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, raw_view),
269        };
270        let cli = match C::from_arg_matches(&matches) {
271            Ok(cli) => cli,
272            Err(error) => return self.clap_error(&error, 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
299fn is_clap_display(kind: clap::error::ErrorKind) -> bool {
300    matches!(
301        kind,
302        clap::error::ErrorKind::DisplayVersion
303            | clap::error::ErrorKind::DisplayHelp
304            | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
305    )
306}
307
308fn exit_code(code: i32) -> ExitCode {
309    u8::try_from(code).map_or(ExitCode::FAILURE, ExitCode::from)
310}
311
312#[cfg(test)]
313mod tests {
314    use std::cell::Cell;
315    use std::ffi::OsString;
316    use std::rc::Rc;
317
318    use clap::{Parser, Subcommand};
319    use serde::Serialize;
320
321    use super::{App, is_clap_display, raw_view};
322    use crate::document::{Document, Fields};
323    use crate::render::{RecordStyle, RenderOptions};
324    use crate::view::{JsonLayout, Present, View};
325    use crate::{ColorMode, OutputArgs, OutputFormat};
326
327    #[derive(Parser)]
328    #[command(version, about = "toy")]
329    struct Cli {
330        #[command(flatten)]
331        output: OutputArgs,
332        #[command(subcommand)]
333        command: Command,
334    }
335
336    #[derive(Subcommand)]
337    enum Command {
338        /// Show status.
339        Status(StatusArgs),
340    }
341
342    #[derive(clap::Args)]
343    struct StatusArgs {
344        /// Domain text that may begin with a hyphen.
345        #[arg(short = 'm', long, allow_hyphen_values = true)]
346        message: Option<String>,
347    }
348
349    #[derive(Parser)]
350    #[command(version, about = "optional toy")]
351    struct OptionalCli {}
352
353    #[derive(Parser)]
354    #[command(version, about = "nested toy")]
355    struct NestedCli {
356        #[command(subcommand)]
357        command: NestedCommand,
358    }
359
360    #[derive(Subcommand)]
361    enum NestedCommand {
362        /// Group commands.
363        Group {
364            #[command(subcommand)]
365            command: GroupCommand,
366        },
367    }
368
369    #[derive(Subcommand)]
370    enum GroupCommand {
371        /// Show status.
372        Status,
373    }
374
375    #[derive(Serialize)]
376    struct Status {
377        pending: usize,
378    }
379
380    impl Present for Status {
381        fn present(&self) -> Document {
382            Document::new().fields(Fields::new().row("pending", self.pending.to_string()))
383        }
384    }
385
386    #[test]
387    fn parses_executes_and_suppresses_quiet_pretty() {
388        let ran = Rc::new(Cell::new(false));
389        let observed = Rc::clone(&ran);
390        let code = App::<Cli>::new("toy")
391            .view(|cli| {
392                View::new(cli.output.format, cli.output.color())
393                    .quiet(cli.output.quiet)
394                    .width(80)
395            })
396            .run_from(["toy", "status", "--quiet"], move |_| {
397                observed.set(true);
398                Ok(Status { pending: 0 })
399            });
400        assert_eq!(code, std::process::ExitCode::SUCCESS);
401        assert!(ran.get());
402    }
403
404    #[test]
405    fn pre_parse_hook_runs_before_clap() {
406        let code = App::<Cli>::new("toy")
407            .before_parse(|args| {
408                args.iter()
409                    .any(|arg| arg == "--complete")
410                    .then_some(std::process::ExitCode::SUCCESS)
411            })
412            .run_from(["toy", "--complete"], |_| Ok(Status { pending: 0 }));
413        assert_eq!(code, std::process::ExitCode::SUCCESS);
414    }
415
416    #[cfg(feature = "usage")]
417    #[test]
418    fn mounted_usage_accepts_a_custom_renderer() {
419        use std::cell::RefCell;
420
421        let observed = Rc::new(RefCell::new(None));
422        let callback_observed = Rc::clone(&observed);
423        let code = App::<Cli>::new("toy")
424            .mounted_as("q")
425            .usage_spec(move |command, bin| {
426                *callback_observed.borrow_mut() =
427                    Some((bin.to_owned(), command.get_bin_name().map(str::to_owned)));
428                format!("custom {bin}\n")
429            })
430            .run_from(["toy", "--usage-spec=mounted"], |_| {
431                Ok(Status { pending: 0 })
432            });
433        assert_eq!(code, std::process::ExitCode::SUCCESS);
434        assert_eq!(
435            observed.borrow().as_ref(),
436            Some(&("mounted".to_owned(), Some("mounted".to_owned())))
437        );
438    }
439
440    #[test]
441    fn app_width_policy_configures_every_view() {
442        let app = App::<Cli>::new("toy")
443            .automatic_width_buffer(0)
444            .automatic_width_buffer_envs(&["TOY_BUFFER", "LEGACY_BUFFER"])
445            .minimum_automatic_width(8);
446        let view = app.configured_view(View::new(OutputFormat::Pretty, ColorMode::Never));
447        assert_eq!(view.explicit_automatic_width_buffer(), Some(0));
448        assert_eq!(
449            view.automatic_width_buffer_env_names(),
450            ["TOY_BUFFER", "LEGACY_BUFFER"]
451        );
452        assert_eq!(view.automatic_width_minimum(), 8);
453    }
454
455    #[test]
456    fn app_json_layout_envs_reach_every_view() {
457        let compact = |name: &str| (name == "TOY_JSON").then(|| "compact".to_owned());
458        let view = App::<Cli>::new("toy")
459            .json_layout_envs(&["TOY_JSON"])
460            .configured_view(View::new(OutputFormat::Json, ColorMode::Never));
461        assert_eq!(view.layout_with(compact), JsonLayout::Compact);
462        let disabled = App::<Cli>::new("toy")
463            .json_layout_envs(&[])
464            .configured_view(View::new(OutputFormat::Json, ColorMode::Never));
465        assert_eq!(disabled.layout_with(compact), JsonLayout::Pretty);
466    }
467
468    #[test]
469    fn app_styles_and_fallback_reach_every_view() {
470        let app = App::<Cli>::new("toy")
471            .fallback_width(None)
472            .styles(RenderOptions::new(ColorMode::Never).record_style(RecordStyle::Boxed))
473            .json_layout(JsonLayout::Compact);
474        let options = app
475            .configured_view(View::new(OutputFormat::Pretty, ColorMode::Never))
476            .render_options();
477        assert_eq!(options.fallback(), None);
478        assert_eq!(options.record(), RecordStyle::Boxed);
479        let json = app
480            .configured_view(View::new(OutputFormat::Json, ColorMode::Never))
481            .capture(&Status { pending: 1 })
482            .unwrap();
483        assert_eq!(json.text(), "{\"pending\":1}\n");
484    }
485
486    #[test]
487    fn defaults_are_pretty_auto() {
488        let app = App::<Cli>::new("toy").view(|_| {
489            View::new(OutputFormat::Pretty, ColorMode::Auto)
490                .quiet(true)
491                .width(80)
492        });
493        let code = app.run_from(["toy", "status"], |_| Ok(Status { pending: 0 }));
494        assert_eq!(code, std::process::ExitCode::SUCCESS);
495    }
496
497    #[test]
498    fn clap_help_kinds_stay_on_claps_display_path() {
499        use clap::error::ErrorKind;
500
501        assert!(is_clap_display(ErrorKind::DisplayVersion));
502        assert!(is_clap_display(ErrorKind::DisplayHelp));
503        assert!(is_clap_display(
504            ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
505        ));
506        assert!(!is_clap_display(ErrorKind::UnknownArgument));
507    }
508
509    #[test]
510    fn missing_nested_subcommand_uses_claps_help_exit() {
511        let code =
512            App::<NestedCli>::new("toy").run_from(["toy", "group"], |_| Ok(Status { pending: 0 }));
513        assert_eq!(code, std::process::ExitCode::from(2));
514    }
515
516    #[test]
517    fn bare_invocation_is_usage_error() {
518        let code = App::<Cli>::new("toy").run_from(["toy"], |_| Ok(Status { pending: 0 }));
519        assert_eq!(code, std::process::ExitCode::from(2));
520    }
521
522    #[test]
523    fn bare_invocation_executes_when_the_root_accepts_it() {
524        let ran = Rc::new(Cell::new(false));
525        let observed = Rc::clone(&ran);
526        let code = App::<OptionalCli>::new("toy").run_from(["toy"], move |_| {
527            observed.set(true);
528            Ok(Status { pending: 0 })
529        });
530        assert_eq!(code, std::process::ExitCode::SUCCESS);
531        assert!(ran.get());
532    }
533
534    #[test]
535    fn explicit_help_used_as_a_domain_value_reaches_execution() {
536        let ran = Rc::new(Cell::new(false));
537        let observed = Rc::clone(&ran);
538        let code = App::<Cli>::new("toy").run_from(["toy", "status", "-m", "--help"], move |_| {
539            observed.set(true);
540            Ok(Status { pending: 0 })
541        });
542        assert_eq!(code, std::process::ExitCode::SUCCESS);
543        assert!(ran.get());
544    }
545
546    #[test]
547    fn raw_view_uses_clap_to_separate_globals_from_domain_values() {
548        let domain_value = ["toy", "status", "-m", "--format=json", "--bogus"].map(OsString::from);
549        let view = raw_view::<Cli>(&domain_value);
550        assert_eq!(view.format, OutputFormat::Pretty);
551        assert_eq!(view.color, ColorMode::Auto);
552
553        let global_after_subcommand =
554            ["toy", "status", "-fjson", "-cnever", "--bogus"].map(OsString::from);
555        let view = raw_view::<Cli>(&global_after_subcommand);
556        assert_eq!(view.format, OutputFormat::Json);
557        assert_eq!(view.color, ColorMode::Never);
558    }
559}