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:
Maximilian Roos
2026-03-08 01:02:43 -08:00
committed by GitHub
parent 486a8195d2
commit 64c70ef869
9 changed files with 146 additions and 15 deletions
+3
View File
@@ -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
View File
@@ -3229,6 +3229,7 @@ dependencies = [
"signal-hook 0.4.3",
"similar",
"skim",
"strsim",
"strum",
"supports-hyperlinks",
"synoptic",
+1
View File
@@ -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
View File
@@ -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,
+24
View File
@@ -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) {
@@ -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 -----
✗ Unknown step command deplyo — perhaps deploy?
@@ -42,4 +42,4 @@ exit_code: 1
----- stdout -----
----- stderr -----
✗ Unknown alias 'nonexistent' (available: hello)
✗ Unknown alias nonexistent (available: hello)
@@ -42,4 +42,4 @@ exit_code: 1
----- stdout -----
----- stderr -----
✗ Unknown step command 'deploy' (no aliases configured)
✗ Unknown step command deploy (no aliases configured)
@@ -42,4 +42,4 @@ exit_code: 1
----- stdout -----
----- stderr -----
✗ Unknown alias 'nonexistent' (available: deploy, hello)
✗ Unknown alias nonexistent (available: deploy, hello)