cabin/lib.rs
1//! Library half of the `cabin` CLI binary.
2//!
3//! The bin (`src/main.rs`) is intentionally a thin shim that
4//! calls [`run`]; the typed parser ([`Cli`]), the command
5//! dispatcher, and the per-command glue modules under `cli/`
6//! live here so integration tests can re-use the same surface
7//! the binary does — `Cli::command()` is the single source of
8//! truth for which subcommands exist and which are hidden.
9
10#![allow(
11 clippy::struct_excessive_bools,
12 clippy::too_many_lines,
13 clippy::unused_self,
14 // Remaining hits are `root_settings: Default::default()` in test
15 // graph fixtures, where the typed-default suggestion is MaybeIncorrect.
16 clippy::default_trait_access
17)]
18
19use std::process::ExitCode;
20
21use clap::FromArgMatches;
22
23pub use crate::cli::Cli;
24use crate::term_setup::{EarlyTerminalState, resolve_early_terminal_state};
25
26// The clap parser stays reachable for this crate's glue modules,
27// but `Cli` is re-exported at the crate root for integration
28// tests and downstream command-tree generation.
29mod cli;
30mod command_list;
31mod completions;
32mod diagnostic_registry;
33mod error_rendering;
34mod help_rendering;
35mod manpages;
36mod port_subcommand;
37mod stamp;
38mod term_setup;
39mod version_info;
40
41/// Return the English plural suffix for a count: empty for one,
42/// `s` for everything else. Used across the reporter glue files
43/// to keep `"<n> file"` / `"<n> files"` consistent.
44pub(crate) fn plural(n: usize) -> &'static str {
45 if n == 1 { "" } else { "s" }
46}
47
48/// Serialize `value` as pretty-printed JSON and write it to stdout
49/// followed by a newline. `error_context` is attached to any
50/// serialization failure, matching `anyhow::Context::context`.
51pub(crate) fn print_pretty_json<T>(value: &T, error_context: &'static str) -> anyhow::Result<()>
52where
53 T: serde::Serialize + ?Sized,
54{
55 use anyhow::Context;
56 let body = serde_json::to_string_pretty(value).context(error_context)?;
57 println!("{body}");
58 Ok(())
59}
60
61/// One top-level subcommand row: the canonical name first,
62/// followed by each visible alias, paired with the short about
63/// text. Shared by the `--list` renderer ([`crate::command_list`])
64/// and the `--help` Commands block ([`crate::help_rendering`]);
65/// each keeps its own render loop (color sink vs. ANSI string,
66/// sorted vs. declaration order, all subcommands vs. visible-only),
67/// but the row extraction and column-width computation are
68/// identical, so they live here.
69#[derive(Debug, Clone)]
70pub(crate) struct SubcommandRow {
71 /// The canonical name first, followed by each visible alias.
72 /// Rendered joined by `, ` to match cargo's `cargo --list` style.
73 pub(crate) tokens: Vec<String>,
74 pub(crate) about: String,
75}
76
77/// Extract the [`SubcommandRow`] for a single clap subcommand: its
78/// name plus any visible aliases, and the first line of its about
79/// block (the short summary clap uses in `--help`).
80pub(crate) fn row_from_subcommand(sub: &clap::Command) -> SubcommandRow {
81 let mut tokens = vec![sub.get_name().to_owned()];
82 for alias in sub.get_visible_aliases() {
83 tokens.push(alias.to_string());
84 }
85 let about = sub
86 .get_about()
87 .map(|s| s.to_string().lines().next().unwrap_or("").trim().to_owned())
88 .unwrap_or_default();
89 SubcommandRow { tokens, about }
90}
91
92/// Display width of the widest row's `<name>[, <alias>…]` cell,
93/// used to align the about column. Computed from the plain-text
94/// `, ` join so embedded ANSI escapes (which do not advance the
95/// cursor) never inflate the width.
96pub(crate) fn rows_display_width(rows: &[SubcommandRow]) -> usize {
97 rows.iter()
98 .map(|row| row.tokens.join(", ").len())
99 .max()
100 .unwrap_or(0)
101}
102
103/// Run the `cabin` CLI to completion using the given argv
104/// iterator. Owns parsing, color/verbosity resolution,
105/// dispatch, and top-level error rendering. The binary
106/// `main` calls this with the process's own arguments.
107pub fn run<I, T>(args: I) -> ExitCode
108where
109 I: IntoIterator<Item = T>,
110 T: Into<std::ffi::OsString> + Clone,
111{
112 // `cabin stamp <FILE> -- <CMD>` is internal build plumbing (the Ninja
113 // syntax-check rule's witness writer); dispatch it before clap so it
114 // never enters the user-facing command surface — no `--help`,
115 // `--list`, completions, or man pages, and no special-case filtering.
116 let arguments: Vec<std::ffi::OsString> = args.into_iter().map(Into::into).collect();
117 if let Some(code) = stamp::dispatch(&arguments) {
118 return code;
119 }
120
121 let cmd = help_rendering::prepare_top_level_command();
122 let matches = match cmd.try_get_matches_from(arguments) {
123 Ok(m) => m,
124 Err(err) => {
125 // `clap::Error` already routes `--help` /
126 // `--version` to stdout and real errors to stderr
127 // with the correct ANSI handling. Just hand it
128 // through.
129 err.exit();
130 }
131 };
132 // `cabin ...` is a help-row affordance that doubles as a
133 // shortcut for `cabin --list`. The unmapped subcommand
134 // produces `cmd: None` after `from_arg_matches`; promote
135 // it to `list = true` so the downstream dispatcher renders
136 // the listing with the same color-aware code path as the
137 // real flag.
138 let dots_shortcut = matches.subcommand_name() == Some(help_rendering::DOTS_HINT);
139 let mut parsed = match Cli::from_arg_matches(&matches) {
140 Ok(cli) => cli,
141 Err(err) => err.exit(),
142 };
143 if dots_shortcut {
144 parsed.list = true;
145 }
146
147 let EarlyTerminalState { color, reporter } =
148 match resolve_early_terminal_state(parsed.color, parsed.verbose, parsed.quiet) {
149 Ok(state) => state,
150 Err(exit_code) => return exit_code,
151 };
152
153 match cli::run(parsed, reporter, color) {
154 Ok(code) => code,
155 Err(error) => {
156 error_rendering::render_error(&error, color);
157 ExitCode::FAILURE
158 }
159 }
160}