use std::{ env, error::Error, ffi::OsString, fs, io::{self, Read, Write}, path::Path, process::ExitCode, time::Duration, }; mod device; const USAGE: &str = "Usage: mfctl schema\n mfctl validate [FILE|-]\n mfctl get [--port NAME] [--timeout SECONDS]\n mfctl apply [FILE|-] [--port NAME] [--timeout SECONDS]\n mfctl import FILE.mbp\n mfctl export FILE.yaml\n\nimport/export write to stdout and never access MIDI.\nOmit FILE to validate/apply stdin. apply resets omitted controls to defaults and\nverifies read-back. Live reply timeouts default to 1 second.\nPort names are discovered through midir, not ALSA hw: addresses."; fn run() -> Result<(), Box> { let mut args = env::args_os().skip(1); let Some(command) = args.next() else { println!("{USAGE}"); return Ok(()); }; if command == "get" || command == "apply" { let mut input = None; let mut port = None; let mut timeout = Duration::from_secs(1); while let Some(option) = args.next() { if option == "--port" { let name = args .next() .ok_or("--port requires a MIDI port name")? .into_string() .map_err(|_| "MIDI port names must be UTF-8")?; if name.is_empty() { return Err("--port requires a nonempty MIDI port name".into()); } port = Some(name); } else if option == "--timeout" { let seconds = args .next() .and_then(|value| value.into_string().ok()) .and_then(|value| value.parse::().ok()) .filter(|seconds| *seconds > 0) .ok_or("--timeout requires positive whole seconds")?; timeout = Duration::from_secs(seconds); } else if option == "--help" || option == "-h" { println!("{USAGE}"); return Ok(()); } else if command == "apply" && input.is_none() && (option == "-" || !option.to_string_lossy().starts_with('-')) { input = Some(option); } else { return Err(USAGE.into()); } } if command == "apply" { device::apply(&read_source(input)?, port.as_deref(), timeout)?; } else { // Buffer the complete capture: stdout stays empty on device/decode errors. let patch = device::get(port.as_deref(), timeout)?; io::stdout().lock().write_all(patch.as_bytes())?; } return Ok(()); } let input = args.next(); if args.next().is_some() { return Err(USAGE.into()); } if command == "--help" || command == "-h" { if input.is_some() { return Err(USAGE.into()); } println!("{USAGE}"); } else if command == "schema" { if input.is_some() { return Err(USAGE.into()); } let mut out = io::stdout().lock(); serde_json::to_writer_pretty(&mut out, &schemars::schema_for!(mfctl_core::patch::Patch))?; writeln!(out)?; } else if command == "import" || command == "export" { let input = input.ok_or(USAGE)?; if input == "--help" || input == "-h" { println!("{USAGE}"); return Ok(()); } let path = Path::new(&input); let extension = path.extension().and_then(|value| value.to_str()); let output = if command == "import" { if !extension.is_some_and(|value| value.eq_ignore_ascii_case("mbp")) { return Err("import requires a .mbp file".into()); } mfctl_core::preset::import_archive(&fs::read(path)?, &mut io::stderr().lock())? } else { if !extension.is_some_and(|value| { value.eq_ignore_ascii_case("yaml") || value.eq_ignore_ascii_case("yml") }) { return Err("export requires a .yaml or .yml file".into()); } Some(mfctl_core::preset::export_archive(&fs::read_to_string( path, )?)?) }; if let Some(output) = output { io::stdout().lock().write_all(output.as_bytes())?; } } else if command == "validate" { mfctl_core::patch::parse(&read_source(input)?)?; } else { return Err(USAGE.into()); } Ok(()) } fn read_source(input: Option) -> Result> { match input { Some(path) if path != "-" => Ok(fs::read_to_string(path)?), _ => { let mut source = String::new(); io::stdin().read_to_string(&mut source)?; Ok(source) } } } fn main() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("mfctl: {error}"); ExitCode::FAILURE } } }