Skip to main content

cli_forge/
app.rs

1//! The application: a registry of commands and the entry point to parsing.
2//!
3//! An [`App`] holds the top-level commands and the (optional) help header and
4//! footer. Commands are added with [`register`](App::register) — from anywhere,
5//! at any point before parsing, which is the property that makes a command
6//! defined in a non-`main` module behave identically to one defined in `main`.
7//!
8//! [`parse`](App::parse) reads the process arguments, resolves the command,
9//! parses its arguments, and runs the selected command's handler. Malformed
10//! input is reported as a structured [`ParseError`]: [`parse`](App::parse) prints
11//! it through the output layer and exits, while
12//! [`try_parse_from`](App::try_parse_from) returns it for the caller to handle.
13
14use crate::command::Command;
15use crate::error::ParseError;
16use crate::matches::Matches;
17use crate::parser::{self, Cli};
18
19/// A command-line application.
20///
21/// Build with [`App::new`], add commands with [`register`](App::register), then
22/// call [`parse`](App::parse).
23///
24/// # Examples
25///
26/// ```no_run
27/// use cli_forge::{App, Arg, Command, out};
28///
29/// let mut app = App::new("forge")
30///     .help_header("forge — project constructor")
31///     .help_footer("docs: https://github.com/jamesgober/cli-forge");
32///
33/// app.register(
34///     Command::new("init")
35///         .about("bootstrap a new project")
36///         .arg(Arg::positional("name").required(true))
37///         .run(|m| out(format!("initializing {}", m.value("name").unwrap_or("?")))),
38/// );
39///
40/// let _matches = app.parse();
41/// ```
42pub struct App {
43    name: String,
44    version: Option<String>,
45    help_header: Option<String>,
46    help_footer: Option<String>,
47    commands: Vec<Command>,
48    #[cfg(feature = "auth")]
49    auth_hook: Option<crate::auth::AuthHook>,
50}
51
52impl App {
53    /// Create an application with the given program name.
54    ///
55    /// # Examples
56    ///
57    /// ```
58    /// use cli_forge::App;
59    /// let app = App::new("forge");
60    /// ```
61    #[must_use]
62    pub fn new(name: impl Into<String>) -> App {
63        App {
64            name: name.into(),
65            version: None,
66            help_header: None,
67            help_footer: None,
68            commands: Vec::new(),
69            #[cfg(feature = "auth")]
70            auth_hook: None,
71        }
72    }
73
74    /// Set the authorization hook that enforces
75    /// [`Command::requires_auth`](crate::Command::requires_auth).
76    ///
77    /// The hook receives an [`AuthRequest`](crate::AuthRequest) naming the command
78    /// being authorized and returns whether to allow it. An auth-gated command
79    /// runs only if the hook returns `true`; otherwise parsing yields
80    /// [`ParseError::Unauthorized`] and the handler does not run. Without a hook,
81    /// auth-gated commands are never authorized (the seam fails closed).
82    ///
83    /// Requires the `auth` feature.
84    ///
85    /// # Examples
86    ///
87    /// ```
88    /// # #[cfg(feature = "auth")]
89    /// # {
90    /// use cli_forge::{App, Command, ParseError};
91    ///
92    /// let mut app = App::new("demo").auth(|req| req.command() != "publish");
93    /// app.register(Command::new("publish").requires_auth(true).run(|_| {}));
94    ///
95    /// let err = app.try_parse_from(["publish"]).unwrap_err();
96    /// assert!(matches!(err, ParseError::Unauthorized { .. }));
97    /// # }
98    /// ```
99    #[cfg(feature = "auth")]
100    #[must_use]
101    pub fn auth(mut self, hook: impl Fn(&crate::auth::AuthRequest<'_>) -> bool + 'static) -> App {
102        self.auth_hook = Some(Box::new(hook));
103        self
104    }
105
106    /// Set the version reported by `-V` / `--version`.
107    ///
108    /// Without this, the version flags are treated as ordinary unknown flags.
109    /// A common idiom is to pass the crate version:
110    ///
111    /// ```
112    /// use cli_forge::App;
113    /// let app = App::new("forge").version(env!("CARGO_PKG_VERSION"));
114    /// ```
115    #[must_use]
116    pub fn version(mut self, version: impl Into<String>) -> App {
117        self.version = Some(version.into());
118        self
119    }
120
121    /// Set the header shown at the top of every generated help page.
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// use cli_forge::App;
127    /// let app = App::new("forge").help_header("forge — project constructor");
128    /// ```
129    #[must_use]
130    pub fn help_header(mut self, text: impl Into<String>) -> App {
131        self.help_header = Some(text.into());
132        self
133    }
134
135    /// Set the footer shown at the bottom of every generated help page.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use cli_forge::App;
141    /// let app = App::new("forge").help_footer("see the docs for more");
142    /// ```
143    #[must_use]
144    pub fn help_footer(mut self, text: impl Into<String>) -> App {
145        self.help_footer = Some(text.into());
146        self
147    }
148
149    /// Register a top-level command.
150    ///
151    /// Call this from anywhere with access to the `App` — a different module, a
152    /// plugin's setup function, a loop over a config — at any point before
153    /// parsing. A command registered outside `main` is reachable and behaves
154    /// identically to one registered in `main`.
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// use cli_forge::{App, Command};
160    ///
161    /// let mut app = App::new("demo");
162    /// app.register(Command::new("status").about("show status"));
163    /// app.register(Command::new("sync").about("synchronize"));
164    /// ```
165    pub fn register(&mut self, cmd: Command) {
166        self.commands.push(cmd);
167    }
168
169    /// Parse the process arguments, run the selected command's handler, and
170    /// return the [`Matches`].
171    ///
172    /// `-h` / `--help` and `-V` / `--version` are handled here: the rendered help
173    /// or version is printed to standard output and the process exits `0`. On
174    /// malformed input the structured [`ParseError`] is printed to standard error
175    /// and the process exits `2`. This never panics. For a non-exiting variant —
176    /// for embedding or tests — use [`try_parse_from`](App::try_parse_from).
177    ///
178    /// # Examples
179    ///
180    /// ```no_run
181    /// use cli_forge::{App, Command, out};
182    ///
183    /// let mut app = App::new("demo").version(env!("CARGO_PKG_VERSION"));
184    /// app.register(Command::new("hello").run(|_| out("hello")));
185    /// let _matches = app.parse();
186    /// ```
187    #[must_use]
188    pub fn parse(&self) -> Matches {
189        let args: Vec<String> = std::env::args().skip(1).collect();
190        match self.try_parse_from(args) {
191            Ok(matches) => matches,
192            Err(ParseError::HelpRequested(text) | ParseError::VersionRequested(text)) => {
193                crate::out(text);
194                std::process::exit(0);
195            }
196            Err(error) => {
197                crate::err(format_args!("error: {error}"));
198                std::process::exit(2);
199            }
200        }
201    }
202
203    /// Render the top-level help as a string.
204    ///
205    /// Useful for printing help on demand — for example, when no command was
206    /// given:
207    ///
208    /// ```
209    /// use cli_forge::{App, Command, out};
210    ///
211    /// let mut app = App::new("demo");
212    /// app.register(Command::new("build").about("compile the project"));
213    ///
214    /// let help = app.help();
215    /// assert!(help.contains("build"));
216    /// assert!(help.contains("compile the project"));
217    /// ```
218    #[must_use]
219    pub fn help(&self) -> String {
220        crate::help::render_app(&self.cli())
221    }
222
223    /// Parse an explicit argument list (excluding the program name), run the
224    /// selected command's handler, and return the [`Matches`] — or a structured
225    /// [`ParseError`] on malformed input. Never exits the process; never panics.
226    ///
227    /// This is the testable, embeddable counterpart to [`parse`](App::parse).
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use cli_forge::{App, Arg, Command, ParseError};
233    ///
234    /// let mut app = App::new("demo");
235    /// app.register(Command::new("build").arg(Arg::option("jobs").short('j')));
236    ///
237    /// // Well-formed input parses.
238    /// let matches = app.try_parse_from(["build", "-j", "4"]).unwrap();
239    /// assert_eq!(matches.subcommand().unwrap().1.value("jobs"), Some("4"));
240    ///
241    /// // Malformed input returns a structured error.
242    /// let err = app.try_parse_from(["build", "--bogus"]).unwrap_err();
243    /// assert!(matches!(err, ParseError::UnknownFlag { .. }));
244    /// ```
245    pub fn try_parse_from<I, S>(&self, args: I) -> Result<Matches, ParseError>
246    where
247        I: IntoIterator<Item = S>,
248        S: Into<String>,
249    {
250        let tokens: Vec<String> = args.into_iter().map(Into::into).collect();
251        let matches = parser::parse_app(&self.cli(), &tokens)?;
252        #[cfg(feature = "auth")]
253        self.enforce_auth(&matches)?;
254        self.dispatch(&matches);
255        Ok(matches)
256    }
257
258    /// Assemble the borrowed context the parser and help engine need.
259    fn cli(&self) -> Cli<'_> {
260        Cli {
261            app_name: &self.name,
262            header: self.help_header.as_deref(),
263            footer: self.help_footer.as_deref(),
264            version: self.version.as_deref(),
265            commands: &self.commands,
266            #[cfg(feature = "auth")]
267            authorizer: self.auth_hook.as_ref(),
268        }
269    }
270
271    /// Refuse the resolved command if it is auth-gated and the hook does not
272    /// authorize it. Fails closed when no hook is set.
273    #[cfg(feature = "auth")]
274    fn enforce_auth(&self, matches: &Matches) -> Result<(), ParseError> {
275        if let Some((path, leaf)) = self.resolve_path(matches) {
276            if leaf.requires_auth {
277                let request = crate::auth::AuthRequest::new(&path);
278                let authorized = self.auth_hook.as_ref().is_some_and(|hook| hook(&request));
279                if !authorized {
280                    return Err(ParseError::Unauthorized {
281                        command: leaf.name.clone(),
282                    });
283                }
284            }
285        }
286        Ok(())
287    }
288
289    /// Walk the resolved subcommand chain, returning the command-name path and
290    /// the deepest (leaf) command.
291    #[cfg(feature = "auth")]
292    fn resolve_path(&self, matches: &Matches) -> Option<(Vec<&str>, &Command)> {
293        let (name, mut sub) = matches.subcommand()?;
294        let mut command = self.commands.iter().find(|c| c.name == name)?;
295        let mut path = vec![command.name.as_str()];
296        while let Some((sub_name, next)) = sub.subcommand() {
297            command = command.find_subcommand(sub_name)?;
298            path.push(command.name.as_str());
299            sub = next;
300        }
301        Some((path, command))
302    }
303
304    /// Run the handler of the deepest command the parse resolved to.
305    fn dispatch(&self, matches: &Matches) {
306        if let Some((name, sub)) = matches.subcommand() {
307            if let Some(command) = self.commands.iter().find(|c| c.name == name) {
308                dispatch_command(command, sub);
309            }
310        }
311    }
312
313    /// The registered commands that are not hidden. Used in tests to verify
314    /// hidden commands are excluded from listings.
315    #[cfg(test)]
316    pub(crate) fn visible_commands(&self) -> impl Iterator<Item = &Command> {
317        self.commands.iter().filter(|c| !c.hidden)
318    }
319}
320
321impl std::fmt::Debug for App {
322    // `DebugStruct::field` returns `&mut Self` for chaining; discarding those
323    // returns is the builder pattern, not a dropped result.
324    #[allow(unused_results)]
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        let mut s = f.debug_struct("App");
327        s.field("name", &self.name);
328        s.field("version", &self.version);
329        s.field("help_header", &self.help_header);
330        s.field("help_footer", &self.help_footer);
331        s.field("commands", &self.commands);
332        #[cfg(feature = "auth")]
333        s.field("has_auth_hook", &self.auth_hook.is_some());
334        s.finish()
335    }
336}
337
338/// Walk to the leaf of the resolved path and run its handler, if any.
339fn dispatch_command(command: &Command, matches: &Matches) {
340    if let Some((name, sub)) = matches.subcommand() {
341        if let Some(child) = command.find_subcommand(name) {
342            dispatch_command(child, sub);
343            return;
344        }
345    }
346    if let Some(handler) = &command.handler {
347        handler(matches);
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    #![allow(clippy::unwrap_used)]
354
355    use std::sync::atomic::{AtomicUsize, Ordering};
356
357    use super::*;
358    use crate::arg::Arg;
359
360    #[test]
361    fn test_unknown_command_is_structured_error() {
362        let app = App::new("demo");
363        let err = app.try_parse_from(["nope"]).unwrap_err();
364        assert_eq!(
365            err,
366            ParseError::UnknownCommand {
367                name: "nope".into()
368            }
369        );
370    }
371
372    #[test]
373    fn test_empty_args_yield_no_subcommand() {
374        let app = App::new("demo");
375        let matches = app.try_parse_from(Vec::<String>::new()).unwrap();
376        assert!(matches.subcommand().is_none());
377    }
378
379    #[test]
380    fn test_hidden_command_is_invokable_but_not_listed() {
381        let mut app = App::new("demo");
382        app.register(Command::new("secret").hidden(true));
383        app.register(Command::new("visible"));
384
385        // Still invokable.
386        let matches = app.try_parse_from(["secret"]).unwrap();
387        assert_eq!(matches.subcommand().map(|(name, _)| name), Some("secret"));
388
389        // Absent from the visible listing the help engine will render.
390        let listed: Vec<&str> = app.visible_commands().map(|c| c.name.as_str()).collect();
391        assert!(listed.contains(&"visible"));
392        assert!(!listed.contains(&"secret"));
393    }
394
395    #[test]
396    fn test_handler_runs_for_selected_command_only() {
397        static INIT_HITS: AtomicUsize = AtomicUsize::new(0);
398        static OTHER_HITS: AtomicUsize = AtomicUsize::new(0);
399
400        let mut app = App::new("demo");
401        app.register(Command::new("init").run(|_| {
402            let _ = INIT_HITS.fetch_add(1, Ordering::SeqCst);
403        }));
404        app.register(Command::new("other").run(|_| {
405            let _ = OTHER_HITS.fetch_add(1, Ordering::SeqCst);
406        }));
407
408        let _ = app.try_parse_from(["init"]).unwrap();
409        assert_eq!(INIT_HITS.load(Ordering::SeqCst), 1);
410        assert_eq!(OTHER_HITS.load(Ordering::SeqCst), 0);
411    }
412
413    #[test]
414    fn test_nested_subcommand_dispatch() {
415        static ADD_HITS: AtomicUsize = AtomicUsize::new(0);
416
417        let mut app = App::new("demo");
418        app.register(
419            Command::new("remote")
420                .subcommand(Command::new("add").run(|_| {
421                    let _ = ADD_HITS.fetch_add(1, Ordering::SeqCst);
422                }))
423                .subcommand(Command::new("remove")),
424        );
425
426        let matches = app.try_parse_from(["remote", "add"]).unwrap();
427        let (_, remote) = matches.subcommand().unwrap();
428        assert_eq!(remote.subcommand().map(|(name, _)| name), Some("add"));
429        assert_eq!(ADD_HITS.load(Ordering::SeqCst), 1);
430    }
431
432    #[test]
433    fn test_missing_required_argument() {
434        let mut app = App::new("demo");
435        app.register(Command::new("greet").arg(Arg::positional("name").required(true)));
436        let err = app.try_parse_from(["greet"]).unwrap_err();
437        assert_eq!(err, ParseError::MissingRequired { arg: "name".into() });
438    }
439
440    #[cfg(not(feature = "auth"))]
441    #[test]
442    fn test_requires_auth_is_inert_without_auth_feature() {
443        let mut app = App::new("demo");
444        static RAN: AtomicUsize = AtomicUsize::new(0);
445        app.register(Command::new("publish").requires_auth(true).run(|_| {
446            let _ = RAN.fetch_add(1, Ordering::SeqCst);
447        }));
448        // Without the `auth` feature the flag does nothing: the command runs.
449        let _ = app.try_parse_from(["publish"]).unwrap();
450        assert_eq!(RAN.load(Ordering::SeqCst), 1);
451    }
452
453    #[test]
454    fn test_combined_short_flags_and_attached_option_value() {
455        let mut app = App::new("demo");
456        app.register(
457            Command::new("run")
458                .arg(Arg::flag("all").short('a'))
459                .arg(Arg::flag("verbose").short('v'))
460                .arg(Arg::option("output").short('o')),
461        );
462        // `-av` bundles two flags; `-ofile` attaches the option value.
463        let matches = app.try_parse_from(["run", "-av", "-ofile"]).unwrap();
464        let (_, run) = matches.subcommand().unwrap();
465        assert!(run.flag("all"));
466        assert!(run.flag("verbose"));
467        assert_eq!(run.value("output"), Some("file"));
468    }
469
470    #[test]
471    fn test_end_of_options_marker_treats_rest_as_positional() {
472        let mut app = App::new("demo");
473        app.register(Command::new("echo").arg(Arg::positional("text")));
474        let matches = app.try_parse_from(["echo", "--", "--not-a-flag"]).unwrap();
475        assert_eq!(
476            matches.subcommand().unwrap().1.value("text"),
477            Some("--not-a-flag")
478        );
479    }
480
481    #[test]
482    fn test_count_flag_bundled_separate_and_long() {
483        let mut app = App::new("demo");
484        app.register(Command::new("run").arg(Arg::count("verbose").short('v')));
485
486        let bundled = app.try_parse_from(["run", "-vvv"]).unwrap();
487        assert_eq!(bundled.subcommand().unwrap().1.count("verbose"), 3);
488
489        let mixed = app
490            .try_parse_from(["run", "-v", "-vv", "--verbose"])
491            .unwrap();
492        let (_, run) = mixed.subcommand().unwrap();
493        assert_eq!(run.count("verbose"), 4);
494        assert!(run.flag("verbose")); // flag() is true once counted
495    }
496
497    #[test]
498    fn test_count_flag_absent_is_zero() {
499        let mut app = App::new("demo");
500        app.register(Command::new("run").arg(Arg::count("verbose").short('v')));
501        let m = app.try_parse_from(["run"]).unwrap();
502        let (_, run) = m.subcommand().unwrap();
503        assert_eq!(run.count("verbose"), 0);
504        assert!(!run.flag("verbose"));
505    }
506
507    #[test]
508    fn test_repeatable_option_collects_every_form() {
509        let mut app = App::new("cc");
510        app.register(Command::new("build").arg(Arg::option("define").short('D').multiple(true)));
511        // long, long=, short-space, short-attached — all append in order.
512        let m = app
513            .try_parse_from(["build", "--define", "A", "--define=B", "-D", "C", "-DD"])
514            .unwrap();
515        let (_, build) = m.subcommand().unwrap();
516        assert_eq!(
517            build.values("define").collect::<Vec<_>>(),
518            ["A", "B", "C", "D"]
519        );
520        assert_eq!(build.value("define"), Some("A")); // value() is the first
521    }
522
523    #[test]
524    fn test_single_option_is_last_wins() {
525        let mut app = App::new("demo");
526        app.register(Command::new("run").arg(Arg::option("out").short('o')));
527        let m = app.try_parse_from(["run", "-o", "a", "-o", "b"]).unwrap();
528        let (_, run) = m.subcommand().unwrap();
529        assert_eq!(run.value("out"), Some("b"));
530        assert_eq!(run.values("out").collect::<Vec<_>>(), ["b"]);
531    }
532
533    #[test]
534    fn test_variadic_positional_slurps_remaining() {
535        let mut app = App::new("demo");
536        app.register(Command::new("rm").arg(Arg::positional("files").multiple(true)));
537        let m = app.try_parse_from(["rm", "a", "b", "c"]).unwrap();
538        assert_eq!(
539            m.subcommand()
540                .unwrap()
541                .1
542                .values("files")
543                .collect::<Vec<_>>(),
544            ["a", "b", "c"]
545        );
546    }
547
548    #[test]
549    fn test_fixed_then_variadic_positional() {
550        let mut app = App::new("demo");
551        app.register(
552            Command::new("cp")
553                .arg(Arg::positional("dest").required(true))
554                .arg(Arg::positional("sources").multiple(true)),
555        );
556        let m = app.try_parse_from(["cp", "target", "a", "b"]).unwrap();
557        let (_, cp) = m.subcommand().unwrap();
558        assert_eq!(cp.value("dest"), Some("target"));
559        assert_eq!(cp.values("sources").collect::<Vec<_>>(), ["a", "b"]);
560    }
561
562    #[test]
563    fn test_required_variadic_needs_at_least_one() {
564        let mut app = App::new("demo");
565        app.register(
566            Command::new("rm").arg(Arg::positional("files").multiple(true).required(true)),
567        );
568        let err = app.try_parse_from(["rm"]).unwrap_err();
569        assert_eq!(
570            err,
571            ParseError::MissingRequired {
572                arg: "files".into()
573            }
574        );
575
576        let ok = app.try_parse_from(["rm", "x"]).unwrap();
577        assert_eq!(
578            ok.subcommand()
579                .unwrap()
580                .1
581                .values("files")
582                .collect::<Vec<_>>(),
583            ["x"]
584        );
585    }
586
587    #[test]
588    fn test_values_empty_for_absent_and_unknown() {
589        let mut app = App::new("demo");
590        app.register(Command::new("run").arg(Arg::option("x")));
591        let m = app.try_parse_from(["run"]).unwrap();
592        let (_, run) = m.subcommand().unwrap();
593        assert_eq!(run.values("x").count(), 0);
594        assert_eq!(run.values("nope").count(), 0);
595        assert_eq!(run.value("nope"), None);
596    }
597
598    fn help_demo() -> App {
599        let mut app = App::new("demo")
600            .version("1.0.0")
601            .help_header("HEADER LINE")
602            .help_footer("FOOTER LINE");
603        app.register(Command::new("build").about("compile the project"));
604        app.register(
605            Command::new("remove")
606                .aliases(["rm", "del"])
607                .about("delete a thing"),
608        );
609        app.register(Command::new("secret").hidden(true).about("do not show me"));
610        app.register(Command::new("publish").requires_auth(true).about("gated"));
611        app
612    }
613
614    #[test]
615    fn test_help_respects_header_footer_and_lists_options() {
616        let help = help_demo().help();
617        assert!(help.contains("HEADER LINE"));
618        assert!(help.contains("FOOTER LINE"));
619        assert!(help.contains("USAGE: demo <command> [options]"));
620        assert!(help.contains("-h, --help"));
621        assert!(help.contains("-V, --version"));
622    }
623
624    #[test]
625    fn test_help_hides_hidden_commands() {
626        let help = help_demo().help();
627        assert!(help.contains("build"));
628        assert!(help.contains("compile the project"));
629        // Hidden commands are always absent from help.
630        assert!(!help.contains("secret"));
631        assert!(!help.contains("do not show me"));
632    }
633
634    #[cfg(not(feature = "auth"))]
635    #[test]
636    fn test_help_shows_auth_command_without_auth_feature() {
637        // Without the `auth` feature, `requires_auth` is inert — the command is
638        // listed like any other.
639        let help = help_demo().help();
640        assert!(help.contains("publish"));
641    }
642
643    #[test]
644    fn test_help_shows_command_aliases() {
645        let help = help_demo().help();
646        assert!(help.contains("remove, rm, del"));
647    }
648
649    #[test]
650    fn test_help_omits_version_line_without_version() {
651        let mut app = App::new("demo");
652        app.register(Command::new("build"));
653        let help = app.help();
654        assert!(help.contains("-h, --help"));
655        assert!(!help.contains("--version"));
656    }
657
658    #[test]
659    fn test_help_flag_returns_help_signal() {
660        let app = help_demo();
661        // Top level.
662        let err = app.try_parse_from(["--help"]).unwrap_err();
663        assert!(matches!(err, ParseError::HelpRequested(ref text) if text.contains("USAGE")));
664        // Command level renders that command's help.
665        let err = app.try_parse_from(["build", "-h"]).unwrap_err();
666        assert!(matches!(err, ParseError::HelpRequested(ref text) if text.contains("demo build")));
667    }
668
669    #[test]
670    fn test_version_flag_returns_version_signal() {
671        let app = help_demo();
672        let err = app.try_parse_from(["--version"]).unwrap_err();
673        assert_eq!(err, ParseError::VersionRequested("1.0.0".into()));
674        let err = app.try_parse_from(["build", "-V"]).unwrap_err();
675        assert_eq!(err, ParseError::VersionRequested("1.0.0".into()));
676    }
677
678    #[test]
679    fn test_version_flag_is_unknown_without_version_set() {
680        let mut app = App::new("demo");
681        app.register(Command::new("build"));
682        let err = app.try_parse_from(["build", "--version"]).unwrap_err();
683        assert_eq!(
684            err,
685            ParseError::UnknownFlag {
686                flag: "--version".into()
687            }
688        );
689    }
690
691    #[test]
692    fn test_alias_dispatches_to_canonical_command() {
693        static HITS: AtomicUsize = AtomicUsize::new(0);
694        let mut app = App::new("demo");
695        app.register(Command::new("remove").aliases(["rm", "del"]).run(|_| {
696            let _ = HITS.fetch_add(1, Ordering::SeqCst);
697        }));
698
699        let matches = app.try_parse_from(["rm"]).unwrap();
700        // The alias resolves to the canonical name in the parsed result.
701        assert_eq!(matches.subcommand().map(|(name, _)| name), Some("remove"));
702        assert_eq!(HITS.load(Ordering::SeqCst), 1);
703    }
704
705    #[test]
706    fn test_user_defined_help_flag_overrides_builtin() {
707        let mut app = App::new("demo");
708        // A command that defines its own `--help` flag suppresses the built-in.
709        app.register(Command::new("run").arg(Arg::flag("help")));
710        let matches = app.try_parse_from(["run", "--help"]).unwrap();
711        assert!(matches.subcommand().unwrap().1.flag("help"));
712    }
713
714    // --- Auth seam (requires the `auth` feature) ---
715
716    #[cfg(feature = "auth")]
717    fn auth_app(ran: &'static AtomicUsize) -> App {
718        let mut app = App::new("demo");
719        app.register(Command::new("publish").requires_auth(true).run(move |_| {
720            let _ = ran.fetch_add(1, Ordering::SeqCst);
721        }));
722        app
723    }
724
725    #[cfg(feature = "auth")]
726    #[test]
727    fn test_auth_gated_command_blocked_without_hook() {
728        static RAN: AtomicUsize = AtomicUsize::new(0);
729        let app = auth_app(&RAN);
730        // No hook set → fail closed.
731        let err = app.try_parse_from(["publish"]).unwrap_err();
732        assert_eq!(
733            err,
734            ParseError::Unauthorized {
735                command: "publish".into()
736            }
737        );
738        assert_eq!(RAN.load(Ordering::SeqCst), 0);
739    }
740
741    #[cfg(feature = "auth")]
742    #[test]
743    fn test_auth_gated_command_refused_when_hook_denies() {
744        static RAN: AtomicUsize = AtomicUsize::new(0);
745        let app = auth_app(&RAN).auth(|_| false);
746        let err = app.try_parse_from(["publish"]).unwrap_err();
747        assert!(matches!(err, ParseError::Unauthorized { .. }));
748        assert_eq!(RAN.load(Ordering::SeqCst), 0);
749    }
750
751    #[cfg(feature = "auth")]
752    #[test]
753    fn test_auth_gated_command_runs_when_authorized() {
754        static RAN: AtomicUsize = AtomicUsize::new(0);
755        let app = auth_app(&RAN).auth(|_| true);
756        let _ = app.try_parse_from(["publish"]).unwrap();
757        assert_eq!(RAN.load(Ordering::SeqCst), 1);
758    }
759
760    #[cfg(feature = "auth")]
761    #[test]
762    fn test_auth_hook_receives_command_name() {
763        static RAN: AtomicUsize = AtomicUsize::new(0);
764        // Authorize everything except `publish`.
765        let app = auth_app(&RAN).auth(|req| req.command() != "publish");
766        let err = app.try_parse_from(["publish"]).unwrap_err();
767        assert!(matches!(err, ParseError::Unauthorized { .. }));
768        assert_eq!(RAN.load(Ordering::SeqCst), 0);
769    }
770
771    #[cfg(feature = "auth")]
772    #[test]
773    fn test_non_auth_command_ignores_hook() {
774        static RAN: AtomicUsize = AtomicUsize::new(0);
775        let mut app = App::new("demo").auth(|_| false);
776        app.register(Command::new("status").run(move |_| {
777            let _ = RAN.fetch_add(1, Ordering::SeqCst);
778        }));
779        // A command without `requires_auth` runs regardless of the (denying) hook.
780        let _ = app.try_parse_from(["status"]).unwrap();
781        assert_eq!(RAN.load(Ordering::SeqCst), 1);
782    }
783
784    #[cfg(feature = "auth")]
785    #[test]
786    fn test_help_lists_auth_command_only_when_authorized() {
787        let build = |authorize: bool| {
788            let mut app = App::new("demo").auth(move |_| authorize);
789            app.register(Command::new("publish").requires_auth(true).about("ship it"));
790            app.register(Command::new("build").about("compile"));
791            app
792        };
793        assert!(!build(false).help().contains("publish"));
794        assert!(build(true).help().contains("publish"));
795        // A non-gated command is listed either way.
796        assert!(build(false).help().contains("build"));
797    }
798}
799
800#[cfg(test)]
801mod proptests {
802    use proptest::prelude::*;
803
804    use super::*;
805    use crate::arg::Arg;
806
807    fn sample_app() -> App {
808        let mut app = App::new("demo").version("1.0.0");
809        app.register(
810            Command::new("build")
811                .aliases(["b"])
812                .arg(Arg::flag("release").short('r'))
813                .arg(Arg::count("verbose").short('v'))
814                .arg(Arg::option("jobs").short('j'))
815                .arg(Arg::option("define").short('D').multiple(true))
816                .arg(Arg::positional("targets").multiple(true))
817                .subcommand(Command::new("clean")),
818        );
819        app
820    }
821
822    proptest! {
823        /// No argument vector — however malformed — may panic the parser.
824        #[test]
825        fn test_try_parse_never_panics(tokens in proptest::collection::vec(".*", 0..8)) {
826            let app = sample_app();
827            let _ = app.try_parse_from(tokens);
828        }
829    }
830}