provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use assert_cmd::Command;
use provenance_core::SUPPORTED_SCHEMA_VERSION;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::process::Command as StdCommand;
use std::time::{Duration, Instant};

#[test]
fn wiki_build_uses_coverage_report_for_implementations_and_verifications() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    let out = dir.path().join("site");
    let report = dir.path().join("coverage.json");
    seed_rules(dir.path(), &repo);
    seed_git_remote(&repo);
    write_coverage_report(&report);

    Command::cargo_bin("provenance")
        .unwrap()
        .args([
            "wiki",
            "build",
            "--repo",
            &repo.to_string_lossy(),
            "--out",
            &out.to_string_lossy(),
            "--coverage",
            &report.to_string_lossy(),
        ])
        .assert()
        .success();

    let bound = std::fs::read_to_string(out.join("rules/rule_bound/index.html")).unwrap();
    assert!(bound.contains("Implementation"), "{bound}");
    assert!(!bound.contains("Rule Function"), "{bound}");
    assert!(bound.contains("decide_bound_rule"), "{bound}");
    assert!(bound.contains("src/rules.rs:7"), "{bound}");
    assert!(
        bound.contains("https://github.com/example/provenance/blob/abc1234/src/rules.rs#L7"),
        "{bound}"
    );
    assert!(bound.contains("Verification"), "{bound}");
    assert!(bound.contains("exhaustion"), "{bound}");
    assert!(bound.contains("bound_rule_exhaustion"), "{bound}");
    assert!(bound.contains("examples"), "{bound}");
    assert!(bound.contains("bound_rule_examples"), "{bound}");
    assert!(bound.contains("tests/rules.rs:12"), "{bound}");
    assert!(bound.contains("Local snippet"), "{bound}");
    assert!(bound.contains("fn decide_bound_rule() {}"), "{bound}");
    assert!(!bound.contains("/blob/HEAD/"), "{bound}");
    assert_eq!(
        bound.matches("outside implementation module").count(),
        1,
        "{bound}"
    );
    assert!(!bound.contains("docs/obsolete.md"), "{bound}");
    assert!(!bound.contains(">Evidence</h2>"), "{bound}");

    assert!(
        bound.contains("Code scan at commit <code>abc1234</code>"),
        "{bound}"
    );

    let unbound = std::fs::read_to_string(out.join("rules/rule_unbound/index.html")).unwrap();
    assert!(unbound.contains("No implementation bound"), "{unbound}");
    assert!(unbound.contains("Not verified"), "{unbound}");
    assert!(
        unbound.contains("Code scan at commit <code>abc1234</code>"),
        "{unbound}"
    );
}

#[test]
fn wiki_build_without_scan_suppresses_unpinned_code_links() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    let out = dir.path().join("site");
    seed_rules(dir.path(), &repo);
    seed_git_remote(&repo);

    Command::cargo_bin("provenance")
        .unwrap()
        .args([
            "wiki",
            "build",
            "--repo",
            &repo.to_string_lossy(),
            "--out",
            &out.to_string_lossy(),
        ])
        .assert()
        .success();

    let source = std::fs::read_to_string(out.join("sources/source_code/index.html")).unwrap();
    assert!(source.contains("src/rules.rs:7"), "{source}");
    assert!(!source.contains("/blob/HEAD/"), "{source}");
    assert!(
        !source.contains("href=\"https://github.com/example/provenance/blob/"),
        "{source}"
    );
}

#[test]
fn wiki_build_without_scan_renders_canonical_typed_implementation() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    let out = dir.path().join("site");
    seed_rules(dir.path(), &repo);
    seed_implementation_binding(&repo);
    seed_git_remote(&repo);

    Command::cargo_bin("provenance")
        .unwrap()
        .args([
            "wiki",
            "build",
            "--repo",
            &repo.to_string_lossy(),
            "--out",
            &out.to_string_lossy(),
        ])
        .assert()
        .success();

    let rule = std::fs::read_to_string(out.join("rules/rule_bound/index.html")).unwrap();
    assert!(rule.contains(">Implementation</h2>"), "{rule}");
    assert!(rule.contains("decide_typed_rule"), "{rule}");
    assert!(rule.contains("src/typed-rules.ts"), "{rule}");
    assert!(rule.contains("No code scan was supplied"), "{rule}");
    assert!(!rule.contains("/blob/HEAD/"), "{rule}");
}

#[test]
fn wiki_serve_without_coverage_omits_scan_sections_and_unpinned_links() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    seed_rules(dir.path(), &repo);
    seed_git_remote(&repo);

    let port = free_port();
    let mut child = spawn_wiki_server(&repo, port, None);
    let rule = wait_for_http(port, "/rules/rule_bound/");
    let source = wait_for_http(port, "/sources/source_code/");
    child.kill().ok();
    child.wait().ok();

    assert!(rule.contains("200 OK"), "{rule}");
    assert!(rule.contains("No code scan was supplied"), "{rule}");
    assert!(!rule.contains("Implementation"), "{rule}");
    assert!(source.contains("src/rules.rs:7"), "{source}");
    assert!(!source.contains("/blob/HEAD/"), "{source}");
}

#[test]
fn wiki_serve_with_coverage_renders_pins_snippets_and_implementations() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    let report = dir.path().join("coverage.json");
    seed_rules(dir.path(), &repo);
    seed_git_remote(&repo);
    write_coverage_report(&report);

    let port = free_port();
    let mut child = spawn_wiki_server(&repo, port, Some(&report));
    let rule = wait_for_http(port, "/rules/rule_bound/");
    child.kill().ok();
    child.wait().ok();

    assert!(rule.contains("200 OK"), "{rule}");
    assert!(
        rule.contains("Code scan at commit <code>abc1234</code>"),
        "{rule}"
    );
    assert!(rule.contains("Implementation"), "{rule}");
    assert!(!rule.contains("Rule Function"), "{rule}");
    assert!(rule.contains("decide_bound_rule"), "{rule}");
    assert!(rule.contains("Verification"), "{rule}");
    assert!(rule.contains("exhaustion"), "{rule}");
    assert!(rule.contains("bound_rule_exhaustion"), "{rule}");
    assert!(rule.contains("examples"), "{rule}");
    assert!(rule.contains("bound_rule_examples"), "{rule}");
    assert!(rule.contains("tests/rules.rs:12"), "{rule}");
    assert!(rule.contains("Local snippet"), "{rule}");
    assert!(rule.contains("fn decide_bound_rule() {}"), "{rule}");
    assert!(
        rule.contains("https://github.com/example/provenance/blob/abc1234/src/rules.rs#L7"),
        "{rule}"
    );
    assert!(!rule.contains("/blob/HEAD/"), "{rule}");
}

fn write_coverage_report(path: &std::path::Path) {
    std::fs::write(
        path,
        r#"{
  "commit": "abc1234",
  "files_scanned": 2,
  "total_annotations": 0,
  "warnings": [],
  "annotations": [],
  "scanned_files": [{
    "file_path": "src/rules.rs",
    "content": "line one\nline two\nline three\nline four\nline five\nline six\nfn decide_bound_rule() {}\nline eight\nline nine\nline ten\nline eleven\nline twelve\nline thirteen\nline fourteen\nline fifteen\nline sixteen\nline seventeen\nline eighteen\nline nineteen\nline twenty\nline twenty-one\nline twenty-two\nline twenty-three\nline twenty-four\nline twenty-five\nline twenty-six\nline twenty-seven\nline twenty-eight\nline twenty-nine\nline thirty\nfn bound_rule_exhaustion() {}\n"
  }, {
    "file_path": "tests/rules.rs",
    "content": "test line one\ntest line two\ntest line three\ntest line four\ntest line five\ntest line six\ntest line seven\ntest line eight\ntest line nine\ntest line ten\ntest line eleven\nfn bound_rule_examples() {}\n"
  }],
  "bindings": [{
    "rule_id": "rule_bound",
    "file_path": "src/rules.rs",
    "line": 7,
    "item_name": "decide_bound_rule",
    "verification": null
  }, {
    "rule_id": "rule_bound",
    "file_path": "src/rules.rs",
    "line": 31,
    "item_name": "bound_rule_exhaustion",
    "verification": "exhaustion"
  }, {
    "rule_id": "rule_bound",
    "file_path": "tests/rules.rs",
    "line": 12,
    "item_name": "bound_rule_examples",
    "verification": "examples"
  }]
}"#,
    )
    .unwrap();
}

fn seed_rules(dir: &std::path::Path, repo: &std::path::Path) {
    Command::cargo_bin("provenance")
        .unwrap()
        .args([
            "init",
            "--path",
            &repo.to_string_lossy(),
            "--scope",
            "default",
            "--path-prefix",
            ".",
        ])
        .assert()
        .success();
    let state = dir.join("state.json");
    std::fs::write(
        &state,
        serde_json::json!({
          "scope": "default",
          "sources": [{
            "schema_version": SUPPORTED_SCHEMA_VERSION.0,
            "scope_id": "default",
            "id": "source_code",
            "name": "Code reference",
            "source_type": "project_artifact",
            "reference": "src/rules.rs:7"
          }],
          "requirements": [{
            "schema_version": SUPPORTED_SCHEMA_VERSION.0,
            "scope_id": "default",
            "id": "req_code",
            "statement": "The code reference shall be honoured.",
            "status": "active",
            "source_refs": [{"source_id": "source_code", "clause": null}]
          }],
          "resolutions": [],
          "rules": [{
            "schema_version": SUPPORTED_SCHEMA_VERSION.0,
            "scope_id": "default",
            "id": "rule_bound",
            "name": "Bound rule",
            "statement": "The bound decision is canonical.",
            "status": "active",
            "severity": "high",
            "requirement_ids": ["req_code"],
            "source_document": "docs/obsolete.md",
            "source_section": "old_description"
          }, {
            "schema_version": SUPPORTED_SCHEMA_VERSION.0,
            "scope_id": "default",
            "id": "rule_unbound",
            "name": "Unbound rule",
            "statement": "An absent binding is shown honestly.",
            "status": "active",
            "severity": "medium",
            "requirement_ids": ["req_code"]
          }],
          "threads": [],
          "messages": []
        })
        .to_string(),
    )
    .unwrap();
    Command::cargo_bin("provenance")
        .unwrap()
        .args([
            "import",
            "--repo",
            &repo.to_string_lossy(),
            "--scope",
            "default",
            "--input",
            &state.to_string_lossy(),
            "--format",
            "json",
        ])
        .assert()
        .success();
}

fn seed_implementation_binding(repo: &std::path::Path) {
    let shard = repo.join(".provenance/state/scopes/default/implementations/binding.jsonl");
    std::fs::create_dir_all(shard.parent().unwrap()).unwrap();
    std::fs::write(
        shard,
        serde_json::json!({"schema_version": SUPPORTED_SCHEMA_VERSION.0,"scope_id":"default","id":"implementation_binding_rule_bound","rule_id":"rule_bound","declared_by":"spec://typescript/payroll","file":"src/typed-rules.ts","symbol":"decide_typed_rule"}).to_string(),
    )
    .unwrap();
}

fn seed_git_remote(repo: &std::path::Path) {
    std::process::Command::new("git")
        .args(["init", repo.to_str().unwrap()])
        .output()
        .unwrap();
    std::process::Command::new("git")
        .args([
            "-C",
            repo.to_str().unwrap(),
            "remote",
            "add",
            "origin",
            "git@github.com:example/provenance.git",
        ])
        .output()
        .unwrap();
}

fn spawn_wiki_server(
    repo: &std::path::Path,
    port: u16,
    coverage: Option<&std::path::Path>,
) -> std::process::Child {
    let mut command = StdCommand::new(assert_cmd::cargo::cargo_bin("provenance"));
    command.args([
        "wiki",
        "serve",
        "--repo",
        repo.to_str().unwrap(),
        "--host",
        "127.0.0.1",
        "--port",
        &port.to_string(),
    ]);
    if let Some(coverage) = coverage {
        command.args(["--coverage", coverage.to_str().unwrap()]);
    }
    command.spawn().unwrap()
}

fn free_port() -> u16 {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    listener.local_addr().unwrap().port()
}

fn wait_for_http(port: u16, path: &str) -> String {
    let deadline = Instant::now() + Duration::from_secs(3);
    let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
    let mut last_error = None;
    while Instant::now() < deadline {
        match attempt_http_request(addr, path) {
            Ok(response) => return response,
            Err(error) => {
                last_error = Some(error);
                std::thread::sleep(Duration::from_millis(50));
            }
        }
    }
    panic!("server did not respond: {last_error:?}");
}

fn attempt_http_request(addr: SocketAddr, path: &str) -> std::io::Result<String> {
    let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(150))?;
    stream.set_read_timeout(Some(Duration::from_secs(2)))?;
    write!(
        stream,
        "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"
    )?;
    let mut response = String::new();
    stream.read_to_string(&mut response)?;
    Ok(response)
}