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 matches = match command.try_get_matches_from(&raw) {
267 Ok(matches) => matches,
268 Err(error) => return self.clap_error(&error, raw_view),
269 };
270 let cli = match C::from_arg_matches(&matches) {
271 Ok(cli) => cli,
272 Err(error) => return self.clap_error(&error, raw_view),
273 };
274 let view = self.configured_view((self.select_view)(&cli));
275 match execute(cli) {
276 Ok(value) => view.show(&value).unwrap_or(ExitCode::FAILURE),
277 Err(error) => view
278 .emit_err(&self.bin, &format!("{error:#}"))
279 .unwrap_or(ExitCode::FAILURE),
280 }
281 }
282
283 fn clap_error(&self, error: &clap::Error, view: View) -> ExitCode {
284 let code = exit_code(error.exit_code());
285 if is_clap_display(error.kind()) {
286 let _ = error.print();
287 return code;
288 }
289 view.emit_err(&self.bin, error.to_string().trim())
290 .map_or(ExitCode::FAILURE, |_| code)
291 }
292}
293
294fn raw_view<C: CommandFactory>(raw: &[OsString]) -> View {
295 let parsed = crate::parser::parsed_output::<C>(raw);
296 View::new(parsed.format, parsed.color)
297}
298
299fn is_clap_display(kind: clap::error::ErrorKind) -> bool {
300 matches!(
301 kind,
302 clap::error::ErrorKind::DisplayVersion
303 | clap::error::ErrorKind::DisplayHelp
304 | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
305 )
306}
307
308fn exit_code(code: i32) -> ExitCode {
309 u8::try_from(code).map_or(ExitCode::FAILURE, ExitCode::from)
310}
311
312#[cfg(test)]
313mod tests {
314 use std::cell::Cell;
315 use std::ffi::OsString;
316 use std::rc::Rc;
317
318 use clap::{Parser, Subcommand};
319 use serde::Serialize;
320
321 use super::{App, is_clap_display, raw_view};
322 use crate::document::{Document, Fields};
323 use crate::render::{RecordStyle, RenderOptions};
324 use crate::view::{JsonLayout, Present, View};
325 use crate::{ColorMode, OutputArgs, OutputFormat};
326
327 #[derive(Parser)]
328 #[command(version, about = "toy")]
329 struct Cli {
330 #[command(flatten)]
331 output: OutputArgs,
332 #[command(subcommand)]
333 command: Command,
334 }
335
336 #[derive(Subcommand)]
337 enum Command {
338 Status(StatusArgs),
340 }
341
342 #[derive(clap::Args)]
343 struct StatusArgs {
344 #[arg(short = 'm', long, allow_hyphen_values = true)]
346 message: Option<String>,
347 }
348
349 #[derive(Parser)]
350 #[command(version, about = "optional toy")]
351 struct OptionalCli {}
352
353 #[derive(Parser)]
354 #[command(version, about = "nested toy")]
355 struct NestedCli {
356 #[command(subcommand)]
357 command: NestedCommand,
358 }
359
360 #[derive(Subcommand)]
361 enum NestedCommand {
362 Group {
364 #[command(subcommand)]
365 command: GroupCommand,
366 },
367 }
368
369 #[derive(Subcommand)]
370 enum GroupCommand {
371 Status,
373 }
374
375 #[derive(Serialize)]
376 struct Status {
377 pending: usize,
378 }
379
380 impl Present for Status {
381 fn present(&self) -> Document {
382 Document::new().fields(Fields::new().row("pending", self.pending.to_string()))
383 }
384 }
385
386 #[test]
387 fn parses_executes_and_suppresses_quiet_pretty() {
388 let ran = Rc::new(Cell::new(false));
389 let observed = Rc::clone(&ran);
390 let code = App::<Cli>::new("toy")
391 .view(|cli| {
392 View::new(cli.output.format, cli.output.color())
393 .quiet(cli.output.quiet)
394 .width(80)
395 })
396 .run_from(["toy", "status", "--quiet"], move |_| {
397 observed.set(true);
398 Ok(Status { pending: 0 })
399 });
400 assert_eq!(code, std::process::ExitCode::SUCCESS);
401 assert!(ran.get());
402 }
403
404 #[test]
405 fn pre_parse_hook_runs_before_clap() {
406 let code = App::<Cli>::new("toy")
407 .before_parse(|args| {
408 args.iter()
409 .any(|arg| arg == "--complete")
410 .then_some(std::process::ExitCode::SUCCESS)
411 })
412 .run_from(["toy", "--complete"], |_| Ok(Status { pending: 0 }));
413 assert_eq!(code, std::process::ExitCode::SUCCESS);
414 }
415
416 #[cfg(feature = "usage")]
417 #[test]
418 fn mounted_usage_accepts_a_custom_renderer() {
419 use std::cell::RefCell;
420
421 let observed = Rc::new(RefCell::new(None));
422 let callback_observed = Rc::clone(&observed);
423 let code = App::<Cli>::new("toy")
424 .mounted_as("q")
425 .usage_spec(move |command, bin| {
426 *callback_observed.borrow_mut() =
427 Some((bin.to_owned(), command.get_bin_name().map(str::to_owned)));
428 format!("custom {bin}\n")
429 })
430 .run_from(["toy", "--usage-spec=mounted"], |_| {
431 Ok(Status { pending: 0 })
432 });
433 assert_eq!(code, std::process::ExitCode::SUCCESS);
434 assert_eq!(
435 observed.borrow().as_ref(),
436 Some(&("mounted".to_owned(), Some("mounted".to_owned())))
437 );
438 }
439
440 #[test]
441 fn app_width_policy_configures_every_view() {
442 let app = App::<Cli>::new("toy")
443 .automatic_width_buffer(0)
444 .automatic_width_buffer_envs(&["TOY_BUFFER", "LEGACY_BUFFER"])
445 .minimum_automatic_width(8);
446 let view = app.configured_view(View::new(OutputFormat::Pretty, ColorMode::Never));
447 assert_eq!(view.explicit_automatic_width_buffer(), Some(0));
448 assert_eq!(
449 view.automatic_width_buffer_env_names(),
450 ["TOY_BUFFER", "LEGACY_BUFFER"]
451 );
452 assert_eq!(view.automatic_width_minimum(), 8);
453 }
454
455 #[test]
456 fn app_json_layout_envs_reach_every_view() {
457 let compact = |name: &str| (name == "TOY_JSON").then(|| "compact".to_owned());
458 let view = App::<Cli>::new("toy")
459 .json_layout_envs(&["TOY_JSON"])
460 .configured_view(View::new(OutputFormat::Json, ColorMode::Never));
461 assert_eq!(view.layout_with(compact), JsonLayout::Compact);
462 let disabled = App::<Cli>::new("toy")
463 .json_layout_envs(&[])
464 .configured_view(View::new(OutputFormat::Json, ColorMode::Never));
465 assert_eq!(disabled.layout_with(compact), JsonLayout::Pretty);
466 }
467
468 #[test]
469 fn app_styles_and_fallback_reach_every_view() {
470 let app = App::<Cli>::new("toy")
471 .fallback_width(None)
472 .styles(RenderOptions::new(ColorMode::Never).record_style(RecordStyle::Boxed))
473 .json_layout(JsonLayout::Compact);
474 let options = app
475 .configured_view(View::new(OutputFormat::Pretty, ColorMode::Never))
476 .render_options();
477 assert_eq!(options.fallback(), None);
478 assert_eq!(options.record(), RecordStyle::Boxed);
479 let json = app
480 .configured_view(View::new(OutputFormat::Json, ColorMode::Never))
481 .capture(&Status { pending: 1 })
482 .unwrap();
483 assert_eq!(json.text(), "{\"pending\":1}\n");
484 }
485
486 #[test]
487 fn defaults_are_pretty_auto() {
488 let app = App::<Cli>::new("toy").view(|_| {
489 View::new(OutputFormat::Pretty, ColorMode::Auto)
490 .quiet(true)
491 .width(80)
492 });
493 let code = app.run_from(["toy", "status"], |_| Ok(Status { pending: 0 }));
494 assert_eq!(code, std::process::ExitCode::SUCCESS);
495 }
496
497 #[test]
498 fn clap_help_kinds_stay_on_claps_display_path() {
499 use clap::error::ErrorKind;
500
501 assert!(is_clap_display(ErrorKind::DisplayVersion));
502 assert!(is_clap_display(ErrorKind::DisplayHelp));
503 assert!(is_clap_display(
504 ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
505 ));
506 assert!(!is_clap_display(ErrorKind::UnknownArgument));
507 }
508
509 #[test]
510 fn missing_nested_subcommand_uses_claps_help_exit() {
511 let code =
512 App::<NestedCli>::new("toy").run_from(["toy", "group"], |_| Ok(Status { pending: 0 }));
513 assert_eq!(code, std::process::ExitCode::from(2));
514 }
515
516 #[test]
517 fn bare_invocation_is_usage_error() {
518 let code = App::<Cli>::new("toy").run_from(["toy"], |_| Ok(Status { pending: 0 }));
519 assert_eq!(code, std::process::ExitCode::from(2));
520 }
521
522 #[test]
523 fn bare_invocation_executes_when_the_root_accepts_it() {
524 let ran = Rc::new(Cell::new(false));
525 let observed = Rc::clone(&ran);
526 let code = App::<OptionalCli>::new("toy").run_from(["toy"], move |_| {
527 observed.set(true);
528 Ok(Status { pending: 0 })
529 });
530 assert_eq!(code, std::process::ExitCode::SUCCESS);
531 assert!(ran.get());
532 }
533
534 #[test]
535 fn explicit_help_used_as_a_domain_value_reaches_execution() {
536 let ran = Rc::new(Cell::new(false));
537 let observed = Rc::clone(&ran);
538 let code = App::<Cli>::new("toy").run_from(["toy", "status", "-m", "--help"], move |_| {
539 observed.set(true);
540 Ok(Status { pending: 0 })
541 });
542 assert_eq!(code, std::process::ExitCode::SUCCESS);
543 assert!(ran.get());
544 }
545
546 #[test]
547 fn raw_view_uses_clap_to_separate_globals_from_domain_values() {
548 let domain_value = ["toy", "status", "-m", "--format=json", "--bogus"].map(OsString::from);
549 let view = raw_view::<Cli>(&domain_value);
550 assert_eq!(view.format, OutputFormat::Pretty);
551 assert_eq!(view.color, ColorMode::Auto);
552
553 let global_after_subcommand =
554 ["toy", "status", "-fjson", "-cnever", "--bogus"].map(OsString::from);
555 let view = raw_view::<Cli>(&global_after_subcommand);
556 assert_eq!(view.format, OutputFormat::Json);
557 assert_eq!(view.color, ColorMode::Never);
558 }
559}