Skip to main content

argot_cmd/cli/
mod.rs

1//! High-level CLI entry point that wires together the argot pipeline.
2//!
3//! [`Cli`] is a batteries-included struct that combines [`Registry`],
4//! [`Parser`] and the render layer into a single `run` method.
5//! It handles the common built-in behaviors (help, version, empty input) so
6//! that application code only needs to build commands and register handlers.
7//!
8//! # Example
9//!
10//! ```no_run
11//! use std::sync::Arc;
12//! use argot_cmd::{Cli, Command};
13//!
14//! let cmd = Command::builder("greet")
15//!     .summary("Say hello")
16//!     .handler(Arc::new(|_| {
17//!         println!("Hello, world!");
18//!         Ok(())
19//!     }))
20//!     .build()
21//!     .unwrap();
22//!
23//! let cli = Cli::new(vec![cmd])
24//!     .app_name("myapp")
25//!     .version("1.0.0");
26//!
27//! // In a real application:
28//! // cli.run_env_args().unwrap();
29//! ```
30
31use crate::parser::{ParseError, Parser};
32use crate::query::Registry;
33use crate::render::{DefaultRenderer, Renderer};
34use crate::resolver::Resolver;
35
36/// Errors produced by [`Cli::run`].
37#[derive(Debug, thiserror::Error)]
38pub enum CliError {
39    /// A parse error occurred (unknown command, missing argument, etc.).
40    ///
41    /// When this variant is returned, `Cli::run` also prints the error and
42    /// best-effort help to stderr before returning.
43    #[error(transparent)]
44    Parse(#[from] ParseError),
45    /// The matched command has no handler registered.
46    ///
47    /// The inner `String` is the canonical name of the command.
48    #[error("command `{0}` has no handler registered")]
49    NoHandler(String),
50    /// The registered handler returned an error.
51    ///
52    /// The inner boxed error carries the handler's error message.
53    #[error("handler error: {0}")]
54    Handler(#[from] Box<dyn std::error::Error + Send + Sync>),
55}
56
57/// A batteries-included entry point that wires together [`Registry`], [`Parser`],
58/// and the render layer so callers do not have to do it themselves.
59///
60/// Build a `Cli` with [`Cli::new`], optionally configure it with
61/// [`Cli::app_name`] and [`Cli::version`], then call [`Cli::run`] (or
62/// [`Cli::run_env_args`] for the common case of reading from
63/// [`std::env::args`]).
64///
65/// ## Built-in behaviors
66///
67/// | Input | Behavior |
68/// |-------|----------|
69/// | `--help` / `-h` anywhere | Print help for the most-specific resolved command; return `Ok(())`. |
70/// | `--version` / `-V` | Print `"<app_name> <version>"` (or just the version); return `Ok(())`. |
71/// | Empty argument list | Print the top-level command listing; return `Ok(())`. |
72/// | Unrecognized command | Print error + help to stderr; return `Err(CliError::Parse(...))`. |
73///
74/// # Examples
75///
76/// ```
77/// # use std::sync::Arc;
78/// # use argot_cmd::{Cli, Command};
79/// let cli = Cli::new(vec![
80///     Command::builder("ping")
81///         .summary("Check connectivity")
82///         .handler(Arc::new(|_| { println!("pong"); Ok(()) }))
83///         .build()
84///         .unwrap(),
85/// ])
86/// .app_name("myapp")
87/// .version("0.1.0");
88///
89/// // Invoking with no args prints the command list (does not error).
90/// assert!(cli.run(std::iter::empty::<&str>()).is_ok());
91/// ```
92pub struct Cli {
93    registry: Registry,
94    app_name: String,
95    version: Option<String>,
96    middlewares: Vec<Box<dyn crate::middleware::Middleware>>,
97    renderer: Box<dyn Renderer>,
98    query_support: bool,
99    /// When `true`, a warning is emitted to stderr at dispatch time for mutating
100    /// commands that have no `--dry-run` flag defined.
101    warn_missing_dry_run: bool,
102}
103
104impl Cli {
105    /// Create a new `Cli` from a list of top-level commands.
106    ///
107    /// # Arguments
108    ///
109    /// - `commands` — The fully-built top-level command list. Ownership is
110    ///   transferred to an internal [`Registry`].
111    pub fn new(commands: Vec<crate::model::Command>) -> Self {
112        Self {
113            registry: Registry::new(commands),
114            app_name: String::new(),
115            version: None,
116            middlewares: vec![],
117            renderer: Box::new(DefaultRenderer),
118            query_support: false,
119            warn_missing_dry_run: false,
120        }
121    }
122
123    /// Set the application name (shown in version output and top-level help).
124    ///
125    /// If not set, the version string is printed without a prefix.
126    pub fn app_name(mut self, name: impl Into<String>) -> Self {
127        self.app_name = name.into();
128        self
129    }
130
131    /// Set the application version (shown by `--version` / `-V`).
132    ///
133    /// If not set, `"(no version set)"` is printed.
134    pub fn version(mut self, version: impl Into<String>) -> Self {
135        self.version = Some(version.into());
136        self
137    }
138
139    /// Register a middleware that hooks into the parse-and-dispatch lifecycle.
140    ///
141    /// Middlewares are invoked in registration order. Multiple middlewares can
142    /// be added by calling `with_middleware` repeatedly.
143    ///
144    /// # Examples
145    ///
146    /// ```no_run
147    /// use argot_cmd::{Cli, Command, middleware::Middleware};
148    ///
149    /// struct Audit;
150    /// impl Middleware for Audit {
151    ///     fn before_dispatch(&self, parsed: &argot_cmd::ParsedCommand<'_>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
152    ///         eprintln!("audit: {}", parsed.command.canonical);
153    ///         Ok(())
154    ///     }
155    /// }
156    ///
157    /// let cli = Cli::new(vec![Command::builder("run").build().unwrap()])
158    ///     .with_middleware(Audit);
159    /// ```
160    pub fn with_middleware<M: crate::middleware::Middleware + 'static>(mut self, m: M) -> Self {
161        self.middlewares.push(Box::new(m));
162        self
163    }
164
165    /// Replace the default renderer with a custom implementation.
166    ///
167    /// The renderer is used for all help text, Markdown, subcommand listings,
168    /// and ambiguity messages produced by this `Cli` instance.
169    ///
170    /// # Examples
171    ///
172    /// ```no_run
173    /// # use argot_cmd::{Cli, Command, render::Renderer};
174    /// struct MyRenderer;
175    /// impl Renderer for MyRenderer {
176    ///     fn render_help(&self, cmd: &argot_cmd::Command) -> String { format!("HELP: {}", cmd.canonical) }
177    ///     fn render_markdown(&self, cmd: &argot_cmd::Command) -> String { String::new() }
178    ///     fn render_subcommand_list(&self, cmds: &[argot_cmd::Command]) -> String { String::new() }
179    ///     fn render_ambiguity(&self, input: &str, _: &[String]) -> String { format!("bad: {}", input) }
180    /// }
181    ///
182    /// let cli = Cli::new(vec![Command::builder("run").build().unwrap()])
183    ///     .with_renderer(MyRenderer);
184    /// ```
185    pub fn with_renderer<R: Renderer + 'static>(mut self, renderer: R) -> Self {
186        self.renderer = Box::new(renderer);
187        self
188    }
189
190    /// Enable agent-discovery query support.
191    ///
192    /// When enabled, the CLI recognises a built-in `query` command:
193    ///
194    /// ```text
195    /// tool query commands                          # list all commands as JSON
196    /// tool query commands --stream                 # NDJSON: one object per line
197    /// tool query commands --fields canonical,summary
198    /// tool query commands --stream --fields canonical,summary
199    /// tool query <name>                            # get structured JSON for one command
200    /// tool query <name> --stream                   # single compact JSON line
201    /// ```
202    ///
203    /// The `query` command is also injected into the registry so that it
204    /// appears in `--help` output and in [`Registry::iter_all_recursive`].
205    ///
206    /// # Examples
207    ///
208    /// ```no_run
209    /// use argot_cmd::{Cli, Command};
210    ///
211    /// let cli = Cli::new(vec![Command::builder("deploy").build().unwrap()])
212    ///     .with_query_support();
213    /// // Now: `tool query commands` and `tool query deploy` work.
214    /// // Also: `tool query commands --stream` and `tool query deploy --stream`.
215    /// ```
216    pub fn with_query_support(mut self) -> Self {
217        self.query_support = true;
218        // Inject a meta `query` command so it shows up in --help and iter_all_recursive.
219        let query_cmd = crate::model::Command::builder("query")
220            .summary("Query command metadata (agent discovery)")
221            .description(
222                "Structured JSON output for agent discovery. \
223                 `query commands` lists all commands; `query <name>` returns metadata for one. \
224                 Use `--fields <csv>` to request only specific top-level fields, reducing output \
225                 size for agents that only need a subset of command metadata.",
226            )
227            .flag(
228                crate::model::Flag::builder("fields")
229                    .description(
230                        "Comma-separated list of top-level fields to include in JSON output \
231                         (e.g. `canonical,summary,examples`). When omitted all fields are returned.",
232                    )
233                    .takes_value()
234                    .build()
235                    .expect("built-in fields flag should always build"),
236            )
237            .example(crate::model::Example::new(
238                "query commands",
239                "List all commands as JSON",
240            ))
241            .example(crate::model::Example::new(
242                "query deploy",
243                "Get metadata for the deploy command",
244            ))
245            .example(crate::model::Example::new(
246                "query deploy --fields canonical,summary,examples",
247                "Get only canonical name, summary, and examples for the deploy command",
248            ))
249            .example(crate::model::Example::new(
250                "query commands --fields canonical,summary",
251                "List all commands showing only canonical name and summary",
252            ))
253            .build()
254            .expect("built-in query command should always build");
255        self.registry.push(query_cmd);
256        self
257    }
258
259    /// Enable advisory warnings for mutating commands that have no `--dry-run` flag.
260    ///
261    /// When enabled (default: off), `Cli::run` (and `Cli::run_async`) will emit a
262    /// warning to stderr before dispatching a mutating command that has no `--dry-run`
263    /// flag defined on it:
264    ///
265    /// ```text
266    /// warning: mutating command 'delete' has no --dry-run flag defined
267    /// ```
268    ///
269    /// This is an advisory lint, not a hard error. It helps developers notice
270    /// missing safety flags while building CLIs with argot.
271    ///
272    /// # Examples
273    ///
274    /// ```no_run
275    /// use std::sync::Arc;
276    /// use argot_cmd::{Cli, Command};
277    ///
278    /// let cli = Cli::new(vec![
279    ///     Command::builder("delete")
280    ///         .summary("Delete a resource")
281    ///         .mutating()
282    ///         .handler(Arc::new(|_| Ok(())))
283    ///         .build()
284    ///         .unwrap(),
285    /// ])
286    /// .warn_missing_dry_run(true);
287    /// // Running `delete` will now emit a warning to stderr.
288    /// ```
289    pub fn warn_missing_dry_run(mut self, enabled: bool) -> Self {
290        self.warn_missing_dry_run = enabled;
291        self
292    }
293
294    /// Parse and dispatch a command from an iterator of string arguments.
295    ///
296    /// The iterator should **not** include the program name (`argv[0]`).
297    ///
298    /// Built-in behaviors:
299    /// - `--help` or `-h` anywhere → print help for the most-specific matched
300    ///   command and return `Ok(())`.
301    /// - `--version` or `-V` → print version string and return `Ok(())`.
302    /// - Empty input → print top-level command list and return `Ok(())`.
303    /// - Parse error → print the error to stderr, then help if possible; return
304    ///   `Err(CliError::Parse(...))`.
305    /// - No handler registered → return `Err(CliError::NoHandler(...))`.
306    ///
307    /// # Arguments
308    ///
309    /// - `args` — Iterator of argument strings, not including the program name.
310    ///
311    /// # Errors
312    ///
313    /// - [`CliError::Parse`] — the argument list could not be parsed.
314    /// - [`CliError::NoHandler`] — the resolved command has no handler.
315    /// - [`CliError::Handler`] — the handler returned an error.
316    ///
317    /// # Examples
318    ///
319    /// ```
320    /// # use std::sync::Arc;
321    /// # use argot_cmd::{Cli, Command, CliError};
322    /// let cli = Cli::new(vec![
323    ///     Command::builder("hello")
324    ///         .handler(Arc::new(|_| Ok(())))
325    ///         .build()
326    ///         .unwrap(),
327    /// ]);
328    ///
329    /// assert!(cli.run(["hello"]).is_ok());
330    /// assert!(matches!(cli.run(["--help"]), Ok(())));
331    /// ```
332    pub fn run(&self, args: impl IntoIterator<Item = impl AsRef<str>>) -> Result<(), CliError> {
333        let argv: Vec<String> = args.into_iter().map(|a| a.as_ref().to_owned()).collect();
334        let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
335
336        // ── Built-in: query support ───────────────────────────────────────────
337        if self.query_support && argv_refs.first().copied() == Some("query") {
338            return self.handle_query(&argv_refs[1..]);
339        }
340
341        // ── Built-in: --help / -h ──────────────────────────────────────────
342        if argv_refs.iter().any(|a| *a == "--help" || *a == "-h") {
343            // Strip the help flag(s) and try to identify the target command.
344            let remaining: Vec<&str> = argv_refs
345                .iter()
346                .copied()
347                .filter(|a| *a != "--help" && *a != "-h")
348                .collect();
349
350            let help_text = self.resolve_help_text(&remaining);
351            print!("{}", help_text);
352            return Ok(());
353        }
354
355        // ── Built-in: --version / -V ──────────────────────────────────────
356        if argv_refs.iter().any(|a| *a == "--version" || *a == "-V") {
357            match &self.version {
358                Some(v) if !self.app_name.is_empty() => println!("{} {}", self.app_name, v),
359                Some(v) => println!("{}", v),
360                None => println!("(no version set)"),
361            }
362            return Ok(());
363        }
364
365        // ── Built-in: empty args → list top-level commands ────────────────
366        if argv_refs.is_empty() {
367            print!(
368                "{}",
369                self.renderer
370                    .render_subcommand_list(self.registry.commands())
371            );
372            return Ok(());
373        }
374
375        // ── Normal parse ──────────────────────────────────────────────────
376        let parser = Parser::new(self.registry.commands());
377        match parser.parse(&argv_refs) {
378            Ok(parsed) => {
379                // Advisory warning: mutating command without --dry-run flag
380                if self.warn_missing_dry_run
381                    && parsed.command.mutating
382                    && !parsed.command.flags.iter().any(|f| f.name == "dry-run")
383                {
384                    eprintln!(
385                        "warning: mutating command '{}' has no --dry-run flag defined",
386                        parsed.command.canonical
387                    );
388                }
389
390                // Before dispatch: run middleware hooks
391                for mw in &self.middlewares {
392                    mw.before_dispatch(&parsed).map_err(CliError::Handler)?;
393                }
394
395                // Call handler
396                let handler_result = match &parsed.command.handler {
397                    Some(handler) => {
398                        // HandlerFn returns Box<dyn Error> (no Send+Sync bound).
399                        // We convert manually to match CliError::Handler.
400                        handler(&parsed).map_err(|e| {
401                            // Wrap in a Send+Sync-compatible error by capturing
402                            // the display string.
403                            let msg = e.to_string();
404                            let boxed: Box<dyn std::error::Error + Send + Sync> = msg.into();
405                            CliError::Handler(boxed)
406                        })
407                    }
408                    None => Err(CliError::NoHandler(parsed.command.canonical.to_string())),
409                };
410
411                // After dispatch: run middleware hooks (even on error)
412                let handler_result_for_mw: Result<(), Box<dyn std::error::Error + Send + Sync>> =
413                    match &handler_result {
414                        Ok(()) => Ok(()),
415                        Err(e) => Err(Box::<dyn std::error::Error + Send + Sync>::from(
416                            e.to_string(),
417                        )),
418                    };
419                for mw in &self.middlewares {
420                    mw.after_dispatch(&parsed, &handler_result_for_mw);
421                }
422
423                handler_result
424            }
425            Err(parse_err) => {
426                // Fire on_parse_error middleware hooks
427                for mw in &self.middlewares {
428                    mw.on_parse_error(&parse_err);
429                }
430
431                eprintln!("error: {}", parse_err);
432                if let crate::parser::ParseError::Resolve(
433                    crate::resolver::ResolveError::Unknown {
434                        ref suggestions, ..
435                    },
436                ) = parse_err
437                {
438                    if !suggestions.is_empty() {
439                        eprintln!("Did you mean one of: {}", suggestions.join(", "));
440                    }
441                }
442                // Best-effort: render help for whatever partial command we can resolve.
443                let help_text = self.resolve_help_text(&argv_refs);
444                eprint!("{}", help_text);
445                Err(CliError::Parse(parse_err))
446            }
447        }
448    }
449
450    /// Convenience: run with `std::env::args().skip(1)`.
451    ///
452    /// Equivalent to `self.run(std::env::args().skip(1))`. Skipping element 0
453    /// is required because `std::env::args` includes the program name.
454    ///
455    /// # Errors
456    ///
457    /// Same as [`Cli::run`].
458    pub fn run_env_args(&self) -> Result<(), CliError> {
459        self.run(std::env::args().skip(1))
460    }
461
462    /// Parse, dispatch, and exit the process with an appropriate exit code.
463    ///
464    /// On success exits with code `0`. On any error, prints the error to `stderr`
465    /// and exits with code `1`.
466    ///
467    /// This is the recommended entry point for binary crates that want `main`
468    /// to be a one-liner:
469    ///
470    /// ```no_run
471    /// use argot_cmd::{Cli, Command};
472    /// use std::sync::Arc;
473    ///
474    /// fn main() {
475    ///     Cli::new(vec![
476    ///         Command::builder("run")
477    ///             .handler(Arc::new(|_| Ok(())))
478    ///             .build()
479    ///             .unwrap(),
480    ///     ])
481    ///     .run_env_args_and_exit();
482    /// }
483    /// ```
484    ///
485    /// # Panics
486    ///
487    /// Does not panic; all errors are handled by printing to stderr and exiting.
488    pub fn run_and_exit(&self, args: impl IntoIterator<Item = impl AsRef<str>>) -> ! {
489        match self.run(args) {
490            Ok(()) => std::process::exit(0),
491            Err(e) => {
492                eprintln!("error: {}", e);
493                std::process::exit(1);
494            }
495        }
496    }
497
498    /// Convenience: [`run_and_exit`][Self::run_and_exit] using `std::env::args().skip(1)`.
499    pub fn run_env_args_and_exit(&self) -> ! {
500        self.run_and_exit(std::env::args().skip(1))
501    }
502
503    /// Async version of [`run_and_exit`][Self::run_and_exit].
504    ///
505    /// Must be called from an async context (e.g., `#[tokio::main]`).
506    #[cfg(feature = "async")]
507    pub async fn run_async_and_exit(&self, args: impl IntoIterator<Item = impl AsRef<str>>) -> ! {
508        match self.run_async(args).await {
509            Ok(()) => std::process::exit(0),
510            Err(e) => {
511                eprintln!("error: {}", e);
512                std::process::exit(1);
513            }
514        }
515    }
516
517    /// Convenience: [`run_async_and_exit`][Self::run_async_and_exit] using `std::env::args().skip(1)`.
518    #[cfg(feature = "async")]
519    pub async fn run_env_args_async_and_exit(&self) -> ! {
520        self.run_async_and_exit(std::env::args().skip(1)).await
521    }
522
523    /// Parse and dispatch a command asynchronously.
524    ///
525    /// Behaves identically to [`Cli::run`] but also invokes
526    /// [`AsyncHandlerFn`][crate::model::AsyncHandlerFn] handlers
527    /// registered with [`crate::CommandBuilder::async_handler`].
528    ///
529    /// Must be called from an async context (e.g., inside `#[tokio::main]`).
530    ///
531    /// Dispatch priority: async handler → sync handler → `CliError::NoHandler`.
532    ///
533    /// # Feature
534    ///
535    /// Requires the `async` feature flag.
536    ///
537    /// # Errors
538    ///
539    /// Same variants as [`Cli::run`].
540    #[cfg(feature = "async")]
541    pub async fn run_async(
542        &self,
543        args: impl IntoIterator<Item = impl AsRef<str>>,
544    ) -> Result<(), CliError> {
545        let args: Vec<String> = args.into_iter().map(|a| a.as_ref().to_string()).collect();
546        let argv: Vec<&str> = args.iter().map(String::as_str).collect();
547
548        // ── Built-in: query support ───────────────────────────────────────────
549        if self.query_support && argv.first().copied() == Some("query") {
550            let refs: Vec<&str> = argv.to_vec();
551            return self.handle_query(&refs[1..]);
552        }
553
554        // ── Built-in: --help / -h ──────────────────────────────────────────
555        if argv.iter().any(|a| *a == "--help" || *a == "-h") {
556            let remaining: Vec<&str> = argv
557                .iter()
558                .copied()
559                .filter(|a| *a != "--help" && *a != "-h")
560                .collect();
561            let help_text = self.resolve_help_text(&remaining);
562            print!("{}", help_text);
563            return Ok(());
564        }
565
566        // ── Built-in: --version / -V ──────────────────────────────────────
567        if argv.iter().any(|a| *a == "--version" || *a == "-V") {
568            match &self.version {
569                Some(v) if !self.app_name.is_empty() => println!("{} {}", self.app_name, v),
570                Some(v) => println!("{}", v),
571                None => println!("(no version set)"),
572            }
573            return Ok(());
574        }
575
576        // ── Built-in: empty args → list top-level commands ────────────────
577        if argv.is_empty() {
578            print!(
579                "{}",
580                self.renderer
581                    .render_subcommand_list(self.registry.commands())
582            );
583            return Ok(());
584        }
585
586        // ── Normal parse ──────────────────────────────────────────────────
587        let parser = Parser::new(self.registry.commands());
588        match parser.parse(&argv) {
589            Ok(parsed) => {
590                // Advisory warning: mutating command without --dry-run flag
591                if self.warn_missing_dry_run
592                    && parsed.command.mutating
593                    && !parsed.command.flags.iter().any(|f| f.name == "dry-run")
594                {
595                    eprintln!(
596                        "warning: mutating command '{}' has no --dry-run flag defined",
597                        parsed.command.canonical
598                    );
599                }
600
601                // Before dispatch: run middleware hooks
602                for mw in &self.middlewares {
603                    mw.before_dispatch(&parsed).map_err(CliError::Handler)?;
604                }
605
606                // Prefer async handler over sync handler
607                let handler_result = if let Some(ref async_handler) = parsed.command.async_handler {
608                    async_handler(&parsed).await.map_err(|e| {
609                        let msg = e.to_string();
610                        let boxed: Box<dyn std::error::Error + Send + Sync> = msg.into();
611                        CliError::Handler(boxed)
612                    })
613                } else if let Some(ref handler) = parsed.command.handler {
614                    handler(&parsed).map_err(|e| {
615                        let msg = e.to_string();
616                        let boxed: Box<dyn std::error::Error + Send + Sync> = msg.into();
617                        CliError::Handler(boxed)
618                    })
619                } else {
620                    Err(CliError::NoHandler(parsed.command.canonical.clone()))
621                };
622
623                // After dispatch: run middleware hooks (even on error)
624                let handler_result_for_mw: Result<(), Box<dyn std::error::Error + Send + Sync>> =
625                    match &handler_result {
626                        Ok(()) => Ok(()),
627                        Err(e) => Err(Box::<dyn std::error::Error + Send + Sync>::from(
628                            e.to_string(),
629                        )),
630                    };
631                for mw in &self.middlewares {
632                    mw.after_dispatch(&parsed, &handler_result_for_mw);
633                }
634
635                handler_result
636            }
637            Err(parse_err) => {
638                // Fire on_parse_error middleware hooks
639                for mw in &self.middlewares {
640                    mw.on_parse_error(&parse_err);
641                }
642
643                eprintln!("error: {}", parse_err);
644                if let crate::parser::ParseError::Resolve(
645                    crate::resolver::ResolveError::Unknown {
646                        ref suggestions, ..
647                    },
648                ) = parse_err
649                {
650                    if !suggestions.is_empty() {
651                        eprintln!("Did you mean one of: {}", suggestions.join(", "));
652                    }
653                }
654                let help_text = self.resolve_help_text(&argv);
655                eprint!("{}", help_text);
656                Err(CliError::Parse(parse_err))
657            }
658        }
659    }
660
661    /// Convenience: `run_async` using `std::env::args().skip(1)`.
662    #[cfg(feature = "async")]
663    pub async fn run_env_args_async(&self) -> Result<(), CliError> {
664        self.run_async(std::env::args().skip(1)).await
665    }
666
667    // ── Private helpers ───────────────────────────────────────────────────
668
669    fn handle_query(&self, args: &[&str]) -> Result<(), CliError> {
670        // Strip --json flag (JSON is always the output format; --json accepted for compatibility).
671        // Extract --stream and --fields flags; --json is a no-op for compat.
672        let mut stream = false;
673        let mut fields_opt: Option<String> = None;
674        let mut positional: Vec<&str> = Vec::new();
675
676        let mut iter = args.iter().copied().peekable();
677        while let Some(arg) = iter.next() {
678            if arg == "--json" {
679                // accepted for compatibility, no-op
680            } else if arg == "--stream" {
681                stream = true;
682            } else if arg == "--fields" {
683                if let Some(val) = iter.next() {
684                    fields_opt = Some(val.to_owned());
685                }
686            } else if let Some(val) = arg.strip_prefix("--fields=") {
687                fields_opt = Some(val.to_owned());
688            } else {
689                positional.push(arg);
690            }
691        }
692        let args = positional.as_slice();
693
694        let field_strings: Vec<String> = fields_opt
695            .as_deref()
696            .unwrap_or("")
697            .split(',')
698            .map(|f| f.trim().to_owned())
699            .filter(|f| !f.is_empty())
700            .collect();
701        let fields: Vec<&str> = field_strings.iter().map(String::as_str).collect();
702
703        match args.first().copied() {
704            // `query commands` → JSON array of all top-level commands (or NDJSON if --stream)
705            None | Some("commands") => {
706                if stream {
707                    let ndjson = self
708                        .registry
709                        .to_ndjson_with_fields(&fields)
710                        .map_err(|e| {
711                            CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
712                                e.to_string(),
713                            ))
714                        })?;
715                    print!("{}", ndjson);
716                } else {
717                    let json = self
718                        .registry
719                        .to_json_with_fields(&fields)
720                        .map_err(|e| {
721                            CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
722                                e.to_string(),
723                            ))
724                        })?;
725                    println!("{}", json);
726                }
727                Ok(())
728            }
729            // `query examples <name>` → JSON array of examples for the named command
730            Some("examples") => {
731                let name = args.get(1).copied().ok_or_else(|| {
732                    CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
733                        "usage: query examples <command-name>",
734                    ))
735                })?;
736                let cmd = self
737                    .registry
738                    .get_command(name)
739                    .or_else(|| {
740                        let resolver = crate::resolver::Resolver::new(self.registry.commands());
741                        resolver.resolve(name).ok()
742                    })
743                    .ok_or_else(|| {
744                        CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
745                            format!("unknown command: `{}`", name),
746                        ))
747                    })?;
748                let json = serde_json::to_string_pretty(&cmd.examples).map_err(|e| {
749                    CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
750                        e.to_string(),
751                    ))
752                })?;
753                println!("{}", json);
754                Ok(())
755            }
756            // `query <name>` → JSON (or NDJSON if --stream) for the named command
757            Some(name) => {
758                // First try exact match, then resolver (which handles prefix/alias).
759                let cmd = self.registry.get_command(name);
760                if let Some(cmd) = cmd {
761                    if stream {
762                        let line = crate::query::command_to_ndjson(cmd).map_err(|e| {
763                            CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
764                                e.to_string(),
765                            ))
766                        })?;
767                        println!("{}", line);
768                    } else {
769                        let json =
770                            crate::query::command_to_json_with_fields(cmd, &fields).map_err(|e| {
771                                CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
772                                    e.to_string(),
773                                ))
774                            })?;
775                        println!("{}", json);
776                    }
777                    return Ok(());
778                }
779
780                // Try resolver for prefix/alias matching; handle ambiguity with structured JSON.
781                let resolver = crate::resolver::Resolver::new(self.registry.commands());
782                match resolver.resolve(name) {
783                    Ok(cmd) => {
784                        if stream {
785                            let line = crate::query::command_to_ndjson(cmd).map_err(|e| {
786                                CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
787                                    e.to_string(),
788                                ))
789                            })?;
790                            println!("{}", line);
791                        } else {
792                            let json = crate::query::command_to_json_with_fields(cmd, &fields)
793                                .map_err(|e| {
794                                    CliError::Handler(
795                                        Box::<dyn std::error::Error + Send + Sync>::from(
796                                            e.to_string(),
797                                        ),
798                                    )
799                                })?;
800                            println!("{}", json);
801                        }
802                        Ok(())
803                    }
804                    Err(crate::resolver::ResolveError::Ambiguous { input, candidates }) => {
805                        // Agents should receive data, not errors — emit structured JSON.
806                        let json = serde_json::json!({
807                            "error": "ambiguous",
808                            "input": input,
809                            "candidates": candidates,
810                        });
811                        println!("{}", json);
812                        Ok(())
813                    }
814                    Err(crate::resolver::ResolveError::Unknown { .. }) => Err(CliError::Handler(
815                        Box::<dyn std::error::Error + Send + Sync>::from(format!(
816                            "unknown command: `{}`",
817                            name
818                        )),
819                    )),
820                }
821            }
822        }
823    }
824
825    /// Walk the arg list and return the help text for the deepest command that
826    /// can be resolved. Falls back to the top-level command list if nothing
827    /// resolves.
828    fn resolve_help_text(&self, argv: &[&str]) -> String {
829        // Try to walk the command tree as far as possible.
830        if argv.is_empty() {
831            return self
832                .renderer
833                .render_subcommand_list(self.registry.commands());
834        }
835
836        // Skip any flag-looking tokens for the purpose of command resolution.
837        let words: Vec<&str> = argv
838            .iter()
839            .copied()
840            .filter(|a| !a.starts_with('-'))
841            .collect();
842
843        if words.is_empty() {
844            return self
845                .renderer
846                .render_subcommand_list(self.registry.commands());
847        }
848
849        // Resolve the first word as a top-level command.
850        let resolver = Resolver::new(self.registry.commands());
851        let top_cmd = match resolver.resolve(words[0]) {
852            Ok(cmd) => cmd,
853            Err(_) => {
854                return self
855                    .renderer
856                    .render_subcommand_list(self.registry.commands())
857            }
858        };
859
860        // Walk into subcommands as far as possible.
861        let mut current = top_cmd;
862        for word in words.iter().skip(1) {
863            if current.subcommands.is_empty() {
864                break;
865            }
866            let sub_resolver = Resolver::new(&current.subcommands);
867            match sub_resolver.resolve(word) {
868                Ok(sub) => current = sub,
869                Err(_) => break,
870            }
871        }
872
873        self.renderer.render_help(current)
874    }
875}
876
877// ── Tests ─────────────────────────────────────────────────────────────────────
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use crate::model::Command;
883    use std::sync::{Arc, Mutex};
884
885    fn make_cli_no_handler() -> Cli {
886        let cmd = Command::builder("greet")
887            .summary("Say hello")
888            .build()
889            .unwrap();
890        Cli::new(vec![cmd]).app_name("testapp").version("1.2.3")
891    }
892
893    fn make_cli_with_handler(called: Arc<Mutex<bool>>) -> Cli {
894        let cmd = Command::builder("greet")
895            .summary("Say hello")
896            .handler(Arc::new(move |_parsed| {
897                *called.lock().unwrap() = true;
898                Ok(())
899            }))
900            .build()
901            .unwrap();
902        Cli::new(vec![cmd]).app_name("testapp").version("1.2.3")
903    }
904
905    #[test]
906    fn test_run_empty_args() {
907        let cli = make_cli_no_handler();
908        let result = cli.run(std::iter::empty::<&str>());
909        assert!(result.is_ok(), "empty args should return Ok");
910    }
911
912    #[test]
913    fn test_run_help_flag() {
914        let cli = make_cli_no_handler();
915        let result = cli.run(["--help"]);
916        assert!(result.is_ok(), "--help should return Ok");
917    }
918
919    #[test]
920    fn test_run_help_flag_short() {
921        let cli = make_cli_no_handler();
922        let result = cli.run(["-h"]);
923        assert!(result.is_ok(), "-h should return Ok");
924    }
925
926    #[test]
927    fn test_run_version_flag() {
928        let cli = make_cli_no_handler();
929        let result = cli.run(["--version"]);
930        assert!(result.is_ok(), "--version should return Ok");
931    }
932
933    #[test]
934    fn test_run_version_flag_short() {
935        let cli = make_cli_no_handler();
936        let result = cli.run(["-V"]);
937        assert!(result.is_ok(), "-V should return Ok");
938    }
939
940    #[test]
941    fn test_run_no_handler() {
942        let cli = make_cli_no_handler();
943        let result = cli.run(["greet"]);
944        assert!(
945            matches!(result, Err(CliError::NoHandler(ref name)) if name == "greet"),
946            "expected NoHandler(\"greet\"), got {:?}",
947            result
948        );
949    }
950
951    #[test]
952    fn test_run_with_handler() {
953        let called = Arc::new(Mutex::new(false));
954        let cli = make_cli_with_handler(called.clone());
955        let result = cli.run(["greet"]);
956        assert!(result.is_ok(), "handler should succeed, got {:?}", result);
957        assert!(*called.lock().unwrap(), "handler should have been called");
958    }
959
960    #[test]
961    fn test_run_unknown_command() {
962        let cli = make_cli_no_handler();
963        let result = cli.run(["unknowncmd"]);
964        assert!(
965            matches!(result, Err(CliError::Parse(_))),
966            "unknown command should yield Parse error, got {:?}",
967            result
968        );
969    }
970
971    #[test]
972    fn test_run_handler_error_wrapped() {
973        use std::sync::Arc;
974        let cmd = crate::model::Command::builder("fail")
975            .handler(Arc::new(|_| {
976                Err(Box::<dyn std::error::Error>::from("something went wrong"))
977            }))
978            .build()
979            .unwrap();
980        let cli = super::Cli::new(vec![cmd]);
981        let result = cli.run(["fail"]);
982        assert!(result.is_err());
983        match result {
984            Err(super::CliError::Handler(e)) => {
985                assert!(e.to_string().contains("something went wrong"));
986            }
987            other => panic!("expected CliError::Handler, got {:?}", other),
988        }
989    }
990
991    #[test]
992    fn test_run_command_named_help_dispatches_correctly() {
993        // A command named "help" passed through Cli should be dispatched,
994        // not intercepted as a built-in --help flag.
995        // This verifies Cli only intercepts "--help" flag, not the word "help".
996        use std::sync::atomic::{AtomicBool, Ordering};
997        use std::sync::Arc;
998        let called = Arc::new(AtomicBool::new(false));
999        let called2 = called.clone();
1000        let cmd = crate::model::Command::builder("help")
1001            .handler(Arc::new(move |_| {
1002                called2.store(true, Ordering::SeqCst);
1003                Ok(())
1004            }))
1005            .build()
1006            .unwrap();
1007        let cli = super::Cli::new(vec![cmd]);
1008        cli.run(["help"]).unwrap();
1009        assert!(
1010            called.load(Ordering::SeqCst),
1011            "handler should have been called"
1012        );
1013    }
1014
1015    #[test]
1016    fn test_middleware_before_dispatch_called() {
1017        use crate::middleware::Middleware;
1018        use std::sync::atomic::{AtomicBool, Ordering};
1019        use std::sync::Arc;
1020
1021        struct Flag(Arc<AtomicBool>);
1022        impl Middleware for Flag {
1023            fn before_dispatch(
1024                &self,
1025                _: &crate::model::ParsedCommand<'_>,
1026            ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1027                self.0.store(true, Ordering::SeqCst);
1028                Ok(())
1029            }
1030        }
1031
1032        let called = Arc::new(AtomicBool::new(false));
1033        let handler_called = Arc::new(AtomicBool::new(false));
1034        let handler_called2 = handler_called.clone();
1035
1036        let cmd = crate::model::Command::builder("run")
1037            .handler(std::sync::Arc::new(move |_| {
1038                handler_called2.store(true, Ordering::SeqCst);
1039                Ok(())
1040            }))
1041            .build()
1042            .unwrap();
1043
1044        let cli = super::Cli::new(vec![cmd]).with_middleware(Flag(called.clone()));
1045        cli.run(["run"]).unwrap();
1046
1047        assert!(called.load(Ordering::SeqCst));
1048        assert!(handler_called.load(Ordering::SeqCst));
1049    }
1050
1051    #[test]
1052    fn test_middleware_can_abort_dispatch() {
1053        use crate::middleware::Middleware;
1054        struct Aborter;
1055        impl Middleware for Aborter {
1056            fn before_dispatch(
1057                &self,
1058                _: &crate::model::ParsedCommand<'_>,
1059            ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1060                Err("aborted by middleware".into())
1061            }
1062        }
1063
1064        let cmd = crate::model::Command::builder("run")
1065            .handler(std::sync::Arc::new(|_| panic!("should not be called")))
1066            .build()
1067            .unwrap();
1068
1069        let cli = super::Cli::new(vec![cmd]).with_middleware(Aborter);
1070        assert!(cli.run(["run"]).is_err());
1071    }
1072
1073    #[test]
1074    fn test_query_commands_outputs_json() {
1075        use crate::model::Command;
1076        let cli = super::Cli::new(vec![
1077            Command::builder("deploy")
1078                .summary("Deploy")
1079                .build()
1080                .unwrap(),
1081            Command::builder("status")
1082                .summary("Status")
1083                .build()
1084                .unwrap(),
1085        ])
1086        .with_query_support();
1087
1088        // Should not error (we can't easily capture stdout in unit tests,
1089        // but we verify the dispatch path succeeds).
1090        assert!(cli.run(["query", "commands"]).is_ok());
1091    }
1092
1093    #[test]
1094    fn test_query_named_command_outputs_json() {
1095        use crate::model::Command;
1096        let cli = super::Cli::new(vec![Command::builder("deploy")
1097            .summary("Deploy svc")
1098            .build()
1099            .unwrap()])
1100        .with_query_support();
1101
1102        assert!(cli.run(["query", "deploy"]).is_ok());
1103    }
1104
1105    #[test]
1106    fn test_query_unknown_command_errors() {
1107        use crate::model::Command;
1108        let cli =
1109            super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1110
1111        assert!(cli.run(["query", "nonexistent"]).is_err());
1112    }
1113
1114    #[test]
1115    fn test_query_meta_command_appears_in_registry() {
1116        use crate::model::Command;
1117        let cli =
1118            super::Cli::new(vec![Command::builder("run").build().unwrap()]).with_query_support();
1119
1120        // The injected `query` command should be discoverable.
1121        assert!(cli.registry.get_command("query").is_some());
1122    }
1123
1124    #[test]
1125    fn test_query_with_json_flag() {
1126        use crate::model::Command;
1127        let cli = super::Cli::new(vec![Command::builder("deploy")
1128            .summary("Deploy")
1129            .build()
1130            .unwrap()])
1131        .with_query_support();
1132        // --json flag must not cause an error
1133        assert!(cli.run(["query", "deploy", "--json"]).is_ok());
1134        assert!(cli.run(["query", "commands", "--json"]).is_ok());
1135    }
1136
1137    #[test]
1138    fn test_query_ambiguous_returns_structured_json() {
1139        use crate::model::Command;
1140        // Two commands sharing the prefix "dep" make resolution ambiguous.
1141        let cli = super::Cli::new(vec![
1142            Command::builder("deploy")
1143                .summary("Deploy")
1144                .build()
1145                .unwrap(),
1146            Command::builder("describe")
1147                .summary("Describe")
1148                .build()
1149                .unwrap(),
1150        ])
1151        .with_query_support();
1152
1153        // Before the fix this would have returned Err; now it must return Ok(())
1154        // and print structured JSON to stdout.
1155        let result = cli.run(["query", "dep"]);
1156        assert!(
1157            result.is_ok(),
1158            "ambiguous query should return Ok(()) with JSON on stdout, got {:?}",
1159            result
1160        );
1161    }
1162
1163    #[test]
1164    fn test_query_examples_returns_examples() {
1165        use crate::model::{Command, Example};
1166        let cli = super::Cli::new(vec![Command::builder("deploy")
1167            .summary("Deploy svc")
1168            .example(Example::new(
1169                "Deploy to production",
1170                "deploy api --env prod",
1171            ))
1172            .build()
1173            .unwrap()])
1174        .with_query_support();
1175
1176        let result = cli.run(["query", "examples", "deploy"]);
1177        assert!(
1178            result.is_ok(),
1179            "query examples for known command should return Ok(()), got {:?}",
1180            result
1181        );
1182    }
1183
1184    #[test]
1185    fn test_query_examples_unknown_errors() {
1186        use crate::model::Command;
1187        let cli =
1188            super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1189
1190        let result = cli.run(["query", "examples", "nonexistent"]);
1191        assert!(
1192            result.is_err(),
1193            "query examples for unknown command should return Err, got {:?}",
1194            result
1195        );
1196    }
1197
1198    #[test]
1199    fn test_warn_missing_dry_run_enabled_dispatches_ok() {
1200        // warn_missing_dry_run should not prevent dispatch — it is advisory only.
1201        use crate::model::Command;
1202        use std::sync::atomic::{AtomicBool, Ordering};
1203        use std::sync::Arc;
1204
1205        let called = Arc::new(AtomicBool::new(false));
1206        let called2 = called.clone();
1207
1208        let cmd = Command::builder("delete")
1209            .summary("Delete a resource")
1210            .mutating()
1211            .handler(Arc::new(move |_| {
1212                called2.store(true, Ordering::SeqCst);
1213                Ok(())
1214            }))
1215            .build()
1216            .unwrap();
1217
1218        let cli = super::Cli::new(vec![cmd]).warn_missing_dry_run(true);
1219        // Dispatch should succeed even though there's no --dry-run flag.
1220        let result = cli.run(["delete"]);
1221        assert!(result.is_ok(), "dispatch should succeed, got {:?}", result);
1222        assert!(called.load(Ordering::SeqCst), "handler should have been called");
1223    }
1224
1225    #[test]
1226    fn test_warn_missing_dry_run_with_dry_run_flag_no_warn() {
1227        // Even with warn enabled, a command that has --dry-run should not warn.
1228        use crate::model::{Command, Flag};
1229        use std::sync::Arc;
1230
1231        let cmd = Command::builder("delete")
1232            .summary("Delete a resource")
1233            .mutating()
1234            .flag(Flag::builder("dry-run").description("Simulate").build().unwrap())
1235            .handler(Arc::new(|_| Ok(())))
1236            .build()
1237            .unwrap();
1238
1239        let cli = super::Cli::new(vec![cmd]).warn_missing_dry_run(true);
1240        // This test verifies the code path doesn't crash; we can't easily
1241        // capture stderr in unit tests, but the absence of a panic is the key check.
1242        let result = cli.run(["delete"]);
1243        assert!(result.is_ok(), "dispatch should succeed, got {:?}", result);
1244    }
1245
1246    #[test]
1247    fn test_warn_missing_dry_run_disabled_no_effect() {
1248        // With warn disabled (default), mutating commands without --dry-run are fine.
1249        use crate::model::Command;
1250        use std::sync::Arc;
1251
1252        let cmd = Command::builder("delete")
1253            .summary("Delete a resource")
1254            .mutating()
1255            .handler(Arc::new(|_| Ok(())))
1256            .build()
1257            .unwrap();
1258
1259        // Default: warn_missing_dry_run is false.
1260        let cli = super::Cli::new(vec![cmd]);
1261        let result = cli.run(["delete"]);
1262        assert!(result.is_ok(), "dispatch should succeed, got {:?}", result);
1263    }
1264
1265    // ── Async unit tests ──────────────────────────────────────────────────────
1266
1267    #[cfg(feature = "async")]
1268    #[tokio::test]
1269    async fn test_run_async_empty_args() {
1270        let cli = make_cli_no_handler();
1271        let result = cli.run_async(std::iter::empty::<&str>()).await;
1272        assert!(
1273            result.is_ok(),
1274            "empty args should return Ok, got {:?}",
1275            result
1276        );
1277    }
1278
1279    #[cfg(feature = "async")]
1280    #[tokio::test]
1281    async fn test_run_async_help_flag() {
1282        let cli = make_cli_no_handler();
1283        let result = cli.run_async(["--help"]).await;
1284        assert!(result.is_ok(), "--help should return Ok, got {:?}", result);
1285    }
1286
1287    #[cfg(feature = "async")]
1288    #[tokio::test]
1289    async fn test_run_async_version_flag() {
1290        let cli = make_cli_no_handler();
1291        let result = cli.run_async(["--version"]).await;
1292        assert!(
1293            result.is_ok(),
1294            "--version should return Ok, got {:?}",
1295            result
1296        );
1297    }
1298
1299    #[cfg(feature = "async")]
1300    #[tokio::test]
1301    async fn test_run_async_with_handler() {
1302        use std::sync::atomic::{AtomicBool, Ordering};
1303        let called = Arc::new(AtomicBool::new(false));
1304        let called2 = called.clone();
1305        let cmd = Command::builder("greet")
1306            .summary("Say hello")
1307            .handler(Arc::new(move |_parsed| {
1308                called2.store(true, Ordering::SeqCst);
1309                Ok(())
1310            }))
1311            .build()
1312            .unwrap();
1313        let cli = super::Cli::new(vec![cmd])
1314            .app_name("testapp")
1315            .version("1.2.3");
1316        let result = cli.run_async(["greet"]).await;
1317        assert!(result.is_ok(), "handler should succeed, got {:?}", result);
1318        assert!(
1319            called.load(Ordering::SeqCst),
1320            "handler should have been called"
1321        );
1322    }
1323
1324    #[cfg(feature = "async")]
1325    #[tokio::test]
1326    async fn test_run_async_unknown_command() {
1327        let cli = make_cli_no_handler();
1328        let result = cli.run_async(["unknowncmd"]).await;
1329        assert!(
1330            matches!(result, Err(CliError::Parse(_))),
1331            "unknown command should yield Parse error, got {:?}",
1332            result
1333        );
1334    }
1335
1336    #[test]
1337    fn test_version_without_app_name() {
1338        let cmd = Command::builder("greet").build().unwrap();
1339        // version set but no app_name — should print just the version
1340        let cli = super::Cli::new(vec![cmd]).version("2.0.0");
1341        assert!(cli.run(["--version"]).is_ok());
1342    }
1343
1344    #[test]
1345    fn test_version_not_set() {
1346        let cmd = Command::builder("greet").build().unwrap();
1347        // no version at all
1348        let cli = super::Cli::new(vec![cmd]);
1349        assert!(cli.run(["--version"]).is_ok());
1350    }
1351
1352    #[test]
1353    fn test_middleware_after_dispatch_called_on_success() {
1354        use crate::middleware::Middleware;
1355        use std::sync::atomic::{AtomicBool, Ordering};
1356        use std::sync::Arc;
1357
1358        struct AfterFlag(Arc<AtomicBool>);
1359        impl Middleware for AfterFlag {
1360            fn after_dispatch(
1361                &self,
1362                _: &crate::model::ParsedCommand<'_>,
1363                _: &Result<(), Box<dyn std::error::Error + Send + Sync>>,
1364            ) {
1365                self.0.store(true, Ordering::SeqCst);
1366            }
1367        }
1368
1369        let called = Arc::new(AtomicBool::new(false));
1370        let cmd = Command::builder("run")
1371            .handler(Arc::new(|_| Ok(())))
1372            .build()
1373            .unwrap();
1374        let cli = super::Cli::new(vec![cmd]).with_middleware(AfterFlag(called.clone()));
1375        cli.run(["run"]).unwrap();
1376        assert!(called.load(Ordering::SeqCst));
1377    }
1378
1379    #[test]
1380    fn test_middleware_after_dispatch_called_on_error() {
1381        use crate::middleware::Middleware;
1382        use std::sync::atomic::{AtomicBool, Ordering};
1383        use std::sync::Arc;
1384
1385        struct AfterFlag(Arc<AtomicBool>);
1386        impl Middleware for AfterFlag {
1387            fn after_dispatch(
1388                &self,
1389                _: &crate::model::ParsedCommand<'_>,
1390                _: &Result<(), Box<dyn std::error::Error + Send + Sync>>,
1391            ) {
1392                self.0.store(true, Ordering::SeqCst);
1393            }
1394        }
1395
1396        let called = Arc::new(AtomicBool::new(false));
1397        let cmd = Command::builder("run")
1398            .handler(Arc::new(|_| Err("handler error".into())))
1399            .build()
1400            .unwrap();
1401        let cli = super::Cli::new(vec![cmd]).with_middleware(AfterFlag(called.clone()));
1402        let _ = cli.run(["run"]);
1403        assert!(called.load(Ordering::SeqCst));
1404    }
1405
1406    #[test]
1407    fn test_middleware_on_parse_error_called() {
1408        use crate::middleware::Middleware;
1409        use std::sync::atomic::{AtomicBool, Ordering};
1410        use std::sync::Arc;
1411
1412        struct OnErrFlag(Arc<AtomicBool>);
1413        impl Middleware for OnErrFlag {
1414            fn on_parse_error(&self, _: &crate::parser::ParseError) {
1415                self.0.store(true, Ordering::SeqCst);
1416            }
1417        }
1418
1419        let called = Arc::new(AtomicBool::new(false));
1420        let cmd = Command::builder("run").build().unwrap();
1421        let cli = super::Cli::new(vec![cmd]).with_middleware(OnErrFlag(called.clone()));
1422        let _ = cli.run(["unknown_xyz"]);
1423        assert!(called.load(Ordering::SeqCst));
1424    }
1425
1426    #[test]
1427    fn test_unknown_command_with_suggestions() {
1428        // "gree" is close to "greet" — should include suggestions in stderr
1429        let cmd = Command::builder("greet").build().unwrap();
1430        let cli = super::Cli::new(vec![cmd]);
1431        let result = cli.run(["gree"]);
1432        // Should fail with parse error (suggestion logic in the error path)
1433        assert!(result.is_err());
1434    }
1435
1436    #[test]
1437    fn test_help_for_subcommand() {
1438        // --help with a known subcommand resolves to that command's help
1439        let sub = Command::builder("rollback")
1440            .summary("Roll back")
1441            .build()
1442            .unwrap();
1443        let parent = Command::builder("deploy")
1444            .summary("Deploy")
1445            .subcommand(sub)
1446            .build()
1447            .unwrap();
1448        let cli = super::Cli::new(vec![parent]);
1449        let result = cli.run(["deploy", "rollback", "--help"]);
1450        assert!(result.is_ok());
1451    }
1452
1453    #[test]
1454    fn test_help_with_only_flags() {
1455        // --help with only flag-like tokens (no command words) renders top-level list
1456        let cmd = Command::builder("greet").build().unwrap();
1457        let cli = super::Cli::new(vec![cmd]);
1458        let result = cli.run(["--flag", "--help"]);
1459        assert!(result.is_ok());
1460    }
1461
1462    #[test]
1463    fn test_help_for_unknown_command() {
1464        // --help with an unknown command name falls back to top-level list
1465        let cmd = Command::builder("greet").build().unwrap();
1466        let cli = super::Cli::new(vec![cmd]);
1467        let result = cli.run(["unknowncmd", "--help"]);
1468        assert!(result.is_ok());
1469    }
1470
1471    #[test]
1472    fn test_query_with_no_arg_outputs_json() {
1473        // "query" alone (no subcommand) is same as "query commands"
1474        use crate::model::Command;
1475        let cli =
1476            super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1477        assert!(cli.run(["query"]).is_ok());
1478    }
1479
1480    #[test]
1481    fn test_query_examples_via_resolver() {
1482        // query examples where exact match fails but resolver finds it by prefix
1483        use crate::model::{Command, Example};
1484        let cli = super::Cli::new(vec![Command::builder("deploy")
1485            .summary("Deploy")
1486            .example(Example::new("prod", "deploy prod"))
1487            .build()
1488            .unwrap()])
1489        .with_query_support();
1490        // "dep" prefix-resolves to "deploy"
1491        let result = cli.run(["query", "examples", "dep"]);
1492        assert!(
1493            result.is_ok(),
1494            "query examples via prefix should succeed, got {:?}",
1495            result
1496        );
1497    }
1498
1499    #[test]
1500    fn test_query_named_command_via_resolver() {
1501        // query <name> where exact match fails but resolver finds by prefix
1502        use crate::model::Command;
1503        let cli = super::Cli::new(vec![Command::builder("deploy")
1504            .summary("Deploy")
1505            .build()
1506            .unwrap()])
1507        .with_query_support();
1508        // "dep" prefix-resolves to "deploy"
1509        let result = cli.run(["query", "dep"]);
1510        assert!(
1511            result.is_ok(),
1512            "query prefix-resolved name should succeed, got {:?}",
1513            result
1514        );
1515    }
1516
1517    #[test]
1518    fn test_query_examples_no_name_errors() {
1519        use crate::model::Command;
1520        let cli =
1521            super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1522        // "query examples" with no command name should error
1523        let result = cli.run(["query", "examples"]);
1524        assert!(result.is_err(), "query examples with no name should error");
1525    }
1526
1527    #[test]
1528    fn test_query_commands_stream_succeeds() {
1529        use crate::model::Command;
1530        let cli = super::Cli::new(vec![
1531            Command::builder("deploy").summary("Deploy").build().unwrap(),
1532            Command::builder("status").summary("Status").build().unwrap(),
1533        ])
1534        .with_query_support();
1535        let result = cli.run(["query", "commands", "--stream"]);
1536        assert!(
1537            result.is_ok(),
1538            "query commands --stream should return Ok, got {:?}",
1539            result
1540        );
1541    }
1542
1543    #[test]
1544    fn test_query_commands_stream_with_fields_succeeds() {
1545        use crate::model::Command;
1546        let cli = super::Cli::new(vec![
1547            Command::builder("deploy").summary("Deploy").build().unwrap(),
1548        ])
1549        .with_query_support();
1550        let result = cli.run(["query", "commands", "--stream", "--fields", "canonical,summary"]);
1551        assert!(
1552            result.is_ok(),
1553            "query commands --stream --fields should return Ok, got {:?}",
1554            result
1555        );
1556    }
1557
1558    #[test]
1559    fn test_query_named_command_stream_succeeds() {
1560        use crate::model::Command;
1561        let cli = super::Cli::new(vec![Command::builder("deploy")
1562            .summary("Deploy svc")
1563            .build()
1564            .unwrap()])
1565        .with_query_support();
1566        let result = cli.run(["query", "deploy", "--stream"]);
1567        assert!(
1568            result.is_ok(),
1569            "query <name> --stream should return Ok, got {:?}",
1570            result
1571        );
1572    }
1573
1574    #[test]
1575    fn test_query_named_command_via_resolver_stream_succeeds() {
1576        use crate::model::Command;
1577        let cli = super::Cli::new(vec![Command::builder("deploy")
1578            .summary("Deploy svc")
1579            .build()
1580            .unwrap()])
1581        .with_query_support();
1582        // "dep" prefix-resolves to "deploy"
1583        let result = cli.run(["query", "dep", "--stream"]);
1584        assert!(
1585            result.is_ok(),
1586            "query prefix-resolved --stream should return Ok, got {:?}",
1587            result
1588        );
1589    }
1590
1591    #[test]
1592    fn test_query_stream_bare_query_succeeds() {
1593        // "query --stream" alone (no subcommand) is same as "query commands --stream"
1594        use crate::model::Command;
1595        let cli =
1596            super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1597        let result = cli.run(["query", "--stream"]);
1598        assert!(
1599            result.is_ok(),
1600            "query --stream (no subcommand) should return Ok, got {:?}",
1601            result
1602        );
1603    }
1604}