tsafe-cli 1.0.20

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use clap::CommandFactory;

use crate::cli::Cli;

const MANUAL: &str = "tsafe Manual";

pub fn render_to_dir(output_dir: &Path) -> io::Result<Vec<PathBuf>> {
    fs::create_dir_all(output_dir)?;
    remove_stale_generated_pages(output_dir)?;

    let root = Cli::command();
    let mut written = Vec::new();
    render_command(&root, output_dir, "tsafe", &mut written)?;
    written.sort();
    Ok(written)
}

fn remove_stale_generated_pages(output_dir: &Path) -> io::Result<()> {
    for entry in fs::read_dir(output_dir)? {
        let entry = entry?;
        let path = entry.path();
        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
            continue;
        };

        if name.starts_with("tsafe") && name.ends_with(".1") {
            fs::remove_file(path)?;
        }
    }

    Ok(())
}

fn render_command(
    cmd: &clap::Command,
    output_dir: &Path,
    page_name: &str,
    written: &mut Vec<PathBuf>,
) -> io::Result<()> {
    let mut rendered = Vec::new();
    clap_mangen::Man::new(cmd.clone())
        .title(page_name.to_ascii_uppercase())
        .section("1")
        .manual(MANUAL)
        .source(format!("tsafe {}", env!("CARGO_PKG_VERSION")))
        .render(&mut rendered)?;

    let output_path = output_dir.join(format!("{page_name}.1"));
    let text = String::from_utf8(rendered)
        .map(|text| {
            text.lines()
                .map(str::trim_end)
                .collect::<Vec<_>>()
                .join("\n")
                + "\n"
        })
        .unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned());
    fs::write(&output_path, text)?;
    written.push(output_path);

    for subcommand in cmd.get_subcommands() {
        let child_name = format!("{page_name}-{}", subcommand.get_name());
        render_command(subcommand, output_dir, &child_name, written)?;
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn renders_root_and_nested_manpages() {
        let temp = tempdir().unwrap();
        let written = render_to_dir(temp.path()).unwrap();

        let root = temp.path().join("tsafe.1");

        assert!(written.contains(&root));
        assert!(
            written.iter().any(|path| {
                path != &root
                    && path.parent() == Some(temp.path())
                    && path
                        .file_name()
                        .and_then(|name| name.to_str())
                        .is_some_and(|name| name.starts_with("tsafe-"))
            }),
            "expected at least one nested manpage, got {written:?}"
        );

        let root_contents = fs::read_to_string(root).unwrap();
        assert!(root_contents.contains(".TH TSAFE 1"));
        assert!(root_contents.contains("tsafe Manual"));
    }

    #[test]
    fn removes_stale_generated_manpages_before_writing_current_set() {
        let temp = tempdir().unwrap();
        let stale = temp.path().join("tsafe-stale-command.1");
        let unrelated = temp.path().join("README.md");
        fs::write(&stale, "stale").unwrap();
        fs::write(&unrelated, "keep me").unwrap();

        let written = render_to_dir(temp.path()).unwrap();

        assert!(
            !stale.exists(),
            "stale generated manpage should be removed before writing fresh output"
        );
        assert_eq!(fs::read_to_string(unrelated).unwrap(), "keep me");
        assert!(!written.contains(&stale));
    }
}