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 (see `CLAUDE.md`), 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::connect` — 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/// Routes the two generator commands.
29///
30/// Shared by `src/main.rs` (which answers them before opening anything) and by
31/// [`crate::cli::dispatch`] (which stays a total function over [`Command`]), so
32/// the match is spelled once rather than in both.
33///
34/// # Panics
35///
36/// On any other [`Command`]. The two callers both match first; this is the arm
37/// that says so out loud rather than silently generating the wrong thing.
38pub fn write(command: &Command, out: &mut impl Write) -> Result<(), CliError> {
39    match command {
40        Command::Completions { shell } => write_completions(*shell, out),
41        Command::Man => write_man(out),
42        _ => unreachable!("both callers match the two generator commands first"),
43    }
44}
45
46/// Writes the completion script for `shell`.
47///
48/// The name passed to the generator is the binary's, not the crate's: it is
49/// what the script registers itself against, so it has to be what an operator
50/// types.
51pub fn write_completions(shell: Shell, out: &mut impl Write) -> Result<(), CliError> {
52    let mut command = Cli::command();
53    let name = command.get_name().to_string();
54    clap_complete::aot::generate(shell, &mut command, name, out);
55    Ok(())
56}
57
58/// Writes the roff source of `acme-proxy.1`.
59///
60/// Rendered section by section rather than through `Man::render`, because
61/// three of them are facts `clap` has no way to know: the environment this
62/// binary reads, the file it looks for, and where the full documentation is.
63/// They are written as roff here rather than hung off `after_long_help`, which
64/// would put the same block — `.TP` markup and all — into `--help`.
65///
66/// One page, for the top-level command. The per-flag detail of every subcommand
67/// lives in the book's Admin CLI chapter, which SEE ALSO names: a tree of ~50
68/// roff files would document `admin user totp reset` for a binary that installs
69/// no man pages at all.
70pub fn write_man(out: &mut impl Write) -> Result<(), CliError> {
71    let man = clap_mangen::Man::new(Cli::command()).section("1");
72
73    let render = |out: &mut dyn Write| -> std::io::Result<()> {
74        man.render_title(out)?;
75        man.render_name_section(out)?;
76        man.render_synopsis_section(out)?;
77        man.render_description_section(out)?;
78        man.render_options_section(out)?;
79        man.render_subcommands_section(out)?;
80        render_environment_section(out)?;
81        render_files_section(out)?;
82        render_see_also_section(out)?;
83        man.render_version_section(out)
84    };
85
86    render(out).map_err(|error| CliError(format!("cannot write the man page: {error}")))
87}
88
89/// What `Config::load` and `main.rs` actually read from the environment.
90///
91/// Deliberately names the variables without restating their defaults: a default
92/// spelled here and in `doc/src/configuration/reference.md` is a default that
93/// drifts, which is the rule `doc/lint.py` enforces inside the book.
94fn render_environment_section(out: &mut dyn Write) -> std::io::Result<()> {
95    writeln!(out, ".SH ENVIRONMENT")?;
96    writeln!(out, ".TP")?;
97    writeln!(out, "\\fBACME_PROXY_CONFIG\\fR")?;
98    writeln!(
99        out,
100        "Path to the configuration file, without its extension. \
101         Defaults to \\fBconfig\\fR in the working directory."
102    )?;
103    writeln!(out, ".TP")?;
104    writeln!(out, "\\fBACME_PROXY_*\\fR")?;
105    writeln!(
106        out,
107        "Per-key overrides of the configuration file, section and key separated \
108         by a double underscore: \\fBACME_PROXY_SERVER__BIND_ADDRESS\\fR \
109         sets \\fBserver.bind_address\\fR. List-valued keys are comma-separated."
110    )?;
111    writeln!(out, ".TP")?;
112    writeln!(out, "\\fBNO_COLOR\\fR")?;
113    writeln!(
114        out,
115        "Set and non-empty, suppresses colour in the human-readable output. \
116         \\fB\\-\\-color always\\fR outranks it; \\fB\\-\\-json\\fR output never \
117         carries colour at any setting."
118    )?;
119    writeln!(out, ".TP")?;
120    writeln!(out, "\\fBRUST_LOG\\fR")?;
121    writeln!(
122        out,
123        "Overrides the log filter from \\fB[logging]\\fR, in \
124         \\fBtracing-subscriber\\fR's \\fBEnvFilter\\fR syntax."
125    )
126}
127
128fn render_files_section(out: &mut dyn Write) -> std::io::Result<()> {
129    writeln!(out, ".SH FILES")?;
130    writeln!(out, ".TP")?;
131    writeln!(out, "\\fBconfig.toml\\fR")?;
132    writeln!(
133        out,
134        "The configuration, read from the working directory unless \
135         \\fBACME_PROXY_CONFIG\\fR says otherwise. There is no \
136         \\fB\\-\\-config\\fR flag: every subcommand reads the same one the \
137         server does, which is what makes the admin commands act on the same \
138         database."
139    )
140}
141
142fn render_see_also_section(out: &mut dyn Write) -> std::io::Result<()> {
143    writeln!(out, ".SH SEE ALSO")?;
144    writeln!(
145        out,
146        "The full documentation, including the per-flag reference for every \
147         subcommand above, the configuration reference and the operator \
148         guides:"
149    )?;
150    writeln!(out, ".UR {BOOK_URL}")?;
151    writeln!(out, ".UE")
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use clap::ValueEnum;
158
159    fn completions(shell: Shell) -> String {
160        let mut out = Vec::new();
161        write_completions(shell, &mut out).expect("a completion script must render");
162        String::from_utf8(out).expect("clap generates UTF-8")
163    }
164
165    fn man() -> String {
166        let mut out = Vec::new();
167        write_man(&mut out).expect("the man page must render");
168        String::from_utf8(out).expect("roff is written as UTF-8 here")
169    }
170
171    /// `Shell::value_variants()` rather than a list written out: a shell `clap`
172    /// adds later is covered without an edit here, which is the point of taking
173    /// its enum instead of declaring one.
174    #[test]
175    fn every_shell_generates_a_script_naming_the_binary() {
176        for shell in Shell::value_variants() {
177            let script = completions(*shell);
178            assert!(!script.is_empty(), "{shell} generated nothing");
179            assert!(
180                script.contains("acme-proxy"),
181                "{shell}'s script does not name the binary"
182            );
183        }
184    }
185
186    /// The whole tree, not just the top level: `recovery-codes` is four levels
187    /// down (`admin user totp recovery-codes`), so a script carrying it walked
188    /// every subcommand rather than stopping at the first rank.
189    ///
190    /// `Fish` is deliberately absent, and that is a fact about the generator
191    /// rather than about this tree: `clap_complete`'s fish output guards each
192    /// candidate with `__fish_seen_subcommand_from`, which cannot express a
193    /// fourth rank, so it stops at `admin user totp`. Asserting it here would
194    /// pin a limitation of that backend as though it were our contract.
195    #[test]
196    fn the_scripts_reach_the_deepest_subcommand() {
197        for shell in [Shell::Bash, Shell::Zsh, Shell::Elvish, Shell::PowerShell] {
198            assert!(
199                completions(shell).contains("recovery-codes"),
200                "{shell}'s script stops short of the deepest subcommand"
201            );
202        }
203    }
204
205    /// The generated half: a title line, and the subcommand list clap builds.
206    #[test]
207    fn the_man_page_carries_a_title_and_the_subcommands() {
208        let page = man();
209        // Not `starts_with`: `render_title` emits roff's `\*(Aq` quote
210        // definition ahead of the `.TH` line.
211        assert!(
212            page.contains(".TH acme-proxy 1"),
213            "no roff title line: {page:.120}"
214        );
215        assert!(
216            page.contains("acme\\-proxy"),
217            "the page does not name itself"
218        );
219        for subcommand in ["serve", "account", "order", "audit", "eab", "admin"] {
220            assert!(
221                page.contains(subcommand),
222                "the SUBCOMMANDS section omits `{subcommand}`"
223            );
224        }
225    }
226
227    /// The hand-written half — the only part of the page that can be wrong
228    /// without a compile error.
229    #[test]
230    fn the_man_page_carries_the_hand_written_sections() {
231        let page = man();
232        for section in [
233            ".SH ENVIRONMENT",
234            "ACME_PROXY_CONFIG",
235            "NO_COLOR",
236            "RUST_LOG",
237            ".SH FILES",
238            "config.toml",
239            ".SH SEE ALSO",
240            BOOK_URL,
241        ] {
242            assert!(page.contains(section), "the page omits `{section}`");
243        }
244    }
245
246    /// They land between the generated sections rather than after the last one:
247    /// a SEE ALSO under VERSION reads as a footnote to the version.
248    #[test]
249    fn the_hand_written_sections_precede_the_version() {
250        let page = man();
251        let environment = page
252            .find(".SH ENVIRONMENT")
253            .expect("ENVIRONMENT is rendered");
254        let see_also = page.find(".SH SEE ALSO").expect("SEE ALSO is rendered");
255        let version = page.find(".SH VERSION").expect("VERSION is rendered");
256        assert!(environment < see_also, "SEE ALSO comes before ENVIRONMENT");
257        assert!(see_also < version, "VERSION comes before SEE ALSO");
258    }
259
260    /// [`write`] routes both, which is what `main.rs` and `dispatch` share.
261    #[test]
262    fn write_routes_both_generator_commands() {
263        let mut script = Vec::new();
264        write(&Command::Completions { shell: Shell::Fish }, &mut script)
265            .expect("completions must render");
266        assert!(String::from_utf8_lossy(&script).contains("acme-proxy"));
267
268        let mut page = Vec::new();
269        write(&Command::Man, &mut page).expect("the man page must render");
270        assert!(String::from_utf8_lossy(&page).contains(".TH acme-proxy 1"));
271    }
272
273    /// A write that fails is reported rather than panicking or being dropped:
274    /// `acme-proxy man | head` closes the pipe under us, and an operator paging
275    /// the output should not see a panic for it.
276    #[test]
277    fn a_broken_writer_becomes_a_cli_error() {
278        struct Broken;
279        impl Write for Broken {
280            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
281                Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe))
282            }
283            fn flush(&mut self) -> std::io::Result<()> {
284                Ok(())
285            }
286        }
287
288        let error = write_man(&mut Broken).expect_err("a broken pipe must be reported");
289        assert!(
290            error.to_string().starts_with("cannot write the man page: "),
291            "{error}"
292        );
293    }
294
295    /// The same, but for a pipe that closes *partway* through — which is what
296    /// `acme-proxy man | head` actually does. Every section's `?` is a distinct
297    /// early return, and a writer that only ever fails on its first call leaves
298    /// all but the first of them unexercised.
299    #[test]
300    fn a_pipe_closing_partway_is_reported_from_every_section() {
301        struct FailsAfter(usize);
302        impl Write for FailsAfter {
303            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
304                if self.0 == 0 {
305                    return Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe));
306                }
307                self.0 -= 1;
308                Ok(buf.len())
309            }
310            fn flush(&mut self) -> std::io::Result<()> {
311                Ok(())
312            }
313        }
314
315        // Every write the page takes, not a handful of samples: each section's
316        // `?` is its own early return, and a sample misses most of them.
317        let mut counting = FailsAfter(usize::MAX);
318        write_man(&mut counting).expect("a writer that never fails must succeed");
319        let writes = usize::MAX - counting.0;
320        assert!(
321            writes > 40,
322            "the page should take many writes, took {writes}"
323        );
324
325        for stop in 0..writes {
326            let error = write_man(&mut FailsAfter(stop))
327                .expect_err("a pipe closing mid-page must still be reported");
328            assert!(
329                error.to_string().starts_with("cannot write the man page: "),
330                "closing after {stop} of {writes} writes: {error}"
331            );
332        }
333    }
334
335    /// The bash script matches its own ids.
336    ///
337    /// It works in two halves: a loop that walks the typed words and builds an
338    /// id (`cmd="acme__proxy__subcmd__admin"`), then a `case` over that id
339    /// whose labels carry the candidates. The two are generated separately, so
340    /// they can disagree — and in `clap_complete` 4.6 they do, for any binary
341    /// whose *name* holds a `-`: the loop escapes it to `__` and the labels to
342    /// `__subcmd__`, so `acme-proxy admin <TAB>` matches no label and offers
343    /// nothing. Every shell but bash is unaffected, and the script is still
344    /// valid bash, so nothing else here would have caught it.
345    ///
346    /// This is why `Cargo.toml` holds `clap_complete` at `~4.5`. A release that
347    /// fixes it passes this test and the pin can go.
348    #[test]
349    fn the_bash_script_is_internally_consistent() {
350        let script = completions(Shell::Bash);
351
352        let assigned: Vec<&str> = script
353            .lines()
354            .filter_map(|line| line.trim().strip_prefix("cmd=\""))
355            .filter_map(|rest| rest.strip_suffix('"'))
356            .filter(|id| !id.is_empty())
357            .collect();
358        assert!(
359            assigned.len() > 50,
360            "expected the whole tree, found {} ids",
361            assigned.len()
362        );
363
364        let labels: std::collections::HashSet<&str> = script
365            .lines()
366            .map(str::trim)
367            .filter_map(|line| line.strip_suffix(')'))
368            .collect();
369
370        for id in assigned {
371            assert!(
372                labels.contains(id),
373                "the word loop builds `{id}`, which no `case` label matches: \
374                 bash completion is dead below that point"
375            );
376        }
377    }
378
379    /// `clap`'s own validity check over the whole tree — duplicate short flags,
380    /// a `value_parser` that cannot parse its default, an argument named twice.
381    /// It panics rather than returning, and only in debug builds, which is
382    /// exactly what a test is.
383    #[test]
384    fn the_command_tree_is_well_formed() {
385        Cli::command().debug_assert();
386    }
387}