diff --git a/e2e/cli/test_unknown_command_suggestion b/e2e/cli/test_unknown_command_suggestion new file mode 100644 index 0000000000..8e282df087 --- /dev/null +++ b/e2e/cli/test_unknown_command_suggestion @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Test that mistyped CLI commands suggest the correct command instead of +# showing a confusing "no tasks defined" error. +# See: https://github.com/jdx/mise/discussions/8278 + +set -euo pipefail +export RUST_BACKTRACE=0 +export MISE_FRIENDLY_ERROR=1 + +# Test: Typo of a known command should suggest the correct command +assert_fail "mise bin-path" "Did you mean" +assert_fail "mise bin-path" "bin-paths" + +# Test: Another typo should suggest the correct command +assert_fail "mise instal" "install" + +# Test: Should say "unknown command" not "no tasks defined" for command typos +assert_fail "mise bin-path" "unknown command" diff --git a/src/task/task_list.rs b/src/task/task_list.rs index 385512dde6..3d07b4cc98 100644 --- a/src/task/task_list.rs +++ b/src/task/task_list.rs @@ -61,9 +61,42 @@ fn validate_monorepo_setup(config: &Arc) -> Result<()> { Ok(()) } +/// Check if a name is similar to any known CLI subcommands using fuzzy matching +fn suggest_similar_commands(name: &str) -> Vec { + use clap::CommandFactory; + let cmd = crate::cli::Cli::command(); + let matcher = SkimMatcherV2::default().use_cache(true).smart_case(); + cmd.get_subcommands() + .flat_map(|s| std::iter::once(s.get_name()).chain(s.get_all_aliases())) + .filter_map(|subcmd| { + matcher + .fuzzy_match(subcmd, name) + .filter(|&score| score > 0) + .map(|score| (score, subcmd.to_string())) + }) + .sorted_by_key(|(score, _)| -1 * *score) + .take(3) + .map(|(_, subcmd)| subcmd) + .collect() +} + /// Show an error when a task is not found, with helpful suggestions async fn err_no_task(config: &Config, name: &str) -> Result<()> { + // Check early if the name looks like a mistyped CLI subcommand + let similar_cmds = suggest_similar_commands(name); + if config.tasks().await.is_ok_and(|t| t.is_empty()) { + // If the name matches a CLI subcommand closely, suggest that instead of + // the confusing "no tasks defined" message + if !similar_cmds.is_empty() { + let mut err_msg = format!("unknown command: {}", style::ered(name)); + err_msg.push_str("\n\nDid you mean:"); + for cmd_name in &similar_cmds { + err_msg.push_str(&format!("\n mise {cmd_name}")); + } + bail!(err_msg); + } + // Check if there are any untrusted config files in the current directory // that might contain tasks if let Some(cwd) = &*dirs::CWD { @@ -149,6 +182,13 @@ async fn err_no_task(config: &Config, name: &str) -> Result<()> { } } + if !similar_cmds.is_empty() { + err_msg.push_str("\n\nDid you mean the command:"); + for cmd_name in &similar_cmds { + err_msg.push_str(&format!("\n mise {cmd_name}")); + } + } + bail!(err_msg); }