From 460da3c7d5487bad24e64c8ec06ef5f6e8c39651 Mon Sep 17 00:00:00 2001 From: qcai-godaddy <86305984+qcai-godaddy@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:51:13 -0500 Subject: [PATCH 1/4] feat(cli): suggest the nearest command on an unknown command --- cli-engine/src/cli.rs | 315 +++++++++++++++++++++++++++++++-- cli-engine/src/prompt.rs | 44 +++++ cli-engine/tests/foundation.rs | 106 +++++++++++ 3 files changed, 453 insertions(+), 12 deletions(-) diff --git a/cli-engine/src/cli.rs b/cli-engine/src/cli.rs index fab3354..1884473 100644 --- a/cli-engine/src/cli.rs +++ b/cli-engine/src/cli.rs @@ -1716,11 +1716,45 @@ impl Cli { &value_flags, &parts, ); - } else if let Some(message) = unknown_group_command_message(&self.root, &positionals) { - return self.finish_run(CliRunOutput { - exit_code: 1, - rendered: message, - }); + } else if let Some(unknown) = + detect_unknown_group_command(&self.root, &positionals[..command_keyword_count]) + { + // Interactive sessions may accept a near-match and re-dispatch. + if let Some(suggestion) = unknown.suggestion.as_deref() { + match crate::prompt::confirm_command_correction( + &clap_args, + suggestion, + self.config.auto_interactive, + ) { + crate::prompt::CommandCorrection::Accepted => { + clap_args = replace_positional_command_token( + &clap_args, + &self.config.name, + &bool_flags, + &value_flags, + unknown.positional_index, + suggestion, + ); + } + crate::prompt::CommandCorrection::Declined => { + return self.finish_run(CliRunOutput { + exit_code: 1, + rendered: unknown.message, + }); + } + crate::prompt::CommandCorrection::Cancelled => { + return self.finish_run(CliRunOutput { + exit_code: 130, + rendered: "Cancelled.".to_owned(), + }); + } + } + } else { + return self.finish_run(CliRunOutput { + exit_code: 1, + rendered: unknown.message, + }); + } } let matches = match self.root.clone().try_get_matches_from(&clap_args) { @@ -2052,7 +2086,7 @@ impl Cli { // `--schema` is an inspection flag and must not require the command's own // arguments, so it short-circuits before clap validates them. Only fire // for a real leaf command, though: unknown paths and groups fall through - // so clap and `unknown_group_command_message` can report them as usual. + // so clap and `detect_unknown_group_command` can report them as usual. let command = find_command_by_colon_path(&self.root, &command_path)?; if command.get_subcommands().next().is_some() { return None; @@ -3314,30 +3348,287 @@ fn direct_subcommand<'command>( }) } -fn unknown_group_command_message(root: &Command, positionals: &[String]) -> Option { +/// An unrecognized command token from the group router, with an optional correction. +struct UnknownGroupCommand { + /// Error text, including a `— did you mean "X"?` suffix when applicable. + message: String, + suggestion: Option, + /// Index within positional command tokens (for arg rewrite on accept). + positional_index: usize, +} + +/// Walks positional command tokens through the group tree and reports the first +/// unknown token under a group. +/// +/// Returns `None` when every token resolves, or when the failure is at a leaf +/// (clap reports those). `positionals` must be pre-`--` command keywords only — +/// the caller slices to `command_keyword_count` like the group-help path. +fn detect_unknown_group_command( + root: &Command, + positionals: &[String], +) -> Option { if positionals.is_empty() { return None; } let mut current = root; let mut path = vec![root.get_name().to_owned()]; - for token in positionals { + for (positional_index, token) in positionals.iter().enumerate() { if let Some(next) = current.find_subcommand(token) { current = next; path.push(next.get_name().to_owned()); continue; } if current.get_subcommands().next().is_some() { - return Some(format!( - "unknown command {token:?} for {:?}", - path.join(" ") - )); + let base = format!("unknown command {token:?} for {:?}", path.join(" ")); + let suggestion = nearest_subcommand(current, token); + let message = match &suggestion { + Some(suggestion) => format!("{base} — did you mean {suggestion:?}?"), + None => base, + }; + return Some(UnknownGroupCommand { + message, + suggestion, + positional_index, + }); } return None; } None } +/// Rewrites the `target`-th positional command token to `replacement`, preserving +/// flags. Token classification mirrors [`positional_command_tokens`]. +fn replace_positional_command_token( + args: &[String], + root_name: &str, + bool_flags: &BTreeSet, + value_flags: &BTreeSet, + target: usize, + replacement: &str, +) -> Vec { + let mut out = args.to_vec(); + let mut index = 0; + if out + .first() + .is_some_and(|arg| arg_matches_root_name(arg, root_name)) + { + index = 1; + } + + let mut positional = 0; + while index < out.len() { + let arg = &out[index]; + if arg == "--" { + break; + } + if arg.contains('=') { + index += 1; + continue; + } + if bool_flags.contains(arg) { + index += 1; + continue; + } + if value_flags.contains(arg) + || unknown_flag_consumes_value(arg, out.get(index + 1).as_ref()) + { + index += 2; + continue; + } + if arg.starts_with('-') { + index += 1; + continue; + } + if positional == target { + out[index] = replacement.to_owned(); + break; + } + positional += 1; + index += 1; + } + out +} + +/// Finds the closest visible subcommand name or alias within edit-distance +/// `max(1, token_len / 3)`. Returns the canonical name; ties break alphabetically. +fn nearest_subcommand(command: &Command, token: &str) -> Option { + let token = token.to_ascii_lowercase(); + let max_distance = 1.max(token.chars().count() / 3); + + command + .get_subcommands() + .filter(|child| !child.is_hide_set()) + .filter_map(|child| { + let best = std::iter::once(child.get_name()) + .chain(child.get_all_aliases()) + .map(|candidate| edit_distance(&token, &candidate.to_ascii_lowercase())) + .min()?; + (best <= max_distance).then(|| (best, child.get_name().to_owned())) + }) + .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))) + .map(|(_, name)| name) +} + +/// Restricted Damerau–Levenshtein distance (insert, delete, substitute, adjacent swap). +fn edit_distance(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let (n, m) = (a.len(), b.len()); + + // Full matrix: transposition needs row i-2; names are short so O(n·m) is fine. + let mut d = vec![vec![0_usize; m + 1]; n + 1]; + for (i, row) in d.iter_mut().enumerate() { + row[0] = i; + } + for (j, cell) in d[0].iter_mut().enumerate() { + *cell = j; + } + + for i in 1..=n { + for j in 1..=m { + let cost = usize::from(a[i - 1] != b[j - 1]); + let mut best = (d[i - 1][j] + 1) + .min(d[i][j - 1] + 1) + .min(d[i - 1][j - 1] + cost); + if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] { + best = best.min(d[i - 2][j - 2] + 1); + } + d[i][j] = best; + } + } + d[n][m] +} + +#[cfg(test)] +mod unknown_command_suggestion_tests { + use super::*; + + fn sample_group() -> Command { + Command::new("gddy").subcommand( + Command::new("domain") + .alias("dns-domain") + .subcommand(Command::new("list")) + .subcommand(Command::new("available")), + ) + } + + #[test] + fn edit_distance_counts_single_edits_and_transpositions() { + assert_eq!(edit_distance("domain", "domain"), 0); + assert_eq!(edit_distance("domian", "domain"), 1); + assert_eq!(edit_distance("lst", "list"), 1); + assert_eq!(edit_distance("lsit", "list"), 1); + assert_eq!(edit_distance("avaliable", "available"), 1); + assert_eq!(edit_distance("cat", "set"), 2); + assert_eq!(edit_distance("", "list"), 4); + } + + #[test] + fn nearest_subcommand_matches_close_typos() { + let root = sample_group(); + let domain = root.find_subcommand("domain").expect("domain registered"); + assert_eq!(nearest_subcommand(domain, "lst").as_deref(), Some("list")); + assert_eq!(nearest_subcommand(domain, "ilst").as_deref(), Some("list")); + assert_eq!( + nearest_subcommand(domain, "avaliable").as_deref(), + Some("available") + ); + } + + #[test] + fn nearest_subcommand_rejects_unrelated_tokens() { + let root = sample_group(); + let domain = root.find_subcommand("domain").expect("domain registered"); + assert_eq!(nearest_subcommand(domain, "missing"), None); + } + + #[test] + fn nearest_subcommand_returns_canonical_name_for_alias_typos() { + let root = sample_group(); + assert_eq!( + nearest_subcommand(&root, "dns-domian").as_deref(), + Some("domain") + ); + } + + #[test] + fn nearest_subcommand_skips_hidden_commands() { + let root = Command::new("gddy") + .subcommand(Command::new("visible")) + .subcommand(Command::new("hiddeen").hide(true)); + assert_eq!(nearest_subcommand(&root, "hidden"), None); + } + + #[test] + fn nearest_subcommand_rejects_short_unrelated_tokens() { + let root = Command::new("gddy").subcommand( + Command::new("config") + .subcommand(Command::new("get")) + .subcommand(Command::new("set")) + .subcommand(Command::new("add")), + ); + let config = root.find_subcommand("config").expect("config registered"); + assert_eq!(nearest_subcommand(config, "cat"), None); + assert_eq!(nearest_subcommand(config, "x"), None); + assert_eq!(nearest_subcommand(config, "st").as_deref(), Some("set")); + } + + #[test] + fn detect_unknown_group_command_annotates_with_suggestion() { + let root = sample_group(); + let unknown = detect_unknown_group_command(&root, &["domian".to_owned()]) + .expect("domian is an unknown top-level command"); + assert_eq!(unknown.suggestion.as_deref(), Some("domain")); + assert_eq!(unknown.positional_index, 0); + assert_eq!( + unknown.message, + "unknown command \"domian\" for \"gddy\" — did you mean \"domain\"?" + ); + } + + #[test] + fn detect_unknown_group_command_reports_nested_typos() { + let root = sample_group(); + let unknown = detect_unknown_group_command(&root, &["domain".to_owned(), "lst".to_owned()]) + .expect("lst is an unknown subcommand of domain"); + assert_eq!(unknown.suggestion.as_deref(), Some("list")); + assert_eq!(unknown.positional_index, 1); + assert_eq!( + unknown.message, + "unknown command \"lst\" for \"gddy domain\" — did you mean \"list\"?" + ); + } + + #[test] + fn detect_unknown_group_command_omits_hint_for_unrelated_tokens() { + let root = sample_group(); + let unknown = detect_unknown_group_command(&root, &["missing".to_owned()]) + .expect("missing is an unknown top-level command"); + assert_eq!(unknown.suggestion, None); + assert_eq!(unknown.message, "unknown command \"missing\" for \"gddy\""); + } + + #[test] + fn replace_positional_command_token_rewrites_only_the_target() { + let bool_flags: BTreeSet = ["--verbose".to_owned()].into_iter().collect(); + let value_flags: BTreeSet = ["--output".to_owned()].into_iter().collect(); + let args = vec![ + "gddy".to_owned(), + "--output".to_owned(), + "json".to_owned(), + "domain".to_owned(), + "lst".to_owned(), + ]; + let corrected = + replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 1, "list"); + assert_eq!( + corrected, + vec!["gddy", "--output", "json", "domain", "list"] + ); + } +} + /// Detects the ` help [sub...]` form and returns the command path whose /// help should be rendered. /// diff --git a/cli-engine/src/prompt.rs b/cli-engine/src/prompt.rs index eb4c6af..e3cf0fc 100644 --- a/cli-engine/src/prompt.rs +++ b/cli-engine/src/prompt.rs @@ -228,6 +228,35 @@ pub fn try_recover_missing_args( Some(RecoveryResult::Recovered { args: augmented }) } +/// Outcome of a "did you mean X?" correction prompt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandCorrection { + /// User accepted; re-dispatch with the corrected token. + Accepted, + /// Non-interactive session or user declined; report the original error. + Declined, + /// User aborted (Escape or Ctrl+C). + Cancelled, +} + +/// Offer to correct an unknown command spelling. Never prompts when the session +/// is non-interactive (agents and piped invocations see the original error). +/// Interactivity follows the same raw-args rules as [`try_recover_missing_args`]. +pub fn confirm_command_correction( + args: &[String], + suggestion: &str, + auto_interactive: bool, +) -> CommandCorrection { + if !is_interactive_from_raw_args(args, auto_interactive) { + return CommandCorrection::Declined; + } + match prompt_confirm(&format!("Did you mean `{suggestion}`?"), true) { + Ok(true) => CommandCorrection::Accepted, + Ok(false) => CommandCorrection::Declined, + Err(_) => CommandCorrection::Cancelled, + } +} + /// Result of attempting interactive recovery for missing args. #[derive(Debug)] pub enum RecoveryResult { @@ -432,6 +461,21 @@ mod tests { assert_eq!(leaf.expect("tested").get_name(), "list"); } + #[test] + fn confirm_command_correction_declines_when_non_interactive() { + let args: Vec = vec!["my-cli".into(), "projet".into()]; + assert_eq!( + confirm_command_correction(&args, "project", false), + CommandCorrection::Declined + ); + + let args: Vec = vec!["my-cli".into(), "projet".into(), "--non-interactive".into()]; + assert_eq!( + confirm_command_correction(&args, "project", true), + CommandCorrection::Declined + ); + } + #[test] fn try_recover_returns_none_for_non_missing_arg_error() { let cmd = clap::Command::new("test").arg( diff --git a/cli-engine/tests/foundation.rs b/cli-engine/tests/foundation.rs index 57460cd..62dbeaf 100644 --- a/cli-engine/tests/foundation.rs +++ b/cli-engine/tests/foundation.rs @@ -942,6 +942,112 @@ async fn cli_runtime_group_unknown_command_respects_registered_value_flags() { ); } +#[tokio::test] +async fn cli_runtime_unknown_top_level_command_suggests_nearest_match() { + let mut cli = Cli::new(CliConfig { + name: "my-cli".to_owned(), + short: "Developer tooling".to_owned(), + ..CliConfig::default() + }); + cli.add_module_group( + "Platform Systems", + RuntimeGroupSpec::new(GroupSpec::new("project", "Manage projects")).with_command( + RuntimeCommandSpec::new( + CommandSpec::new("list", "List projects").no_auth(true), + async |_credential, _args| Ok(CommandResult::new(json!({}))), + ), + ), + ); + + let output = cli.run(["my-cli", "projet"]).await; + + assert_eq!(output.exit_code, 1); + assert_eq!( + output.rendered, + "unknown command \"projet\" for \"my-cli\" — did you mean \"project\"?" + ); +} + +#[tokio::test] +async fn cli_runtime_unknown_nested_command_suggests_nearest_match() { + let mut cli = Cli::new(CliConfig { + name: "my-cli".to_owned(), + short: "Developer tooling".to_owned(), + ..CliConfig::default() + }); + cli.add_module_group( + "Platform Systems", + RuntimeGroupSpec::new(GroupSpec::new("project", "Manage projects")).with_command( + RuntimeCommandSpec::new( + CommandSpec::new("list", "List projects").no_auth(true), + async |_credential, _args| Ok(CommandResult::new(json!({}))), + ), + ), + ); + + let output = cli.run(["my-cli", "project", "lst"]).await; + + assert_eq!(output.exit_code, 1); + assert_eq!( + output.rendered, + "unknown command \"lst\" for \"my-cli project\" — did you mean \"list\"?" + ); +} + +#[tokio::test] +async fn cli_runtime_unknown_command_omits_hint_when_no_close_match() { + let mut cli = Cli::new(CliConfig { + name: "my-cli".to_owned(), + short: "Developer tooling".to_owned(), + ..CliConfig::default() + }); + cli.add_module_group( + "Platform Systems", + RuntimeGroupSpec::new(GroupSpec::new("project", "Manage projects")).with_command( + RuntimeCommandSpec::new( + CommandSpec::new("list", "List projects").no_auth(true), + async |_credential, _args| Ok(CommandResult::new(json!({}))), + ), + ), + ); + + let output = cli.run(["my-cli", "project", "xyzzy"]).await; + + assert_eq!(output.exit_code, 1); + assert_eq!( + output.rendered, + "unknown command \"xyzzy\" for \"my-cli project\"" + ); +} + +#[tokio::test] +async fn cli_runtime_unknown_command_ignores_operands_after_a_double_dash() { + let mut cli = Cli::new(CliConfig { + name: "my-cli".to_owned(), + short: "Developer tooling".to_owned(), + ..CliConfig::default() + }); + cli.add_module_group( + "Platform Systems", + RuntimeGroupSpec::new(GroupSpec::new("project", "Manage projects")).with_command( + RuntimeCommandSpec::new( + CommandSpec::new("list", "List projects").no_auth(true), + async |_credential, _args| Ok(CommandResult::new(json!({}))), + ), + ), + ); + + // Post-`--` tokens are operands, not command keywords. + let output = cli.run(["my-cli", "project", "--", "lst"]).await; + + assert_ne!(output.exit_code, 0, "{}", output.rendered); + assert!( + !output.rendered.contains("did you mean"), + "post-`--` operand must not be treated as a mistyped command: {}", + output.rendered + ); +} + #[tokio::test] async fn cli_runtime_help_command_errors_for_unknown_target() { let cli = Cli::new(CliConfig { From 870d35ee952740f44ab549bf57d1f22147c0856b Mon Sep 17 00:00:00 2001 From: qcai-godaddy <86305984+qcai-godaddy@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:34:01 -0500 Subject: [PATCH 2/4] address comments --- cli-engine/src/cli.rs | 70 ++++++++++++++++++++++++++++++++-------- cli-engine/src/prompt.rs | 7 ++-- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/cli-engine/src/cli.rs b/cli-engine/src/cli.rs index 1884473..f5c8ed8 100644 --- a/cli-engine/src/cli.rs +++ b/cli-engine/src/cli.rs @@ -1686,20 +1686,8 @@ impl Cli { let value_flags = derive_value_flags(&self.root); let positionals = positional_command_tokens(&text_args, &self.config.name, &bool_flags, &value_flags); - // Positional tokens after a `--` separator are literal operands, not - // command keywords, so the group-help shim must not treat a `help` - // among them as a help request. Count the positionals that precede any - // `--` to mark where genuine command keywords end. - let command_keyword_count = match text_args.iter().position(|arg| arg == "--") { - Some(end) => positional_command_tokens( - &text_args[..end], - &self.config.name, - &bool_flags, - &value_flags, - ) - .len(), - None => positionals.len(), - }; + let command_keyword_count = + command_keyword_count(&text_args, &self.config.name, &bool_flags, &value_flags); if let Some(parts) = group_help_target_parts(&self.root, &positionals, command_keyword_count) { @@ -1735,6 +1723,13 @@ impl Cli { unknown.positional_index, suggestion, ); + clap_args = rewrite_group_help_if_needed( + &self.root, + &clap_args, + &self.config.name, + &bool_flags, + &value_flags, + ); } crate::prompt::CommandCorrection::Declined => { return self.finish_run(CliRunOutput { @@ -3397,6 +3392,39 @@ fn detect_unknown_group_command( None } +/// Counts positional command tokens that precede any `--` separator. +fn command_keyword_count( + args: &[String], + root_name: &str, + bool_flags: &BTreeSet, + value_flags: &BTreeSet, +) -> usize { + let positionals = positional_command_tokens(args, root_name, bool_flags, value_flags); + match args.iter().position(|arg| arg == "--") { + Some(end) => { + positional_command_tokens(&args[..end], root_name, bool_flags, value_flags).len() + } + None => positionals.len(), + } +} + +/// Rewrites ` help [sub...]` into `help [sub...]` when the form +/// is present; otherwise returns `clap_args` unchanged. +fn rewrite_group_help_if_needed( + root: &Command, + clap_args: &[String], + root_name: &str, + bool_flags: &BTreeSet, + value_flags: &BTreeSet, +) -> Vec { + let positionals = positional_command_tokens(clap_args, root_name, bool_flags, value_flags); + let keyword_count = command_keyword_count(clap_args, root_name, bool_flags, value_flags); + let Some(parts) = group_help_target_parts(root, &positionals, keyword_count) else { + return clap_args.to_vec(); + }; + rewrite_group_help_args(clap_args, root_name, bool_flags, value_flags, &parts) +} + /// Rewrites the `target`-th positional command token to `replacement`, preserving /// flags. Token classification mirrors [`positional_command_tokens`]. fn replace_positional_command_token( @@ -3627,6 +3655,20 @@ mod unknown_command_suggestion_tests { vec!["gddy", "--output", "json", "domain", "list"] ); } + + #[test] + fn rewrite_group_help_if_needed_runs_after_typo_correction() { + let root = sample_group(); + let bool_flags = derive_bool_flags(&root); + let value_flags = derive_value_flags(&root); + let args = vec!["gddy".to_owned(), "domian".to_owned(), "help".to_owned()]; + let corrected = + replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 0, "domain"); + assert_eq!(corrected, vec!["gddy", "domain", "help"]); + let rewritten = + rewrite_group_help_if_needed(&root, &corrected, "gddy", &bool_flags, &value_flags); + assert_eq!(rewritten, vec!["gddy", "help", "domain"]); + } } /// Detects the ` help [sub...]` form and returns the command path whose diff --git a/cli-engine/src/prompt.rs b/cli-engine/src/prompt.rs index e3cf0fc..cd5ffaa 100644 --- a/cli-engine/src/prompt.rs +++ b/cli-engine/src/prompt.rs @@ -233,15 +233,16 @@ pub fn try_recover_missing_args( pub enum CommandCorrection { /// User accepted; re-dispatch with the corrected token. Accepted, - /// Non-interactive session or user declined; report the original error. + /// Don't rewrite args; caller reports `unknown.message` (may include a hint). Declined, /// User aborted (Escape or Ctrl+C). Cancelled, } /// Offer to correct an unknown command spelling. Never prompts when the session -/// is non-interactive (agents and piped invocations see the original error). -/// Interactivity follows the same raw-args rules as [`try_recover_missing_args`]. +/// is non-interactive; the nearest-match hint is still included in the error +/// the caller renders on [`CommandCorrection::Declined`]. Interactivity follows +/// the same raw-args rules as [`try_recover_missing_args`]. pub fn confirm_command_correction( args: &[String], suggestion: &str, From b6df768160e7b014c4c4e9817549538b438fecac Mon Sep 17 00:00:00 2001 From: qcai-godaddy <86305984+qcai-godaddy@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:33:16 -0500 Subject: [PATCH 3/4] use strsim and extend unknown-command suggestions to full-path correction --- Cargo.lock | 1 + cli-engine/Cargo.toml | 1 + cli-engine/src/cli.rs | 268 +++++++++++++++++++++++---------- cli-engine/src/prompt.rs | 8 +- cli-engine/tests/foundation.rs | 75 +++++++++ 5 files changed, 269 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb30b2d..22c7b95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -463,6 +463,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "strsim", "tempfile", "termimad", "thiserror", diff --git a/cli-engine/Cargo.toml b/cli-engine/Cargo.toml index 08abedd..6efa0c1 100644 --- a/cli-engine/Cargo.toml +++ b/cli-engine/Cargo.toml @@ -33,6 +33,7 @@ regex = "1.12.2" schemars = { version = "1.2.1", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" +strsim = "0.11" thiserror = "2.0.17" toml = "0.9" toml_edit = { version = "0.22", features = ["serde"] } diff --git a/cli-engine/src/cli.rs b/cli-engine/src/cli.rs index f5c8ed8..a3c632f 100644 --- a/cli-engine/src/cli.rs +++ b/cli-engine/src/cli.rs @@ -1707,22 +1707,32 @@ impl Cli { } else if let Some(unknown) = detect_unknown_group_command(&self.root, &positionals[..command_keyword_count]) { - // Interactive sessions may accept a near-match and re-dispatch. - if let Some(suggestion) = unknown.suggestion.as_deref() { + // Hint/re-dispatch only when the whole path resolves to one command. + if let Some(corrections) = + full_command_correction(&self.root, &positionals[..command_keyword_count]) + { + let display = correction_display( + &self.config.name, + &positionals[..command_keyword_count], + &corrections, + ); + let full_fix_message = format_did_you_mean(&unknown.base, &display); match crate::prompt::confirm_command_correction( &clap_args, - suggestion, + &display, self.config.auto_interactive, ) { crate::prompt::CommandCorrection::Accepted => { - clap_args = replace_positional_command_token( - &clap_args, - &self.config.name, - &bool_flags, - &value_flags, - unknown.positional_index, - suggestion, - ); + for (index, replacement) in &corrections { + clap_args = replace_positional_command_token( + &clap_args, + &self.config.name, + &bool_flags, + &value_flags, + *index, + replacement, + ); + } clap_args = rewrite_group_help_if_needed( &self.root, &clap_args, @@ -1734,7 +1744,7 @@ impl Cli { crate::prompt::CommandCorrection::Declined => { return self.finish_run(CliRunOutput { exit_code: 1, - rendered: unknown.message, + rendered: full_fix_message, }); } crate::prompt::CommandCorrection::Cancelled => { @@ -1747,7 +1757,7 @@ impl Cli { } else { return self.finish_run(CliRunOutput { exit_code: 1, - rendered: unknown.message, + rendered: unknown.base, }); } } @@ -3343,21 +3353,18 @@ fn direct_subcommand<'command>( }) } -/// An unrecognized command token from the group router, with an optional correction. +/// Appends a `— did you mean "…"?` suffix to an unknown-command error clause. +fn format_did_you_mean(base: &str, suggestion: &str) -> String { + format!("{base} — did you mean {suggestion:?}?") +} + +/// First unknown group token (`unknown command "X" for "Y"`, no hint suffix). struct UnknownGroupCommand { - /// Error text, including a `— did you mean "X"?` suffix when applicable. - message: String, - suggestion: Option, - /// Index within positional command tokens (for arg rewrite on accept). - positional_index: usize, + base: String, } -/// Walks positional command tokens through the group tree and reports the first -/// unknown token under a group. -/// -/// Returns `None` when every token resolves, or when the failure is at a leaf -/// (clap reports those). `positionals` must be pre-`--` command keywords only — -/// the caller slices to `command_keyword_count` like the group-help path. +/// Reports the first unknown token under a group. `positionals` must be pre-`--` +/// command keywords (slice to `command_keyword_count` like the group-help path). fn detect_unknown_group_command( root: &Command, positionals: &[String], @@ -3368,7 +3375,7 @@ fn detect_unknown_group_command( let mut current = root; let mut path = vec![root.get_name().to_owned()]; - for (positional_index, token) in positionals.iter().enumerate() { + for token in positionals { if let Some(next) = current.find_subcommand(token) { current = next; path.push(next.get_name().to_owned()); @@ -3376,16 +3383,7 @@ fn detect_unknown_group_command( } if current.get_subcommands().next().is_some() { let base = format!("unknown command {token:?} for {:?}", path.join(" ")); - let suggestion = nearest_subcommand(current, token); - let message = match &suggestion { - Some(suggestion) => format!("{base} — did you mean {suggestion:?}?"), - None => base, - }; - return Some(UnknownGroupCommand { - message, - suggestion, - positional_index, - }); + return Some(UnknownGroupCommand { base }); } return None; } @@ -3490,7 +3488,7 @@ fn nearest_subcommand(command: &Command, token: &str) -> Option { .filter_map(|child| { let best = std::iter::once(child.get_name()) .chain(child.get_all_aliases()) - .map(|candidate| edit_distance(&token, &candidate.to_ascii_lowercase())) + .map(|candidate| strsim::osa_distance(&token, &candidate.to_ascii_lowercase())) .min()?; (best <= max_distance).then(|| (best, child.get_name().to_owned())) }) @@ -3498,34 +3496,53 @@ fn nearest_subcommand(command: &Command, token: &str) -> Option { .map(|(_, name)| name) } -/// Restricted Damerau–Levenshtein distance (insert, delete, substitute, adjacent swap). -fn edit_distance(a: &str, b: &str) -> usize { - let a: Vec = a.chars().collect(); - let b: Vec = b.chars().collect(); - let (n, m) = (a.len(), b.len()); - - // Full matrix: transposition needs row i-2; names are short so O(n·m) is fine. - let mut d = vec![vec![0_usize; m + 1]; n + 1]; - for (i, row) in d.iter_mut().enumerate() { - row[0] = i; - } - for (j, cell) in d[0].iter_mut().enumerate() { - *cell = j; - } - - for i in 1..=n { - for j in 1..=m { - let cost = usize::from(a[i - 1] != b[j - 1]); - let mut best = (d[i - 1][j] + 1) - .min(d[i][j - 1] + 1) - .min(d[i - 1][j - 1] + cost); - if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] { - best = best.min(d[i - 2][j - 2] + 1); - } - d[i][j] = best; +/// Corrects every unknown group token to its nearest subcommand. Returns `None` +/// when any token has no near match, or when there is nothing to correct. +/// Stops at a leaf operand, curated ` help`, or an unfixable token. +fn full_command_correction(root: &Command, positionals: &[String]) -> Option> { + let mut current = root; + let mut corrections = Vec::new(); + for (index, token) in positionals.iter().enumerate() { + if let Some(next) = current.find_subcommand(token) { + current = next; + continue; } + if current.get_subcommands().next().is_none() { + break; + } + if token == "help" && current.find_subcommand("help").is_none() { + break; + } + let suggestion = nearest_subcommand(current, token)?; + let next = current.find_subcommand(&suggestion)?; + corrections.push((index, suggestion)); + current = next; + } + (!corrections.is_empty()).then_some(corrections) +} + +/// Prompt/display text for a correction. Last-token-only fixes show the bare +/// token; anything else shows the full corrected command path. +fn correction_display( + root_name: &str, + positionals: &[String], + corrections: &[(usize, String)], +) -> String { + if let [(index, only)] = corrections + && *index + 1 == positionals.len() + { + return only.clone(); + } + let mut tokens = vec![root_name.to_owned()]; + for (index, token) in positionals.iter().enumerate() { + let corrected = corrections + .iter() + .find(|(i, _)| *i == index) + .map(|(_, replacement)| replacement.clone()) + .unwrap_or_else(|| token.clone()); + tokens.push(corrected); } - d[n][m] + tokens.join(" ") } #[cfg(test)] @@ -3542,14 +3559,13 @@ mod unknown_command_suggestion_tests { } #[test] - fn edit_distance_counts_single_edits_and_transpositions() { - assert_eq!(edit_distance("domain", "domain"), 0); - assert_eq!(edit_distance("domian", "domain"), 1); - assert_eq!(edit_distance("lst", "list"), 1); - assert_eq!(edit_distance("lsit", "list"), 1); - assert_eq!(edit_distance("avaliable", "available"), 1); - assert_eq!(edit_distance("cat", "set"), 2); - assert_eq!(edit_distance("", "list"), 4); + fn osa_distance_treats_adjacent_transposition_as_one_edit() { + // Guard against swapping to `strsim::levenshtein`, which counts swaps as two edits. + assert_eq!(strsim::osa_distance("domain", "domain"), 0); + assert_eq!(strsim::osa_distance("domian", "domain"), 1); + assert_eq!(strsim::osa_distance("lst", "list"), 1); + assert_eq!(strsim::osa_distance("lsit", "list"), 1); + assert_eq!(strsim::osa_distance("cat", "set"), 2); } #[test] @@ -3607,10 +3623,9 @@ mod unknown_command_suggestion_tests { let root = sample_group(); let unknown = detect_unknown_group_command(&root, &["domian".to_owned()]) .expect("domian is an unknown top-level command"); - assert_eq!(unknown.suggestion.as_deref(), Some("domain")); - assert_eq!(unknown.positional_index, 0); + assert_eq!(unknown.base, "unknown command \"domian\" for \"gddy\""); assert_eq!( - unknown.message, + format_did_you_mean(&unknown.base, "domain"), "unknown command \"domian\" for \"gddy\" — did you mean \"domain\"?" ); } @@ -3620,10 +3635,9 @@ mod unknown_command_suggestion_tests { let root = sample_group(); let unknown = detect_unknown_group_command(&root, &["domain".to_owned(), "lst".to_owned()]) .expect("lst is an unknown subcommand of domain"); - assert_eq!(unknown.suggestion.as_deref(), Some("list")); - assert_eq!(unknown.positional_index, 1); + assert_eq!(unknown.base, "unknown command \"lst\" for \"gddy domain\""); assert_eq!( - unknown.message, + format_did_you_mean(&unknown.base, "list"), "unknown command \"lst\" for \"gddy domain\" — did you mean \"list\"?" ); } @@ -3633,8 +3647,104 @@ mod unknown_command_suggestion_tests { let root = sample_group(); let unknown = detect_unknown_group_command(&root, &["missing".to_owned()]) .expect("missing is an unknown top-level command"); - assert_eq!(unknown.suggestion, None); - assert_eq!(unknown.message, "unknown command \"missing\" for \"gddy\""); + assert_eq!(unknown.base, "unknown command \"missing\" for \"gddy\""); + } + + #[test] + fn full_command_correction_fixes_a_single_group_typo() { + let root = sample_group(); + let corrections = full_command_correction(&root, &["domian".to_owned()]) + .expect("domian is correctable to domain"); + assert_eq!(corrections, vec![(0, "domain".to_owned())]); + } + + #[test] + fn full_command_correction_fixes_every_typo_in_a_nested_path() { + let root = sample_group(); + let corrections = full_command_correction(&root, &["domian".to_owned(), "lst".to_owned()]) + .expect("both tokens are correctable"); + assert_eq!( + corrections, + vec![(0, "domain".to_owned()), (1, "list".to_owned())] + ); + } + + #[test] + fn full_command_correction_bails_when_a_token_has_no_near_match() { + let root = sample_group(); + assert_eq!( + full_command_correction(&root, &["domain".to_owned(), "missing".to_owned()]), + None + ); + } + + #[test] + fn full_command_correction_is_none_when_there_is_nothing_to_correct() { + let root = sample_group(); + assert_eq!(full_command_correction(&root, &["domain".to_owned()]), None); + assert_eq!(full_command_correction(&root, &[]), None); + } + + #[test] + fn full_command_correction_corrects_the_group_before_curated_help() { + let root = sample_group(); + let corrections = full_command_correction(&root, &["domian".to_owned(), "help".to_owned()]) + .expect("domian is correctable even ahead of a help token"); + assert_eq!(corrections, vec![(0, "domain".to_owned())]); + } + + #[test] + fn full_command_correction_keeps_corrections_when_a_leaf_is_followed_by_an_operand() { + let root = sample_group(); + let corrections = full_command_correction( + &root, + &[ + "domain".to_owned(), + "avaliable".to_owned(), + "example.com".to_owned(), + ], + ) + .expect("avaliable is correctable to available"); + assert_eq!(corrections, vec![(1, "available".to_owned())]); + } + + #[test] + fn correction_display_shows_the_bare_token_for_a_single_fix() { + let corrections = vec![(1, "list".to_owned())]; + assert_eq!( + correction_display( + "gddy", + &["domain".to_owned(), "lst".to_owned()], + &corrections + ), + "list" + ); + } + + #[test] + fn correction_display_shows_the_full_command_when_a_single_fix_is_not_the_last_token() { + let corrections = vec![(0, "domain".to_owned())]; + assert_eq!( + correction_display( + "gddy", + &["domian".to_owned(), "list".to_owned()], + &corrections + ), + "gddy domain list" + ); + } + + #[test] + fn correction_display_shows_the_full_command_for_multiple_fixes() { + let corrections = vec![(0, "domain".to_owned()), (1, "list".to_owned())]; + assert_eq!( + correction_display( + "gddy", + &["domian".to_owned(), "lst".to_owned()], + &corrections + ), + "gddy domain list" + ); } #[test] diff --git a/cli-engine/src/prompt.rs b/cli-engine/src/prompt.rs index cd5ffaa..662ebed 100644 --- a/cli-engine/src/prompt.rs +++ b/cli-engine/src/prompt.rs @@ -233,16 +233,14 @@ pub fn try_recover_missing_args( pub enum CommandCorrection { /// User accepted; re-dispatch with the corrected token. Accepted, - /// Don't rewrite args; caller reports `unknown.message` (may include a hint). + /// Don't rewrite args; caller renders the error (with hint when applicable). Declined, /// User aborted (Escape or Ctrl+C). Cancelled, } -/// Offer to correct an unknown command spelling. Never prompts when the session -/// is non-interactive; the nearest-match hint is still included in the error -/// the caller renders on [`CommandCorrection::Declined`]. Interactivity follows -/// the same raw-args rules as [`try_recover_missing_args`]. +/// Offer to correct an unknown command spelling. Never prompts when non-interactive; +/// interactivity follows the same raw-args rules as [`try_recover_missing_args`]. pub fn confirm_command_correction( args: &[String], suggestion: &str, diff --git a/cli-engine/tests/foundation.rs b/cli-engine/tests/foundation.rs index 62dbeaf..896fa7e 100644 --- a/cli-engine/tests/foundation.rs +++ b/cli-engine/tests/foundation.rs @@ -994,6 +994,81 @@ async fn cli_runtime_unknown_nested_command_suggests_nearest_match() { ); } +#[tokio::test] +async fn cli_runtime_unknown_command_suggests_the_full_corrected_command_for_multiple_typos() { + let mut cli = Cli::new(CliConfig { + name: "my-cli".to_owned(), + short: "Developer tooling".to_owned(), + ..CliConfig::default() + }); + cli.add_module_group( + "Platform Systems", + RuntimeGroupSpec::new(GroupSpec::new("project", "Manage projects")).with_command( + RuntimeCommandSpec::new( + CommandSpec::new("list", "List projects").no_auth(true), + async |_credential, _args| Ok(CommandResult::new(json!({}))), + ), + ), + ); + + let output = cli.run(["my-cli", "projet", "lst"]).await; + + assert_eq!(output.exit_code, 1); + assert_eq!( + output.rendered, + "unknown command \"projet\" for \"my-cli\" — did you mean \"my-cli project list\"?" + ); +} + +#[tokio::test] +async fn cli_runtime_unknown_command_suggests_the_full_command_when_only_the_group_is_mistyped() { + let mut cli = Cli::new(CliConfig { + name: "my-cli".to_owned(), + short: "Developer tooling".to_owned(), + ..CliConfig::default() + }); + cli.add_module_group( + "Platform Systems", + RuntimeGroupSpec::new(GroupSpec::new("project", "Manage projects")).with_command( + RuntimeCommandSpec::new( + CommandSpec::new("list", "List projects").no_auth(true), + async |_credential, _args| Ok(CommandResult::new(json!({}))), + ), + ), + ); + + let output = cli.run(["my-cli", "projet", "list"]).await; + + assert_eq!(output.exit_code, 1); + assert_eq!( + output.rendered, + "unknown command \"projet\" for \"my-cli\" — did you mean \"my-cli project list\"?" + ); +} + +#[tokio::test] +async fn cli_runtime_unknown_command_omits_hint_when_a_later_token_is_unfixable() { + let mut cli = Cli::new(CliConfig { + name: "my-cli".to_owned(), + short: "Developer tooling".to_owned(), + ..CliConfig::default() + }); + cli.add_module_group( + "Platform Systems", + RuntimeGroupSpec::new(GroupSpec::new("project", "Manage projects")).with_command( + RuntimeCommandSpec::new( + CommandSpec::new("list", "List projects").no_auth(true), + async |_credential, _args| Ok(CommandResult::new(json!({}))), + ), + ), + ); + + let output = cli.run(["my-cli", "projet", "xyzzy"]).await; + + assert_eq!(output.exit_code, 1); + assert_eq!(output.rendered, "unknown command \"projet\" for \"my-cli\""); +} + #[tokio::test] async fn cli_runtime_unknown_command_omits_hint_when_no_close_match() { let mut cli = Cli::new(CliConfig { From f0d9dc463c42b82c30348e2443aecaf8e3d544e0 Mon Sep 17 00:00:00 2001 From: qcai-godaddy <86305984+qcai-godaddy@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:43:18 -0500 Subject: [PATCH 4/4] fix test name --- cli-engine/src/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli-engine/src/cli.rs b/cli-engine/src/cli.rs index a3c632f..91944f4 100644 --- a/cli-engine/src/cli.rs +++ b/cli-engine/src/cli.rs @@ -3619,7 +3619,7 @@ mod unknown_command_suggestion_tests { } #[test] - fn detect_unknown_group_command_annotates_with_suggestion() { + fn unknown_group_command_formats_did_you_mean_suffix() { let root = sample_group(); let unknown = detect_unknown_group_command(&root, &["domian".to_owned()]) .expect("domian is an unknown top-level command");