Skip to main content

fs_core/
cli.rs

1//! The command-line plumbing every tool in the family shares: dispatch on
2//! `argv[0]`, `--version`, `doctor`, the JSON output and the structured
3//! error, the `--json`/`--text` switch, and the man pages and shell
4//! completions packaging needs. Behind the `cli` cargo feature; the default
5//! build, and the static library, contain none of it.
6//!
7//! IT KNOWS NOTHING ABOUT ANY FORMAT, and must not learn. A crate describes
8//! its tools once, as a [`Family`] — the repository name, the crate and
9//! version, the install hints, and one [`Tool`] per dotted name, each a
10//! clap command and a function that runs it — and hands that to [`main`].
11//! Everything here reads only that description.
12//!
13//! ONE COPY, NOT ONE PER CRATE. This module began as a directory each
14//! tool carried by hand (`src/cli/common/`), and the copies drifted within
15//! weeks: one `doctor` learnt to drain a long `--version` answer while the
16//! others still stalled on it, one entry point learnt to align its help,
17//! and some could write man pages while others could not. Do not copy it
18//! back out; fix it here.
19//!
20//! THE CONTRACT IT CARRIES, so each crate fills it in rather than defining
21//! its own:
22//!
23//! - **One binary, many names.** Invoked as a tool's dotted name
24//!   (`mkfs.<fs>`, `fs.<fs>`, `img.<fmt>`), it is that tool. Invoked under
25//!   any other name — the repository's (`rust-fs-<fs>`), or a renamed copy
26//!   — the first argument names the tool, by its verb (`mkfs`) or its full
27//!   name (`mkfs.<fs>`). That second form is the one nothing on PATH can
28//!   shadow.
29//! - **`--version`** prints `<tool> (<crate>) <version>` for every name,
30//!   which is how `doctor` and the test suites tell our binary from
31//!   another package's program of the same name.
32//! - **Output.** A result is JSON on stdout by default, `--text` for
33//!   people; file content is raw bytes and is never wrapped. A failure is
34//!   `{"error": "...", "code": N}` on STDERR, and `N` is the exit status,
35//!   so stdout carries a result or nothing — never half of one, and never
36//!   an error a pipe would take for data.
37//! - **Exit statuses**, outside a tool that has its own scheme (`fsck`'s
38//!   0/1/4/8): 0 done, 1 failed, 2 the command line was wrong, 3 the verb
39//!   exists but this crate cannot do it (not implemented, or the format is
40//!   read-only). A script moved between formats fails loudly on 3 instead
41//!   of meaning something else.
42//! - **`<repo> doctor`** resolves every dotted name on PATH and says, per
43//!   name, whether the program found is ours, and if not, what wins and
44//!   how to fix it.
45//! - **`<repo> generate names|man|completions`** (hidden) prints the dotted
46//!   names to link, and writes the man pages and shell completions, from
47//!   the same clap commands the tools parse with.
48//!
49//! ```no_run
50//! use fs_core::cli::{self, CliError, Family, Json, Outcome, Tool};
51//! use fs_core::cli::clap::{ArgMatches, Command};
52//!
53//! fn command() -> Command {
54//!     Command::new("fs")
55//!         .about("Work on an image")
56//!         .args(cli::format_args())
57//!         .after_help("Examples:\n  fs.demo disk.img")
58//! }
59//!
60//! fn run(_: &ArgMatches) -> Result<Outcome, CliError> {
61//!     Ok(Outcome::report(Json::object([("fs", Json::from("demo"))])))
62//! }
63//!
64//! static FAMILY: Family = Family {
65//!     repo: "rust-fs-demo",
66//!     crate_name: "am-fs-demo",
67//!     version: "0.1.0",
68//!     about: "Demo tools",
69//!     install_hints: &["`cargo install am-fs-demo --features cli`"],
70//!     tools: &[Tool {
71//!         name: "fs.demo",
72//!         verb: "fs",
73//!         section: 1,
74//!         about: "Work on a demo image",
75//!         usage_exit: cli::output::EXIT_USAGE,
76//!         command,
77//!         run,
78//!     }],
79//! };
80//!
81//! fn main() -> std::process::ExitCode {
82//!     cli::main(&FAMILY)
83//! }
84//! ```
85
86pub mod dispatch;
87pub mod docs;
88pub mod doctor;
89pub mod family;
90pub mod output;
91pub mod version;
92
93// clap's `Command` is imported as `Cmd` throughout: it is not a process,
94// and a reader scanning for `Command` constructors is looking for spawns.
95// The one process this plumbing starts is doctor's `--version` probe
96// (std's `Command`, imported there as `Process`), which runs a program by
97// the name PATH gives it because that is the question doctor answers.
98
99/// The argument parser the tools are written against, re-exported so a
100/// crate's tools and this plumbing can never disagree about its version.
101pub use clap;
102
103pub use family::{Family, Tool};
104pub use output::{CliError, Format, Json, Outcome};
105
106use std::ffi::OsString;
107use std::io::Write as _;
108use std::process::ExitCode;
109
110use clap::{Arg, ArgAction, Command as Cmd};
111
112/// The whole program: work out which tool this is, parse its command
113/// line, run it and print what it returned.
114pub fn main(family: &'static Family) -> ExitCode {
115    run(family, std::env::args_os().collect())
116}
117
118/// [`main`] over an explicit argument vector, `argv[0]` included.
119pub fn run(family: &'static Family, argv: Vec<OsString>) -> ExitCode {
120    respond(family, argv).emit()
121}
122
123/// What the plumbing prints for one invocation, and the exit status, before
124/// any of it is written.
125///
126/// A tool that streams raw bytes writes them itself while it runs; this is
127/// everything else — the report, the structured error, clap's help — which
128/// is what makes the contract testable without spawning a process.
129#[derive(Debug, Default, Clone, PartialEq, Eq)]
130pub struct Response {
131    /// Printed on stdout, as is.
132    pub stdout: String,
133    /// Printed on stderr, as is.
134    pub stderr: String,
135    /// The exit status.
136    pub code: u8,
137}
138
139impl Response {
140    fn out(text: String, code: u8) -> Response {
141        Response {
142            stdout: text,
143            stderr: String::new(),
144            code,
145        }
146    }
147
148    fn err(text: String, code: u8) -> Response {
149        Response {
150            stdout: String::new(),
151            stderr: text,
152            code,
153        }
154    }
155
156    /// Write both streams and turn the status into an [`ExitCode`]. A
157    /// closed pipe (`| head`) is the reader's choice, not a failure.
158    pub fn emit(self) -> ExitCode {
159        if !self.stdout.is_empty() {
160            let mut stdout = std::io::stdout().lock();
161            let _ = stdout.write_all(self.stdout.as_bytes());
162            let _ = stdout.flush();
163        }
164        if !self.stderr.is_empty() {
165            let mut stderr = std::io::stderr().lock();
166            let _ = stderr.write_all(self.stderr.as_bytes());
167            let _ = stderr.flush();
168        }
169        ExitCode::from(self.code)
170    }
171}
172
173/// [`run`] without the writing: resolve, parse, run the tool, and return
174/// what would be printed.
175pub fn respond(family: &'static Family, argv: Vec<OsString>) -> Response {
176    match dispatch::resolve(family, argv) {
177        dispatch::Target::Tool(tool, argv) => respond_tool(family, tool, argv),
178        dispatch::Target::Repo(argv) => respond_repo(family, argv),
179    }
180}
181
182/// A tool's clap command, named and versioned for the name it runs under.
183pub fn tool_command(family: &'static Family, tool: &Tool) -> Cmd {
184    (tool.command)()
185        .name(tool.name)
186        .bin_name(tool.name)
187        .version(version::clap_version(family))
188}
189
190/// The repository-named entry point's clap command: every tool as a
191/// subcommand (so its help and its man page list them), plus `doctor`.
192///
193/// Parsing never reaches a tool's subcommand here: [`dispatch::resolve`]
194/// hands `<repo> <verb> ...` to the tool itself before this runs.
195pub fn repo_command(family: &'static Family) -> Cmd {
196    let mut cmd = Cmd::new(family.repo)
197        .bin_name(family.repo)
198        .version(version::clap_version(family))
199        .about(family.about)
200        .subcommand_required(true)
201        .arg_required_else_help(true)
202        .after_help(repo_examples(family));
203    for tool in family.tools {
204        cmd = cmd.subcommand((tool.command)().name(tool.verb).about(format!(
205            "{} (the same program as `{}`)",
206            tool.about, tool.name
207        )));
208    }
209    cmd.subcommand(doctor::command(family)).subcommand(
210        Cmd::new("generate")
211            .about("Print what packaging needs from the binary itself")
212            .hide(true)
213            .subcommand_required(true)
214            .subcommand(
215                Cmd::new("names").about("The dotted names to link to this binary, one per line"),
216            )
217            .subcommand(
218                Cmd::new("man")
219                    .about("Write a man page per name under SHARE/man/man<section>/")
220                    .arg(share_arg()),
221            )
222            .subcommand(
223                Cmd::new("completions")
224                    .about("Write zsh, bash and fish completions per name under SHARE/")
225                    .arg(share_arg()),
226            ),
227    )
228}
229
230/// `generate`'s SHARE: a path, and so taken as bytes. A path need not be
231/// UTF-8, and a `String` argument would turn one that is not into a usage
232/// error before the filesystem had a say.
233fn share_arg() -> Arg {
234    Arg::new("share")
235        .value_name("SHARE")
236        .value_parser(clap::value_parser!(OsString))
237        .required(true)
238}
239
240/// The entry point's examples, one command per line with its explanation
241/// in a column aligned on the longest command, however long the repository
242/// name and the verbs are.
243fn repo_examples(family: &Family) -> String {
244    let mut rows: Vec<(String, String)> = family
245        .tools
246        .iter()
247        .map(|tool| {
248            (
249                format!("{} {} --help", family.repo, tool.verb),
250                format!("same as `{} --help`", tool.name),
251            )
252        })
253        .collect();
254    rows.push((
255        format!("{} doctor", family.repo),
256        "is every tool on PATH ours?".to_string(),
257    ));
258    let width = rows.iter().map(|(c, _)| c.len()).max().unwrap_or(0);
259    let mut text = String::from("Examples:\n");
260    for (cmd, what) in rows {
261        text.push_str(&format!("  {cmd:<width$}    {what}\n"));
262    }
263    text
264}
265
266fn respond_tool(family: &'static Family, tool: &'static Tool, argv: Vec<OsString>) -> Response {
267    let text_requested = output::text_requested(&argv);
268    let argv_copy = argv.clone();
269    match tool_command(family, tool).try_get_matches_from(argv) {
270        Err(error) => clap_failure(tool.name, error, text_requested, tool.usage_exit),
271        Ok(matches) => {
272            let format = Format::of(&matches, &argv_copy);
273            let result = (tool.run)(&matches);
274            output::render(tool.name, format, result)
275        }
276    }
277}
278
279fn respond_repo(family: &'static Family, argv: Vec<OsString>) -> Response {
280    let text_requested = output::text_requested(&argv);
281    let argv_copy = argv.clone();
282    let matches = match repo_command(family).try_get_matches_from(argv) {
283        Ok(matches) => matches,
284        Err(error) => return clap_failure(family.repo, error, text_requested, output::EXIT_USAGE),
285    };
286    match matches.subcommand() {
287        Some(("doctor", sub)) => {
288            let format = Format::of(sub, &argv_copy);
289            output::render(family.repo, format, Ok(doctor::run(family)))
290        }
291        Some(("generate", sub)) => match sub.subcommand() {
292            Some(("names", _)) => {
293                let names: String = family
294                    .tools
295                    .iter()
296                    .map(|tool| format!("{}\n", tool.name))
297                    .collect();
298                Response::out(names, 0)
299            }
300            Some((what @ ("man" | "completions"), args)) => {
301                let share = std::path::Path::new(
302                    args.get_one::<OsString>("share")
303                        .expect("clap requires the share directory"),
304                );
305                let written = if what == "man" {
306                    docs::man_pages(family, share)
307                } else {
308                    docs::completions(family, share)
309                };
310                let result = written
311                    .map(|paths| {
312                        Outcome::report(Json::Arr(
313                            paths
314                                .iter()
315                                .map(|p| Json::from(p.display().to_string()))
316                                .collect(),
317                        ))
318                    })
319                    .map_err(|e| CliError::failed(format!("generate {what}: {e}")));
320                output::render(family.repo, Format::Json, result)
321            }
322            _ => unreachable!("clap requires a generate subcommand"),
323        },
324        // A tool's verb never reaches here (dispatch took it), and clap
325        // refuses anything else before this point.
326        _ => unreachable!("clap requires a known subcommand"),
327    }
328}
329
330/// Help and version go to stdout with status 0; anything else is a
331/// command line that was wrong, status `usage_exit`, as a structured error
332/// unless `--text` was asked for.
333fn clap_failure(
334    program: &str,
335    error: clap::Error,
336    text_requested: bool,
337    usage_exit: u8,
338) -> Response {
339    use clap::error::ErrorKind;
340    let rendered = error.render().to_string();
341    match error.kind() {
342        ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => Response::out(rendered, 0),
343        // A bare `<repo>`: the help is the answer, but nothing was done.
344        // clap prints this one on stderr.
345        ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => Response::err(rendered, usage_exit),
346        _ if text_requested => Response::err(rendered, usage_exit),
347        _ => {
348            let message = rendered
349                .trim()
350                .strip_prefix("error: ")
351                .unwrap_or(rendered.trim())
352                .to_string();
353            output::render(
354                program,
355                Format::Json,
356                Err(CliError::usage(message).with_code(usage_exit)),
357            )
358        }
359    }
360}
361
362/// `--json` and `--text`, for any command that reports. The last one
363/// given wins, so an alias or a wrapper can add either without breaking a
364/// command line that already has the other.
365pub fn format_args() -> [Arg; 2] {
366    [
367        Arg::new("json")
368            .long("json")
369            .help("Report as JSON on stdout (the default)")
370            .action(ArgAction::SetTrue)
371            .overrides_with("text"),
372        Arg::new("text")
373            .long("text")
374            .help("Report as text for a person, instead of JSON")
375            .action(ArgAction::SetTrue)
376            .overrides_with("json"),
377    ]
378}