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
9pub trait Command: Sized {
12 fn schema() -> CommandSchema;
13 fn from_parsed(parsed: &ParseResult) -> Result<Self>;
14}
15
16pub trait Runnable {
19 fn run(&self) -> Result<()>;
20}
21
22pub 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
34pub 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 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 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 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 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
98pub 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 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 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 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 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 pub fn schema(&self) -> CommandSchema {
169 self.combined_schema()
170 }
171
172 fn combined_schema(&self) -> CommandSchema {
173 let mut schema = G::schema();
174 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 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 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
234fn 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
266fn 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 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
314fn 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 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 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 #[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 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 match err {
521 Error::Runtime(inner) => assert!(matches!(*inner, Error::Custom(_))),
522 other => panic!("expected Error::Runtime, got {other:?}"),
523 }
524 }
525
526 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 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 #[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 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 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 let launcher = Launcher::<AppWithStubSub>::of().command::<CloneCmd>("clone");
648 let _ = launcher.run_args(&["clone".into()]);
649 }
650
651 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 assert!(composed.options.iter().any(|o| o.matches_long("verbose")));
670 assert!(composed.arguments.iter().any(|a| a.name == "url"));
672 }
673
674 #[test]
675 fn compose_help_schema_folds_root_positionals_into_name() {
676 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 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}