provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use assert_cmd::Command;
use predicates::str::contains;
use provenance_core::SUPPORTED_SCHEMA_VERSION;
use serde_json::{json, Value};
use std::path::Path;

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

fn init(repo: &Path) {
    provenance()
        .args([
            "init",
            "--path",
            repo.to_str().unwrap(),
            "--scope",
            "default",
            "--path-prefix",
            ".",
        ])
        .assert()
        .success();
    provenance()
        .args([
            "requirements",
            "create",
            "--repo",
            repo.to_str().unwrap(),
            "--scope",
            "default",
            "--id",
            "req_workflows",
            "--statement",
            "Accepted workflows start",
        ])
        .assert()
        .success();
}

fn create_rule(repo: &Path, id: &str) {
    provenance()
        .args([
            "rules",
            "create",
            "--repo",
            repo.to_str().unwrap(),
            "--scope",
            "default",
            "--id",
            id,
            "--requirement-id",
            "req_workflows",
            "--statement",
            "Accepted workflows start",
        ])
        .assert()
        .success();
}

fn binding(id: &str, rule_id: &str) -> Value {
    json!({
        "schema_version": SUPPORTED_SCHEMA_VERSION.0,
        "scope_id": "default",
        "id": id,
        "rule_id": rule_id,
        "declared_by": "spec://typescript/workflows",
        "file": "src/runtime.ts",
        "symbol": "startWorkflow"
    })
}

fn write_implementation_bindings(repo: &Path, bindings: &[Value]) {
    let path = repo.join(".provenance/state/scopes/default/implementations/binding.jsonl");
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    let mut contents = String::new();
    for record in bindings {
        contents.push_str(&serde_json::to_string(record).unwrap());
        contents.push('\n');
    }
    std::fs::write(path, contents).unwrap();
}

fn export(repo: &Path, output: &Path) -> Value {
    provenance()
        .args([
            "export",
            "--repo",
            repo.to_str().unwrap(),
            "--scope",
            "default",
            "--format",
            "json",
            "--output",
            output.to_str().unwrap(),
        ])
        .assert()
        .success();
    serde_json::from_slice(&std::fs::read(output).unwrap()).unwrap()
}

#[test]
fn scope_export_import_round_trip_preserves_implementation_bindings() {
    let directory = tempfile::tempdir().unwrap();
    let source = directory.path().join("source");
    let target = directory.path().join("target");
    let exported_path = directory.path().join("scope.json");
    let reexported_path = directory.path().join("round-trip.json");
    init(&source);
    create_rule(&source, "rule_start");
    let mut retired = binding("implementation_binding_start", "rule_start");
    retired["retired"] = json!(true);
    write_implementation_bindings(&source, &[retired.clone()]);

    let exported = export(&source, &exported_path);
    assert_eq!(exported["implementation_bindings"], json!([retired]));

    init(&target);
    provenance()
        .args([
            "import",
            "--repo",
            target.to_str().unwrap(),
            "--scope",
            "default",
            "--input",
            exported_path.to_str().unwrap(),
        ])
        .assert()
        .success();
    provenance()
        .args(["check", "--repo", target.to_str().unwrap()])
        .assert()
        .success();

    let reexported = export(&target, &reexported_path);
    assert_eq!(
        reexported["implementation_bindings"],
        exported["implementation_bindings"]
    );
}

#[test]
fn scope_import_defaults_an_omitted_implementation_binding_family_to_empty() {
    let directory = tempfile::tempdir().unwrap();
    let source = directory.path().join("source");
    let target = directory.path().join("target");
    let exported_path = directory.path().join("scope.json");
    init(&source);
    init(&target);
    let mut exported = export(&source, &exported_path);
    assert_eq!(exported["implementation_bindings"], json!([]));
    exported
        .as_object_mut()
        .unwrap()
        .remove("implementation_bindings");
    std::fs::write(&exported_path, serde_json::to_vec(&exported).unwrap()).unwrap();

    provenance()
        .args([
            "import",
            "--repo",
            target.to_str().unwrap(),
            "--scope",
            "default",
            "--input",
            exported_path.to_str().unwrap(),
        ])
        .assert()
        .success();
}

#[test]
fn check_rejects_invalid_implementation_binding_rows() {
    let future_version = format!("schema_version {}", SUPPORTED_SCHEMA_VERSION.0 + 1);
    let cases = [
        (
            "unsupported schema",
            vec![{
                let mut record = binding("implementation_binding_start", "rule_start");
                record["schema_version"] = json!(SUPPORTED_SCHEMA_VERSION.0 + 1);
                record
            }],
            future_version.as_str(),
        ),
        (
            "wrong scope",
            vec![{
                let mut record = binding("implementation_binding_start", "rule_start");
                record["scope_id"] = json!("other");
                record
            }],
            "implementation binding implementation_binding_start loaded from scope default",
        ),
        (
            "unknown rule",
            vec![binding("implementation_binding_missing", "rule_missing")],
            "implementation binding implementation_binding_missing has dangling reference: rule_id rule rule_missing",
        ),
        (
            "duplicate primary",
            vec![
                binding("implementation_binding_first", "rule_start"),
                binding("implementation_binding_second", "rule_start"),
            ],
            "more than one canonical primary implementation binding for rule rule_start",
        ),
        (
            "absolute file",
            vec![{
                let mut record = binding("implementation_binding_start", "rule_start");
                record["file"] = json!("/tmp/runtime.ts");
                record
            }],
            "implementation binding implementation_binding_start file must be repository-relative",
        ),
        (
            "empty symbol",
            vec![{
                let mut record = binding("implementation_binding_start", "rule_start");
                record["symbol"] = json!(" ");
                record
            }],
            "implementation binding implementation_binding_start symbol must not be empty",
        ),
        (
            "parent file",
            vec![{
                let mut record = binding("implementation_binding_start", "rule_start");
                record["file"] = json!("../runtime.ts");
                record
            }],
            "implementation binding implementation_binding_start file must be repository-relative",
        ),
        (
            "platform-specific separator",
            vec![{
                let mut record = binding("implementation_binding_start", "rule_start");
                record["file"] = json!(r"src\runtime.ts");
                record
            }],
            "implementation binding implementation_binding_start file must be repository-relative",
        ),
        (
            "empty owner",
            vec![{
                let mut record = binding("implementation_binding_start", "rule_start");
                record["declared_by"] = json!(" ");
                record
            }],
            "implementation binding implementation_binding_start declared_by must not be empty",
        ),
    ];

    for (_name, records, expected) in cases {
        let directory = tempfile::tempdir().unwrap();
        init(directory.path());
        create_rule(directory.path(), "rule_start");
        write_implementation_bindings(directory.path(), &records);
        provenance()
            .args(["check", "--repo", directory.path().to_str().unwrap()])
            .assert()
            .failure()
            .stderr(contains(expected));
    }
}

#[test]
fn check_rejects_a_platform_specific_verification_path() {
    let directory = tempfile::tempdir().unwrap();
    init(directory.path());
    create_rule(directory.path(), "rule_start");
    let path = directory
        .path()
        .join(".provenance/state/scopes/default/verifications/binding.jsonl");
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    let record = json!({
        "schema_version": SUPPORTED_SCHEMA_VERSION.0,
        "scope_id": "default",
        "id": "verification_binding_start",
        "rule_id": "rule_start",
        "key": "start",
        "method": "examples",
        "declared_by": "test://typescript",
        "file": r"tests\runtime.test.ts",
        "symbol": "workflow starts"
    });
    std::fs::write(
        path,
        format!("{}\n", serde_json::to_string(&record).unwrap()),
    )
    .unwrap();

    provenance()
        .args(["check", "--repo", directory.path().to_str().unwrap()])
        .assert()
        .failure()
        .stderr(contains(
            "verification binding verification_binding_start file must be repository-relative",
        ));
}