Files
gitlore/src/cli/autocorrect.rs
teernisse 32134ea933 feat(explain): implement lore explain command for auto-generating issue/MR narratives
Adds the full explain command with 7 output sections: entity summary, description,
key decisions (heuristic event-note correlation), activity summary, open threads,
related entities (closing MRs, cross-references), and timeline excerpt (reuses
existing pipeline). Supports --sections filtering, --since time scoping,
--no-timeline, --max-decisions, and robot mode JSON output.

Closes: bd-2i3z, bd-a3j8, bd-wb0b, bd-3q5e, bd-nj7f, bd-9lbr
2026-03-10 15:04:35 -04:00

1875 lines
59 KiB
Rust

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,
/// Fuzzy subcommand match: "issuess" → "issues"
SubcommandFuzzy,
/// Flag-style subcommand: "--robot-docs" → "robot-docs"
FlagAsSubcommand,
ValueNormalization,
ValueFuzzy,
FlagPrefix,
NoColorExpansion,
}
/// Result of the correction pass over raw args.
#[derive(Debug, Clone)]
pub struct CorrectionResult {
pub args: Vec<String>,
pub corrections: Vec<Correction>,
}
// ---------------------------------------------------------------------------
// 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",
],
),
("related", &["--limit", "--project"]),
(
"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"]),
(
"explain",
&[
"--project",
"--sections",
"--no-timeline",
"--max-decisions",
"--since",
],
),
(
"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",
&[
"--refresh",
"--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",
],
),
("reset", &["--yes"]),
(
"me",
&[
"--issues",
"--mrs",
"--activity",
"--mentions",
"--since",
"--project",
"--all",
"--user",
"--fields",
"--reset-cursor",
],
),
];
/// 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;
/// Fuzzy subcommand threshold — higher than flags because subcommand names
/// are shorter words where JW scores inflate more easily.
const FUZZY_SUBCMD_THRESHOLD: f64 = 0.85;
/// All canonical subcommand names for fuzzy matching and flag-as-subcommand
/// detection. Includes hidden commands so agents that know about them can
/// still benefit from typo correction.
const CANONICAL_SUBCOMMANDS: &[&str] = &[
"issues",
"mrs",
"notes",
"ingest",
"count",
"status",
"auth",
"doctor",
"version",
"init",
"search",
"stats",
"generate-docs",
"embed",
"sync",
"migrate",
"health",
"robot-docs",
"completions",
"timeline",
"who",
"me",
"file-history",
"trace",
"drift",
"explain",
"related",
"cron",
"token",
// Hidden but still valid
"backup",
"reset",
"list",
"auth-test",
"sync-status",
];
// ---------------------------------------------------------------------------
// 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<String>, 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, fuzzy-match
/// typo'd subcommands, and detect flag-style subcommands (`--robot-docs`).
///
/// Three-step pipeline:
/// - A1: Exact alias match (underscore/no-separator forms)
/// - A2: Fuzzy subcommand match ("issuess" → "issues")
/// - A3: Flag-as-subcommand ("--robot-docs" → "robot-docs")
fn correct_subcommand(mut args: Vec<String>, corrections: &mut Vec<Correction>) -> Vec<String> {
// Find the subcommand position index.
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 {
// A1: Exact alias match (existing logic)
if 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();
}
// A2: Fuzzy subcommand match — only if not already a canonical name
else {
let lower = args[i].to_lowercase();
if !CANONICAL_SUBCOMMANDS.contains(&lower.as_str()) {
// Guard: don't fuzzy-match words that look like misplaced global flags
// (e.g., "robot" should not match "robot-docs")
let as_flag = format!("--{lower}");
let is_flag_word = GLOBAL_FLAGS
.iter()
.any(|f| f.eq_ignore_ascii_case(&as_flag));
// Guard: don't fuzzy-match if it's a valid prefix of a canonical command
// (clap's infer_subcommands handles prefix resolution)
let is_prefix = CANONICAL_SUBCOMMANDS
.iter()
.any(|cmd| cmd.starts_with(&*lower) && *cmd != lower);
if !is_flag_word && !is_prefix {
let best = CANONICAL_SUBCOMMANDS
.iter()
.map(|cmd| (*cmd, jaro_winkler(&lower, cmd)))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
if let Some((cmd, score)) = best
&& score >= FUZZY_SUBCMD_THRESHOLD
{
corrections.push(Correction {
original: args[i].clone(),
corrected: cmd.to_string(),
rule: CorrectionRule::SubcommandFuzzy,
confidence: score,
});
args[i] = cmd.to_string();
}
}
}
}
} else {
// A3: No subcommand detected — check for flag-style subcommands.
// Agents sometimes type `--robot-docs` or `--generate-docs` as flags.
let mut flag_as_subcmd: Option<(usize, String)> = None;
let mut flag_skip = false;
for (i, arg) in args.iter().enumerate().skip(1) {
if flag_skip {
flag_skip = false;
continue;
}
if !arg.starts_with("--") || arg.contains('=') {
continue;
}
let arg_lower = arg.to_lowercase();
// Skip clap built-in flags (--help, --version)
if CLAP_BUILTINS
.iter()
.any(|b| b.eq_ignore_ascii_case(&arg_lower))
{
continue;
}
// Skip known global flags
if GLOBAL_FLAGS.iter().any(|f| f.to_lowercase() == arg_lower) {
if matches!(arg_lower.as_str(), "--config" | "--color" | "--log-format") {
flag_skip = true;
}
continue;
}
let stripped = arg_lower[2..].to_string();
if CANONICAL_SUBCOMMANDS.contains(&stripped.as_str()) {
flag_as_subcmd = Some((i, stripped));
break;
}
}
if let Some((i, subcmd)) = flag_as_subcmd {
corrections.push(Correction {
original: args[i].clone(),
corrected: subcmd.clone(),
rule: CorrectionRule::FlagAsSubcommand,
confidence: 1.0,
});
args[i] = subcmd;
}
}
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
/// 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<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]
} 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<String> {
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::SubcommandFuzzy => {
format!(
"Correct command spelling: lore {} (not lore {})",
correction.corrected, correction.original
)
}
CorrectionRule::FlagAsSubcommand => {
format!(
"Commands are positional, not flags: lore {} (not lore --{})",
correction.corrected, correction.corrected
)
}
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<String> {
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);
}
// ---- Fuzzy subcommand matching (A2) ----
#[test]
fn fuzzy_subcommand_issuess() {
let result = correct_args(args("lore --robot issuess -n 10"), false);
assert!(
result
.corrections
.iter()
.any(|c| c.rule == CorrectionRule::SubcommandFuzzy && c.corrected == "issues"),
"expected 'issuess' to fuzzy-match 'issues'"
);
assert!(result.args.contains(&"issues".to_string()));
}
#[test]
fn fuzzy_subcommand_timline() {
let result = correct_args(args("lore timline \"auth\""), false);
assert!(
result.corrections.iter().any(|c| c.corrected == "timeline"),
"expected 'timline' to fuzzy-match 'timeline'"
);
}
#[test]
fn fuzzy_subcommand_serach() {
let result = correct_args(args("lore --robot serach \"auth bug\""), false);
assert!(
result.corrections.iter().any(|c| c.corrected == "search"),
"expected 'serach' to fuzzy-match 'search'"
);
}
#[test]
fn fuzzy_subcommand_already_valid_untouched() {
let result = correct_args(args("lore issues -n 10"), false);
assert!(
!result
.corrections
.iter()
.any(|c| c.rule == CorrectionRule::SubcommandFuzzy)
);
}
#[test]
fn fuzzy_subcommand_robot_not_matched_to_robot_docs() {
// "robot" looks like a misplaced --robot flag, not a typo for "robot-docs"
let result = correct_args(args("lore robot issues"), false);
assert!(
!result
.corrections
.iter()
.any(|c| c.rule == CorrectionRule::SubcommandFuzzy),
"expected 'robot' NOT to fuzzy-match 'robot-docs' (it's a misplaced flag)"
);
}
#[test]
fn fuzzy_subcommand_prefix_deferred_to_clap() {
// "iss" is a prefix of "issues" — clap's infer_subcommands handles this
let result = correct_args(args("lore iss -n 10"), false);
assert!(
!result
.corrections
.iter()
.any(|c| c.rule == CorrectionRule::SubcommandFuzzy),
"expected prefix 'iss' NOT to be fuzzy-matched (clap handles it)"
);
}
#[test]
fn fuzzy_subcommand_wildly_wrong_not_matched() {
let result = correct_args(args("lore xyzzyplugh"), false);
assert!(
!result
.corrections
.iter()
.any(|c| c.rule == CorrectionRule::SubcommandFuzzy),
"expected gibberish NOT to fuzzy-match any command"
);
}
// ---- Flag-as-subcommand (A3) ----
#[test]
fn flag_as_subcommand_robot_docs() {
let result = correct_args(args("lore --robot-docs"), false);
assert!(
result
.corrections
.iter()
.any(|c| c.rule == CorrectionRule::FlagAsSubcommand && c.corrected == "robot-docs"),
"expected '--robot-docs' to be corrected to 'robot-docs'"
);
assert!(result.args.contains(&"robot-docs".to_string()));
}
#[test]
fn flag_as_subcommand_generate_docs() {
let result = correct_args(args("lore --generate-docs"), false);
assert!(
result
.corrections
.iter()
.any(|c| c.corrected == "generate-docs"),
"expected '--generate-docs' to be corrected to 'generate-docs'"
);
}
#[test]
fn flag_as_subcommand_with_robot_flag() {
// `lore --robot --robot-docs` — --robot is a valid global flag, --robot-docs is not
let result = correct_args(args("lore --robot --robot-docs"), false);
assert!(
result
.corrections
.iter()
.any(|c| c.corrected == "robot-docs"),
);
assert_eq!(result.args, args("lore --robot robot-docs"));
}
#[test]
fn flag_as_subcommand_does_not_touch_real_flags() {
// --robot is a real global flag, should NOT be rewritten to "robot"
let result = correct_args(args("lore --robot issues"), false);
assert!(
!result
.corrections
.iter()
.any(|c| c.rule == CorrectionRule::FlagAsSubcommand),
);
}
#[test]
fn flag_as_subcommand_not_triggered_when_subcommand_present() {
// A subcommand IS detected, so A3 shouldn't activate
let result = correct_args(args("lore issues --robot-docs"), false);
assert!(
!result
.corrections
.iter()
.any(|c| c.rule == CorrectionRule::FlagAsSubcommand),
"expected A3 not to trigger when subcommand is already present"
);
}
// ---- Teaching notes for new rules ----
#[test]
fn teaching_note_subcommand_fuzzy() {
let c = Correction {
original: "issuess".to_string(),
corrected: "issues".to_string(),
rule: CorrectionRule::SubcommandFuzzy,
confidence: 0.92,
};
let note = format_teaching_note(&c);
assert!(note.contains("spelling"));
assert!(note.contains("issues"));
}
#[test]
fn teaching_note_flag_as_subcommand() {
let c = Correction {
original: "--robot-docs".to_string(),
corrected: "robot-docs".to_string(),
rule: CorrectionRule::FlagAsSubcommand,
confidence: 1.0,
};
let note = format_teaching_note(&c);
assert!(note.contains("positional"));
assert!(note.contains("robot-docs"));
}
// ---- Canonical subcommands registry drift test ----
#[test]
fn canonical_subcommands_covers_clap() {
use clap::CommandFactory;
let cmd = crate::cli::Cli::command();
for sub in cmd.get_subcommands() {
let name = sub.get_name();
assert!(
CANONICAL_SUBCOMMANDS.contains(&name),
"Clap subcommand '{name}' is missing from CANONICAL_SUBCOMMANDS. \
Add it to autocorrect.rs."
);
}
}
// ---- 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<String> = 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<String> = 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."
);
}
}
}
}