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