use std::collections::BTreeMap;
use std::io::{Read, Write as _};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::Result;
use crate::coverage::{self, CoverageFile, CoverageKey, MatchSource, RunOutcome};
use crate::coverage_store;
use crate::git;
use crate::requirement::CoverageStatus;
use crate::verify::{self, Resolved, VerificationConfig};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EntryReport {
pub(crate) key: CoverageKey,
pub(crate) old_status: CoverageStatus,
pub(crate) new_status: CoverageStatus,
pub(crate) exit_code_only: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BackfillEntry {
pub(crate) key: CoverageKey,
pub(crate) status: CoverageStatus,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Report {
pub(crate) verified: Vec<EntryReport>,
pub(crate) backfill: Vec<BackfillEntry>,
}
impl Report {
pub(crate) fn backfill_count(&self) -> usize {
self.backfill.len()
}
pub(crate) fn exit_code_only_count(&self) -> usize {
self.verified.iter().filter(|e| e.exit_code_only).count()
}
}
enum RunResult {
Ran {
exit_ok: bool,
stdout: Vec<u8>,
stderr: Vec<u8>,
},
Unobtainable,
}
pub(crate) fn run(root: &Path, slice_ids: &[u32]) -> Result<Report> {
let cfg = coverage_store::load_config(root)?;
let head = git::head_sha(root);
let mut files: Vec<(u32, CoverageFile)> = Vec::with_capacity(slice_ids.len());
for &slice_id in slice_ids {
files.push((slice_id, coverage_store::load(root, slice_id)?));
}
let mut run_cache: BTreeMap<Vec<String>, RunResult> = BTreeMap::new();
let mut report = Report::default();
for (slice_id, file) in &mut files {
let mut changed = false;
for entry in &mut file.entry {
if entry.key.mode != "VT" {
continue;
}
let Some(check) = entry.check.clone() else {
report.backfill.push(BackfillEntry {
key: entry.key.clone(),
status: entry.status,
});
continue;
};
let outcome = match verify::resolve(&cfg, &check) {
Err(_) => RunOutcome::Unobtainable,
Ok(resolved) => {
let result = run_argv_cached(root, &cfg, &resolved.argv, &mut run_cache);
outcome_for(root, result, &check, &resolved)
}
};
let old_status = entry.status;
let new_status = coverage::derive_status(&outcome);
let ran = matches!(outcome, RunOutcome::Ran { .. });
if let (true, Some(h)) = (ran, &head) {
entry.git_anchor.clone_from(h);
}
entry.status = new_status;
changed = true;
let exit_code_only = check.command.is_some() && check.matcher.is_none();
report.verified.push(EntryReport {
key: entry.key.clone(),
old_status,
new_status,
exit_code_only,
});
}
if changed {
coverage_store::save(root, *slice_id, file)?;
}
}
Ok(report)
}
const SLICE_DIR: &str = ".doctrine/slice";
fn all_slice_ids(root: &Path) -> Result<Vec<u32>> {
let mut ids = Vec::new();
let dir = root.join(SLICE_DIR);
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ids),
Err(e) => return Err(anyhow::anyhow!("failed to read {}: {e}", dir.display())),
};
for entry in entries {
let entry = entry
.map_err(|e| anyhow::anyhow!("failed to read an entry in {}: {e}", dir.display()))?;
let file_type = entry
.file_type()
.map_err(|e| anyhow::anyhow!("failed to stat {}: {e}", entry.path().display()))?;
if file_type.is_symlink() {
continue;
}
if let Some(id) = entry
.file_name()
.to_str()
.and_then(|n| n.parse::<u32>().ok())
{
ids.push(id);
}
}
ids.sort_unstable();
Ok(ids)
}
pub(crate) fn run_cli(path: Option<PathBuf>, slice: Option<&str>, all: bool) -> Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let slice_ids = match (slice, all) {
(Some(_), true) => anyhow::bail!("pass a single <slice> OR --all, not both"),
(None, false) => anyhow::bail!("pass a single <slice> or --all"),
(Some(s), false) => vec![crate::slice::parse_ref(s)?],
(None, true) => all_slice_ids(&root)?,
};
let report = run(&root, &slice_ids)?;
print_report(&report)
}
fn status_label(status: CoverageStatus) -> String {
format!("{status:?}")
}
fn print_report(report: &Report) -> Result<()> {
let mut out = std::io::stdout();
for e in &report.verified {
let flag = if e.exit_code_only {
" [exit-code-only]"
} else {
""
};
let (old, new) = (status_label(e.old_status), status_label(e.new_status));
writeln!(
out,
"{}/{}/{}/{}: {old}→{new}{flag}",
e.key.slice, e.key.requirement, e.key.contributing_change, e.key.mode,
)?;
}
for b in &report.backfill {
writeln!(
out,
"{}/{}/{}/{}: no check — backfill",
b.key.slice, b.key.requirement, b.key.contributing_change, b.key.mode,
)?;
}
writeln!(
out,
"{} VT entries lack a check — backfill",
report.backfill_count(),
)?;
if report.exit_code_only_count() > 0 {
writeln!(
out,
"{} exit-code-only cells (no matcher) — audit",
report.exit_code_only_count(),
)?;
}
Ok(())
}
fn outcome_for(
root: &Path,
result: &RunResult,
check: &coverage::VtCheck,
resolved: &Resolved,
) -> RunOutcome {
let (exit_ok, stdout, stderr) = match result {
RunResult::Unobtainable => return RunOutcome::Unobtainable,
RunResult::Ran {
exit_ok,
stdout,
stderr,
} => (*exit_ok, stdout, stderr),
};
let Some(matcher) = &check.matcher else {
return RunOutcome::Ran {
exit_ok,
matched: None,
};
};
let haystack = match &resolved.source {
MatchSource::Stdout => String::from_utf8_lossy(stdout).into_owned(),
MatchSource::Stderr => String::from_utf8_lossy(stderr).into_owned(),
MatchSource::File(glob) => match read_file_haystack(root, glob) {
Some(text) => text,
None => return RunOutcome::Unobtainable,
},
};
match coverage::evaluate_matcher(&matcher.pattern, matcher.regex, &haystack) {
Some(matched) => RunOutcome::Ran {
exit_ok,
matched: Some(matched),
},
None => RunOutcome::Unobtainable,
}
}
fn read_file_haystack(root: &Path, glob: &str) -> Option<String> {
let root_canon = std::fs::canonicalize(root).ok()?;
let pattern = root.join(glob);
let pattern_str = pattern.to_str()?;
let paths = glob::glob(pattern_str).ok()?;
let mut concatenated = String::new();
let mut any = false;
for entry in paths {
let path = entry.ok()?;
let canon = std::fs::canonicalize(&path).ok()?;
if !canon.starts_with(&root_canon) {
return None;
}
let body = std::fs::read_to_string(&canon).ok()?;
concatenated.push_str(&body);
any = true;
}
if any { Some(concatenated) } else { None }
}
fn run_argv_cached<'cache>(
root: &Path,
cfg: &VerificationConfig,
argv: &[String],
cache: &'cache mut BTreeMap<Vec<String>, RunResult>,
) -> &'cache RunResult {
cache
.entry(argv.to_vec())
.or_insert_with(|| run_argv(root, cfg, argv))
}
fn run_argv(root: &Path, cfg: &VerificationConfig, argv: &[String]) -> RunResult {
let Some((program, args)) = argv.split_first() else {
return RunResult::Unobtainable; };
let spawned = Command::new(program)
.args(args)
.current_dir(root)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let Ok(mut child) = spawned else {
return RunResult::Unobtainable;
};
let stdout_handle = child.stdout.take();
let stderr_handle = child.stderr.take();
let oh = thread::spawn(move || drain(stdout_handle));
let eh = thread::spawn(move || drain(stderr_handle));
let deadline = Instant::now() + Duration::from_secs(cfg.timeout_secs());
loop {
match child.try_wait() {
Ok(Some(status)) => {
let stdout = oh.join().unwrap_or_default();
let stderr = eh.join().unwrap_or_default();
return RunResult::Ran {
exit_ok: status.success(),
stdout,
stderr,
};
}
Ok(None) if Instant::now() >= deadline => {
reap(&mut child, oh, eh);
return RunResult::Unobtainable; }
Ok(None) => thread::sleep(Duration::from_millis(50)),
Err(_) => {
reap(&mut child, oh, eh);
return RunResult::Unobtainable;
}
}
}
}
fn reap(
child: &mut std::process::Child,
oh: thread::JoinHandle<Vec<u8>>,
eh: thread::JoinHandle<Vec<u8>>,
) {
drop(child.kill());
drop(child.wait());
drop(oh.join());
drop(eh.join());
}
fn drain<R: Read + Send + 'static>(handle: Option<R>) -> Vec<u8> {
let mut buf = Vec::new();
if let Some(mut h) = handle {
drop(h.read_to_end(&mut buf));
}
buf
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests: fail-fast unwrap on disk round-trip / spawn is idiomatic"
)]
mod tests {
use super::*;
use crate::coverage::{CoverageEntry, Matcher, VtCheck};
fn key(slice: &str, req: &str, change: &str, mode: &str) -> CoverageKey {
CoverageKey {
slice: slice.to_owned(),
requirement: req.to_owned(),
contributing_change: change.to_owned(),
mode: mode.to_owned(),
}
}
fn entry(k: CoverageKey, status: CoverageStatus, check: Option<VtCheck>) -> CoverageEntry {
CoverageEntry {
key: k,
status,
git_anchor: "prior-anchor".to_owned(),
attested_date: None,
touched_paths: vec!["src/x.rs".to_owned()],
check,
}
}
fn cmd_check(command: Vec<&str>, matcher: Option<Matcher>) -> VtCheck {
VtCheck {
alias: None,
command: Some(command.into_iter().map(str::to_owned).collect()),
extra_args: Vec::new(),
matcher,
}
}
fn matcher(source: Option<MatchSource>, pattern: &str, regex: bool) -> Matcher {
Matcher {
source,
pattern: pattern.to_owned(),
regex,
}
}
fn seed(root: &Path, slice_id: u32, entries: Vec<CoverageEntry>) {
let file = CoverageFile { entry: entries };
coverage_store::save(root, slice_id, &file).unwrap();
}
fn reload(root: &Path, slice_id: u32) -> CoverageFile {
coverage_store::load(root, slice_id).unwrap()
}
fn write_doctrine_toml(root: &Path, body: &str) {
let path = root.join(crate::dtoml::DOCTRINE_TOML);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, body).unwrap();
}
fn status_of(file: &CoverageFile, req: &str) -> CoverageStatus {
file.entry
.iter()
.find(|e| e.key.requirement == req)
.unwrap()
.status
}
#[test]
fn distinct_argv_runs_exactly_once_across_entries() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let argv = vec!["sh", "-c", "printf 'x\\n' >> counter.txt"];
let entries = vec![
entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(argv.clone(), None)),
),
entry(
key("SL-057", "REQ-201", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(argv.clone(), None)),
),
entry(
key("SL-057", "REQ-202", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(argv.clone(), None)),
),
];
seed(root, 57, entries);
run(root, &[57]).unwrap();
let counter = std::fs::read_to_string(root.join("counter.txt")).unwrap();
assert_eq!(
counter.lines().count(),
1,
"the shared argv ran exactly once across all three entries (INV-2)"
);
}
#[test]
fn dedup_spans_multiple_slices() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let argv = vec!["sh", "-c", "printf 'x\\n' >> counter.txt"];
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(argv.clone(), None)),
)],
);
seed(
root,
58,
vec![entry(
key("SL-058", "REQ-300", "SL-058", "VT"),
CoverageStatus::Planned,
Some(cmd_check(argv.clone(), None)),
)],
);
run(root, &[57, 58]).unwrap();
let counter = std::fs::read_to_string(root.join("counter.txt")).unwrap();
assert_eq!(
counter.lines().count(),
1,
"dedup spans every slice in the invocation (F-2/INV-2)"
);
}
#[test]
fn exit_zero_with_matcher_hit_is_verified() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(
vec!["sh", "-c", "printf MARKER"],
Some(matcher(Some(MatchSource::Stdout), "MARKER", false)),
)),
)],
);
run(root, &[57]).unwrap();
assert_eq!(
status_of(&reload(root, 57), "REQ-200"),
CoverageStatus::Verified
);
}
#[test]
fn nonzero_exit_is_failed() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(vec!["false"], None)),
)],
);
run(root, &[57]).unwrap();
assert_eq!(
status_of(&reload(root, 57), "REQ-200"),
CoverageStatus::Failed
);
}
#[test]
fn matcher_miss_is_failed() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(
vec!["true"],
Some(matcher(Some(MatchSource::Stdout), "ABSENT", false)),
)),
)],
);
run(root, &[57]).unwrap();
assert_eq!(
status_of(&reload(root, 57), "REQ-200"),
CoverageStatus::Failed,
"exit 0 but matcher miss ⇒ Failed"
);
}
#[test]
fn unknown_alias_is_blocked() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let check = VtCheck {
alias: Some("missing".to_owned()),
command: None,
extra_args: Vec::new(),
matcher: Some(matcher(Some(MatchSource::Stdout), "ok", false)),
};
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(check),
)],
);
run(root, &[57]).unwrap();
assert_eq!(
status_of(&reload(root, 57), "REQ-200"),
CoverageStatus::Blocked,
"unresolvable alias ⇒ Blocked (never run)"
);
}
#[test]
fn spawn_failure_is_blocked() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(vec!["this-binary-does-not-exist-xyzzy"], None)),
)],
);
run(root, &[57]).unwrap();
assert_eq!(
status_of(&reload(root, 57), "REQ-200"),
CoverageStatus::Blocked,
"a spawn failure ⇒ Blocked, never silent-Failed (F-VII)"
);
}
#[test]
fn wall_clock_timeout_is_blocked() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_doctrine_toml(root, "[verification]\ntimeout-secs = 1\n");
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(vec!["sleep", "5"], None)),
)],
);
run(root, &[57]).unwrap();
assert_eq!(
status_of(&reload(root, 57), "REQ-200"),
CoverageStatus::Blocked,
"a wall-clock timeout ⇒ Blocked (F-VII)"
);
}
#[test]
fn absent_doctrine_toml_default_base_is_blocked_not_green() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let check = VtCheck {
alias: None,
command: None,
extra_args: Vec::new(),
matcher: Some(matcher(Some(MatchSource::Stdout), "ok", false)),
};
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(check),
)],
);
run(root, &[57]).unwrap();
assert_eq!(
status_of(&reload(root, 57), "REQ-200"),
CoverageStatus::Blocked,
"absent config + default-base ⇒ Blocked, never green"
);
}
#[test]
fn stderr_source_matcher_reads_stderr() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(
vec!["sh", "-c", "printf ERRMARK >&2"],
Some(matcher(Some(MatchSource::Stderr), "ERRMARK", false)),
)),
)],
);
run(root, &[57]).unwrap();
assert_eq!(
status_of(&reload(root, 57), "REQ-200"),
CoverageStatus::Verified,
"the matcher reads the captured stderr"
);
}
#[test]
fn ran_entry_restamps_anchor_blocked_keeps_prior() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
init_repo(root);
let head = git::head_sha(root).expect("repo has a HEAD");
seed(
root,
57,
vec![
entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(vec!["true"], None)),
),
entry(
key("SL-057", "REQ-201", "SL-057", "VT"),
CoverageStatus::Planned,
Some(VtCheck {
alias: Some("missing".to_owned()),
command: None,
extra_args: Vec::new(),
matcher: Some(matcher(Some(MatchSource::Stdout), "ok", false)),
}),
),
],
);
run(root, &[57]).unwrap();
let file = reload(root, 57);
let ran = file
.entry
.iter()
.find(|e| e.key.requirement == "REQ-200")
.unwrap();
let blocked = file
.entry
.iter()
.find(|e| e.key.requirement == "REQ-201")
.unwrap();
assert_eq!(ran.git_anchor, head, "a Ran observation re-stamps to HEAD");
assert_eq!(ran.status, CoverageStatus::Verified);
assert_eq!(
blocked.git_anchor, "prior-anchor",
"a Blocked cell KEEPS its prior anchor (F-VIII)"
);
assert_eq!(blocked.status, CoverageStatus::Blocked);
}
#[test]
fn command_runs_with_cwd_at_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("present.txt"), "MARK").unwrap();
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(
vec!["cat", "present.txt"],
Some(matcher(Some(MatchSource::Stdout), "MARK", false)),
)),
)],
);
run(root, &[57]).unwrap();
assert_eq!(
status_of(&reload(root, 57), "REQ-200"),
CoverageStatus::Verified,
"the command ran with cwd == root (read a root-relative file)"
);
}
#[test]
fn file_source_matcher_reads_under_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("report.txt"), "PASS here").unwrap();
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(
vec!["true"],
Some(matcher(
Some(MatchSource::File("report.txt".to_owned())),
"PASS",
false,
)),
)),
)],
);
run(root, &[57]).unwrap();
assert_eq!(
status_of(&reload(root, 57), "REQ-200"),
CoverageStatus::Verified,
"a File matcher reads the file under root and matches"
);
}
#[test]
fn ascending_file_glob_finds_nothing_is_blocked() {
let outer = tempfile::tempdir().unwrap();
std::fs::write(outer.path().join("secret.txt"), "PASS").unwrap();
let root = outer.path().join("inner");
std::fs::create_dir_all(&root).unwrap();
seed(
&root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(
vec!["true"],
Some(matcher(
Some(MatchSource::File("../secret.txt".to_owned())),
"PASS",
false,
)),
)),
)],
);
run(&root, &[57]).unwrap();
assert_eq!(
status_of(&reload(&root, 57), "REQ-200"),
CoverageStatus::Blocked,
"a ..-escaping glob finds nothing under root ⇒ Blocked"
);
}
#[test]
fn unparseable_regex_matcher_is_blocked() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(
vec!["true"],
Some(matcher(Some(MatchSource::Stdout), "(", true)),
)),
)],
);
run(root, &[57]).unwrap();
assert_eq!(
status_of(&reload(root, 57), "REQ-200"),
CoverageStatus::Blocked,
"an unparseable regex ⇒ Blocked, never a silent Failed (F-3)"
);
}
#[test]
fn report_flags_exit_code_only_and_backfill_untouched() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed(
root,
57,
vec![
entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(vec!["true"], None)),
),
entry(
key("SL-057", "REQ-201", "SL-057", "VT"),
CoverageStatus::InProgress,
None,
),
],
);
let report = run(root, &[57]).unwrap();
assert_eq!(
report.exit_code_only_count(),
1,
"the literal-command cell is flagged"
);
let flagged = report
.verified
.iter()
.find(|e| e.key.requirement == "REQ-200")
.unwrap();
assert!(flagged.exit_code_only);
assert_eq!(flagged.old_status, CoverageStatus::Planned);
assert_eq!(flagged.new_status, CoverageStatus::Verified);
assert_eq!(
report.backfill_count(),
1,
"the check-less VT is counted for backfill"
);
assert_eq!(report.backfill.first().unwrap().key.requirement, "REQ-201");
let file = reload(root, 57);
assert_eq!(
status_of(&file, "REQ-201"),
CoverageStatus::InProgress,
"the check-less VT entry is left untouched"
);
}
#[test]
fn write_back_preserves_key_touched_paths_and_check() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let check = cmd_check(vec!["true"], None);
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(check.clone()),
)],
);
run(root, &[57]).unwrap();
let file = reload(root, 57);
let e = file.entry.first().unwrap();
assert_eq!(
e.key,
key("SL-057", "REQ-200", "SL-057", "VT"),
"key preserved"
);
assert_eq!(
e.touched_paths,
vec!["src/x.rs".to_owned()],
"touched_paths preserved"
);
assert_eq!(e.check.as_ref(), Some(&check), "check preserved");
assert_eq!(e.status, CoverageStatus::Verified, "status re-derived");
}
#[test]
fn non_vt_entries_are_left_untouched() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed(
root,
57,
vec![entry(
key("SL-057", "REQ-200", "SL-057", "VH"),
CoverageStatus::Verified,
None,
)],
);
let report = run(root, &[57]).unwrap();
assert!(report.verified.is_empty(), "no VT entry to verify");
assert!(
report.backfill.is_empty(),
"a VH entry is not a backfill candidate"
);
assert_eq!(
status_of(&reload(root, 57), "REQ-200"),
CoverageStatus::Verified,
"the VH entry's status is untouched"
);
}
#[test]
fn run_mutates_only_coverage_toml_not_a_sibling_entity_file() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let req_dir = root.join(".doctrine/requirement/200");
std::fs::create_dir_all(&req_dir).unwrap();
let req_file = req_dir.join("requirement-200.toml");
let req_body =
"id = 200\ntitle = \"x\"\nslug = \"x\"\nstatus = \"active\"\nkind = \"functional\"\n";
std::fs::write(&req_file, req_body).unwrap();
seed(
root,
57,
vec![
entry(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(vec!["true"], None)),
),
entry(
key("SL-057", "REQ-201", "SL-057", "VT"),
CoverageStatus::Planned,
Some(cmd_check(vec!["false"], None)),
),
entry(
key("SL-057", "REQ-202", "SL-057", "VT"),
CoverageStatus::Planned,
Some(VtCheck {
alias: Some("missing".to_owned()),
command: None,
extra_args: Vec::new(),
matcher: Some(matcher(Some(MatchSource::Stdout), "ok", false)),
}),
),
],
);
run(root, &[57]).unwrap();
assert_eq!(
std::fs::read_to_string(&req_file).unwrap(),
req_body,
"run() drove the write seam and left the requirement entity byte-identical (NF-001/INV-1)"
);
let file = reload(root, 57);
assert_eq!(status_of(&file, "REQ-200"), CoverageStatus::Verified);
assert_eq!(status_of(&file, "REQ-201"), CoverageStatus::Failed);
assert_eq!(status_of(&file, "REQ-202"), CoverageStatus::Blocked);
}
fn init_repo(root: &Path) {
let run_git = |args: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.unwrap();
};
run_git(&["init", "-q"]);
run_git(&["config", "user.email", "t@t.t"]);
run_git(&["config", "user.name", "t"]);
std::fs::write(root.join("seed"), "x").unwrap();
run_git(&["add", "-A"]);
run_git(&["commit", "-q", "-m", "seed"]);
}
}