1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use clap::CommandFactory;
6
7use crate::cli::Cli;
8
9const MANUAL: &str = "tsafe Manual";
10
11pub fn render_to_dir(output_dir: &Path) -> io::Result<Vec<PathBuf>> {
12 fs::create_dir_all(output_dir)?;
13 remove_stale_generated_pages(output_dir)?;
14
15 let root = Cli::command();
16 let mut written = Vec::new();
17 render_command(&root, output_dir, "tsafe", &mut written)?;
18 written.sort();
19 Ok(written)
20}
21
22fn remove_stale_generated_pages(output_dir: &Path) -> io::Result<()> {
23 for entry in fs::read_dir(output_dir)? {
24 let entry = entry?;
25 let path = entry.path();
26 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
27 continue;
28 };
29
30 if name.starts_with("tsafe") && name.ends_with(".1") {
31 fs::remove_file(path)?;
32 }
33 }
34
35 Ok(())
36}
37
38fn render_command(
39 cmd: &clap::Command,
40 output_dir: &Path,
41 page_name: &str,
42 written: &mut Vec<PathBuf>,
43) -> io::Result<()> {
44 let mut rendered = Vec::new();
45 clap_mangen::Man::new(cmd.clone())
46 .title(page_name.to_ascii_uppercase())
47 .section("1")
48 .manual(MANUAL)
49 .source(format!("tsafe {}", env!("CARGO_PKG_VERSION")))
50 .render(&mut rendered)?;
51
52 let output_path = output_dir.join(format!("{page_name}.1"));
53 let text = String::from_utf8(rendered)
54 .map(|text| {
55 text.lines()
56 .map(str::trim_end)
57 .collect::<Vec<_>>()
58 .join("\n")
59 + "\n"
60 })
61 .unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned());
62 fs::write(&output_path, text)?;
63 written.push(output_path);
64
65 for subcommand in cmd.get_subcommands() {
66 let child_name = format!("{page_name}-{}", subcommand.get_name());
67 render_command(subcommand, output_dir, &child_name, written)?;
68 }
69
70 Ok(())
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use tempfile::tempdir;
77
78 #[test]
79 fn renders_root_and_nested_manpages() {
80 let temp = tempdir().unwrap();
81 let written = render_to_dir(temp.path()).unwrap();
82
83 let root = temp.path().join("tsafe.1");
84
85 assert!(written.contains(&root));
86 assert!(
87 written.iter().any(|path| {
88 path != &root
89 && path.parent() == Some(temp.path())
90 && path
91 .file_name()
92 .and_then(|name| name.to_str())
93 .is_some_and(|name| name.starts_with("tsafe-"))
94 }),
95 "expected at least one nested manpage, got {written:?}"
96 );
97
98 let root_contents = fs::read_to_string(root).unwrap();
99 assert!(root_contents.contains(".TH TSAFE 1"));
100 assert!(root_contents.contains("tsafe Manual"));
101 }
102
103 #[test]
104 fn removes_stale_generated_manpages_before_writing_current_set() {
105 let temp = tempdir().unwrap();
106 let stale = temp.path().join("tsafe-stale-command.1");
107 let unrelated = temp.path().join("README.md");
108 fs::write(&stale, "stale").unwrap();
109 fs::write(&unrelated, "keep me").unwrap();
110
111 let written = render_to_dir(temp.path()).unwrap();
112
113 assert!(
114 !stale.exists(),
115 "stale generated manpage should be removed before writing fresh output"
116 );
117 assert_eq!(fs::read_to_string(unrelated).unwrap(), "keep me");
118 assert!(!written.contains(&stale));
119 }
120}