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