provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use assert_cmd::Command;
use provenance_core::SUPPORTED_SCHEMA_VERSION;
use serde_json::{json, Value};

fn provenance() -> Command {
    Command::new(assert_cmd::cargo::cargo_bin!("provenance"))
}

fn init_repo() -> tempfile::TempDir {
    let directory = tempfile::tempdir().unwrap();
    provenance()
        .args([
            "init",
            "--path",
            directory.path().to_str().unwrap(),
            "--scope",
            "default",
            "--path-prefix",
            ".",
        ])
        .assert()
        .success();
    std::fs::create_dir(directory.path().join("tests")).unwrap();
    std::fs::write(
        directory.path().join("tests/share-links.test.ts"),
        "// verification fixture\n",
    )
    .unwrap();
    directory
}

fn sdk(repo: &str, command: &str, input: &Value) -> Result<Value, String> {
    let output = provenance()
        .args([
            "sdk", command, "--repo", repo, "--scope", "default", "--format", "json",
        ])
        .write_stdin(serde_json::to_vec(input).unwrap())
        .output()
        .unwrap();
    if !output.status.success() {
        return Err(String::from_utf8_lossy(&output.stderr).into_owned());
    }
    Ok(serde_json::from_slice(&output.stdout).unwrap())
}

fn document(owner: &str) -> Value {
    json!({
        "schema_version": SUPPORTED_SCHEMA_VERSION.0,
        "spec": "share-links",
        "declared_by": owner,
        "sources": [{
            "key": "policy",
            "name": "Sharing policy",
            "kind": "policy"
        }],
        "requirements": [{
            "key": "sharing",
            "statement": "Users can securely share documentation",
            "sources": ["policy"]
        }],
        "rules": [{
            "key": "expiry",
            "requirement": "sharing",
            "statement": "Share links expire within 30 days"
        }]
    })
}

fn empty_document(owner: &str) -> Value {
    json!({
        "schema_version": SUPPORTED_SCHEMA_VERSION.0,
        "spec": "share-links",
        "declared_by": owner,
        "sources": [],
        "requirements": [],
        "rules": []
    })
}

fn resource_id(result: &Value, kind: &str, key: &str) -> String {
    result["resources"]
        .as_array()
        .unwrap()
        .iter()
        .find(|resource| resource["kind"] == kind && resource["key"] == key)
        .unwrap()["id"]
        .as_str()
        .unwrap()
        .to_string()
}

#[test]
fn omission_plans_and_applies_retirement_without_deleting_history() {
    let directory = init_repo();
    let repo = directory.path().to_str().unwrap();
    let owner = "spec://typescript/share-links";
    let initial = sdk(repo, "apply", &document(owner)).unwrap();
    let ids = [
        resource_id(&initial, "source", "policy"),
        resource_id(&initial, "requirement", "sharing"),
        resource_id(&initial, "rule", "expiry"),
    ];

    let planned = sdk(repo, "plan", &empty_document(owner)).unwrap();
    assert_eq!(planned["retired"], 3);
    assert!(planned["resources"]
        .as_array()
        .unwrap()
        .iter()
        .all(|resource| resource["state"] == "retired"));
    assert!(read_records(directory.path(), "sources/source.jsonl")
        .iter()
        .all(|record| record.get("retired").is_none()));

    let applied = sdk(repo, "apply", &empty_document(owner)).unwrap();
    assert_eq!(applied["retired"], planned["retired"]);
    assert_eq!(applied["resources"], planned["resources"]);
    for relative in [
        "sources/source.jsonl",
        "requirements/req.jsonl",
        "rules/rule.jsonl",
    ] {
        assert_eq!(read_records(directory.path(), relative)[0]["retired"], true);
    }
    let verification_error = sdk(
        repo,
        "begin-verification",
        &json!({
            "declaration": {
                "declared_by": owner,
                "address": ["share-links", "requirement", "sharing", "rule", "expiry"]
            },
            "key": "expiry-after-retirement",
            "method": "examples",
            "declared_by": "ci://typescript",
            "file": "tests/share-links.test.ts"
        }),
    )
    .unwrap_err();
    assert!(
        verification_error.contains("retired"),
        "{verification_error}"
    );

    let clean = sdk(repo, "plan", &empty_document(owner)).unwrap();
    assert_eq!(clean["retired"], 0);
    assert!(clean["resources"].as_array().unwrap().is_empty());

    let reactivated = sdk(repo, "apply", &document(owner)).unwrap();
    assert_eq!(reactivated["updated"], 3);
    assert_eq!(resource_id(&reactivated, "source", "policy"), ids[0]);
    assert_eq!(resource_id(&reactivated, "requirement", "sharing"), ids[1]);
    assert_eq!(resource_id(&reactivated, "rule", "expiry"), ids[2]);
}

#[test]
fn moving_a_rule_between_requirements_preserves_id_and_reparents_the_edge() {
    let directory = init_repo();
    let repo = directory.path().to_str().unwrap();
    let owner = "spec://typescript/lifecycles";
    let mut initial = document(owner);
    initial["spec"] = json!("lifecycles");
    initial["requirements"].as_array_mut().unwrap().push(json!({
        "key": "sessions",
        "statement": "Sessions expire"
    }));
    let first = sdk(repo, "apply", &initial).unwrap();
    let rule_id = resource_id(&first, "rule", "expiry");
    let sessions_id = resource_id(&first, "requirement", "sessions");

    let mut moved = initial.clone();
    moved["rules"][0]["id"] = json!(rule_id);
    moved["rules"][0]["requirement"] = json!("sessions");
    let planned = sdk(repo, "plan", &moved).unwrap();
    let rule = planned["resources"]
        .as_array()
        .unwrap()
        .iter()
        .find(|resource| resource["kind"] == "rule")
        .unwrap();
    assert_eq!(planned["moved"], 1);
    assert_eq!(rule["state"], "moved");
    assert_eq!(rule["id"], rule_id);
    assert_eq!(rule["parent"], "sessions");

    sdk(repo, "apply", &moved).unwrap();
    let rules = read_records(directory.path(), "rules/rule.jsonl");
    let moved = rules.iter().find(|rule| rule["id"] == rule_id).unwrap();
    assert_eq!(moved["requirement_ids"], json!([sessions_id]));
}

#[test]
fn plan_returns_an_ownership_conflict_while_apply_refuses_it() {
    let directory = init_repo();
    let repo = directory.path().to_str().unwrap();
    let first = sdk(repo, "apply", &document("spec://typescript/first")).unwrap();
    let source_id = resource_id(&first, "source", "policy");
    let conflicting = json!({
        "schema_version": SUPPORTED_SCHEMA_VERSION.0,
        "spec": "other",
        "declared_by": "spec://typescript/other",
        "sources": [{
            "key": "policy",
            "id": source_id,
            "name": "Other policy",
            "kind": "policy"
        }]
    });

    let planned = sdk(repo, "plan", &conflicting).unwrap();
    assert_eq!(planned["conflicts"], 1);
    assert_eq!(planned["resources"][0]["state"], "conflict");
    assert_eq!(
        planned["resources"][0]["changes"][0]["field"],
        "declared_by"
    );

    let error = sdk(repo, "apply", &conflicting).unwrap_err();
    assert!(error.contains("not owned"), "{error}");
}

fn read_records(root: &std::path::Path, relative: &str) -> Vec<Value> {
    std::fs::read_to_string(root.join(".provenance/state/scopes/default").join(relative))
        .unwrap()
        .lines()
        .map(|line| serde_json::from_str(line).unwrap())
        .collect()
}