use std::io::Write;
use clap::CommandFactory;
use clap_complete::aot::Shell;
use crate::cli::{Cli, CliError, Command};
const BOOK_URL: &str = env!("CARGO_PKG_HOMEPAGE");
const CLI_CHAPTER_URL: &str = concat!(env!("CARGO_PKG_HOMEPAGE"), "operations/cli.html");
pub fn write(command: &Command, out: &mut impl Write) -> Result<(), CliError> {
match command {
Command::Completions { shell } => write_completions(*shell, out),
Command::Man => write_man(out),
_ => unreachable!("both callers match the two generator commands first"),
}
}
pub fn write_completions(shell: Shell, out: &mut impl Write) -> Result<(), CliError> {
let mut command = Cli::command();
let name = command.get_name().to_string();
clap_complete::aot::generate(shell, &mut command, name, out);
Ok(())
}
pub fn write_man(out: &mut impl Write) -> Result<(), CliError> {
let man = clap_mangen::Man::new(Cli::command()).section("1");
let render = |out: &mut dyn Write| -> std::io::Result<()> {
man.render_title(out)?;
man.render_name_section(out)?;
man.render_synopsis_section(out)?;
man.render_description_section(out)?;
man.render_options_section(out)?;
man.render_subcommands_section(out)?;
render_exit_status_section(out)?;
render_examples_section(out)?;
render_environment_section(out)?;
render_files_section(out)?;
render_see_also_section(out)?;
man.render_version_section(out)
};
render(out).map_err(|error| CliError::failed(format!("cannot write the man page: {error}")))
}
fn render_exit_status_section(out: &mut dyn Write) -> std::io::Result<()> {
writeln!(out, ".SH EXIT STATUS")?;
for (code, meaning) in [
("0", "Success."),
(
"1",
"The host could not carry out the request: a database that will not \
open, a signer or CA error, an unreadable file, an unreachable \
upstream, invalid configuration. Worth retrying once the host is \
fixed. \\fBserve\\fR exits 1 for any startup failure.",
),
(
"2",
"The command line was rejected by the argument parser: an unknown \
flag, subcommand or \\fB\\-\\-role\\fR, a missing argument.",
),
(
"3",
"The request cannot be satisfied as written: no object with that id, \
an object in the wrong state, an unknown \\fB\\-\\-status\\fR, \
\\fB\\-\\-event\\fR or \\fB\\-\\-outcome\\fR value, \
contradictory flags. Re-running the identical command will not help.",
),
] {
writeln!(out, ".TP")?;
writeln!(out, "\\fB{code}\\fR")?;
writeln!(out, "{meaning}")?;
}
Ok(())
}
fn render_examples_section(out: &mut dyn Write) -> std::io::Result<()> {
writeln!(out, ".SH EXAMPLES")?;
for (what, command) in [
(
"Prepare a new deployment, then run every role in one process:",
"acme\\-proxy init\nacme\\-proxy serve",
),
(
"Create the first web admin operator, reading the password from stdin:",
"acme\\-proxy admin user create alice",
),
(
"Find the order behind a certificate serial, and revoke it:",
"acme\\-proxy order list \\-\\-cert\\-serial 03:a1:5f\n\
acme\\-proxy order revoke <order\\-id> \\-\\-reason 1",
),
(
"Page through the audit trail as JSON:",
"acme\\-proxy audit list \\-\\-since\\-days 7 \\-\\-limit 100 \\-\\-offset 100 \\-\\-json",
),
(
"Check a configuration's access policy before restarting:",
"acme\\-proxy filter explain \\-\\-client\\-ip 192.0.2.10 \\-\\-identifier www.example.com",
),
] {
writeln!(out, ".PP")?;
writeln!(out, "{what}")?;
writeln!(out, ".PP")?;
writeln!(out, ".nf")?;
writeln!(out, ".RS 4")?;
writeln!(out, "{command}")?;
writeln!(out, ".RE")?;
writeln!(out, ".fi")?;
}
Ok(())
}
fn render_environment_section(out: &mut dyn Write) -> std::io::Result<()> {
writeln!(out, ".SH ENVIRONMENT")?;
writeln!(out, ".TP")?;
writeln!(out, "\\fBACME_PROXY_CONFIG\\fR")?;
writeln!(
out,
"Path to the configuration file; its \\fB.toml\\fR extension may be \
omitted. Defaults to \\fBconfig\\fR in the working directory."
)?;
writeln!(out, ".TP")?;
writeln!(out, "\\fBACME_PROXY_*\\fR")?;
writeln!(
out,
"Per-key overrides of the configuration file, section and key separated \
by a double underscore: \\fBACME_PROXY_SERVER__BIND_ADDRESS\\fR \
sets \\fBserver.bind_address\\fR. List-valued keys are comma-separated."
)?;
writeln!(out, ".TP")?;
writeln!(out, "\\fBNO_COLOR\\fR")?;
writeln!(
out,
"Set and non-empty, suppresses colour in the human-readable output. \
\\fB\\-\\-color always\\fR outranks it; \\fB\\-\\-json\\fR output never \
carries colour at any setting."
)?;
writeln!(out, ".TP")?;
writeln!(out, "\\fBRUST_LOG\\fR")?;
writeln!(
out,
"A log filter in \\fBtracing-subscriber\\fR's \\fBEnvFilter\\fR \
syntax. For \\fBserve\\fR it overrides \\fB[logging]\\fR, and \
\\fB\\-\\-log\\-level\\fR overrides it. For every other command, \
set and non-empty, it turns logging on, on stderr."
)
}
fn render_files_section(out: &mut dyn Write) -> std::io::Result<()> {
writeln!(out, ".SH FILES")?;
writeln!(out, ".TP")?;
writeln!(out, "\\fBconfig.toml\\fR")?;
writeln!(
out,
"The configuration, read from the working directory unless \
\\fBACME_PROXY_CONFIG\\fR says otherwise. There is no \
\\fB\\-\\-config\\fR flag: every subcommand reads the same one the \
server does, which is what makes the admin commands act on the same \
database."
)
}
fn render_see_also_section(out: &mut dyn Write) -> std::io::Result<()> {
writeln!(out, ".SH SEE ALSO")?;
writeln!(
out,
"The per-flag reference for every subcommand above is the Admin CLI \
chapter of the book:"
)?;
writeln!(out, ".UR {CLI_CHAPTER_URL}")?;
writeln!(out, ".UE")?;
writeln!(
out,
"The whole book, with the configuration reference and the operator \
guides:"
)?;
writeln!(out, ".UR {BOOK_URL}")?;
writeln!(out, ".UE")
}
#[cfg(test)]
mod tests {
use super::*;
use clap::ValueEnum;
fn completions(shell: Shell) -> String {
let mut out = Vec::new();
write_completions(shell, &mut out).expect("a completion script must render");
String::from_utf8(out).expect("clap generates UTF-8")
}
fn man() -> String {
let mut out = Vec::new();
write_man(&mut out).expect("the man page must render");
String::from_utf8(out).expect("roff is written as UTF-8 here")
}
#[test]
fn every_shell_generates_a_script_naming_the_binary() {
for shell in Shell::value_variants() {
let script = completions(*shell);
assert!(!script.is_empty(), "{shell} generated nothing");
assert!(
script.contains("acme-proxy"),
"{shell}'s script does not name the binary"
);
}
}
#[test]
fn the_scripts_reach_the_deepest_subcommand() {
for shell in [Shell::Bash, Shell::Zsh, Shell::Elvish, Shell::PowerShell] {
assert!(
completions(shell).contains("recovery-codes"),
"{shell}'s script stops short of the deepest subcommand"
);
}
}
#[test]
fn the_man_page_carries_a_title_and_the_subcommands() {
let page = man();
assert!(
page.contains(".TH acme-proxy 1"),
"no roff title line: {page:.120}"
);
assert!(
page.contains("acme\\-proxy"),
"the page does not name itself"
);
for subcommand in [
"serve", "account", "order", "audit", "nonce", "profile", "eab", "admin",
] {
assert!(
page.contains(subcommand),
"the SUBCOMMANDS section omits `{subcommand}`"
);
}
}
#[test]
fn the_man_page_carries_the_hand_written_sections() {
let page = man();
for section in [
".SH ENVIRONMENT",
"ACME_PROXY_CONFIG",
"NO_COLOR",
"RUST_LOG",
".SH FILES",
"config.toml",
".SH SEE ALSO",
BOOK_URL,
CLI_CHAPTER_URL,
".SH EXIT STATUS",
".SH EXAMPLES",
] {
assert!(page.contains(section), "the page omits `{section}`");
}
}
#[test]
fn the_hand_written_sections_precede_the_version() {
let page = man();
let environment = page
.find(".SH ENVIRONMENT")
.expect("ENVIRONMENT is rendered");
let see_also = page.find(".SH SEE ALSO").expect("SEE ALSO is rendered");
let version = page.find(".SH VERSION").expect("VERSION is rendered");
assert!(environment < see_also, "SEE ALSO comes before ENVIRONMENT");
assert!(see_also < version, "VERSION comes before SEE ALSO");
}
#[test]
fn write_routes_both_generator_commands() {
let mut script = Vec::new();
write(&Command::Completions { shell: Shell::Fish }, &mut script)
.expect("completions must render");
assert!(String::from_utf8_lossy(&script).contains("acme-proxy"));
let mut page = Vec::new();
write(&Command::Man, &mut page).expect("the man page must render");
assert!(String::from_utf8_lossy(&page).contains(".TH acme-proxy 1"));
}
#[test]
fn a_broken_writer_becomes_a_cli_error() {
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let error = write_man(&mut Broken).expect_err("a broken pipe must be reported");
assert!(
error.to_string().starts_with("cannot write the man page: "),
"{error}"
);
}
#[test]
fn a_pipe_closing_partway_is_reported_from_every_section() {
struct FailsAfter(usize);
impl Write for FailsAfter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.0 == 0 {
return Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe));
}
self.0 -= 1;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut counting = FailsAfter(usize::MAX);
write_man(&mut counting).expect("a writer that never fails must succeed");
let writes = usize::MAX - counting.0;
assert!(
writes > 40,
"the page should take many writes, took {writes}"
);
for stop in 0..writes {
let error = write_man(&mut FailsAfter(stop))
.expect_err("a pipe closing mid-page must still be reported");
assert!(
error.to_string().starts_with("cannot write the man page: "),
"closing after {stop} of {writes} writes: {error}"
);
}
}
#[test]
fn the_bash_script_is_internally_consistent() {
let script = completions(Shell::Bash);
let assigned: Vec<&str> = script
.lines()
.filter_map(|line| line.trim().strip_prefix("cmd=\""))
.filter_map(|rest| rest.strip_suffix('"'))
.filter(|id| !id.is_empty())
.collect();
assert!(
assigned.len() > 50,
"expected the whole tree, found {} ids",
assigned.len()
);
let labels: std::collections::HashSet<&str> = script
.lines()
.map(str::trim)
.filter_map(|line| line.strip_suffix(')'))
.collect();
for id in assigned {
assert!(
labels.contains(id),
"the word loop builds `{id}`, which no `case` label matches: \
bash completion is dead below that point"
);
}
}
#[test]
fn the_command_tree_is_well_formed() {
Cli::command().debug_assert();
}
}