1use std::io::Write;
18
19use clap::CommandFactory;
20use clap_complete::aot::Shell;
21
22use crate::cli::{Cli, CliError, Command};
23
24const BOOK_URL: &str = env!("CARGO_PKG_HOMEPAGE");
27
28const CLI_CHAPTER_URL: &str = concat!(env!("CARGO_PKG_HOMEPAGE"), "operations/cli.html");
31
32pub 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
50pub 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
62pub 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
96fn 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
129fn 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
168fn 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 #[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 #[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 #[test]
294 fn the_man_page_carries_a_title_and_the_subcommands() {
295 let page = man();
296 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[test]
476 fn the_command_tree_is_well_formed() {
477 Cli::command().debug_assert();
478 }
479}