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 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 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 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 let err = app.try_parse_from(["--help"]).unwrap_err();
546 assert!(matches!(err, ParseError::HelpRequested(ref text) if text.contains("USAGE")));
547 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 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 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 #[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 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 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 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 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 #[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}