every tool writes its own manual page

This commit is contained in:
funkpower
2026-09-19 15:25:13 -04:00
parent df2a62ec84
commit ab33ac4693
6 changed files with 135 additions and 5 deletions
Generated
+17
View File
@@ -204,6 +204,7 @@ name = "catena-cli"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"clap_mangen",
"corpus-core", "corpus-core",
"serde", "serde",
] ]
@@ -295,6 +296,16 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" 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]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -1098,6 +1109,12 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rrf" name = "rrf"
version = "0.1.0" version = "0.1.0"
+1
View File
@@ -29,6 +29,7 @@ booksniff = { path = "crates/booksniff", version = "0.1.0" }
emb = { path = "crates/emb", version = "0.1.0" } emb = { path = "crates/emb", version = "0.1.0" }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap_mangen = "0.3"
deunicode = "1" deunicode = "1"
fancy-regex = "0.16" fancy-regex = "0.16"
# 0.38 is the last release that builds on the pinned toolchain; the ones # 0.38 is the last release that builds on the pinned toolchain; the ones
+2
View File
@@ -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. - 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 - `--json` is the stable form, and does not change shape without a major
version. 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 The whole of it, with the reasoning: [what these programs
promise](docs/contract.md). promise](docs/contract.md).
+1
View File
@@ -9,6 +9,7 @@ repository.workspace = true
[dependencies] [dependencies]
clap.workspace = true clap.workspace = true
clap_mangen.workspace = true
corpus-core.workspace = true corpus-core.workspace = true
serde.workspace = true serde.workspace = true
+95 -2
View File
@@ -8,6 +8,9 @@
//! - a consumer that closed the pipe is the end of the job, not a failure; //! - 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 //! - a record is flushed as it is written, so a slow stage downstream sees it
//! immediately. //! 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::io::{BufWriter, Write};
use std::process::ExitCode; 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 /// wrong with the arguments leaves with 64, which is what the rest of the
/// suite means by "the command line was wrong". /// suite means by "the command line was wrong".
pub fn parse<T: clap::Parser>() -> Result<T, ExitCode> { pub fn parse<T: clap::Parser>() -> Result<T, ExitCode> {
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(); let _ = e.print();
match e.use_stderr() { match e.use_stderr() {
true => ExitCode::from(exit::USAGE), true => ExitCode::from(exit::USAGE),
false => ExitCode::from(exit::OK), 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<String> {
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. /// Write records to stdout as NDJSON, one per line, flushed as they go.
@@ -159,4 +217,39 @@ mod tests {
format!("{:?}", ExitCode::from(exit::OK)) 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 = <Example as clap::CommandFactory>::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());
}
} }
+19 -3
View File
@@ -61,11 +61,16 @@ same as any other filter.
## Nothing reaches outside the process ## Nothing reaches outside the process
No network, no database, no configuration file, no state kept between runs. 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. - [`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` - [`pdftriage`](../crates/pdftriage/README.md) runs `pdftotext` and `pdfinfo`.
when either is missing rather than guessing. - [`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 ## 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 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 version. Programs whose only sensible output is a stream of records write that
stream and have no `--json` to add. 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.