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 ["serve", "account", "order", "audit", "eab", "admin"] {
220 assert!(
221 page.contains(subcommand),
222 "the SUBCOMMANDS section omits `{subcommand}`"
223 );
224 }
225 }
226
227 #[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 #[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 #[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 #[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 #[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 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 #[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 #[test]
384 fn the_command_tree_is_well_formed() {
385 Cli::command().debug_assert();
386 }
387}