Skip to main content

ctl_core/
app.rs

1//! Fluent typed CLI lifecycle.
2
3use std::ffi::OsString;
4use std::fmt;
5use std::marker::PhantomData;
6use std::process::ExitCode;
7
8use anyhow::Result;
9#[cfg(feature = "usage")]
10use clap::Command;
11use clap::{CommandFactory, Parser};
12
13use crate::color::ColorMode;
14use crate::format::OutputFormat;
15use crate::render::RenderOptions;
16use crate::view::{JsonLayout, Present, View};
17
18type BeforeParse = Box<dyn Fn(&[OsString]) -> Option<ExitCode>>;
19type SelectView<C> = Box<dyn Fn(&C) -> View>;
20#[cfg(feature = "usage")]
21type RenderUsage = Box<dyn Fn(Command, &str) -> String>;
22
23/// A fallback width the owner chose, including `None` to disable it.
24#[derive(Clone, Copy)]
25struct Fallback(Option<u16>);
26
27/// One ctl process: short-circuits, help, parsing, execution, and presentation.
28pub struct App<C> {
29    bin: String,
30    before_parse: Vec<BeforeParse>,
31    select_view: SelectView<C>,
32    automatic_width_buffer: Option<u16>,
33    automatic_width_buffer_envs: Option<&'static [&'static str]>,
34    minimum_automatic_width: Option<u16>,
35    fallback_width: Option<Fallback>,
36    styles: Option<RenderOptions>,
37    json_layout: Option<JsonLayout>,
38    json_layout_envs: Option<&'static [&'static str]>,
39    #[cfg(feature = "usage")]
40    mounted_as: Option<String>,
41    #[cfg(feature = "usage")]
42    render_usage: RenderUsage,
43    marker: PhantomData<C>,
44}
45
46/// Names the binary only: the hooks are closures, which have no `Debug`.
47impl<C> fmt::Debug for App<C> {
48    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49        formatter
50            .debug_struct("App")
51            .field("bin", &self.bin)
52            .finish_non_exhaustive()
53    }
54}
55
56impl<C> App<C> {
57    /// Build a CLI with pretty automatic-color output.
58    #[must_use]
59    pub fn new(bin: impl Into<String>) -> Self {
60        Self {
61            bin: bin.into(),
62            before_parse: Vec::new(),
63            select_view: Box::new(|_| View::new(OutputFormat::Pretty, ColorMode::Auto)),
64            automatic_width_buffer: None,
65            automatic_width_buffer_envs: None,
66            minimum_automatic_width: None,
67            fallback_width: None,
68            styles: None,
69            json_layout: None,
70            json_layout_envs: None,
71            #[cfg(feature = "usage")]
72            mounted_as: None,
73            #[cfg(feature = "usage")]
74            render_usage: Box::new(crate::usage::spec),
75            marker: PhantomData,
76        }
77    }
78
79    /// Select output, color, and quiet policy from the parsed CLI.
80    #[must_use]
81    pub fn view(mut self, select: impl Fn(&C) -> View + 'static) -> Self {
82        self.select_view = Box::new(select);
83        self
84    }
85
86    /// Override the automatic-width buffer for help, errors, and command
87    /// output. Zero disables buffering; explicit view widths remain exact.
88    #[must_use]
89    pub fn automatic_width_buffer(mut self, columns: u16) -> Self {
90        self.automatic_width_buffer = Some(columns);
91        self
92    }
93
94    /// Replace the ordered environment names for every automatic-width render.
95    /// An empty slice disables environment lookup.
96    #[must_use]
97    pub fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
98        self.automatic_width_buffer_envs = Some(names);
99        self
100    }
101
102    /// Set the floor for automatically detected effective widths.
103    #[must_use]
104    pub fn minimum_automatic_width(mut self, columns: u16) -> Self {
105        self.minimum_automatic_width = Some(columns);
106        self
107    }
108
109    /// Lay out to `width` when no width is detected; `None` disables it.
110    #[must_use]
111    pub fn fallback_width(mut self, width: Option<u16>) -> Self {
112        self.fallback_width = Some(Fallback(width));
113        self
114    }
115
116    /// Set the process-wide input limits when called. A repository's
117    /// `.ctl/input.yaml` and a single call's limits both beat them.
118    #[cfg(feature = "input")]
119    #[must_use]
120    pub fn input_limits(self, limits: crate::input::Limits) -> Self {
121        crate::input::set_default_limits(limits);
122        self
123    }
124
125    /// Take record style, list style, and row separation from `styles` for
126    /// help, errors, and command output.
127    #[must_use]
128    pub fn styles(mut self, styles: RenderOptions) -> Self {
129        self.styles = Some(styles);
130        self
131    }
132
133    /// Lay out JSON output as `layout`.
134    #[must_use]
135    pub fn json_layout(mut self, layout: JsonLayout) -> Self {
136        self.json_layout = Some(layout);
137        self
138    }
139
140    /// Replace the ordered environment names read for the JSON layout.
141    /// An empty slice disables lookup.
142    #[must_use]
143    pub fn json_layout_envs(mut self, names: &'static [&'static str]) -> Self {
144        self.json_layout_envs = Some(names);
145        self
146    }
147
148    fn configured_view(&self, mut view: View) -> View {
149        if let Some(buffer) = self.automatic_width_buffer {
150            view = view.automatic_width_buffer(buffer);
151        }
152        if let Some(names) = self.automatic_width_buffer_envs {
153            view = view.automatic_width_buffer_envs(names);
154        }
155        if let Some(minimum) = self.minimum_automatic_width {
156            view = view.minimum_automatic_width(minimum);
157        }
158        if let Some(Fallback(width)) = self.fallback_width {
159            view = view.fallback_width(width);
160        }
161        if let Some(styles) = self.styles {
162            view = view.styles(styles);
163        }
164        if let Some(layout) = self.json_layout {
165            view = view.json_layout(layout);
166        }
167        if let Some(names) = self.json_layout_envs {
168            view = view.json_layout_envs(names);
169        }
170        view
171    }
172
173    /// Add an ordered pre-parse short-circuit, such as dynamic completion.
174    #[must_use]
175    pub fn before_parse(
176        mut self,
177        hook: impl Fn(&[OsString]) -> Option<ExitCode> + 'static,
178    ) -> Self {
179        self.before_parse.push(Box::new(hook));
180        self
181    }
182
183    /// Expose a mise Usage spec under a mounted task name.
184    #[cfg(feature = "usage")]
185    #[must_use]
186    pub fn mounted_as(mut self, task: impl Into<String>) -> Self {
187        self.mounted_as = Some(task.into());
188        self
189    }
190
191    /// Customize the mounted Usage document while keeping App's short-circuit
192    /// and stream ownership. The callback receives the declared Clap graph and
193    /// the requested mounted binary name.
194    #[cfg(feature = "usage")]
195    #[must_use]
196    pub fn usage_spec(mut self, render: impl Fn(Command, &str) -> String + 'static) -> Self {
197        self.render_usage = Box::new(render);
198        self
199    }
200}
201
202impl<C> App<C>
203where
204    C: Parser + CommandFactory,
205{
206    /// Run against process argv.
207    #[must_use = "return it from main, or the command exits 0"]
208    pub fn run<T>(self, execute: impl FnOnce(C) -> Result<T>) -> ExitCode
209    where
210        T: Present,
211    {
212        self.run_from(std::env::args_os(), execute)
213    }
214
215    /// Run against explicit argv. The first item is the binary name.
216    #[must_use = "return it from main, or the command exits 0"]
217    pub fn run_from<T>(
218        self,
219        args: impl IntoIterator<Item = impl Into<OsString>>,
220        execute: impl FnOnce(C) -> Result<T>,
221    ) -> ExitCode
222    where
223        T: Present,
224    {
225        let raw = args.into_iter().map(Into::into).collect::<Vec<_>>();
226        let words = raw
227            .iter()
228            .map(|arg| arg.to_string_lossy().into_owned())
229            .collect::<Vec<_>>();
230
231        #[cfg(feature = "usage")]
232        if let Some(task) = &self.mounted_as
233            && let Some(spec_bin) = crate::usage::spec_bin(words.iter().skip(1), task)
234        {
235            let mut command = C::command();
236            command.set_bin_name(&spec_bin);
237            let spec = (self.render_usage)(command, &spec_bin);
238            return crate::view::write_stdout(spec.as_bytes(), ColorMode::Never)
239                .map_or(ExitCode::FAILURE, |()| ExitCode::SUCCESS);
240        }
241
242        for hook in &self.before_parse {
243            if let Some(code) = hook(&raw) {
244                return code;
245            }
246        }
247
248        let raw_view = self.configured_view(raw_view::<C>(&raw));
249        for warning in raw_view.env_warnings(crate::render::process_env) {
250            eprintln!("{}: warning: {warning}", self.bin);
251        }
252        if words.len() == 1 && crate::parser::requires_input::<C>() {
253            return crate::help::emit_bare_with_options::<C>(raw_view.render_options())
254                .map_or(ExitCode::FAILURE, |()| ExitCode::from(2));
255        }
256        match crate::help::try_emit_from_with_options::<C>(&words, raw_view.render_options()) {
257            Ok(true) => return ExitCode::SUCCESS,
258            Ok(false) => {}
259            Err(_) => return ExitCode::FAILURE,
260        }
261
262        let mut command = crate::parser::apply_defaults(C::command());
263        if raw_view.format.is_json() {
264            command = command.color(clap::ColorChoice::Never);
265        }
266        let matches = match command.try_get_matches_from(&raw) {
267            Ok(matches) => matches,
268            Err(error) => return self.clap_error(&error, parse_error_view(&raw, raw_view)),
269        };
270        let cli = match C::from_arg_matches(&matches) {
271            Ok(cli) => cli,
272            Err(error) => return self.clap_error(&error, parse_error_view(&raw, raw_view)),
273        };
274        let view = self.configured_view((self.select_view)(&cli));
275        match execute(cli) {
276            Ok(value) => view.show(&value).unwrap_or(ExitCode::FAILURE),
277            Err(error) => view
278                .emit_err(&self.bin, &format!("{error:#}"))
279                .unwrap_or(ExitCode::FAILURE),
280        }
281    }
282
283    fn clap_error(&self, error: &clap::Error, view: View) -> ExitCode {
284        let code = exit_code(error.exit_code());
285        if is_clap_display(error.kind()) {
286            let _ = error.print();
287            return code;
288        }
289        view.emit_err(&self.bin, error.to_string().trim())
290            .map_or(ExitCode::FAILURE, |_| code)
291    }
292}
293
294fn raw_view<C: CommandFactory>(raw: &[OsString]) -> View {
295    let parsed = crate::parser::parsed_output::<C>(raw);
296    View::new(parsed.format, parsed.color)
297}
298
299/// The view for a parse error. Clap's lenient read stops at the bad argument,
300/// so a `--format json` after it goes unseen, and the error would print as
301/// text, coloured when `CLICOLOR_FORCE` or `FORCE_COLOR` is set, to a caller
302/// that asked for JSON and pipes it to `jq`. A long `--format json` or
303/// `--format=json` before `--` makes the error JSON, which never carries
304/// ANSI. The last `--format` value Clap accepts wins in either direction, as
305/// it does in a full parse, so a later `--format pretty` undoes an earlier
306/// JSON choice. The short `-f`
307/// is left out: a consumer may bind it to another flag. The scan is lexical,
308/// so a domain option whose value happens to be `--format=json` also gets a
309/// JSON error; that only changes how a failed parse is reported.
310fn parse_error_view(raw: &[OsString], mut view: View) -> View {
311    let mut args = raw.iter().skip(1).map(|arg| arg.to_str());
312    let mut last = None;
313    while let Some(arg) = args.next() {
314        let value = match arg {
315            Some("--") => break,
316            Some("--format") => args.next().flatten(),
317            Some(arg) => arg.strip_prefix("--format="),
318            None => None,
319        };
320        if let Some(format) =
321            value.and_then(|value| <OutputFormat as clap::ValueEnum>::from_str(value, false).ok())
322        {
323            last = Some(format);
324        }
325    }
326    if let Some(format) = last {
327        view.format = format;
328        if format.is_json() {
329            view.color = ColorMode::Never;
330        }
331    }
332    view
333}
334
335fn is_clap_display(kind: clap::error::ErrorKind) -> bool {
336    matches!(
337        kind,
338        clap::error::ErrorKind::DisplayVersion
339            | clap::error::ErrorKind::DisplayHelp
340            | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
341    )
342}
343
344fn exit_code(code: i32) -> ExitCode {
345    u8::try_from(code).map_or(ExitCode::FAILURE, ExitCode::from)
346}
347
348#[cfg(test)]
349mod tests {
350    use std::cell::Cell;
351    use std::ffi::OsString;
352    use std::rc::Rc;
353
354    use clap::{Parser, Subcommand};
355    use serde::Serialize;
356
357    use super::{App, is_clap_display, parse_error_view, raw_view};
358    use crate::document::{Document, Fields};
359    use crate::render::{RecordStyle, RenderOptions};
360    use crate::view::{JsonLayout, Present, View};
361    use crate::{ColorMode, OutputArgs, OutputFormat};
362
363    #[derive(Parser)]
364    #[command(version, about = "toy")]
365    struct Cli {
366        #[command(flatten)]
367        output: OutputArgs,
368        #[command(subcommand)]
369        command: Command,
370    }
371
372    #[derive(Subcommand)]
373    enum Command {
374        /// Show status.
375        Status(StatusArgs),
376    }
377
378    #[derive(clap::Args)]
379    struct StatusArgs {
380        /// Domain text that may begin with a hyphen.
381        #[arg(short = 'm', long, allow_hyphen_values = true)]
382        message: Option<String>,
383    }
384
385    #[derive(Parser)]
386    #[command(version, about = "optional toy")]
387    struct OptionalCli {}
388
389    #[derive(Parser)]
390    #[command(version, about = "nested toy")]
391    struct NestedCli {
392        #[command(subcommand)]
393        command: NestedCommand,
394    }
395
396    #[derive(Subcommand)]
397    enum NestedCommand {
398        /// Group commands.
399        Group {
400            #[command(subcommand)]
401            command: GroupCommand,
402        },
403    }
404
405    #[derive(Subcommand)]
406    enum GroupCommand {
407        /// Show status.
408        Status,
409    }
410
411    #[derive(Serialize)]
412    struct Status {
413        pending: usize,
414    }
415
416    impl Present for Status {
417        fn present(&self) -> Document {
418            Document::new().fields(Fields::new().row("pending", self.pending.to_string()))
419        }
420    }
421
422    #[test]
423    fn parses_executes_and_suppresses_quiet_pretty() {
424        let ran = Rc::new(Cell::new(false));
425        let observed = Rc::clone(&ran);
426        let code = App::<Cli>::new("toy")
427            .view(|cli| {
428                View::new(cli.output.format, cli.output.color())
429                    .quiet(cli.output.quiet)
430                    .width(80)
431            })
432            .run_from(["toy", "status", "--quiet"], move |_| {
433                observed.set(true);
434                Ok(Status { pending: 0 })
435            });
436        assert_eq!(code, std::process::ExitCode::SUCCESS);
437        assert!(ran.get());
438    }
439
440    /// A bad argument before `--format json` still gets a JSON error, so
441    /// machine output stays plain whatever the colour environment says.
442    #[test]
443    fn a_parse_error_honours_a_later_json_format() {
444        let pretty = View::new(OutputFormat::Pretty, ColorMode::Always);
445        let cases = [
446            (&["toy", "status", "--bogus", "--format", "json"][..], true),
447            (&["toy", "status", "--bogus", "--format=json"][..], true),
448            (&["toy", "status", "--bogus"][..], false),
449            (
450                &["toy", "status", "--bogus", "--", "--format", "json"][..],
451                false,
452            ),
453            (
454                &["toy", "status", "--bogus", "--format", "pretty"][..],
455                false,
456            ),
457            (
458                &["toy", "--bogus", "--format", "json", "--format", "pretty"][..],
459                false,
460            ),
461            (
462                &["toy", "--bogus", "--format=pretty", "--format", "json"][..],
463                true,
464            ),
465        ];
466        for (argv, json) in cases {
467            let raw: Vec<OsString> = argv.iter().map(OsString::from).collect();
468            let view = parse_error_view(&raw, pretty);
469            assert_eq!(view.format.is_json(), json, "{argv:?}");
470            if json {
471                assert_eq!(view.color, ColorMode::Never, "{argv:?}");
472            }
473        }
474        let json_first = View::new(OutputFormat::Json, ColorMode::Never);
475        let raw: Vec<OsString> = ["toy", "--format", "json", "--bogus", "--format", "pretty"]
476            .iter()
477            .map(OsString::from)
478            .collect();
479        assert!(
480            !parse_error_view(&raw, json_first).format.is_json(),
481            "a later --format pretty undoes the pre-parse's JSON"
482        );
483    }
484
485    #[test]
486    fn pre_parse_hook_runs_before_clap() {
487        let code = App::<Cli>::new("toy")
488            .before_parse(|args| {
489                args.iter()
490                    .any(|arg| arg == "--complete")
491                    .then_some(std::process::ExitCode::SUCCESS)
492            })
493            .run_from(["toy", "--complete"], |_| Ok(Status { pending: 0 }));
494        assert_eq!(code, std::process::ExitCode::SUCCESS);
495    }
496
497    #[cfg(feature = "usage")]
498    #[test]
499    fn mounted_usage_accepts_a_custom_renderer() {
500        use std::cell::RefCell;
501
502        let observed = Rc::new(RefCell::new(None));
503        let callback_observed = Rc::clone(&observed);
504        let code = App::<Cli>::new("toy")
505            .mounted_as("q")
506            .usage_spec(move |command, bin| {
507                *callback_observed.borrow_mut() =
508                    Some((bin.to_owned(), command.get_bin_name().map(str::to_owned)));
509                format!("custom {bin}\n")
510            })
511            .run_from(["toy", "--usage-spec=mounted"], |_| {
512                Ok(Status { pending: 0 })
513            });
514        assert_eq!(code, std::process::ExitCode::SUCCESS);
515        assert_eq!(
516            observed.borrow().as_ref(),
517            Some(&("mounted".to_owned(), Some("mounted".to_owned())))
518        );
519    }
520
521    #[test]
522    fn app_width_policy_configures_every_view() {
523        let app = App::<Cli>::new("toy")
524            .automatic_width_buffer(0)
525            .automatic_width_buffer_envs(&["TOY_BUFFER", "LEGACY_BUFFER"])
526            .minimum_automatic_width(8);
527        let view = app.configured_view(View::new(OutputFormat::Pretty, ColorMode::Never));
528        assert_eq!(view.explicit_automatic_width_buffer(), Some(0));
529        assert_eq!(
530            view.automatic_width_buffer_env_names(),
531            ["TOY_BUFFER", "LEGACY_BUFFER"]
532        );
533        assert_eq!(view.automatic_width_minimum(), 8);
534    }
535
536    #[test]
537    fn app_json_layout_envs_reach_every_view() {
538        let compact = |name: &str| (name == "TOY_JSON").then(|| "compact".to_owned());
539        let view = App::<Cli>::new("toy")
540            .json_layout_envs(&["TOY_JSON"])
541            .configured_view(View::new(OutputFormat::Json, ColorMode::Never));
542        assert_eq!(view.layout_with(compact), JsonLayout::Compact);
543        let disabled = App::<Cli>::new("toy")
544            .json_layout_envs(&[])
545            .configured_view(View::new(OutputFormat::Json, ColorMode::Never));
546        assert_eq!(disabled.layout_with(compact), JsonLayout::Pretty);
547    }
548
549    #[test]
550    fn app_styles_and_fallback_reach_every_view() {
551        let app = App::<Cli>::new("toy")
552            .fallback_width(None)
553            .styles(RenderOptions::new(ColorMode::Never).record_style(RecordStyle::Boxed))
554            .json_layout(JsonLayout::Compact);
555        let options = app
556            .configured_view(View::new(OutputFormat::Pretty, ColorMode::Never))
557            .render_options();
558        assert_eq!(options.fallback(), None);
559        assert_eq!(options.record(), RecordStyle::Boxed);
560        let json = app
561            .configured_view(View::new(OutputFormat::Json, ColorMode::Never))
562            .capture(&Status { pending: 1 })
563            .unwrap();
564        assert_eq!(json.text(), "{\"pending\":1}\n");
565    }
566
567    #[test]
568    fn defaults_are_pretty_auto() {
569        let app = App::<Cli>::new("toy").view(|_| {
570            View::new(OutputFormat::Pretty, ColorMode::Auto)
571                .quiet(true)
572                .width(80)
573        });
574        let code = app.run_from(["toy", "status"], |_| Ok(Status { pending: 0 }));
575        assert_eq!(code, std::process::ExitCode::SUCCESS);
576    }
577
578    #[test]
579    fn clap_help_kinds_stay_on_claps_display_path() {
580        use clap::error::ErrorKind;
581
582        assert!(is_clap_display(ErrorKind::DisplayVersion));
583        assert!(is_clap_display(ErrorKind::DisplayHelp));
584        assert!(is_clap_display(
585            ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
586        ));
587        assert!(!is_clap_display(ErrorKind::UnknownArgument));
588    }
589
590    #[test]
591    fn missing_nested_subcommand_uses_claps_help_exit() {
592        let code =
593            App::<NestedCli>::new("toy").run_from(["toy", "group"], |_| Ok(Status { pending: 0 }));
594        assert_eq!(code, std::process::ExitCode::from(2));
595    }
596
597    #[test]
598    fn bare_invocation_is_usage_error() {
599        let code = App::<Cli>::new("toy").run_from(["toy"], |_| Ok(Status { pending: 0 }));
600        assert_eq!(code, std::process::ExitCode::from(2));
601    }
602
603    #[test]
604    fn bare_invocation_executes_when_the_root_accepts_it() {
605        let ran = Rc::new(Cell::new(false));
606        let observed = Rc::clone(&ran);
607        let code = App::<OptionalCli>::new("toy").run_from(["toy"], move |_| {
608            observed.set(true);
609            Ok(Status { pending: 0 })
610        });
611        assert_eq!(code, std::process::ExitCode::SUCCESS);
612        assert!(ran.get());
613    }
614
615    #[test]
616    fn explicit_help_used_as_a_domain_value_reaches_execution() {
617        let ran = Rc::new(Cell::new(false));
618        let observed = Rc::clone(&ran);
619        let code = App::<Cli>::new("toy").run_from(["toy", "status", "-m", "--help"], move |_| {
620            observed.set(true);
621            Ok(Status { pending: 0 })
622        });
623        assert_eq!(code, std::process::ExitCode::SUCCESS);
624        assert!(ran.get());
625    }
626
627    #[test]
628    fn raw_view_uses_clap_to_separate_globals_from_domain_values() {
629        let domain_value = ["toy", "status", "-m", "--format=json", "--bogus"].map(OsString::from);
630        let view = raw_view::<Cli>(&domain_value);
631        assert_eq!(view.format, OutputFormat::Pretty);
632        assert_eq!(view.color, ColorMode::Auto);
633
634        let global_after_subcommand =
635            ["toy", "status", "-fjson", "-cnever", "--bogus"].map(OsString::from);
636        let view = raw_view::<Cli>(&global_after_subcommand);
637        assert_eq!(view.format, OutputFormat::Json);
638        assert_eq!(view.color, ColorMode::Never);
639    }
640}