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