govctl 0.9.2

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

use common::{init_project, run_commands, write_guard_with_timeout};
use rusqlite::Connection;
use std::fs;
use std::path::Path;

fn write_clause(
    dir: &Path,
    rfc_id: &str,
    clause_id: &str,
    tag: Option<&str>,
) -> common::TestResult {
    let tags = tag
        .map(|tag| format!("tags = [\"{tag}\"]\n"))
        .unwrap_or_default();
    let path = dir
        .join("gov/rfc")
        .join(rfc_id)
        .join("clauses")
        .join(format!("{clause_id}.toml"));
    fs::write(
        path,
        format!(
            r#"[govctl]
schema = 1
id = "{clause_id}"
title = "Cache Clause"
kind = "normative"
status = "active"
anchors = ["cache-anchor"]
since = "0.1.0"
superseded_by = "C-NEXT"
{tags}
[content]
text = "cache clause body"
"#
        ),
    )?;
    Ok(())
}

fn write_search_rfc(dir: &Path) -> common::TestResult {
    let rfc_dir = dir.join("gov/rfc/RFC-0001");
    fs::create_dir_all(rfc_dir.join("clauses"))?;
    fs::write(
        rfc_dir.join("rfc.toml"),
        r#"[govctl]
schema = 1
id = "RFC-0001"
title = "Cache Policy"
version = "0.1.0"
status = "draft"
phase = "spec"
owners = ["@test-user"]
created = "2026-01-01"
refs = ["ADR-0001"]
tags = ["rfctag"]

[[sections]]
title = "Cache Specification"
clauses = ["clauses/C-CACHE.toml"]

[[changelog]]
version = "0.1.0"
date = "2026-01-01"
notes = "cache changelog note"
added = ["cache added"]
changed = ["cache changed"]
deprecated = ["cache deprecated"]
removed = ["cache removed"]
fixed = ["cache fixed"]
security = ["cache security"]
"#,
    )?;
    Ok(())
}

fn write_adr(dir: &Path) -> common::TestResult {
    fs::write(
        dir.join("gov/adr/ADR-0001-cache-decision.toml"),
        r#"[govctl]
schema = 1
id = "ADR-0001"
title = "Cache Decision"
status = "superseded"
date = "2026-01-01"
superseded_by = "ADR-0002"
refs = ["RFC-0001"]
tags = ["adrtag"]

[content]
context = "cache context"
decision = "Use cache search"
consequences = "Searchable ADR"

[[content.alternatives]]
text = "cache alternative"
status = "rejected"
pros = ["cache pro"]
cons = ["cache con"]
rejection_reason = "cache reason"
"#,
    )?;
    Ok(())
}

fn write_work(dir: &Path, id: &str, title: &str, description: &str) -> common::TestResult {
    fs::write(
        dir.join("gov/work/2026-01-01-search-work.toml"),
        format!(
            r#"[govctl]
schema = 1
id = "{id}"
title = "{title}"
status = "active"
created = "2026-01-01"
started = "2026-01-01"
refs = ["RFC-0001"]
depends_on = ["WI-2026-01-01-999"]
tags = ["worktag"]

[content]
description = "{description}"
notes = ["cache durable note"]

[[content.acceptance_criteria]]
text = "cache criterion"
status = "pending"
category = "chore"
"#
        ),
    )?;
    Ok(())
}

fn write_work_with_journal(dir: &Path) -> common::TestResult {
    fs::write(
        dir.join("gov/work/2026-01-01-journal-work.toml"),
        r#"[govctl]
schema = 1
id = "WI-2026-01-01-002"
title = "Journal Work"
status = "active"
created = "2026-01-01"
started = "2026-01-01"

[content]
description = "stable searchable body"

[[content.journal]]
date = "2026-01-01"
content = "journalonlytoken"
"#,
    )?;
    Ok(())
}

#[test]
fn test_search_finds_all_artifact_types_and_output_formats() -> common::TestResult {
    let temp_dir = init_project()?;
    write_search_rfc(temp_dir.path())?;
    write_clause(temp_dir.path(), "RFC-0001", "C-CACHE", Some("searchtag"))?;
    write_adr(temp_dir.path())?;
    write_work(
        temp_dir.path(),
        "WI-2026-01-01-001",
        "Cache Work",
        "cache work body",
    )?;
    write_guard_with_timeout(
        temp_dir.path(),
        "GUARD-CACHE",
        "echo cache",
        Some("cache"),
        5,
    )?;

    let json = run_commands(
        temp_dir.path(),
        &[&["search", "cache", "-o", "json", "-n", "10"]],
    )?;
    assert!(json.contains("\"kind\": \"rfc\""), "{json}");
    assert!(json.contains("\"kind\": \"clause\""), "{json}");
    assert!(json.contains("\"kind\": \"adr\""), "{json}");
    assert!(json.contains("\"kind\": \"work\""), "{json}");
    assert!(json.contains("\"kind\": \"guard\""), "{json}");
    assert!(json.contains("\"path\""), "{json}");
    assert!(json.contains("\"snippet\""), "{json}");

    let plain = run_commands(
        temp_dir.path(),
        &[&["search", "cache", "--type", "guard", "-o", "plain"]],
    )?;
    assert!(plain.contains("GUARD-CACHE"), "{plain}");
    assert!(!plain.contains("ADR-0001"), "{plain}");

    let tagged = run_commands(
        temp_dir.path(),
        &[&["search", "cache", "--tag", "searchtag", "-o", "plain"]],
    )?;
    assert!(tagged.contains("RFC-0001:C-CACHE"), "{tagged}");
    assert!(!tagged.contains("ADR-0001"), "{tagged}");

    let table = run_commands(temp_dir.path(), &[&["search", "cache", "--type", "rfc"]])?;
    assert!(table.contains("Kind"), "{table}");
    assert!(table.contains("ID"), "{table}");
    assert!(table.contains("Title"), "{table}");
    assert!(table.contains("Path"), "{table}");
    assert!(table.contains("Snippet"), "{table}");

    let stemmed = run_commands(
        temp_dir.path(),
        &[&["search", "caching", "--type", "work", "-o", "plain"]],
    )?;
    assert!(stemmed.contains("WI-2026-01-01-001"), "{stemmed}");

    let clause = run_commands(
        temp_dir.path(),
        &[&["search", "cache", "--type", "clause", "-o", "plain"]],
    )?;
    assert!(clause.contains("RFC-0001:C-CACHE"), "{clause}");

    let adr = run_commands(
        temp_dir.path(),
        &[&[
            "search",
            "cache",
            "--type",
            "adr",
            "--reindex",
            "-o",
            "plain",
        ]],
    )?;
    assert!(adr.contains("ADR-0001"), "{adr}");
    Ok(())
}

#[test]
fn test_search_sync_updates_changed_and_deleted_artifacts() -> common::TestResult {
    let temp_dir = init_project()?;
    let work_id = "WI-2026-01-01-001";
    write_work(temp_dir.path(), work_id, "Needle Work", "needle body")?;

    let initial = run_commands(
        temp_dir.path(),
        &[&["search", "needle", "--type", "work", "-o", "plain"]],
    )?;
    assert!(initial.contains(work_id), "{initial}");

    write_work(temp_dir.path(), work_id, "Haystack Work", "haystack body")?;
    let stale_query = run_commands(
        temp_dir.path(),
        &[&["search", "needle", "--type", "work", "-o", "plain"]],
    )?;
    assert!(!stale_query.contains(work_id), "{stale_query}");

    let changed_query = run_commands(
        temp_dir.path(),
        &[&["search", "haystack", "--type", "work", "-o", "plain"]],
    )?;
    assert!(changed_query.contains(work_id), "{changed_query}");

    fs::remove_file(temp_dir.path().join("gov/work/2026-01-01-search-work.toml"))?;
    let deleted_query = run_commands(
        temp_dir.path(),
        &[&["search", "haystack", "--type", "work", "-o", "plain"]],
    )?;
    assert!(!deleted_query.contains(work_id), "{deleted_query}");
    Ok(())
}

#[test]
fn test_search_repairs_manifest_fts_divergence() -> common::TestResult {
    let temp_dir = init_project()?;
    let work_id = "WI-2026-01-01-001";
    let work_path = temp_dir.path().join("gov/work/2026-01-01-search-work.toml");
    let db_path = temp_dir.path().join(".govctl/index.db");
    write_work(temp_dir.path(), work_id, "Needle Work", "needle body")?;

    let initial = run_commands(
        temp_dir.path(),
        &[&["search", "needle", "--type", "work", "-o", "plain"]],
    )?;
    assert!(initial.contains(work_id), "{initial}");

    {
        let connection = Connection::open(&db_path)?;
        connection.execute(
            "DELETE FROM search_fts WHERE kind = 'work' AND id = ?1",
            [work_id],
        )?;
    }
    let repaired = run_commands(
        temp_dir.path(),
        &[&["search", "needle", "--type", "work", "-o", "plain"]],
    )?;
    assert!(repaired.contains(work_id), "{repaired}");

    {
        let connection = Connection::open(&db_path)?;
        connection.execute(
            "DELETE FROM search_manifest WHERE kind = 'work' AND id = ?1",
            [work_id],
        )?;
    }
    fs::remove_file(work_path)?;
    let orphan_removed = run_commands(
        temp_dir.path(),
        &[&["search", "needle", "--type", "work", "-o", "plain"]],
    )?;
    assert!(!orphan_removed.contains(work_id), "{orphan_removed}");
    Ok(())
}

#[test]
fn test_search_does_not_index_legacy_work_journal() -> common::TestResult {
    let temp_dir = init_project()?;
    write_work_with_journal(temp_dir.path())?;

    let rendered = run_commands(temp_dir.path(), &[&["work", "show", "WI-2026-01-01-002"]])?;
    assert!(rendered.contains("journalonlytoken"), "{rendered}");

    let search = run_commands(
        temp_dir.path(),
        &[&[
            "search",
            "journalonlytoken",
            "--type",
            "work",
            "-o",
            "plain",
        ]],
    )?;
    assert!(!search.contains("WI-2026-01-01-002"), "{search}");
    Ok(())
}

#[test]
fn test_search_rebuilds_unversioned_existing_fts_table() -> common::TestResult {
    let temp_dir = init_project()?;
    let db_dir = temp_dir.path().join(".govctl");
    fs::create_dir_all(&db_dir)?;
    {
        let connection = Connection::open(db_dir.join("index.db"))?;
        connection.execute_batch(
            "
            CREATE TABLE search_meta (
                key TEXT PRIMARY KEY,
                value TEXT NOT NULL
            );
            CREATE VIRTUAL TABLE search_fts USING fts5(
                kind UNINDEXED,
                id UNINDEXED,
                title,
                path UNINDEXED,
                tags UNINDEXED,
                status UNINDEXED,
                body
            );
            ",
        )?;
    }
    write_work(
        temp_dir.path(),
        "WI-2026-01-01-003",
        "Cache Work",
        "cache body",
    )?;

    let search = run_commands(
        temp_dir.path(),
        &[&["search", "caching", "--type", "work", "-o", "plain"]],
    )?;
    assert!(search.contains("WI-2026-01-01-003"), "{search}");
    Ok(())
}