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}