use thiserror::Error;
use crate::model::Command;
#[derive(Debug, Error, PartialEq)]
pub enum ResolveError {
#[error("unknown command: `{input}`")]
Unknown {
input: String,
suggestions: Vec<String>,
},
#[error("ambiguous command \"{input}\": could match {candidates:?}")]
Ambiguous {
input: String,
candidates: Vec<String>,
},
}
pub struct Resolver<'a> {
commands: &'a [Command],
}
impl<'a> Resolver<'a> {
pub fn new(commands: &'a [Command]) -> Self {
Self { commands }
}
pub fn resolve(&self, input: &str) -> Result<&'a Command, ResolveError> {
let normalized = input.trim().to_lowercase();
if normalized.is_empty() {
return Err(ResolveError::Unknown {
input: input.to_string(),
suggestions: vec![],
});
}
for cmd in self.commands {
if cmd.matchable_strings().contains(&normalized) {
return Ok(cmd);
}
}
let matches: Vec<&'a Command> = self
.commands
.iter()
.filter(|cmd| {
cmd.prefix_matchable_strings()
.iter()
.any(|s| s.starts_with(&normalized))
})
.collect();
match matches.len() {
0 => {
let mut suggestions: Vec<(String, usize)> = self
.commands
.iter()
.filter_map(|cmd| {
let dist = edit_distance(&normalized, &cmd.canonical.to_lowercase());
if dist <= 2 || cmd.canonical.to_lowercase().contains(&normalized) {
Some((cmd.canonical.clone(), dist))
} else {
None
}
})
.collect();
suggestions.sort_by_key(|(_, d)| *d);
let suggestions: Vec<String> =
suggestions.into_iter().take(3).map(|(s, _)| s).collect();
Err(ResolveError::Unknown {
input: input.to_string(),
suggestions,
})
}
1 => Ok(matches[0]),
_ => Err(ResolveError::Ambiguous {
input: input.to_string(),
candidates: matches.iter().map(|c| c.canonical.clone()).collect(),
}),
}
}
}
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let (la, lb) = (a.len(), b.len());
let mut dp = vec![vec![0usize; lb + 1]; la + 1];
for (i, row) in dp.iter_mut().enumerate().take(la + 1) {
row[0] = i;
}
for (j, cell) in dp[0].iter_mut().enumerate().take(lb + 1) {
*cell = j;
}
for i in 1..=la {
for j in 1..=lb {
dp[i][j] = if a[i - 1] == b[j - 1] {
dp[i - 1][j - 1]
} else {
1 + dp[i - 1][j - 1].min(dp[i - 1][j]).min(dp[i][j - 1])
};
}
}
dp[la][lb]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Command;
fn cmds() -> Vec<Command> {
vec![
Command::builder("list")
.alias("ls")
.spelling("LIST")
.build()
.unwrap(),
Command::builder("log").build().unwrap(),
Command::builder("get").build().unwrap(),
]
}
struct TestCase {
name: &'static str,
input: &'static str,
expected_canonical: Option<&'static str>,
expect_ambiguous: bool,
expect_unknown: bool,
}
#[test]
fn test_resolve() {
let commands = cmds();
let resolver = Resolver::new(&commands);
let cases = vec![
TestCase {
name: "exact canonical",
input: "list",
expected_canonical: Some("list"),
expect_ambiguous: false,
expect_unknown: false,
},
TestCase {
name: "exact alias",
input: "ls",
expected_canonical: Some("list"),
expect_ambiguous: false,
expect_unknown: false,
},
TestCase {
name: "exact spelling (uppercase normalized)",
input: "LIST",
expected_canonical: Some("list"),
expect_ambiguous: false,
expect_unknown: false,
},
TestCase {
name: "case insensitive canonical",
input: "GET",
expected_canonical: Some("get"),
expect_ambiguous: false,
expect_unknown: false,
},
TestCase {
name: "unambiguous prefix",
input: "ge",
expected_canonical: Some("get"),
expect_ambiguous: false,
expect_unknown: false,
},
TestCase {
name: "ambiguous prefix (list + log share 'l')",
input: "l",
expected_canonical: None,
expect_ambiguous: true,
expect_unknown: false,
},
TestCase {
name: "unknown",
input: "xyz",
expected_canonical: None,
expect_ambiguous: false,
expect_unknown: true,
},
TestCase {
name: "empty input unknown",
input: "",
expected_canonical: None,
expect_ambiguous: false,
expect_unknown: true,
},
];
for tc in &cases {
let result = resolver.resolve(tc.input);
match result {
Ok(cmd) => {
assert!(
tc.expected_canonical.is_some(),
"case '{}': expected error but got Ok({})",
tc.name,
cmd.canonical
);
assert_eq!(
cmd.canonical,
tc.expected_canonical.unwrap(),
"case '{}'",
tc.name
);
}
Err(ResolveError::Ambiguous { .. }) => {
assert!(
tc.expect_ambiguous,
"case '{}': unexpected Ambiguous",
tc.name
);
}
Err(ResolveError::Unknown { .. }) => {
assert!(tc.expect_unknown, "case '{}': unexpected Unknown", tc.name);
}
}
}
}
#[test]
fn test_ambiguous_candidates_are_canonicals() {
let commands = cmds();
let resolver = Resolver::new(&commands);
match resolver.resolve("l") {
Err(ResolveError::Ambiguous { candidates, .. }) => {
assert!(candidates.contains(&"list".to_string()));
assert!(candidates.contains(&"log".to_string()));
}
other => panic!("expected Ambiguous, got {:?}", other),
}
}
#[test]
fn test_unknown_with_suggestions() {
let commands = cmds(); let resolver = Resolver::new(&commands);
match resolver.resolve("lust") {
Err(ResolveError::Unknown { suggestions, .. }) => {
assert!(
suggestions.contains(&"list".to_string()),
"expected 'list' in suggestions, got {:?}",
suggestions
);
}
other => panic!("expected Unknown, got {:?}", other),
}
}
#[test]
fn test_unknown_no_suggestions_for_gibberish() {
let commands = cmds();
let resolver = Resolver::new(&commands);
match resolver.resolve("xyzzy") {
Err(ResolveError::Unknown { suggestions, .. }) => {
assert!(
suggestions.is_empty(),
"expected no suggestions for gibberish, got {:?}",
suggestions
);
}
other => panic!("expected Unknown, got {:?}", other),
}
}
#[test]
fn test_spelling_resolves_to_canonical() {
let cmds = vec![Command::builder("deploy")
.alias("release")
.spelling("deply")
.build()
.unwrap()];
let resolver = Resolver::new(&cmds);
assert_eq!(resolver.resolve("deploy").unwrap().canonical, "deploy");
assert_eq!(resolver.resolve("release").unwrap().canonical, "deploy");
assert_eq!(resolver.resolve("deply").unwrap().canonical, "deploy");
}
#[test]
fn test_spelling_not_shown_in_aliases_field() {
let cmd = Command::builder("deploy")
.alias("release")
.spelling("deply")
.build()
.unwrap();
assert!(cmd.aliases.contains(&"release".to_string()));
assert!(!cmd.aliases.contains(&"deply".to_string()));
assert!(cmd.spellings.contains(&"deply".to_string()));
}
#[test]
fn test_spelling_not_in_ambiguity_candidates() {
let cmds = vec![
Command::builder("deploy")
.spelling("deply")
.build()
.unwrap(),
Command::builder("delete").build().unwrap(),
];
let resolver = Resolver::new(&cmds);
match resolver.resolve("del") {
Err(ResolveError::Ambiguous { candidates, .. }) => {
assert!(!candidates.contains(&"deply".to_string()));
}
other => {
let _ = other;
}
}
}
}