use crate::command::Command;
use crate::error::ParseError;
use crate::matches::Matches;
use crate::parser::{self, Cli};
pub struct App {
name: String,
version: Option<String>,
help_header: Option<String>,
help_footer: Option<String>,
commands: Vec<Command>,
#[cfg(feature = "auth")]
auth_hook: Option<crate::auth::AuthHook>,
}
impl App {
#[must_use]
pub fn new(name: impl Into<String>) -> App {
App {
name: name.into(),
version: None,
help_header: None,
help_footer: None,
commands: Vec::new(),
#[cfg(feature = "auth")]
auth_hook: None,
}
}
#[cfg(feature = "auth")]
#[must_use]
pub fn auth(mut self, hook: impl Fn(&crate::auth::AuthRequest<'_>) -> bool + 'static) -> App {
self.auth_hook = Some(Box::new(hook));
self
}
#[must_use]
pub fn version(mut self, version: impl Into<String>) -> App {
self.version = Some(version.into());
self
}
#[must_use]
pub fn help_header(mut self, text: impl Into<String>) -> App {
self.help_header = Some(text.into());
self
}
#[must_use]
pub fn help_footer(mut self, text: impl Into<String>) -> App {
self.help_footer = Some(text.into());
self
}
pub fn register(&mut self, cmd: Command) {
self.commands.push(cmd);
}
#[must_use]
pub fn parse(&self) -> Matches {
let args: Vec<String> = std::env::args().skip(1).collect();
match self.try_parse_from(args) {
Ok(matches) => matches,
Err(ParseError::HelpRequested(text) | ParseError::VersionRequested(text)) => {
crate::out(text);
std::process::exit(0);
}
Err(error) => {
crate::err(format_args!("error: {error}"));
std::process::exit(2);
}
}
}
#[must_use]
pub fn help(&self) -> String {
crate::help::render_app(&self.cli())
}
pub fn try_parse_from<I, S>(&self, args: I) -> Result<Matches, ParseError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let tokens: Vec<String> = args.into_iter().map(Into::into).collect();
let matches = parser::parse_app(&self.cli(), &tokens)?;
#[cfg(feature = "auth")]
self.enforce_auth(&matches)?;
self.dispatch(&matches);
Ok(matches)
}
fn cli(&self) -> Cli<'_> {
Cli {
app_name: &self.name,
header: self.help_header.as_deref(),
footer: self.help_footer.as_deref(),
version: self.version.as_deref(),
commands: &self.commands,
#[cfg(feature = "auth")]
authorizer: self.auth_hook.as_ref(),
}
}
#[cfg(feature = "auth")]
fn enforce_auth(&self, matches: &Matches) -> Result<(), ParseError> {
if let Some((path, leaf)) = self.resolve_path(matches) {
if leaf.requires_auth {
let request = crate::auth::AuthRequest::new(&path);
let authorized = self.auth_hook.as_ref().is_some_and(|hook| hook(&request));
if !authorized {
return Err(ParseError::Unauthorized {
command: leaf.name.clone(),
});
}
}
}
Ok(())
}
#[cfg(feature = "auth")]
fn resolve_path(&self, matches: &Matches) -> Option<(Vec<&str>, &Command)> {
let (name, mut sub) = matches.subcommand()?;
let mut command = self.commands.iter().find(|c| c.name == name)?;
let mut path = vec![command.name.as_str()];
while let Some((sub_name, next)) = sub.subcommand() {
command = command.find_subcommand(sub_name)?;
path.push(command.name.as_str());
sub = next;
}
Some((path, command))
}
fn dispatch(&self, matches: &Matches) {
if let Some((name, sub)) = matches.subcommand() {
if let Some(command) = self.commands.iter().find(|c| c.name == name) {
dispatch_command(command, sub);
}
}
}
#[cfg(test)]
pub(crate) fn visible_commands(&self) -> impl Iterator<Item = &Command> {
self.commands.iter().filter(|c| !c.hidden)
}
}
impl std::fmt::Debug for App {
#[allow(unused_results)]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = f.debug_struct("App");
s.field("name", &self.name);
s.field("version", &self.version);
s.field("help_header", &self.help_header);
s.field("help_footer", &self.help_footer);
s.field("commands", &self.commands);
#[cfg(feature = "auth")]
s.field("has_auth_hook", &self.auth_hook.is_some());
s.finish()
}
}
fn dispatch_command(command: &Command, matches: &Matches) {
if let Some((name, sub)) = matches.subcommand() {
if let Some(child) = command.find_subcommand(name) {
dispatch_command(child, sub);
return;
}
}
if let Some(handler) = &command.handler {
handler(matches);
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use crate::arg::Arg;
#[test]
fn test_unknown_command_is_structured_error() {
let app = App::new("demo");
let err = app.try_parse_from(["nope"]).unwrap_err();
assert_eq!(
err,
ParseError::UnknownCommand {
name: "nope".into()
}
);
}
#[test]
fn test_empty_args_yield_no_subcommand() {
let app = App::new("demo");
let matches = app.try_parse_from(Vec::<String>::new()).unwrap();
assert!(matches.subcommand().is_none());
}
#[test]
fn test_hidden_command_is_invokable_but_not_listed() {
let mut app = App::new("demo");
app.register(Command::new("secret").hidden(true));
app.register(Command::new("visible"));
let matches = app.try_parse_from(["secret"]).unwrap();
assert_eq!(matches.subcommand().map(|(name, _)| name), Some("secret"));
let listed: Vec<&str> = app.visible_commands().map(|c| c.name.as_str()).collect();
assert!(listed.contains(&"visible"));
assert!(!listed.contains(&"secret"));
}
#[test]
fn test_handler_runs_for_selected_command_only() {
static INIT_HITS: AtomicUsize = AtomicUsize::new(0);
static OTHER_HITS: AtomicUsize = AtomicUsize::new(0);
let mut app = App::new("demo");
app.register(Command::new("init").run(|_| {
let _ = INIT_HITS.fetch_add(1, Ordering::SeqCst);
}));
app.register(Command::new("other").run(|_| {
let _ = OTHER_HITS.fetch_add(1, Ordering::SeqCst);
}));
let _ = app.try_parse_from(["init"]).unwrap();
assert_eq!(INIT_HITS.load(Ordering::SeqCst), 1);
assert_eq!(OTHER_HITS.load(Ordering::SeqCst), 0);
}
#[test]
fn test_nested_subcommand_dispatch() {
static ADD_HITS: AtomicUsize = AtomicUsize::new(0);
let mut app = App::new("demo");
app.register(
Command::new("remote")
.subcommand(Command::new("add").run(|_| {
let _ = ADD_HITS.fetch_add(1, Ordering::SeqCst);
}))
.subcommand(Command::new("remove")),
);
let matches = app.try_parse_from(["remote", "add"]).unwrap();
let (_, remote) = matches.subcommand().unwrap();
assert_eq!(remote.subcommand().map(|(name, _)| name), Some("add"));
assert_eq!(ADD_HITS.load(Ordering::SeqCst), 1);
}
#[test]
fn test_missing_required_argument() {
let mut app = App::new("demo");
app.register(Command::new("greet").arg(Arg::positional("name").required(true)));
let err = app.try_parse_from(["greet"]).unwrap_err();
assert_eq!(err, ParseError::MissingRequired { arg: "name".into() });
}
#[cfg(not(feature = "auth"))]
#[test]
fn test_requires_auth_is_inert_without_auth_feature() {
let mut app = App::new("demo");
static RAN: AtomicUsize = AtomicUsize::new(0);
app.register(Command::new("publish").requires_auth(true).run(|_| {
let _ = RAN.fetch_add(1, Ordering::SeqCst);
}));
let _ = app.try_parse_from(["publish"]).unwrap();
assert_eq!(RAN.load(Ordering::SeqCst), 1);
}
#[test]
fn test_combined_short_flags_and_attached_option_value() {
let mut app = App::new("demo");
app.register(
Command::new("run")
.arg(Arg::flag("all").short('a'))
.arg(Arg::flag("verbose").short('v'))
.arg(Arg::option("output").short('o')),
);
let matches = app.try_parse_from(["run", "-av", "-ofile"]).unwrap();
let (_, run) = matches.subcommand().unwrap();
assert!(run.flag("all"));
assert!(run.flag("verbose"));
assert_eq!(run.value("output"), Some("file"));
}
#[test]
fn test_end_of_options_marker_treats_rest_as_positional() {
let mut app = App::new("demo");
app.register(Command::new("echo").arg(Arg::positional("text")));
let matches = app.try_parse_from(["echo", "--", "--not-a-flag"]).unwrap();
assert_eq!(
matches.subcommand().unwrap().1.value("text"),
Some("--not-a-flag")
);
}
#[test]
fn test_count_flag_bundled_separate_and_long() {
let mut app = App::new("demo");
app.register(Command::new("run").arg(Arg::count("verbose").short('v')));
let bundled = app.try_parse_from(["run", "-vvv"]).unwrap();
assert_eq!(bundled.subcommand().unwrap().1.count("verbose"), 3);
let mixed = app
.try_parse_from(["run", "-v", "-vv", "--verbose"])
.unwrap();
let (_, run) = mixed.subcommand().unwrap();
assert_eq!(run.count("verbose"), 4);
assert!(run.flag("verbose")); }
#[test]
fn test_count_flag_absent_is_zero() {
let mut app = App::new("demo");
app.register(Command::new("run").arg(Arg::count("verbose").short('v')));
let m = app.try_parse_from(["run"]).unwrap();
let (_, run) = m.subcommand().unwrap();
assert_eq!(run.count("verbose"), 0);
assert!(!run.flag("verbose"));
}
#[test]
fn test_repeatable_option_collects_every_form() {
let mut app = App::new("cc");
app.register(Command::new("build").arg(Arg::option("define").short('D').multiple(true)));
let m = app
.try_parse_from(["build", "--define", "A", "--define=B", "-D", "C", "-DD"])
.unwrap();
let (_, build) = m.subcommand().unwrap();
assert_eq!(
build.values("define").collect::<Vec<_>>(),
["A", "B", "C", "D"]
);
assert_eq!(build.value("define"), Some("A")); }
#[test]
fn test_single_option_is_last_wins() {
let mut app = App::new("demo");
app.register(Command::new("run").arg(Arg::option("out").short('o')));
let m = app.try_parse_from(["run", "-o", "a", "-o", "b"]).unwrap();
let (_, run) = m.subcommand().unwrap();
assert_eq!(run.value("out"), Some("b"));
assert_eq!(run.values("out").collect::<Vec<_>>(), ["b"]);
}
#[test]
fn test_variadic_positional_slurps_remaining() {
let mut app = App::new("demo");
app.register(Command::new("rm").arg(Arg::positional("files").multiple(true)));
let m = app.try_parse_from(["rm", "a", "b", "c"]).unwrap();
assert_eq!(
m.subcommand()
.unwrap()
.1
.values("files")
.collect::<Vec<_>>(),
["a", "b", "c"]
);
}
#[test]
fn test_fixed_then_variadic_positional() {
let mut app = App::new("demo");
app.register(
Command::new("cp")
.arg(Arg::positional("dest").required(true))
.arg(Arg::positional("sources").multiple(true)),
);
let m = app.try_parse_from(["cp", "target", "a", "b"]).unwrap();
let (_, cp) = m.subcommand().unwrap();
assert_eq!(cp.value("dest"), Some("target"));
assert_eq!(cp.values("sources").collect::<Vec<_>>(), ["a", "b"]);
}
#[test]
fn test_required_variadic_needs_at_least_one() {
let mut app = App::new("demo");
app.register(
Command::new("rm").arg(Arg::positional("files").multiple(true).required(true)),
);
let err = app.try_parse_from(["rm"]).unwrap_err();
assert_eq!(
err,
ParseError::MissingRequired {
arg: "files".into()
}
);
let ok = app.try_parse_from(["rm", "x"]).unwrap();
assert_eq!(
ok.subcommand()
.unwrap()
.1
.values("files")
.collect::<Vec<_>>(),
["x"]
);
}
#[test]
fn test_values_empty_for_absent_and_unknown() {
let mut app = App::new("demo");
app.register(Command::new("run").arg(Arg::option("x")));
let m = app.try_parse_from(["run"]).unwrap();
let (_, run) = m.subcommand().unwrap();
assert_eq!(run.values("x").count(), 0);
assert_eq!(run.values("nope").count(), 0);
assert_eq!(run.value("nope"), None);
}
fn help_demo() -> App {
let mut app = App::new("demo")
.version("1.0.0")
.help_header("HEADER LINE")
.help_footer("FOOTER LINE");
app.register(Command::new("build").about("compile the project"));
app.register(
Command::new("remove")
.aliases(["rm", "del"])
.about("delete a thing"),
);
app.register(Command::new("secret").hidden(true).about("do not show me"));
app.register(Command::new("publish").requires_auth(true).about("gated"));
app
}
#[test]
fn test_help_respects_header_footer_and_lists_options() {
let help = help_demo().help();
assert!(help.contains("HEADER LINE"));
assert!(help.contains("FOOTER LINE"));
assert!(help.contains("USAGE: demo <command> [options]"));
assert!(help.contains("-h, --help"));
assert!(help.contains("-V, --version"));
}
#[test]
fn test_help_hides_hidden_commands() {
let help = help_demo().help();
assert!(help.contains("build"));
assert!(help.contains("compile the project"));
assert!(!help.contains("secret"));
assert!(!help.contains("do not show me"));
}
#[cfg(not(feature = "auth"))]
#[test]
fn test_help_shows_auth_command_without_auth_feature() {
let help = help_demo().help();
assert!(help.contains("publish"));
}
#[test]
fn test_help_shows_command_aliases() {
let help = help_demo().help();
assert!(help.contains("remove, rm, del"));
}
#[test]
fn test_help_omits_version_line_without_version() {
let mut app = App::new("demo");
app.register(Command::new("build"));
let help = app.help();
assert!(help.contains("-h, --help"));
assert!(!help.contains("--version"));
}
#[test]
fn test_help_flag_returns_help_signal() {
let app = help_demo();
let err = app.try_parse_from(["--help"]).unwrap_err();
assert!(matches!(err, ParseError::HelpRequested(ref text) if text.contains("USAGE")));
let err = app.try_parse_from(["build", "-h"]).unwrap_err();
assert!(matches!(err, ParseError::HelpRequested(ref text) if text.contains("demo build")));
}
#[test]
fn test_version_flag_returns_version_signal() {
let app = help_demo();
let err = app.try_parse_from(["--version"]).unwrap_err();
assert_eq!(err, ParseError::VersionRequested("1.0.0".into()));
let err = app.try_parse_from(["build", "-V"]).unwrap_err();
assert_eq!(err, ParseError::VersionRequested("1.0.0".into()));
}
#[test]
fn test_version_flag_is_unknown_without_version_set() {
let mut app = App::new("demo");
app.register(Command::new("build"));
let err = app.try_parse_from(["build", "--version"]).unwrap_err();
assert_eq!(
err,
ParseError::UnknownFlag {
flag: "--version".into()
}
);
}
#[test]
fn test_alias_dispatches_to_canonical_command() {
static HITS: AtomicUsize = AtomicUsize::new(0);
let mut app = App::new("demo");
app.register(Command::new("remove").aliases(["rm", "del"]).run(|_| {
let _ = HITS.fetch_add(1, Ordering::SeqCst);
}));
let matches = app.try_parse_from(["rm"]).unwrap();
assert_eq!(matches.subcommand().map(|(name, _)| name), Some("remove"));
assert_eq!(HITS.load(Ordering::SeqCst), 1);
}
#[test]
fn test_user_defined_help_flag_overrides_builtin() {
let mut app = App::new("demo");
app.register(Command::new("run").arg(Arg::flag("help")));
let matches = app.try_parse_from(["run", "--help"]).unwrap();
assert!(matches.subcommand().unwrap().1.flag("help"));
}
#[cfg(feature = "auth")]
fn auth_app(ran: &'static AtomicUsize) -> App {
let mut app = App::new("demo");
app.register(Command::new("publish").requires_auth(true).run(move |_| {
let _ = ran.fetch_add(1, Ordering::SeqCst);
}));
app
}
#[cfg(feature = "auth")]
#[test]
fn test_auth_gated_command_blocked_without_hook() {
static RAN: AtomicUsize = AtomicUsize::new(0);
let app = auth_app(&RAN);
let err = app.try_parse_from(["publish"]).unwrap_err();
assert_eq!(
err,
ParseError::Unauthorized {
command: "publish".into()
}
);
assert_eq!(RAN.load(Ordering::SeqCst), 0);
}
#[cfg(feature = "auth")]
#[test]
fn test_auth_gated_command_refused_when_hook_denies() {
static RAN: AtomicUsize = AtomicUsize::new(0);
let app = auth_app(&RAN).auth(|_| false);
let err = app.try_parse_from(["publish"]).unwrap_err();
assert!(matches!(err, ParseError::Unauthorized { .. }));
assert_eq!(RAN.load(Ordering::SeqCst), 0);
}
#[cfg(feature = "auth")]
#[test]
fn test_auth_gated_command_runs_when_authorized() {
static RAN: AtomicUsize = AtomicUsize::new(0);
let app = auth_app(&RAN).auth(|_| true);
let _ = app.try_parse_from(["publish"]).unwrap();
assert_eq!(RAN.load(Ordering::SeqCst), 1);
}
#[cfg(feature = "auth")]
#[test]
fn test_auth_hook_receives_command_name() {
static RAN: AtomicUsize = AtomicUsize::new(0);
let app = auth_app(&RAN).auth(|req| req.command() != "publish");
let err = app.try_parse_from(["publish"]).unwrap_err();
assert!(matches!(err, ParseError::Unauthorized { .. }));
assert_eq!(RAN.load(Ordering::SeqCst), 0);
}
#[cfg(feature = "auth")]
#[test]
fn test_non_auth_command_ignores_hook() {
static RAN: AtomicUsize = AtomicUsize::new(0);
let mut app = App::new("demo").auth(|_| false);
app.register(Command::new("status").run(move |_| {
let _ = RAN.fetch_add(1, Ordering::SeqCst);
}));
let _ = app.try_parse_from(["status"]).unwrap();
assert_eq!(RAN.load(Ordering::SeqCst), 1);
}
#[cfg(feature = "auth")]
#[test]
fn test_help_lists_auth_command_only_when_authorized() {
let build = |authorize: bool| {
let mut app = App::new("demo").auth(move |_| authorize);
app.register(Command::new("publish").requires_auth(true).about("ship it"));
app.register(Command::new("build").about("compile"));
app
};
assert!(!build(false).help().contains("publish"));
assert!(build(true).help().contains("publish"));
assert!(build(false).help().contains("build"));
}
}
#[cfg(test)]
mod proptests {
use proptest::prelude::*;
use super::*;
use crate::arg::Arg;
fn sample_app() -> App {
let mut app = App::new("demo").version("1.0.0");
app.register(
Command::new("build")
.aliases(["b"])
.arg(Arg::flag("release").short('r'))
.arg(Arg::count("verbose").short('v'))
.arg(Arg::option("jobs").short('j'))
.arg(Arg::option("define").short('D').multiple(true))
.arg(Arg::positional("targets").multiple(true))
.subcommand(Command::new("clean")),
);
app
}
proptest! {
#[test]
fn test_try_parse_never_panics(tokens in proptest::collection::vec(".*", 0..8)) {
let app = sample_app();
let _ = app.try_parse_from(tokens);
}
}
}