1use crate::command::Command;
15use crate::error::ParseError;
16use crate::matches::Matches;
17use crate::parser::{self, Cli};
18
19pub 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 #[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 #[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 #[must_use]
116 pub fn version(mut self, version: impl Into<String>) -> App {
117 self.version = Some(version.into());
118 self
119 }
120
121 #[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 #[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 pub fn register(&mut self, cmd: Command) {
166 self.commands.push(cmd);
167 }
168
169 #[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 #[must_use]
219 pub fn help(&self) -> String {
220 crate::help::render_app(&self.cli())
221 }
222
223 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 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 #[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 #[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 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 #[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 #[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
338fn 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 let matches = app.try_parse_from(["secret"]).unwrap();
387 assert_eq!(matches.subcommand().map(|(name, _)| name), Some("secret"));
388
389 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 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 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")); }
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 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")); }
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 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 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 let err = app.try_parse_from(["--help"]).unwrap_err();
663 assert!(matches!(err, ParseError::HelpRequested(ref text) if text.contains("USAGE")));
664 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 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 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 #[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 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 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 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 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 #[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}