Skip to main content

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}