mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
feat(alias): add typo suggestions for unknown step commands (#1363)
When the user types an unknown step command or alias name, suggest the
closest match using Jaro similarity (same algorithm and 0.7 threshold as
clap). Checks both built-in step commands and configured aliases.
Also fixes error message styling per output guidelines: bold for names
instead of quotes, no second-person pronouns ("perhaps" instead of "did
you mean").
Examples:
- `wt step comit` → `Unknown step command comit — perhaps commit?`
- `wt step deplyo` (with alias `deploy`) → `Unknown step command deplyo
— perhaps deploy?`
- `wt step zzz` (no close match) → `Unknown alias zzz (available:
deploy, hello)`
> _This was written by Claude Code on behalf of max-sixty_
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,3 +9,6 @@ ede = "ede" # Part of commit hashes like ede2fd3
|
||||
mis = "mis"
|
||||
# "PNGs" is correct (Portable Network Graphics plural)
|
||||
PNGs = "PNGs"
|
||||
# Intentional typos in tests for "did you mean?" suggestions
|
||||
deplyo = "deplyo"
|
||||
comit = "comit"
|
||||
|
||||
Generated
+1
@@ -3229,6 +3229,7 @@ dependencies = [
|
||||
"signal-hook 0.4.3",
|
||||
"similar",
|
||||
"skim",
|
||||
"strsim",
|
||||
"strum",
|
||||
"supports-hyperlinks",
|
||||
"synoptic",
|
||||
|
||||
@@ -86,6 +86,7 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
shell-escape = "0.1"
|
||||
shellexpand = "3.1"
|
||||
strsim = "0.11"
|
||||
strum = { version = "0.28", features = ["derive"] }
|
||||
synoptic = "2"
|
||||
terminal_size = "0.4"
|
||||
|
||||
+69
-12
@@ -126,6 +126,20 @@ fn alias_needs_approval(
|
||||
Some(project_template.clone())
|
||||
}
|
||||
|
||||
/// Find the closest match for `input` among `candidates` using Jaro similarity.
|
||||
///
|
||||
/// Returns `Some(match)` if a candidate is sufficiently similar (threshold 0.7),
|
||||
/// `None` otherwise. Uses `jaro` (not `jaro_winkler`) with the same threshold
|
||||
/// as clap — see clap GH #4660 for why.
|
||||
fn find_closest_match<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
|
||||
candidates
|
||||
.iter()
|
||||
.map(|c| (*c, strsim::jaro(input, c)))
|
||||
.filter(|(_, score)| *score > 0.7)
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(name, _)| name)
|
||||
}
|
||||
|
||||
/// Run a configured alias by name.
|
||||
///
|
||||
/// Looks up the alias in merged config (project config + user config),
|
||||
@@ -145,25 +159,42 @@ pub fn step_alias(opts: AliasOptions) -> anyhow::Result<()> {
|
||||
aliases.extend(user_config.aliases(project_id.as_deref()));
|
||||
|
||||
let Some(template) = aliases.get(&opts.name) else {
|
||||
// Filter out aliases that shadow built-in step commands — they're
|
||||
// unreachable since clap dispatches to the built-in first.
|
||||
let available: Vec<_> = aliases
|
||||
// Check for typos against both built-in commands and aliases
|
||||
let mut all_candidates: Vec<&str> = BUILTIN_STEP_COMMANDS.to_vec();
|
||||
// Only include non-shadowed aliases as candidates
|
||||
let available_aliases: Vec<_> = aliases
|
||||
.keys()
|
||||
.filter(|k| !BUILTIN_STEP_COMMANDS.contains(&k.as_str()))
|
||||
.map(|k| k.as_str())
|
||||
.collect();
|
||||
if available.is_empty() {
|
||||
all_candidates.extend(&available_aliases);
|
||||
|
||||
if let Some(closest) = find_closest_match(&opts.name, &all_candidates) {
|
||||
bail!(
|
||||
"Unknown step command '{}' (no aliases configured)",
|
||||
opts.name,
|
||||
);
|
||||
} else {
|
||||
bail!(
|
||||
"Unknown alias '{}' (available: {})",
|
||||
opts.name,
|
||||
available.join(", "),
|
||||
"{}",
|
||||
cformat!(
|
||||
"Unknown step command <bold>{}</> — perhaps <bold>{closest}</>?",
|
||||
opts.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
if available_aliases.is_empty() {
|
||||
bail!(
|
||||
"{}",
|
||||
cformat!(
|
||||
"Unknown step command <bold>{}</> (no aliases configured)",
|
||||
opts.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
bail!(
|
||||
"{}",
|
||||
cformat!(
|
||||
"Unknown alias <bold>{}</> (available: {})",
|
||||
opts.name,
|
||||
available_aliases.join(", "),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// Check if this alias needs project-config approval (skip for --dry-run)
|
||||
@@ -340,6 +371,32 @@ mod tests {
|
||||
assert_eq!(opts.vars, vec![("key".into(), String::new())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_closest_match_typo() {
|
||||
assert_eq!(
|
||||
find_closest_match("deplyo", &["deploy", "hello"]),
|
||||
Some("deploy"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_closest_match_missing_letter() {
|
||||
assert_eq!(
|
||||
find_closest_match("comit", &["commit", "squash", "push", "rebase"]),
|
||||
Some("commit"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_closest_match_no_match() {
|
||||
assert_eq!(find_closest_match("zzz", &["deploy", "hello"]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_closest_match_empty_candidates() {
|
||||
assert_eq!(find_closest_match("deploy", &[]), None);
|
||||
}
|
||||
|
||||
/// Verify BUILTIN_STEP_COMMANDS stays in sync with the actual StepCommand variants.
|
||||
///
|
||||
/// If a new step subcommand is added without updating BUILTIN_STEP_COMMANDS,
|
||||
|
||||
@@ -77,6 +77,30 @@ deploy = "make deploy"
|
||||
));
|
||||
}
|
||||
|
||||
/// Typo in alias name suggests the closest match
|
||||
#[rstest]
|
||||
fn test_step_alias_did_you_mean(mut repo: TestRepo) {
|
||||
repo.write_project_config(
|
||||
r#"
|
||||
[aliases]
|
||||
deploy = "make deploy"
|
||||
hello = "echo Hello"
|
||||
"#,
|
||||
);
|
||||
repo.commit("Add alias config");
|
||||
let feature_path = repo.add_worktree("feature");
|
||||
|
||||
let settings = setup_snapshot_settings(&repo);
|
||||
let _guard = settings.bind_to_scope();
|
||||
|
||||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||||
&repo,
|
||||
"step",
|
||||
&["deplyo"],
|
||||
Some(&feature_path),
|
||||
));
|
||||
}
|
||||
|
||||
/// Unknown step command with no aliases configured
|
||||
#[rstest]
|
||||
fn test_step_alias_unknown_no_aliases(mut repo: TestRepo) {
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
---
|
||||
source: tests/integration_tests/step_alias.rs
|
||||
info:
|
||||
program: wt
|
||||
args:
|
||||
- step
|
||||
- deplyo
|
||||
env:
|
||||
APPDATA: "[TEST_CONFIG_HOME]"
|
||||
CLICOLOR_FORCE: "1"
|
||||
COLUMNS: "500"
|
||||
GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z"
|
||||
GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z"
|
||||
GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]"
|
||||
GIT_CONFIG_SYSTEM: /dev/null
|
||||
GIT_EDITOR: ""
|
||||
GIT_TERMINAL_PROMPT: "0"
|
||||
HOME: "[TEST_HOME]"
|
||||
LANG: C
|
||||
LC_ALL: C
|
||||
MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]"
|
||||
NO_COLOR: ""
|
||||
PATH: "[PATH]"
|
||||
PSModulePath: ""
|
||||
RUST_LOG: warn
|
||||
SHELL: ""
|
||||
TERM: alacritty
|
||||
USERPROFILE: "[TEST_HOME]"
|
||||
WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]"
|
||||
WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]"
|
||||
WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]"
|
||||
WORKTRUNK_TEST_CLAUDE_INSTALLED: "0"
|
||||
WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1"
|
||||
WORKTRUNK_TEST_EPOCH: "1735776000"
|
||||
WORKTRUNK_TEST_NUSHELL_ENV: "0"
|
||||
WORKTRUNK_TEST_POWERSHELL_ENV: "0"
|
||||
WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1"
|
||||
XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]"
|
||||
---
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
[31m✗[39m [31mUnknown step command [1mdeplyo[22m — perhaps [1mdeploy[22m?[39m
|
||||
+1
-1
@@ -42,4 +42,4 @@ exit_code: 1
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
[31m✗[39m [31mUnknown alias 'nonexistent' (available: hello)[39m
|
||||
[31m✗[39m [31mUnknown alias [1mnonexistent[22m (available: hello)[39m
|
||||
|
||||
+1
-1
@@ -42,4 +42,4 @@ exit_code: 1
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
[31m✗[39m [31mUnknown step command 'deploy' (no aliases configured)[39m
|
||||
[31m✗[39m [31mUnknown step command [1mdeploy[22m (no aliases configured)[39m
|
||||
|
||||
+1
-1
@@ -42,4 +42,4 @@ exit_code: 1
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
[31m✗[39m [31mUnknown alias 'nonexistent' (available: deploy, hello)[39m
|
||||
[31m✗[39m [31mUnknown alias [1mnonexistent[22m (available: deploy, hello)[39m
|
||||
|
||||
Reference in New Issue
Block a user