use std::io::Write;
use std::path::Path;
use clap::{Command, CommandFactory};
use clap_mangen::Man;
const MANUAL: &str = "Sandogasa Manual";
fn source(cmd: &Command) -> String {
match cmd.get_version() {
Some(version) => format!("sandogasa {version}"),
None => "sandogasa".to_string(),
}
}
pub fn render_dated(cmd: Command, date: &str) -> Vec<u8> {
let mut cmd = cmd;
cmd.build();
let man = Man::new(cmd.clone())
.date(date)
.source(source(&cmd))
.manual(MANUAL);
let mut out = Vec::new();
man.render_title(&mut out).expect(EXPECT);
push_body(&mut out, &man, Man::render_name_section, false);
push_body(&mut out, &man, Man::render_synopsis_section, false);
push_body(&mut out, &man, Man::render_description_section, false);
push_body(&mut out, &man, Man::render_options_section, false);
let name = cmd.get_name().to_string();
if subcommands(&cmd).next().is_some() {
out.extend_from_slice(b".SH COMMANDS\n");
for sub in subcommands(&cmd) {
render_subcommand(&mut out, &name, sub);
}
}
out
}
pub fn render(cmd: Command) -> Vec<u8> {
render_dated(cmd, &today())
}
fn today() -> String {
chrono::Utc::now().format("%Y-%m-%d").to_string()
}
pub fn check<C: CommandFactory>(path: &str) {
let source = source(&C::command());
if std::env::var_os("SANDOGASA_UPDATE_MAN").is_some() {
update::<C>(Path::new(path), &source);
return;
}
let committed = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}\n{REGENERATE}"));
let flat = String::from_utf8_lossy(&committed).replace('\\', "");
let (title, _) = split_title(&flat);
assert!(
!title.is_empty(),
"{path} does not look like a man page\n{REGENERATE}"
);
assert!(
title.contains(&source),
"{path} does not name `{source}` — the version moved on\n{REGENERATE}"
);
for token in tokens(&C::command()) {
assert!(
flat.contains(&token),
"{path} does not document `{token}`\n{REGENERATE}"
);
}
}
fn update<C: CommandFactory>(path: &Path, source: &str) {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display()));
}
let page = render(C::command());
if let Ok(committed) = std::fs::read_to_string(path) {
let fresh = String::from_utf8_lossy(&page);
let (old_title, old_body) = split_title(&committed);
let (_, new_body) = split_title(&fresh);
if old_body == new_body && old_title.contains(source) {
return;
}
}
std::fs::write(path, &page).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
}
fn split_title(page: &str) -> (&str, &str) {
let Some(start) = page.find(".TH ") else {
return ("", page);
};
let end = page[start..]
.find('\n')
.map_or(page.len(), |n| start + n + 1);
(&page[start..end], &page[end..])
}
const EXPECT: &str = "writing roff to a Vec cannot fail";
const REGENERATE: &str = "regenerate the man pages with scripts/gen-man.sh";
fn subcommands(cmd: &Command) -> impl Iterator<Item = &Command> {
cmd.get_subcommands()
.filter(|c| !c.is_hide_set() && c.get_name() != "help")
}
fn strip_line(block: &str, pred: impl Fn(&str) -> bool) -> &str {
match block.split_once('\n') {
Some((head, rest)) if pred(head) => rest,
_ => block,
}
}
fn body(block: &str, strip_header: bool) -> &str {
let block = strip_line(block, |l| l.starts_with(".ie "));
let block = strip_line(block, |l| l.starts_with(".el "));
if strip_header {
strip_line(block, |l| l.starts_with(".SH"))
} else {
block
}
}
fn push_body(
out: &mut Vec<u8>,
man: &Man,
section: fn(&Man, &mut dyn Write) -> std::io::Result<()>,
strip_header: bool,
) {
let mut block = Vec::new();
section(man, &mut block).expect(EXPECT);
let block = String::from_utf8_lossy(&block);
out.extend_from_slice(body(&block, strip_header).as_bytes());
}
fn render_subcommand(out: &mut Vec<u8>, path: &str, cmd: &Command) {
let name = format!("{path} {}", cmd.get_name());
let sub = cmd.clone().display_name(name.clone());
let man = Man::new(sub.clone());
writeln!(out, ".SS {name}").expect(EXPECT);
push_body(out, &man, Man::render_synopsis_section, true);
out.extend_from_slice(b".PP\n");
push_body(out, &man, Man::render_description_section, true);
push_body(out, &man, Man::render_options_section, true);
for nested in subcommands(&sub) {
render_subcommand(out, &name, nested);
}
}
fn tokens(cmd: &Command) -> Vec<String> {
let mut out = Vec::new();
collect_tokens(cmd, &mut out);
out
}
fn collect_tokens(cmd: &Command, out: &mut Vec<String>) {
for arg in cmd.get_arguments().filter(|a| !a.is_hide_set()) {
if let Some(long) = arg.get_long() {
out.push(format!("--{long}"));
}
}
for sub in subcommands(cmd) {
out.push(sub.get_name().to_string());
collect_tokens(sub, out);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cmd() -> Command {
Command::new("demo")
.about("Demo tool")
.arg(clap::Arg::new("json").long("json").num_args(0))
.subcommand(
Command::new("show")
.about("Show a thing")
.arg(clap::Arg::new("all").long("all").num_args(0)),
)
.subcommand(Command::new("secret").hide(true))
}
#[test]
fn render_documents_flags_and_subcommands() {
let page = String::from_utf8(render(cmd())).unwrap();
assert!(page.contains(".TH demo 1"), "{page}");
assert!(page.contains(".SH COMMANDS"));
assert!(page.contains(".SS demo show"));
assert!(page.contains("\\-\\-all"));
assert_eq!(page.matches(".SH OPTIONS").count(), 1, "{page}");
assert!(!page.contains("secret"), "{page}");
assert!(!page.contains(".SS demo help"), "{page}");
}
#[test]
fn render_titles_carry_the_date_and_version() {
let page = String::from_utf8(render_dated(cmd().version("1.2.3"), "2026-08-05")).unwrap();
let (title, _) = split_title(&page);
assert_eq!(
title.trim_end(),
r#".TH demo 1 2026-08-05 "sandogasa 1.2.3" "Sandogasa Manual""#,
"{title}"
);
assert!(page.contains("\\-\\-version"), "{page}");
}
#[test]
fn render_without_a_version_still_names_the_project() {
let page = String::from_utf8(render_dated(cmd(), "2026-08-05")).unwrap();
let (title, _) = split_title(&page);
assert!(title.contains(" sandogasa "), "{title}");
}
struct Demo;
impl clap::CommandFactory for Demo {
fn command() -> Command {
cmd().version("1.2.3")
}
fn command_for_update() -> Command {
Self::command()
}
}
#[test]
fn update_keeps_a_page_whose_content_is_unchanged() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("demo.1");
let source = source(&Demo::command());
let old = render_dated(Demo::command(), "2020-01-01");
std::fs::write(&path, &old).unwrap();
update::<Demo>(&path, &source);
assert_eq!(std::fs::read(&path).unwrap(), old);
let stale = String::from_utf8(old.clone())
.unwrap()
.replace("sandogasa 1.2.3", "sandogasa 1.0.0");
std::fs::write(&path, &stale).unwrap();
update::<Demo>(&path, &source);
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("sandogasa 1.2.3"), "{written}");
assert!(!written.contains("2020-01-01"), "{written}");
}
#[test]
fn update_writes_a_missing_page() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("man").join("demo.1");
update::<Demo>(&path, &source(&Demo::command()));
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains(".TH demo 1"), "{written}");
}
#[test]
fn split_title_finds_the_th_line() {
let (title, body) = split_title(".ie x\n.TH a 1 b\nrest\n");
assert_eq!(title, ".TH a 1 b\n");
assert_eq!(body, "rest\n");
assert_eq!(split_title("no title\n"), ("", "no title\n"));
}
#[test]
fn tokens_cover_nested_commands() {
let found = tokens(&cmd());
assert!(found.contains(&"--json".to_string()));
assert!(found.contains(&"show".to_string()));
assert!(found.contains(&"--all".to_string()));
assert!(!found.contains(&"secret".to_string()));
}
#[test]
fn body_strips_the_preamble_and_optionally_the_header() {
let block = ".ie x\n.el y\n.SH OPTIONS\nbody\n";
assert_eq!(body(block, false), ".SH OPTIONS\nbody\n");
assert_eq!(body(block, true), "body\n");
assert_eq!(body("no preamble\n", true), "no preamble\n");
}
}