govctl 0.9.3

Project governance CLI for RFC, ADR, and Work Item management
use super::*;
use crate::config::{Config, PathsConfig};
use crate::diagnostic::Diagnostic;
use crate::write::WriteOp;
use std::collections::BTreeMap;

type TestResult = Result<(), Box<dyn std::error::Error>>;

fn test_config(root: &std::path::Path) -> Config {
    Config {
        gov_root: root.join("gov"),
        paths: PathsConfig {
            docs_output: root.join("docs"),
            agent_dir: root.join(".claude"),
        },
        ..Default::default()
    }
}

fn deps(entries: &[(&str, &[&str])]) -> BTreeMap<String, Vec<String>> {
    entries
        .iter()
        .map(|(id, deps)| {
            (
                (*id).to_string(),
                deps.iter().map(|dep| (*dep).to_string()).collect(),
            )
        })
        .collect()
}

fn assert_err_contains<T>(
    result: Result<T, Diagnostic>,
    needle: &str,
    context: &str,
) -> TestResult {
    let Err(err) = result else {
        return Err(format!("{context}: expected error containing '{needle}'").into());
    };
    if !err.to_string().contains(needle) {
        return Err(format!("error should contain '{needle}', got: {err}").into());
    }
    Ok(())
}

#[test]
fn test_loop_state_round_trips_state_toml() -> TestResult {
    let temp_dir = tempfile::TempDir::new()?;
    let config = test_config(temp_dir.path());
    let root = "WI-2026-05-31-001";
    let dependency = "WI-2026-05-31-002";

    let state = LoopState::new(
        "LOOP-2026-05-31-001",
        vec![root.to_string()],
        vec![root.to_string(), dependency.to_string()],
        deps(&[(root, &[dependency]), (dependency, &[])]),
    )?;

    write_loop_state_with_op(&config, &state, WriteOp::Execute)?;

    let state_path = temp_dir
        .path()
        .join(".govctl/loops/LOOP-2026-05-31-001/state.toml");
    assert!(state_path.exists(), "state path: {}", state_path.display());
    let state_toml = std::fs::read_to_string(&state_path)?;
    assert!(state_toml.contains("work = ["));
    assert!(state_toml.contains("resolved = ["));
    assert!(!state_toml.contains("root_work_items"));
    assert!(!state_toml.contains("work_items"));
    assert!(
        !temp_dir
            .path()
            .join("gov/.govctl/loops/LOOP-2026-05-31-001/state.toml")
            .exists(),
        "loop state must be outside governed artifacts"
    );

    let loaded = load_loop_state(&config, "LOOP-2026-05-31-001")?;
    assert_eq!(loaded, state);
    assert_eq!(loaded.loop_meta.state, LoopLifecycleState::Pending);
    assert_eq!(loaded.items[root].status, LoopWorkItemStatus::Pending);
    assert_eq!(loaded.items[root].round_count, 0);
    Ok(())
}

#[test]
fn test_loop_state_rejects_legacy_state_keys() -> TestResult {
    let temp_dir = tempfile::TempDir::new()?;
    let config = test_config(temp_dir.path());
    let loop_id = "LOOP-2026-05-31-006";
    let work_id = "WI-2026-05-31-001";
    let state_dir = temp_dir.path().join(format!(".govctl/loops/{loop_id}"));
    std::fs::create_dir_all(&state_dir)?;
    std::fs::write(
        state_dir.join("state.toml"),
        format!(
            r#"[loop]
id = "{loop_id}"
state = "pending"
work = ["{work_id}"]
resolved = ["{work_id}"]
root_work_items = ["{work_id}"]

[dependencies]
"{work_id}" = []

[items."{work_id}"]
status = "pending"
round_count = 0
"#
        ),
    )?;

    assert_err_contains(
        load_loop_state(&config, loop_id),
        "unknown field `root_work_items`",
        "legacy explicit work key must be rejected",
    )?;
    Ok(())
}

#[test]
fn test_loop_state_updates_lifecycle_item_status_and_round_count() -> TestResult {
    let temp_dir = tempfile::TempDir::new()?;
    let config = test_config(temp_dir.path());
    let work_id = "WI-2026-05-31-001";
    let mut state = LoopState::new(
        "LOOP-2026-05-31-002",
        vec![work_id.to_string()],
        vec![work_id.to_string()],
        deps(&[(work_id, &[])]),
    )?;

    state.transition_to(LoopLifecycleState::Active)?;
    state.set_item_status(work_id, LoopWorkItemStatus::Active)?;
    assert_eq!(state.increment_round_count(work_id)?, 1);
    write_loop_state_with_op(&config, &state, WriteOp::Execute)?;

    let loaded = load_loop_state(&config, "LOOP-2026-05-31-002")?;
    assert_eq!(loaded.loop_meta.state, LoopLifecycleState::Active);
    assert_eq!(loaded.items[work_id].status, LoopWorkItemStatus::Active);
    assert_eq!(loaded.items[work_id].round_count, 1);
    Ok(())
}

#[test]
fn test_loop_state_rejects_invalid_lifecycle_transition() -> TestResult {
    let work_id = "WI-2026-05-31-001";
    let mut state = LoopState::new(
        "LOOP-2026-05-31-003",
        vec![work_id.to_string()],
        vec![work_id.to_string()],
        deps(&[(work_id, &[])]),
    )?;

    let err = state.transition_to(LoopLifecycleState::Completed);
    assert_err_contains(
        err,
        "Invalid loop transition",
        "pending -> completed must be rejected",
    )?;

    state.transition_to(LoopLifecycleState::Active)?;
    state.transition_to(LoopLifecycleState::Completed)?;
    let terminal_err = state.transition_to(LoopLifecycleState::Completed);
    assert_err_contains(
        terminal_err,
        "Invalid loop transition",
        "completed -> completed must be rejected",
    )?;
    Ok(())
}

#[test]
fn test_loop_state_rejects_invalid_ids_and_contract_violations() -> TestResult {
    let work_id = "WI-2026-05-31-001";

    validate_loop_id("LOOP-2026-05-31-001")?;

    assert_err_contains(
        validate_loop_id("loop-plain-text"),
        "LOOP-YYYY-MM-DD-NNN",
        "plain-text loop IDs must be rejected",
    )?;

    assert_err_contains(
        LoopState::new(
            "../bad",
            vec![work_id.to_string()],
            vec![work_id.to_string()],
            deps(&[(work_id, &[])]),
        ),
        "Invalid loop ID",
        "path traversal loop IDs must be rejected",
    )?;

    assert_err_contains(
        LoopState::new(
            "LOOP-2026-05-31-004",
            vec![work_id.to_string()],
            vec![work_id.to_string()],
            BTreeMap::new(),
        ),
        "missing dependency entry",
        "each work item must have a dependency entry",
    )?;

    assert_err_contains(
        LoopState::new(
            "LOOP-2026-05-31-005",
            vec![work_id.to_string()],
            vec![work_id.to_string(), work_id.to_string()],
            deps(&[(work_id, &[])]),
        ),
        "duplicate",
        "duplicate work item IDs must be rejected",
    )?;

    Ok(())
}