govctl 0.19.0

Project governance CLI for RFC, ADR, and Work Item management
use super::*;

const ACCEPTANCE_CRITERIA: &str = "acceptance_criteria";

fn work_edit_tick_acceptance_criteria_index(id: &str, index: usize, status: &str) -> Vec<String> {
    let field = format!("{ACCEPTANCE_CRITERIA}[{index}]");
    command(&["work", "edit", id, &field, "--tick", status])
}

fn read_first_criterion(
    project: &std::path::Path,
) -> Result<toml::value::Table, Box<dyn std::error::Error>> {
    let work_path = std::fs::read_dir(project.join("gov/work"))?
        .next()
        .ok_or("work item file missing")??
        .path();
    let work: toml::Value = toml::from_str(&std::fs::read_to_string(work_path)?)?;
    work.get("content")
        .and_then(|value| value.get("acceptance_criteria"))
        .and_then(toml::Value::as_array)
        .and_then(|criteria| criteria.first())
        .and_then(toml::Value::as_table)
        .cloned()
        .ok_or_else(|| "first acceptance criterion missing".into())
}

#[test]
fn test_work_add_acceptance_criteria() -> common::TestResult {
    let (temp_dir, date) = init_project_with_date()?;
    let id = first_work_id(&date);

    let output = common::run_dynamic_commands(
        temp_dir.path(),
        &[
            work_new("Test Task"),
            work_add_acceptance(&id, "add: Criterion 1"),
            work_add_acceptance(&id, "add: Criterion 2"),
            work_show(&id),
        ],
    )?;
    assert_edit_snapshot!(normalize_output(&output, temp_dir.path(), &date)?);
    Ok(())
}

#[test]
fn test_work_acceptance_criteria_use_category_prefixes() -> common::TestResult {
    let (temp_dir, date) = init_project_with_date()?;
    let id = first_work_id(&date);

    let output = common::run_dynamic_commands(
        temp_dir.path(),
        &[
            work_new("Category Extras"),
            work_add_acceptance(&id, "fixed: Add with prefix"),
            work_add_acceptance(&id, "changed: Edit with prefix"),
            work_show(&id),
        ],
    )?;

    assert!(
        output.contains("- ○ fixed: Add with prefix"),
        "output: {}",
        output
    );
    assert!(
        output.contains("- ○ changed: Edit with prefix"),
        "output: {}",
        output
    );
    Ok(())
}

#[test]
fn test_work_set_acceptance_criterion_parses_prefix_and_preserves_status() -> common::TestResult {
    let (temp_dir, date) = init_project_with_date()?;
    let id = first_work_id(&date);

    common::run_dynamic_commands(
        temp_dir.path(),
        &[
            work_new("Correct Criterion"),
            work_add_acceptance(&id, "add: Original text"),
            work_tick_acceptance(&id, 0, "done"),
            work_set_field(&id, "acceptance_criteria[0]", "fix: Corrected text"),
        ],
    )?;

    let criterion = read_first_criterion(temp_dir.path())?;
    assert_eq!(
        criterion.get("text").and_then(toml::Value::as_str),
        Some("Corrected text")
    );
    assert_eq!(
        criterion.get("category").and_then(toml::Value::as_str),
        Some("fixed")
    );
    assert_eq!(
        criterion.get("status").and_then(toml::Value::as_str),
        Some("done")
    );
    Ok(())
}

#[test]
fn test_work_set_acceptance_criterion_falls_back_to_text_and_preserves_metadata()
-> common::TestResult {
    let (temp_dir, date) = init_project_with_date()?;
    let id = first_work_id(&date);

    common::run_dynamic_commands(
        temp_dir.path(),
        &[
            work_new("Correct Criterion"),
            work_add_acceptance(&id, "change: Original text"),
            work_tick_acceptance(&id, 0, "cancelled"),
            work_set_field(
                &id,
                "acceptance_criteria[0]",
                "API: Preserve response shape",
            ),
        ],
    )?;

    let criterion = read_first_criterion(temp_dir.path())?;
    assert_eq!(
        criterion.get("text").and_then(toml::Value::as_str),
        Some("API: Preserve response shape")
    );
    assert_eq!(
        criterion.get("category").and_then(toml::Value::as_str),
        Some("changed")
    );
    assert_eq!(
        criterion.get("status").and_then(toml::Value::as_str),
        Some("cancelled")
    );
    Ok(())
}

#[test]
fn test_work_set_acceptance_criterion_text_path_is_literal() -> common::TestResult {
    let (temp_dir, date) = init_project_with_date()?;
    let id = first_work_id(&date);

    common::run_dynamic_commands(
        temp_dir.path(),
        &[
            work_new("Correct Criterion"),
            work_add_acceptance(&id, "add: Original text"),
            work_tick_acceptance(&id, 0, "done"),
            work_set_field(&id, "acceptance_criteria[0].text", "fix: Literal text"),
        ],
    )?;

    let criterion = read_first_criterion(temp_dir.path())?;
    assert_eq!(
        criterion.get("text").and_then(toml::Value::as_str),
        Some("fix: Literal text")
    );
    assert_eq!(
        criterion.get("category").and_then(toml::Value::as_str),
        Some("added")
    );
    assert_eq!(
        criterion.get("status").and_then(toml::Value::as_str),
        Some("done")
    );
    Ok(())
}

#[test]
fn test_work_set_acceptance_criterion_rejects_invalid_value_and_index_atomically()
-> common::TestResult {
    let (temp_dir, date) = init_project_with_date()?;
    let id = first_work_id(&date);

    let output = common::run_dynamic_commands(
        temp_dir.path(),
        &[
            work_new("Correct Criterion"),
            work_add_acceptance(&id, "add: Original text"),
            work_set_field(&id, "acceptance_criteria[0]", ""),
            work_set_field(&id, "acceptance_criteria[0].text", ""),
            work_set_field(&id, "acceptance_criteria[4]", "fix: Missing item"),
        ],
    )?;

    assert_eq!(
        output.matches("error[E0805]").count(),
        2,
        "output: {output}"
    );
    assert!(output.contains("error[E0816]"), "output: {output}");
    let criterion = read_first_criterion(temp_dir.path())?;
    assert_eq!(
        criterion.get("text").and_then(toml::Value::as_str),
        Some("Original text")
    );
    assert_eq!(
        criterion.get("category").and_then(toml::Value::as_str),
        Some("added")
    );
    assert_eq!(
        criterion.get("status").and_then(toml::Value::as_str),
        Some("pending")
    );
    Ok(())
}

#[test]
fn test_work_tick_acceptance_criteria() -> common::TestResult {
    let (temp_dir, date) = init_project_with_date()?;
    let id = first_work_id(&date);

    let output = common::run_dynamic_commands(
        temp_dir.path(),
        &[
            work_new("Test Task"),
            work_add_acceptance(&id, "add: Criterion 1"),
            work_add_acceptance(&id, "add: Criterion 2"),
            work_tick_acceptance(&id, 0, "done"),
            work_show(&id),
        ],
    )?;
    assert_edit_snapshot!(normalize_output(&output, temp_dir.path(), &date)?);
    Ok(())
}

#[test]
fn test_work_edit_tick_indexed_path_canonical() -> common::TestResult {
    let (temp_dir, date) = init_project_with_date()?;
    let id = first_work_id(&date);

    let output = common::run_dynamic_commands(
        temp_dir.path(),
        &[
            work_new("Canonical Tick"),
            work_add_acceptance(&id, "add: Criterion 1"),
            work_edit_tick_acceptance_criteria_index(&id, 0, "done"),
            work_show(&id),
        ],
    )?;

    assert!(
        output.contains("Added 'add: Criterion 1' to WI-"),
        "output: {}",
        output
    );
    assert!(
        output.contains("Marked 'Criterion 1' as done"),
        "output: {}",
        output
    );
    assert!(
        output.contains("- ✓ added: Criterion 1"),
        "output: {}",
        output
    );
    Ok(())
}

#[test]
fn test_work_tick_cancel_acceptance_criteria() -> common::TestResult {
    let (temp_dir, date) = init_project_with_date()?;
    let id = first_work_id(&date);

    let output = common::run_dynamic_commands(
        temp_dir.path(),
        &[
            work_new("Test Task"),
            work_add_acceptance(&id, "add: Criterion 1"),
            work_tick_acceptance(&id, 0, "cancelled"),
            work_show(&id),
        ],
    )?;
    assert_edit_snapshot!(normalize_output(&output, temp_dir.path(), &date)?);
    Ok(())
}

#[test]
fn test_work_remove_acceptance_criteria() -> common::TestResult {
    let (temp_dir, date) = init_project_with_date()?;
    let id = first_work_id(&date);

    let output = common::run_dynamic_commands(
        temp_dir.path(),
        &[
            work_new("Test Task"),
            work_add_acceptance(&id, "add: To remove"),
            work_add_acceptance(&id, "add: To keep"),
            work_remove_acceptance(&id, "To remove"),
            work_show(&id),
        ],
    )?;
    assert_edit_snapshot!(normalize_output(&output, temp_dir.path(), &date)?);
    Ok(())
}