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