diff --git a/crates/webcodex-cli/src/lib.rs b/crates/webcodex-cli/src/lib.rs index 56aa3854a..1c00cf468 100644 --- a/crates/webcodex-cli/src/lib.rs +++ b/crates/webcodex-cli/src/lib.rs @@ -1545,125 +1545,29 @@ fn parse_ops_subcommand(args: &[String]) -> CliAction { stdout: ops_usage().to_string(), stderr: String::new(), }, - "status" => { - if args.get(1).is_some_and(|a| a == "--help" || a == "-h") { - return CliAction::Exit { - code: 0, - stdout: ops_status_usage().to_string(), - stderr: String::new(), - }; - } - match parse_ops_common(&args[1..], "status") { - Ok(opts) => CliAction::Ops(OpsCommand::Status(opts)), - Err(e) => CliAction::Exit { - code: 2, - stdout: String::new(), - stderr: format!("{}\n", e), - }, - } - } - "runners" => { - if args.get(1).is_some_and(|a| a == "--help" || a == "-h") { - return CliAction::Exit { - code: 0, - stdout: ops_runners_usage().to_string(), - stderr: String::new(), - }; - } - match parse_ops_common(&args[1..], "runners") { - Ok(opts) => CliAction::Ops(OpsCommand::Runners(opts)), - Err(e) => CliAction::Exit { - code: 2, - stdout: String::new(), - stderr: format!("{}\n", e), - }, - } - } - "runner" => { - if args.get(1).is_some_and(|a| a == "--help" || a == "-h") { - return CliAction::Exit { - code: 0, - stdout: ops_runner_usage().to_string(), - stderr: String::new(), - }; - } - match parse_ops_runner(&args[1..]) { - Ok(opts) => CliAction::Ops(OpsCommand::Runner(opts)), - Err(e) => CliAction::Exit { - code: 2, - stdout: String::new(), - stderr: format!("{}\n", e), - }, - } - } - "projects" => { - if args.get(1).is_some_and(|a| a == "--help" || a == "-h") { - return CliAction::Exit { - code: 0, - stdout: ops_projects_usage().to_string(), - stderr: String::new(), - }; - } - match parse_ops_common(&args[1..], "projects") { - Ok(opts) => CliAction::Ops(OpsCommand::Projects(opts)), - Err(e) => CliAction::Exit { - code: 2, - stdout: String::new(), - stderr: format!("{}\n", e), - }, - } - } - "windows" => { - if args.get(1).is_some_and(|a| a == "--help" || a == "-h") { - return CliAction::Exit { - code: 0, - stdout: ops_windows_usage().to_string(), - stderr: String::new(), - }; - } - match parse_ops_windows(&args[1..]) { - Ok(opts) => CliAction::Ops(OpsCommand::Windows(opts)), - Err(e) => CliAction::Exit { - code: 2, - stdout: String::new(), - stderr: format!("{}\n", e), - }, - } - } + "status" => parse_ops_action(&args[1..], ops_status_usage(), |args| { + parse_ops_common(args, "status").map(OpsCommand::Status) + }), + "runners" => parse_ops_action(&args[1..], ops_runners_usage(), |args| { + parse_ops_common(args, "runners").map(OpsCommand::Runners) + }), + "runner" => parse_ops_action(&args[1..], ops_runner_usage(), |args| { + parse_ops_runner(args).map(OpsCommand::Runner) + }), + "projects" => parse_ops_action(&args[1..], ops_projects_usage(), |args| { + parse_ops_common(args, "projects").map(OpsCommand::Projects) + }), + "windows" => parse_ops_action(&args[1..], ops_windows_usage(), |args| { + parse_ops_windows(args).map(OpsCommand::Windows) + }), "diagnose-provider" => { - if args.get(1).is_some_and(|a| a == "--help" || a == "-h") { - return CliAction::Exit { - code: 0, - stdout: ops_diagnose_provider_usage().to_string(), - stderr: String::new(), - }; - } - match parse_ops_diagnose_provider(&args[1..]) { - Ok(opts) => CliAction::Ops(OpsCommand::DiagnoseProvider(opts)), - Err(e) => CliAction::Exit { - code: 2, - stdout: String::new(), - stderr: format!("{}\n", e), - }, - } - } - "smoke-preflight" => { - if args.get(1).is_some_and(|a| a == "--help" || a == "-h") { - return CliAction::Exit { - code: 0, - stdout: ops_smoke_preflight_usage().to_string(), - stderr: String::new(), - }; - } - match parse_ops_smoke_preflight(&args[1..]) { - Ok(opts) => CliAction::Ops(OpsCommand::SmokePreflight(opts)), - Err(e) => CliAction::Exit { - code: 2, - stdout: String::new(), - stderr: format!("{}\n", e), - }, - } + parse_ops_action(&args[1..], ops_diagnose_provider_usage(), |args| { + parse_ops_diagnose_provider(args).map(OpsCommand::DiagnoseProvider) + }) } + "smoke-preflight" => parse_ops_action(&args[1..], ops_smoke_preflight_usage(), |args| { + parse_ops_smoke_preflight(args).map(OpsCommand::SmokePreflight) + }), other => CliAction::Exit { code: 2, stdout: String::new(), @@ -1672,6 +1576,31 @@ fn parse_ops_subcommand(args: &[String]) -> CliAction { } } +fn parse_ops_action( + args: &[String], + usage: &str, + parse: impl FnOnce(&[String]) -> Result, +) -> CliAction { + if args + .first() + .is_some_and(|arg| arg == "--help" || arg == "-h") + { + return CliAction::Exit { + code: 0, + stdout: usage.to_string(), + stderr: String::new(), + }; + } + match parse(args) { + Ok(command) => CliAction::Ops(command), + Err(error) => CliAction::Exit { + code: 2, + stdout: String::new(), + stderr: format!("{error}\n"), + }, + } +} + fn default_ops_common_options() -> OpsCommonOptions { OpsCommonOptions { server_url: "http://127.0.0.1:8080".to_string(), @@ -1688,22 +1617,33 @@ fn parse_ops_common(args: &[String], command: &str) -> Result opts.server_url = next_value(&mut iter, arg)?, - "--proxy" => opts.server_http.proxy = Some(next_value(&mut iter, arg)?), - "--no-system-proxy" => opts.server_http.no_system_proxy = true, - "--env-file" => opts.env_file = Some(PathBuf::from(next_value(&mut iter, arg)?)), - "--token-file" => opts.token_file = Some(PathBuf::from(next_value(&mut iter, arg)?)), - "--token" => opts.token = Some(next_value(&mut iter, arg)?), - "--json" => opts.json = true, - "--strict" => opts.strict = true, - other => return Err(format!("unknown ops {} flag: {}", command, other)), - } + parse_ops_common_flag(&mut opts, command, arg, &mut iter)?; } - validate_ops_common(&opts) + validate_ops_common(&opts)?; + Ok(opts) } -fn validate_ops_common(opts: &OpsCommonOptions) -> Result { +fn parse_ops_common_flag<'a>( + opts: &mut OpsCommonOptions, + command: &str, + arg: &str, + iter: &mut impl Iterator, +) -> Result<(), String> { + match arg { + "--server-url" => opts.server_url = next_value(iter, arg)?, + "--proxy" => opts.server_http.proxy = Some(next_value(iter, arg)?), + "--no-system-proxy" => opts.server_http.no_system_proxy = true, + "--env-file" => opts.env_file = Some(PathBuf::from(next_value(iter, arg)?)), + "--token-file" => opts.token_file = Some(PathBuf::from(next_value(iter, arg)?)), + "--token" => opts.token = Some(next_value(iter, arg)?), + "--json" => opts.json = true, + "--strict" => opts.strict = true, + other => return Err(format!("unknown ops {} flag: {}", command, other)), + } + Ok(()) +} + +fn validate_ops_common(opts: &OpsCommonOptions) -> Result<(), String> { opts.server_http.validate()?; if opts.server_url.trim().is_empty() { return Err("--server-url cannot be empty".to_string()); @@ -1715,7 +1655,7 @@ fn validate_ops_common(opts: &OpsCommonOptions) -> Result Result { @@ -1731,18 +1671,10 @@ fn parse_ops_runner(args: &[String]) -> Result { .parse::() .map_err(|_| "--request-timeout-ms must be an integer".to_string())?; } - "--server-url" => common.server_url = next_value(&mut iter, arg)?, - "--proxy" => common.server_http.proxy = Some(next_value(&mut iter, arg)?), - "--no-system-proxy" => common.server_http.no_system_proxy = true, - "--env-file" => common.env_file = Some(PathBuf::from(next_value(&mut iter, arg)?)), - "--token-file" => common.token_file = Some(PathBuf::from(next_value(&mut iter, arg)?)), - "--token" => common.token = Some(next_value(&mut iter, arg)?), - "--json" => common.json = true, - "--strict" => common.strict = true, - other => return Err(format!("unknown ops runner flag: {}", other)), + other => parse_ops_common_flag(&mut common, "runner", other, &mut iter)?, } } - common = validate_ops_common(&common)?; + validate_ops_common(&common)?; let client_id = client_id.trim().to_string(); if client_id.is_empty() { return Err("--client-id is required".to_string()); @@ -1773,18 +1705,10 @@ fn parse_ops_windows(args: &[String]) -> Result { .parse::() .map_err(|_| "--limit must be an integer".to_string())?; } - "--server-url" => common.server_url = next_value(&mut iter, arg)?, - "--proxy" => common.server_http.proxy = Some(next_value(&mut iter, arg)?), - "--no-system-proxy" => common.server_http.no_system_proxy = true, - "--env-file" => common.env_file = Some(PathBuf::from(next_value(&mut iter, arg)?)), - "--token-file" => common.token_file = Some(PathBuf::from(next_value(&mut iter, arg)?)), - "--token" => common.token = Some(next_value(&mut iter, arg)?), - "--json" => common.json = true, - "--strict" => common.strict = true, - other => return Err(format!("unknown ops windows flag: {}", other)), + other => parse_ops_common_flag(&mut common, "windows", other, &mut iter)?, } } - common = validate_ops_common(&common)?; + validate_ops_common(&common)?; let project = project.trim().to_string(); if project.is_empty() { return Err("--project is required".to_string()); @@ -1810,18 +1734,10 @@ fn parse_ops_diagnose_provider(args: &[String]) -> Result project = next_value(&mut iter, arg)?, "--provider" => provider = next_value(&mut iter, arg)?, "--include-live-acp" => include_live_acp = true, - "--server-url" => common.server_url = next_value(&mut iter, arg)?, - "--proxy" => common.server_http.proxy = Some(next_value(&mut iter, arg)?), - "--no-system-proxy" => common.server_http.no_system_proxy = true, - "--env-file" => common.env_file = Some(PathBuf::from(next_value(&mut iter, arg)?)), - "--token-file" => common.token_file = Some(PathBuf::from(next_value(&mut iter, arg)?)), - "--token" => common.token = Some(next_value(&mut iter, arg)?), - "--json" => common.json = true, - "--strict" => common.strict = true, - other => return Err(format!("unknown ops diagnose-provider flag: {other}")), - } - } - common = validate_ops_common(&common)?; + other => parse_ops_common_flag(&mut common, "diagnose-provider", other, &mut iter)?, + } + } + validate_ops_common(&common)?; let project = project.trim().to_string(); if project.is_empty() { return Err("--project is required".to_string()); @@ -1845,18 +1761,10 @@ fn parse_ops_smoke_preflight(args: &[String]) -> Result project = next_value(&mut iter, arg)?, - "--server-url" => common.server_url = next_value(&mut iter, arg)?, - "--proxy" => common.server_http.proxy = Some(next_value(&mut iter, arg)?), - "--no-system-proxy" => common.server_http.no_system_proxy = true, - "--env-file" => common.env_file = Some(PathBuf::from(next_value(&mut iter, arg)?)), - "--token-file" => common.token_file = Some(PathBuf::from(next_value(&mut iter, arg)?)), - "--token" => common.token = Some(next_value(&mut iter, arg)?), - "--json" => common.json = true, - "--strict" => common.strict = true, - other => return Err(format!("unknown ops smoke-preflight flag: {}", other)), - } - } - common = validate_ops_common(&common)?; + other => parse_ops_common_flag(&mut common, "smoke-preflight", other, &mut iter)?, + } + } + validate_ops_common(&common)?; if project.trim().is_empty() { return Err("--project is required".to_string()); } diff --git a/crates/webcodex-cli/src/webcodex_cli/tests/ops.rs b/crates/webcodex-cli/src/webcodex_cli/tests/ops.rs index e08a72252..0dfb104d6 100644 --- a/crates/webcodex-cli/src/webcodex_cli/tests/ops.rs +++ b/crates/webcodex-cli/src/webcodex_cli/tests/ops.rs @@ -6,6 +6,34 @@ use crate::webcodex_cli::ops::{ use crate::webcodex_cli::run_ops_command; use std::time::Duration; +const OPS_COMMAND_ARGS: &[(&str, &[&str])] = &[ + ("status", &[]), + ("runners", &[]), + ("projects", &[]), + ("runner", &["--client-id", "fixture-runner"]), + ("windows", &["--project", "fixture-project"]), + ("smoke-preflight", &["--project", "fixture-project"]), + ( + "diagnose-provider", + &["--project", "fixture-project", "--provider", "codex"], + ), +]; + +fn parsed_ops_common(action: CliAction) -> OpsCommonOptions { + match action { + CliAction::Ops(command) => match command { + OpsCommand::Status(opts) | OpsCommand::Runners(opts) | OpsCommand::Projects(opts) => { + opts + } + OpsCommand::Runner(opts) => opts.common, + OpsCommand::Windows(opts) => opts.common, + OpsCommand::SmokePreflight(opts) => opts.common, + OpsCommand::DiagnoseProvider(opts) => opts.common, + }, + other => panic!("expected ops action, got {other:?}"), + } +} + // A stateful HTTP fixture proves that an operator inventory follows the actual // continuation and does not silently treat the first model page as complete. #[tokio::test] @@ -236,33 +264,83 @@ fn ops_unknown_subcommand_is_clear() { } #[test] -fn ops_common_flags_parse_without_printing_token() { - match cli_action([ - "ops", - "status", +fn ops_commands_share_common_defaults_and_last_value_wins() { + let defaults = OpsCommonOptions { + server_url: "http://127.0.0.1:8080".into(), + server_http: ServerHttpOptions::default(), + env_file: None, + token_file: None, + token: None, + json: false, + strict: false, + }; + let flags = [ + "--server-url", + "", "--server-url", "http://runtime.example", + "--proxy", + "invalid", + "--proxy", + "http://proxy.example:8080", + "--env-file", + "old.env", "--env-file", - "/tmp/webcodex.env", + "fixture.env", "--token-file", - "/tmp/token", + "old.token", + "--token-file", + "fixture.token", + "--token", + "", "--token", - "secret-token-value", + "fixture-token", + "--json", "--json", "--strict", - ]) { - CliAction::Ops(OpsCommand::Status(opts)) => { - assert_eq!(opts.server_url, "http://runtime.example"); - assert_eq!( - opts.env_file.as_deref(), - Some(Path::new("/tmp/webcodex.env")) - ); - assert_eq!(opts.token_file.as_deref(), Some(Path::new("/tmp/token"))); - assert_eq!(opts.token.as_deref(), Some("secret-token-value")); - assert!(opts.json); - assert!(opts.strict); + "--strict", + ]; + let expected = OpsCommonOptions { + server_url: "http://runtime.example".into(), + server_http: ServerHttpOptions { + proxy: Some("http://proxy.example:8080".into()), + no_system_proxy: false, + }, + env_file: Some(PathBuf::from("fixture.env")), + token_file: Some(PathBuf::from("fixture.token")), + token: Some("fixture-token".into()), + json: true, + strict: true, + }; + for &(command, required) in OPS_COMMAND_ARGS { + let prefix = ["ops", command]; + assert_eq!( + parsed_ops_common(cli_action([prefix.as_slice(), required].concat())), + defaults, + "{command}" + ); + // Common options can precede or follow command-specific options. + for args in [ + [prefix.as_slice(), &flags[..], required].concat(), + [prefix.as_slice(), required, &flags[..]].concat(), + ] { + assert_eq!(parsed_ops_common(cli_action(args)), expected, "{command}"); } - other => panic!("expected ops status action, got {other:?}"), + let args = [ + prefix.as_slice(), + required, + &["--no-system-proxy", "--server-url", "--json"], + ] + .concat(); + assert_eq!( + parsed_ops_common(cli_action(args)), + OpsCommonOptions { + server_url: "--json".into(), // A value that looks like a flag is still consumed. + server_http: direct_server_http(), + ..defaults.clone() + }, + "{command}" + ); } } @@ -369,14 +447,84 @@ fn ops_runner_requires_exact_client_id() { #[test] fn ops_parser_errors_do_not_leak_token_value() { - let secret = "secret-token-value"; - match cli_action(["ops", "status", "--token", secret, "--bad-flag"]) { - CliAction::Exit { code, stderr, .. } => { - assert_eq!(code, 2); - assert!(stderr.contains("unknown ops status flag: --bad-flag")); - assert!(!stderr.contains(secret)); + let cases: &[(&[&str], &str)] = &[ + (&["--server-url"], "--server-url requires a value"), + (&["--proxy"], "--proxy requires a value"), + (&["--env-file"], "--env-file requires a value"), + (&["--token-file"], "--token-file requires a value"), + (&["--token"], "--token requires a value"), + (&["--bad-flag"], "unknown ops {command} flag: --bad-flag"), + (&["positional"], "unknown ops {command} flag: positional"), + (&["--json", "--help"], "unknown ops {command} flag: --help"), + (&["--json", "-h"], "unknown ops {command} flag: -h"), + (&["--token", " "], "--token cannot be empty"), + ( + &["--server-url", " ", "--token", ""], + "--server-url cannot be empty", + ), + ( + &["--proxy", "", "--server-url", ""], + "--proxy cannot be empty", + ), + ( + &["--proxy", "", "--no-system-proxy"], + "--proxy and --no-system-proxy are mutually exclusive", + ), + ]; + let secret = "fixture-token-do-not-print"; + for &(command, required) in OPS_COMMAND_ARGS { + for &(flags, error) in cases { + // Also check validation precedence when the command's required options are absent. + for required in [required, &[][..]] { + let args = [&["ops", command, "--token", secret][..], required, flags].concat(); + match cli_action(args) { + CliAction::Exit { + code, + stdout, + stderr, + } => { + assert_eq!(code, 2, "{command} {flags:?}"); + assert!(stdout.is_empty()); + assert_eq!(stderr, format!("{}\n", error.replace("{command}", command))); + assert!(!stderr.contains(secret)); + } + other => panic!("expected parser error for {command} {flags:?}, got {other:?}"), + } + } + } + } +} + +#[test] +fn ops_help_only_short_circuits_at_the_start_of_known_commands() { + for &(command, _) in OPS_COMMAND_ARGS { + let expected = cli_exit(["ops", command, "--help"]).unwrap(); + for help in ["--help", "-h"] { + assert_eq!( + cli_exit(["ops", command, help, "--bad-flag"]).unwrap(), + expected + ); + } + } + for (args, error) in [ + (vec!["ops"], format!("{}\n", ops_usage())), + ( + vec!["ops", "unknown", "--help"], + "unknown ops subcommand: unknown\n".into(), + ), + ] { + match cli_action(args) { + CliAction::Exit { + code, + stdout, + stderr, + } => { + assert_eq!(code, 2); + assert!(stdout.is_empty()); + assert_eq!(stderr, error); + } + other => panic!("expected ops usage error, got {other:?}"), } - other => panic!("expected parser error, got {other:?}"), } }