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    fn help_demo() -> App {
482        let mut app = App::new("demo")
483            .version("1.0.0")
484            .help_header("HEADER LINE")
485            .help_footer("FOOTER LINE");
486        app.register(Command::new("build").about("compile the project"));
487        app.register(
488            Command::new("remove")
489                .aliases(["rm", "del"])
490                .about("delete a thing"),
491        );
492        app.register(Command::new("secret").hidden(true).about("do not show me"));
493        app.register(Command::new("publish").requires_auth(true).about("gated"));
494        app
495    }
496
497    #[test]
498    fn test_help_respects_header_footer_and_lists_options() {
499        let help = help_demo().help();
500        assert!(help.contains("HEADER LINE"));
501        assert!(help.contains("FOOTER LINE"));
502        assert!(help.contains("USAGE: demo <command> [options]"));
503        assert!(help.contains("-h, --help"));
504        assert!(help.contains("-V, --version"));
505    }
506
507    #[test]
508    fn test_help_hides_hidden_commands() {
509        let help = help_demo().help();
510        assert!(help.contains("build"));
511        assert!(help.contains("compile the project"));
512        // Hidden commands are always absent from help.
513        assert!(!help.contains("secret"));
514        assert!(!help.contains("do not show me"));
515    }
516
517    #[cfg(not(feature = "auth"))]
518    #[test]
519    fn test_help_shows_auth_command_without_auth_feature() {
520        // Without the `auth` feature, `requires_auth` is inert — the command is
521        // listed like any other.
522        let help = help_demo().help();
523        assert!(help.contains("publish"));
524    }
525
526    #[test]
527    fn test_help_shows_command_aliases() {
528        let help = help_demo().help();
529        assert!(help.contains("remove, rm, del"));
530    }
531
532    #[test]
533    fn test_help_omits_version_line_without_version() {
534        let mut app = App::new("demo");
535        app.register(Command::new("build"));
536        let help = app.help();
537        assert!(help.contains("-h, --help"));
538        assert!(!help.contains("--version"));
539    }
540
541    #[test]
542    fn test_help_flag_returns_help_signal() {
543        let app = help_demo();
544        // Top level.
545        let err = app.try_parse_from(["--help"]).unwrap_err();
546        assert!(matches!(err, ParseError::HelpRequested(ref text) if text.contains("USAGE")));
547        // Command level renders that command's help.
548        let err = app.try_parse_from(["build", "-h"]).unwrap_err();
549        assert!(matches!(err, ParseError::HelpRequested(ref text) if text.contains("demo build")));
550    }
551
552    #[test]
553    fn test_version_flag_returns_version_signal() {
554        let app = help_demo();
555        let err = app.try_parse_from(["--version"]).unwrap_err();
556        assert_eq!(err, ParseError::VersionRequested("1.0.0".into()));
557        let err = app.try_parse_from(["build", "-V"]).unwrap_err();
558        assert_eq!(err, ParseError::VersionRequested("1.0.0".into()));
559    }
560
561    #[test]
562    fn test_version_flag_is_unknown_without_version_set() {
563        let mut app = App::new("demo");
564        app.register(Command::new("build"));
565        let err = app.try_parse_from(["build", "--version"]).unwrap_err();
566        assert_eq!(
567            err,
568            ParseError::UnknownFlag {
569                flag: "--version".into()
570            }
571        );
572    }
573
574    #[test]
575    fn test_alias_dispatches_to_canonical_command() {
576        static HITS: AtomicUsize = AtomicUsize::new(0);
577        let mut app = App::new("demo");
578        app.register(Command::new("remove").aliases(["rm", "del"]).run(|_| {
579            let _ = HITS.fetch_add(1, Ordering::SeqCst);
580        }));
581
582        let matches = app.try_parse_from(["rm"]).unwrap();
583        // The alias resolves to the canonical name in the parsed result.
584        assert_eq!(matches.subcommand().map(|(name, _)| name), Some("remove"));
585        assert_eq!(HITS.load(Ordering::SeqCst), 1);
586    }
587
588    #[test]
589    fn test_user_defined_help_flag_overrides_builtin() {
590        let mut app = App::new("demo");
591        // A command that defines its own `--help` flag suppresses the built-in.
592        app.register(Command::new("run").arg(Arg::flag("help")));
593        let matches = app.try_parse_from(["run", "--help"]).unwrap();
594        assert!(matches.subcommand().unwrap().1.flag("help"));
595    }
596
597    // --- Auth seam (requires the `auth` feature) ---
598
599    #[cfg(feature = "auth")]
600    fn auth_app(ran: &'static AtomicUsize) -> App {
601        let mut app = App::new("demo");
602        app.register(Command::new("publish").requires_auth(true).run(move |_| {
603            let _ = ran.fetch_add(1, Ordering::SeqCst);
604        }));
605        app
606    }
607
608    #[cfg(feature = "auth")]
609    #[test]
610    fn test_auth_gated_command_blocked_without_hook() {
611        static RAN: AtomicUsize = AtomicUsize::new(0);
612        let app = auth_app(&RAN);
613        // No hook set → fail closed.
614        let err = app.try_parse_from(["publish"]).unwrap_err();
615        assert_eq!(
616            err,
617            ParseError::Unauthorized {
618                command: "publish".into()
619            }
620        );
621        assert_eq!(RAN.load(Ordering::SeqCst), 0);
622    }
623
624    #[cfg(feature = "auth")]
625    #[test]
626    fn test_auth_gated_command_refused_when_hook_denies() {
627        static RAN: AtomicUsize = AtomicUsize::new(0);
628        let app = auth_app(&RAN).auth(|_| false);
629        let err = app.try_parse_from(["publish"]).unwrap_err();
630        assert!(matches!(err, ParseError::Unauthorized { .. }));
631        assert_eq!(RAN.load(Ordering::SeqCst), 0);
632    }
633
634    #[cfg(feature = "auth")]
635    #[test]
636    fn test_auth_gated_command_runs_when_authorized() {
637        static RAN: AtomicUsize = AtomicUsize::new(0);
638        let app = auth_app(&RAN).auth(|_| true);
639        let _ = app.try_parse_from(["publish"]).unwrap();
640        assert_eq!(RAN.load(Ordering::SeqCst), 1);
641    }
642
643    #[cfg(feature = "auth")]
644    #[test]
645    fn test_auth_hook_receives_command_name() {
646        static RAN: AtomicUsize = AtomicUsize::new(0);
647        // Authorize everything except `publish`.
648        let app = auth_app(&RAN).auth(|req| req.command() != "publish");
649        let err = app.try_parse_from(["publish"]).unwrap_err();
650        assert!(matches!(err, ParseError::Unauthorized { .. }));
651        assert_eq!(RAN.load(Ordering::SeqCst), 0);
652    }
653
654    #[cfg(feature = "auth")]
655    #[test]
656    fn test_non_auth_command_ignores_hook() {
657        static RAN: AtomicUsize = AtomicUsize::new(0);
658        let mut app = App::new("demo").auth(|_| false);
659        app.register(Command::new("status").run(move |_| {
660            let _ = RAN.fetch_add(1, Ordering::SeqCst);
661        }));
662        // A command without `requires_auth` runs regardless of the (denying) hook.
663        let _ = app.try_parse_from(["status"]).unwrap();
664        assert_eq!(RAN.load(Ordering::SeqCst), 1);
665    }
666
667    #[cfg(feature = "auth")]
668    #[test]
669    fn test_help_lists_auth_command_only_when_authorized() {
670        let build = |authorize: bool| {
671            let mut app = App::new("demo").auth(move |_| authorize);
672            app.register(Command::new("publish").requires_auth(true).about("ship it"));
673            app.register(Command::new("build").about("compile"));
674            app
675        };
676        assert!(!build(false).help().contains("publish"));
677        assert!(build(true).help().contains("publish"));
678        // A non-gated command is listed either way.
679        assert!(build(false).help().contains("build"));
680    }
681}
682
683#[cfg(test)]
684mod proptests {
685    use proptest::prelude::*;
686
687    use super::*;
688    use crate::arg::Arg;
689
690    fn sample_app() -> App {
691        let mut app = App::new("demo");
692        app.register(
693            Command::new("build")
694                .arg(Arg::flag("release").short('r'))
695                .arg(Arg::option("jobs").short('j'))
696                .arg(Arg::positional("target"))
697                .subcommand(Command::new("clean")),
698        );
699        app
700    }
701
702    proptest! {
703        /// No argument vector — however malformed — may panic the parser.
704        #[test]
705        fn test_try_parse_never_panics(tokens in proptest::collection::vec(".*", 0..8)) {
706            let app = sample_app();
707            let _ = app.try_parse_from(tokens);
708        }
709    }
710}