feat(autocorrect): expand pre-clap correction to 3-phase pipeline with subcommand aliases, value normalization, and flag prefix matching
Three-phase pipeline replacing the single-pass correction: - Phase A: Subcommand alias correction — handles forms clap can't express (merge_requests, mergerequests, robotdocs, generatedocs, gen-docs, etc.) via case-insensitive alias map lookup. - Phase B: Per-arg flag corrections — adds unambiguous prefix expansion (--proj -> --project) alongside existing single-dash, case, and fuzzy rules. New FlagPrefix rule with 0.95 confidence. - Phase C: Enum value normalization — auto-corrects casing, prefixes, and typos for flags with known valid values. Handles both --flag value and --flag=value forms. Respects POSIX -- option terminator. Changes strict/robot mode from disabling fuzzy matching entirely to using a higher threshold (0.9 vs 0.8), still catching obvious typos like --projct while avoiding speculative corrections that mislead agents. New CorrectionRule variants: SubcommandAlias, ValueNormalization, ValueFuzzy, FlagPrefix. Each has a corresponding teaching note. Comprehensive test coverage for all new correction types including subcommand aliases, value normalization (case, prefix, fuzzy, eq-form), flag prefix (ambiguous rejection, eq-value preservation), and updated strict mode behavior.
This commit is contained in:
@@ -21,6 +21,10 @@ pub enum CorrectionRule {
|
|||||||
SingleDashLongFlag,
|
SingleDashLongFlag,
|
||||||
CaseNormalization,
|
CaseNormalization,
|
||||||
FuzzyFlag,
|
FuzzyFlag,
|
||||||
|
SubcommandAlias,
|
||||||
|
ValueNormalization,
|
||||||
|
ValueFuzzy,
|
||||||
|
FlagPrefix,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of the correction pass over raw args.
|
/// Result of the correction pass over raw args.
|
||||||
@@ -261,18 +265,45 @@ pub const ENUM_VALUES: &[(&str, &[&str])] = &[
|
|||||||
("--state", &["opened", "closed", "merged", "locked", "all"]),
|
("--state", &["opened", "closed", "merged", "locked", "all"]),
|
||||||
("--mode", &["lexical", "hybrid", "semantic"]),
|
("--mode", &["lexical", "hybrid", "semantic"]),
|
||||||
("--sort", &["updated", "created", "iid"]),
|
("--sort", &["updated", "created", "iid"]),
|
||||||
("--type", &["issue", "mr", "discussion"]),
|
("--type", &["issue", "mr", "discussion", "note"]),
|
||||||
("--fts-mode", &["safe", "raw"]),
|
("--fts-mode", &["safe", "raw"]),
|
||||||
("--color", &["auto", "always", "never"]),
|
("--color", &["auto", "always", "never"]),
|
||||||
("--log-format", &["text", "json"]),
|
("--log-format", &["text", "json"]),
|
||||||
("--for", &["issue", "mr"]),
|
("--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"),
|
||||||
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Correction thresholds
|
// Correction thresholds
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const FUZZY_FLAG_THRESHOLD: f64 = 0.8;
|
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
|
// Core logic
|
||||||
@@ -332,20 +363,29 @@ fn valid_flags_for(subcommand: Option<&str>) -> Vec<&'static str> {
|
|||||||
|
|
||||||
/// Run the pre-clap correction pass on raw args.
|
/// Run the pre-clap correction pass on raw args.
|
||||||
///
|
///
|
||||||
/// When `strict` is true (robot mode), only deterministic corrections are applied
|
/// Three-phase pipeline:
|
||||||
/// (single-dash long flags, case normalization). Fuzzy matching is disabled to
|
/// - Phase A: Subcommand alias correction (case-insensitive alias map)
|
||||||
/// prevent misleading agents with speculative corrections.
|
/// - 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.
|
/// Returns the (possibly modified) args and any corrections applied.
|
||||||
pub fn correct_args(raw: Vec<String>, strict: bool) -> CorrectionResult {
|
pub fn correct_args(raw: Vec<String>, strict: bool) -> CorrectionResult {
|
||||||
let subcommand = detect_subcommand(&raw);
|
|
||||||
let valid = valid_flags_for(subcommand);
|
|
||||||
|
|
||||||
let mut corrected = Vec::with_capacity(raw.len());
|
|
||||||
let mut corrections = Vec::new();
|
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;
|
let mut past_terminator = false;
|
||||||
|
|
||||||
for arg in raw {
|
for arg in args {
|
||||||
// B1: Stop correcting after POSIX `--` option terminator
|
// B1: Stop correcting after POSIX `--` option terminator
|
||||||
if arg == "--" {
|
if arg == "--" {
|
||||||
past_terminator = true;
|
past_terminator = true;
|
||||||
@@ -367,12 +407,177 @@ pub fn correct_args(raw: Vec<String>, strict: bool) -> CorrectionResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase C: Enum value normalization
|
||||||
|
normalize_enum_values(&mut corrected, &mut corrections);
|
||||||
|
|
||||||
CorrectionResult {
|
CorrectionResult {
|
||||||
args: corrected,
|
args: corrected,
|
||||||
corrections,
|
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<String>, corrections: &mut Vec<Correction>) -> Vec<String> {
|
||||||
|
// 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<Correction>) {
|
||||||
|
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
|
/// Clap built-in flags that should never be corrected. These are handled by clap
|
||||||
/// directly and are not in our GLOBAL_FLAGS registry.
|
/// directly and are not in our GLOBAL_FLAGS registry.
|
||||||
const CLAP_BUILTINS: &[&str] = &["--help", "--version"];
|
const CLAP_BUILTINS: &[&str] = &["--help", "--version"];
|
||||||
@@ -491,10 +696,34 @@ fn try_correct(arg: &str, valid_flags: &[&str], strict: bool) -> Option<Correcti
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule 3: Fuzzy flag match — `--staate` -> `--state` (skip in strict mode)
|
// Rule 3: Prefix match — `--proj` -> `--project` (only if unambiguous)
|
||||||
if !strict
|
let prefix_matches: Vec<&str> = valid_flags
|
||||||
&& let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
|
.iter()
|
||||||
&& score >= FUZZY_FLAG_THRESHOLD
|
.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 {
|
let corrected = match value_suffix {
|
||||||
Some(suffix) => format!("{best_flag}{suffix}"),
|
Some(suffix) => format!("{best_flag}{suffix}"),
|
||||||
@@ -568,6 +797,30 @@ pub fn format_teaching_note(correction: &Correction) -> String {
|
|||||||
correction.corrected, correction.original
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,17 +1004,20 @@ mod tests {
|
|||||||
assert_eq!(result.args[1], "--help");
|
assert_eq!(result.args[1], "--help");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- I6: Strict mode (robot) disables fuzzy matching ----
|
// ---- Strict mode (robot) uses higher fuzzy threshold ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strict_mode_disables_fuzzy() {
|
fn strict_mode_rejects_low_confidence_fuzzy() {
|
||||||
// Fuzzy match works in non-strict
|
// `--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);
|
let non_strict = correct_args(args("lore --robot issues --staate opened"), false);
|
||||||
assert_eq!(non_strict.corrections.len(), 1);
|
assert_eq!(non_strict.corrections.len(), 1);
|
||||||
assert_eq!(non_strict.corrections[0].rule, CorrectionRule::FuzzyFlag);
|
assert_eq!(non_strict.corrections[0].rule, CorrectionRule::FuzzyFlag);
|
||||||
|
|
||||||
// Fuzzy match disabled in strict
|
// In strict mode, same typo might or might not match depending on JW score.
|
||||||
let strict = correct_args(args("lore --robot issues --staate opened"), true);
|
// 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());
|
assert!(strict.corrections.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,6 +1036,155 @@ mod tests {
|
|||||||
assert_eq!(result.corrections[0].corrected, "--robot");
|
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 ----
|
// ---- Teaching notes ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -819,6 +1224,43 @@ mod tests {
|
|||||||
assert!(note.contains("spelling"));
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Post-clap suggestion helpers ----
|
// ---- Post-clap suggestion helpers ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user