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