Skip to main content

ctl_core/
app.rs

1//! Fluent typed CLI lifecycle.
2
3use 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
21/// One ctl process: short-circuits, help, parsing, execution, and presentation.
22pub 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    /// Build a CLI with pretty automatic-color output.
35    #[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    /// Select output, color, and quiet policy from the parsed CLI.
50    #[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    /// Add an ordered pre-parse short-circuit, such as dynamic completion.
57    #[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    /// Expose a mise Usage spec under a mounted task name.
67    #[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    /// Customize the mounted Usage document while keeping App's short-circuit
75    /// and stream ownership. The callback receives the declared Clap graph and
76    /// the requested mounted binary name.
77    #[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    /// Run against process argv.
90    #[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    /// Run against explicit argv. The first item is the binary name.
99    #[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        /// Show status.
218        Status(StatusArgs),
219    }
220
221    #[derive(clap::Args)]
222    struct StatusArgs {
223        /// Domain text that may begin with a hyphen.
224        #[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 commands.
242        Group {
243            #[command(subcommand)]
244            command: GroupCommand,
245        },
246    }
247
248    #[derive(Subcommand)]
249    enum GroupCommand {
250        /// Show status.
251        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}