acme_proxy/cli/
generate.rs1use 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
28pub 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
46pub 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
58pub 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
89fn 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 #[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 #[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 #[test]
207 fn the_man_page_carries_a_title_and_the_subcommands() {
208 let page = man();
209 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 [
220 "serve", "account", "order", "audit", "nonce", "profile", "eab", "admin",
221 ] {
222 assert!(
223 page.contains(subcommand),
224 "the SUBCOMMANDS section omits `{subcommand}`"
225 );
226 }
227 }
228
229 #[test]
232 fn the_man_page_carries_the_hand_written_sections() {
233 let page = man();
234 for section in [
235 ".SH ENVIRONMENT",
236 "ACME_PROXY_CONFIG",
237 "NO_COLOR",
238 "RUST_LOG",
239 ".SH FILES",
240 "config.toml",
241 ".SH SEE ALSO",
242 BOOK_URL,
243 ] {
244 assert!(page.contains(section), "the page omits `{section}`");
245 }
246 }
247
248 #[test]
251 fn the_hand_written_sections_precede_the_version() {
252 let page = man();
253 let environment = page
254 .find(".SH ENVIRONMENT")
255 .expect("ENVIRONMENT is rendered");
256 let see_also = page.find(".SH SEE ALSO").expect("SEE ALSO is rendered");
257 let version = page.find(".SH VERSION").expect("VERSION is rendered");
258 assert!(environment < see_also, "SEE ALSO comes before ENVIRONMENT");
259 assert!(see_also < version, "VERSION comes before SEE ALSO");
260 }
261
262 #[test]
264 fn write_routes_both_generator_commands() {
265 let mut script = Vec::new();
266 write(&Command::Completions { shell: Shell::Fish }, &mut script)
267 .expect("completions must render");
268 assert!(String::from_utf8_lossy(&script).contains("acme-proxy"));
269
270 let mut page = Vec::new();
271 write(&Command::Man, &mut page).expect("the man page must render");
272 assert!(String::from_utf8_lossy(&page).contains(".TH acme-proxy 1"));
273 }
274
275 #[test]
279 fn a_broken_writer_becomes_a_cli_error() {
280 struct Broken;
281 impl Write for Broken {
282 fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
283 Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe))
284 }
285 fn flush(&mut self) -> std::io::Result<()> {
286 Ok(())
287 }
288 }
289
290 let error = write_man(&mut Broken).expect_err("a broken pipe must be reported");
291 assert!(
292 error.to_string().starts_with("cannot write the man page: "),
293 "{error}"
294 );
295 }
296
297 #[test]
302 fn a_pipe_closing_partway_is_reported_from_every_section() {
303 struct FailsAfter(usize);
304 impl Write for FailsAfter {
305 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
306 if self.0 == 0 {
307 return Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe));
308 }
309 self.0 -= 1;
310 Ok(buf.len())
311 }
312 fn flush(&mut self) -> std::io::Result<()> {
313 Ok(())
314 }
315 }
316
317 let mut counting = FailsAfter(usize::MAX);
320 write_man(&mut counting).expect("a writer that never fails must succeed");
321 let writes = usize::MAX - counting.0;
322 assert!(
323 writes > 40,
324 "the page should take many writes, took {writes}"
325 );
326
327 for stop in 0..writes {
328 let error = write_man(&mut FailsAfter(stop))
329 .expect_err("a pipe closing mid-page must still be reported");
330 assert!(
331 error.to_string().starts_with("cannot write the man page: "),
332 "closing after {stop} of {writes} writes: {error}"
333 );
334 }
335 }
336
337 #[test]
351 fn the_bash_script_is_internally_consistent() {
352 let script = completions(Shell::Bash);
353
354 let assigned: Vec<&str> = script
355 .lines()
356 .filter_map(|line| line.trim().strip_prefix("cmd=\""))
357 .filter_map(|rest| rest.strip_suffix('"'))
358 .filter(|id| !id.is_empty())
359 .collect();
360 assert!(
361 assigned.len() > 50,
362 "expected the whole tree, found {} ids",
363 assigned.len()
364 );
365
366 let labels: std::collections::HashSet<&str> = script
367 .lines()
368 .map(str::trim)
369 .filter_map(|line| line.strip_suffix(')'))
370 .collect();
371
372 for id in assigned {
373 assert!(
374 labels.contains(id),
375 "the word loop builds `{id}`, which no `case` label matches: \
376 bash completion is dead below that point"
377 );
378 }
379 }
380
381 #[test]
386 fn the_command_tree_is_well_formed() {
387 Cli::command().debug_assert();
388 }
389}