argot_cmd/resolver/mod.rs
1//! String-to-command resolution with prefix and ambiguity detection.
2//!
3//! The resolver implements a three-phase algorithm:
4//!
5//! 1. **Normalize** — trim whitespace and lowercase the input.
6//! 2. **Exact match** — check the input against every command's canonical
7//! name, aliases, and spellings. Return immediately if exactly one matches.
8//! 3. **Prefix match** — check which commands have at least one matchable
9//! string that *starts with* the normalized input. If exactly one command
10//! matches, return it. If more than one matches, return
11//! [`ResolveError::Ambiguous`]. If none match, return
12//! [`ResolveError::Unknown`].
13//!
14//! This algorithm allows users (and agents) to type unambiguous prefixes like
15//! `dep` instead of `deploy` while still producing clear errors when a prefix
16//! is shared by multiple commands.
17//!
18//! When a command cannot be found, the resolver also computes up to three
19//! "did you mean?" suggestions based on Levenshtein edit distance (≤ 2) or
20//! substring containment, and attaches them to the [`ResolveError::Unknown`]
21//! variant.
22//!
23//! # Example
24//!
25//! ```
26//! # use argot_cmd::{Command, Resolver, ResolveError};
27//! let cmds = vec![
28//! Command::builder("list").alias("ls").build().unwrap(),
29//! Command::builder("log").build().unwrap(),
30//! ];
31//!
32//! let resolver = Resolver::new(&cmds);
33//!
34//! // Exact canonical
35//! assert_eq!(resolver.resolve("list").unwrap().canonical, "list");
36//! // Exact alias
37//! assert_eq!(resolver.resolve("ls").unwrap().canonical, "list");
38//! // Unambiguous prefix
39//! assert_eq!(resolver.resolve("lo").unwrap().canonical, "log");
40//! // Ambiguous prefix — "l" matches both "list" and "log"
41//! assert!(resolver.resolve("l").is_err());
42//! // Near-miss — "lust" is one edit away from "list"
43//! match resolver.resolve("lust") {
44//! Err(ResolveError::Unknown { suggestions, .. }) => {
45//! assert!(suggestions.contains(&"list".to_string()));
46//! }
47//! _ => unreachable!(),
48//! }
49//! ```
50
51use thiserror::Error;
52
53use crate::model::Command;
54
55/// Errors produced by [`Resolver::resolve`].
56#[derive(Debug, Error, PartialEq)]
57pub enum ResolveError {
58 /// The input did not match any registered command. `suggestions` contains
59 /// up to three canonically close alternatives determined by edit distance.
60 #[error("unknown command: `{input}`")]
61 Unknown {
62 /// The original (untrimmed) input string.
63 input: String,
64 /// Up to three canonical names that are close to `input`. May be empty.
65 suggestions: Vec<String>,
66 },
67 /// The input matched more than one command as a prefix, making it
68 /// ambiguous. The `candidates` field lists the canonical names of the
69 /// matching commands.
70 #[error("ambiguous command \"{input}\": could match {candidates:?}")]
71 Ambiguous {
72 /// The original (untrimmed) input string.
73 input: String,
74 /// Canonical names of all commands that matched the prefix.
75 candidates: Vec<String>,
76 },
77}
78
79/// Resolves a string token to a [`Command`] in a slice, supporting aliases,
80/// spellings, and unambiguous prefix matching.
81///
82/// Create a resolver by passing a slice of commands to [`Resolver::new`], then
83/// call [`Resolver::resolve`] with a raw string token. The returned reference
84/// borrows from the original command slice (lifetime `'a`).
85///
86/// # Examples
87///
88/// ```
89/// # use argot_cmd::{Command, Resolver};
90/// let cmds = vec![
91/// Command::builder("deploy").alias("d").build().unwrap(),
92/// Command::builder("delete").build().unwrap(),
93/// ];
94/// let resolver = Resolver::new(&cmds);
95///
96/// // Exact match via alias
97/// assert_eq!(resolver.resolve("d").unwrap().canonical, "deploy");
98/// // Prefix "del" is unambiguous
99/// assert_eq!(resolver.resolve("del").unwrap().canonical, "delete");
100/// ```
101pub struct Resolver<'a> {
102 commands: &'a [Command],
103}
104
105impl<'a> Resolver<'a> {
106 /// Create a new `Resolver` over the given command slice.
107 ///
108 /// # Arguments
109 ///
110 /// - `commands` — The slice of commands to resolve against. The lifetime
111 /// `'a` is propagated to the references returned by [`Resolver::resolve`].
112 pub fn new(commands: &'a [Command]) -> Self {
113 Self { commands }
114 }
115
116 /// Resolve `input` against the registered commands.
117 ///
118 /// Resolution order:
119 /// 1. Normalize: trim + lowercase.
120 /// 2. Exact match across canonical/aliases/spellings → return immediately.
121 /// 3. Prefix match → return if exactly one command matches; else `Ambiguous`.
122 /// 4. No match → `Unknown`.
123 ///
124 /// # Arguments
125 ///
126 /// - `input` — The raw string to resolve (trimming and lowercasing are
127 /// applied internally).
128 ///
129 /// # Errors
130 ///
131 /// - [`ResolveError::Unknown`] — no command matched `input` exactly or as
132 /// a prefix. The `suggestions` field contains up to three canonical names
133 /// whose edit distance from `input` is ≤ 2, or which contain `input` as
134 /// a substring. May be empty if no close matches exist.
135 /// - [`ResolveError::Ambiguous`] — `input` is a prefix of more than one
136 /// command; the `candidates` field lists their canonical names.
137 ///
138 /// # Examples
139 ///
140 /// ```
141 /// # use argot_cmd::{Command, Resolver, ResolveError};
142 /// let cmds = vec![Command::builder("get").build().unwrap()];
143 /// let resolver = Resolver::new(&cmds);
144 ///
145 /// assert_eq!(resolver.resolve("get").unwrap().canonical, "get");
146 /// assert_eq!(resolver.resolve("GET").unwrap().canonical, "get"); // case-insensitive
147 /// assert!(matches!(resolver.resolve("xyz"), Err(ResolveError::Unknown { .. })));
148 /// ```
149 pub fn resolve(&self, input: &str) -> Result<&'a Command, ResolveError> {
150 let normalized = input.trim().to_lowercase();
151
152 if normalized.is_empty() {
153 return Err(ResolveError::Unknown {
154 input: input.to_string(),
155 suggestions: vec![],
156 });
157 }
158
159 // 1. Exact match
160 for cmd in self.commands {
161 if cmd.matchable_strings().contains(&normalized) {
162 return Ok(cmd);
163 }
164 }
165
166 // 2. Prefix match — spellings are intentionally excluded here so they
167 // do not contribute to ambiguity candidates and never appear in
168 // "did you mean?" suggestions. Only canonical name and aliases are
169 // eligible for prefix matching.
170 let matches: Vec<&'a Command> = self
171 .commands
172 .iter()
173 .filter(|cmd| {
174 cmd.prefix_matchable_strings()
175 .iter()
176 .any(|s| s.starts_with(&normalized))
177 })
178 .collect();
179
180 match matches.len() {
181 0 => {
182 // Compute "did you mean?" suggestions — canonical names within
183 // edit distance 2 or containing the normalized input as a substring.
184 let mut suggestions: Vec<(String, usize)> = self
185 .commands
186 .iter()
187 .filter_map(|cmd| {
188 let dist = edit_distance(&normalized, &cmd.canonical.to_lowercase());
189 if dist <= 2 || cmd.canonical.to_lowercase().contains(&normalized) {
190 Some((cmd.canonical.clone(), dist))
191 } else {
192 None
193 }
194 })
195 .collect();
196 suggestions.sort_by_key(|(_, d)| *d);
197 let suggestions: Vec<String> =
198 suggestions.into_iter().take(3).map(|(s, _)| s).collect();
199 Err(ResolveError::Unknown {
200 input: input.to_string(),
201 suggestions,
202 })
203 }
204 1 => Ok(matches[0]),
205 _ => Err(ResolveError::Ambiguous {
206 input: input.to_string(),
207 candidates: matches.iter().map(|c| c.canonical.clone()).collect(),
208 }),
209 }
210 }
211}
212
213/// Compute the Levenshtein edit distance between two strings.
214fn edit_distance(a: &str, b: &str) -> usize {
215 let a: Vec<char> = a.chars().collect();
216 let b: Vec<char> = b.chars().collect();
217 let (la, lb) = (a.len(), b.len());
218 let mut dp = vec![vec![0usize; lb + 1]; la + 1];
219 for (i, row) in dp.iter_mut().enumerate().take(la + 1) {
220 row[0] = i;
221 }
222 for (j, cell) in dp[0].iter_mut().enumerate().take(lb + 1) {
223 *cell = j;
224 }
225 for i in 1..=la {
226 for j in 1..=lb {
227 dp[i][j] = if a[i - 1] == b[j - 1] {
228 dp[i - 1][j - 1]
229 } else {
230 1 + dp[i - 1][j - 1].min(dp[i - 1][j]).min(dp[i][j - 1])
231 };
232 }
233 }
234 dp[la][lb]
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::model::Command;
241
242 fn cmds() -> Vec<Command> {
243 vec![
244 Command::builder("list")
245 .alias("ls")
246 .spelling("LIST")
247 .build()
248 .unwrap(),
249 Command::builder("log").build().unwrap(),
250 Command::builder("get").build().unwrap(),
251 ]
252 }
253
254 struct TestCase {
255 name: &'static str,
256 input: &'static str,
257 expected_canonical: Option<&'static str>,
258 expect_ambiguous: bool,
259 expect_unknown: bool,
260 }
261
262 #[test]
263 fn test_resolve() {
264 let commands = cmds();
265 let resolver = Resolver::new(&commands);
266
267 let cases = vec![
268 TestCase {
269 name: "exact canonical",
270 input: "list",
271 expected_canonical: Some("list"),
272 expect_ambiguous: false,
273 expect_unknown: false,
274 },
275 TestCase {
276 name: "exact alias",
277 input: "ls",
278 expected_canonical: Some("list"),
279 expect_ambiguous: false,
280 expect_unknown: false,
281 },
282 TestCase {
283 name: "exact spelling (uppercase normalized)",
284 input: "LIST",
285 expected_canonical: Some("list"),
286 expect_ambiguous: false,
287 expect_unknown: false,
288 },
289 TestCase {
290 name: "case insensitive canonical",
291 input: "GET",
292 expected_canonical: Some("get"),
293 expect_ambiguous: false,
294 expect_unknown: false,
295 },
296 TestCase {
297 name: "unambiguous prefix",
298 input: "ge",
299 expected_canonical: Some("get"),
300 expect_ambiguous: false,
301 expect_unknown: false,
302 },
303 TestCase {
304 name: "ambiguous prefix (list + log share 'l')",
305 input: "l",
306 expected_canonical: None,
307 expect_ambiguous: true,
308 expect_unknown: false,
309 },
310 TestCase {
311 name: "unknown",
312 input: "xyz",
313 expected_canonical: None,
314 expect_ambiguous: false,
315 expect_unknown: true,
316 },
317 TestCase {
318 name: "empty input unknown",
319 input: "",
320 expected_canonical: None,
321 expect_ambiguous: false,
322 expect_unknown: true,
323 },
324 ];
325
326 for tc in &cases {
327 let result = resolver.resolve(tc.input);
328 match result {
329 Ok(cmd) => {
330 assert!(
331 tc.expected_canonical.is_some(),
332 "case '{}': expected error but got Ok({})",
333 tc.name,
334 cmd.canonical
335 );
336 assert_eq!(
337 cmd.canonical,
338 tc.expected_canonical.unwrap(),
339 "case '{}'",
340 tc.name
341 );
342 }
343 Err(ResolveError::Ambiguous { .. }) => {
344 assert!(
345 tc.expect_ambiguous,
346 "case '{}': unexpected Ambiguous",
347 tc.name
348 );
349 }
350 Err(ResolveError::Unknown { .. }) => {
351 assert!(tc.expect_unknown, "case '{}': unexpected Unknown", tc.name);
352 }
353 }
354 }
355 }
356
357 #[test]
358 fn test_ambiguous_candidates_are_canonicals() {
359 let commands = cmds();
360 let resolver = Resolver::new(&commands);
361 match resolver.resolve("l") {
362 Err(ResolveError::Ambiguous { candidates, .. }) => {
363 assert!(candidates.contains(&"list".to_string()));
364 assert!(candidates.contains(&"log".to_string()));
365 }
366 other => panic!("expected Ambiguous, got {:?}", other),
367 }
368 }
369
370 #[test]
371 fn test_unknown_with_suggestions() {
372 let commands = cmds(); // list / log / get
373 let resolver = Resolver::new(&commands);
374 // "lust" is close to "list" (edit distance 1 after normalization)
375 match resolver.resolve("lust") {
376 Err(ResolveError::Unknown { suggestions, .. }) => {
377 assert!(
378 suggestions.contains(&"list".to_string()),
379 "expected 'list' in suggestions, got {:?}",
380 suggestions
381 );
382 }
383 other => panic!("expected Unknown, got {:?}", other),
384 }
385 }
386
387 #[test]
388 fn test_unknown_no_suggestions_for_gibberish() {
389 let commands = cmds();
390 let resolver = Resolver::new(&commands);
391 match resolver.resolve("xyzzy") {
392 Err(ResolveError::Unknown { suggestions, .. }) => {
393 assert!(
394 suggestions.is_empty(),
395 "expected no suggestions for gibberish, got {:?}",
396 suggestions
397 );
398 }
399 other => panic!("expected Unknown, got {:?}", other),
400 }
401 }
402
403 #[test]
404 fn test_spelling_resolves_to_canonical() {
405 let cmds = vec![Command::builder("deploy")
406 .alias("release")
407 .spelling("deply")
408 .build()
409 .unwrap()];
410 let resolver = Resolver::new(&cmds);
411
412 // Canonical
413 assert_eq!(resolver.resolve("deploy").unwrap().canonical, "deploy");
414 // Alias (official)
415 assert_eq!(resolver.resolve("release").unwrap().canonical, "deploy");
416 // Spelling (silent typo correction)
417 assert_eq!(resolver.resolve("deply").unwrap().canonical, "deploy");
418 }
419
420 #[test]
421 fn test_spelling_not_shown_in_aliases_field() {
422 let cmd = Command::builder("deploy")
423 .alias("release")
424 .spelling("deply")
425 .build()
426 .unwrap();
427
428 assert!(cmd.aliases.contains(&"release".to_string()));
429 assert!(!cmd.aliases.contains(&"deply".to_string()));
430 assert!(cmd.spellings.contains(&"deply".to_string()));
431 }
432
433 #[test]
434 fn test_spelling_not_in_ambiguity_candidates() {
435 // Spellings should not be surfaced in "did you mean" candidates.
436 let cmds = vec![
437 Command::builder("deploy")
438 .spelling("deply")
439 .build()
440 .unwrap(),
441 Command::builder("delete").build().unwrap(),
442 ];
443 let resolver = Resolver::new(&cmds);
444
445 match resolver.resolve("del") {
446 Err(ResolveError::Ambiguous { candidates, .. }) => {
447 // "deply" (a spelling) should not appear as a candidate
448 assert!(!candidates.contains(&"deply".to_string()));
449 }
450 other => {
451 // May resolve unambiguously if "delete" is the only prefix match — also fine
452 let _ = other;
453 }
454 }
455 }
456}