govctl 0.8.2

Project governance CLI for RFC, ADR, and Work Item management
//! Common test helpers for CLI snapshot tests.

#![allow(dead_code)] // Functions used across different test binaries

use std::path::Path;
use std::process::Command;
use tempfile::TempDir;

/// Get today's date in YYYY-MM-DD format (same as govctl uses)
pub fn today() -> String {
    chrono::Local::now().format("%Y-%m-%d").to_string()
}

/// Normalize output for stable snapshots:
/// - Replace temp directory paths with `<TEMPDIR>`
/// - Replace today's date with `<DATE>`
/// - Replace work item IDs (WI-YYYY-MM-DD-NNN) with WI-<DATE>-NNN
/// - Replace ADR IDs with date component normalized
pub fn normalize_output(output: &str, dir: &Path, date: &str) -> String {
    let canonical = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
    let canonical_str = canonical.display().to_string();
    let dir_str = dir.display().to_string();
    let mut normalized = output.replace(&canonical_str, "<TEMPDIR>");
    normalized = normalized.replace(&dir_str, "<TEMPDIR>");
    normalized = normalized.replace(date, "<DATE>");

    // Replace work item IDs
    let wi_pattern = regex::Regex::new(r"WI-\d{4}-\d{2}-\d{2}-(\d{3})").unwrap();
    normalized = wi_pattern
        .replace_all(&normalized, "WI-<DATE>-$1")
        .to_string();

    // Replace ADR filenames with dates
    let adr_file_pattern = regex::Regex::new(r"ADR-(\d{4})-").unwrap();
    normalized = adr_file_pattern
        .replace_all(&normalized, "ADR-XXXX-")
        .to_string();

    // Replace signature hashes (date-dependent due to embedded dates in specs)
    let sig_pattern = regex::Regex::new(r"sha256:[0-9a-f]{64}").unwrap();
    normalized = sig_pattern
        .replace_all(&normalized, "sha256:<HASH>")
        .to_string();

    // Replace govctl version in JSON contexts to avoid snapshot churn on version bumps.
    // Only replace inside double quotes to avoid corrupting semver strings in CHANGELOG fixtures.
    let version = env!("CARGO_PKG_VERSION");
    normalized = normalized.replace(&format!("\"{version}\""), "\"<VERSION>\"");

    normalized
}

/// Run govctl commands in a directory and capture output.
pub fn run_commands(dir: &Path, commands: &[&[&str]]) -> String {
    let mut output = String::new();

    for args in commands {
        output.push_str(&format!("$ govctl {}\n", args.join(" ")));

        let result = Command::new(env!("CARGO_BIN_EXE_govctl"))
            .args(*args)
            .current_dir(dir)
            .env("NO_COLOR", "1")
            .env("GOVCTL_DEFAULT_OWNER", "@test-user")
            .output()
            .expect("failed to run govctl");

        let stdout = String::from_utf8_lossy(&result.stdout);
        let stderr = String::from_utf8_lossy(&result.stderr);

        if !stdout.is_empty() {
            output.push_str(&stdout);
            if !stdout.ends_with('\n') {
                output.push('\n');
            }
        }
        if !stderr.is_empty() {
            output.push_str(&stderr);
            if !stderr.ends_with('\n') {
                output.push('\n');
            }
        }

        output.push_str(&format!("exit: {}\n\n", result.status.code().unwrap_or(-1)));
    }

    output
}

/// Run commands with dynamic String arguments (for work item IDs with dates)
pub fn run_dynamic_commands(dir: &Path, commands: &[Vec<String>]) -> String {
    let mut output = String::new();

    for args in commands {
        let args_str: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
        output.push_str(&format!("$ govctl {}\n", args_str.join(" ")));

        let result = Command::new(env!("CARGO_BIN_EXE_govctl"))
            .args(&args_str)
            .current_dir(dir)
            .env("NO_COLOR", "1")
            .env("GOVCTL_DEFAULT_OWNER", "@test-user")
            .output()
            .expect("failed to run govctl");

        let stdout = String::from_utf8_lossy(&result.stdout);
        let stderr = String::from_utf8_lossy(&result.stderr);

        if !stdout.is_empty() {
            output.push_str(&stdout);
            if !stdout.ends_with('\n') {
                output.push('\n');
            }
        }
        if !stderr.is_empty() {
            output.push_str(&stderr);
            if !stderr.ends_with('\n') {
                output.push('\n');
            }
        }

        output.push_str(&format!("exit: {}\n\n", result.status.code().unwrap_or(-1)));
    }

    output
}

/// Initialize a govctl project in a temp directory.
///
/// If `schema_version` is provided, overrides the config schema version
/// (used by migration tests to simulate older repositories).
pub fn init_project_at(schema_version: Option<u32>) -> TempDir {
    let temp_dir = TempDir::new().expect("failed to create temp dir");
    let mut cmd = Command::new(env!("CARGO_BIN_EXE_govctl"));
    cmd.args(["init"])
        .current_dir(temp_dir.path())
        .env("NO_COLOR", "1")
        .env("GOVCTL_DEFAULT_OWNER", "@test-user");

    if let Some(v) = schema_version {
        cmd.env("GOVCTL_SCHEMA_VERSION", v.to_string());
    }

    let result = cmd.output().expect("failed to run govctl init");
    assert!(result.status.success(), "govctl init failed");
    temp_dir
}

pub fn init_project() -> TempDir {
    init_project_at(None)
}

pub fn init_project_v1() -> TempDir {
    init_project_at(Some(1))
}