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 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-18 13:28:53 -05:00
parent 097249f4e6
commit 7d032833a2

View File

@@ -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<String>, strict: bool) -> CorrectionResult {
}
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);
}
@@ -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<Correction> {
// 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<Correcti
}
/// 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))
}
@@ -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]