use std::path::PathBuf;
use anyhow::{ensure, Context};
use clap::{Parser, ValueEnum};
use tsafe_cli::manpages;
const RELEASE_MAN_DIR: &str = "docs/man";
const DEFAULT_CORE_BUILD_PROFILE: &[&str] =
&["agent", "akv-pull", "biometric", "ssh", "team-core", "tui"];
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum ReleaseProfile {
#[value(name = "default-core")]
DefaultCore,
#[value(name = "compiled")]
Compiled,
}
impl ReleaseProfile {
fn as_str(self) -> &'static str {
match self {
Self::DefaultCore => "default-core",
Self::Compiled => "compiled",
}
}
}
#[derive(Debug, Parser)]
#[command(
name = "generate-manpages",
about = "Generate tsafe CLI manpages from the compiled clap tree",
long_about = "Generate tsafe CLI manpages from the compiled clap tree.\n\n`docs/man` is reserved for the frozen default-core release surface. Broader/custom builds must opt into `--release-profile compiled` and write to a separate output directory."
)]
struct Args {
#[arg(long, value_enum, default_value = "default-core")]
release_profile: ReleaseProfile,
#[arg(default_value = RELEASE_MAN_DIR)]
output_dir: PathBuf,
}
fn run(args: Args) -> anyhow::Result<()> {
validate_generation_boundary(&args)?;
let compiled_profile = current_build_profile_label();
let written = manpages::render_to_dir(&args.output_dir).with_context(|| {
format!(
"failed to write man pages into {}",
args.output_dir.display()
)
})?;
println!(
"Generated {} man page(s) for release lane {} from compiled build {} in {}",
written.len(),
args.release_profile.as_str(),
compiled_profile,
args.output_dir.display()
);
for path in written {
println!(" {}", path.display());
}
Ok(())
}
fn main() -> anyhow::Result<()> {
let args = Args::parse();
#[cfg(windows)]
{
let worker = std::thread::Builder::new()
.name("generate-manpages".into())
.stack_size(32 * 1024 * 1024)
.spawn(move || run(args))
.context("failed to start generate-manpages worker")?;
match worker.join() {
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!("generate-manpages worker panicked")),
}
}
#[cfg(not(windows))]
{
run(args)
}
}
fn validate_generation_boundary(args: &Args) -> anyhow::Result<()> {
validate_generation_boundary_for(
current_build_profile_label(),
&args.output_dir,
args.release_profile,
)
}
fn validate_generation_boundary_for(
compiled_profile: &str,
output_dir: &std::path::Path,
release_profile: ReleaseProfile,
) -> anyhow::Result<()> {
if release_profile == ReleaseProfile::Compiled {
ensure!(
!is_release_docs_man_dir(output_dir),
"`{RELEASE_MAN_DIR}` is reserved for the frozen default-core manpage set; use a separate output directory for broader/custom compiled builds",
);
return Ok(());
}
ensure!(
compiled_profile == "default-core",
"default-core manpage generation requires a default-core compiled build, but this binary is `{compiled_profile}`; rebuild with the frozen default feature set or use `--release-profile compiled` with a separate output directory",
);
Ok(())
}
fn is_release_docs_man_dir(path: &std::path::Path) -> bool {
const RELEASE_MAN_COMPONENTS: &[&str] = &["docs", "man"];
let components = path
.components()
.filter_map(|component| component.as_os_str().to_str())
.collect::<Vec<_>>();
components.len() >= RELEASE_MAN_COMPONENTS.len()
&& components[components.len() - RELEASE_MAN_COMPONENTS.len()..] == *RELEASE_MAN_COMPONENTS
}
fn current_build_profile_label() -> &'static str {
let capabilities = compile_time_feature_flags();
build_profile_label(&capabilities)
}
fn build_profile_label(capabilities: &[&'static str]) -> &'static str {
if capabilities.is_empty() {
"enterprise-minimal"
} else if capabilities == DEFAULT_CORE_BUILD_PROFILE {
"default-core"
} else {
"custom"
}
}
fn compile_time_feature_flags() -> Vec<&'static str> {
let mut feature_flags = Vec::new();
if cfg!(feature = "agent") {
feature_flags.push("agent");
}
if cfg!(feature = "akv-pull") {
feature_flags.push("akv-pull");
}
if cfg!(feature = "biometric") {
feature_flags.push("biometric");
}
if cfg!(feature = "team-core") {
feature_flags.push("team-core");
}
if cfg!(feature = "tui") {
feature_flags.push("tui");
}
if cfg!(feature = "cloud-pull-aws") {
feature_flags.push("cloud-pull-aws");
}
if cfg!(feature = "cloud-pull-gcp") {
feature_flags.push("cloud-pull-gcp");
}
if cfg!(feature = "cloud-pull-vault") {
feature_flags.push("cloud-pull-vault");
}
if cfg!(feature = "cloud-pull-1password") {
feature_flags.push("cloud-pull-1password");
}
if cfg!(feature = "multi-pull") {
feature_flags.push("multi-pull");
}
if cfg!(feature = "pm-import-extended") {
feature_flags.push("pm-import-extended");
}
if cfg!(feature = "ots-sharing") {
feature_flags.push("ots-sharing");
}
if cfg!(feature = "git-helpers") {
feature_flags.push("git-helpers");
}
if cfg!(feature = "browser") {
feature_flags.push("browser");
}
if cfg!(feature = "nativehost") {
feature_flags.push("nativehost");
}
if cfg!(feature = "ssh") {
feature_flags.push("ssh");
}
if cfg!(feature = "plugins") {
feature_flags.push("plugins");
}
if cfg!(feature = "otel") {
feature_flags.push("otel");
}
feature_flags.sort_unstable();
feature_flags
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn release_docs_man_dir_detection_matches_relative_and_absolute_paths() {
assert!(is_release_docs_man_dir(Path::new("docs/man")));
assert!(is_release_docs_man_dir(Path::new("./docs/man")));
assert!(is_release_docs_man_dir(Path::new("C:/repo/docs/man")));
assert!(!is_release_docs_man_dir(Path::new("target/man")));
}
#[test]
fn default_core_generation_requires_default_core_build() {
let err = validate_generation_boundary_for(
"custom",
Path::new("target/custom-manpages"),
ReleaseProfile::DefaultCore,
)
.unwrap_err();
assert!(err.to_string().contains("default-core compiled build"));
}
#[test]
fn release_docs_man_dir_rejects_compiled_lane_generation() {
let err = validate_generation_boundary_for(
"custom",
Path::new("docs/man"),
ReleaseProfile::Compiled,
)
.unwrap_err();
assert!(err
.to_string()
.contains("reserved for the frozen default-core"));
}
#[test]
fn broader_compiled_lane_can_write_to_separate_directory() {
validate_generation_boundary_for(
"custom",
Path::new("target/gated-broader-dev-manpages"),
ReleaseProfile::Compiled,
)
.unwrap();
}
}