From 7d032833a23244b59547c5eecab8be23e397bcd1 Mon Sep 17 00:00:00 2001 From: teernisse Date: Wed, 18 Feb 2026 13:28:53 -0500 Subject: [PATCH] feat(cli): improve autocorrect with --no-color expansion and --lock flag Add NoColorExpansion correction rule that rewrites --no-color into the two-arg form --color never, matching clap's expected syntax. The caller detects the rule variant and inserts the second arg. Also: add --lock to the sync command's known flags, and remove --format from the notes command's known flags (format selection was removed). Co-Authored-By: Claude Opus 4.6 --- src/cli/autocorrect.rs | 98 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/src/cli/autocorrect.rs b/src/cli/autocorrect.rs index e8dffd3..1f19af5 100644 --- a/src/cli/autocorrect.rs +++ b/src/cli/autocorrect.rs @@ -25,6 +25,7 @@ pub enum CorrectionRule { ValueNormalization, ValueFuzzy, FlagPrefix, + NoColorExpansion, } /// Result of the correction pass over raw args. @@ -128,6 +129,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[ "--dry-run", "--no-dry-run", "--timings", + "--lock", ], ), ( @@ -203,7 +205,6 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[ &[ "--limit", "--fields", - "--format", "--author", "--note-type", "--contains", @@ -424,9 +425,21 @@ pub fn correct_args(raw: Vec, strict: bool) -> CorrectionResult { } if let Some(fixed) = try_correct(&arg, &valid, strict) { - let s = fixed.corrected.clone(); - corrections.push(fixed); - corrected.push(s); + 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); } @@ -611,12 +624,27 @@ const CLAP_BUILTINS: &[&str] = &["--help", "--version"]; /// /// 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] @@ -766,9 +794,21 @@ fn try_correct(arg: &str, valid_flags: &[&str], strict: bool) -> Option(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)) } @@ -846,6 +886,9 @@ pub fn format_teaching_note(correction: &Correction) -> String { correction.corrected, correction.original ) } + CorrectionRule::NoColorExpansion => { + "Use `--color never` instead of `--no-color`".to_string() + } } } @@ -1286,6 +1329,53 @@ mod tests { 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]