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    /// 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}