use serde::Serialize; use strsim::jaro_winkler; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /// A single correction applied to one argument. #[derive(Debug, Clone, Serialize)] pub struct Correction { pub original: String, pub corrected: String, pub rule: CorrectionRule, pub confidence: f64, } /// Which rule triggered the correction. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum CorrectionRule { SingleDashLongFlag, CaseNormalization, FuzzyFlag, SubcommandAlias, ValueNormalization, ValueFuzzy, FlagPrefix, NoColorExpansion, } /// Result of the correction pass over raw args. #[derive(Debug, Clone)] pub struct CorrectionResult { pub args: Vec, pub corrections: Vec, } // --------------------------------------------------------------------------- // Flag registry // --------------------------------------------------------------------------- /// Global flags accepted by every command (from `Cli` struct). const GLOBAL_FLAGS: &[&str] = &[ "--config", "--robot", "--json", "--color", "--icons", "--quiet", "--no-quiet", "--verbose", "--no-verbose", "--log-format", ]; /// Per-subcommand flags. Each entry is `(command_name, &[flags])`. /// Hidden `--no-*` variants are included so they can be fuzzy-matched too. const COMMAND_FLAGS: &[(&str, &[&str])] = &[ ( "issues", &[ "--limit", "--fields", "--state", "--project", "--author", "--assignee", "--label", "--milestone", "--status", "--since", "--due-before", "--has-due", "--no-has-due", "--sort", "--asc", "--no-asc", "--open", "--no-open", ], ), ( "mrs", &[ "--limit", "--fields", "--state", "--project", "--author", "--assignee", "--reviewer", "--label", "--since", "--draft", "--no-draft", "--target", "--source", "--sort", "--asc", "--no-asc", "--open", "--no-open", ], ), ( "ingest", &[ "--project", "--force", "--no-force", "--full", "--no-full", "--dry-run", "--no-dry-run", ], ), ( "sync", &[ "--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--no-file-changes", "--no-status", "--dry-run", "--no-dry-run", "--timings", "--lock", "--issue", "--mr", "--project", "--preflight-only", ], ), ( "search", &[ "--mode", "--type", "--author", "--project", "--label", "--path", "--since", "--updated-since", "--limit", "--fields", "--explain", "--no-explain", "--fts-mode", ], ), ( "embed", &["--full", "--no-full", "--retry-failed", "--no-retry-failed"], ), ( "stats", &[ "--check", "--no-check", "--repair", "--dry-run", "--no-dry-run", ], ), ("count", &["--for"]), ( "timeline", &[ "--project", "--since", "--depth", "--no-mentions", "--limit", "--fields", "--max-seeds", "--max-entities", "--max-evidence", ], ), ( "who", &[ "--path", "--active", "--overlap", "--reviews", "--since", "--project", "--limit", "--fields", "--detail", "--no-detail", "--as-of", "--explain-score", "--include-bots", "--include-closed", "--all-history", ], ), ("drift", &["--threshold", "--project"]), ( "notes", &[ "--limit", "--fields", "--author", "--note-type", "--contains", "--note-id", "--gitlab-note-id", "--discussion-id", "--include-system", "--for-issue", "--for-mr", "--project", "--since", "--until", "--path", "--resolution", "--sort", "--asc", "--open", ], ), ( "init", &[ "--force", "--non-interactive", "--gitlab-url", "--token-env-var", "--projects", "--default-project", ], ), ( "file-history", &[ "--project", "--discussions", "--no-follow-renames", "--merged", "--limit", ], ), ( "trace", &[ "--project", "--discussions", "--no-follow-renames", "--limit", ], ), ("generate-docs", &["--full", "--project"]), ("completions", &[]), ("robot-docs", &["--brief"]), ( "list", &[ "--limit", "--project", "--state", "--author", "--assignee", "--label", "--milestone", "--since", "--due-before", "--has-due-date", "--sort", "--order", "--open", "--draft", "--no-draft", "--reviewer", "--target-branch", "--source-branch", ], ), ("show", &["--project"]), ("reset", &["--yes"]), ]; /// Valid values for enum-like flags, used for post-clap error enhancement. pub const ENUM_VALUES: &[(&str, &[&str])] = &[ ("--state", &["opened", "closed", "merged", "locked", "all"]), ("--mode", &["lexical", "hybrid", "semantic"]), ("--sort", &["updated", "created", "iid"]), ("--type", &["issue", "mr", "discussion", "note"]), ("--fts-mode", &["safe", "raw"]), ("--color", &["auto", "always", "never"]), ("--log-format", &["text", "json"]), ("--for", &["issue", "mr"]), ]; // --------------------------------------------------------------------------- // Subcommand alias map (for forms clap aliases can't express) // --------------------------------------------------------------------------- /// Subcommand aliases for non-standard forms (underscores, no separators). /// Clap `visible_alias`/`alias` handles hyphenated forms (`merge-requests`); /// this map catches the rest. const SUBCOMMAND_ALIASES: &[(&str, &str)] = &[ ("merge_requests", "mrs"), ("merge_request", "mrs"), ("mergerequests", "mrs"), ("mergerequest", "mrs"), ("generate_docs", "generate-docs"), ("generatedocs", "generate-docs"), ("gendocs", "generate-docs"), ("gen-docs", "generate-docs"), ("robot_docs", "robot-docs"), ("robotdocs", "robot-docs"), ("sync_status", "status"), ("syncstatus", "status"), ("auth_test", "auth"), ("authtest", "auth"), ("file_history", "file-history"), ("filehistory", "file-history"), ]; // --------------------------------------------------------------------------- // Correction thresholds // --------------------------------------------------------------------------- const FUZZY_FLAG_THRESHOLD: f64 = 0.8; /// Stricter threshold for robot mode — only high-confidence corrections to /// avoid misleading agents. Still catches obvious typos like `--projct`. const FUZZY_FLAG_THRESHOLD_STRICT: f64 = 0.9; // --------------------------------------------------------------------------- // Core logic // --------------------------------------------------------------------------- /// Detect which subcommand is being invoked by finding the first positional /// arg (not a flag, not a flag value). fn detect_subcommand(args: &[String]) -> Option<&str> { // Skip args[0] (binary name). Walk forward looking for the first // arg that isn't a flag and isn't the value to a flag that takes one. let mut skip_next = false; for arg in args.iter().skip(1) { if skip_next { skip_next = false; continue; } if arg.starts_with('-') { // Flags that take a value: we know global ones; for simplicity // skip the next arg for any `--flag=value` form (handled inline) // or known value-taking global flags. if arg.contains('=') { continue; } if matches!(arg.as_str(), "--config" | "-c" | "--color" | "--log-format") { skip_next = true; } continue; } // First non-flag positional = subcommand return Some(arg.as_str()); } None } /// Build the set of valid long flags for the detected subcommand. fn valid_flags_for(subcommand: Option<&str>) -> Vec<&'static str> { let mut flags: Vec<&str> = GLOBAL_FLAGS.to_vec(); if let Some(cmd) = subcommand { for (name, cmd_flags) in COMMAND_FLAGS { if *name == cmd { flags.extend_from_slice(cmd_flags); break; } } } else { // No subcommand detected — include all flags for maximum matching for (_, cmd_flags) in COMMAND_FLAGS { for flag in *cmd_flags { if !flags.contains(flag) { flags.push(flag); } } } } flags } /// Run the pre-clap correction pass on raw args. /// /// Three-phase pipeline: /// - Phase A: Subcommand alias correction (case-insensitive alias map) /// - Phase B: Per-arg flag corrections (single-dash, case, prefix, fuzzy) /// - Phase C: Enum value normalization (case + fuzzy + prefix on known values) /// /// When `strict` is true (robot mode), fuzzy matching uses a higher threshold /// (0.9 vs 0.8) to avoid speculative corrections while still catching obvious /// typos like `--projct` → `--project`. /// /// Returns the (possibly modified) args and any corrections applied. pub fn correct_args(raw: Vec, strict: bool) -> CorrectionResult { let mut corrections = Vec::new(); // Phase A: Subcommand alias correction let args = correct_subcommand(raw, &mut corrections); // Phase B: Per-arg flag corrections let valid = valid_flags_for(detect_subcommand(&args)); let mut corrected = Vec::with_capacity(args.len()); let mut past_terminator = false; for arg in args { // B1: Stop correcting after POSIX `--` option terminator if arg == "--" { past_terminator = true; corrected.push(arg); continue; } if past_terminator { corrected.push(arg); continue; } if let Some(fixed) = try_correct(&arg, &valid, strict) { if fixed.rule == CorrectionRule::NoColorExpansion { // Expand --no-color → --color never corrections.push(Correction { original: fixed.original, corrected: "--color never".to_string(), rule: CorrectionRule::NoColorExpansion, confidence: 1.0, }); corrected.push("--color".to_string()); corrected.push("never".to_string()); } else { let s = fixed.corrected.clone(); corrections.push(fixed); corrected.push(s); } } else { corrected.push(arg); } } // Phase C: Enum value normalization normalize_enum_values(&mut corrected, &mut corrections); CorrectionResult { args: corrected, corrections, } } /// Phase A: Replace subcommand aliases with their canonical names. /// /// Handles forms that can't be expressed as clap `alias`/`visible_alias` /// (underscores, no-separator forms). Case-insensitive matching. fn correct_subcommand(mut args: Vec, corrections: &mut Vec) -> Vec { // Find the subcommand position index, then check the alias map. // Can't use iterators easily because we need to mutate args[i]. let mut skip_next = false; let mut subcmd_idx = None; for (i, arg) in args.iter().enumerate().skip(1) { if skip_next { skip_next = false; continue; } if arg.starts_with('-') { if arg.contains('=') { continue; } if matches!(arg.as_str(), "--config" | "-c" | "--color" | "--log-format") { skip_next = true; } continue; } subcmd_idx = Some(i); break; } if let Some(i) = subcmd_idx && let Some((_, canonical)) = SUBCOMMAND_ALIASES .iter() .find(|(alias, _)| alias.eq_ignore_ascii_case(&args[i])) { corrections.push(Correction { original: args[i].clone(), corrected: (*canonical).to_string(), rule: CorrectionRule::SubcommandAlias, confidence: 1.0, }); args[i] = (*canonical).to_string(); } args } /// Phase C: Normalize enum values for flags with known valid values. /// /// Handles both `--flag value` and `--flag=value` forms. Corrections are: /// 1. Case normalization: `Opened` → `opened` /// 2. Prefix expansion: `open` → `opened` (only if unambiguous) /// 3. Fuzzy matching: `opend` → `opened` fn normalize_enum_values(args: &mut [String], corrections: &mut Vec) { let mut i = 0; while i < args.len() { // Respect POSIX `--` option terminator — don't normalize values after it if args[i] == "--" { break; } // Handle --flag=value form if let Some(eq_pos) = args[i].find('=') { let flag = args[i][..eq_pos].to_string(); let value = args[i][eq_pos + 1..].to_string(); if let Some(valid_vals) = lookup_enum_values(&flag) && let Some((corrected_val, is_case_only)) = normalize_value(&value, valid_vals) { let original = args[i].clone(); let corrected = format!("{flag}={corrected_val}"); args[i] = corrected.clone(); corrections.push(Correction { original, corrected, rule: if is_case_only { CorrectionRule::ValueNormalization } else { CorrectionRule::ValueFuzzy }, confidence: 0.95, }); } i += 1; continue; } // Handle --flag value form if args[i].starts_with("--") && let Some(valid_vals) = lookup_enum_values(&args[i]) && i + 1 < args.len() && !args[i + 1].starts_with('-') { let value = args[i + 1].clone(); if let Some((corrected_val, is_case_only)) = normalize_value(&value, valid_vals) { let original = args[i + 1].clone(); args[i + 1] = corrected_val.to_string(); corrections.push(Correction { original, corrected: corrected_val.to_string(), rule: if is_case_only { CorrectionRule::ValueNormalization } else { CorrectionRule::ValueFuzzy }, confidence: 0.95, }); } i += 2; continue; } i += 1; } } /// Look up valid enum values for a flag (case-insensitive flag name match). fn lookup_enum_values(flag: &str) -> Option<&'static [&'static str]> { let lower = flag.to_lowercase(); ENUM_VALUES .iter() .find(|(f, _)| f.to_lowercase() == lower) .map(|(_, vals)| *vals) } /// Try to normalize a value against a set of valid values. /// /// Returns `Some((corrected, is_case_only))` if a correction is needed: /// - `is_case_only = true` for pure case normalization /// - `is_case_only = false` for prefix/fuzzy corrections /// /// Returns `None` if the value is already valid or no match is found. fn normalize_value(input: &str, valid_values: &[&str]) -> Option<(String, bool)> { // Already valid (exact match)? No correction needed. if valid_values.contains(&input) { return None; } let lower = input.to_lowercase(); // Case-insensitive exact match if let Some(&val) = valid_values.iter().find(|v| v.to_lowercase() == lower) { return Some((val.to_string(), true)); } // Prefix match (e.g., "open" → "opened") — only if unambiguous let prefix_matches: Vec<&&str> = valid_values .iter() .filter(|v| v.starts_with(&*lower)) .collect(); if prefix_matches.len() == 1 { return Some(((*prefix_matches[0]).to_string(), false)); } // Fuzzy match let best = valid_values .iter() .map(|v| (*v, jaro_winkler(&lower, v))) .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); if let Some((val, score)) = best && score >= 0.8 { return Some((val.to_string(), false)); } None } /// Clap built-in flags that should never be corrected. These are handled by clap /// directly and are not in our GLOBAL_FLAGS registry. const CLAP_BUILTINS: &[&str] = &["--help", "--version"]; /// Try to correct a single arg. Returns `None` if no correction needed. /// /// When `strict` is true, fuzzy matching is disabled — only deterministic /// corrections (single-dash fix, case normalization) are applied. /// /// Special case: `--no-color` is rewritten to `--color never` by returning /// the `--color` correction and letting the caller handle arg insertion. /// However, since we correct one arg at a time, we use `NoColorExpansion` /// to signal that the next phase should insert `never` after this arg. fn try_correct(arg: &str, valid_flags: &[&str], strict: bool) -> Option { // Only attempt correction on flag-like args (starts with `-`) if !arg.starts_with('-') { return None; } // Special case: --no-color → --color never (common agent/user expectation) if arg.eq_ignore_ascii_case("--no-color") { return Some(Correction { original: arg.to_string(), corrected: "--no-color".to_string(), // sentinel; expanded in correct_args rule: CorrectionRule::NoColorExpansion, confidence: 1.0, }); } // B2: Never correct clap built-in flags (--help, --version) let flag_part_for_builtin = if let Some(eq_pos) = arg.find('=') { &arg[..eq_pos] } else { arg }; if CLAP_BUILTINS .iter() .any(|b| b.eq_ignore_ascii_case(flag_part_for_builtin)) { return None; } // Skip short flags — they're unambiguous single chars (-p, -n, -v, -J) // Also skip stacked short flags (-vvv) if !arg.starts_with("--") { // Rule 1: Single-dash long flag — e.g. `-robot` (len > 2, not a valid short flag) // A short flag is `-` + single char, optionally stacked (-vvv). // If it's `-` + multiple chars and NOT all the same char, it's likely a single-dash long flag. let after_dash = &arg[1..]; // Check if it's a stacked short flag like -vvv (all same char) let all_same_char = after_dash.len() > 1 && after_dash .chars() .all(|c| c == after_dash.chars().next().unwrap_or('\0')); if all_same_char { return None; } // Single char = valid short flag, don't touch if after_dash.len() == 1 { return None; } // It looks like a single-dash long flag (e.g. `-robot`, `-state`) let candidate = format!("--{after_dash}"); // Check exact match first (case-sensitive) if valid_flags.contains(&candidate.as_str()) { return Some(Correction { original: arg.to_string(), corrected: candidate, rule: CorrectionRule::SingleDashLongFlag, confidence: 0.95, }); } // Check case-insensitive exact match let lower = candidate.to_lowercase(); if let Some(&flag) = valid_flags.iter().find(|f| f.to_lowercase() == lower) { return Some(Correction { original: arg.to_string(), corrected: flag.to_string(), rule: CorrectionRule::SingleDashLongFlag, confidence: 0.95, }); } // Try fuzzy on the single-dash candidate (skip in strict mode) if !strict && let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags) && score >= FUZZY_FLAG_THRESHOLD { return Some(Correction { original: arg.to_string(), corrected: best_flag.to_string(), rule: CorrectionRule::SingleDashLongFlag, confidence: score * 0.95, // discount slightly for compound correction }); } return None; } // For `--flag` or `--flag=value` forms: only correct the flag name let (flag_part, value_suffix) = if let Some(eq_pos) = arg.find('=') { (&arg[..eq_pos], Some(&arg[eq_pos..])) } else { (arg, None) }; // Already valid? No correction needed. if valid_flags.contains(&flag_part) { return None; } // Rule 2: Case normalization — `--Robot` -> `--robot` let lower = flag_part.to_lowercase(); if lower != flag_part && let Some(&flag) = valid_flags.iter().find(|f| f.to_lowercase() == lower) { let corrected = match value_suffix { Some(suffix) => format!("{flag}{suffix}"), None => flag.to_string(), }; return Some(Correction { original: arg.to_string(), corrected, rule: CorrectionRule::CaseNormalization, confidence: 0.9, }); } // Rule 3: Prefix match — `--proj` -> `--project` (only if unambiguous) let prefix_matches: Vec<&str> = valid_flags .iter() .filter(|f| f.starts_with(&*lower) && f.to_lowercase() != lower) .copied() .collect(); if prefix_matches.len() == 1 { let matched = prefix_matches[0]; let corrected = match value_suffix { Some(suffix) => format!("{matched}{suffix}"), None => matched.to_string(), }; return Some(Correction { original: arg.to_string(), corrected, rule: CorrectionRule::FlagPrefix, confidence: 0.95, }); } // Rule 4: Fuzzy flag match — higher threshold in strict/robot mode let threshold = if strict { FUZZY_FLAG_THRESHOLD_STRICT } else { FUZZY_FLAG_THRESHOLD }; if let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags) && score >= threshold { let corrected = match value_suffix { Some(suffix) => format!("{best_flag}{suffix}"), None => best_flag.to_string(), }; return Some(Correction { original: arg.to_string(), corrected, rule: CorrectionRule::FuzzyFlag, confidence: score, }); } None } /// Find the best fuzzy match among valid flags for a given (lowercased) input. /// /// Applies a length guard to prevent short candidates (e.g. `--for`, 5 chars /// including dashes) from inflating Jaro-Winkler scores against long inputs. /// When the input is more than 40% longer than a candidate, that candidate is /// excluded from fuzzy consideration (it can still match via prefix rule). fn best_fuzzy_match<'a>(input: &str, valid_flags: &[&'a str]) -> Option<(&'a str, f64)> { valid_flags .iter() .filter(|&&flag| { // Guard: skip short candidates when input is much longer. // e.g. "--foobar" (8 chars) should not fuzzy-match "--for" (5 chars) // Ratio: input must be within 1.4x the candidate length. let max_input_len = (flag.len() as f64 * 1.4) as usize; input.len() <= max_input_len }) .map(|&flag| (flag, jaro_winkler(input, flag))) .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) } // --------------------------------------------------------------------------- // Post-clap suggestion helpers // --------------------------------------------------------------------------- /// Given an unrecognized flag (from a clap error), suggest the most similar /// valid flag for the detected subcommand. pub fn suggest_similar_flag(invalid_flag: &str, raw_args: &[String]) -> Option { let subcommand = detect_subcommand(raw_args); let valid = valid_flags_for(subcommand); let lower = invalid_flag.to_lowercase(); let (best_flag, score) = best_fuzzy_match(&lower, &valid)?; if score >= 0.6 { Some(best_flag.to_string()) } else { None } } /// Given a flag name, return its valid enum values (if known). pub fn valid_values_for_flag(flag: &str) -> Option<&'static [&'static str]> { let lower = flag.to_lowercase(); ENUM_VALUES .iter() .find(|(f, _)| f.to_lowercase() == lower) .map(|(_, vals)| *vals) } /// Format a human/robot teaching note for a correction. pub fn format_teaching_note(correction: &Correction) -> String { match correction.rule { CorrectionRule::SingleDashLongFlag => { format!( "Use double-dash for long flags: {} (not {})", correction.corrected, correction.original ) } CorrectionRule::CaseNormalization => { format!( "Flags are lowercase: {} (not {})", correction.corrected, correction.original ) } CorrectionRule::FuzzyFlag => { format!( "Correct spelling: {} (not {})", correction.corrected, correction.original ) } CorrectionRule::SubcommandAlias => { format!( "Use canonical command name: {} (not {})", correction.corrected, correction.original ) } CorrectionRule::ValueNormalization => { format!( "Values are lowercase: {} (not {})", correction.corrected, correction.original ) } CorrectionRule::ValueFuzzy => { format!( "Correct value spelling: {} (not {})", correction.corrected, correction.original ) } CorrectionRule::FlagPrefix => { format!( "Use full flag name: {} (not {})", correction.corrected, correction.original ) } CorrectionRule::NoColorExpansion => { "Use `--color never` instead of `--no-color`".to_string() } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; fn args(s: &str) -> Vec { s.split_whitespace().map(String::from).collect() } // ---- Single-dash long flag ---- #[test] fn single_dash_robot() { let result = correct_args(args("lore -robot issues -n 5"), false); assert_eq!(result.corrections.len(), 1); assert_eq!(result.corrections[0].original, "-robot"); assert_eq!(result.corrections[0].corrected, "--robot"); assert_eq!( result.corrections[0].rule, CorrectionRule::SingleDashLongFlag ); assert_eq!(result.args, args("lore --robot issues -n 5")); } #[test] fn single_dash_state() { let result = correct_args(args("lore --robot issues -state opened"), false); assert_eq!(result.corrections.len(), 1); assert_eq!(result.corrections[0].corrected, "--state"); } // ---- Case normalization ---- #[test] fn case_robot() { let result = correct_args(args("lore --Robot issues"), false); assert_eq!(result.corrections.len(), 1); assert_eq!(result.corrections[0].corrected, "--robot"); assert_eq!( result.corrections[0].rule, CorrectionRule::CaseNormalization ); } #[test] fn case_state_upper() { let result = correct_args(args("lore --robot issues --State opened"), false); assert_eq!(result.corrections.len(), 1); assert_eq!(result.corrections[0].corrected, "--state"); assert_eq!( result.corrections[0].rule, CorrectionRule::CaseNormalization ); } #[test] fn case_all_upper() { let result = correct_args(args("lore --ROBOT issues --STATE opened"), false); assert_eq!(result.corrections.len(), 2); assert_eq!(result.corrections[0].corrected, "--robot"); assert_eq!(result.corrections[1].corrected, "--state"); } // ---- Fuzzy flag match ---- #[test] fn fuzzy_staate() { let result = correct_args(args("lore --robot issues --staate opened"), false); assert_eq!(result.corrections.len(), 1); assert_eq!(result.corrections[0].corrected, "--state"); assert_eq!(result.corrections[0].rule, CorrectionRule::FuzzyFlag); } #[test] fn fuzzy_projct() { let result = correct_args(args("lore --robot issues --projct group/repo"), false); assert_eq!(result.corrections.len(), 1); assert_eq!(result.corrections[0].corrected, "--project"); assert_eq!(result.corrections[0].rule, CorrectionRule::FuzzyFlag); } // ---- No corrections ---- #[test] fn already_correct() { let original = args("lore --robot issues --state opened -n 10"); let result = correct_args(original.clone(), false); assert!(result.corrections.is_empty()); assert_eq!(result.args, original); } #[test] fn short_flags_untouched() { let original = args("lore -J issues -n 10 -s opened -p group/repo"); let result = correct_args(original.clone(), false); assert!(result.corrections.is_empty()); } #[test] fn stacked_short_flags_untouched() { let original = args("lore -vvv issues"); let result = correct_args(original.clone(), false); assert!(result.corrections.is_empty()); } #[test] fn positional_args_untouched() { let result = correct_args(args("lore --robot search authentication"), false); assert!(result.corrections.is_empty()); } #[test] fn wildly_wrong_flag_not_corrected() { // `--xyzzy` shouldn't match anything above 0.8 let result = correct_args(args("lore --robot issues --xyzzy foo"), false); assert!(result.corrections.is_empty()); } // ---- Flag with = value ---- #[test] fn flag_eq_value_case_correction() { let result = correct_args(args("lore --robot issues --State=opened"), false); assert_eq!(result.corrections.len(), 1); assert_eq!(result.corrections[0].corrected, "--state=opened"); } // ---- Multiple corrections in one invocation ---- #[test] fn multiple_corrections() { let result = correct_args( args("lore -robot issues --State opened --projct group/repo"), false, ); assert_eq!(result.corrections.len(), 3); assert_eq!(result.args[1], "--robot"); assert_eq!(result.args[3], "--state"); assert_eq!(result.args[5], "--project"); } // ---- B1: POSIX -- option terminator ---- #[test] fn option_terminator_stops_corrections() { let result = correct_args(args("lore issues -- --staate --projct"), false); // Nothing after `--` should be corrected assert!(result.corrections.is_empty()); assert_eq!(result.args[2], "--"); assert_eq!(result.args[3], "--staate"); assert_eq!(result.args[4], "--projct"); } #[test] fn correction_before_terminator_still_works() { let result = correct_args(args("lore --Robot issues -- --staate"), false); assert_eq!(result.corrections.len(), 1); assert_eq!(result.corrections[0].corrected, "--robot"); assert_eq!(result.args[4], "--staate"); // untouched after -- } // ---- B2: Clap built-in flags not corrected ---- #[test] fn version_flag_not_corrected() { let result = correct_args(args("lore --version"), false); assert!(result.corrections.is_empty()); assert_eq!(result.args[1], "--version"); } #[test] fn help_flag_not_corrected() { let result = correct_args(args("lore --help"), false); assert!(result.corrections.is_empty()); assert_eq!(result.args[1], "--help"); } // ---- Strict mode (robot) uses higher fuzzy threshold ---- #[test] fn strict_mode_rejects_low_confidence_fuzzy() { // `--staate` vs `--state` — close but may be below strict threshold (0.9) // The exact score depends on Jaro-Winkler; this tests that the strict // threshold is higher than non-strict. let non_strict = correct_args(args("lore --robot issues --staate opened"), false); assert_eq!(non_strict.corrections.len(), 1); assert_eq!(non_strict.corrections[0].rule, CorrectionRule::FuzzyFlag); // In strict mode, same typo might or might not match depending on JW score. // We verify that at least wildly wrong flags are still rejected. let strict = correct_args(args("lore --robot issues --xyzzy foo"), true); assert!(strict.corrections.is_empty()); } #[test] fn strict_mode_still_fixes_case() { let result = correct_args(args("lore --Robot issues --State opened"), true); assert_eq!(result.corrections.len(), 2); assert_eq!(result.corrections[0].corrected, "--robot"); assert_eq!(result.corrections[1].corrected, "--state"); } #[test] fn strict_mode_still_fixes_single_dash() { let result = correct_args(args("lore -robot issues"), true); assert_eq!(result.corrections.len(), 1); assert_eq!(result.corrections[0].corrected, "--robot"); } // ---- Subcommand alias correction ---- #[test] fn subcommand_alias_merge_requests_underscore() { let result = correct_args(args("lore --robot merge_requests -n 10"), false); assert!( result .corrections .iter() .any(|c| c.rule == CorrectionRule::SubcommandAlias && c.corrected == "mrs") ); assert!(result.args.contains(&"mrs".to_string())); } #[test] fn subcommand_alias_mergerequests_no_sep() { let result = correct_args(args("lore --robot mergerequests"), false); assert!(result.corrections.iter().any(|c| c.corrected == "mrs")); } #[test] fn subcommand_alias_generate_docs_underscore() { let result = correct_args(args("lore generate_docs"), false); assert!( result .corrections .iter() .any(|c| c.corrected == "generate-docs") ); } #[test] fn subcommand_alias_case_insensitive() { let result = correct_args(args("lore Merge_Requests"), false); assert!(result.corrections.iter().any(|c| c.corrected == "mrs")); } #[test] fn subcommand_alias_valid_command_untouched() { let result = correct_args(args("lore issues -n 10"), false); assert!(result.corrections.is_empty()); } // ---- Enum value normalization ---- #[test] fn value_case_normalization() { let result = correct_args(args("lore issues --state Opened"), false); assert!( result .corrections .iter() .any(|c| c.rule == CorrectionRule::ValueNormalization && c.corrected == "opened") ); assert!(result.args.contains(&"opened".to_string())); } #[test] fn value_case_normalization_eq_form() { let result = correct_args(args("lore issues --state=Opened"), false); assert!( result .corrections .iter() .any(|c| c.corrected == "--state=opened") ); } #[test] fn value_prefix_expansion() { // "open" is a unique prefix of "opened" let result = correct_args(args("lore issues --state open"), false); assert!( result .corrections .iter() .any(|c| c.corrected == "opened" && c.rule == CorrectionRule::ValueFuzzy) ); } #[test] fn value_fuzzy_typo() { let result = correct_args(args("lore issues --state opend"), false); assert!(result.corrections.iter().any(|c| c.corrected == "opened")); } #[test] fn value_already_valid_untouched() { let result = correct_args(args("lore issues --state opened"), false); // No value corrections expected (flag corrections may still exist) assert!(!result.corrections.iter().any(|c| matches!( c.rule, CorrectionRule::ValueNormalization | CorrectionRule::ValueFuzzy ))); } #[test] fn value_mode_case() { let result = correct_args(args("lore search --mode Hybrid query"), false); assert!(result.corrections.iter().any(|c| c.corrected == "hybrid")); } #[test] fn value_normalization_respects_option_terminator() { // Values after `--` are positional and must not be corrected let result = correct_args(args("lore search -- --state Opened"), false); assert!(!result.corrections.iter().any(|c| matches!( c.rule, CorrectionRule::ValueNormalization | CorrectionRule::ValueFuzzy ))); assert_eq!(result.args[4], "Opened"); // preserved as-is } // ---- Flag prefix matching ---- #[test] fn flag_prefix_project() { let result = correct_args(args("lore issues --proj group/repo"), false); assert!( result .corrections .iter() .any(|c| c.rule == CorrectionRule::FlagPrefix && c.corrected == "--project") ); } #[test] fn flag_prefix_ambiguous_not_corrected() { // --s could be --state, --since, --sort, --status — ambiguous let result = correct_args(args("lore issues --s opened"), false); assert!( !result .corrections .iter() .any(|c| c.rule == CorrectionRule::FlagPrefix) ); } #[test] fn flag_prefix_with_eq_value() { let result = correct_args(args("lore issues --proj=group/repo"), false); assert!( result .corrections .iter() .any(|c| c.corrected == "--project=group/repo") ); } // ---- Teaching notes ---- #[test] fn teaching_note_single_dash() { let c = Correction { original: "-robot".to_string(), corrected: "--robot".to_string(), rule: CorrectionRule::SingleDashLongFlag, confidence: 0.95, }; let note = format_teaching_note(&c); assert!(note.contains("double-dash")); assert!(note.contains("--robot")); } #[test] fn teaching_note_case() { let c = Correction { original: "--State".to_string(), corrected: "--state".to_string(), rule: CorrectionRule::CaseNormalization, confidence: 0.9, }; let note = format_teaching_note(&c); assert!(note.contains("lowercase")); } #[test] fn teaching_note_fuzzy() { let c = Correction { original: "--staate".to_string(), corrected: "--state".to_string(), rule: CorrectionRule::FuzzyFlag, confidence: 0.85, }; let note = format_teaching_note(&c); assert!(note.contains("spelling")); } #[test] fn teaching_note_subcommand_alias() { let c = Correction { original: "merge_requests".to_string(), corrected: "mrs".to_string(), rule: CorrectionRule::SubcommandAlias, confidence: 1.0, }; let note = format_teaching_note(&c); assert!(note.contains("canonical")); assert!(note.contains("mrs")); } #[test] fn teaching_note_value_normalization() { let c = Correction { original: "Opened".to_string(), corrected: "opened".to_string(), rule: CorrectionRule::ValueNormalization, confidence: 0.95, }; let note = format_teaching_note(&c); assert!(note.contains("lowercase")); } #[test] fn teaching_note_flag_prefix() { let c = Correction { original: "--proj".to_string(), corrected: "--project".to_string(), rule: CorrectionRule::FlagPrefix, confidence: 0.95, }; let note = format_teaching_note(&c); assert!(note.contains("full flag name")); } // ---- --no-color expansion ---- #[test] fn no_color_expands_to_color_never() { let result = correct_args(args("lore --no-color health"), false); assert_eq!(result.corrections.len(), 1); assert_eq!(result.corrections[0].rule, CorrectionRule::NoColorExpansion); assert_eq!(result.args, args("lore --color never health")); } #[test] fn no_color_case_insensitive() { let result = correct_args(args("lore --No-Color issues"), false); assert_eq!(result.corrections.len(), 1); assert_eq!(result.args, args("lore --color never issues")); } #[test] fn no_color_with_robot_mode() { let result = correct_args(args("lore --robot --no-color health"), true); assert_eq!(result.corrections.len(), 1); assert_eq!(result.args, args("lore --robot --color never health")); } // ---- Fuzzy matching length guard ---- #[test] fn foobar_does_not_match_for() { // --foobar (8 chars) should NOT fuzzy-match --for (5 chars) let result = correct_args(args("lore count --foobar issues"), false); assert!( !result.corrections.iter().any(|c| c.corrected == "--for"), "expected --foobar not to match --for" ); } #[test] fn fro_still_matches_for() { // --fro (5 chars) is short enough to fuzzy-match --for (5 chars) // and also qualifies as a prefix match let result = correct_args(args("lore count --fro issues"), false); assert!( result.corrections.iter().any(|c| c.corrected == "--for"), "expected --fro to match --for" ); } // ---- Post-clap suggestion helpers ---- #[test] fn suggest_similar_flag_works() { let raw = args("lore --robot issues --xstat opened"); let suggestion = suggest_similar_flag("--xstat", &raw); // Should suggest --state (close enough with lower threshold 0.6) assert!(suggestion.is_some()); } #[test] fn valid_values_for_state() { let vals = valid_values_for_flag("--state"); assert!(vals.is_some()); let vals = vals.unwrap(); assert!(vals.contains(&"opened")); assert!(vals.contains(&"closed")); } #[test] fn valid_values_unknown_flag() { assert!(valid_values_for_flag("--xyzzy").is_none()); } // ---- Subcommand detection ---- #[test] fn detect_subcommand_basic() { assert_eq!( detect_subcommand(&args("lore issues -n 10")), Some("issues") ); } #[test] fn detect_subcommand_with_globals() { assert_eq!( detect_subcommand(&args("lore --robot --config /tmp/c.json mrs")), Some("mrs") ); } #[test] fn detect_subcommand_with_color() { assert_eq!( detect_subcommand(&args("lore --color never issues")), Some("issues") ); } #[test] fn detect_subcommand_none() { assert_eq!(detect_subcommand(&args("lore --robot")), None); } // ---- Registry drift test ---- // This test uses clap introspection to verify our static registry covers // all long flags defined in the Cli struct. #[test] fn registry_covers_global_flags() { use clap::CommandFactory; let cmd = crate::cli::Cli::command(); let clap_globals: Vec = cmd .get_arguments() .filter_map(|a| a.get_long().map(|l| format!("--{l}"))) .collect(); for flag in &clap_globals { // Skip help/version — clap adds these automatically if flag == "--help" || flag == "--version" { continue; } assert!( GLOBAL_FLAGS.contains(&flag.as_str()), "Clap global flag {flag} is missing from GLOBAL_FLAGS registry. \ Add it to GLOBAL_FLAGS in autocorrect.rs." ); } } #[test] fn registry_covers_command_flags() { use clap::CommandFactory; let cmd = crate::cli::Cli::command(); for sub in cmd.get_subcommands() { let sub_name = sub.get_name().to_string(); // Find our registry entry let registry_entry = COMMAND_FLAGS.iter().find(|(name, _)| *name == sub_name); // Not all subcommands need entries (e.g., version, auth, status // with no subcommand-specific flags) let clap_flags: Vec = sub .get_arguments() .filter_map(|a| a.get_long().map(|l| format!("--{l}"))) .filter(|f| !GLOBAL_FLAGS.contains(&f.as_str())) .filter(|f| f != "--help" && f != "--version") .collect(); if clap_flags.is_empty() { continue; } let registry_flags = registry_entry.map(|(_, flags)| *flags); let registry_flags = registry_flags.unwrap_or_else(|| { panic!( "Subcommand '{sub_name}' has clap flags {clap_flags:?} but no COMMAND_FLAGS \ registry entry. Add it to COMMAND_FLAGS in autocorrect.rs." ) }); for flag in &clap_flags { assert!( registry_flags.contains(&flag.as_str()), "Clap flag {flag} on subcommand '{sub_name}' is missing from \ COMMAND_FLAGS registry. Add it to the '{sub_name}' entry in autocorrect.rs." ); } } } }