Skip to main content

runi_cli/launcher/
dispatch.rs

1use std::marker::PhantomData;
2use std::process;
3
4use super::error::{Error, Result};
5use super::help::HelpPrinter;
6use super::parser::{OptionParser, ParseResult};
7use super::schema::{CLArgument, CommandSchema};
8
9/// A command (root or subcommand) that knows how to produce its argument
10/// schema and how to construct itself from a [`ParseResult`].
11pub trait Command: Sized {
12    fn schema() -> CommandSchema;
13    fn from_parsed(parsed: &ParseResult) -> Result<Self>;
14}
15
16/// A root command that can be run standalone. The launcher calls `run` after
17/// parsing arguments when no subcommands are registered.
18pub trait Runnable {
19    fn run(&self) -> Result<()>;
20}
21
22/// A subcommand invoked in the context of a parent (global-options) struct `G`.
23pub trait SubCommandOf<G>: Sized {
24    fn run(&self, global: &G) -> Result<()>;
25}
26
27type Runner<G> = Box<dyn Fn(&G, &ParseResult) -> Result<()>>;
28
29struct Entry<G> {
30    schema: CommandSchema,
31    runner: Runner<G>,
32}
33
34/// Launcher before any subcommand has been registered.
35///
36/// Calling [`Launcher::command`] transitions to [`LauncherWithSubs`].
37pub struct Launcher<G: Command> {
38    _marker: PhantomData<G>,
39}
40
41impl<G: Command + 'static> Launcher<G> {
42    pub fn of() -> Self {
43        Self {
44            _marker: PhantomData,
45        }
46    }
47
48    /// Register the first subcommand, moving into subcommand mode.
49    pub fn command<S>(self, name: &str) -> LauncherWithSubs<G>
50    where
51        S: Command + SubCommandOf<G> + 'static,
52    {
53        LauncherWithSubs::<G>::new().command::<S>(name)
54    }
55
56    /// Like [`Launcher::command`] but with a description override.
57    pub fn command_with_description<S>(self, name: &str, description: &str) -> LauncherWithSubs<G>
58    where
59        S: Command + SubCommandOf<G> + 'static,
60    {
61        LauncherWithSubs::<G>::new().command_with_description::<S>(name, description)
62    }
63
64    /// Parse `args` into a `G` without running.
65    pub fn parse(&self, args: &[String]) -> Result<G> {
66        let schema = root_schema::<G>();
67        let parsed = OptionParser::parse(&schema, args)?;
68        G::from_parsed(&parsed)
69    }
70
71    /// Parse `std::env::args()`, run `G::run`, and exit. Parse-origin
72    /// failures (including those from `G::from_parsed`, e.g. missing
73    /// required args, invalid typed values) route through the help printer
74    /// with exit code 2. Runtime failures from `G::run` exit with code 1
75    /// and no help banner.
76    pub fn execute(self) -> !
77    where
78        G: Runnable,
79    {
80        let args = env_args();
81        let schema = root_schema::<G>();
82        let parse_result =
83            OptionParser::parse(&schema, &args).and_then(|parsed| G::from_parsed(&parsed));
84        let code = match parse_result {
85            Ok(g) => match g.run() {
86                Ok(()) => 0,
87                Err(e) => {
88                    eprintln!("error: {e}");
89                    1
90                }
91            },
92            Err(e) => report_error(e, &schema),
93        };
94        process::exit(code);
95    }
96}
97
98/// Launcher that already has at least one subcommand registered.
99pub struct LauncherWithSubs<G: Command> {
100    subs: Vec<Entry<G>>,
101}
102
103impl<G: Command + 'static> LauncherWithSubs<G> {
104    fn new() -> Self {
105        Self { subs: Vec::new() }
106    }
107
108    /// Register a subcommand. `S` must implement [`Command`] (for parsing)
109    /// and [`SubCommandOf<G>`] (for running with access to the parsed global
110    /// options).
111    pub fn command<S>(self, name: &str) -> Self
112    where
113        S: Command + SubCommandOf<G> + 'static,
114    {
115        self.register::<S>(name, None)
116    }
117
118    /// Like [`LauncherWithSubs::command`] but overrides the help description
119    /// for the registered subcommand (otherwise `S::schema().description`
120    /// is used). Primarily called by `#[derive(Command)]` on enums when a
121    /// variant carries its own `#[command(description = "...")]` or doc
122    /// comment.
123    pub fn command_with_description<S>(self, name: &str, description: &str) -> Self
124    where
125        S: Command + SubCommandOf<G> + 'static,
126    {
127        self.register::<S>(name, Some(description))
128    }
129
130    fn register<S>(mut self, name: &str, description: Option<&str>) -> Self
131    where
132        S: Command + SubCommandOf<G> + 'static,
133    {
134        // Silently accepting a duplicate would make later registrations
135        // unreachable because parsing stops at the first match. That's a
136        // programmer error — fail loudly at startup.
137        assert!(
138            !self.subs.iter().any(|e| e.schema.name == name),
139            "duplicate subcommand name: {name}",
140        );
141        let mut schema = S::schema();
142        schema.name = name.to_string();
143        if let Some(d) = description {
144            schema.description = d.to_string();
145        }
146        let name_owned = schema.name.clone();
147        let runner: Runner<G> = Box::new(move |global, parsed| {
148            // S::from_parsed failures are parse-origin — wrap them with
149            // subcommand context so the launcher picks the right help.
150            // S::run failures are runtime — wrap them in Error::Runtime so
151            // the launcher can tell them apart from parse variants even
152            // when user code reuses e.g. MissingArgument for its own
153            // validation.
154            let sub = S::from_parsed(parsed).map_err(|e| Error::InSubcommand {
155                path: vec![name_owned.clone()],
156                source: Box::new(e),
157            })?;
158            sub.run(global).map_err(|e| Error::Runtime(Box::new(e)))
159        });
160        self.subs.push(Entry { schema, runner });
161        self
162    }
163
164    /// Return the combined root + subcommand schema. Useful for tests and
165    /// for introspection (e.g. generating shell completions in the
166    /// future). Panics on the same invariants as execution — don't call
167    /// this from production code if those might fire.
168    pub fn schema(&self) -> CommandSchema {
169        self.combined_schema()
170    }
171
172    fn combined_schema(&self) -> CommandSchema {
173        let mut schema = G::schema();
174        // A subcommand declared directly on G::schema() would not have a
175        // runner registered here, so if parsing matched it run_args would
176        // report `UnknownSubcommand` at dispatch time. Force users to
177        // register subcommands via Launcher::command() where a runner is
178        // always attached.
179        assert!(
180            schema.subcommands.is_empty(),
181            "G::schema() must not declare subcommands directly; register them via Launcher::command()",
182        );
183        schema
184            .subcommands
185            .extend(self.subs.iter().map(|e| e.schema.clone()));
186        schema
187    }
188
189    /// Parse `args` and run the matched subcommand. Use this in tests to
190    /// exercise the launcher without touching the process environment.
191    pub fn run_args(&self, args: &[String]) -> Result<()> {
192        let schema = self.combined_schema();
193        let parsed = OptionParser::parse(&schema, args)?;
194        let global = G::from_parsed(&parsed)?;
195        let (name, sub_parsed) = parsed
196            .subcommand()
197            .ok_or_else(|| Error::MissingSubcommand {
198                available: self.subs.iter().map(|e| e.schema.name.clone()).collect(),
199            })?;
200        let entry = self
201            .subs
202            .iter()
203            .find(|e| e.schema.name == name)
204            .ok_or_else(|| Error::UnknownSubcommand {
205                name: name.to_string(),
206                available: self.subs.iter().map(|e| e.schema.name.clone()).collect(),
207            })?;
208        (entry.runner)(&global, sub_parsed)
209    }
210
211    /// Parse `std::env::args()`, dispatch to the matching subcommand, and
212    /// exit. Prints help on `--help` and parse error messages to stderr
213    /// before exiting. When the subcommand's own `run` returns an error
214    /// (wrapped in `Error::Runtime` by the registered runner), that is
215    /// treated as a runtime failure (exit code 1) without printing help,
216    /// so legitimate runtime errors aren't reported as bad CLI syntax —
217    /// even when the subcommand reuses parse-origin `Error` variants for
218    /// its own post-parse validation.
219    pub fn execute(self) -> ! {
220        let args = env_args();
221        let schema = self.combined_schema();
222        let code = match self.run_args(&args) {
223            Ok(()) => 0,
224            Err(Error::Runtime(inner)) => {
225                eprintln!("error: {inner}");
226                1
227            }
228            Err(e) => report_error(e, &schema),
229        };
230        process::exit(code);
231    }
232}
233
234/// Print a parse error with the most specific help schema available and
235/// return the exit code to use. `HelpRequested` is not an error to the user,
236/// so it exits 0.
237fn report_error(err: Error, root: &CommandSchema) -> i32 {
238    match err {
239        Error::HelpRequested => {
240            HelpPrinter::print(root);
241            0
242        }
243        Error::InSubcommand { path, source } => {
244            let composed = compose_help_schema(root, &path);
245            let schema = composed.as_ref().unwrap_or(root);
246            match *source {
247                Error::HelpRequested => {
248                    HelpPrinter::print(schema);
249                    0
250                }
251                inner => {
252                    eprintln!("error: {inner}");
253                    HelpPrinter::print_error(schema);
254                    2
255                }
256            }
257        }
258        other => {
259            eprintln!("error: {other}");
260            HelpPrinter::print_error(root);
261            2
262        }
263    }
264}
265
266/// Build a help-only schema that represents `root ... path` as a single
267/// command. The usage line reads e.g. `git clone [OPTIONS] <url>` or
268/// `app <workspace> run [OPTIONS] <target>` — ancestor positionals and
269/// subcommand names are folded into the composed schema's `name` so they
270/// appear in the order the user must actually type them. Options from the
271/// whole chain are merged into a single options list. The returned schema
272/// is only suitable for help printing — it is not used for parsing.
273fn compose_help_schema(root: &CommandSchema, path: &[String]) -> Option<CommandSchema> {
274    let mut options = root.options.clone();
275    let mut name_parts = vec![root.name.clone()];
276    for arg in &root.arguments {
277        name_parts.push(argument_display(arg));
278    }
279
280    let mut schema = root;
281    for (i, sub_name) in path.iter().enumerate() {
282        schema = schema.subcommands.iter().find(|s| s.name == *sub_name)?;
283        options.extend(schema.options.iter().cloned());
284        name_parts.push(sub_name.clone());
285        // Intermediate subcommands' positionals come between this name and
286        // the next subcommand. The deepest subcommand's arguments are left
287        // in the composed schema's `arguments` so the help printer renders
288        // them after `[OPTIONS]`.
289        if i + 1 < path.len() {
290            for arg in &schema.arguments {
291                name_parts.push(argument_display(arg));
292            }
293        }
294    }
295
296    let mut composed = schema.clone();
297    composed.name = name_parts.join(" ");
298    composed.options = options;
299    Some(composed)
300}
301
302fn argument_display(arg: &CLArgument) -> String {
303    if arg.required {
304        format!("<{}>", arg.name)
305    } else {
306        format!("[{}]", arg.name)
307    }
308}
309
310fn env_args() -> Vec<String> {
311    std::env::args().skip(1).collect()
312}
313
314/// Fetch `G::schema()` and assert it declares no subcommands. The root-only
315/// `Launcher` path has no dispatch table, so a subcommand declared directly
316/// on the schema would parse successfully but never execute — forcing
317/// callers to register subcommands via `Launcher::command()` makes the
318/// misuse impossible.
319fn root_schema<G: Command>() -> CommandSchema {
320    let schema = G::schema();
321    assert!(
322        schema.subcommands.is_empty(),
323        "Launcher::<G> does not dispatch subcommands; register them via Launcher::command()",
324    );
325    schema
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use runi_test::pretty_assertions::assert_eq;
332    use std::cell::RefCell;
333
334    // ----- Root-only command ---------------------------------------------
335
336    struct Greeter {
337        loud: bool,
338        target: String,
339    }
340
341    impl Command for Greeter {
342        fn schema() -> CommandSchema {
343            CommandSchema::new("greet", "Say hello")
344                .flag("-l,--loud", "Shout")
345                .argument("target", "Who to greet")
346        }
347
348        fn from_parsed(p: &ParseResult) -> Result<Self> {
349            Ok(Self {
350                loud: p.flag("--loud"),
351                target: p.require::<String>("target")?,
352            })
353        }
354    }
355
356    impl Runnable for Greeter {
357        fn run(&self) -> Result<()> {
358            Ok(())
359        }
360    }
361
362    #[test]
363    fn root_command_parse() {
364        let launcher = Launcher::<Greeter>::of();
365        let g = launcher.parse(&["-l".into(), "world".into()]).unwrap();
366        assert!(g.loud);
367        assert_eq!(g.target, "world");
368    }
369
370    // ----- Subcommand mode -----------------------------------------------
371
372    struct GitApp {
373        verbose: bool,
374    }
375
376    impl Command for GitApp {
377        fn schema() -> CommandSchema {
378            CommandSchema::new("git", "VCS").flag("-v,--verbose", "Verbose")
379        }
380
381        fn from_parsed(p: &ParseResult) -> Result<Self> {
382            Ok(Self {
383                verbose: p.flag("--verbose"),
384            })
385        }
386    }
387
388    #[derive(Clone)]
389    struct CloneCmd {
390        url: String,
391        depth: Option<u32>,
392    }
393
394    impl Command for CloneCmd {
395        fn schema() -> CommandSchema {
396            CommandSchema::new("clone", "Clone a repo")
397                .option("--depth", "Clone depth")
398                .argument("url", "Repository URL")
399        }
400
401        fn from_parsed(p: &ParseResult) -> Result<Self> {
402            Ok(Self {
403                url: p.require::<String>("url")?,
404                depth: p.get::<u32>("--depth")?,
405            })
406        }
407    }
408
409    thread_local! {
410        static CAPTURE: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
411    }
412
413    impl SubCommandOf<GitApp> for CloneCmd {
414        fn run(&self, global: &GitApp) -> Result<()> {
415            CAPTURE.with(|c| {
416                c.borrow_mut().push(format!(
417                    "clone verbose={} url={} depth={:?}",
418                    global.verbose, self.url, self.depth
419                ))
420            });
421            Ok(())
422        }
423    }
424
425    #[test]
426    fn dispatch_subcommand_with_globals() {
427        CAPTURE.with(|c| c.borrow_mut().clear());
428        let launcher = Launcher::<GitApp>::of().command::<CloneCmd>("clone");
429        launcher
430            .run_args(&[
431                "-v".into(),
432                "clone".into(),
433                "--depth".into(),
434                "1".into(),
435                "https://example.com".into(),
436            ])
437            .unwrap();
438        CAPTURE.with(|c| {
439            let captured = c.borrow();
440            assert_eq!(captured.len(), 1);
441            assert_eq!(
442                captured[0],
443                "clone verbose=true url=https://example.com depth=Some(1)"
444            );
445        });
446    }
447
448    #[test]
449    fn missing_subcommand_error() {
450        let launcher = Launcher::<GitApp>::of().command::<CloneCmd>("clone");
451        let err = launcher.run_args(&[]).unwrap_err();
452        assert!(matches!(err, Error::MissingSubcommand { .. }));
453    }
454
455    #[test]
456    fn help_requested_error_propagates() {
457        let launcher = Launcher::<GitApp>::of().command::<CloneCmd>("clone");
458        let err = launcher.run_args(&["--help".into()]).unwrap_err();
459        assert!(matches!(err, Error::HelpRequested));
460    }
461
462    #[test]
463    fn subcommand_rejects_unknown_name() {
464        let launcher = Launcher::<GitApp>::of().command::<CloneCmd>("clone");
465        let err = launcher.run_args(&["nope".into()]).unwrap_err();
466        match err {
467            Error::UnknownSubcommand { name, .. } => assert_eq!(name, "nope"),
468            other => panic!("unexpected: {other:?}"),
469        }
470    }
471
472    // Sanity check that the from_parsed path reports invalid types with the
473    // argument name, not just the FromStr::Err message.
474    #[derive(Debug, Clone)]
475    struct NeedsInt {
476        n: u32,
477    }
478
479    impl Command for NeedsInt {
480        fn schema() -> CommandSchema {
481            CommandSchema::new("n", "").option("-n,--num", "a number")
482        }
483
484        fn from_parsed(p: &ParseResult) -> Result<Self> {
485            Ok(Self {
486                n: p.require::<u32>("--num")?,
487            })
488        }
489    }
490    impl Runnable for NeedsInt {
491        fn run(&self) -> Result<()> {
492            let _ = self.n;
493            Ok(())
494        }
495    }
496
497    // Subcommand whose run() returns a runtime error.
498    struct FailingCmd;
499    impl Command for FailingCmd {
500        fn schema() -> CommandSchema {
501            CommandSchema::new("fail", "always fails")
502        }
503        fn from_parsed(_: &ParseResult) -> Result<Self> {
504            Ok(Self)
505        }
506    }
507    impl SubCommandOf<GitApp> for FailingCmd {
508        fn run(&self, _: &GitApp) -> Result<()> {
509            Err(Error::custom("something went wrong"))
510        }
511    }
512
513    #[test]
514    fn runtime_error_is_not_a_parse_error() {
515        let launcher = Launcher::<GitApp>::of().command::<FailingCmd>("fail");
516        let err = launcher.run_args(&["fail".into()]).unwrap_err();
517        assert!(!err.is_parse_error());
518        // The runner wraps SubCommandOf::run errors in Error::Runtime so
519        // the launcher can tell them apart from parse-origin variants.
520        match err {
521            Error::Runtime(inner) => assert!(matches!(*inner, Error::Custom(_))),
522            other => panic!("expected Error::Runtime, got {other:?}"),
523        }
524    }
525
526    // Subcommand whose run() returns a parse-origin variant for its own
527    // validation, to confirm Error::Runtime wrapping classifies it as a
528    // runtime failure.
529    struct ValidatingCmd;
530    impl Command for ValidatingCmd {
531        fn schema() -> CommandSchema {
532            CommandSchema::new("validate", "")
533        }
534        fn from_parsed(_: &ParseResult) -> Result<Self> {
535            Ok(Self)
536        }
537    }
538    impl SubCommandOf<GitApp> for ValidatingCmd {
539        fn run(&self, _: &GitApp) -> Result<()> {
540            // User code legitimately uses a parse-origin variant for its
541            // own post-parse validation.
542            Err(Error::MissingArgument("config".into()))
543        }
544    }
545
546    #[test]
547    fn subcommand_run_returning_parse_variant_is_still_runtime() {
548        let launcher = Launcher::<GitApp>::of().command::<ValidatingCmd>("validate");
549        let err = launcher.run_args(&["validate".into()]).unwrap_err();
550        assert!(!err.is_parse_error());
551        match err {
552            Error::Runtime(inner) => {
553                assert!(matches!(*inner, Error::MissingArgument(_)));
554            }
555            other => panic!("expected Error::Runtime, got {other:?}"),
556        }
557    }
558
559    // Subcommand that requires an argument — exercises parse-error wrapping
560    // from inside the runner (S::from_parsed path).
561    #[derive(Debug)]
562    struct Needy {
563        _name: String,
564    }
565    impl Command for Needy {
566        fn schema() -> CommandSchema {
567            CommandSchema::new("needy", "").argument("name", "required")
568        }
569        fn from_parsed(p: &ParseResult) -> Result<Self> {
570            Ok(Self {
571                _name: p.require::<String>("name")?,
572            })
573        }
574    }
575    impl SubCommandOf<GitApp> for Needy {
576        fn run(&self, _: &GitApp) -> Result<()> {
577            Ok(())
578        }
579    }
580
581    #[test]
582    fn subcommand_from_parsed_error_wrapped_with_context() {
583        // The parser accepts `needy` with no further args (the positional is
584        // declared on the subcommand schema but nothing violates parse shape
585        // there), so the MissingArgument surfaces from the runner's call to
586        // from_parsed and must be wrapped with the subcommand path for the
587        // launcher to pick the right help schema.
588        let launcher = Launcher::<GitApp>::of().command::<Needy>("needy");
589        let err = launcher.run_args(&["needy".into()]).unwrap_err();
590        match err {
591            Error::InSubcommand { path, source } => {
592                assert_eq!(path, vec!["needy".to_string()]);
593                assert!(matches!(*source, Error::MissingArgument(_)));
594            }
595            other => panic!("expected InSubcommand, got {other:?}"),
596        }
597    }
598
599    #[test]
600    #[should_panic(expected = "duplicate subcommand name: clone")]
601    fn duplicate_subcommand_registration_panics() {
602        let _ = Launcher::<GitApp>::of()
603            .command::<CloneCmd>("clone")
604            .command::<CloneCmd>("clone");
605    }
606
607    struct AppWithStubSub;
608    impl Command for AppWithStubSub {
609        fn schema() -> CommandSchema {
610            CommandSchema::new("app", "").subcommand(CommandSchema::new("clone", "stub"))
611        }
612        fn from_parsed(_: &ParseResult) -> Result<Self> {
613            Ok(Self)
614        }
615    }
616
617    // Root-only Launcher must also reject G::schema() declaring subcommands
618    // — there is no dispatch table in that mode, so it would silently ignore
619    // user input.
620    struct RunnableStubSub;
621    impl Command for RunnableStubSub {
622        fn schema() -> CommandSchema {
623            CommandSchema::new("app", "").subcommand(CommandSchema::new("clone", "stub"))
624        }
625        fn from_parsed(_: &ParseResult) -> Result<Self> {
626            Ok(Self)
627        }
628    }
629    impl Runnable for RunnableStubSub {
630        fn run(&self) -> Result<()> {
631            Ok(())
632        }
633    }
634
635    #[test]
636    #[should_panic(expected = "Launcher::<G> does not dispatch subcommands")]
637    fn root_launcher_rejects_schema_declared_subcommands() {
638        let _ = Launcher::<RunnableStubSub>::of().parse(&[]);
639    }
640
641    #[test]
642    #[should_panic(expected = "G::schema() must not declare subcommands")]
643    fn schema_declared_subcommands_panic() {
644        // combined_schema runs at parse time. Declaring a subcommand
645        // directly on G::schema() is unsafe because no runner is
646        // registered for it — reject up front.
647        let launcher = Launcher::<AppWithStubSub>::of().command::<CloneCmd>("clone");
648        let _ = launcher.run_args(&["clone".into()]);
649    }
650
651    // Dummy SubCommandOf<AppWithStubSub> impl so the Launcher registration
652    // compiles; the panic in combined_schema fires before dispatch.
653    impl SubCommandOf<AppWithStubSub> for CloneCmd {
654        fn run(&self, _: &AppWithStubSub) -> Result<()> {
655            Ok(())
656        }
657    }
658
659    #[test]
660    fn compose_help_schema_prefixes_root_name_and_options() {
661        let root = CommandSchema::new("git", "").flag("-v,--verbose", "Verbose");
662        let sub = CommandSchema::new("clone", "Clone a repo").argument("url", "URL");
663        let mut with_sub = root.clone();
664        with_sub.subcommands.push(sub);
665        let composed =
666            compose_help_schema(&with_sub, &["clone".to_string()]).expect("must resolve");
667        assert_eq!(composed.name, "git clone");
668        // Root's --verbose must appear alongside clone's own options.
669        assert!(composed.options.iter().any(|o| o.matches_long("verbose")));
670        // Clone's own positional must be preserved.
671        assert!(composed.arguments.iter().any(|a| a.name == "url"));
672    }
673
674    #[test]
675    fn compose_help_schema_folds_root_positionals_into_name() {
676        // `app <workspace> run <target>` — the root has a positional that
677        // must appear before the subcommand name in the usage line.
678        let root = CommandSchema::new("app", "").argument("workspace", "");
679        let sub = CommandSchema::new("run", "").argument("target", "");
680        let mut with_sub = root.clone();
681        with_sub.subcommands.push(sub);
682        let composed = compose_help_schema(&with_sub, &["run".to_string()]).expect("must resolve");
683        assert_eq!(composed.name, "app <workspace> run");
684        // The deepest subcommand's own arguments stay in `arguments` so the
685        // help printer renders them after `[OPTIONS]` in the usage line.
686        assert_eq!(composed.arguments.len(), 1);
687        assert_eq!(composed.arguments[0].name, "target");
688    }
689
690    #[test]
691    fn invalid_value_error_is_informative() {
692        let launcher = Launcher::<NeedsInt>::of();
693        let err = launcher.parse(&["--num".into(), "abc".into()]).unwrap_err();
694        match err {
695            Error::InvalidValue { name, value, .. } => {
696                assert_eq!(name, "--num");
697                assert_eq!(value, "abc");
698            }
699            other => panic!("unexpected: {other:?}"),
700        }
701    }
702}