govctl 0.19.0

Project governance CLI for RFC, ADR, and Work Item management
mod common;

use common::{first_work_id, init_project, run_commands};
use std::fs;

fn assert_success_with(output: &str, marker: &str) {
    assert!(output.contains(marker), "output: {output}");
    assert!(output.contains("exit: 0"), "output: {output}");
}

fn successful_payload(output: &str) -> Result<&str, Box<dyn std::error::Error>> {
    let payload = output
        .split_once('\n')
        .and_then(|(_, body)| body.strip_suffix("exit: 0\n\n"))
        .ok_or_else(|| std::io::Error::other("missing successful command payload"))?;
    Ok(payload.trim_end())
}

fn get_value(output: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
    let payload = successful_payload(output)?;
    Ok(serde_json::from_str(payload)?)
}

#[test]
fn test_show_structured_formats_are_complete_for_every_resource() -> common::TestResult {
    let temp_dir = init_project()?;
    run_commands(
        temp_dir.path(),
        &[
            &["rfc", "new", "Show RFC"],
            &["clause", "new", "RFC-0001:C-SHOW", "Show Clause"],
            &["adr", "new", "Show ADR"],
            &["work", "new", "Show Work"],
            &["guard", "new", "Show Guard"],
        ],
    )?;
    let work_id = first_work_id(&common::today());
    let resources = [
        ("rfc", "RFC-0001", "rfc_id", "sections", "[[sections]]"),
        ("clause", "RFC-0001:C-SHOW", "clause_id", "text", "text ="),
        ("adr", "ADR-0001", "govctl", "content", "[content]"),
        ("work", work_id.as_str(), "govctl", "content", "[content]"),
        ("guard", "GUARD-SHOW-GUARD", "govctl", "check", "[check]"),
    ];

    for (resource, id, identity_key, content_key, toml_content_marker) in resources {
        let get = run_commands(
            temp_dir.path(),
            &[&[resource, "get", id, "--output", "json"]],
        )?;
        let expected = get_value(&get)?;
        let get_yaml = run_commands(
            temp_dir.path(),
            &[&[resource, "get", id, "--output", "yaml"]],
        )?;
        let get_yaml_value: serde_json::Value =
            serde_yaml::from_str(successful_payload(&get_yaml)?)?;
        assert_eq!(get_yaml_value, expected, "resource: {resource}");

        let get_toml = run_commands(
            temp_dir.path(),
            &[&[resource, "get", id, "--output", "toml"]],
        )?;
        let get_toml_value = serde_json::to_value(toml::from_str::<toml::Value>(
            successful_payload(&get_toml)?,
        )?)?;
        assert_eq!(get_toml_value, expected, "resource: {resource}");

        let get_table = run_commands(
            temp_dir.path(),
            &[&[resource, "get", id, "--output", "table"]],
        )?;
        assert_success_with(&get_table, identity_key);

        let json = run_commands(
            temp_dir.path(),
            &[&[resource, "show", id, "--output", "json"]],
        )?;
        assert_success_with(&json, &format!("\"{identity_key}\""));
        assert!(json.contains(&format!("\"{content_key}\"")), "{json}");
        let json_value: serde_json::Value = serde_json::from_str(successful_payload(&json)?)?;
        assert_eq!(json_value, expected, "resource: {resource}");

        let yaml = run_commands(
            temp_dir.path(),
            &[&[resource, "show", id, "--output", "yaml"]],
        )?;
        assert_success_with(&yaml, &format!("{identity_key}:"));
        assert!(yaml.contains(&format!("{content_key}:")), "{yaml}");
        let yaml_value: serde_json::Value = serde_yaml::from_str(successful_payload(&yaml)?)?;
        assert_eq!(yaml_value, expected, "resource: {resource}");

        let toml = run_commands(
            temp_dir.path(),
            &[&[resource, "show", id, "--output", "toml"]],
        )?;
        let toml_identity_marker = if identity_key == "govctl" {
            "[govctl]".to_string()
        } else {
            format!("{identity_key} =")
        };
        assert_success_with(&toml, &toml_identity_marker);
        assert!(toml.contains(toml_content_marker), "{toml}");
        let toml_value =
            serde_json::to_value(toml::from_str::<toml::Value>(successful_payload(&toml)?)?)?;
        assert_eq!(toml_value, expected, "resource: {resource}");

        for format in ["json", "yaml", "toml"] {
            let conflict = run_commands(
                temp_dir.path(),
                &[&[resource, "show", id, "--history", "--output", format]],
            )?;
            assert!(conflict.contains("error[E0802]"), "output: {conflict}");
            assert!(conflict.contains("exit: 1"), "output: {conflict}");
        }
    }
    Ok(())
}

#[test]
fn test_rfc_show_filters_obsolete_content_but_render_keeps_history() -> common::TestResult {
    let temp_dir = init_project()?;
    run_commands(
        temp_dir.path(),
        &[
            &["rfc", "new", "Projection RFC"],
            &["clause", "new", "RFC-0001:C-ACTIVE", "Active Clause"],
            &[
                "clause",
                "edit",
                "RFC-0001:C-ACTIVE",
                "text",
                "--set",
                "ACTIVE BODY",
            ],
            &["clause", "new", "RFC-0001:C-OLD", "Old Clause"],
            &[
                "clause",
                "edit",
                "RFC-0001:C-OLD",
                "text",
                "--set",
                "OBSOLETE BODY",
            ],
            &["clause", "new", "RFC-0001:C-NEW", "Replacement Clause"],
            &[
                "clause",
                "supersede",
                "RFC-0001:C-OLD",
                "--by",
                "RFC-0001:C-NEW",
                "--force",
            ],
            &["tag", "new", "projection"],
            &["rfc", "edit", "RFC-0001", "tags", "--add", "projection"],
        ],
    )?;

    let current = run_commands(temp_dir.path(), &[&["rfc", "show", "RFC-0001"]])?;
    assert!(current.contains("ACTIVE BODY"), "output: {current}");
    assert!(
        current.contains("**Status:** superseded"),
        "output: {current}"
    );
    assert!(!current.contains("OBSOLETE BODY"), "output: {current}");

    let history = run_commands(
        temp_dir.path(),
        &[&["rfc", "show", "RFC-0001", "--history"]],
    )?;
    assert!(history.contains("ACTIVE BODY"), "output: {history}");
    assert!(history.contains("OBSOLETE BODY"), "output: {history}");

    run_commands(
        temp_dir.path(),
        &[
            &["rfc", "finalize", "RFC-0001", "normative"],
            &["rfc", "new", "Replacement RFC"],
            &[
                "rfc",
                "supersede",
                "RFC-0001",
                "--by",
                "RFC-0002",
                "--force",
            ],
        ],
    )?;
    let deprecated = run_commands(temp_dir.path(), &[&["rfc", "show", "RFC-0001"]])?;
    assert!(deprecated.contains("**Status:** deprecated"));
    assert!(deprecated.contains("**Owners:** @test-user"));
    assert!(deprecated.contains("**Tags:** `projection`"));
    assert!(deprecated.contains("**Superseded by:** RFC-0002"));
    assert!(!deprecated.contains("## 1."), "output: {deprecated}");
    assert!(!deprecated.contains("ACTIVE BODY"), "output: {deprecated}");

    let replacement_history = run_commands(
        temp_dir.path(),
        &[&["rfc", "show", "RFC-0002", "--history"]],
    )?;
    assert!(
        replacement_history.contains("**Supersedes:** RFC-0001"),
        "output: {replacement_history}"
    );

    run_commands(temp_dir.path(), &[&["rfc", "render", "RFC-0001"]])?;
    let rendered = fs::read_to_string(temp_dir.path().join("docs/rfc/RFC-0001.md"))?;
    assert!(rendered.contains("**Owners:** @test-user"));
    assert!(rendered.contains("**Tags:** `projection`"));
    assert!(rendered.contains("ACTIVE BODY"), "rendered: {rendered}");
    assert!(rendered.contains("OBSOLETE BODY"), "rendered: {rendered}");
    Ok(())
}

#[test]
fn test_content_equivalent_resources_accept_explicit_human_projection_matrix() -> common::TestResult
{
    let temp_dir = init_project()?;
    run_commands(
        temp_dir.path(),
        &[
            &["work", "new", "Projection Work"],
            &["guard", "new", "Projection Guard"],
        ],
    )?;
    let work_id = first_work_id(&common::today());

    for format in ["table", "plain"] {
        let work_current = run_commands(
            temp_dir.path(),
            &[&["work", "show", work_id.as_str(), "--output", format]],
        )?;
        let work_history = run_commands(
            temp_dir.path(),
            &[&[
                "work",
                "show",
                work_id.as_str(),
                "--output",
                format,
                "--history",
            ]],
        )?;
        assert_success_with(&work_current, "Projection Work");
        assert_eq!(
            successful_payload(&work_current)?,
            successful_payload(&work_history)?,
            "work format: {format}"
        );

        let guard_current = run_commands(
            temp_dir.path(),
            &[&[
                "guard",
                "show",
                "GUARD-PROJECTION-GUARD",
                "--output",
                format,
            ]],
        )?;
        let guard_history = run_commands(
            temp_dir.path(),
            &[&[
                "guard",
                "show",
                "GUARD-PROJECTION-GUARD",
                "--output",
                format,
                "--history",
            ]],
        )?;
        assert_success_with(&guard_current, "Projection Guard");
        assert_eq!(
            successful_payload(&guard_current)?,
            successful_payload(&guard_history)?,
            "guard format: {format}"
        );
    }
    Ok(())
}

#[test]
fn test_superseded_adr_show_requires_history_for_body_content() -> common::TestResult {
    let temp_dir = init_project()?;
    run_commands(
        temp_dir.path(),
        &[
            &["adr", "new", "Old Decision"],
            &[
                "adr",
                "edit",
                "ADR-0001",
                "context",
                "--set",
                "OBSOLETE ADR CONTEXT",
            ],
            &[
                "adr",
                "edit",
                "ADR-0001",
                "decision",
                "--set",
                "Old decision",
            ],
            &[
                "adr",
                "edit",
                "ADR-0001",
                "consequences",
                "--set",
                "Old consequences",
            ],
            &["adr", "accept", "ADR-0001", "--force"],
            &["adr", "new", "New Decision"],
            &["adr", "edit", "ADR-0002", "context", "--set", "New context"],
            &[
                "adr",
                "edit",
                "ADR-0002",
                "decision",
                "--set",
                "New decision",
            ],
            &[
                "adr",
                "edit",
                "ADR-0002",
                "consequences",
                "--set",
                "New consequences",
            ],
            &["adr", "accept", "ADR-0002", "--force"],
            &[
                "adr",
                "supersede",
                "ADR-0001",
                "--by",
                "ADR-0002",
                "--force",
            ],
        ],
    )?;

    let current = run_commands(temp_dir.path(), &[&["adr", "show", "ADR-0001"]])?;
    assert!(
        current.contains("**Status:** superseded"),
        "output: {current}"
    );
    assert!(current.contains("**Superseded by:** ADR-0002"));
    assert!(!current.contains("OBSOLETE ADR CONTEXT"));

    let history = run_commands(
        temp_dir.path(),
        &[&["adr", "show", "ADR-0001", "--history"]],
    )?;
    assert!(
        history.contains("OBSOLETE ADR CONTEXT"),
        "output: {history}"
    );
    Ok(())
}