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