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 /// Serialize the entire command tree to a pretty-printed JSON string,
454 /// filtering each command object to only include the requested top-level
455 /// fields.
456 ///
457 /// Each command object (including nested subcommands at any depth) is
458 /// filtered so that only keys listed in `fields` are retained. The
459 /// `subcommands` key is always walked recursively even if it is not in
460 /// `fields`; its entries are filtered before being emitted.
461 ///
462 /// If `fields` is empty the method falls back to the same output as
463 /// [`Registry::to_json`].
464 ///
465 /// Field names that do not exist in the serialized command are silently
466 /// ignored (no error is returned for missing fields).
467 ///
468 /// Valid field names correspond to the top-level keys of the serialized
469 /// [`Command`] object: `canonical`, `aliases`, `spellings`,
470 /// `semantic_aliases`, `summary`, `description`, `arguments`, `flags`,
471 /// `examples`, `best_practices`, `anti_patterns`, `subcommands`, `meta`,
472 /// `mutating`, etc.
473 ///
474 /// # Errors
475 ///
476 /// Returns [`QueryError::Serialization`] if `serde_json` fails.
477 ///
478 /// # Examples
479 ///
480 /// ```
481 /// # use argot_cmd::{Command, Registry};
482 /// let registry = Registry::new(vec![
483 /// Command::builder("deploy")
484 /// .summary("Deploy the app")
485 /// .build()
486 /// .unwrap(),
487 /// ]);
488 ///
489 /// let json = registry.to_json_with_fields(&["canonical", "summary"]).unwrap();
490 /// let v: serde_json::Value = serde_json::from_str(&json).unwrap();
491 /// let obj = &v[0];
492 /// assert_eq!(obj["canonical"], "deploy");
493 /// assert_eq!(obj["summary"], "Deploy the app");
494 /// // fields not requested are absent
495 /// assert!(obj.get("examples").is_none());
496 /// ```
497 pub fn to_json_with_fields(&self, fields: &[&str]) -> Result<String, QueryError> {
498 if fields.is_empty() {
499 return self.to_json();
500 }
501 let value = serde_json::to_value(&self.commands).map_err(QueryError::Serialization)?;
502 let filtered = filter_commands_value(value, fields);
503 serde_json::to_string_pretty(&filtered).map_err(QueryError::Serialization)
504 }
505
506 /// Serialize the command tree as NDJSON (one compact JSON object per line).
507 ///
508 /// Commands are emitted depth-first using [`Registry::iter_all_recursive`].
509 /// Each line is a single, self-contained JSON object representing one
510 /// command (handler closures excluded). Subcommands appear as their own
511 /// lines rather than being nested inside their parent, which lets agents
512 /// process the registry incrementally without buffering the entire tree.
513 ///
514 /// An empty registry returns an empty string (no trailing newline).
515 /// A non-empty registry ends with a trailing newline.
516 ///
517 /// # Errors
518 ///
519 /// Returns [`QueryError::Serialization`] if serialization fails.
520 ///
521 /// # Examples
522 ///
523 /// ```
524 /// # use argot_cmd::{Command, Registry};
525 /// let registry = Registry::new(vec![
526 /// Command::builder("remote")
527 /// .subcommand(Command::builder("add").build().unwrap())
528 /// .build()
529 /// .unwrap(),
530 /// ]);
531 ///
532 /// let ndjson = registry.to_ndjson().unwrap();
533 /// let lines: Vec<&str> = ndjson.trim_end_matches('\n').split('\n').collect();
534 /// assert_eq!(lines.len(), 2); // "remote" + "remote add"
535 /// // Every line must be valid JSON.
536 /// for line in &lines {
537 /// assert!(serde_json::from_str::<serde_json::Value>(line).is_ok());
538 /// }
539 /// ```
540 pub fn to_ndjson(&self) -> Result<String, QueryError> {
541 self.to_ndjson_with_fields(&[])
542 }
543
544 /// Serialize the command tree as NDJSON, filtering each object to the
545 /// given field names.
546 ///
547 /// Behaves identically to [`Registry::to_ndjson`] except that each JSON
548 /// object is filtered to include only the keys listed in `fields`. When
549 /// `fields` is empty, all fields are included (equivalent to
550 /// [`Registry::to_ndjson`]).
551 ///
552 /// # Arguments
553 ///
554 /// - `fields` — Field names to keep in each output object. Pass `&[]` to
555 /// include all fields.
556 ///
557 /// # Errors
558 ///
559 /// Returns [`QueryError::Serialization`] if serialization fails.
560 ///
561 /// # Examples
562 ///
563 /// ```
564 /// # use argot_cmd::{Command, Registry};
565 /// let registry = Registry::new(vec![
566 /// Command::builder("deploy").summary("Deploy the app").build().unwrap(),
567 /// ]);
568 ///
569 /// let ndjson = registry.to_ndjson_with_fields(&["canonical", "summary"]).unwrap();
570 /// let val: serde_json::Value = serde_json::from_str(ndjson.trim_end_matches('\n')).unwrap();
571 /// assert!(val.get("canonical").is_some());
572 /// assert!(val.get("summary").is_some());
573 /// assert!(val.get("description").is_none());
574 /// ```
575 pub fn to_ndjson_with_fields(&self, fields: &[&str]) -> Result<String, QueryError> {
576 let entries = self.iter_all_recursive();
577 if entries.is_empty() {
578 return Ok(String::new());
579 }
580 let mut lines = Vec::with_capacity(entries.len());
581 for entry in &entries {
582 let line = command_to_ndjson_with_fields(entry.command, fields)?;
583 lines.push(line);
584 }
585 let mut output = lines.join("\n");
586 output.push('\n');
587 Ok(output)
588 }
589
590 /// Iterate over every command in the tree depth-first, including all
591 /// nested subcommands at any depth.
592 ///
593 /// Each entry carries the [`CommandEntry::path`] (canonical names from the
594 /// registry root to the command) and a reference to the [`Command`].
595 ///
596 /// Commands are yielded in depth-first order: a parent command appears
597 /// immediately before all of its descendants. Within each level, commands
598 /// appear in registration order.
599 ///
600 /// # Examples
601 ///
602 /// ```
603 /// # use argot_cmd::{Command, Registry};
604 /// let registry = Registry::new(vec![
605 /// Command::builder("remote")
606 /// .subcommand(Command::builder("add").build().unwrap())
607 /// .subcommand(Command::builder("remove").build().unwrap())
608 /// .build()
609 /// .unwrap(),
610 /// Command::builder("status").build().unwrap(),
611 /// ]);
612 ///
613 /// let all: Vec<_> = registry.iter_all_recursive();
614 /// let names: Vec<String> = all.iter().map(|e| e.path_str()).collect();
615 ///
616 /// assert_eq!(names, ["remote", "remote.add", "remote.remove", "status"]);
617 /// ```
618 pub fn iter_all_recursive(&self) -> Vec<CommandEntry<'_>> {
619 let mut out = Vec::new();
620 for cmd in &self.commands {
621 collect_recursive(cmd, vec![], &mut out);
622 }
623 out
624 }
625}
626
627/// Serialize a single [`Command`] to a pretty-printed JSON string, filtering
628/// the output to only include the requested top-level fields.
629///
630/// Behaves like [`Registry::to_json_with_fields`] but for a single command
631/// rather than the whole registry.
632///
633/// If `fields` is empty the method serializes the full command (equivalent to
634/// `serde_json::to_string_pretty(cmd)`).
635///
636/// # Errors
637///
638/// Returns [`QueryError::Serialization`] if `serde_json` fails.
639///
640/// # Examples
641///
642/// ```
643/// # use argot_cmd::{Command, Registry};
644/// # use argot_cmd::query::command_to_json_with_fields;
645/// let cmd = Command::builder("deploy")
646/// .summary("Deploy the app")
647/// .build()
648/// .unwrap();
649///
650/// let json = command_to_json_with_fields(&cmd, &["canonical", "summary"]).unwrap();
651/// let v: serde_json::Value = serde_json::from_str(&json).unwrap();
652/// assert_eq!(v["canonical"], "deploy");
653/// assert_eq!(v["summary"], "Deploy the app");
654/// assert!(v.get("examples").is_none());
655/// ```
656pub fn command_to_json_with_fields(cmd: &Command, fields: &[&str]) -> Result<String, QueryError> {
657 if fields.is_empty() {
658 return serde_json::to_string_pretty(cmd).map_err(QueryError::Serialization);
659 }
660 let value = serde_json::to_value(cmd).map_err(QueryError::Serialization)?;
661 let filtered = filter_command_object(value, fields);
662 serde_json::to_string_pretty(&filtered).map_err(QueryError::Serialization)
663}
664
665/// Filter a JSON array of command objects to only include the requested fields
666/// in each entry.
667fn filter_commands_value(value: serde_json::Value, fields: &[&str]) -> serde_json::Value {
668 match value {
669 serde_json::Value::Array(arr) => {
670 serde_json::Value::Array(
671 arr.into_iter()
672 .map(|v| filter_command_object(v, fields))
673 .collect(),
674 )
675 }
676 other => other,
677 }
678}
679
680/// Filter a single command JSON object to only include the requested fields.
681///
682/// The `subcommands` value (if present and requested) has its entries
683/// recursively filtered as well.
684fn filter_command_object(value: serde_json::Value, fields: &[&str]) -> serde_json::Value {
685 match value {
686 serde_json::Value::Object(map) => {
687 let mut out = serde_json::Map::new();
688 for field in fields {
689 if let Some(v) = map.get(*field) {
690 // If this field is `subcommands`, recursively filter its entries.
691 let filtered_v = if *field == "subcommands" {
692 filter_commands_value(v.clone(), fields)
693 } else {
694 v.clone()
695 };
696 out.insert((*field).to_owned(), filtered_v);
697 }
698 }
699 serde_json::Value::Object(out)
700 }
701 other => other,
702 }
703}
704
705fn collect_recursive<'a>(cmd: &'a Command, mut path: Vec<String>, out: &mut Vec<CommandEntry<'a>>) {
706 path.push(cmd.canonical.clone());
707 out.push(CommandEntry {
708 path: path.clone(),
709 command: cmd,
710 });
711 for sub in &cmd.subcommands {
712 collect_recursive(sub, path.clone(), out);
713 }
714}
715
716/// Serialize a single [`Command`] to a compact (single-line) JSON string.
717///
718/// The output contains no trailing newline. Handler closures are excluded.
719/// This is the single-command companion to [`Registry::to_ndjson`].
720///
721/// # Errors
722///
723/// Returns [`QueryError::Serialization`] if serialization fails.
724///
725/// # Examples
726///
727/// ```
728/// # use argot_cmd::{Command, command_to_ndjson};
729/// let cmd = Command::builder("deploy").summary("Deploy").build().unwrap();
730/// let line = command_to_ndjson(&cmd).unwrap();
731/// // No trailing newline.
732/// assert!(!line.ends_with('\n'));
733/// // Must be valid compact JSON on one line.
734/// assert!(!line.contains('\n'));
735/// let val: serde_json::Value = serde_json::from_str(&line).unwrap();
736/// assert_eq!(val["canonical"], "deploy");
737/// ```
738pub fn command_to_ndjson(cmd: &Command) -> Result<String, QueryError> {
739 command_to_ndjson_with_fields(cmd, &[])
740}
741
742/// Internal helper: serialize a command to compact JSON, optionally filtering
743/// to the given set of field names. When `fields` is empty all fields are kept.
744fn command_to_ndjson_with_fields(cmd: &Command, fields: &[&str]) -> Result<String, QueryError> {
745 let mut value = serde_json::to_value(cmd).map_err(QueryError::Serialization)?;
746 if !fields.is_empty() {
747 if let serde_json::Value::Object(ref mut map) = value {
748 let keys_to_remove: Vec<String> = map
749 .keys()
750 .filter(|k| !fields.contains(&k.as_str()))
751 .cloned()
752 .collect();
753 for key in keys_to_remove {
754 map.remove(&key);
755 }
756 }
757 }
758 serde_json::to_string(&value).map_err(QueryError::Serialization)
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764 use crate::model::Command;
765
766 fn registry() -> Registry {
767 let sub = Command::builder("push")
768 .summary("Push changes")
769 .build()
770 .unwrap();
771 let remote = Command::builder("remote")
772 .summary("Manage remotes")
773 .subcommand(sub)
774 .build()
775 .unwrap();
776 let list = Command::builder("list")
777 .summary("List all items in the store")
778 .build()
779 .unwrap();
780 Registry::new(vec![remote, list])
781 }
782
783 #[test]
784 fn test_list_commands() {
785 let r = registry();
786 let cmds = r.list_commands();
787 assert_eq!(cmds.len(), 2);
788 }
789
790 #[test]
791 fn test_get_command() {
792 let r = registry();
793 assert!(r.get_command("remote").is_some());
794 assert!(r.get_command("missing").is_none());
795 }
796
797 #[test]
798 fn test_get_subcommand() {
799 let r = registry();
800 assert_eq!(
801 r.get_subcommand(&["remote", "push"]).unwrap().canonical,
802 "push"
803 );
804 assert!(r.get_subcommand(&["remote", "nope"]).is_none());
805 assert!(r.get_subcommand(&[]).is_none());
806 }
807
808 #[test]
809 fn test_get_examples_empty() {
810 let r = registry();
811 assert_eq!(r.get_examples("list"), Some([].as_slice()));
812 }
813
814 #[test]
815 fn test_search_match() {
816 let r = registry();
817 let results = r.search("store");
818 assert_eq!(results.len(), 1);
819 assert_eq!(results[0].canonical, "list");
820 }
821
822 #[test]
823 fn test_search_no_match() {
824 let r = registry();
825 assert!(r.search("zzz").is_empty());
826 }
827
828 #[cfg(feature = "fuzzy")]
829 #[test]
830 fn test_fuzzy_search_match() {
831 let r = registry();
832 let results = r.fuzzy_search("lst");
833 assert!(!results.is_empty());
834 assert!(results.iter().any(|(cmd, _)| cmd.canonical == "list"));
835 }
836
837 #[cfg(feature = "fuzzy")]
838 #[test]
839 fn test_fuzzy_search_no_match() {
840 let r = registry();
841 assert!(r.fuzzy_search("zzzzz").is_empty());
842 }
843
844 #[cfg(feature = "fuzzy")]
845 #[test]
846 fn test_fuzzy_search_sorted_by_score() {
847 let exact = Command::builder("list")
848 .summary("List all items")
849 .build()
850 .unwrap();
851 let weak = Command::builder("remote")
852 .summary("Manage remotes")
853 .build()
854 .unwrap();
855 let r = Registry::new(vec![weak, exact]);
856 let results = r.fuzzy_search("list");
857 assert!(!results.is_empty());
858 assert_eq!(results[0].0.canonical, "list");
859 for window in results.windows(2) {
860 assert!(window[0].1 >= window[1].1);
861 }
862 }
863
864 #[test]
865 fn test_to_json() {
866 let r = registry();
867 let json = r.to_json().unwrap();
868 assert!(json.contains("remote"));
869 assert!(json.contains("list"));
870 let _: serde_json::Value = serde_json::from_str(&json).unwrap();
871 }
872
873 // ── NDJSON tests ──────────────────────────────────────────────────────────
874
875 #[test]
876 fn test_to_ndjson_empty_registry_returns_empty_string() {
877 let r = Registry::new(vec![]);
878 let ndjson = r.to_ndjson().unwrap();
879 assert_eq!(ndjson, "");
880 }
881
882 #[test]
883 fn test_to_ndjson_one_line_per_command_including_subcommands() {
884 // registry() has: remote (with push subcommand) + list = 3 total entries
885 let r = registry();
886 let ndjson = r.to_ndjson().unwrap();
887 assert!(ndjson.ends_with('\n'), "should end with trailing newline");
888 let lines: Vec<&str> = ndjson
889 .trim_end_matches('\n')
890 .split('\n')
891 .filter(|l| !l.is_empty())
892 .collect();
893 assert_eq!(lines.len(), 3, "remote + push + list = 3 lines, got: {:?}", lines);
894 }
895
896 #[test]
897 fn test_to_ndjson_each_line_is_valid_json() {
898 let r = registry();
899 let ndjson = r.to_ndjson().unwrap();
900 for line in ndjson.trim_end_matches('\n').split('\n') {
901 let result: Result<serde_json::Value, _> = serde_json::from_str(line);
902 assert!(result.is_ok(), "line is not valid JSON: {:?}", line);
903 }
904 }
905
906 #[test]
907 fn test_to_ndjson_depth_first_order() {
908 let r = registry();
909 let ndjson = r.to_ndjson().unwrap();
910 let lines: Vec<&str> = ndjson.trim_end_matches('\n').split('\n').collect();
911 // depth-first: remote, push (subcommand of remote), list
912 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
913 let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
914 let third: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
915 assert_eq!(first["canonical"], "remote");
916 assert_eq!(second["canonical"], "push");
917 assert_eq!(third["canonical"], "list");
918 }
919
920 #[test]
921 fn test_to_ndjson_with_fields_filters_correctly() {
922 let r = registry();
923 let ndjson = r.to_ndjson_with_fields(&["canonical", "summary"]).unwrap();
924 for line in ndjson.trim_end_matches('\n').split('\n') {
925 let val: serde_json::Value = serde_json::from_str(line).unwrap();
926 assert!(val.get("canonical").is_some(), "missing 'canonical' in line: {}", line);
927 assert!(val.get("summary").is_some(), "missing 'summary' in line: {}", line);
928 assert!(
929 val.get("description").is_none(),
930 "'description' should be filtered out: {}",
931 line
932 );
933 assert!(
934 val.get("examples").is_none(),
935 "'examples' should be filtered out: {}",
936 line
937 );
938 }
939 }
940
941 #[test]
942 fn test_to_ndjson_with_empty_fields_includes_all() {
943 let r = registry();
944 let ndjson_all = r.to_ndjson().unwrap();
945 let ndjson_empty_fields = r.to_ndjson_with_fields(&[]).unwrap();
946 assert_eq!(ndjson_all, ndjson_empty_fields);
947 }
948
949 #[test]
950 fn test_command_to_ndjson_single_compact_line() {
951 let cmd = Command::builder("deploy").summary("Deploy").build().unwrap();
952 let line = super::command_to_ndjson(&cmd).unwrap();
953 assert!(
954 !line.ends_with('\n'),
955 "command_to_ndjson should have no trailing newline"
956 );
957 assert!(
958 !line.contains('\n'),
959 "command_to_ndjson should be a single line"
960 );
961 let val: serde_json::Value = serde_json::from_str(&line).unwrap();
962 assert_eq!(val["canonical"], "deploy");
963 assert_eq!(val["summary"], "Deploy");
964 }
965
966 #[test]
967 fn test_command_to_ndjson_compact_not_pretty() {
968 let cmd = Command::builder("run").build().unwrap();
969 let line = super::command_to_ndjson(&cmd).unwrap();
970 // Compact JSON has no newlines and is not pretty-printed.
971 assert!(!line.contains('\n'));
972 // Compact JSON should not have extra whitespace after colons (serde_json compact).
973 assert!(!line.contains(": "), "should be compact, not pretty-printed");
974 }
975
976 #[test]
977 fn test_to_json_with_fields_filters_keys() {
978 let r = Registry::new(vec![
979 Command::builder("deploy")
980 .summary("Deploy the app")
981 .description("Deploys to production")
982 .build()
983 .unwrap(),
984 ]);
985 let json = r.to_json_with_fields(&["canonical", "summary"]).unwrap();
986 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
987 let obj = &v[0];
988 assert_eq!(obj["canonical"], "deploy");
989 assert_eq!(obj["summary"], "Deploy the app");
990 // fields not in the filter must be absent
991 assert!(obj.get("description").is_none());
992 assert!(obj.get("examples").is_none());
993 assert!(obj.get("aliases").is_none());
994 }
995
996 #[test]
997 fn test_to_json_with_fields_empty_falls_back_to_full() {
998 let r = Registry::new(vec![
999 Command::builder("deploy")
1000 .summary("Deploy the app")
1001 .build()
1002 .unwrap(),
1003 ]);
1004 let full = r.to_json().unwrap();
1005 let filtered = r.to_json_with_fields(&[]).unwrap();
1006 assert_eq!(full, filtered);
1007 }
1008
1009 #[test]
1010 fn test_to_json_with_fields_missing_field_silently_omitted() {
1011 let r = Registry::new(vec![
1012 Command::builder("deploy").build().unwrap(),
1013 ]);
1014 // "nonexistent_key" does not exist — should produce an object with only "canonical"
1015 let json = r
1016 .to_json_with_fields(&["canonical", "nonexistent_key"])
1017 .unwrap();
1018 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1019 let obj = &v[0];
1020 assert_eq!(obj["canonical"], "deploy");
1021 assert!(obj.get("nonexistent_key").is_none());
1022 }
1023
1024 #[test]
1025 fn test_to_json_with_fields_subcommands_filtered_recursively() {
1026 let r = Registry::new(vec![
1027 Command::builder("remote")
1028 .summary("Manage remotes")
1029 .subcommand(
1030 Command::builder("add")
1031 .summary("Add a remote")
1032 .description("Detailed add docs")
1033 .build()
1034 .unwrap(),
1035 )
1036 .build()
1037 .unwrap(),
1038 ]);
1039 let json = r
1040 .to_json_with_fields(&["canonical", "summary", "subcommands"])
1041 .unwrap();
1042 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1043 let obj = &v[0];
1044 assert_eq!(obj["canonical"], "remote");
1045 assert!(obj.get("description").is_none());
1046 // subcommands array should be present
1047 let subs = obj["subcommands"].as_array().unwrap();
1048 assert_eq!(subs.len(), 1);
1049 // the subcommand entry should also be filtered
1050 assert_eq!(subs[0]["canonical"], "add");
1051 assert_eq!(subs[0]["summary"], "Add a remote");
1052 assert!(subs[0].get("description").is_none());
1053 }
1054
1055 #[test]
1056 fn test_to_json_with_fields_subcommands_not_requested_absent() {
1057 let r = Registry::new(vec![
1058 Command::builder("remote")
1059 .subcommand(Command::builder("add").build().unwrap())
1060 .build()
1061 .unwrap(),
1062 ]);
1063 // "subcommands" not in requested fields → key should be absent
1064 let json = r.to_json_with_fields(&["canonical"]).unwrap();
1065 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1066 let obj = &v[0];
1067 assert_eq!(obj["canonical"], "remote");
1068 assert!(obj.get("subcommands").is_none());
1069 }
1070
1071 #[test]
1072 fn test_command_to_json_with_fields() {
1073 let cmd = Command::builder("deploy")
1074 .summary("Deploy the app")
1075 .description("Long description")
1076 .build()
1077 .unwrap();
1078 let json = command_to_json_with_fields(&cmd, &["canonical", "summary"]).unwrap();
1079 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1080 assert_eq!(v["canonical"], "deploy");
1081 assert_eq!(v["summary"], "Deploy the app");
1082 assert!(v.get("description").is_none());
1083 }
1084
1085 #[test]
1086 fn test_command_to_json_with_fields_empty_falls_back_to_full() {
1087 let cmd = Command::builder("deploy")
1088 .summary("Deploy the app")
1089 .build()
1090 .unwrap();
1091 let full = serde_json::to_string_pretty(&cmd).unwrap();
1092 let filtered = command_to_json_with_fields(&cmd, &[]).unwrap();
1093 assert_eq!(full, filtered);
1094 }
1095
1096 #[test]
1097 fn test_match_intent_single_word() {
1098 let r = Registry::new(vec![
1099 Command::builder("deploy")
1100 .summary("Deploy a service")
1101 .build()
1102 .unwrap(),
1103 Command::builder("status")
1104 .summary("Check service status")
1105 .build()
1106 .unwrap(),
1107 ]);
1108 let results = r.match_intent("deploy");
1109 assert!(!results.is_empty());
1110 assert_eq!(results[0].0.canonical, "deploy");
1111 }
1112
1113 #[test]
1114 fn test_match_intent_phrase() {
1115 let r = Registry::new(vec![
1116 Command::builder("deploy")
1117 .summary("Deploy a service to an environment")
1118 .semantic_alias("release to production")
1119 .semantic_alias("push to environment")
1120 .build()
1121 .unwrap(),
1122 Command::builder("status")
1123 .summary("Check service status")
1124 .build()
1125 .unwrap(),
1126 ]);
1127 let results = r.match_intent("release to production");
1128 assert!(!results.is_empty());
1129 assert_eq!(results[0].0.canonical, "deploy");
1130 }
1131
1132 #[test]
1133 fn test_match_intent_no_match() {
1134 let r = Registry::new(vec![Command::builder("deploy")
1135 .summary("Deploy a service")
1136 .build()
1137 .unwrap()]);
1138 let results = r.match_intent("zzz xyzzy foobar");
1139 assert!(results.is_empty());
1140 }
1141
1142 #[test]
1143 fn test_match_intent_sorted_by_score() {
1144 let r = Registry::new(vec![
1145 Command::builder("status")
1146 .summary("Check service status")
1147 .build()
1148 .unwrap(),
1149 Command::builder("deploy")
1150 .summary("Deploy a service to an environment")
1151 .semantic_alias("release to production")
1152 .semantic_alias("push to environment")
1153 .build()
1154 .unwrap(),
1155 ]);
1156 // "deploy to production" matches deploy on "deploy", "to", "production"
1157 // and matches status only on "to" (if present in summary)
1158 let results = r.match_intent("deploy to production");
1159 assert!(!results.is_empty());
1160 // deploy should score higher than status
1161 assert_eq!(results[0].0.canonical, "deploy");
1162 // scores are descending
1163 for window in results.windows(2) {
1164 assert!(window[0].1 >= window[1].1);
1165 }
1166 }
1167
1168 #[test]
1169 fn test_iter_all_recursive_flat() {
1170 let r = Registry::new(vec![
1171 Command::builder("a").build().unwrap(),
1172 Command::builder("b").build().unwrap(),
1173 ]);
1174 let entries = r.iter_all_recursive();
1175 assert_eq!(entries.len(), 2);
1176 assert_eq!(entries[0].path_str(), "a");
1177 assert_eq!(entries[1].path_str(), "b");
1178 }
1179
1180 #[test]
1181 fn test_iter_all_recursive_nested() {
1182 let registry = Registry::new(vec![
1183 Command::builder("remote")
1184 .subcommand(Command::builder("add").build().unwrap())
1185 .subcommand(Command::builder("remove").build().unwrap())
1186 .build()
1187 .unwrap(),
1188 Command::builder("status").build().unwrap(),
1189 ]);
1190
1191 let names: Vec<String> = registry
1192 .iter_all_recursive()
1193 .iter()
1194 .map(|e| e.path_str())
1195 .collect();
1196
1197 assert_eq!(names, ["remote", "remote.add", "remote.remove", "status"]);
1198 }
1199
1200 #[test]
1201 fn test_iter_all_recursive_deep_nesting() {
1202 let leaf = Command::builder("blue-green").build().unwrap();
1203 let mid = Command::builder("strategy")
1204 .subcommand(leaf)
1205 .build()
1206 .unwrap();
1207 let top = Command::builder("deploy").subcommand(mid).build().unwrap();
1208 let r = Registry::new(vec![top]);
1209
1210 let names: Vec<String> = r
1211 .iter_all_recursive()
1212 .iter()
1213 .map(|e| e.path_str())
1214 .collect();
1215
1216 assert_eq!(
1217 names,
1218 ["deploy", "deploy.strategy", "deploy.strategy.blue-green"]
1219 );
1220 }
1221
1222 #[test]
1223 fn test_iter_all_recursive_entry_helpers() {
1224 let registry = Registry::new(vec![Command::builder("remote")
1225 .subcommand(Command::builder("add").build().unwrap())
1226 .build()
1227 .unwrap()]);
1228 let entries = registry.iter_all_recursive();
1229 assert_eq!(entries[1].name(), "add");
1230 assert_eq!(entries[1].path, vec!["remote", "add"]);
1231 assert_eq!(entries[1].path_str(), "remote.add");
1232 }
1233
1234 #[test]
1235 fn test_iter_all_recursive_empty() {
1236 let r = Registry::new(vec![]);
1237 assert!(r.iter_all_recursive().is_empty());
1238 }
1239}
1240
1241#[cfg(test)]
1242#[cfg(feature = "fuzzy")]
1243mod fuzzy_tests {
1244 use super::*;
1245 use crate::model::Command;
1246
1247 #[test]
1248 fn test_fuzzy_search_returns_matches() {
1249 let r = Registry::new(vec![
1250 Command::builder("deploy").build().unwrap(),
1251 Command::builder("delete").build().unwrap(),
1252 Command::builder("status").build().unwrap(),
1253 ]);
1254 let results = r.fuzzy_search("dep");
1255 assert!(!results.is_empty(), "should find matches for 'dep'");
1256 // "deploy" should be the top match
1257 assert_eq!(results[0].0.canonical, "deploy");
1258 }
1259
1260 #[test]
1261 fn test_fuzzy_search_sorted_by_score_descending() {
1262 let r = Registry::new(vec![
1263 Command::builder("deploy").build().unwrap(),
1264 Command::builder("delete").build().unwrap(),
1265 ]);
1266 let results = r.fuzzy_search("deploy");
1267 assert!(!results.is_empty());
1268 // Scores should be in descending order
1269 for i in 1..results.len() {
1270 assert!(
1271 results[i - 1].1 >= results[i].1,
1272 "results should be sorted by score desc"
1273 );
1274 }
1275 }
1276
1277 #[test]
1278 fn test_fuzzy_search_no_match_returns_empty() {
1279 let r = Registry::new(vec![Command::builder("run").build().unwrap()]);
1280 let results = r.fuzzy_search("zzzzzzz");
1281 // No match should return empty (or very low score filtered out)
1282 // The fuzzy matcher may return low-score matches, so just verify
1283 // that "run" is NOT the top result for a nonsense query, or it returns empty
1284 if !results.is_empty() {
1285 // If it returns anything, score must be positive
1286 assert!(results.iter().all(|(_, score)| *score > 0));
1287 }
1288 }
1289
1290 #[test]
1291 fn test_fuzzy_search_score_type() {
1292 let r = Registry::new(vec![Command::builder("deploy").build().unwrap()]);
1293 let results = r.fuzzy_search("deploy");
1294 assert!(!results.is_empty());
1295 // Score is i64
1296 let score: i64 = results[0].1;
1297 assert!(score > 0);
1298 }
1299}