Skip to main content

argot_cmd/query/
mod.rs

1//! Central command registry with lookup and search operations.
2//!
3//! [`Registry`] is the primary store for the command tree in an argot
4//! application. It owns a `Vec<Command>` and exposes several query methods:
5//!
6//! - **[`Registry::get_command`]** — exact lookup by canonical name.
7//! - **[`Registry::get_subcommand`]** — walk a path of canonical names into
8//!   the nested subcommand tree.
9//! - **[`Registry::list_commands`]** — iterate all top-level commands.
10//! - **[`Registry::search`]** — case-insensitive substring search across
11//!   canonical name, summary, and description.
12//! - **[`Registry::fuzzy_search`]** — fuzzy (skim) search returning results
13//!   sorted by score (best match first). Requires the `fuzzy` feature.
14//! - **[`Registry::to_json`]** — serialize the command tree to pretty-printed
15//!   JSON (handler closures are excluded).
16//!
17//! Pass `registry.commands()` to [`crate::Parser::new`] to wire the registry
18//! into the parsing pipeline.
19//!
20//! # Example
21//!
22//! ```
23//! # use argot_cmd::{Command, Registry};
24//! let registry = Registry::new(vec![
25//!     Command::builder("list").summary("List all items").build().unwrap(),
26//!     Command::builder("get").summary("Get a single item").build().unwrap(),
27//! ]);
28//!
29//! assert!(registry.get_command("list").is_some());
30//! assert_eq!(registry.search("item").len(), 2);
31//! ```
32
33#[cfg(feature = "fuzzy")]
34use fuzzy_matcher::skim::SkimMatcherV2;
35#[cfg(feature = "fuzzy")]
36use fuzzy_matcher::FuzzyMatcher;
37use thiserror::Error;
38
39use crate::model::{Command, Example};
40
41/// A command paired with its canonical path from the registry root.
42///
43/// Produced by [`Registry::iter_all_recursive`].
44#[derive(Debug, Clone)]
45pub struct CommandEntry<'a> {
46    /// Canonical names from root to this command, e.g. `["remote", "add"]`.
47    pub path: Vec<String>,
48    /// The command at this path.
49    pub command: &'a Command,
50}
51
52impl<'a> CommandEntry<'a> {
53    /// The canonical name of this command (last element of `path`).
54    ///
55    /// # Examples
56    ///
57    /// ```
58    /// # use argot_cmd::{Command, Registry};
59    /// let registry = Registry::new(vec![
60    ///     Command::builder("remote")
61    ///         .subcommand(Command::builder("add").build().unwrap())
62    ///         .build()
63    ///         .unwrap(),
64    /// ]);
65    /// let entries = registry.iter_all_recursive();
66    /// assert_eq!(entries[0].name(), "remote");
67    /// assert_eq!(entries[1].name(), "add");
68    /// ```
69    pub fn name(&self) -> &str {
70        self.path.last().map(String::as_str).unwrap_or("")
71    }
72
73    /// The full dotted path string, e.g. `"remote.add"`.
74    ///
75    /// # Examples
76    ///
77    /// ```
78    /// # use argot_cmd::{Command, Registry};
79    /// let registry = Registry::new(vec![
80    ///     Command::builder("remote")
81    ///         .subcommand(Command::builder("add").build().unwrap())
82    ///         .build()
83    ///         .unwrap(),
84    /// ]);
85    /// let entries = registry.iter_all_recursive();
86    /// assert_eq!(entries[0].path_str(), "remote");
87    /// assert_eq!(entries[1].path_str(), "remote.add");
88    /// ```
89    pub fn path_str(&self) -> String {
90        self.path.join(".")
91    }
92}
93
94/// Errors produced by [`Registry`] methods.
95#[derive(Debug, Error)]
96pub enum QueryError {
97    /// JSON serialization failed.
98    ///
99    /// Wraps the underlying [`serde_json::Error`].
100    #[error("serialization error: {0}")]
101    Serialization(#[from] serde_json::Error),
102}
103
104/// Owns the registered command tree and provides query/search operations.
105///
106/// Create a `Registry` with [`Registry::new`], passing the fully-built list of
107/// top-level commands. The registry takes ownership of the command list and
108/// makes it available through a variety of lookup and search methods.
109///
110/// # Examples
111///
112/// ```
113/// # use argot_cmd::{Command, Registry};
114/// let registry = Registry::new(vec![
115///     Command::builder("deploy").summary("Deploy the app").build().unwrap(),
116/// ]);
117///
118/// let cmd = registry.get_command("deploy").unwrap();
119/// assert_eq!(cmd.summary, "Deploy the app");
120/// ```
121pub struct Registry {
122    commands: Vec<Command>,
123}
124
125impl Registry {
126    /// Create a new `Registry` owning the given command list.
127    ///
128    /// # Arguments
129    ///
130    /// - `commands` — The top-level command list. Subcommands are nested
131    ///   inside the respective [`Command::subcommands`] fields.
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// # use argot_cmd::{Command, Registry};
137    /// let registry = Registry::new(vec![
138    ///     Command::builder("run").build().unwrap(),
139    /// ]);
140    /// assert_eq!(registry.list_commands().len(), 1);
141    /// ```
142    pub fn new(commands: Vec<Command>) -> Self {
143        Self { commands }
144    }
145
146    /// Append a command to the registry.
147    ///
148    /// Used internally by [`crate::Cli::with_query_support`] to inject the
149    /// built-in `query` meta-command.
150    pub(crate) fn push(&mut self, cmd: Command) {
151        self.commands.push(cmd);
152    }
153
154    /// Borrow the raw command slice (useful for constructing a [`Parser`][crate::parser::Parser]).
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// # use argot_cmd::{Command, Registry, Parser};
160    /// let registry = Registry::new(vec![Command::builder("ping").build().unwrap()]);
161    /// let parser = Parser::new(registry.commands());
162    /// let parsed = parser.parse(&["ping"]).unwrap();
163    /// assert_eq!(parsed.command.canonical, "ping");
164    /// ```
165    pub fn commands(&self) -> &[Command] {
166        &self.commands
167    }
168
169    /// Return references to all top-level commands.
170    ///
171    /// # Examples
172    ///
173    /// ```
174    /// # use argot_cmd::{Command, Registry};
175    /// let registry = Registry::new(vec![
176    ///     Command::builder("a").build().unwrap(),
177    ///     Command::builder("b").build().unwrap(),
178    /// ]);
179    /// assert_eq!(registry.list_commands().len(), 2);
180    /// ```
181    pub fn list_commands(&self) -> Vec<&Command> {
182        self.commands.iter().collect()
183    }
184
185    /// Look up a top-level command by its exact canonical name.
186    ///
187    /// Returns `None` if no command with that canonical name exists. Does not
188    /// match aliases or spellings — use [`crate::Resolver`] for fuzzy/prefix
189    /// matching.
190    ///
191    /// # Arguments
192    ///
193    /// - `canonical` — The exact canonical name to look up.
194    ///
195    /// # Examples
196    ///
197    /// ```
198    /// # use argot_cmd::{Command, Registry};
199    /// let registry = Registry::new(vec![
200    ///     Command::builder("deploy").alias("d").build().unwrap(),
201    /// ]);
202    ///
203    /// assert!(registry.get_command("deploy").is_some());
204    /// assert!(registry.get_command("d").is_none()); // alias, not canonical
205    /// ```
206    pub fn get_command(&self, canonical: &str) -> Option<&Command> {
207        self.commands.iter().find(|c| c.canonical == canonical)
208    }
209
210    /// Walk a path of canonical names into the subcommand tree.
211    ///
212    /// `path = &["remote", "add"]` returns the `add` subcommand of `remote`.
213    /// Each path segment must be an *exact canonical* name at that level of
214    /// the tree.
215    ///
216    /// Returns `None` if any segment fails to match or if `path` is empty.
217    ///
218    /// # Arguments
219    ///
220    /// - `path` — Ordered slice of canonical command names from top-level down.
221    ///
222    /// # Examples
223    ///
224    /// ```
225    /// # use argot_cmd::{Command, Registry};
226    /// let registry = Registry::new(vec![
227    ///     Command::builder("remote")
228    ///         .subcommand(Command::builder("add").build().unwrap())
229    ///         .build()
230    ///         .unwrap(),
231    /// ]);
232    ///
233    /// let sub = registry.get_subcommand(&["remote", "add"]).unwrap();
234    /// assert_eq!(sub.canonical, "add");
235    ///
236    /// assert!(registry.get_subcommand(&[]).is_none());
237    /// assert!(registry.get_subcommand(&["remote", "nope"]).is_none());
238    /// ```
239    pub fn get_subcommand(&self, path: &[&str]) -> Option<&Command> {
240        if path.is_empty() {
241            return None;
242        }
243        let mut current = self.get_command(path[0])?;
244        for &segment in &path[1..] {
245            current = current
246                .subcommands
247                .iter()
248                .find(|c| c.canonical == segment)?;
249        }
250        Some(current)
251    }
252
253    /// Return the examples slice for a top-level command, or `None` if the
254    /// command does not exist.
255    ///
256    /// An empty examples list returns `Some(&[])`.
257    ///
258    /// # Arguments
259    ///
260    /// - `canonical` — The exact canonical name of the command.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// # use argot_cmd::{Command, Example, Registry};
266    /// let registry = Registry::new(vec![
267    ///     Command::builder("run")
268    ///         .example(Example::new("basic run", "myapp run"))
269    ///         .build()
270    ///         .unwrap(),
271    /// ]);
272    ///
273    /// assert_eq!(registry.get_examples("run").unwrap().len(), 1);
274    /// assert!(registry.get_examples("missing").is_none());
275    /// ```
276    pub fn get_examples(&self, canonical: &str) -> Option<&[Example]> {
277        self.get_command(canonical).map(|c| c.examples.as_slice())
278    }
279
280    /// Substring search across canonical name, summary, and description.
281    ///
282    /// The search is case-insensitive. Returns all top-level commands for
283    /// which the query appears in at least one of the three text fields.
284    ///
285    /// # Arguments
286    ///
287    /// - `query` — The substring to search for (case-insensitive).
288    ///
289    /// # Examples
290    ///
291    /// ```
292    /// # use argot_cmd::{Command, Registry};
293    /// let registry = Registry::new(vec![
294    ///     Command::builder("list").summary("List all records").build().unwrap(),
295    ///     Command::builder("get").summary("Get a single record").build().unwrap(),
296    /// ]);
297    ///
298    /// let results = registry.search("record");
299    /// assert_eq!(results.len(), 2);
300    /// assert!(registry.search("zzz").is_empty());
301    /// ```
302    pub fn search(&self, query: &str) -> Vec<&Command> {
303        let q = query.to_lowercase();
304        self.commands
305            .iter()
306            .filter(|c| {
307                c.canonical.to_lowercase().contains(&q)
308                    || c.summary.to_lowercase().contains(&q)
309                    || c.description.to_lowercase().contains(&q)
310            })
311            .collect()
312    }
313
314    /// Fuzzy search across canonical name, summary, and description.
315    ///
316    /// Uses the skim fuzzy-matching algorithm (requires the `fuzzy` feature).
317    /// Returns matches sorted descending by score (best match first).
318    /// Commands that produce no fuzzy match are excluded.
319    ///
320    /// # Arguments
321    ///
322    /// - `query` — The fuzzy query string.
323    ///
324    /// # Examples
325    ///
326    /// ```
327    /// # #[cfg(feature = "fuzzy")] {
328    /// # use argot_cmd::{Command, Registry};
329    /// let registry = Registry::new(vec![
330    ///     Command::builder("deploy").summary("Deploy a service").build().unwrap(),
331    ///     Command::builder("delete").summary("Delete a resource").build().unwrap(),
332    ///     Command::builder("describe").summary("Describe a resource").build().unwrap(),
333    /// ]);
334    ///
335    /// // Fuzzy-matches all commands starting with 'de'
336    /// let results = registry.fuzzy_search("dep");
337    /// assert!(!results.is_empty());
338    /// // Results are sorted by match score descending
339    /// assert_eq!(results[0].0.canonical, "deploy");
340    /// // Scores are positive integers — higher is a better match
341    /// assert!(results[0].1 > 0);
342    /// # }
343    /// ```
344    #[cfg(feature = "fuzzy")]
345    pub fn fuzzy_search(&self, query: &str) -> Vec<(&Command, i64)> {
346        let matcher = SkimMatcherV2::default();
347        let mut results: Vec<(&Command, i64)> = self
348            .commands
349            .iter()
350            .filter_map(|cmd| {
351                let text = format!("{} {} {}", cmd.canonical, cmd.summary, cmd.description);
352                matcher.fuzzy_match(&text, query).map(|score| (cmd, score))
353            })
354            .collect();
355        results.sort_by(|a, b| b.1.cmp(&a.1));
356        results
357    }
358
359    /// Match commands by natural-language intent phrase.
360    ///
361    /// Scores each command by how many words from `phrase` appear in its
362    /// combined text (canonical name, aliases, semantic aliases, summary,
363    /// description). Returns matches sorted by score descending.
364    ///
365    /// # Examples
366    ///
367    /// ```
368    /// # use argot_cmd::{Command, Registry};
369    /// let registry = Registry::new(vec![
370    ///     Command::builder("deploy")
371    ///         .summary("Deploy a service to an environment")
372    ///         .semantic_alias("release to production")
373    ///         .semantic_alias("push to environment")
374    ///         .build().unwrap(),
375    ///     Command::builder("status")
376    ///         .summary("Check service status")
377    ///         .build().unwrap(),
378    /// ]);
379    ///
380    /// let results = registry.match_intent("deploy to production");
381    /// assert!(!results.is_empty());
382    /// assert_eq!(results[0].0.canonical, "deploy");
383    /// ```
384    pub fn match_intent(&self, phrase: &str) -> Vec<(&Command, u32)> {
385        let phrase_lower = phrase.to_lowercase();
386        let words: Vec<&str> = phrase_lower
387            .split_whitespace()
388            .filter(|w| !w.is_empty())
389            .collect();
390
391        if words.is_empty() {
392            return vec![];
393        }
394
395        let mut results: Vec<(&Command, u32)> = self
396            .commands
397            .iter()
398            .filter_map(|cmd| {
399                let combined = format!(
400                    "{} {} {} {} {}",
401                    cmd.canonical.to_lowercase(),
402                    cmd.aliases
403                        .iter()
404                        .map(|s| s.to_lowercase())
405                        .collect::<Vec<_>>()
406                        .join(" "),
407                    cmd.semantic_aliases
408                        .iter()
409                        .map(|s| s.to_lowercase())
410                        .collect::<Vec<_>>()
411                        .join(" "),
412                    cmd.summary.to_lowercase(),
413                    cmd.description.to_lowercase(),
414                );
415                let score = words.iter().filter(|&&w| combined.contains(w)).count() as u32;
416                if score > 0 {
417                    Some((cmd, score))
418                } else {
419                    None
420                }
421            })
422            .collect();
423
424        results.sort_by(|a, b| b.1.cmp(&a.1));
425        results
426    }
427
428    /// Serialize the entire command tree to a pretty-printed JSON string.
429    ///
430    /// Handler closures are excluded from the output (they are skipped by the
431    /// `serde` configuration on [`Command`]).
432    ///
433    /// # Errors
434    ///
435    /// Returns [`QueryError::Serialization`] if `serde_json` fails (in
436    /// practice this should not happen for well-formed command trees).
437    ///
438    /// # Examples
439    ///
440    /// ```
441    /// # use argot_cmd::{Command, Registry};
442    /// let registry = Registry::new(vec![
443    ///     Command::builder("deploy").summary("Deploy").build().unwrap(),
444    /// ]);
445    ///
446    /// let json = registry.to_json().unwrap();
447    /// assert!(json.contains("deploy"));
448    /// ```
449    pub fn to_json(&self) -> Result<String, QueryError> {
450        serde_json::to_string_pretty(&self.commands).map_err(QueryError::Serialization)
451    }
452
453    /// Iterate over every command in the tree depth-first, including all
454    /// nested subcommands at any depth.
455    ///
456    /// Each entry carries the [`CommandEntry::path`] (canonical names from the
457    /// registry root to the command) and a reference to the [`Command`].
458    ///
459    /// Commands are yielded in depth-first order: a parent command appears
460    /// immediately before all of its descendants. Within each level, commands
461    /// appear in registration order.
462    ///
463    /// # Examples
464    ///
465    /// ```
466    /// # use argot_cmd::{Command, Registry};
467    /// let registry = Registry::new(vec![
468    ///     Command::builder("remote")
469    ///         .subcommand(Command::builder("add").build().unwrap())
470    ///         .subcommand(Command::builder("remove").build().unwrap())
471    ///         .build()
472    ///         .unwrap(),
473    ///     Command::builder("status").build().unwrap(),
474    /// ]);
475    ///
476    /// let all: Vec<_> = registry.iter_all_recursive();
477    /// let names: Vec<String> = all.iter().map(|e| e.path_str()).collect();
478    ///
479    /// assert_eq!(names, ["remote", "remote.add", "remote.remove", "status"]);
480    /// ```
481    pub fn iter_all_recursive(&self) -> Vec<CommandEntry<'_>> {
482        let mut out = Vec::new();
483        for cmd in &self.commands {
484            collect_recursive(cmd, vec![], &mut out);
485        }
486        out
487    }
488}
489
490fn collect_recursive<'a>(cmd: &'a Command, mut path: Vec<String>, out: &mut Vec<CommandEntry<'a>>) {
491    path.push(cmd.canonical.clone());
492    out.push(CommandEntry {
493        path: path.clone(),
494        command: cmd,
495    });
496    for sub in &cmd.subcommands {
497        collect_recursive(sub, path.clone(), out);
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::model::Command;
505
506    fn registry() -> Registry {
507        let sub = Command::builder("push")
508            .summary("Push changes")
509            .build()
510            .unwrap();
511        let remote = Command::builder("remote")
512            .summary("Manage remotes")
513            .subcommand(sub)
514            .build()
515            .unwrap();
516        let list = Command::builder("list")
517            .summary("List all items in the store")
518            .build()
519            .unwrap();
520        Registry::new(vec![remote, list])
521    }
522
523    #[test]
524    fn test_list_commands() {
525        let r = registry();
526        let cmds = r.list_commands();
527        assert_eq!(cmds.len(), 2);
528    }
529
530    #[test]
531    fn test_get_command() {
532        let r = registry();
533        assert!(r.get_command("remote").is_some());
534        assert!(r.get_command("missing").is_none());
535    }
536
537    #[test]
538    fn test_get_subcommand() {
539        let r = registry();
540        assert_eq!(
541            r.get_subcommand(&["remote", "push"]).unwrap().canonical,
542            "push"
543        );
544        assert!(r.get_subcommand(&["remote", "nope"]).is_none());
545        assert!(r.get_subcommand(&[]).is_none());
546    }
547
548    #[test]
549    fn test_get_examples_empty() {
550        let r = registry();
551        assert_eq!(r.get_examples("list"), Some([].as_slice()));
552    }
553
554    #[test]
555    fn test_search_match() {
556        let r = registry();
557        let results = r.search("store");
558        assert_eq!(results.len(), 1);
559        assert_eq!(results[0].canonical, "list");
560    }
561
562    #[test]
563    fn test_search_no_match() {
564        let r = registry();
565        assert!(r.search("zzz").is_empty());
566    }
567
568    #[cfg(feature = "fuzzy")]
569    #[test]
570    fn test_fuzzy_search_match() {
571        let r = registry();
572        let results = r.fuzzy_search("lst");
573        assert!(!results.is_empty());
574        assert!(results.iter().any(|(cmd, _)| cmd.canonical == "list"));
575    }
576
577    #[cfg(feature = "fuzzy")]
578    #[test]
579    fn test_fuzzy_search_no_match() {
580        let r = registry();
581        assert!(r.fuzzy_search("zzzzz").is_empty());
582    }
583
584    #[cfg(feature = "fuzzy")]
585    #[test]
586    fn test_fuzzy_search_sorted_by_score() {
587        let exact = Command::builder("list")
588            .summary("List all items")
589            .build()
590            .unwrap();
591        let weak = Command::builder("remote")
592            .summary("Manage remotes")
593            .build()
594            .unwrap();
595        let r = Registry::new(vec![weak, exact]);
596        let results = r.fuzzy_search("list");
597        assert!(!results.is_empty());
598        assert_eq!(results[0].0.canonical, "list");
599        for window in results.windows(2) {
600            assert!(window[0].1 >= window[1].1);
601        }
602    }
603
604    #[test]
605    fn test_to_json() {
606        let r = registry();
607        let json = r.to_json().unwrap();
608        assert!(json.contains("remote"));
609        assert!(json.contains("list"));
610        let _: serde_json::Value = serde_json::from_str(&json).unwrap();
611    }
612
613    #[test]
614    fn test_match_intent_single_word() {
615        let r = Registry::new(vec![
616            Command::builder("deploy")
617                .summary("Deploy a service")
618                .build()
619                .unwrap(),
620            Command::builder("status")
621                .summary("Check service status")
622                .build()
623                .unwrap(),
624        ]);
625        let results = r.match_intent("deploy");
626        assert!(!results.is_empty());
627        assert_eq!(results[0].0.canonical, "deploy");
628    }
629
630    #[test]
631    fn test_match_intent_phrase() {
632        let r = Registry::new(vec![
633            Command::builder("deploy")
634                .summary("Deploy a service to an environment")
635                .semantic_alias("release to production")
636                .semantic_alias("push to environment")
637                .build()
638                .unwrap(),
639            Command::builder("status")
640                .summary("Check service status")
641                .build()
642                .unwrap(),
643        ]);
644        let results = r.match_intent("release to production");
645        assert!(!results.is_empty());
646        assert_eq!(results[0].0.canonical, "deploy");
647    }
648
649    #[test]
650    fn test_match_intent_no_match() {
651        let r = Registry::new(vec![Command::builder("deploy")
652            .summary("Deploy a service")
653            .build()
654            .unwrap()]);
655        let results = r.match_intent("zzz xyzzy foobar");
656        assert!(results.is_empty());
657    }
658
659    #[test]
660    fn test_match_intent_sorted_by_score() {
661        let r = Registry::new(vec![
662            Command::builder("status")
663                .summary("Check service status")
664                .build()
665                .unwrap(),
666            Command::builder("deploy")
667                .summary("Deploy a service to an environment")
668                .semantic_alias("release to production")
669                .semantic_alias("push to environment")
670                .build()
671                .unwrap(),
672        ]);
673        // "deploy to production" matches deploy on "deploy", "to", "production"
674        // and matches status only on "to" (if present in summary)
675        let results = r.match_intent("deploy to production");
676        assert!(!results.is_empty());
677        // deploy should score higher than status
678        assert_eq!(results[0].0.canonical, "deploy");
679        // scores are descending
680        for window in results.windows(2) {
681            assert!(window[0].1 >= window[1].1);
682        }
683    }
684
685    #[test]
686    fn test_iter_all_recursive_flat() {
687        let r = Registry::new(vec![
688            Command::builder("a").build().unwrap(),
689            Command::builder("b").build().unwrap(),
690        ]);
691        let entries = r.iter_all_recursive();
692        assert_eq!(entries.len(), 2);
693        assert_eq!(entries[0].path_str(), "a");
694        assert_eq!(entries[1].path_str(), "b");
695    }
696
697    #[test]
698    fn test_iter_all_recursive_nested() {
699        let registry = Registry::new(vec![
700            Command::builder("remote")
701                .subcommand(Command::builder("add").build().unwrap())
702                .subcommand(Command::builder("remove").build().unwrap())
703                .build()
704                .unwrap(),
705            Command::builder("status").build().unwrap(),
706        ]);
707
708        let names: Vec<String> = registry
709            .iter_all_recursive()
710            .iter()
711            .map(|e| e.path_str())
712            .collect();
713
714        assert_eq!(names, ["remote", "remote.add", "remote.remove", "status"]);
715    }
716
717    #[test]
718    fn test_iter_all_recursive_deep_nesting() {
719        let leaf = Command::builder("blue-green").build().unwrap();
720        let mid = Command::builder("strategy")
721            .subcommand(leaf)
722            .build()
723            .unwrap();
724        let top = Command::builder("deploy").subcommand(mid).build().unwrap();
725        let r = Registry::new(vec![top]);
726
727        let names: Vec<String> = r
728            .iter_all_recursive()
729            .iter()
730            .map(|e| e.path_str())
731            .collect();
732
733        assert_eq!(
734            names,
735            ["deploy", "deploy.strategy", "deploy.strategy.blue-green"]
736        );
737    }
738
739    #[test]
740    fn test_iter_all_recursive_entry_helpers() {
741        let registry = Registry::new(vec![Command::builder("remote")
742            .subcommand(Command::builder("add").build().unwrap())
743            .build()
744            .unwrap()]);
745        let entries = registry.iter_all_recursive();
746        assert_eq!(entries[1].name(), "add");
747        assert_eq!(entries[1].path, vec!["remote", "add"]);
748        assert_eq!(entries[1].path_str(), "remote.add");
749    }
750
751    #[test]
752    fn test_iter_all_recursive_empty() {
753        let r = Registry::new(vec![]);
754        assert!(r.iter_all_recursive().is_empty());
755    }
756}
757
758#[cfg(test)]
759#[cfg(feature = "fuzzy")]
760mod fuzzy_tests {
761    use super::*;
762    use crate::model::Command;
763
764    #[test]
765    fn test_fuzzy_search_returns_matches() {
766        let r = Registry::new(vec![
767            Command::builder("deploy").build().unwrap(),
768            Command::builder("delete").build().unwrap(),
769            Command::builder("status").build().unwrap(),
770        ]);
771        let results = r.fuzzy_search("dep");
772        assert!(!results.is_empty(), "should find matches for 'dep'");
773        // "deploy" should be the top match
774        assert_eq!(results[0].0.canonical, "deploy");
775    }
776
777    #[test]
778    fn test_fuzzy_search_sorted_by_score_descending() {
779        let r = Registry::new(vec![
780            Command::builder("deploy").build().unwrap(),
781            Command::builder("delete").build().unwrap(),
782        ]);
783        let results = r.fuzzy_search("deploy");
784        assert!(!results.is_empty());
785        // Scores should be in descending order
786        for i in 1..results.len() {
787            assert!(
788                results[i - 1].1 >= results[i].1,
789                "results should be sorted by score desc"
790            );
791        }
792    }
793
794    #[test]
795    fn test_fuzzy_search_no_match_returns_empty() {
796        let r = Registry::new(vec![Command::builder("run").build().unwrap()]);
797        let results = r.fuzzy_search("zzzzzzz");
798        // No match should return empty (or very low score filtered out)
799        // The fuzzy matcher may return low-score matches, so just verify
800        // that "run" is NOT the top result for a nonsense query, or it returns empty
801        if !results.is_empty() {
802            // If it returns anything, score must be positive
803            assert!(results.iter().all(|(_, score)| *score > 0));
804        }
805    }
806
807    #[test]
808    fn test_fuzzy_search_score_type() {
809        let r = Registry::new(vec![Command::builder("deploy").build().unwrap()]);
810        let results = r.fuzzy_search("deploy");
811        assert!(!results.is_empty());
812        // Score is i64
813        let score: i64 = results[0].1;
814        assert!(score > 0);
815    }
816}