Wire --issue/--mr surgical dispatch, fix effective_project resolution bug, remove dead struct fields and stale allow annotations, fix collapsible-if clippy lints from concurrent changes. Pipeline: PREFLIGHT -> TOCTOU -> INGEST -> DEPENDENTS -> DOCS -> EMBED -> FINALIZE Token management: add lore token set/show commands with config file storage
1507 lines
47 KiB
Rust
1507 lines
47 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,
|
|
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",
|
|
],
|
|
),
|
|
(
|
|
"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<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.
|
|
///
|
|
/// 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
|
|
/// 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::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);
|
|
}
|
|
|
|
// ---- 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."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|