govctl 0.15.0

Project governance CLI for RFC, ADR, and Work Item management
//! Tests for `govctl init` command behavior.

mod common;

use common::run_commands;
use std::fs;
use tempfile::TempDir;

#[test]
fn test_init_creates_gitignore() -> common::TestResult {
    let temp_dir = TempDir::new()?;

    let output = run_commands(temp_dir.path(), &[&["init"]])?;
    assert!(output.contains("Project initialized"));

    let gitignore_path = temp_dir.path().join(".gitignore");
    assert!(gitignore_path.exists(), ".gitignore should be created");

    let content = fs::read_to_string(&gitignore_path)?;
    assert!(
        content.contains(".govctl.lock"),
        ".gitignore should contain .govctl.lock"
    );
    assert!(
        content.contains(".govctl/"),
        ".gitignore should contain .govctl/"
    );
    Ok(())
}

#[test]
fn test_init_recovers_partial_directories_without_governance_artifacts() -> common::TestResult {
    let temp_dir = TempDir::new()?;
    fs::create_dir_all(temp_dir.path().join("gov/rfc"))?;
    fs::create_dir_all(temp_dir.path().join("gov/adr"))?;
    fs::create_dir_all(temp_dir.path().join("gov/schema"))?;
    fs::write(temp_dir.path().join("gov/.DS_Store"), "metadata")?;
    fs::write(
        temp_dir.path().join("gov/schema/rfc.schema.json"),
        "partial generated state",
    )?;

    let output = run_commands(temp_dir.path(), &[&["init", "--force"]])?;

    assert!(output.contains("Project initialized"), "{output}");
    assert!(temp_dir.path().join("gov/config.toml").exists());
    Ok(())
}

#[test]
fn test_init_in_nested_directory_does_not_select_ancestor_project() -> common::TestResult {
    let temp_dir = TempDir::new()?;
    run_commands(temp_dir.path(), &[&["init"]])?;
    let outer_config = temp_dir.path().join("gov/config.toml");
    let outer_before = fs::read(&outer_config)?;
    let inner = temp_dir.path().join("inner");
    fs::create_dir(&inner)?;

    let output = run_commands(&inner, &[&["init"]])?;

    assert!(output.contains("Project initialized"), "{output}");
    assert!(inner.join("gov/config.toml").exists());
    assert_eq!(fs::read(&outer_config)?, outer_before);
    Ok(())
}

#[test]
fn test_init_force_recovers_nested_partial_project_without_rewriting_ancestor() -> common::TestResult
{
    let temp_dir = TempDir::new()?;
    run_commands(temp_dir.path(), &[&["init"]])?;
    let outer_config = temp_dir.path().join("gov/config.toml");
    let outer_before = fs::read(&outer_config)?;
    let inner = temp_dir.path().join("inner");
    fs::create_dir_all(inner.join("gov/rfc"))?;
    fs::create_dir_all(inner.join("gov/schema"))?;

    let output = run_commands(&inner, &[&["init", "--force"]])?;

    assert!(output.contains("Project initialized"), "{output}");
    assert!(inner.join("gov/config.toml").exists());
    assert_eq!(fs::read(&outer_config)?, outer_before);
    Ok(())
}

#[test]
fn test_init_force_dry_run_in_nested_directory_does_not_mutate_either_project() -> common::TestResult
{
    let temp_dir = TempDir::new()?;
    run_commands(temp_dir.path(), &[&["init"]])?;
    let outer_config = temp_dir.path().join("gov/config.toml");
    let outer_before = fs::read(&outer_config)?;
    let inner = temp_dir.path().join("inner");
    fs::create_dir(&inner)?;

    run_commands(&inner, &[&["--dry-run", "init", "--force"]])?;

    assert!(!inner.join("gov").exists());
    assert_eq!(fs::read(&outer_config)?, outer_before);
    Ok(())
}

#[test]
fn test_init_force_rejects_unsupported_existing_schema_without_mutation() -> common::TestResult {
    let temp_dir = TempDir::new()?;
    run_commands(temp_dir.path(), &[&["init"]])?;
    let config_path = temp_dir.path().join("gov/config.toml");
    let mut config: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?;
    config["schema"]["version"] = toml::Value::Integer(2);
    fs::write(&config_path, toml::to_string_pretty(&config)?)?;
    let before = fs::read(&config_path)?;

    let output = run_commands(temp_dir.path(), &[&["init", "--force"]])?;

    assert!(output.contains("error[E0505]"), "{output}");
    assert!(
        output.contains("schema version 2 is unsupported"),
        "{output}"
    );
    assert_eq!(fs::read(&config_path)?, before);
    Ok(())
}

#[test]
fn test_init_dry_run_rejects_existing_project_without_force() -> common::TestResult {
    let temp_dir = TempDir::new()?;
    run_commands(temp_dir.path(), &[&["init"]])?;
    let config_path = temp_dir.path().join("gov/config.toml");
    let before = fs::read(&config_path)?;

    let output = run_commands(temp_dir.path(), &[&["init", "--dry-run"]])?;

    assert!(output.contains("exit: 1"), "{output}");
    assert!(output.contains("already exists"), "{output}");
    assert!(!output.contains("Would write"), "{output}");
    assert_eq!(fs::read(&config_path)?, before);
    Ok(())
}

#[cfg(unix)]
#[test]
fn test_init_allows_symlinked_default_governance_root() -> common::TestResult {
    use std::os::unix::fs::symlink;

    let temp_dir = TempDir::new()?;
    let project_dir = temp_dir.path().join("project");
    let external_gov = temp_dir.path().join("external-gov");
    fs::create_dir(&project_dir)?;
    fs::create_dir(&external_gov)?;
    symlink(&external_gov, project_dir.join("gov"))?;

    let output = run_commands(
        &project_dir,
        &[&["init"], &["rfc", "new", "Symlinked storage"], &["check"]],
    )?;

    assert!(output.contains("Project initialized"), "{output}");
    assert!(output.contains("Created RFC"), "{output}");
    assert!(output.contains("All checks passed"), "{output}");
    assert!(external_gov.join("config.toml").exists());
    assert!(external_gov.join("rfc/RFC-0001/rfc.toml").exists());
    Ok(())
}

#[test]
fn test_init_config_reserves_default_guards_for_universal_checks() -> common::TestResult {
    let temp_dir = TempDir::new()?;

    run_commands(temp_dir.path(), &[&["init"]])?;

    let content = fs::read_to_string(temp_dir.path().join("gov/config.toml"))?;
    assert!(
        content.contains("# default_guards = [\"GUARD-GOVCTL-CHECK\"]"),
        "config should show a narrow universal default: {content}"
    );
    assert!(
        !content.contains("# default_guards = [\"GUARD-GOVCTL-CHECK\", \"GUARD-CARGO-TEST\"]"),
        "config should not present the full test suite as a project default: {content}"
    );
    assert!(
        content.contains("verification.required_guards"),
        "config should direct scoped checks to work items: {content}"
    );
    Ok(())
}

#[test]
fn test_init_creates_artifact_schema_files() -> common::TestResult {
    let temp_dir = TempDir::new()?;

    let output = run_commands(temp_dir.path(), &[&["init"]])?;
    assert!(output.contains("Project initialized"));

    for filename in [
        "rfc.schema.json",
        "clause.schema.json",
        "adr.schema.json",
        "work.schema.json",
        "release.schema.json",
        "guard.schema.json",
        "loop-state.schema.json",
        "loop-round.schema.json",
    ] {
        let schema_path = temp_dir.path().join("gov/schema").join(filename);
        assert!(
            schema_path.exists(),
            "schema file should exist: {}",
            filename
        );
        let schema_text = fs::read_to_string(&schema_path)?;
        let schema_value: serde_json::Value = serde_json::from_str(&schema_text)?;
        jsonschema::validator_for(&schema_value)?;
        if filename == "work.schema.json" {
            assert!(
                !schema_text.contains("journal"),
                "work schema should not advertise legacy inline execution history"
            );
        }
    }

    assert!(
        temp_dir.path().join("gov/guard").exists(),
        "guard directory should exist after init"
    );
    Ok(())
}

#[test]
fn test_init_appends_to_existing_gitignore() -> common::TestResult {
    let temp_dir = TempDir::new()?;

    let gitignore_path = temp_dir.path().join(".gitignore");
    fs::write(&gitignore_path, "# Existing content\ntarget/\n")?;

    let output = run_commands(temp_dir.path(), &[&["init"]])?;
    assert!(output.contains("Project initialized"));

    let content = fs::read_to_string(&gitignore_path)?;
    assert!(
        content.contains("target/"),
        ".gitignore should retain existing content"
    );
    assert!(
        content.contains(".govctl.lock"),
        ".gitignore should have .govctl.lock appended"
    );
    assert!(
        content.contains(".govctl/"),
        ".gitignore should have .govctl/ appended"
    );
    Ok(())
}

#[test]
fn test_init_appends_to_existing_gitignore_without_trailing_newline() -> common::TestResult {
    let temp_dir = TempDir::new()?;

    let gitignore_path = temp_dir.path().join(".gitignore");
    fs::write(&gitignore_path, "target/")?;

    let output = run_commands(temp_dir.path(), &[&["init"]])?;
    assert!(output.contains("Project initialized"));

    let content = fs::read_to_string(&gitignore_path)?;
    assert_eq!(content, "target/\n.govctl.lock\n.govctl/\n");
    Ok(())
}

#[test]
fn test_init_reports_gitignore_read_error() -> common::TestResult {
    let temp_dir = TempDir::new()?;

    fs::create_dir(temp_dir.path().join(".gitignore"))?;

    let output = run_commands(temp_dir.path(), &[&["init"]])?;
    assert!(output.contains("error"), "output: {}", output);
    assert!(output.contains("read .gitignore"), "output: {}", output);
    assert!(output.contains(".gitignore"), "output: {}", output);
    assert!(output.contains("exit: 1"), "output: {}", output);
    Ok(())
}

#[test]
fn test_init_no_duplicate_gitignore_entry() -> common::TestResult {
    let temp_dir = TempDir::new()?;

    let gitignore_path = temp_dir.path().join(".gitignore");
    fs::write(&gitignore_path, ".govctl.lock\n.govctl/\n")?;

    let output = run_commands(temp_dir.path(), &[&["init"]])?;
    assert!(output.contains("Project initialized"));

    let content = fs::read_to_string(&gitignore_path)?;
    let lock_count = content.matches(".govctl.lock").count();
    assert_eq!(
        lock_count, 1,
        ".gitignore should not have duplicate .govctl.lock entries"
    );
    let state_count = content.matches(".govctl/").count();
    assert_eq!(
        state_count, 1,
        ".gitignore should not have duplicate .govctl/ entries"
    );
    Ok(())
}

#[test]
fn test_init_custom_docs_output() -> common::TestResult {
    let temp_dir = TempDir::new()?;

    run_commands(temp_dir.path(), &[&["init"]])?;

    let config_path = temp_dir.path().join("gov/config.toml");
    let mut config: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?;
    config["paths"]["docs_output"] = toml::Value::String("documentation".to_string());
    fs::write(&config_path, toml::to_string_pretty(&config)?)?;

    let output = run_commands(temp_dir.path(), &[&["rfc", "new", "Test RFC"]])?;
    assert!(output.contains("Created RFC"));

    run_commands(temp_dir.path(), &[&["rfc", "render", "RFC-0001"]])?;

    let docs_dir = temp_dir.path().join("documentation/rfc");
    assert!(docs_dir.exists(), "docs should be under documentation/rfc/");
    Ok(())
}

#[test]
fn test_init_custom_paths_combined() -> common::TestResult {
    let temp_dir = TempDir::new()?;

    run_commands(temp_dir.path(), &[&["init"]])?;

    let config_path = temp_dir.path().join("gov/config.toml");
    let mut config: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?;
    config["paths"]["docs_output"] = toml::Value::String("output/docs".to_string());
    fs::write(&config_path, toml::to_string_pretty(&config)?)?;

    let output = run_commands(temp_dir.path(), &[&["adr", "new", "Test ADR"]])?;
    assert!(output.contains("Created ADR"), "output: {}", output);

    let adr_dir = temp_dir.path().join("gov/adr");
    assert!(
        adr_dir.exists() && adr_dir.read_dir()?.count() > 0,
        "ADR should be under gov/adr/"
    );

    let _output = run_commands(temp_dir.path(), &[&["adr", "render", "ADR-0001"]])?;
    let docs_dir = temp_dir.path().join("output/docs/adr");
    assert!(docs_dir.exists(), "docs should be under output/docs/adr/");
    Ok(())
}