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}
100
101impl Cli {
102    /// Create a new `Cli` from a list of top-level commands.
103    ///
104    /// # Arguments
105    ///
106    /// - `commands` — The fully-built top-level command list. Ownership is
107    ///   transferred to an internal [`Registry`].
108    pub fn new(commands: Vec<crate::model::Command>) -> Self {
109        Self {
110            registry: Registry::new(commands),
111            app_name: String::new(),
112            version: None,
113            middlewares: vec![],
114            renderer: Box::new(DefaultRenderer),
115            query_support: false,
116        }
117    }
118
119    /// Set the application name (shown in version output and top-level help).
120    ///
121    /// If not set, the version string is printed without a prefix.
122    pub fn app_name(mut self, name: impl Into<String>) -> Self {
123        self.app_name = name.into();
124        self
125    }
126
127    /// Set the application version (shown by `--version` / `-V`).
128    ///
129    /// If not set, `"(no version set)"` is printed.
130    pub fn version(mut self, version: impl Into<String>) -> Self {
131        self.version = Some(version.into());
132        self
133    }
134
135    /// Register a middleware that hooks into the parse-and-dispatch lifecycle.
136    ///
137    /// Middlewares are invoked in registration order. Multiple middlewares can
138    /// be added by calling `with_middleware` repeatedly.
139    ///
140    /// # Examples
141    ///
142    /// ```no_run
143    /// use argot_cmd::{Cli, Command, middleware::Middleware};
144    ///
145    /// struct Audit;
146    /// impl Middleware for Audit {
147    ///     fn before_dispatch(&self, parsed: &argot_cmd::ParsedCommand<'_>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
148    ///         eprintln!("audit: {}", parsed.command.canonical);
149    ///         Ok(())
150    ///     }
151    /// }
152    ///
153    /// let cli = Cli::new(vec![Command::builder("run").build().unwrap()])
154    ///     .with_middleware(Audit);
155    /// ```
156    pub fn with_middleware<M: crate::middleware::Middleware + 'static>(mut self, m: M) -> Self {
157        self.middlewares.push(Box::new(m));
158        self
159    }
160
161    /// Replace the default renderer with a custom implementation.
162    ///
163    /// The renderer is used for all help text, Markdown, subcommand listings,
164    /// and ambiguity messages produced by this `Cli` instance.
165    ///
166    /// # Examples
167    ///
168    /// ```no_run
169    /// # use argot_cmd::{Cli, Command, render::Renderer};
170    /// struct MyRenderer;
171    /// impl Renderer for MyRenderer {
172    ///     fn render_help(&self, cmd: &argot_cmd::Command) -> String { format!("HELP: {}", cmd.canonical) }
173    ///     fn render_markdown(&self, cmd: &argot_cmd::Command) -> String { String::new() }
174    ///     fn render_subcommand_list(&self, cmds: &[argot_cmd::Command]) -> String { String::new() }
175    ///     fn render_ambiguity(&self, input: &str, _: &[String]) -> String { format!("bad: {}", input) }
176    /// }
177    ///
178    /// let cli = Cli::new(vec![Command::builder("run").build().unwrap()])
179    ///     .with_renderer(MyRenderer);
180    /// ```
181    pub fn with_renderer<R: Renderer + 'static>(mut self, renderer: R) -> Self {
182        self.renderer = Box::new(renderer);
183        self
184    }
185
186    /// Enable agent-discovery query support.
187    ///
188    /// When enabled, the CLI recognises a built-in `query` command:
189    ///
190    /// ```text
191    /// tool query commands            # list all commands as JSON
192    /// tool query <name>              # get structured JSON for a named command
193    /// ```
194    ///
195    /// The `query` command is also injected into the registry so that it
196    /// appears in `--help` output and in [`Registry::iter_all_recursive`].
197    ///
198    /// # Examples
199    ///
200    /// ```no_run
201    /// use argot_cmd::{Cli, Command};
202    ///
203    /// let cli = Cli::new(vec![Command::builder("deploy").build().unwrap()])
204    ///     .with_query_support();
205    /// // Now: `tool query commands` and `tool query deploy` work.
206    /// ```
207    pub fn with_query_support(mut self) -> Self {
208        self.query_support = true;
209        // Inject a meta `query` command so it shows up in --help and iter_all_recursive.
210        let query_cmd = crate::model::Command::builder("query")
211            .summary("Query command metadata (agent discovery)")
212            .description(
213                "Structured JSON output for agent discovery. \
214                 `query commands` lists all commands; `query <name>` returns metadata for one.",
215            )
216            .example(crate::model::Example::new(
217                "query commands",
218                "List all commands as JSON",
219            ))
220            .example(crate::model::Example::new(
221                "query deploy",
222                "Get metadata for the deploy command",
223            ))
224            .build()
225            .expect("built-in query command should always build");
226        self.registry.push(query_cmd);
227        self
228    }
229
230    /// Parse and dispatch a command from an iterator of string arguments.
231    ///
232    /// The iterator should **not** include the program name (`argv[0]`).
233    ///
234    /// Built-in behaviors:
235    /// - `--help` or `-h` anywhere → print help for the most-specific matched
236    ///   command and return `Ok(())`.
237    /// - `--version` or `-V` → print version string and return `Ok(())`.
238    /// - Empty input → print top-level command list and return `Ok(())`.
239    /// - Parse error → print the error to stderr, then help if possible; return
240    ///   `Err(CliError::Parse(...))`.
241    /// - No handler registered → return `Err(CliError::NoHandler(...))`.
242    ///
243    /// # Arguments
244    ///
245    /// - `args` — Iterator of argument strings, not including the program name.
246    ///
247    /// # Errors
248    ///
249    /// - [`CliError::Parse`] — the argument list could not be parsed.
250    /// - [`CliError::NoHandler`] — the resolved command has no handler.
251    /// - [`CliError::Handler`] — the handler returned an error.
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// # use std::sync::Arc;
257    /// # use argot_cmd::{Cli, Command, CliError};
258    /// let cli = Cli::new(vec![
259    ///     Command::builder("hello")
260    ///         .handler(Arc::new(|_| Ok(())))
261    ///         .build()
262    ///         .unwrap(),
263    /// ]);
264    ///
265    /// assert!(cli.run(["hello"]).is_ok());
266    /// assert!(matches!(cli.run(["--help"]), Ok(())));
267    /// ```
268    pub fn run(&self, args: impl IntoIterator<Item = impl AsRef<str>>) -> Result<(), CliError> {
269        let argv: Vec<String> = args.into_iter().map(|a| a.as_ref().to_owned()).collect();
270        let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
271
272        // ── Built-in: query support ───────────────────────────────────────────
273        if self.query_support && argv_refs.first().copied() == Some("query") {
274            return self.handle_query(&argv_refs[1..]);
275        }
276
277        // ── Built-in: --help / -h ──────────────────────────────────────────
278        if argv_refs.iter().any(|a| *a == "--help" || *a == "-h") {
279            // Strip the help flag(s) and try to identify the target command.
280            let remaining: Vec<&str> = argv_refs
281                .iter()
282                .copied()
283                .filter(|a| *a != "--help" && *a != "-h")
284                .collect();
285
286            let help_text = self.resolve_help_text(&remaining);
287            print!("{}", help_text);
288            return Ok(());
289        }
290
291        // ── Built-in: --version / -V ──────────────────────────────────────
292        if argv_refs.iter().any(|a| *a == "--version" || *a == "-V") {
293            match &self.version {
294                Some(v) if !self.app_name.is_empty() => println!("{} {}", self.app_name, v),
295                Some(v) => println!("{}", v),
296                None => println!("(no version set)"),
297            }
298            return Ok(());
299        }
300
301        // ── Built-in: empty args → list top-level commands ────────────────
302        if argv_refs.is_empty() {
303            print!(
304                "{}",
305                self.renderer
306                    .render_subcommand_list(self.registry.commands())
307            );
308            return Ok(());
309        }
310
311        // ── Normal parse ──────────────────────────────────────────────────
312        let parser = Parser::new(self.registry.commands());
313        match parser.parse(&argv_refs) {
314            Ok(parsed) => {
315                // Before dispatch: run middleware hooks
316                for mw in &self.middlewares {
317                    mw.before_dispatch(&parsed).map_err(CliError::Handler)?;
318                }
319
320                // Call handler
321                let handler_result = match &parsed.command.handler {
322                    Some(handler) => {
323                        // HandlerFn returns Box<dyn Error> (no Send+Sync bound).
324                        // We convert manually to match CliError::Handler.
325                        handler(&parsed).map_err(|e| {
326                            // Wrap in a Send+Sync-compatible error by capturing
327                            // the display string.
328                            let msg = e.to_string();
329                            let boxed: Box<dyn std::error::Error + Send + Sync> = msg.into();
330                            CliError::Handler(boxed)
331                        })
332                    }
333                    None => Err(CliError::NoHandler(parsed.command.canonical.to_string())),
334                };
335
336                // After dispatch: run middleware hooks (even on error)
337                let handler_result_for_mw: Result<(), Box<dyn std::error::Error + Send + Sync>> =
338                    match &handler_result {
339                        Ok(()) => Ok(()),
340                        Err(e) => Err(Box::<dyn std::error::Error + Send + Sync>::from(
341                            e.to_string(),
342                        )),
343                    };
344                for mw in &self.middlewares {
345                    mw.after_dispatch(&parsed, &handler_result_for_mw);
346                }
347
348                handler_result
349            }
350            Err(parse_err) => {
351                // Fire on_parse_error middleware hooks
352                for mw in &self.middlewares {
353                    mw.on_parse_error(&parse_err);
354                }
355
356                eprintln!("error: {}", parse_err);
357                if let crate::parser::ParseError::Resolve(
358                    crate::resolver::ResolveError::Unknown {
359                        ref suggestions, ..
360                    },
361                ) = parse_err
362                {
363                    if !suggestions.is_empty() {
364                        eprintln!("Did you mean one of: {}", suggestions.join(", "));
365                    }
366                }
367                // Best-effort: render help for whatever partial command we can resolve.
368                let help_text = self.resolve_help_text(&argv_refs);
369                eprint!("{}", help_text);
370                Err(CliError::Parse(parse_err))
371            }
372        }
373    }
374
375    /// Convenience: run with `std::env::args().skip(1)`.
376    ///
377    /// Equivalent to `self.run(std::env::args().skip(1))`. Skipping element 0
378    /// is required because `std::env::args` includes the program name.
379    ///
380    /// # Errors
381    ///
382    /// Same as [`Cli::run`].
383    pub fn run_env_args(&self) -> Result<(), CliError> {
384        self.run(std::env::args().skip(1))
385    }
386
387    /// Parse, dispatch, and exit the process with an appropriate exit code.
388    ///
389    /// On success exits with code `0`. On any error, prints the error to `stderr`
390    /// and exits with code `1`.
391    ///
392    /// This is the recommended entry point for binary crates that want `main`
393    /// to be a one-liner:
394    ///
395    /// ```no_run
396    /// use argot_cmd::{Cli, Command};
397    /// use std::sync::Arc;
398    ///
399    /// fn main() {
400    ///     Cli::new(vec![
401    ///         Command::builder("run")
402    ///             .handler(Arc::new(|_| Ok(())))
403    ///             .build()
404    ///             .unwrap(),
405    ///     ])
406    ///     .run_env_args_and_exit();
407    /// }
408    /// ```
409    ///
410    /// # Panics
411    ///
412    /// Does not panic; all errors are handled by printing to stderr and exiting.
413    pub fn run_and_exit(&self, args: impl IntoIterator<Item = impl AsRef<str>>) -> ! {
414        match self.run(args) {
415            Ok(()) => std::process::exit(0),
416            Err(e) => {
417                eprintln!("error: {}", e);
418                std::process::exit(1);
419            }
420        }
421    }
422
423    /// Convenience: [`run_and_exit`][Self::run_and_exit] using `std::env::args().skip(1)`.
424    pub fn run_env_args_and_exit(&self) -> ! {
425        self.run_and_exit(std::env::args().skip(1))
426    }
427
428    /// Async version of [`run_and_exit`][Self::run_and_exit].
429    ///
430    /// Must be called from an async context (e.g., `#[tokio::main]`).
431    #[cfg(feature = "async")]
432    pub async fn run_async_and_exit(&self, args: impl IntoIterator<Item = impl AsRef<str>>) -> ! {
433        match self.run_async(args).await {
434            Ok(()) => std::process::exit(0),
435            Err(e) => {
436                eprintln!("error: {}", e);
437                std::process::exit(1);
438            }
439        }
440    }
441
442    /// Convenience: [`run_async_and_exit`][Self::run_async_and_exit] using `std::env::args().skip(1)`.
443    #[cfg(feature = "async")]
444    pub async fn run_env_args_async_and_exit(&self) -> ! {
445        self.run_async_and_exit(std::env::args().skip(1)).await
446    }
447
448    /// Parse and dispatch a command asynchronously.
449    ///
450    /// Behaves identically to [`Cli::run`] but also invokes
451    /// [`AsyncHandlerFn`][crate::model::AsyncHandlerFn] handlers
452    /// registered with [`crate::CommandBuilder::async_handler`].
453    ///
454    /// Must be called from an async context (e.g., inside `#[tokio::main]`).
455    ///
456    /// Dispatch priority: async handler → sync handler → `CliError::NoHandler`.
457    ///
458    /// # Feature
459    ///
460    /// Requires the `async` feature flag.
461    ///
462    /// # Errors
463    ///
464    /// Same variants as [`Cli::run`].
465    #[cfg(feature = "async")]
466    pub async fn run_async(
467        &self,
468        args: impl IntoIterator<Item = impl AsRef<str>>,
469    ) -> Result<(), CliError> {
470        let args: Vec<String> = args.into_iter().map(|a| a.as_ref().to_string()).collect();
471        let argv: Vec<&str> = args.iter().map(String::as_str).collect();
472
473        // ── Built-in: query support ───────────────────────────────────────────
474        if self.query_support && argv.first().copied() == Some("query") {
475            let refs: Vec<&str> = argv.to_vec();
476            return self.handle_query(&refs[1..]);
477        }
478
479        // ── Built-in: --help / -h ──────────────────────────────────────────
480        if argv.iter().any(|a| *a == "--help" || *a == "-h") {
481            let remaining: Vec<&str> = argv
482                .iter()
483                .copied()
484                .filter(|a| *a != "--help" && *a != "-h")
485                .collect();
486            let help_text = self.resolve_help_text(&remaining);
487            print!("{}", help_text);
488            return Ok(());
489        }
490
491        // ── Built-in: --version / -V ──────────────────────────────────────
492        if argv.iter().any(|a| *a == "--version" || *a == "-V") {
493            match &self.version {
494                Some(v) if !self.app_name.is_empty() => println!("{} {}", self.app_name, v),
495                Some(v) => println!("{}", v),
496                None => println!("(no version set)"),
497            }
498            return Ok(());
499        }
500
501        // ── Built-in: empty args → list top-level commands ────────────────
502        if argv.is_empty() {
503            print!(
504                "{}",
505                self.renderer
506                    .render_subcommand_list(self.registry.commands())
507            );
508            return Ok(());
509        }
510
511        // ── Normal parse ──────────────────────────────────────────────────
512        let parser = Parser::new(self.registry.commands());
513        match parser.parse(&argv) {
514            Ok(parsed) => {
515                // Before dispatch: run middleware hooks
516                for mw in &self.middlewares {
517                    mw.before_dispatch(&parsed).map_err(CliError::Handler)?;
518                }
519
520                // Prefer async handler over sync handler
521                let handler_result = if let Some(ref async_handler) = parsed.command.async_handler {
522                    async_handler(&parsed).await.map_err(|e| {
523                        let msg = e.to_string();
524                        let boxed: Box<dyn std::error::Error + Send + Sync> = msg.into();
525                        CliError::Handler(boxed)
526                    })
527                } else if let Some(ref handler) = parsed.command.handler {
528                    handler(&parsed).map_err(|e| {
529                        let msg = e.to_string();
530                        let boxed: Box<dyn std::error::Error + Send + Sync> = msg.into();
531                        CliError::Handler(boxed)
532                    })
533                } else {
534                    Err(CliError::NoHandler(parsed.command.canonical.clone()))
535                };
536
537                // After dispatch: run middleware hooks (even on error)
538                let handler_result_for_mw: Result<(), Box<dyn std::error::Error + Send + Sync>> =
539                    match &handler_result {
540                        Ok(()) => Ok(()),
541                        Err(e) => Err(Box::<dyn std::error::Error + Send + Sync>::from(
542                            e.to_string(),
543                        )),
544                    };
545                for mw in &self.middlewares {
546                    mw.after_dispatch(&parsed, &handler_result_for_mw);
547                }
548
549                handler_result
550            }
551            Err(parse_err) => {
552                // Fire on_parse_error middleware hooks
553                for mw in &self.middlewares {
554                    mw.on_parse_error(&parse_err);
555                }
556
557                eprintln!("error: {}", parse_err);
558                if let crate::parser::ParseError::Resolve(
559                    crate::resolver::ResolveError::Unknown {
560                        ref suggestions, ..
561                    },
562                ) = parse_err
563                {
564                    if !suggestions.is_empty() {
565                        eprintln!("Did you mean one of: {}", suggestions.join(", "));
566                    }
567                }
568                let help_text = self.resolve_help_text(&argv);
569                eprint!("{}", help_text);
570                Err(CliError::Parse(parse_err))
571            }
572        }
573    }
574
575    /// Convenience: `run_async` using `std::env::args().skip(1)`.
576    #[cfg(feature = "async")]
577    pub async fn run_env_args_async(&self) -> Result<(), CliError> {
578        self.run_async(std::env::args().skip(1)).await
579    }
580
581    // ── Private helpers ───────────────────────────────────────────────────
582
583    fn handle_query(&self, args: &[&str]) -> Result<(), CliError> {
584        // Strip --json flag (JSON is always the output format; --json accepted for compatibility).
585        let args: Vec<&str> = args.iter().copied().filter(|a| *a != "--json").collect();
586        let args = args.as_slice();
587
588        match args.first().copied() {
589            // `query commands` → JSON array of all top-level commands
590            None | Some("commands") => {
591                let json = self.registry.to_json().map_err(|e| {
592                    CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
593                        e.to_string(),
594                    ))
595                })?;
596                println!("{}", json);
597                Ok(())
598            }
599            // `query examples <name>` → JSON array of examples for the named command
600            Some("examples") => {
601                let name = args.get(1).copied().ok_or_else(|| {
602                    CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
603                        "usage: query examples <command-name>",
604                    ))
605                })?;
606                let cmd = self
607                    .registry
608                    .get_command(name)
609                    .or_else(|| {
610                        let resolver = crate::resolver::Resolver::new(self.registry.commands());
611                        resolver.resolve(name).ok()
612                    })
613                    .ok_or_else(|| {
614                        CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
615                            format!("unknown command: `{}`", name),
616                        ))
617                    })?;
618                let json = serde_json::to_string_pretty(&cmd.examples).map_err(|e| {
619                    CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
620                        e.to_string(),
621                    ))
622                })?;
623                println!("{}", json);
624                Ok(())
625            }
626            // `query <name>` → JSON for the named command
627            Some(name) => {
628                // First try exact match, then resolver (which handles prefix/alias).
629                let cmd = self.registry.get_command(name);
630                if let Some(cmd) = cmd {
631                    let json = serde_json::to_string_pretty(cmd).map_err(|e| {
632                        CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
633                            e.to_string(),
634                        ))
635                    })?;
636                    println!("{}", json);
637                    return Ok(());
638                }
639
640                // Try resolver for prefix/alias matching; handle ambiguity with structured JSON.
641                let resolver = crate::resolver::Resolver::new(self.registry.commands());
642                match resolver.resolve(name) {
643                    Ok(cmd) => {
644                        let json = serde_json::to_string_pretty(cmd).map_err(|e| {
645                            CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
646                                e.to_string(),
647                            ))
648                        })?;
649                        println!("{}", json);
650                        Ok(())
651                    }
652                    Err(crate::resolver::ResolveError::Ambiguous { input, candidates }) => {
653                        // Agents should receive data, not errors — emit structured JSON.
654                        let json = serde_json::json!({
655                            "error": "ambiguous",
656                            "input": input,
657                            "candidates": candidates,
658                        });
659                        println!("{}", json);
660                        Ok(())
661                    }
662                    Err(crate::resolver::ResolveError::Unknown { .. }) => Err(CliError::Handler(
663                        Box::<dyn std::error::Error + Send + Sync>::from(format!(
664                            "unknown command: `{}`",
665                            name
666                        )),
667                    )),
668                }
669            }
670        }
671    }
672
673    /// Walk the arg list and return the help text for the deepest command that
674    /// can be resolved. Falls back to the top-level command list if nothing
675    /// resolves.
676    fn resolve_help_text(&self, argv: &[&str]) -> String {
677        // Try to walk the command tree as far as possible.
678        if argv.is_empty() {
679            return self
680                .renderer
681                .render_subcommand_list(self.registry.commands());
682        }
683
684        // Skip any flag-looking tokens for the purpose of command resolution.
685        let words: Vec<&str> = argv
686            .iter()
687            .copied()
688            .filter(|a| !a.starts_with('-'))
689            .collect();
690
691        if words.is_empty() {
692            return self
693                .renderer
694                .render_subcommand_list(self.registry.commands());
695        }
696
697        // Resolve the first word as a top-level command.
698        let resolver = Resolver::new(self.registry.commands());
699        let top_cmd = match resolver.resolve(words[0]) {
700            Ok(cmd) => cmd,
701            Err(_) => {
702                return self
703                    .renderer
704                    .render_subcommand_list(self.registry.commands())
705            }
706        };
707
708        // Walk into subcommands as far as possible.
709        let mut current = top_cmd;
710        for word in words.iter().skip(1) {
711            if current.subcommands.is_empty() {
712                break;
713            }
714            let sub_resolver = Resolver::new(&current.subcommands);
715            match sub_resolver.resolve(word) {
716                Ok(sub) => current = sub,
717                Err(_) => break,
718            }
719        }
720
721        self.renderer.render_help(current)
722    }
723}
724
725// ── Tests ─────────────────────────────────────────────────────────────────────
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use crate::model::Command;
731    use std::sync::{Arc, Mutex};
732
733    fn make_cli_no_handler() -> Cli {
734        let cmd = Command::builder("greet")
735            .summary("Say hello")
736            .build()
737            .unwrap();
738        Cli::new(vec![cmd]).app_name("testapp").version("1.2.3")
739    }
740
741    fn make_cli_with_handler(called: Arc<Mutex<bool>>) -> Cli {
742        let cmd = Command::builder("greet")
743            .summary("Say hello")
744            .handler(Arc::new(move |_parsed| {
745                *called.lock().unwrap() = true;
746                Ok(())
747            }))
748            .build()
749            .unwrap();
750        Cli::new(vec![cmd]).app_name("testapp").version("1.2.3")
751    }
752
753    #[test]
754    fn test_run_empty_args() {
755        let cli = make_cli_no_handler();
756        let result = cli.run(std::iter::empty::<&str>());
757        assert!(result.is_ok(), "empty args should return Ok");
758    }
759
760    #[test]
761    fn test_run_help_flag() {
762        let cli = make_cli_no_handler();
763        let result = cli.run(["--help"]);
764        assert!(result.is_ok(), "--help should return Ok");
765    }
766
767    #[test]
768    fn test_run_help_flag_short() {
769        let cli = make_cli_no_handler();
770        let result = cli.run(["-h"]);
771        assert!(result.is_ok(), "-h should return Ok");
772    }
773
774    #[test]
775    fn test_run_version_flag() {
776        let cli = make_cli_no_handler();
777        let result = cli.run(["--version"]);
778        assert!(result.is_ok(), "--version should return Ok");
779    }
780
781    #[test]
782    fn test_run_version_flag_short() {
783        let cli = make_cli_no_handler();
784        let result = cli.run(["-V"]);
785        assert!(result.is_ok(), "-V should return Ok");
786    }
787
788    #[test]
789    fn test_run_no_handler() {
790        let cli = make_cli_no_handler();
791        let result = cli.run(["greet"]);
792        assert!(
793            matches!(result, Err(CliError::NoHandler(ref name)) if name == "greet"),
794            "expected NoHandler(\"greet\"), got {:?}",
795            result
796        );
797    }
798
799    #[test]
800    fn test_run_with_handler() {
801        let called = Arc::new(Mutex::new(false));
802        let cli = make_cli_with_handler(called.clone());
803        let result = cli.run(["greet"]);
804        assert!(result.is_ok(), "handler should succeed, got {:?}", result);
805        assert!(*called.lock().unwrap(), "handler should have been called");
806    }
807
808    #[test]
809    fn test_run_unknown_command() {
810        let cli = make_cli_no_handler();
811        let result = cli.run(["unknowncmd"]);
812        assert!(
813            matches!(result, Err(CliError::Parse(_))),
814            "unknown command should yield Parse error, got {:?}",
815            result
816        );
817    }
818
819    #[test]
820    fn test_run_handler_error_wrapped() {
821        use std::sync::Arc;
822        let cmd = crate::model::Command::builder("fail")
823            .handler(Arc::new(|_| {
824                Err(Box::<dyn std::error::Error>::from("something went wrong"))
825            }))
826            .build()
827            .unwrap();
828        let cli = super::Cli::new(vec![cmd]);
829        let result = cli.run(["fail"]);
830        assert!(result.is_err());
831        match result {
832            Err(super::CliError::Handler(e)) => {
833                assert!(e.to_string().contains("something went wrong"));
834            }
835            other => panic!("expected CliError::Handler, got {:?}", other),
836        }
837    }
838
839    #[test]
840    fn test_run_command_named_help_dispatches_correctly() {
841        // A command named "help" passed through Cli should be dispatched,
842        // not intercepted as a built-in --help flag.
843        // This verifies Cli only intercepts "--help" flag, not the word "help".
844        use std::sync::atomic::{AtomicBool, Ordering};
845        use std::sync::Arc;
846        let called = Arc::new(AtomicBool::new(false));
847        let called2 = called.clone();
848        let cmd = crate::model::Command::builder("help")
849            .handler(Arc::new(move |_| {
850                called2.store(true, Ordering::SeqCst);
851                Ok(())
852            }))
853            .build()
854            .unwrap();
855        let cli = super::Cli::new(vec![cmd]);
856        cli.run(["help"]).unwrap();
857        assert!(
858            called.load(Ordering::SeqCst),
859            "handler should have been called"
860        );
861    }
862
863    #[test]
864    fn test_middleware_before_dispatch_called() {
865        use crate::middleware::Middleware;
866        use std::sync::atomic::{AtomicBool, Ordering};
867        use std::sync::Arc;
868
869        struct Flag(Arc<AtomicBool>);
870        impl Middleware for Flag {
871            fn before_dispatch(
872                &self,
873                _: &crate::model::ParsedCommand<'_>,
874            ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
875                self.0.store(true, Ordering::SeqCst);
876                Ok(())
877            }
878        }
879
880        let called = Arc::new(AtomicBool::new(false));
881        let handler_called = Arc::new(AtomicBool::new(false));
882        let handler_called2 = handler_called.clone();
883
884        let cmd = crate::model::Command::builder("run")
885            .handler(std::sync::Arc::new(move |_| {
886                handler_called2.store(true, Ordering::SeqCst);
887                Ok(())
888            }))
889            .build()
890            .unwrap();
891
892        let cli = super::Cli::new(vec![cmd]).with_middleware(Flag(called.clone()));
893        cli.run(["run"]).unwrap();
894
895        assert!(called.load(Ordering::SeqCst));
896        assert!(handler_called.load(Ordering::SeqCst));
897    }
898
899    #[test]
900    fn test_middleware_can_abort_dispatch() {
901        use crate::middleware::Middleware;
902        struct Aborter;
903        impl Middleware for Aborter {
904            fn before_dispatch(
905                &self,
906                _: &crate::model::ParsedCommand<'_>,
907            ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
908                Err("aborted by middleware".into())
909            }
910        }
911
912        let cmd = crate::model::Command::builder("run")
913            .handler(std::sync::Arc::new(|_| panic!("should not be called")))
914            .build()
915            .unwrap();
916
917        let cli = super::Cli::new(vec![cmd]).with_middleware(Aborter);
918        assert!(cli.run(["run"]).is_err());
919    }
920
921    #[test]
922    fn test_query_commands_outputs_json() {
923        use crate::model::Command;
924        let cli = super::Cli::new(vec![
925            Command::builder("deploy")
926                .summary("Deploy")
927                .build()
928                .unwrap(),
929            Command::builder("status")
930                .summary("Status")
931                .build()
932                .unwrap(),
933        ])
934        .with_query_support();
935
936        // Should not error (we can't easily capture stdout in unit tests,
937        // but we verify the dispatch path succeeds).
938        assert!(cli.run(["query", "commands"]).is_ok());
939    }
940
941    #[test]
942    fn test_query_named_command_outputs_json() {
943        use crate::model::Command;
944        let cli = super::Cli::new(vec![Command::builder("deploy")
945            .summary("Deploy svc")
946            .build()
947            .unwrap()])
948        .with_query_support();
949
950        assert!(cli.run(["query", "deploy"]).is_ok());
951    }
952
953    #[test]
954    fn test_query_unknown_command_errors() {
955        use crate::model::Command;
956        let cli =
957            super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
958
959        assert!(cli.run(["query", "nonexistent"]).is_err());
960    }
961
962    #[test]
963    fn test_query_meta_command_appears_in_registry() {
964        use crate::model::Command;
965        let cli =
966            super::Cli::new(vec![Command::builder("run").build().unwrap()]).with_query_support();
967
968        // The injected `query` command should be discoverable.
969        assert!(cli.registry.get_command("query").is_some());
970    }
971
972    #[test]
973    fn test_query_with_json_flag() {
974        use crate::model::Command;
975        let cli = super::Cli::new(vec![Command::builder("deploy")
976            .summary("Deploy")
977            .build()
978            .unwrap()])
979        .with_query_support();
980        // --json flag must not cause an error
981        assert!(cli.run(["query", "deploy", "--json"]).is_ok());
982        assert!(cli.run(["query", "commands", "--json"]).is_ok());
983    }
984
985    #[test]
986    fn test_query_ambiguous_returns_structured_json() {
987        use crate::model::Command;
988        // Two commands sharing the prefix "dep" make resolution ambiguous.
989        let cli = super::Cli::new(vec![
990            Command::builder("deploy")
991                .summary("Deploy")
992                .build()
993                .unwrap(),
994            Command::builder("describe")
995                .summary("Describe")
996                .build()
997                .unwrap(),
998        ])
999        .with_query_support();
1000
1001        // Before the fix this would have returned Err; now it must return Ok(())
1002        // and print structured JSON to stdout.
1003        let result = cli.run(["query", "dep"]);
1004        assert!(
1005            result.is_ok(),
1006            "ambiguous query should return Ok(()) with JSON on stdout, got {:?}",
1007            result
1008        );
1009    }
1010
1011    #[test]
1012    fn test_query_examples_returns_examples() {
1013        use crate::model::{Command, Example};
1014        let cli = super::Cli::new(vec![Command::builder("deploy")
1015            .summary("Deploy svc")
1016            .example(Example::new(
1017                "Deploy to production",
1018                "deploy api --env prod",
1019            ))
1020            .build()
1021            .unwrap()])
1022        .with_query_support();
1023
1024        let result = cli.run(["query", "examples", "deploy"]);
1025        assert!(
1026            result.is_ok(),
1027            "query examples for known command should return Ok(()), got {:?}",
1028            result
1029        );
1030    }
1031
1032    #[test]
1033    fn test_query_examples_unknown_errors() {
1034        use crate::model::Command;
1035        let cli =
1036            super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1037
1038        let result = cli.run(["query", "examples", "nonexistent"]);
1039        assert!(
1040            result.is_err(),
1041            "query examples for unknown command should return Err, got {:?}",
1042            result
1043        );
1044    }
1045
1046    // ── Async unit tests ──────────────────────────────────────────────────────
1047
1048    #[cfg(feature = "async")]
1049    #[tokio::test]
1050    async fn test_run_async_empty_args() {
1051        let cli = make_cli_no_handler();
1052        let result = cli.run_async(std::iter::empty::<&str>()).await;
1053        assert!(
1054            result.is_ok(),
1055            "empty args should return Ok, got {:?}",
1056            result
1057        );
1058    }
1059
1060    #[cfg(feature = "async")]
1061    #[tokio::test]
1062    async fn test_run_async_help_flag() {
1063        let cli = make_cli_no_handler();
1064        let result = cli.run_async(["--help"]).await;
1065        assert!(result.is_ok(), "--help should return Ok, got {:?}", result);
1066    }
1067
1068    #[cfg(feature = "async")]
1069    #[tokio::test]
1070    async fn test_run_async_version_flag() {
1071        let cli = make_cli_no_handler();
1072        let result = cli.run_async(["--version"]).await;
1073        assert!(
1074            result.is_ok(),
1075            "--version should return Ok, got {:?}",
1076            result
1077        );
1078    }
1079
1080    #[cfg(feature = "async")]
1081    #[tokio::test]
1082    async fn test_run_async_with_handler() {
1083        use std::sync::atomic::{AtomicBool, Ordering};
1084        let called = Arc::new(AtomicBool::new(false));
1085        let called2 = called.clone();
1086        let cmd = Command::builder("greet")
1087            .summary("Say hello")
1088            .handler(Arc::new(move |_parsed| {
1089                called2.store(true, Ordering::SeqCst);
1090                Ok(())
1091            }))
1092            .build()
1093            .unwrap();
1094        let cli = super::Cli::new(vec![cmd])
1095            .app_name("testapp")
1096            .version("1.2.3");
1097        let result = cli.run_async(["greet"]).await;
1098        assert!(result.is_ok(), "handler should succeed, got {:?}", result);
1099        assert!(
1100            called.load(Ordering::SeqCst),
1101            "handler should have been called"
1102        );
1103    }
1104
1105    #[cfg(feature = "async")]
1106    #[tokio::test]
1107    async fn test_run_async_unknown_command() {
1108        let cli = make_cli_no_handler();
1109        let result = cli.run_async(["unknowncmd"]).await;
1110        assert!(
1111            matches!(result, Err(CliError::Parse(_))),
1112            "unknown command should yield Parse error, got {:?}",
1113            result
1114        );
1115    }
1116
1117    #[test]
1118    fn test_version_without_app_name() {
1119        let cmd = Command::builder("greet").build().unwrap();
1120        // version set but no app_name — should print just the version
1121        let cli = super::Cli::new(vec![cmd]).version("2.0.0");
1122        assert!(cli.run(["--version"]).is_ok());
1123    }
1124
1125    #[test]
1126    fn test_version_not_set() {
1127        let cmd = Command::builder("greet").build().unwrap();
1128        // no version at all
1129        let cli = super::Cli::new(vec![cmd]);
1130        assert!(cli.run(["--version"]).is_ok());
1131    }
1132
1133    #[test]
1134    fn test_middleware_after_dispatch_called_on_success() {
1135        use crate::middleware::Middleware;
1136        use std::sync::atomic::{AtomicBool, Ordering};
1137        use std::sync::Arc;
1138
1139        struct AfterFlag(Arc<AtomicBool>);
1140        impl Middleware for AfterFlag {
1141            fn after_dispatch(
1142                &self,
1143                _: &crate::model::ParsedCommand<'_>,
1144                _: &Result<(), Box<dyn std::error::Error + Send + Sync>>,
1145            ) {
1146                self.0.store(true, Ordering::SeqCst);
1147            }
1148        }
1149
1150        let called = Arc::new(AtomicBool::new(false));
1151        let cmd = Command::builder("run")
1152            .handler(Arc::new(|_| Ok(())))
1153            .build()
1154            .unwrap();
1155        let cli = super::Cli::new(vec![cmd]).with_middleware(AfterFlag(called.clone()));
1156        cli.run(["run"]).unwrap();
1157        assert!(called.load(Ordering::SeqCst));
1158    }
1159
1160    #[test]
1161    fn test_middleware_after_dispatch_called_on_error() {
1162        use crate::middleware::Middleware;
1163        use std::sync::atomic::{AtomicBool, Ordering};
1164        use std::sync::Arc;
1165
1166        struct AfterFlag(Arc<AtomicBool>);
1167        impl Middleware for AfterFlag {
1168            fn after_dispatch(
1169                &self,
1170                _: &crate::model::ParsedCommand<'_>,
1171                _: &Result<(), Box<dyn std::error::Error + Send + Sync>>,
1172            ) {
1173                self.0.store(true, Ordering::SeqCst);
1174            }
1175        }
1176
1177        let called = Arc::new(AtomicBool::new(false));
1178        let cmd = Command::builder("run")
1179            .handler(Arc::new(|_| Err("handler error".into())))
1180            .build()
1181            .unwrap();
1182        let cli = super::Cli::new(vec![cmd]).with_middleware(AfterFlag(called.clone()));
1183        let _ = cli.run(["run"]);
1184        assert!(called.load(Ordering::SeqCst));
1185    }
1186
1187    #[test]
1188    fn test_middleware_on_parse_error_called() {
1189        use crate::middleware::Middleware;
1190        use std::sync::atomic::{AtomicBool, Ordering};
1191        use std::sync::Arc;
1192
1193        struct OnErrFlag(Arc<AtomicBool>);
1194        impl Middleware for OnErrFlag {
1195            fn on_parse_error(&self, _: &crate::parser::ParseError) {
1196                self.0.store(true, Ordering::SeqCst);
1197            }
1198        }
1199
1200        let called = Arc::new(AtomicBool::new(false));
1201        let cmd = Command::builder("run").build().unwrap();
1202        let cli = super::Cli::new(vec![cmd]).with_middleware(OnErrFlag(called.clone()));
1203        let _ = cli.run(["unknown_xyz"]);
1204        assert!(called.load(Ordering::SeqCst));
1205    }
1206
1207    #[test]
1208    fn test_unknown_command_with_suggestions() {
1209        // "gree" is close to "greet" — should include suggestions in stderr
1210        let cmd = Command::builder("greet").build().unwrap();
1211        let cli = super::Cli::new(vec![cmd]);
1212        let result = cli.run(["gree"]);
1213        // Should fail with parse error (suggestion logic in the error path)
1214        assert!(result.is_err());
1215    }
1216
1217    #[test]
1218    fn test_help_for_subcommand() {
1219        // --help with a known subcommand resolves to that command's help
1220        let sub = Command::builder("rollback")
1221            .summary("Roll back")
1222            .build()
1223            .unwrap();
1224        let parent = Command::builder("deploy")
1225            .summary("Deploy")
1226            .subcommand(sub)
1227            .build()
1228            .unwrap();
1229        let cli = super::Cli::new(vec![parent]);
1230        let result = cli.run(["deploy", "rollback", "--help"]);
1231        assert!(result.is_ok());
1232    }
1233
1234    #[test]
1235    fn test_help_with_only_flags() {
1236        // --help with only flag-like tokens (no command words) renders top-level list
1237        let cmd = Command::builder("greet").build().unwrap();
1238        let cli = super::Cli::new(vec![cmd]);
1239        let result = cli.run(["--flag", "--help"]);
1240        assert!(result.is_ok());
1241    }
1242
1243    #[test]
1244    fn test_help_for_unknown_command() {
1245        // --help with an unknown command name falls back to top-level list
1246        let cmd = Command::builder("greet").build().unwrap();
1247        let cli = super::Cli::new(vec![cmd]);
1248        let result = cli.run(["unknowncmd", "--help"]);
1249        assert!(result.is_ok());
1250    }
1251
1252    #[test]
1253    fn test_query_with_no_arg_outputs_json() {
1254        // "query" alone (no subcommand) is same as "query commands"
1255        use crate::model::Command;
1256        let cli =
1257            super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1258        assert!(cli.run(["query"]).is_ok());
1259    }
1260
1261    #[test]
1262    fn test_query_examples_via_resolver() {
1263        // query examples where exact match fails but resolver finds it by prefix
1264        use crate::model::{Command, Example};
1265        let cli = super::Cli::new(vec![Command::builder("deploy")
1266            .summary("Deploy")
1267            .example(Example::new("prod", "deploy prod"))
1268            .build()
1269            .unwrap()])
1270        .with_query_support();
1271        // "dep" prefix-resolves to "deploy"
1272        let result = cli.run(["query", "examples", "dep"]);
1273        assert!(
1274            result.is_ok(),
1275            "query examples via prefix should succeed, got {:?}",
1276            result
1277        );
1278    }
1279
1280    #[test]
1281    fn test_query_named_command_via_resolver() {
1282        // query <name> where exact match fails but resolver finds by prefix
1283        use crate::model::Command;
1284        let cli = super::Cli::new(vec![Command::builder("deploy")
1285            .summary("Deploy")
1286            .build()
1287            .unwrap()])
1288        .with_query_support();
1289        // "dep" prefix-resolves to "deploy"
1290        let result = cli.run(["query", "dep"]);
1291        assert!(
1292            result.is_ok(),
1293            "query prefix-resolved name should succeed, got {:?}",
1294            result
1295        );
1296    }
1297
1298    #[test]
1299    fn test_query_examples_no_name_errors() {
1300        use crate::model::Command;
1301        let cli =
1302            super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1303        // "query examples" with no command name should error
1304        let result = cli.run(["query", "examples"]);
1305        assert!(result.is_err(), "query examples with no name should error");
1306    }
1307}