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