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;
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
17/// One ctl process: short-circuits, help, parsing, execution, and presentation.
18pub 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    /// Build a CLI with pretty automatic-color output.
29    #[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    /// Select output, color, and quiet policy from the parsed CLI.
42    #[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    /// Add an ordered pre-parse short-circuit, such as dynamic completion.
49    #[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    /// Expose a mise Usage spec under a mounted task name.
59    #[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    /// Run against process argv.
72    #[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    /// Run against explicit argv. The first item is the binary name.
81    #[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        /// Show status.
198        Status(StatusArgs),
199    }
200
201    #[derive(clap::Args)]
202    struct StatusArgs {
203        /// Domain text that may begin with a hyphen.
204        #[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 commands.
222        Group {
223            #[command(subcommand)]
224            command: GroupCommand,
225        },
226    }
227
228    #[derive(Subcommand)]
229    enum GroupCommand {
230        /// Show status.
231        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}