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    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    /// Build a CLI with pretty automatic-color output.
38    #[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    /// Select output, color, and quiet policy from the parsed CLI.
56    #[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    /// Override the automatic-width buffer for help, errors, and command
63    /// output. Zero disables buffering; explicit view widths remain exact.
64    #[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    /// Replace the ordered environment names for every automatic-width render.
71    /// An empty slice disables environment lookup.
72    #[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    /// Set the floor for automatically detected effective widths.
79    #[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    /// Add an ordered pre-parse short-circuit, such as dynamic completion.
99    #[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    /// Expose a mise Usage spec under a mounted task name.
109    #[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    /// Customize the mounted Usage document while keeping App's short-circuit
117    /// and stream ownership. The callback receives the declared Clap graph and
118    /// the requested mounted binary name.
119    #[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    /// Run against process argv.
132    #[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    /// Run against explicit argv. The first item is the binary name.
141    #[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        /// Show status.
260        Status(StatusArgs),
261    }
262
263    #[derive(clap::Args)]
264    struct StatusArgs {
265        /// Domain text that may begin with a hyphen.
266        #[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 commands.
284        Group {
285            #[command(subcommand)]
286            command: GroupCommand,
287        },
288    }
289
290    #[derive(Subcommand)]
291    enum GroupCommand {
292        /// Show status.
293        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}