1use std::ffi::OsString;
4use std::fmt;
5use std::marker::PhantomData;
6use std::process::ExitCode;
7
8use anyhow::Result;
9#[cfg(feature = "usage")]
10use clap::Command;
11use clap::{CommandFactory, Parser};
12
13use crate::color::ColorMode;
14use crate::format::OutputFormat;
15use crate::render::RenderOptions;
16use crate::view::{JsonLayout, Present, View};
17
18type BeforeParse = Box<dyn Fn(&[OsString]) -> Option<ExitCode>>;
19type SelectView<C> = Box<dyn Fn(&C) -> View>;
20#[cfg(feature = "usage")]
21type RenderUsage = Box<dyn Fn(Command, &str) -> String>;
22
23#[derive(Clone, Copy)]
25struct Fallback(Option<u16>);
26
27pub struct App<C> {
29 bin: String,
30 before_parse: Vec<BeforeParse>,
31 select_view: SelectView<C>,
32 automatic_width_buffer: Option<u16>,
33 automatic_width_buffer_envs: Option<&'static [&'static str]>,
34 minimum_automatic_width: Option<u16>,
35 fallback_width: Option<Fallback>,
36 styles: Option<RenderOptions>,
37 json_layout: Option<JsonLayout>,
38 json_layout_envs: Option<&'static [&'static str]>,
39 #[cfg(feature = "usage")]
40 mounted_as: Option<String>,
41 #[cfg(feature = "usage")]
42 render_usage: RenderUsage,
43 marker: PhantomData<C>,
44}
45
46impl<C> fmt::Debug for App<C> {
48 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49 formatter
50 .debug_struct("App")
51 .field("bin", &self.bin)
52 .finish_non_exhaustive()
53 }
54}
55
56impl<C> App<C> {
57 #[must_use]
59 pub fn new(bin: impl Into<String>) -> Self {
60 Self {
61 bin: bin.into(),
62 before_parse: Vec::new(),
63 select_view: Box::new(|_| View::new(OutputFormat::Pretty, ColorMode::Auto)),
64 automatic_width_buffer: None,
65 automatic_width_buffer_envs: None,
66 minimum_automatic_width: None,
67 fallback_width: None,
68 styles: None,
69 json_layout: None,
70 json_layout_envs: None,
71 #[cfg(feature = "usage")]
72 mounted_as: None,
73 #[cfg(feature = "usage")]
74 render_usage: Box::new(crate::usage::spec),
75 marker: PhantomData,
76 }
77 }
78
79 #[must_use]
81 pub fn view(mut self, select: impl Fn(&C) -> View + 'static) -> Self {
82 self.select_view = Box::new(select);
83 self
84 }
85
86 #[must_use]
89 pub fn automatic_width_buffer(mut self, columns: u16) -> Self {
90 self.automatic_width_buffer = Some(columns);
91 self
92 }
93
94 #[must_use]
97 pub fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
98 self.automatic_width_buffer_envs = Some(names);
99 self
100 }
101
102 #[must_use]
104 pub fn minimum_automatic_width(mut self, columns: u16) -> Self {
105 self.minimum_automatic_width = Some(columns);
106 self
107 }
108
109 #[must_use]
111 pub fn fallback_width(mut self, width: Option<u16>) -> Self {
112 self.fallback_width = Some(Fallback(width));
113 self
114 }
115
116 #[cfg(feature = "input")]
119 #[must_use]
120 pub fn input_limits(self, limits: crate::input::Limits) -> Self {
121 crate::input::set_default_limits(limits);
122 self
123 }
124
125 #[must_use]
128 pub fn styles(mut self, styles: RenderOptions) -> Self {
129 self.styles = Some(styles);
130 self
131 }
132
133 #[must_use]
135 pub fn json_layout(mut self, layout: JsonLayout) -> Self {
136 self.json_layout = Some(layout);
137 self
138 }
139
140 #[must_use]
143 pub fn json_layout_envs(mut self, names: &'static [&'static str]) -> Self {
144 self.json_layout_envs = Some(names);
145 self
146 }
147
148 fn configured_view(&self, mut view: View) -> View {
149 if let Some(buffer) = self.automatic_width_buffer {
150 view = view.automatic_width_buffer(buffer);
151 }
152 if let Some(names) = self.automatic_width_buffer_envs {
153 view = view.automatic_width_buffer_envs(names);
154 }
155 if let Some(minimum) = self.minimum_automatic_width {
156 view = view.minimum_automatic_width(minimum);
157 }
158 if let Some(Fallback(width)) = self.fallback_width {
159 view = view.fallback_width(width);
160 }
161 if let Some(styles) = self.styles {
162 view = view.styles(styles);
163 }
164 if let Some(layout) = self.json_layout {
165 view = view.json_layout(layout);
166 }
167 if let Some(names) = self.json_layout_envs {
168 view = view.json_layout_envs(names);
169 }
170 view
171 }
172
173 #[must_use]
175 pub fn before_parse(
176 mut self,
177 hook: impl Fn(&[OsString]) -> Option<ExitCode> + 'static,
178 ) -> Self {
179 self.before_parse.push(Box::new(hook));
180 self
181 }
182
183 #[cfg(feature = "usage")]
185 #[must_use]
186 pub fn mounted_as(mut self, task: impl Into<String>) -> Self {
187 self.mounted_as = Some(task.into());
188 self
189 }
190
191 #[cfg(feature = "usage")]
195 #[must_use]
196 pub fn usage_spec(mut self, render: impl Fn(Command, &str) -> String + 'static) -> Self {
197 self.render_usage = Box::new(render);
198 self
199 }
200}
201
202impl<C> App<C>
203where
204 C: Parser + CommandFactory,
205{
206 #[must_use = "return it from main, or the command exits 0"]
208 pub fn run<T>(self, execute: impl FnOnce(C) -> Result<T>) -> ExitCode
209 where
210 T: Present,
211 {
212 self.run_from(std::env::args_os(), execute)
213 }
214
215 #[must_use = "return it from main, or the command exits 0"]
217 pub fn run_from<T>(
218 self,
219 args: impl IntoIterator<Item = impl Into<OsString>>,
220 execute: impl FnOnce(C) -> Result<T>,
221 ) -> ExitCode
222 where
223 T: Present,
224 {
225 let raw = args.into_iter().map(Into::into).collect::<Vec<_>>();
226 let words = raw
227 .iter()
228 .map(|arg| arg.to_string_lossy().into_owned())
229 .collect::<Vec<_>>();
230
231 #[cfg(feature = "usage")]
232 if let Some(task) = &self.mounted_as
233 && let Some(spec_bin) = crate::usage::spec_bin(words.iter().skip(1), task)
234 {
235 let mut command = C::command();
236 command.set_bin_name(&spec_bin);
237 let spec = (self.render_usage)(command, &spec_bin);
238 return crate::view::write_stdout(spec.as_bytes(), ColorMode::Never)
239 .map_or(ExitCode::FAILURE, |()| ExitCode::SUCCESS);
240 }
241
242 for hook in &self.before_parse {
243 if let Some(code) = hook(&raw) {
244 return code;
245 }
246 }
247
248 let raw_view = self.configured_view(raw_view::<C>(&raw));
249 for warning in raw_view.env_warnings(crate::render::process_env) {
250 eprintln!("{}: warning: {warning}", self.bin);
251 }
252 if words.len() == 1 && crate::parser::requires_input::<C>() {
253 return crate::help::emit_bare_with_options::<C>(raw_view.render_options())
254 .map_or(ExitCode::FAILURE, |()| ExitCode::from(2));
255 }
256 match crate::help::try_emit_from_with_options::<C>(&words, raw_view.render_options()) {
257 Ok(true) => return ExitCode::SUCCESS,
258 Ok(false) => {}
259 Err(_) => return ExitCode::FAILURE,
260 }
261
262 let mut command = crate::parser::apply_defaults(C::command());
263 if raw_view.format.is_json() {
264 command = command.color(clap::ColorChoice::Never);
265 }
266 let command = match crate::flags::env_under_flags(command, &raw) {
267 Ok(command) => command,
268 Err(error) => return self.clap_error(&error, parse_error_view(&raw, raw_view)),
269 };
270 let matches = match command.try_get_matches_from(&raw) {
271 Ok(matches) => matches,
272 Err(error) => return self.clap_error(&error, parse_error_view(&raw, raw_view)),
273 };
274 let cli = match C::from_arg_matches(&matches) {
275 Ok(cli) => cli,
276 Err(error) => return self.clap_error(&error, parse_error_view(&raw, raw_view)),
277 };
278 let view = self.configured_view((self.select_view)(&cli));
279 match execute(cli) {
280 Ok(value) => view.show(&value).unwrap_or(ExitCode::FAILURE),
281 Err(error) => view
282 .emit_err(&self.bin, &format!("{error:#}"))
283 .unwrap_or(ExitCode::FAILURE),
284 }
285 }
286
287 fn clap_error(&self, error: &clap::Error, view: View) -> ExitCode {
288 let code = exit_code(error.exit_code());
289 if is_clap_display(error.kind()) {
290 let _ = error.print();
291 return code;
292 }
293 view.emit_err(&self.bin, error.to_string().trim())
294 .map_or(ExitCode::FAILURE, |_| code)
295 }
296}
297
298fn raw_view<C: CommandFactory>(raw: &[OsString]) -> View {
299 let parsed = crate::parser::parsed_output::<C>(raw);
300 View::new(parsed.format, parsed.color)
301}
302
303fn parse_error_view(raw: &[OsString], mut view: View) -> View {
315 let mut args = raw.iter().skip(1).map(|arg| arg.to_str());
316 let mut last = None;
317 while let Some(arg) = args.next() {
318 let value = match arg {
319 Some("--") => break,
320 Some("--format") => args.next().flatten(),
321 Some(arg) => arg.strip_prefix("--format="),
322 None => None,
323 };
324 if let Some(format) =
325 value.and_then(|value| <OutputFormat as clap::ValueEnum>::from_str(value, false).ok())
326 {
327 last = Some(format);
328 }
329 }
330 if let Some(format) = last {
331 view.format = format;
332 if format.is_json() {
333 view.color = ColorMode::Never;
334 }
335 }
336 view
337}
338
339fn is_clap_display(kind: clap::error::ErrorKind) -> bool {
340 matches!(
341 kind,
342 clap::error::ErrorKind::DisplayVersion
343 | clap::error::ErrorKind::DisplayHelp
344 | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
345 )
346}
347
348fn exit_code(code: i32) -> ExitCode {
349 u8::try_from(code).map_or(ExitCode::FAILURE, ExitCode::from)
350}
351
352#[cfg(test)]
353mod tests {
354 use std::cell::Cell;
355 use std::ffi::OsString;
356 use std::rc::Rc;
357
358 use clap::{Parser, Subcommand};
359 use serde::Serialize;
360
361 use super::{App, is_clap_display, parse_error_view, raw_view};
362 use crate::document::{Document, Fields};
363 use crate::render::{RecordStyle, RenderOptions};
364 use crate::view::{JsonLayout, Present, View};
365 use crate::{ColorMode, OutputArgs, OutputFormat};
366
367 #[derive(Parser)]
368 #[command(version, about = "toy")]
369 struct Cli {
370 #[command(flatten)]
371 output: OutputArgs,
372 #[command(subcommand)]
373 command: Command,
374 }
375
376 #[derive(Subcommand)]
377 enum Command {
378 Status(StatusArgs),
380 }
381
382 #[derive(clap::Args)]
383 struct StatusArgs {
384 #[arg(short = 'm', long, allow_hyphen_values = true)]
386 message: Option<String>,
387 }
388
389 #[derive(Parser)]
390 #[command(version, about = "optional toy")]
391 struct OptionalCli {}
392
393 #[derive(Parser)]
394 #[command(version, about = "nested toy")]
395 struct NestedCli {
396 #[command(subcommand)]
397 command: NestedCommand,
398 }
399
400 #[derive(Subcommand)]
401 enum NestedCommand {
402 Group {
404 #[command(subcommand)]
405 command: GroupCommand,
406 },
407 }
408
409 #[derive(Subcommand)]
410 enum GroupCommand {
411 Status,
413 }
414
415 #[derive(Serialize)]
416 struct Status {
417 pending: usize,
418 }
419
420 impl Present for Status {
421 fn present(&self) -> Document {
422 Document::new().fields(Fields::new().row("pending", self.pending.to_string()))
423 }
424 }
425
426 #[test]
427 fn parses_executes_and_suppresses_quiet_pretty() {
428 let ran = Rc::new(Cell::new(false));
429 let observed = Rc::clone(&ran);
430 let code = App::<Cli>::new("toy")
431 .view(|cli| {
432 View::new(cli.output.format, cli.output.color())
433 .quiet(cli.output.quiet)
434 .width(80)
435 })
436 .run_from(["toy", "status", "--quiet"], move |_| {
437 observed.set(true);
438 Ok(Status { pending: 0 })
439 });
440 assert_eq!(code, std::process::ExitCode::SUCCESS);
441 assert!(ran.get());
442 }
443
444 #[test]
447 fn a_parse_error_honours_a_later_json_format() {
448 let pretty = View::new(OutputFormat::Pretty, ColorMode::Always);
449 let cases = [
450 (&["toy", "status", "--bogus", "--format", "json"][..], true),
451 (&["toy", "status", "--bogus", "--format=json"][..], true),
452 (&["toy", "status", "--bogus"][..], false),
453 (
454 &["toy", "status", "--bogus", "--", "--format", "json"][..],
455 false,
456 ),
457 (
458 &["toy", "status", "--bogus", "--format", "pretty"][..],
459 false,
460 ),
461 (
462 &["toy", "--bogus", "--format", "json", "--format", "pretty"][..],
463 false,
464 ),
465 (
466 &["toy", "--bogus", "--format=pretty", "--format", "json"][..],
467 true,
468 ),
469 ];
470 for (argv, json) in cases {
471 let raw: Vec<OsString> = argv.iter().map(OsString::from).collect();
472 let view = parse_error_view(&raw, pretty);
473 assert_eq!(view.format.is_json(), json, "{argv:?}");
474 if json {
475 assert_eq!(view.color, ColorMode::Never, "{argv:?}");
476 }
477 }
478 let json_first = View::new(OutputFormat::Json, ColorMode::Never);
479 let raw: Vec<OsString> = ["toy", "--format", "json", "--bogus", "--format", "pretty"]
480 .iter()
481 .map(OsString::from)
482 .collect();
483 assert!(
484 !parse_error_view(&raw, json_first).format.is_json(),
485 "a later --format pretty undoes the pre-parse's JSON"
486 );
487 }
488
489 #[test]
490 fn pre_parse_hook_runs_before_clap() {
491 let code = App::<Cli>::new("toy")
492 .before_parse(|args| {
493 args.iter()
494 .any(|arg| arg == "--complete")
495 .then_some(std::process::ExitCode::SUCCESS)
496 })
497 .run_from(["toy", "--complete"], |_| Ok(Status { pending: 0 }));
498 assert_eq!(code, std::process::ExitCode::SUCCESS);
499 }
500
501 #[cfg(feature = "usage")]
502 #[test]
503 fn mounted_usage_accepts_a_custom_renderer() {
504 use std::cell::RefCell;
505
506 let observed = Rc::new(RefCell::new(None));
507 let callback_observed = Rc::clone(&observed);
508 let code = App::<Cli>::new("toy")
509 .mounted_as("q")
510 .usage_spec(move |command, bin| {
511 *callback_observed.borrow_mut() =
512 Some((bin.to_owned(), command.get_bin_name().map(str::to_owned)));
513 format!("custom {bin}\n")
514 })
515 .run_from(["toy", "--usage-spec=mounted"], |_| {
516 Ok(Status { pending: 0 })
517 });
518 assert_eq!(code, std::process::ExitCode::SUCCESS);
519 assert_eq!(
520 observed.borrow().as_ref(),
521 Some(&("mounted".to_owned(), Some("mounted".to_owned())))
522 );
523 }
524
525 #[test]
526 fn app_width_policy_configures_every_view() {
527 let app = App::<Cli>::new("toy")
528 .automatic_width_buffer(0)
529 .automatic_width_buffer_envs(&["TOY_BUFFER", "LEGACY_BUFFER"])
530 .minimum_automatic_width(8);
531 let view = app.configured_view(View::new(OutputFormat::Pretty, ColorMode::Never));
532 assert_eq!(view.explicit_automatic_width_buffer(), Some(0));
533 assert_eq!(
534 view.automatic_width_buffer_env_names(),
535 ["TOY_BUFFER", "LEGACY_BUFFER"]
536 );
537 assert_eq!(view.automatic_width_minimum(), 8);
538 }
539
540 #[test]
541 fn app_json_layout_envs_reach_every_view() {
542 let compact = |name: &str| (name == "TOY_JSON").then(|| "compact".to_owned());
543 let view = App::<Cli>::new("toy")
544 .json_layout_envs(&["TOY_JSON"])
545 .configured_view(View::new(OutputFormat::Json, ColorMode::Never));
546 assert_eq!(view.layout_with(compact), JsonLayout::Compact);
547 let disabled = App::<Cli>::new("toy")
548 .json_layout_envs(&[])
549 .configured_view(View::new(OutputFormat::Json, ColorMode::Never));
550 assert_eq!(disabled.layout_with(compact), JsonLayout::Pretty);
551 }
552
553 #[test]
554 fn app_styles_and_fallback_reach_every_view() {
555 let app = App::<Cli>::new("toy")
556 .fallback_width(None)
557 .styles(RenderOptions::new(ColorMode::Never).record_style(RecordStyle::Boxed))
558 .json_layout(JsonLayout::Compact);
559 let options = app
560 .configured_view(View::new(OutputFormat::Pretty, ColorMode::Never))
561 .render_options();
562 assert_eq!(options.fallback(), None);
563 assert_eq!(options.record(), RecordStyle::Boxed);
564 let json = app
565 .configured_view(View::new(OutputFormat::Json, ColorMode::Never))
566 .capture(&Status { pending: 1 })
567 .unwrap();
568 assert_eq!(json.text(), "{\"pending\":1}\n");
569 }
570
571 #[test]
572 fn defaults_are_pretty_auto() {
573 let app = App::<Cli>::new("toy").view(|_| {
574 View::new(OutputFormat::Pretty, ColorMode::Auto)
575 .quiet(true)
576 .width(80)
577 });
578 let code = app.run_from(["toy", "status"], |_| Ok(Status { pending: 0 }));
579 assert_eq!(code, std::process::ExitCode::SUCCESS);
580 }
581
582 #[test]
583 fn clap_help_kinds_stay_on_claps_display_path() {
584 use clap::error::ErrorKind;
585
586 assert!(is_clap_display(ErrorKind::DisplayVersion));
587 assert!(is_clap_display(ErrorKind::DisplayHelp));
588 assert!(is_clap_display(
589 ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
590 ));
591 assert!(!is_clap_display(ErrorKind::UnknownArgument));
592 }
593
594 #[test]
595 fn missing_nested_subcommand_uses_claps_help_exit() {
596 let code =
597 App::<NestedCli>::new("toy").run_from(["toy", "group"], |_| Ok(Status { pending: 0 }));
598 assert_eq!(code, std::process::ExitCode::from(2));
599 }
600
601 #[test]
602 fn bare_invocation_is_usage_error() {
603 let code = App::<Cli>::new("toy").run_from(["toy"], |_| Ok(Status { pending: 0 }));
604 assert_eq!(code, std::process::ExitCode::from(2));
605 }
606
607 #[test]
608 fn bare_invocation_executes_when_the_root_accepts_it() {
609 let ran = Rc::new(Cell::new(false));
610 let observed = Rc::clone(&ran);
611 let code = App::<OptionalCli>::new("toy").run_from(["toy"], move |_| {
612 observed.set(true);
613 Ok(Status { pending: 0 })
614 });
615 assert_eq!(code, std::process::ExitCode::SUCCESS);
616 assert!(ran.get());
617 }
618
619 #[test]
620 fn explicit_help_used_as_a_domain_value_reaches_execution() {
621 let ran = Rc::new(Cell::new(false));
622 let observed = Rc::clone(&ran);
623 let code = App::<Cli>::new("toy").run_from(["toy", "status", "-m", "--help"], move |_| {
624 observed.set(true);
625 Ok(Status { pending: 0 })
626 });
627 assert_eq!(code, std::process::ExitCode::SUCCESS);
628 assert!(ran.get());
629 }
630
631 #[test]
632 fn raw_view_uses_clap_to_separate_globals_from_domain_values() {
633 let domain_value = ["toy", "status", "-m", "--format=json", "--bogus"].map(OsString::from);
634 let view = raw_view::<Cli>(&domain_value);
635 assert_eq!(view.format, OutputFormat::Pretty);
636 assert_eq!(view.color, ColorMode::Auto);
637
638 let global_after_subcommand =
639 ["toy", "status", "-fjson", "-cnever", "--bogus"].map(OsString::from);
640 let view = raw_view::<Cli>(&global_after_subcommand);
641 assert_eq!(view.format, OutputFormat::Json);
642 assert_eq!(view.color, ColorMode::Never);
643 }
644}