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}