1use crate::command::Command;
15use crate::error::ParseError;
16use crate::matches::Matches;
17use crate::parser::{self, Cli};
18
19#[derive(Debug)]
43pub struct App {
44 name: String,
45 version: Option<String>,
46 help_header: Option<String>,
47 help_footer: Option<String>,
48 commands: Vec<Command>,
49}
50
51impl App {
52 #[must_use]
61 pub fn new(name: impl Into<String>) -> App {
62 App {
63 name: name.into(),
64 version: None,
65 help_header: None,
66 help_footer: None,
67 commands: Vec::new(),
68 }
69 }
70
71 #[must_use]
81 pub fn version(mut self, version: impl Into<String>) -> App {
82 self.version = Some(version.into());
83 self
84 }
85
86 #[must_use]
95 pub fn help_header(mut self, text: impl Into<String>) -> App {
96 self.help_header = Some(text.into());
97 self
98 }
99
100 #[must_use]
109 pub fn help_footer(mut self, text: impl Into<String>) -> App {
110 self.help_footer = Some(text.into());
111 self
112 }
113
114 pub fn register(&mut self, cmd: Command) {
131 self.commands.push(cmd);
132 }
133
134 #[must_use]
153 pub fn parse(&self) -> Matches {
154 let args: Vec<String> = std::env::args().skip(1).collect();
155 match self.try_parse_from(args) {
156 Ok(matches) => matches,
157 Err(ParseError::HelpRequested(text) | ParseError::VersionRequested(text)) => {
158 crate::out(text);
159 std::process::exit(0);
160 }
161 Err(error) => {
162 crate::err(format_args!("error: {error}"));
163 std::process::exit(2);
164 }
165 }
166 }
167
168 #[must_use]
184 pub fn help(&self) -> String {
185 crate::help::render_app(&self.cli())
186 }
187
188 pub fn try_parse_from<I, S>(&self, args: I) -> Result<Matches, ParseError>
211 where
212 I: IntoIterator<Item = S>,
213 S: Into<String>,
214 {
215 let tokens: Vec<String> = args.into_iter().map(Into::into).collect();
216 let matches = parser::parse_app(&self.cli(), &tokens)?;
217 self.dispatch(&matches);
218 Ok(matches)
219 }
220
221 fn cli(&self) -> Cli<'_> {
223 Cli {
224 app_name: &self.name,
225 header: self.help_header.as_deref(),
226 footer: self.help_footer.as_deref(),
227 version: self.version.as_deref(),
228 commands: &self.commands,
229 }
230 }
231
232 fn dispatch(&self, matches: &Matches) {
234 if let Some((name, sub)) = matches.subcommand() {
235 if let Some(command) = self.commands.iter().find(|c| c.name == name) {
236 dispatch_command(command, sub);
237 }
238 }
239 }
240
241 #[cfg(test)]
244 pub(crate) fn visible_commands(&self) -> impl Iterator<Item = &Command> {
245 self.commands.iter().filter(|c| !c.hidden)
246 }
247}
248
249fn dispatch_command(command: &Command, matches: &Matches) {
251 if let Some((name, sub)) = matches.subcommand() {
252 if let Some(child) = command.find_subcommand(name) {
253 dispatch_command(child, sub);
254 return;
255 }
256 }
257 if let Some(handler) = &command.handler {
258 handler(matches);
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 #![allow(clippy::unwrap_used)]
265
266 use std::sync::atomic::{AtomicUsize, Ordering};
267
268 use super::*;
269 use crate::arg::Arg;
270
271 #[test]
272 fn test_unknown_command_is_structured_error() {
273 let app = App::new("demo");
274 let err = app.try_parse_from(["nope"]).unwrap_err();
275 assert_eq!(
276 err,
277 ParseError::UnknownCommand {
278 name: "nope".into()
279 }
280 );
281 }
282
283 #[test]
284 fn test_empty_args_yield_no_subcommand() {
285 let app = App::new("demo");
286 let matches = app.try_parse_from(Vec::<String>::new()).unwrap();
287 assert!(matches.subcommand().is_none());
288 }
289
290 #[test]
291 fn test_hidden_command_is_invokable_but_not_listed() {
292 let mut app = App::new("demo");
293 app.register(Command::new("secret").hidden(true));
294 app.register(Command::new("visible"));
295
296 let matches = app.try_parse_from(["secret"]).unwrap();
298 assert_eq!(matches.subcommand().map(|(name, _)| name), Some("secret"));
299
300 let listed: Vec<&str> = app.visible_commands().map(|c| c.name.as_str()).collect();
302 assert!(listed.contains(&"visible"));
303 assert!(!listed.contains(&"secret"));
304 }
305
306 #[test]
307 fn test_handler_runs_for_selected_command_only() {
308 static INIT_HITS: AtomicUsize = AtomicUsize::new(0);
309 static OTHER_HITS: AtomicUsize = AtomicUsize::new(0);
310
311 let mut app = App::new("demo");
312 app.register(Command::new("init").run(|_| {
313 let _ = INIT_HITS.fetch_add(1, Ordering::SeqCst);
314 }));
315 app.register(Command::new("other").run(|_| {
316 let _ = OTHER_HITS.fetch_add(1, Ordering::SeqCst);
317 }));
318
319 let _ = app.try_parse_from(["init"]).unwrap();
320 assert_eq!(INIT_HITS.load(Ordering::SeqCst), 1);
321 assert_eq!(OTHER_HITS.load(Ordering::SeqCst), 0);
322 }
323
324 #[test]
325 fn test_nested_subcommand_dispatch() {
326 static ADD_HITS: AtomicUsize = AtomicUsize::new(0);
327
328 let mut app = App::new("demo");
329 app.register(
330 Command::new("remote")
331 .subcommand(Command::new("add").run(|_| {
332 let _ = ADD_HITS.fetch_add(1, Ordering::SeqCst);
333 }))
334 .subcommand(Command::new("remove")),
335 );
336
337 let matches = app.try_parse_from(["remote", "add"]).unwrap();
338 let (_, remote) = matches.subcommand().unwrap();
339 assert_eq!(remote.subcommand().map(|(name, _)| name), Some("add"));
340 assert_eq!(ADD_HITS.load(Ordering::SeqCst), 1);
341 }
342
343 #[test]
344 fn test_missing_required_argument() {
345 let mut app = App::new("demo");
346 app.register(Command::new("greet").arg(Arg::positional("name").required(true)));
347 let err = app.try_parse_from(["greet"]).unwrap_err();
348 assert_eq!(err, ParseError::MissingRequired { arg: "name".into() });
349 }
350
351 #[test]
352 fn test_requires_auth_flag_is_stored_not_enforced() {
353 let mut app = App::new("demo");
354 static RAN: AtomicUsize = AtomicUsize::new(0);
355 app.register(Command::new("publish").requires_auth(true).run(|_| {
356 let _ = RAN.fetch_add(1, Ordering::SeqCst);
357 }));
358 let _ = app.try_parse_from(["publish"]).unwrap();
360 assert_eq!(RAN.load(Ordering::SeqCst), 1);
361 }
362
363 #[test]
364 fn test_combined_short_flags_and_attached_option_value() {
365 let mut app = App::new("demo");
366 app.register(
367 Command::new("run")
368 .arg(Arg::flag("all").short('a'))
369 .arg(Arg::flag("verbose").short('v'))
370 .arg(Arg::option("output").short('o')),
371 );
372 let matches = app.try_parse_from(["run", "-av", "-ofile"]).unwrap();
374 let (_, run) = matches.subcommand().unwrap();
375 assert!(run.flag("all"));
376 assert!(run.flag("verbose"));
377 assert_eq!(run.value("output"), Some("file"));
378 }
379
380 #[test]
381 fn test_end_of_options_marker_treats_rest_as_positional() {
382 let mut app = App::new("demo");
383 app.register(Command::new("echo").arg(Arg::positional("text")));
384 let matches = app.try_parse_from(["echo", "--", "--not-a-flag"]).unwrap();
385 assert_eq!(
386 matches.subcommand().unwrap().1.value("text"),
387 Some("--not-a-flag")
388 );
389 }
390
391 fn help_demo() -> App {
392 let mut app = App::new("demo")
393 .version("1.0.0")
394 .help_header("HEADER LINE")
395 .help_footer("FOOTER LINE");
396 app.register(Command::new("build").about("compile the project"));
397 app.register(
398 Command::new("remove")
399 .aliases(["rm", "del"])
400 .about("delete a thing"),
401 );
402 app.register(Command::new("secret").hidden(true).about("do not show me"));
403 app.register(Command::new("publish").requires_auth(true).about("gated"));
404 app
405 }
406
407 #[test]
408 fn test_help_respects_header_footer_and_lists_options() {
409 let help = help_demo().help();
410 assert!(help.contains("HEADER LINE"));
411 assert!(help.contains("FOOTER LINE"));
412 assert!(help.contains("USAGE: demo <command> [options]"));
413 assert!(help.contains("-h, --help"));
414 assert!(help.contains("-V, --version"));
415 }
416
417 #[test]
418 fn test_help_hides_hidden_and_auth_commands() {
419 let help = help_demo().help();
420 assert!(help.contains("build"));
421 assert!(help.contains("compile the project"));
422 assert!(!help.contains("secret"));
424 assert!(!help.contains("do not show me"));
425 assert!(!help.contains("publish"));
426 assert!(!help.contains("gated"));
427 }
428
429 #[test]
430 fn test_help_shows_command_aliases() {
431 let help = help_demo().help();
432 assert!(help.contains("remove, rm, del"));
433 }
434
435 #[test]
436 fn test_help_omits_version_line_without_version() {
437 let mut app = App::new("demo");
438 app.register(Command::new("build"));
439 let help = app.help();
440 assert!(help.contains("-h, --help"));
441 assert!(!help.contains("--version"));
442 }
443
444 #[test]
445 fn test_help_flag_returns_help_signal() {
446 let app = help_demo();
447 let err = app.try_parse_from(["--help"]).unwrap_err();
449 assert!(matches!(err, ParseError::HelpRequested(ref text) if text.contains("USAGE")));
450 let err = app.try_parse_from(["build", "-h"]).unwrap_err();
452 assert!(matches!(err, ParseError::HelpRequested(ref text) if text.contains("demo build")));
453 }
454
455 #[test]
456 fn test_version_flag_returns_version_signal() {
457 let app = help_demo();
458 let err = app.try_parse_from(["--version"]).unwrap_err();
459 assert_eq!(err, ParseError::VersionRequested("1.0.0".into()));
460 let err = app.try_parse_from(["build", "-V"]).unwrap_err();
461 assert_eq!(err, ParseError::VersionRequested("1.0.0".into()));
462 }
463
464 #[test]
465 fn test_version_flag_is_unknown_without_version_set() {
466 let mut app = App::new("demo");
467 app.register(Command::new("build"));
468 let err = app.try_parse_from(["build", "--version"]).unwrap_err();
469 assert_eq!(
470 err,
471 ParseError::UnknownFlag {
472 flag: "--version".into()
473 }
474 );
475 }
476
477 #[test]
478 fn test_alias_dispatches_to_canonical_command() {
479 static HITS: AtomicUsize = AtomicUsize::new(0);
480 let mut app = App::new("demo");
481 app.register(Command::new("remove").aliases(["rm", "del"]).run(|_| {
482 let _ = HITS.fetch_add(1, Ordering::SeqCst);
483 }));
484
485 let matches = app.try_parse_from(["rm"]).unwrap();
486 assert_eq!(matches.subcommand().map(|(name, _)| name), Some("remove"));
488 assert_eq!(HITS.load(Ordering::SeqCst), 1);
489 }
490
491 #[test]
492 fn test_user_defined_help_flag_overrides_builtin() {
493 let mut app = App::new("demo");
494 app.register(Command::new("run").arg(Arg::flag("help")));
496 let matches = app.try_parse_from(["run", "--help"]).unwrap();
497 assert!(matches.subcommand().unwrap().1.flag("help"));
498 }
499}
500
501#[cfg(test)]
502mod proptests {
503 use proptest::prelude::*;
504
505 use super::*;
506 use crate::arg::Arg;
507
508 fn sample_app() -> App {
509 let mut app = App::new("demo");
510 app.register(
511 Command::new("build")
512 .arg(Arg::flag("release").short('r'))
513 .arg(Arg::option("jobs").short('j'))
514 .arg(Arg::positional("target"))
515 .subcommand(Command::new("clean")),
516 );
517 app
518 }
519
520 proptest! {
521 #[test]
523 fn test_try_parse_never_panics(tokens in proptest::collection::vec(".*", 0..8)) {
524 let app = sample_app();
525 let _ = app.try_parse_from(tokens);
526 }
527 }
528}