use std::collections::HashSet;
use std::fs;
use std::os::unix::fs::PermissionsExt;
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
}
}
fn collect_path_commands() -> HashSet<String> {
let path = std::env::var("PATH").unwrap_or_default();
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
}
pub fn find_correction(cmd: &str) -> Option<String> {
let threshold = correction_threshold(cmd.len());
let commands = collect_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);
}
}