diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index f696bd130..cefe6dd16 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -34,12 +34,53 @@ fn arg_value(args: &[String], name: &str) -> Option { args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned() } +#[derive(Clone, Copy)] +struct OptionSpec { + name: &'static str, + takes_value: bool, +} + +fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), String> { + let mut i = 0; + while let Some(arg) = args.get(i) { + if let Some(name) = arg.strip_prefix("--") { + let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { + return Err(format!("{command}: unknown option --{name}")); + }; + if spec.takes_value && args.get(i + 1).is_some_and(|value| !value.starts_with("--")) { + i += 2; + } else { + i += 1; + } + } else { + i += 1; + } + } + Ok(()) +} + +const NO_OPTIONS: &[OptionSpec] = &[]; +const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }]; +const RUN_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--file", takes_value: true }, + OptionSpec { name: "--template", takes_value: true }, + OptionSpec { name: "--cmd", takes_value: true }, + OptionSpec { name: "--save", takes_value: true }, + OptionSpec { name: "--page", takes_value: true }, + OptionSpec { name: "--scale", takes_value: true }, + OptionSpec { name: "--print", takes_value: false }, +]; +const COMMANDS_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--json", takes_value: false }]; +const PARITY_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markdown", takes_value: false }]; +const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--connect", takes_value: true }]; + fn run(args: &[String]) -> Result<(), String> { let cmd = args.first().map(String::as_str).unwrap_or(""); let rest: Vec = args.iter().skip(1).cloned().collect(); let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}")); match cmd { "convert" => { + validate_options("convert", &rest, NO_OPTIONS)?; let (i, o) = (pos(0)?, pos(1)?); let mut s = open(&i)?; let r = if o.to_ascii_lowercase().ends_with(".png") { @@ -52,21 +93,25 @@ fn run(args: &[String]) -> Result<(), String> { Ok(()) } "info" => { + validate_options("info", &rest, NO_OPTIONS)?; let mut s = open(&pos(0)?)?; println!("{}", serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default()); Ok(()) } "text" => { + validate_options("text", &rest, NO_OPTIONS)?; let s = open(&pos(0)?)?; println!("{}", s.doc.plain_text(wordcraft_doc::StoryRef::Body)); Ok(()) } "inspect" => { + validate_options("inspect", &rest, NO_OPTIONS)?; let mut s = open(&pos(0)?)?; println!("{}", serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default()); Ok(()) } "render" => { + validate_options("render", &rest, RENDER_OPTIONS)?; let (i, o) = (pos(0)?, pos(1)?); let mut s = open(&i)?; let page = arg_value(&rest, "--page").and_then(|p| p.parse::().ok()).unwrap_or(1); @@ -76,6 +121,7 @@ fn run(args: &[String]) -> Result<(), String> { Ok(()) } "run" => { + validate_options("run", &rest, RUN_OPTIONS)?; let mut s = match (arg_value(&rest, "--file"), arg_value(&rest, "--template")) { (Some(f), _) => open(&f)?, (None, Some(t)) => { @@ -121,6 +167,7 @@ fn run(args: &[String]) -> Result<(), String> { Ok(()) } "commands" => { + validate_options("commands", &rest, COMMANDS_OPTIONS)?; let s = Session::new(wordcraft_doc::Document::new()); if rest.iter().any(|a| a == "--json") { println!("{}", serde_json::to_string_pretty(&s.registry.describe()).unwrap_or_default()); @@ -132,6 +179,7 @@ fn run(args: &[String]) -> Result<(), String> { Ok(()) } "parity" => { + validate_options("parity", &rest, PARITY_OPTIONS)?; let s = Session::new(wordcraft_doc::Document::new()); let p = wordcraft_engine::catalog::parity(&s.registry); if rest.iter().any(|a| a == "--markdown") { @@ -142,6 +190,7 @@ fn run(args: &[String]) -> Result<(), String> { Ok(()) } "mcp" => { + validate_options("mcp", &rest, MCP_OPTIONS)?; let backend: Box = match arg_value(&rest, "--connect") { Some(addr) => Box::new(wordcraft_mcp::Remote::connect(&addr).map_err(|e| format!("can't reach the WordCraft app at {addr}: {e}"))?), None => Box::new(wordcraft_mcp::Headless::default()), @@ -198,3 +247,29 @@ fn main() -> ExitCode { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).into()).collect() + } + + #[test] + fn validators_accept_only_the_documented_options() { + assert!(validate_options("info", &args(&["file.docx"]), NO_OPTIONS).is_ok()); + assert!(validate_options("render", &args(&["in.docx", "out.png", "--page", "2", "--scale", "1.5"]), RENDER_OPTIONS).is_ok()); + assert!(validate_options("run", &args(&["--cmd", "text.insert={}", "--cmd", "edit.undo", "--print"]), RUN_OPTIONS).is_ok()); + assert!(validate_options("commands", &args(&["--json"]), COMMANDS_OPTIONS).is_ok()); + assert!(validate_options("parity", &args(&["--markdown"]), PARITY_OPTIONS).is_ok()); + assert!(validate_options("mcp", &args(&["--connect", "127.0.0.1:9000"]), MCP_OPTIONS).is_ok()); + assert!(validate_options("render", &args(&["in.docx", "out.png", "--page"]), RENDER_OPTIONS).is_ok()); + } + + #[test] + fn unknown_options_are_rejected_before_file_access() { + assert_eq!(run(&args(&["info", "missing.docx", "--passwrod", "x"])), Err("info: unknown option --passwrod".into())); + assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--sclae", "2"])), Err("render: unknown option --sclae".into())); + } +}