Skip to main content

tsafe_cli/
manpages.rs

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}