//! The CLI reference in the workspace's `README.md`, made from each binary's //! own `--help`. //! //! Every binary a person runs has a `cli_reference` test that renders its //! section and fails while the README holds anything else, so a new //! subcommand or flag cannot land without its entry. A clap binary also fails //! on any subcommand or argument with no help text, since that text is the //! whole reference. `scripts/gen-cli-reference.sh` writes every section again. //! //! Behind the `reference` feature, which only those tests turn on. use std::path::PathBuf; use clap::Command; /// Set to anything but empty, and a stale section is written rather than /// failed. pub const BLESS: &str = "DIDBOT_BLESS_README"; /// The column help text is wrapped at, whatever terminal the test runs in. const WIDTH: usize = 100; /// What a developer runs when a section is stale. const FIX: &str = "run scripts/gen-cli-reference.sh and stage README.md"; /// Checks the section of a binary whose parser is clap. /// /// # Panics /// /// When a subcommand or argument has no help text, or the section is stale /// and [`BLESS`] is unset. pub fn check_command(binary: &str, command: Command) { let mut command = command.term_width(WIDTH); command.build(); let missing = undocumented(&command); assert!( missing.is_empty(), "{binary}: give these help text, which the README's CLI reference is made of:\n {}", missing.join("\n ") ); check(binary, §ion(binary, &pages(binary, &command))); } /// Checks the section of a binary that prints a hand-written usage. /// /// # Panics /// /// When the section is stale and [`BLESS`] is unset. pub fn check_usage(binary: &str, usage: &str) { check( binary, §ion(binary, &[(binary.to_owned(), usage.to_owned())]), ); } fn check(binary: &str, section: &str) { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../README.md"); let readme = std::fs::read_to_string(&path).expect("the workspace README"); let Some(wanted) = stale(&readme, binary, section).unwrap_or_else(|why| panic!("{why}")) else { return; }; if std::env::var_os(BLESS).is_some_and(|set| !set.is_empty()) { std::fs::write(&path, wanted).expect("the workspace README is writable"); return; } panic!("README.md's CLI reference for {binary} is not what its --help prints: {FIX}"); } /// The README with `binary`'s section replaced by `section`, or `None` when /// it already holds exactly that. fn stale(readme: &str, binary: &str, section: &str) -> Result, String> { let begin = format!("\n"); let end = format!(""); let missing = || format!("README.md has no `{begin}` … `{end}` pair to write {binary} into"); let start = readme.find(&begin).ok_or_else(missing)? + begin.len(); let stop = start + readme[start..].find(&end).ok_or_else(missing)?; if &readme[start..stop] == section { return Ok(None); } Ok(Some(format!( "{}{section}{}", &readme[..start], &readme[stop..] ))) } /// One collapsed block per binary, and in it each command's help under a /// heading naming what a person types. fn section(binary: &str, pages: &[(String, String)]) -> String { let mut out = format!("
\n{binary}\n\n"); for (title, help) in pages { let help: Vec<&str> = help.trim_end().lines().map(str::trim_end).collect(); out.push_str(&format!( "### `{title}`\n\n```text\n{}\n```\n\n", help.join("\n") )); } out.push_str("
\n"); out } /// Every command a person can reach, each with its `--help`: the root under /// the binary's name, and each subcommand as a person types it. fn pages(binary: &str, command: &Command) -> Vec<(String, String)> { let mut out = Vec::new(); walk(command, &mut |sub| { let title = match sub.get_bin_name() { Some(typed) if !std::ptr::eq(sub, command) => typed, _ => binary, }; out.push((title.to_owned(), sub.clone().render_long_help().to_string())); }); out } /// Every subcommand and argument with no help text, by where it sits. fn undocumented(command: &Command) -> Vec { let mut out = Vec::new(); walk(command, &mut |command| { let title = command.get_bin_name().unwrap_or(command.get_name()); if command.get_about().is_none() && command.get_long_about().is_none() { out.push(title.to_owned()); } for arg in command.get_arguments().filter(|arg| !arg.is_hide_set()) { if arg.get_help().is_none() && arg.get_long_help().is_none() { out.push(format!("{title}: {}", arg.get_id())); } } }); out } /// `visit` on `command` and every visible subcommand beneath it, skipping the /// `help` subcommand clap adds. fn walk(command: &Command, visit: &mut impl FnMut(&Command)) { visit(command); for sub in command.get_subcommands() { if !sub.is_hide_set() && sub.get_name() != "help" { walk(sub, visit); } } } #[cfg(test)] mod tests { use clap::{Arg, Command}; use super::{pages, section, stale, undocumented}; fn tool() -> Command { Command::new("tool") .about("Does a thing") .subcommand(Command::new("list").about("Lists the things")) } fn rendered(mut command: Command) -> String { command.build(); section("tool", &pages("tool", &command)) } fn readme(section: &str) -> String { format!("# Title\n\n\n{section}\n\nAfter.\n") } /// A README written from one parser is current for it, and stale as soon /// as a subcommand is added without writing it again. The rewrite holds /// the new subcommand and leaves the rest of the file alone. #[test] fn a_new_subcommand_makes_the_written_reference_stale() { let written = readme(&rendered(tool())); assert_eq!(stale(&written, "tool", &rendered(tool())), Ok(None)); let grown = tool().subcommand(Command::new("export").about("Exports the things")); let fixed = stale(&written, "tool", &rendered(grown.clone())) .unwrap() .expect("the added subcommand is not in the README yet"); assert!(fixed.contains("### `tool export`"), "{fixed}"); assert!(fixed.starts_with("# Title\n\n") && fixed.ends_with("\n\nAfter.\n")); assert_eq!(stale(&fixed, "tool", &rendered(grown)), Ok(None)); } /// A binary with no place in the README is refused rather than appended /// somewhere a person did not choose. #[test] fn a_readme_without_the_markers_is_refused() { let error = stale("# Title\n", "tool", &rendered(tool())).unwrap_err(); assert!(error.contains(""), "{error}"); } /// A flag or subcommand with no help text is named, so its section never /// carries an entry that says nothing. #[test] fn an_argument_or_subcommand_without_help_is_named() { let mut command = tool() .arg(Arg::new("quiet").long("quiet")) .subcommand(Command::new("export")); command.build(); assert_eq!( undocumented(&command), ["tool: quiet", "tool export"].map(str::to_owned) ); } }