use std::collections::HashSet;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::sync::Mutex;
use std::time::Instant;
fn damerau_levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let m = a.len();
let n = b.len();
let mut dp = vec![vec![0usize; n + 1]; m + 1];
for (i, row) in dp.iter_mut().enumerate() {
row[0] = i;
}
for (j, val) in dp[0].iter_mut().enumerate() {
*val = j;
}
for i in 1..=m {
for j in 1..=n {
let cost = usize::from(a[i - 1] != b[j - 1]);
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
dp[i][j] = dp[i][j].min(dp[i - 2][j - 2] + cost);
}
}
}
dp[m][n]
}
pub fn is_command_like(token: &str) -> bool {
token.len() >= 2
&& token
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
fn correction_threshold(cmd_len: usize) -> usize {
if cmd_len <= 3 {
1
} else if cmd_len <= 7 {
2
} else {
3
}
}
const COMMANDS_CACHE_TTL_SECS: u64 = 60;
struct CommandCache {
commands: HashSet<String>,
cached_at: Instant,
path_env: String,
}
static PATH_COMMANDS_CACHE: Mutex<Option<CommandCache>> = Mutex::new(None);
fn collect_path_commands_raw(path: &str) -> HashSet<String> {
let mut commands = HashSet::new();
for dir in path.split(':') {
let Ok(entries) = fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(metadata) = entry.metadata() else {
continue;
};
if metadata.is_dir() {
continue;
}
if metadata.permissions().mode() & 0o111 == 0 {
continue;
}
if let Some(name) = entry.file_name().to_str() {
commands.insert(name.to_string());
}
}
}
commands
}
fn get_path_commands() -> HashSet<String> {
let current_path = std::env::var("PATH").unwrap_or_default();
let now = Instant::now();
let Ok(mut cache) = PATH_COMMANDS_CACHE.lock() else {
return collect_path_commands_raw(¤t_path);
};
if let Some(ref c) = *cache {
let ttl_valid = now.duration_since(c.cached_at).as_secs() < COMMANDS_CACHE_TTL_SECS;
let path_unchanged = c.path_env == current_path;
if ttl_valid && path_unchanged {
return c.commands.clone();
}
}
let commands = collect_path_commands_raw(¤t_path);
*cache = Some(CommandCache {
commands: commands.clone(),
cached_at: now,
path_env: current_path,
});
commands
}
pub fn find_correction(cmd: &str) -> Option<String> {
let threshold = correction_threshold(cmd.len());
let commands = get_path_commands();
let mut best: Option<(usize, String)> = None;
for candidate in &commands {
let dist = damerau_levenshtein(cmd, candidate);
if dist > threshold {
continue;
}
let better = match &best {
None => true,
Some((best_dist, best_name)) => {
dist < *best_dist || (dist == *best_dist && candidate < best_name)
}
};
if better {
best = Some((dist, candidate.clone()));
}
}
best.map(|(_, name)| name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distance_same() {
assert_eq!(damerau_levenshtein("git", "git"), 0);
}
#[test]
fn distance_transposition() {
assert_eq!(damerau_levenshtein("gti", "git"), 1);
}
#[test]
fn distance_substitution() {
assert_eq!(damerau_levenshtein("gut", "git"), 1);
}
#[test]
fn distance_insertion() {
assert_eq!(damerau_levenshtein("gt", "git"), 1);
}
#[test]
fn distance_deletion() {
assert_eq!(damerau_levenshtein("grpe", "grep"), 1);
}
#[test]
fn is_command_like_valid() {
assert!(is_command_like("git"));
assert!(is_command_like("ls"));
assert!(is_command_like("cargo-build"));
assert!(is_command_like("node.js"));
}
#[test]
fn is_command_like_invalid() {
assert!(!is_command_like("g"));
assert!(!is_command_like(""));
assert!(!is_command_like("エラー"));
assert!(!is_command_like("hello world"));
}
#[test]
fn find_correction_suggests_command() {
if which::which("git").is_ok() {
let result = find_correction("gti");
assert!(result.is_some());
}
}
#[test]
fn find_correction_no_match() {
assert_eq!(find_correction("zzzjarvishtest"), None);
}
}