mod common;
use common::Sandbox;
use std::fs;
use std::process::Command;
fn git(dir: &std::path::Path, args: &[&str]) -> std::process::Output {
Command::new("git")
.current_dir(dir)
.args(args)
.output()
.expect("git")
}
fn fresh_git_repo() -> Sandbox {
let s = Sandbox::new();
s.init("doctor-test");
let _ = git(s.dir.path(), &["init", "-q", "-b", "main"]);
let _ = git(s.dir.path(), &["config", "user.email", "t@example.com"]);
let _ = git(s.dir.path(), &["config", "user.name", "Tester"]);
let _ = git(s.dir.path(), &["config", "commit.gpgsign", "false"]);
s
}
#[test]
fn req_0064_doctor_reports_missing_pre_commit() {
let s = fresh_git_repo();
let out = Command::new(env!("CARGO_BIN_EXE_req"))
.current_dir(s.dir.path())
.args(["--file", s.path().to_str().unwrap(), "doctor"])
.output()
.expect("invoke req");
assert!(
!out.status.success(),
"doctor should fail when nothing is configured"
);
let body = String::from_utf8_lossy(&out.stdout);
assert!(body.contains("pre-commit hook"));
assert!(body.contains("FAIL"));
}
#[test]
fn req_0064_doctor_passes_after_hooks_install() {
let s = fresh_git_repo();
let install = Command::new(env!("CARGO_BIN_EXE_req"))
.current_dir(s.dir.path())
.args([
"--file",
s.path().to_str().unwrap(),
"hooks",
"install",
"--force",
])
.output()
.expect("hooks install");
assert!(
install.status.success(),
"hooks install: {}",
String::from_utf8_lossy(&install.stderr)
);
let _ = git(
s.dir.path(),
&[
"config",
"merge.req-merge.driver",
"req renumber --base %O || true",
],
);
let out = Command::new(env!("CARGO_BIN_EXE_req"))
.current_dir(s.dir.path())
.args(["--file", s.path().to_str().unwrap(), "doctor", "--json"])
.output()
.expect("invoke req");
let body = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&body).expect("doctor --json shape");
let checks = v["checks"].as_array().expect("checks array");
let hook = checks
.iter()
.find(|c| c["name"] == "pre-commit hook")
.unwrap();
assert!(hook["ok"].as_bool().unwrap(), "pre-commit should be OK");
let pin = checks
.iter()
.find(|c| c["name"] == "gitattributes line-ending pin")
.unwrap();
assert!(
pin["ok"].as_bool().unwrap(),
"gitattributes pin should be OK"
);
}
#[test]
fn req_0103_doctor_surfaces_post_commit_hook_state() {
let s = fresh_git_repo();
let doctor_json = || {
let out = Command::new(env!("CARGO_BIN_EXE_req"))
.current_dir(s.dir.path())
.args(["--file", s.path().to_str().unwrap(), "doctor", "--json"])
.output()
.expect("invoke req");
let v: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("doctor --json");
v
};
let before = doctor_json();
let post = before["checks"]
.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == "post-commit hook")
.expect("doctor must surface the post-commit hook state");
assert!(
!post["ok"].as_bool().unwrap(),
"post-commit reported present before install"
);
let install = Command::new(env!("CARGO_BIN_EXE_req"))
.current_dir(s.dir.path())
.args([
"--file",
s.path().to_str().unwrap(),
"hooks",
"install",
"--force",
])
.output()
.expect("hooks install");
assert!(
install.status.success(),
"install: {}",
String::from_utf8_lossy(&install.stderr)
);
let after = doctor_json();
let post2 = after["checks"]
.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == "post-commit hook")
.unwrap();
assert!(
post2["ok"].as_bool().unwrap(),
"post-commit should be OK after install"
);
}
#[test]
fn req_0100_no_strict_downgrades_strict_hook() {
let s = fresh_git_repo();
let hook_path = s.dir.path().join(".git/hooks/pre-commit");
let path = s.path();
let path_s = path.to_str().unwrap();
let install = |extra: &[&str]| {
let mut a = vec!["--file", path_s, "hooks", "install", "--force"];
a.extend_from_slice(extra);
let out = Command::new(env!("CARGO_BIN_EXE_req"))
.current_dir(s.dir.path())
.args(&a)
.output()
.expect("install");
assert!(
out.status.success(),
"install {:?}: {}",
extra,
String::from_utf8_lossy(&out.stderr)
);
fs::read_to_string(&hook_path).unwrap()
};
assert!(
install(&["--strict"]).contains("# mode: strict"),
"--strict installs strict mode"
);
assert!(
install(&[]).contains("# mode: strict"),
"bare re-run preserves strict (no accidental downgrade)"
);
assert!(
install(&["--no-strict"]).contains("# mode: default"),
"--no-strict downgrades deterministically to default"
);
assert!(
install(&["--strict"]).contains("# mode: strict"),
"--strict upgrades again"
);
}
#[test]
fn req_0069_diff_reports_added_and_changed() {
let s = fresh_git_repo();
let add1 = s.run(&[
"add",
"--title",
"Baseline requirement here",
"--statement",
"The system shall start with this established baseline.",
"--rationale",
"Setup.",
"--kind",
"constraint",
"--priority",
"could",
]);
assert!(add1.status.success());
let _ = git(s.dir.path(), &["add", "project.req"]);
let _ = git(s.dir.path(), &["commit", "-q", "-m", "baseline"]);
let add2 = s.run(&[
"add",
"--title",
"Second requirement appears now",
"--statement",
"The system shall now also do this additional thing.",
"--rationale",
"Added.",
"--kind",
"constraint",
"--priority",
"could",
]);
assert!(add2.status.success());
for status in ["proposed", "approved", "implemented"] {
let upd = s.run(&[
"update",
"REQ-0001",
"--status",
status,
"--reason",
"Done in this branch",
]);
assert!(upd.status.success(), "step to {}", status);
}
let _ = git(s.dir.path(), &["add", "project.req"]);
let _ = git(s.dir.path(), &["commit", "-q", "-m", "head"]);
let out = Command::new(env!("CARGO_BIN_EXE_req"))
.current_dir(s.dir.path())
.args(["--file", s.path().to_str().unwrap(), "diff", "HEAD~1..HEAD"])
.output()
.expect("invoke req");
let body = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
body.contains("ADDED"),
"should report ADDED section: {}",
body
);
assert!(body.contains("REQ-0002"));
assert!(body.contains("CHANGED"));
assert!(body.contains("REQ-0001"));
assert!(body.contains("status:"));
}
#[test]
fn req_0069_diff_empty_when_no_changes() {
let s = fresh_git_repo();
s.run(&[
"add",
"--title",
"Single requirement only",
"--statement",
"The system shall have just this requirement, nothing more.",
"--rationale",
"Setup.",
"--kind",
"constraint",
"--priority",
"could",
]);
let _ = git(s.dir.path(), &["add", "project.req"]);
let _ = git(s.dir.path(), &["commit", "-q", "-m", "only"]);
let _ = git(
s.dir.path(),
&["commit", "-q", "--allow-empty", "-m", "empty"],
);
let out = Command::new(env!("CARGO_BIN_EXE_req"))
.current_dir(s.dir.path())
.args(["--file", s.path().to_str().unwrap(), "diff", "HEAD~1..HEAD"])
.output()
.expect("req diff");
let body = String::from_utf8_lossy(&out.stdout);
assert!(body.contains("no requirement-level changes"));
}
#[test]
fn req_0070_test_only_marker_is_distinct_from_referenced() {
use crate::common as common_alias;
let _ = common_alias::req(&["--help"]);
let s = Sandbox::new();
s.init("p");
let _ = s.run(&[
"add",
"--title",
"Impl-only requirement",
"--statement",
"The system shall be referenced from src only.",
"--rationale",
"Test fixture.",
"--kind",
"constraint",
"--priority",
"could",
]);
let _ = s.run(&[
"add",
"--title",
"Test-only requirement",
"--statement",
"The system shall be referenced from tests only.",
"--rationale",
"Test fixture.",
"--kind",
"constraint",
"--priority",
"could",
]);
fs::create_dir_all(s.dir.path().join("src")).unwrap();
fs::create_dir_all(s.dir.path().join("tests")).unwrap();
fs::write(
s.dir.path().join("src/lib.rs"),
"// REQ-0001 implementation site\nfn _foo() {}\n",
)
.unwrap();
fs::write(
s.dir.path().join("tests/coverage_test.rs"),
"// REQ-0002 test-only reference\nfn _t() {}\n",
)
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_req"))
.args([
"--file",
s.path().to_str().unwrap(),
"coverage",
"--path",
s.dir.path().to_str().unwrap(),
"--json",
])
.output()
.expect("coverage --json");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("coverage --json shape");
assert!(
v["referenced"]
.as_object()
.unwrap()
.contains_key("REQ-0001"),
"REQ-0001 should be referenced: {}",
v
);
assert!(
v["test_only"].as_object().unwrap().contains_key("REQ-0002"),
"REQ-0002 should be test-only: {}",
v
);
assert!(
!v["referenced"]
.as_object()
.unwrap()
.contains_key("REQ-0002"),
"REQ-0002 must NOT count as fully-referenced"
);
}
fn req_in(dir: &std::path::Path, file: &std::path::Path, args: &[&str]) -> std::process::Output {
let mut full: Vec<String> = vec!["--file".into(), file.to_str().unwrap().into()];
full.extend(args.iter().map(|s| s.to_string()));
Command::new(env!("CARGO_BIN_EXE_req"))
.current_dir(dir) .args(&full)
.env_remove("REQ_FILE")
.output()
.expect("invoke req")
}
#[test]
fn req_0159_renumber_rewrites_safety_links() {
let s = fresh_git_repo();
let dir = s.dir.path();
let file = s.path();
common::enable_safety(&file);
let _ = req_in(
dir,
&file,
&[
"hazard",
"add",
"-t",
"Base hazard",
"--harm",
"a hand is severed",
"-C",
"C_C",
"-F",
"F_B",
"-P",
"P_B",
"-W",
"W3",
],
);
let _ = req_in(
dir,
&file,
&[
"sf",
"add",
"-t",
"Guard interlock",
"--mitigates",
"HAZ-0001",
],
);
let _ = git(dir, &["add", "-A"]);
let _ = git(dir, &["commit", "-q", "-m", "base"]);
let up = req_in(
dir,
&file,
&[
"hazard",
"update",
"HAZ-0001",
"--title",
"Diverged hazard",
"--reason",
"simulate a merge-time id collision",
],
);
assert!(
up.status.success(),
"hazard update: {}",
String::from_utf8_lossy(&up.stderr)
);
let out = req_in(dir, &file, &["renumber", "--base", "main"]);
assert!(
out.status.success(),
"renumber: {}",
String::from_utf8_lossy(&out.stderr)
);
let body = String::from_utf8_lossy(&out.stdout);
assert!(
body.contains("HAZ-0001 -> HAZ-0002"),
"expected HAZ rename, got: {}",
body
);
let conform = req_in(dir, &file, &["conform"]);
assert!(
conform.status.success(),
"conform after renumber: {}",
String::from_utf8_lossy(&conform.stdout)
);
let trace = req_in(dir, &file, &["trace", "HAZ-0002"]);
let tbody = String::from_utf8_lossy(&trace.stdout);
assert!(
tbody.contains("SF-0001"),
"trace should link SF-0001 to HAZ-0002, got: {}",
tbody
);
}