Skip to main content

acme_proxy/cli/
generate.rs

1//! `completions <shell>` and `man`: the two commands whose output *is* the
2//! command tree.
3//!
4//! Both render from [`Cli::command()`] — the same builder `clap` parses argv
5//! with — rather than from a script or a roff file checked in beside it. That
6//! is the whole reason they exist as generators: the CLI is deliberately not
7//! frozen before 1.0.0 (ADR 0001, in the book), so a hand-maintained completion
8//! script or man page goes stale at the first rename with nothing in CI to say
9//! so, while a generated one cannot.
10//!
11//! **Neither reads the configuration or the database**, which is why
12//! `src/main.rs` answers them *before* it calls `Config::load` and
13//! `Database::open` — see the note there. Everything below is therefore a
14//! plain function over an injectable writer, so the tests assert on the bytes
15//! instead of on a process's stdout.
16
17use std::io::Write;
18
19use clap::CommandFactory;
20use clap_complete::aot::Shell;
21
22use crate::cli::{Cli, CliError, Command};
23
24/// The book, named in the man page's SEE ALSO. Read from the manifest rather
25/// than written out, so a moved documentation site moves this too.
26const BOOK_URL: &str = env!("CARGO_PKG_HOMEPAGE");
27
28/// The book's Admin CLI chapter, which holds the per-flag reference this page
29/// leaves out. `CARGO_PKG_HOMEPAGE` ends in a `/`.
30const CLI_CHAPTER_URL: &str = concat!(env!("CARGO_PKG_HOMEPAGE"), "operations/cli.html");
31
32/// Routes the two generator commands.
33///
34/// Shared by `src/main.rs` (which answers them before opening anything) and by
35/// [`crate::cli::dispatch`] (which stays a total function over [`Command`]), so
36/// the match is spelled once rather than in both.
37///
38/// # Panics
39///
40/// On any other [`Command`]. The two callers both match first; this is the arm
41/// that says so out loud rather than silently generating the wrong thing.
42pub fn write(command: &Command, out: &mut impl Write) -> Result<(), CliError> {
43    match command {
44        Command::Completions { shell } => write_completions(*shell, out),
45        Command::Man => write_man(out),
46        _ => unreachable!("both callers match the two generator commands first"),
47    }
48}
49
50/// Writes the completion script for `shell`.
51///
52/// The name passed to the generator is the binary's, not the crate's: it is
53/// what the script registers itself against, so it has to be what an operator
54/// types.
55pub fn write_completions(shell: Shell, out: &mut impl Write) -> Result<(), CliError> {
56    let mut command = Cli::command();
57    let name = command.get_name().to_string();
58    clap_complete::aot::generate(shell, &mut command, name, out);
59    Ok(())
60}
61
62/// Writes the roff source of `acme-proxy.1`.
63///
64/// Rendered section by section rather than through `Man::render`, because
65/// five of them are facts `clap` has no way to know: what the exit status
66/// means, a few typical invocations, the environment this binary reads, the
67/// file it looks for, and where the full documentation is.
68/// They are written as roff here rather than hung off `after_long_help`, which
69/// would put the same block — `.TP` markup and all — into `--help`.
70///
71/// One page, for the top-level command. The per-flag detail of every subcommand
72/// lives in the book's Admin CLI chapter, which SEE ALSO names: a tree of ~50
73/// roff files would document `admin user totp reset` for a binary that installs
74/// no man pages at all.
75pub fn write_man(out: &mut impl Write) -> Result<(), CliError> {
76    let man = clap_mangen::Man::new(Cli::command()).section("1");
77
78    let render = |out: &mut dyn Write| -> std::io::Result<()> {
79        man.render_title(out)?;
80        man.render_name_section(out)?;
81        man.render_synopsis_section(out)?;
82        man.render_description_section(out)?;
83        man.render_options_section(out)?;
84        man.render_subcommands_section(out)?;
85        render_exit_status_section(out)?;
86        render_examples_section(out)?;
87        render_environment_section(out)?;
88        render_files_section(out)?;
89        render_see_also_section(out)?;
90        man.render_version_section(out)
91    };
92
93    render(out).map_err(|error| CliError::failed(format!("cannot write the man page: {error}")))
94}
95
96/// The contract `CliErrorKind` keeps, as `doc/src/operations/cli.md`'s exit
97/// code table states it.
98fn render_exit_status_section(out: &mut dyn Write) -> std::io::Result<()> {
99    writeln!(out, ".SH EXIT STATUS")?;
100    for (code, meaning) in [
101        ("0", "Success."),
102        (
103            "1",
104            "The host could not carry out the request: a database that will not \
105             open, a signer or CA error, an unreadable file, an unreachable \
106             upstream, invalid configuration. Worth retrying once the host is \
107             fixed. \\fBserve\\fR exits 1 for any startup failure.",
108        ),
109        (
110            "2",
111            "The command line was rejected by the argument parser: an unknown \
112             flag, subcommand or \\fB\\-\\-role\\fR, a missing argument.",
113        ),
114        (
115            "3",
116            "The request cannot be satisfied as written: no object with that id, \
117             an object in the wrong state, an unknown \\fB\\-\\-status\\fR, \
118             \\fB\\-\\-event\\fR or \\fB\\-\\-outcome\\fR value, \
119             contradictory flags. Re-running the identical command will not help.",
120        ),
121    ] {
122        writeln!(out, ".TP")?;
123        writeln!(out, "\\fB{code}\\fR")?;
124        writeln!(out, "{meaning}")?;
125    }
126    Ok(())
127}
128
129/// A handful of invocations, one per kind of task, so the page answers "how do
130/// I start" without the book. Not a reference: every flag is in `--help`.
131fn render_examples_section(out: &mut dyn Write) -> std::io::Result<()> {
132    writeln!(out, ".SH EXAMPLES")?;
133    for (what, command) in [
134        (
135            "Prepare a new deployment, then run every role in one process:",
136            "acme\\-proxy init\nacme\\-proxy serve",
137        ),
138        (
139            "Create the first web admin operator, reading the password from stdin:",
140            "acme\\-proxy admin user create alice",
141        ),
142        (
143            "Find the order behind a certificate serial, and revoke it:",
144            "acme\\-proxy order list \\-\\-cert\\-serial 03:a1:5f\n\
145             acme\\-proxy order revoke <order\\-id> \\-\\-reason 1",
146        ),
147        (
148            "Page through the audit trail as JSON:",
149            "acme\\-proxy audit list \\-\\-since\\-days 7 \\-\\-limit 100 \\-\\-offset 100 \\-\\-json",
150        ),
151        (
152            "Check a configuration's access policy before restarting:",
153            "acme\\-proxy filter explain \\-\\-client\\-ip 192.0.2.10 \\-\\-identifier www.example.com",
154        ),
155    ] {
156        writeln!(out, ".PP")?;
157        writeln!(out, "{what}")?;
158        writeln!(out, ".PP")?;
159        writeln!(out, ".nf")?;
160        writeln!(out, ".RS 4")?;
161        writeln!(out, "{command}")?;
162        writeln!(out, ".RE")?;
163        writeln!(out, ".fi")?;
164    }
165    Ok(())
166}
167
168/// What `Config::load` and `main.rs` actually read from the environment.
169///
170/// Deliberately names the variables without restating their defaults: a default
171/// spelled here and in `doc/src/configuration/reference.md` is a default that
172/// drifts, which is the rule `doc/lint.py` enforces inside the book.
173fn render_environment_section(out: &mut dyn Write) -> std::io::Result<()> {
174    writeln!(out, ".SH ENVIRONMENT")?;
175    writeln!(out, ".TP")?;
176    writeln!(out, "\\fBACME_PROXY_CONFIG\\fR")?;
177    writeln!(
178        out,
179        "Path to the configuration file; its \\fB.toml\\fR extension may be \
180         omitted. Defaults to \\fBconfig\\fR in the working directory."
181    )?;
182    writeln!(out, ".TP")?;
183    writeln!(out, "\\fBACME_PROXY_*\\fR")?;
184    writeln!(
185        out,
186        "Per-key overrides of the configuration file, section and key separated \
187         by a double underscore: \\fBACME_PROXY_SERVER__BIND_ADDRESS\\fR \
188         sets \\fBserver.bind_address\\fR. List-valued keys are comma-separated."
189    )?;
190    writeln!(out, ".TP")?;
191    writeln!(out, "\\fBNO_COLOR\\fR")?;
192    writeln!(
193        out,
194        "Set and non-empty, suppresses colour in the human-readable output. \
195         \\fB\\-\\-color always\\fR outranks it; \\fB\\-\\-json\\fR output never \
196         carries colour at any setting."
197    )?;
198    writeln!(out, ".TP")?;
199    writeln!(out, "\\fBRUST_LOG\\fR")?;
200    writeln!(
201        out,
202        "A log filter in \\fBtracing-subscriber\\fR's \\fBEnvFilter\\fR \
203         syntax. For \\fBserve\\fR it overrides \\fB[logging]\\fR, and \
204         \\fB\\-\\-log\\-level\\fR overrides it. For every other command, \
205         set and non-empty, it turns logging on, on stderr."
206    )
207}
208
209fn render_files_section(out: &mut dyn Write) -> std::io::Result<()> {
210    writeln!(out, ".SH FILES")?;
211    writeln!(out, ".TP")?;
212    writeln!(out, "\\fBconfig.toml\\fR")?;
213    writeln!(
214        out,
215        "The configuration, read from the working directory unless \
216         \\fBACME_PROXY_CONFIG\\fR says otherwise. There is no \
217         \\fB\\-\\-config\\fR flag: every subcommand reads the same one the \
218         server does, which is what makes the admin commands act on the same \
219         database."
220    )
221}
222
223fn render_see_also_section(out: &mut dyn Write) -> std::io::Result<()> {
224    writeln!(out, ".SH SEE ALSO")?;
225    writeln!(
226        out,
227        "The per-flag reference for every subcommand above is the Admin CLI \
228         chapter of the book:"
229    )?;
230    writeln!(out, ".UR {CLI_CHAPTER_URL}")?;
231    writeln!(out, ".UE")?;
232    writeln!(
233        out,
234        "The whole book, with the configuration reference and the operator \
235         guides:"
236    )?;
237    writeln!(out, ".UR {BOOK_URL}")?;
238    writeln!(out, ".UE")
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use clap::ValueEnum;
245
246    fn completions(shell: Shell) -> String {
247        let mut out = Vec::new();
248        write_completions(shell, &mut out).expect("a completion script must render");
249        String::from_utf8(out).expect("clap generates UTF-8")
250    }
251
252    fn man() -> String {
253        let mut out = Vec::new();
254        write_man(&mut out).expect("the man page must render");
255        String::from_utf8(out).expect("roff is written as UTF-8 here")
256    }
257
258    /// `Shell::value_variants()` rather than a list written out: a shell `clap`
259    /// adds later is covered without an edit here, which is the point of taking
260    /// its enum instead of declaring one.
261    #[test]
262    fn every_shell_generates_a_script_naming_the_binary() {
263        for shell in Shell::value_variants() {
264            let script = completions(*shell);
265            assert!(!script.is_empty(), "{shell} generated nothing");
266            assert!(
267                script.contains("acme-proxy"),
268                "{shell}'s script does not name the binary"
269            );
270        }
271    }
272
273    /// The whole tree, not just the top level: `recovery-codes` is four levels
274    /// down (`admin user totp recovery-codes`), so a script carrying it walked
275    /// every subcommand rather than stopping at the first rank.
276    ///
277    /// `Fish` is deliberately absent, and that is a fact about the generator
278    /// rather than about this tree: `clap_complete`'s fish output guards each
279    /// candidate with `__fish_seen_subcommand_from`, which cannot express a
280    /// fourth rank, so it stops at `admin user totp`. Asserting it here would
281    /// pin a limitation of that backend as though it were our contract.
282    #[test]
283    fn the_scripts_reach_the_deepest_subcommand() {
284        for shell in [Shell::Bash, Shell::Zsh, Shell::Elvish, Shell::PowerShell] {
285            assert!(
286                completions(shell).contains("recovery-codes"),
287                "{shell}'s script stops short of the deepest subcommand"
288            );
289        }
290    }
291
292    /// The generated half: a title line, and the subcommand list clap builds.
293    #[test]
294    fn the_man_page_carries_a_title_and_the_subcommands() {
295        let page = man();
296        // Not `starts_with`: `render_title` emits roff's `\*(Aq` quote
297        // definition ahead of the `.TH` line.
298        assert!(
299            page.contains(".TH acme-proxy 1"),
300            "no roff title line: {page:.120}"
301        );
302        assert!(
303            page.contains("acme\\-proxy"),
304            "the page does not name itself"
305        );
306        for subcommand in [
307            "serve", "account", "order", "audit", "nonce", "profile", "eab", "admin",
308        ] {
309            assert!(
310                page.contains(subcommand),
311                "the SUBCOMMANDS section omits `{subcommand}`"
312            );
313        }
314    }
315
316    /// The hand-written half — the only part of the page that can be wrong
317    /// without a compile error.
318    #[test]
319    fn the_man_page_carries_the_hand_written_sections() {
320        let page = man();
321        for section in [
322            ".SH ENVIRONMENT",
323            "ACME_PROXY_CONFIG",
324            "NO_COLOR",
325            "RUST_LOG",
326            ".SH FILES",
327            "config.toml",
328            ".SH SEE ALSO",
329            BOOK_URL,
330            CLI_CHAPTER_URL,
331            ".SH EXIT STATUS",
332            ".SH EXAMPLES",
333        ] {
334            assert!(page.contains(section), "the page omits `{section}`");
335        }
336    }
337
338    /// They land between the generated sections rather than after the last one:
339    /// a SEE ALSO under VERSION reads as a footnote to the version.
340    #[test]
341    fn the_hand_written_sections_precede_the_version() {
342        let page = man();
343        let environment = page
344            .find(".SH ENVIRONMENT")
345            .expect("ENVIRONMENT is rendered");
346        let see_also = page.find(".SH SEE ALSO").expect("SEE ALSO is rendered");
347        let version = page.find(".SH VERSION").expect("VERSION is rendered");
348        assert!(environment < see_also, "SEE ALSO comes before ENVIRONMENT");
349        assert!(see_also < version, "VERSION comes before SEE ALSO");
350    }
351
352    /// [`write`] routes both, which is what `main.rs` and `dispatch` share.
353    #[test]
354    fn write_routes_both_generator_commands() {
355        let mut script = Vec::new();
356        write(&Command::Completions { shell: Shell::Fish }, &mut script)
357            .expect("completions must render");
358        assert!(String::from_utf8_lossy(&script).contains("acme-proxy"));
359
360        let mut page = Vec::new();
361        write(&Command::Man, &mut page).expect("the man page must render");
362        assert!(String::from_utf8_lossy(&page).contains(".TH acme-proxy 1"));
363    }
364
365    /// A write that fails is reported rather than panicking or being dropped:
366    /// `acme-proxy man | head` closes the pipe under us, and an operator paging
367    /// the output should not see a panic for it.
368    #[test]
369    fn a_broken_writer_becomes_a_cli_error() {
370        struct Broken;
371        impl Write for Broken {
372            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
373                Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe))
374            }
375            fn flush(&mut self) -> std::io::Result<()> {
376                Ok(())
377            }
378        }
379
380        let error = write_man(&mut Broken).expect_err("a broken pipe must be reported");
381        assert!(
382            error.to_string().starts_with("cannot write the man page: "),
383            "{error}"
384        );
385    }
386
387    /// The same, but for a pipe that closes *partway* through — which is what
388    /// `acme-proxy man | head` actually does. Every section's `?` is a distinct
389    /// early return, and a writer that only ever fails on its first call leaves
390    /// all but the first of them unexercised.
391    #[test]
392    fn a_pipe_closing_partway_is_reported_from_every_section() {
393        struct FailsAfter(usize);
394        impl Write for FailsAfter {
395            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
396                if self.0 == 0 {
397                    return Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe));
398                }
399                self.0 -= 1;
400                Ok(buf.len())
401            }
402            fn flush(&mut self) -> std::io::Result<()> {
403                Ok(())
404            }
405        }
406
407        // Every write the page takes, not a handful of samples: each section's
408        // `?` is its own early return, and a sample misses most of them.
409        let mut counting = FailsAfter(usize::MAX);
410        write_man(&mut counting).expect("a writer that never fails must succeed");
411        let writes = usize::MAX - counting.0;
412        assert!(
413            writes > 40,
414            "the page should take many writes, took {writes}"
415        );
416
417        for stop in 0..writes {
418            let error = write_man(&mut FailsAfter(stop))
419                .expect_err("a pipe closing mid-page must still be reported");
420            assert!(
421                error.to_string().starts_with("cannot write the man page: "),
422                "closing after {stop} of {writes} writes: {error}"
423            );
424        }
425    }
426
427    /// The bash script matches its own ids.
428    ///
429    /// It works in two halves: a loop that walks the typed words and builds an
430    /// id (`cmd="acme__proxy__subcmd__admin"`), then a `case` over that id
431    /// whose labels carry the candidates. The two are generated separately, so
432    /// they can disagree — and in `clap_complete` 4.6 they do, for any binary
433    /// whose *name* holds a `-`: the loop escapes it to `__` and the labels to
434    /// `__subcmd__`, so `acme-proxy admin <TAB>` matches no label and offers
435    /// nothing. Every shell but bash is unaffected, and the script is still
436    /// valid bash, so nothing else here would have caught it.
437    ///
438    /// This is why `Cargo.toml` holds `clap_complete` at `~4.5`. A release that
439    /// fixes it passes this test and the pin can go.
440    #[test]
441    fn the_bash_script_is_internally_consistent() {
442        let script = completions(Shell::Bash);
443
444        let assigned: Vec<&str> = script
445            .lines()
446            .filter_map(|line| line.trim().strip_prefix("cmd=\""))
447            .filter_map(|rest| rest.strip_suffix('"'))
448            .filter(|id| !id.is_empty())
449            .collect();
450        assert!(
451            assigned.len() > 50,
452            "expected the whole tree, found {} ids",
453            assigned.len()
454        );
455
456        let labels: std::collections::HashSet<&str> = script
457            .lines()
458            .map(str::trim)
459            .filter_map(|line| line.strip_suffix(')'))
460            .collect();
461
462        for id in assigned {
463            assert!(
464                labels.contains(id),
465                "the word loop builds `{id}`, which no `case` label matches: \
466                 bash completion is dead below that point"
467            );
468        }
469    }
470
471    /// `clap`'s own validity check over the whole tree — duplicate short flags,
472    /// a `value_parser` that cannot parse its default, an argument named twice.
473    /// It panics rather than returning, and only in debug builds, which is
474    /// exactly what a test is.
475    #[test]
476    fn the_command_tree_is_well_formed() {
477        Cli::command().debug_assert();
478    }
479}