diff --git a/Cargo.lock b/Cargo.lock index 04c4502..d85ee49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,7 @@ name = "catena-cli" version = "0.1.0" dependencies = [ "clap", + "clap_mangen", "corpus-core", "serde", ] @@ -295,6 +296,16 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1098,6 +1109,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rrf" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 54023ea..e94470d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ booksniff = { path = "crates/booksniff", version = "0.1.0" } emb = { path = "crates/emb", version = "0.1.0" } clap = { version = "4", features = ["derive"] } +clap_mangen = "0.3" deunicode = "1" fancy-regex = "0.16" # 0.38 is the last release that builds on the pinned toolchain; the ones diff --git a/README.md b/README.md index a13b405..4766287 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ these go into a pipeline without first reading how it works: - The same input gives the same output, on every run and every machine. - `--json` is the stable form, and does not change shape without a major version. +- `--man` writes the program's manual page, generated from the same definition + `--help` is. The whole of it, with the reasoning: [what these programs promise](docs/contract.md). diff --git a/crates/catena-cli/Cargo.toml b/crates/catena-cli/Cargo.toml index ad92357..354e847 100644 --- a/crates/catena-cli/Cargo.toml +++ b/crates/catena-cli/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true [dependencies] clap.workspace = true +clap_mangen.workspace = true corpus-core.workspace = true serde.workspace = true diff --git a/crates/catena-cli/src/lib.rs b/crates/catena-cli/src/lib.rs index 50d8710..0f5e7de 100644 --- a/crates/catena-cli/src/lib.rs +++ b/crates/catena-cli/src/lib.rs @@ -8,6 +8,9 @@ //! - a consumer that closed the pipe is the end of the job, not a failure; //! - a record is flushed as it is written, so a slow stage downstream sees it //! immediately. +//! +//! It also gives every tool `--man`, so that the manual page and the help +//! text can never say different things. use std::io::{BufWriter, Write}; use std::process::ExitCode; @@ -21,13 +24,68 @@ use serde::Serialize; /// wrong with the arguments leaves with 64, which is what the rest of the /// suite means by "the command line was wrong". pub fn parse() -> Result { - T::try_parse().map_err(|e| { + let command = T::command().arg( + clap::Arg::new("man") + .long("man") + .action(clap::ArgAction::SetTrue) + .help("Write this page as a manual page, in roff"), + ); + + // Looked for before parsing, the way `--help` is: a mode, not an option, + // and not blocked by an argument the mode does not need. Anything after a + // bare `--` is a value and never a flag. + if std::env::args() + .skip(1) + .take_while(|argument| argument != "--") + .any(|argument| argument == "--man") + { + return Err(man(command)); + } + + let leave = |e: clap::Error| { let _ = e.print(); match e.use_stderr() { true => ExitCode::from(exit::USAGE), false => ExitCode::from(exit::OK), } - }) + }; + let matches = command.try_get_matches().map_err(leave)?; + T::from_arg_matches(&matches).map_err(leave) +} + +/// Write the manual page for a command and leave. +/// +/// Generated from the same definition `--help` is, because a manual page kept +/// by hand is a manual page that is wrong. +fn man(command: clap::Command) -> ExitCode { + // A package holding two commands would otherwise put the package's name + // at the head of both pages, and `man vocabmerge` would open bibclean. + let command = match invoked_as() { + // Leaked because clap holds names for the life of the program, + // which from here is one page of roff and an exit. + Some(name) => command.name(&*name.leak()), + None => command, + }; + + let mut roff = Vec::new(); + if let Err(e) = clap_mangen::Man::new(command).render(&mut roff) { + eprintln!("man: {e}"); + return ExitCode::from(exit::SOFTWARE); + } + match String::from_utf8(roff) { + Ok(page) => text("man", &page), + Err(_) => ExitCode::from(exit::SOFTWARE), + } +} + +/// What this program was called, without a path and without an extension. +fn invoked_as() -> Option { + let argv0 = std::env::args_os().next()?; + let name = std::path::Path::new(&argv0).file_stem()?.to_str()?; + match name.is_empty() { + true => None, + false => Some(name.to_string()), + } } /// Write records to stdout as NDJSON, one per line, flushed as they go. @@ -159,4 +217,39 @@ mod tests { format!("{:?}", ExitCode::from(exit::OK)) ); } + + /// A command shaped like the tools here: a required path and a flag. + #[derive(clap::Parser)] + #[command(name = "example", about = "An example.")] + struct Example { + /// The file to read. + path: std::path::PathBuf, + /// Emit a record. + #[arg(long)] + json: bool, + } + + fn page() -> String { + let command = ::command(); + let mut roff = Vec::new(); + clap_mangen::Man::new(command) + .render(&mut roff) + .expect("a rendered page"); + String::from_utf8(roff).expect("text") + } + + #[test] + fn the_manual_page_says_what_the_help_says() { + let page = page(); + assert!(page.contains(".TH example 1"), "{page}"); + assert!(page.contains("An example."), "{page}"); + assert!(page.contains("\\-\\-json"), "{page}"); + } + + #[test] + fn the_manual_page_describes_arguments_the_command_requires() { + // The page has to describe a command whose arguments are required, + // which is why it cannot be reached by parsing them first. + assert!(page().contains("PATH"), "{}", page()); + } } diff --git a/docs/contract.md b/docs/contract.md index 2500fdd..2b9aefa 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -61,11 +61,16 @@ same as any other filter. ## Nothing reaches outside the process No network, no database, no configuration file, no state kept between runs. -Two exceptions, both declared in `--help`: +Four exceptions, each declared in `--help`: - [`emb`](../crates/emb/README.md) makes an HTTP call, which is the whole of what it does. -- [`pdftriage`](../crates/pdftriage/README.md) runs `pdftotext` and `pdfinfo`, and exits `69` - when either is missing rather than guessing. +- [`pdftriage`](../crates/pdftriage/README.md) runs `pdftotext` and `pdfinfo`. +- [`pdfgutter`](../crates/pdfgutter/README.md) runs `pdftoppm` to look at a page. +- [`avplan`](../crates/avplan/README.md) runs `ffprobe` to read a file's streams, and + `ffmpeg` to ask which encoders the machine has. + +The three that call another program exit `69` when it is missing, rather than +guessing at what it would have said. ## Nothing is assumed about where it is running @@ -87,3 +92,14 @@ Where a program prints something for a person to read, `--json` prints the same answer as a record, and that record does not change shape without a major version. Programs whose only sensible output is a stream of records write that stream and have no `--json` to add. + +## Every one writes its own manual page + +`--man` writes the program's manual page, in roff, to stdout: + +```sh +pdfgutter --man | gzip > /usr/local/share/man/man1/pdfgutter.1.gz +``` + +It is generated from the same definition `--help` is, so the two can never +come to say different things.