provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use super::support::{
    create_requirement, create_rule, diagnostic, git, init, provenance, REQUIREMENTS_SHARD,
    RULES_SHARD,
};
use provenance_macros::verifies;
use serde_json::{json, Value};

fn commit(repository: &std::path::Path, message: &str) -> String {
    git(repository, &["add", "."]);
    git(repository, &["commit", "-m", message]);
    String::from_utf8(git(repository, &["rev-parse", "HEAD"]).stdout)
        .unwrap()
        .trim()
        .to_owned()
}

fn check_strict(repository: &std::path::Path, base: Option<&str>) -> std::process::Output {
    let mut command = provenance();
    command.args([
        "check",
        "--repo",
        repository.to_str().unwrap(),
        "--strict",
        "--format",
        "json",
    ]);
    if let Some(base) = base {
        command.args(["--base", base]);
    }
    command.output().unwrap()
}

fn rewrite_statement(repository: &std::path::Path, shard: &str, id: &str, statement: &str) {
    let path = repository.join(shard);
    let contents = std::fs::read_to_string(&path).unwrap();
    let rewritten = contents
        .lines()
        .map(|line| {
            let mut value: Value = serde_json::from_str(line).unwrap();
            if value["id"] == id {
                value["statement"] = json!(statement);
            }
            serde_json::to_string(&value).unwrap()
        })
        .collect::<Vec<_>>()
        .join("\n")
        + "\n";
    std::fs::write(path, rewritten).unwrap();
}

fn repository() -> tempfile::TempDir {
    let directory = tempfile::tempdir().unwrap();
    let repo = directory.path();
    git(repo, &["init", "--initial-branch", "main"]);
    git(repo, &["config", "user.email", "test@example.test"]);
    git(repo, &["config", "user.name", "Test"]);
    init(repo);
    directory
}

#[test]
#[verifies("rule_ste_strict_committed_statement_selection", examples)]
#[verifies("rule_ste_strict_committed_statement_gate", examples)]
fn strict_check_reads_the_git_head_candidate_on_a_clean_checkout() {
    let directory = repository();
    let repo = directory.path();
    create_requirement(repo, "req_changed", "Original statement");
    create_rule(repo, "rule_changed", "Original rule");
    let base = commit(repo, "base");
    rewrite_statement(repo, REQUIREMENTS_SHARD, "req_changed", "Café; changed");
    rewrite_statement(repo, RULES_SHARD, "rule_changed", "Rule; changed");
    let candidate = commit(repo, "direct JSONL statement edits");

    let output = check_strict(repo, None);

    assert!(!output.status.success());
    let report: Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(
        report,
        json!({
            "status": "findings",
            "candidate_commit": candidate,
            "base_commit": base,
            "diagnostics": [
                diagnostic("requirement", "req_changed", 5),
                diagnostic("rule", "rule_changed", 4)
            ]
        })
    );
}

#[test]
#[verifies("rule_ste_strict_committed_statement_selection", examples)]
#[verifies("rule_ste_strict_committed_statement_gate", examples)]
fn explicit_base_can_check_findings_that_the_parent_policy_permits() {
    let directory = repository();
    let repo = directory.path();
    create_requirement(repo, "req_history", "Original statement");
    let clean_base = commit(repo, "clean base");
    rewrite_statement(repo, REQUIREMENTS_SHARD, "req_history", "History; finding");
    commit(repo, "statement finding");
    std::fs::write(repo.join("README.md"), "Unrelated candidate change.\n").unwrap();
    let candidate = commit(repo, "candidate");

    let parent_output = check_strict(repo, None);
    assert!(parent_output.status.success());
    let parent_report: Value = serde_json::from_slice(&parent_output.stdout).unwrap();
    assert_eq!(parent_report["status"], "ok");
    assert_eq!(parent_report["candidate_commit"], candidate);
    assert_eq!(parent_report["diagnostics"], json!([]));

    let explicit_output = check_strict(repo, Some(&clean_base));
    assert!(!explicit_output.status.success());
    let explicit_report: Value = serde_json::from_slice(&explicit_output.stdout).unwrap();
    assert_eq!(explicit_report["status"], "findings");
    assert_eq!(explicit_report["candidate_commit"], candidate);
    assert_eq!(explicit_report["base_commit"], clean_base);
    assert_eq!(
        explicit_report["diagnostics"],
        json!([diagnostic("requirement", "req_history", 7)])
    );
}

#[test]
#[verifies("rule_ste_strict_initial_commit_base", examples)]
#[verifies("rule_ste_strict_committed_statement_gate", examples)]
fn initial_commit_uses_an_empty_comparison_base() {
    let directory = repository();
    let repo = directory.path();
    create_requirement(repo, "req_initial", "Initial statement");
    rewrite_statement(repo, REQUIREMENTS_SHARD, "req_initial", "Initial; finding");
    let candidate = commit(repo, "initial commit");

    let output = check_strict(repo, None);

    assert!(!output.status.success());
    let report: Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(report["status"], "findings");
    assert_eq!(report["candidate_commit"], candidate);
    assert_eq!(report["base_commit"], Value::Null);
    assert_eq!(
        report["diagnostics"],
        json!([diagnostic("requirement", "req_initial", 7)])
    );
}

#[test]
#[verifies("rule_ste_strict_committed_statement_selection", examples)]
fn strict_check_ignores_dirty_working_tree_statement_edits() {
    let directory = repository();
    let repo = directory.path();
    create_requirement(repo, "req_committed", "Committed statement");
    let base = commit(repo, "base");
    std::fs::write(repo.join("README.md"), "Committed candidate change.\n").unwrap();
    let candidate = commit(repo, "candidate");
    rewrite_statement(repo, REQUIREMENTS_SHARD, "req_committed", "Dirty; finding");

    let output = check_strict(repo, None);

    assert!(output.status.success());
    let report: Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(
        report,
        json!({
            "status": "ok",
            "candidate_commit": candidate,
            "base_commit": base,
            "diagnostics": []
        })
    );
}

#[test]
#[verifies("rule_ste_strict_committed_statement_selection", examples)]
fn invalid_explicit_base_reports_the_revision_error() {
    let directory = repository();
    let repo = directory.path();
    create_requirement(repo, "req_candidate", "Candidate statement");
    commit(repo, "candidate");

    let output = check_strict(repo, Some("does-not-exist"));

    assert!(!output.status.success());
    assert!(output.stdout.is_empty());
    assert!(String::from_utf8(output.stderr)
        .unwrap()
        .contains("Git revision \"does-not-exist\" does not identify a commit"));
}

#[test]
#[verifies("rule_ste_strict_committed_statement_selection", examples)]
fn merge_commit_uses_its_first_parent_as_the_default_base() {
    let directory = repository();
    let repo = directory.path();
    create_requirement(repo, "req_merge", "Original statement");
    let common = commit(repo, "common base");
    git(repo, &["checkout", "-b", "side", &common]);
    std::fs::write(repo.join("SIDE.md"), "Side change.\n").unwrap();
    commit(repo, "side change");
    git(repo, &["checkout", "main"]);
    rewrite_statement(
        repo,
        REQUIREMENTS_SHARD,
        "req_merge",
        "First parent; finding",
    );
    let first_parent = commit(repo, "first parent statement");
    git(repo, &["merge", "--no-ff", "side", "-m", "merge side"]);
    let candidate = String::from_utf8(git(repo, &["rev-parse", "HEAD"]).stdout)
        .unwrap()
        .trim()
        .to_owned();

    let output = check_strict(repo, None);

    assert!(output.status.success());
    let report: Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(report["status"], "ok");
    assert_eq!(report["candidate_commit"], candidate);
    assert_eq!(report["base_commit"], first_parent);
    assert_eq!(report["diagnostics"], json!([]));
}

#[test]
#[verifies("rule_ste_strict_committed_statement_selection", examples)]
fn shallow_checkout_does_not_mistake_an_unavailable_parent_for_an_empty_base() {
    let scratch = tempfile::tempdir().unwrap();
    let origin = scratch.path().join("origin");
    std::fs::create_dir_all(&origin).unwrap();
    git(&origin, &["init", "--initial-branch", "main"]);
    git(&origin, &["config", "user.email", "test@example.test"]);
    git(&origin, &["config", "user.name", "Test"]);
    init(&origin);
    create_requirement(&origin, "req_history", "Original statement");
    rewrite_statement(
        &origin,
        REQUIREMENTS_SHARD,
        "req_history",
        "History; finding",
    );
    commit(&origin, "historical statement finding");
    std::fs::write(origin.join("README.md"), "Candidate change.\n").unwrap();
    commit(&origin, "candidate");
    let checkout = scratch.path().join("checkout");
    let source = format!("file://{}", origin.display());
    git(
        scratch.path(),
        &["clone", "--depth", "1", &source, checkout.to_str().unwrap()],
    );

    let output = check_strict(&checkout, None);

    assert!(!output.status.success());
    assert!(output.stdout.is_empty());
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(stderr.contains("first parent is not available. Fetch more Git history"));
    assert!(stderr.contains("does not identify a commit"));
}