req-cli 0.5.0-rc.7

Managed requirements CLI for LLM agents and humans
// REQ-0112: content-hashing for test record staleness. New records
// carry a sha256 of the linked-file contents; `req stale` uses the
// hash (when present) so STALE fires only on actual content change,
// not on every HEAD movement.
mod common;
use common::{stderr, stdout, Sandbox};
use std::fs;
use std::process::Command;

fn init_git_repo(path: &std::path::Path) {
    let _ = Command::new("git")
        .args(["init", "-q"])
        .current_dir(path)
        .output()
        .expect("git init");
    let _ = Command::new("git")
        .args(["config", "user.email", "t@e"])
        .current_dir(path)
        .output();
    let _ = Command::new("git")
        .args(["config", "user.name", "t"])
        .current_dir(path)
        .output();
    let _ = Command::new("git")
        .args(["config", "commit.gpgsign", "false"])
        .current_dir(path)
        .output();
}

fn git_commit(path: &std::path::Path, msg: &str) {
    let _ = Command::new("git")
        .args(["add", "-A"])
        .current_dir(path)
        .output();
    let _ = Command::new("git")
        .args(["commit", "-q", "-m", msg])
        .current_dir(path)
        .output();
}

#[test]
fn req_0112_record_carries_content_hash_when_marker_present() {
    let s = Sandbox::new();
    s.init("p");
    init_git_repo(s.dir.path());

    let out = s.run(&[
        "add",
        "--title",
        "Hashed requirement here",
        "--statement",
        "The system shall be referenced from a source file with a marker.",
        "--rationale",
        "Content-hash fixture.",
        "--kind",
        "constraint",
        "--priority",
        "could",
    ]);
    assert!(out.status.success(), "add: {}", stderr(&out));

    // Drop a source file with the marker.
    fs::create_dir_all(s.dir.path().join("src")).unwrap();
    fs::write(
        s.dir.path().join("src/lib.rs"),
        "// REQ-0001: this implementation\nfn _ok() {}\n",
    )
    .unwrap();
    git_commit(s.dir.path(), "initial");

    // Record a passing test. The test_record path uses the binary's
    // working directory for auto-discovery, so jump into the sandbox
    // for this call by absolute --path tricks.
    let abs = s.dir.path().to_path_buf();
    let out = Command::new(env!("CARGO_BIN_EXE_req"))
        .current_dir(&abs)
        .args([
            "--file",
            s.path().to_str().unwrap(),
            "test",
            "record",
            "REQ-0001",
            "--result",
            "pass",
            "--notes",
            "content-hash test",
        ])
        .output()
        .expect("test record");
    assert!(
        out.status.success(),
        "test record: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let show = stdout(&s.run(&["show", "REQ-0001", "--json"]));
    assert!(
        show.contains("\"content_hash\""),
        "record should carry content_hash: {}",
        show
    );
    assert!(
        show.contains("\"linked_files\""),
        "record should carry linked_files: {}",
        show
    );
}

#[test]
fn req_0112_stale_fires_only_on_actual_content_change() {
    let s = Sandbox::new();
    s.init("p");
    init_git_repo(s.dir.path());

    let _ = s.run(&[
        "add",
        "--title",
        "Stale tracking target",
        "--statement",
        "The system shall be content-stable across unrelated commits.",
        "--rationale",
        "Stale-flap fixture.",
        "--kind",
        "constraint",
        "--priority",
        "could",
    ]);
    fs::create_dir_all(s.dir.path().join("src")).unwrap();
    fs::write(
        s.dir.path().join("src/lib.rs"),
        "// REQ-0001: anchor\nfn _ok() {}\n",
    )
    .unwrap();
    git_commit(s.dir.path(), "initial");

    let abs = s.dir.path().to_path_buf();
    let rec = Command::new(env!("CARGO_BIN_EXE_req"))
        .current_dir(&abs)
        .args([
            "--file",
            s.path().to_str().unwrap(),
            "test",
            "record",
            "REQ-0001",
            "--result",
            "pass",
            "--notes",
            "baseline",
        ])
        .output()
        .expect("test record");
    assert!(
        rec.status.success(),
        "{}",
        String::from_utf8_lossy(&rec.stderr)
    );

    // Touch an UNRELATED file and commit — HEAD moves but the linked
    // file's content is unchanged, so under content-hashing this must
    // NOT be flagged stale.
    fs::write(s.dir.path().join("README.md"), "unrelated change\n").unwrap();
    git_commit(s.dir.path(), "unrelated");

    let stale = Command::new(env!("CARGO_BIN_EXE_req"))
        .current_dir(&abs)
        .args([
            "--file",
            s.path().to_str().unwrap(),
            "stale",
            "--path",
            abs.to_str().unwrap(),
            "--json",
        ])
        .output()
        .expect("stale");
    let body = String::from_utf8_lossy(&stale.stdout);
    assert!(
        body.contains("\"stale\": 0") || body.contains("\"state\": \"fresh\""),
        "content-hash should keep STALE quiet on unrelated commits: {}",
        body
    );

    // Now actually modify the linked file. Content-hash should now
    // fire STALE.
    fs::write(
        s.dir.path().join("src/lib.rs"),
        "// REQ-0001: anchor\nfn _ok() { /* changed */ }\n",
    )
    .unwrap();
    git_commit(s.dir.path(), "real change");

    let stale2 = Command::new(env!("CARGO_BIN_EXE_req"))
        .current_dir(&abs)
        .args([
            "--file",
            s.path().to_str().unwrap(),
            "stale",
            "--path",
            abs.to_str().unwrap(),
            "--json",
        ])
        .output()
        .expect("stale 2");
    let body2 = String::from_utf8_lossy(&stale2.stdout);
    assert!(
        body2.contains("\"STALE\"") || body2.contains("\"stale\": 1"),
        "content-hash should fire STALE when linked file content changes: {}",
        body2
    );
}

#[test]
fn req_0112_old_record_without_hash_falls_back_to_sha() {
    // Older records (no content_hash) should still work via the
    // original SHA-based comparison. We don't test the full path
    // here because creating an old-shape record requires direct edit
    // + repair — and the field is added with serde default + skip
    // when None, so an absent content_hash is the legitimate "old
    // record" case. The behavioural contract: stale doesn't panic
    // and produces a non-error report on a sandbox with no records.
    let s = Sandbox::new();
    s.init("p");
    let out = s.run(&["stale", "--json"]);
    assert!(
        out.status.success(),
        "stale should succeed on empty: {}",
        stderr(&out)
    );
}

/// REQ-0153: `refresh-anchors` must NEVER silently rehash a dossier whose
/// source genuinely changed — it only re-normalizes anchors proven unchanged.
/// A fresh anchor is left alone; a drifted one is reported (not refreshed).
#[test]
fn req_0153_refresh_never_rehashes_drifted_source() {
    let s = Sandbox::new();
    s.init("p");
    init_git_repo(s.dir.path());
    let abs = s.dir.path().to_path_buf();
    let file = s.path().to_str().unwrap().to_string();
    let run_in = |args: &[&str]| {
        Command::new(env!("CARGO_BIN_EXE_req"))
            .current_dir(&abs)
            .args(["--file", &file])
            .args(args)
            .output()
            .expect("req")
    };

    run_in(&[
        "add",
        "--title",
        "Hashed requirement here",
        "--statement",
        "The system shall be referenced from a marked source file.",
        "--rationale",
        "Refresh fixture.",
        "--kind",
        "constraint",
        "--priority",
        "could",
    ]);
    fs::create_dir_all(abs.join("src")).unwrap();
    fs::write(abs.join("src/lib.rs"), "// REQ-0001: impl\nfn a() {}\n").unwrap();
    git_commit(&abs, "init");
    for st in ["proposed", "approved", "implemented"] {
        run_in(&["update", "REQ-0001", "--status", st, "--reason", "x"]);
    }
    run_in(&["verification", "plan", "REQ-0001", "--plan", "review"]);
    run_in(&[
        "verification",
        "analysis",
        "REQ-0001",
        "--findings",
        "ok",
        "--result",
        "pass",
    ]);
    run_in(&[
        "verification",
        "test",
        "REQ-0001",
        "--findings",
        "ok",
        "--result",
        "pass",
    ]);
    let c = run_in(&[
        "verification",
        "conclude",
        "REQ-0001",
        "--statement",
        "met",
        "--promote",
    ]);
    assert!(
        c.status.success(),
        "conclude: {}",
        String::from_utf8_lossy(&c.stderr)
    );

    // Freshly anchored (new-format hash) -> nothing to refresh, not drifted.
    let fresh = run_in(&["verification", "refresh-anchors", "--path", ".", "--json"]);
    let jf = String::from_utf8_lossy(&fresh.stdout);
    assert!(
        jf.contains("\"refreshed\": []"),
        "a fresh anchor must not be refreshed: {jf}"
    );

    // Genuinely change the linked source.
    fs::write(
        abs.join("src/lib.rs"),
        "// REQ-0001: impl\nfn a() { let _x = 1; }\n",
    )
    .unwrap();

    // Drifted source must be reported, NOT rehashed.
    let out = run_in(&["verification", "refresh-anchors", "--path", ".", "--json"]);
    let j = String::from_utf8_lossy(&out.stdout);
    assert!(
        j.contains("\"refreshed\": []"),
        "a genuinely drifted source must never be refreshed: {j}"
    );
    assert!(
        j.contains("REQ-0001"),
        "the drifted requirement should be reported: {j}"
    );
}