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/// ```
42#[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    /// Create an application with the given program name.
53    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use cli_forge::App;
58    /// let app = App::new("forge");
59    /// ```
60    #[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    /// Set the version reported by `-V` / `--version`.
72    ///
73    /// Without this, the version flags are treated as ordinary unknown flags.
74    /// A common idiom is to pass the crate version:
75    ///
76    /// ```
77    /// use cli_forge::App;
78    /// let app = App::new("forge").version(env!("CARGO_PKG_VERSION"));
79    /// ```
80    #[must_use]
81    pub fn version(mut self, version: impl Into<String>) -> App {
82        self.version = Some(version.into());
83        self
84    }
85
86    /// Set the header shown at the top of every generated help page.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use cli_forge::App;
92    /// let app = App::new("forge").help_header("forge — project constructor");
93    /// ```
94    #[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    /// Set the footer shown at the bottom of every generated help page.
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use cli_forge::App;
106    /// let app = App::new("forge").help_footer("see the docs for more");
107    /// ```
108    #[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    /// Register a top-level command.
115    ///
116    /// Call this from anywhere with access to the `App` — a different module, a
117    /// plugin's setup function, a loop over a config — at any point before
118    /// parsing. A command registered outside `main` is reachable and behaves
119    /// identically to one registered in `main`.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// use cli_forge::{App, Command};
125    ///
126    /// let mut app = App::new("demo");
127    /// app.register(Command::new("status").about("show status"));
128    /// app.register(Command::new("sync").about("synchronize"));
129    /// ```
130    pub fn register(&mut self, cmd: Command) {
131        self.commands.push(cmd);
132    }
133
134    /// Parse the process arguments, run the selected command's handler, and
135    /// return the [`Matches`].
136    ///
137    /// `-h` / `--help` and `-V` / `--version` are handled here: the rendered help
138    /// or version is printed to standard output and the process exits `0`. On
139    /// malformed input the structured [`ParseError`] is printed to standard error
140    /// and the process exits `2`. This never panics. For a non-exiting variant —
141    /// for embedding or tests — use [`try_parse_from`](App::try_parse_from).
142    ///
143    /// # Examples
144    ///
145    /// ```no_run
146    /// use cli_forge::{App, Command, out};
147    ///
148    /// let mut app = App::new("demo").version(env!("CARGO_PKG_VERSION"));
149    /// app.register(Command::new("hello").run(|_| out("hello")));
150    /// let _matches = app.parse();
151    /// ```
152    #[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    /// Render the top-level help as a string.
169    ///
170    /// Useful for printing help on demand — for example, when no command was
171    /// given:
172    ///
173    /// ```
174    /// use cli_forge::{App, Command, out};
175    ///
176    /// let mut app = App::new("demo");
177    /// app.register(Command::new("build").about("compile the project"));
178    ///
179    /// let help = app.help();
180    /// assert!(help.contains("build"));
181    /// assert!(help.contains("compile the project"));
182    /// ```
183    #[must_use]
184    pub fn help(&self) -> String {
185        crate::help::render_app(&self.cli())
186    }
187
188    /// Parse an explicit argument list (excluding the program name), run the
189    /// selected command's handler, and return the [`Matches`] — or a structured
190    /// [`ParseError`] on malformed input. Never exits the process; never panics.
191    ///
192    /// This is the testable, embeddable counterpart to [`parse`](App::parse).
193    ///
194    /// # Examples
195    ///
196    /// ```
197    /// use cli_forge::{App, Arg, Command, ParseError};
198    ///
199    /// let mut app = App::new("demo");
200    /// app.register(Command::new("build").arg(Arg::option("jobs").short('j')));
201    ///
202    /// // Well-formed input parses.
203    /// let matches = app.try_parse_from(["build", "-j", "4"]).unwrap();
204    /// assert_eq!(matches.subcommand().unwrap().1.value("jobs"), Some("4"));
205    ///
206    /// // Malformed input returns a structured error.
207    /// let err = app.try_parse_from(["build", "--bogus"]).unwrap_err();
208    /// assert!(matches!(err, ParseError::UnknownFlag { .. }));
209    /// ```
210    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    /// Assemble the borrowed context the parser and help engine need.
222    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    /// Run the handler of the deepest command the parse resolved to.
233    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    /// The registered commands that are not hidden. Drives the help engine
242    /// (v0.4.0); used today to verify hidden commands are excluded from listings.
243    #[cfg(test)]
244    pub(crate) fn visible_commands(&self) -> impl Iterator<Item = &Command> {
245        self.commands.iter().filter(|c| !c.hidden)
246    }
247}
248
249/// Walk to the leaf of the resolved path and run its handler, if any.
250fn 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        // Still invokable.
297        let matches = app.try_parse_from(["secret"]).unwrap();
298        assert_eq!(matches.subcommand().map(|(name, _)| name), Some("secret"));
299
300        // Absent from the visible listing the help engine will render.
301        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        // Enforcement arrives in v0.5.0; for now the command still runs.
359        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        // `-av` bundles two flags; `-ofile` attaches the option value.
373        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        // Hidden and auth-gated commands are absent from help.
423        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        // Top level.
448        let err = app.try_parse_from(["--help"]).unwrap_err();
449        assert!(matches!(err, ParseError::HelpRequested(ref text) if text.contains("USAGE")));
450        // Command level renders that command's help.
451        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        // The alias resolves to the canonical name in the parsed result.
487        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        // A command that defines its own `--help` flag suppresses the built-in.
495        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        /// No argument vector — however malformed — may panic the parser.
522        #[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}