use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::coverage::{
self, CoverageEntry, CoverageFile, CoverageKey, MatchSource, Matcher, VtCheck,
};
use crate::fsutil;
use crate::git;
use crate::requirement::CoverageStatus;
use crate::verify::{self, VerificationConfig};
const SLICE_DIR: &str = ".doctrine/slice";
fn coverage_path(root: &Path, slice_id: u32) -> PathBuf {
root.join(SLICE_DIR)
.join(format!("{slice_id:03}"))
.join("coverage.toml")
}
pub(crate) fn load(root: &Path, slice_id: u32) -> Result<CoverageFile> {
let path = coverage_path(root, slice_id);
match fs::read_to_string(&path) {
Ok(body) => coverage::parse(&body)
.map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(CoverageFile::default()),
Err(e) => Err(anyhow::anyhow!("failed to read {}: {e}", path.display())),
}
}
pub(crate) fn save(root: &Path, slice_id: u32, file: &CoverageFile) -> Result<()> {
let path = coverage_path(root, slice_id);
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)
.map_err(|e| anyhow::anyhow!("failed to create {}: {e}", dir.display()))?;
}
let body = coverage::render(file)?;
fsutil::write_atomic(&path, body.as_bytes())
}
pub(crate) struct RecordInput {
pub(crate) key: CoverageKey,
pub(crate) status: CoverageStatus,
pub(crate) check: Option<VtCheck>,
pub(crate) touched_paths: Vec<String>,
}
pub(crate) fn record(
root: &Path,
slice_id: u32,
input: RecordInput,
cfg: &VerificationConfig,
today: &str,
attested_override: Option<&str>,
) -> Result<()> {
let RecordInput {
key,
status,
check,
touched_paths,
} = input;
if let Some(check) = &check {
coverage::valid(check).map_err(|e| anyhow::anyhow!("invalid VT-check: {e:?}"))?;
verify::resolve(cfg, check).map_err(|e| anyhow::anyhow!("unresolvable VT-check: {e:?}"))?;
}
let is_vt = check.is_some();
let (status, attested_date) = if is_vt {
(CoverageStatus::Planned, None)
} else {
let date = attested_override.unwrap_or(today).to_owned();
(status, Some(date))
};
let entry = CoverageEntry {
key,
status,
git_anchor: git::head_sha(root).unwrap_or_default(),
attested_date,
touched_paths,
check,
};
let mut file = load(root, slice_id)?;
coverage::upsert(&mut file, entry);
save(root, slice_id, &file)
}
pub(crate) fn forget(
root: &Path,
slice_id: u32,
key: &CoverageKey,
) -> Result<Option<(CoverageKey, CoverageStatus)>> {
let mut file = load(root, slice_id)?;
let Some(pos) = file.entry.iter().position(|e| &e.key == key) else {
return Ok(None);
};
let removed = file.entry.remove(pos);
save(root, slice_id, &file)?;
Ok(Some((removed.key, removed.status)))
}
pub(crate) fn withdrawal_line(key: &CoverageKey, status: CoverageStatus) -> String {
format!(
"withdrew {}/{}/{}/{} [{status:?}]",
key.slice, key.requirement, key.contributing_change, key.mode,
)
}
pub(crate) fn load_config(root: &Path) -> Result<VerificationConfig> {
let path = root.join("doctrine.toml");
match fs::read_to_string(&path) {
Ok(text) => Ok(crate::dtoml::parse(&text)?.verification),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(VerificationConfig::default()),
Err(e) => Err(anyhow::anyhow!("failed to read {}: {e}", path.display())),
}
}
fn slice_key(id: u32) -> String {
format!("SL-{id:03}")
}
fn canonical_slice_ref(reference: &str) -> Result<String> {
Ok(slice_key(crate::slice::parse_ref(reference)?))
}
pub(crate) fn parse_status(s: &str) -> Result<CoverageStatus, String> {
match s {
"planned" => Ok(CoverageStatus::Planned),
"in-progress" => Ok(CoverageStatus::InProgress),
"verified" => Ok(CoverageStatus::Verified),
"failed" => Ok(CoverageStatus::Failed),
"blocked" => Ok(CoverageStatus::Blocked),
other => Err(format!(
"unknown status `{other}` (expected planned|in-progress|verified|failed|blocked)"
)),
}
}
pub(crate) struct CoverageRecordArgs<'a> {
pub(crate) slice: &'a str,
pub(crate) requirement: &'a str,
pub(crate) change: &'a str,
pub(crate) mode: &'a str,
pub(crate) status: Option<CoverageStatus>,
pub(crate) alias: Option<&'a str>,
pub(crate) command: &'a [String],
pub(crate) extra_args: &'a [String],
pub(crate) matcher_source: Option<&'a str>,
pub(crate) matcher_pattern: Option<&'a str>,
pub(crate) regex: bool,
pub(crate) attested_date: Option<&'a str>,
}
impl CoverageRecordArgs<'_> {
fn has_check(&self) -> bool {
self.alias.is_some()
|| !self.command.is_empty()
|| !self.extra_args.is_empty()
|| self.matcher_source.is_some()
|| self.matcher_pattern.is_some()
|| self.regex
}
fn matcher(&self) -> Result<Option<Matcher>> {
if self.matcher_source.is_none() && self.matcher_pattern.is_none() && !self.regex {
return Ok(None);
}
let source = match self.matcher_source {
Some(s) => Some(
MatchSource::try_from(s.to_owned())
.map_err(|e| anyhow::anyhow!("invalid --matcher-source: {e}"))?,
),
None => None,
};
Ok(Some(Matcher {
source,
pattern: self.matcher_pattern.unwrap_or_default().to_owned(),
regex: self.regex,
}))
}
fn check(&self) -> Result<VtCheck> {
Ok(VtCheck {
alias: self.alias.map(str::to_owned),
command: if self.command.is_empty() {
None
} else {
Some(self.command.to_vec())
},
extra_args: self.extra_args.to_vec(),
matcher: self.matcher()?,
})
}
}
pub(crate) fn run_record(path: Option<PathBuf>, args: &CoverageRecordArgs<'_>) -> Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let slice_id = crate::slice::parse_ref(args.slice)?;
if !crate::coverage::mode_is_valid(args.mode) {
anyhow::bail!("invalid --mode `{}` (expected VT|VA|VH)", args.mode);
}
let key = CoverageKey {
slice: slice_key(slice_id),
requirement: crate::requirement::canonicalize_fk(args.requirement),
contributing_change: canonical_slice_ref(args.change)?,
mode: args.mode.to_owned(),
};
let check = if args.has_check() {
Some(args.check()?)
} else {
None
};
let status = args.status.unwrap_or(CoverageStatus::Verified);
let cfg = load_config(&root)?;
let today = crate::clock::today();
record(
&root,
slice_id,
RecordInput {
key: key.clone(),
status,
check,
touched_paths: Vec::new(),
},
&cfg,
&today,
args.attested_date,
)?;
writeln!(
std::io::stdout(),
"recorded {}/{}/{}/{}",
key.slice,
key.requirement,
key.contributing_change,
key.mode,
)?;
Ok(())
}
pub(crate) fn run_forget(
path: Option<PathBuf>,
slice: &str,
requirement: &str,
change: &str,
mode: &str,
) -> Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let slice_id = crate::slice::parse_ref(slice)?;
let key = CoverageKey {
slice: slice_key(slice_id),
requirement: crate::requirement::canonicalize_fk(requirement),
contributing_change: canonical_slice_ref(change)?,
mode: mode.to_owned(),
};
let mut out = std::io::stdout();
match forget(&root, slice_id, &key)? {
Some((k, status)) => writeln!(out, "{}", withdrawal_line(&k, status))?,
None => writeln!(
out,
"no coverage cell {}/{}/{}/{}",
key.slice, key.requirement, key.contributing_change, key.mode,
)?,
}
Ok(())
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests: fail-fast unwrap on disk round-trip / parse is idiomatic"
)]
mod tests {
use super::*;
use crate::coverage::{MatchSource, Matcher};
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 good_vtcheck() -> VtCheck {
VtCheck {
alias: Some("unit".to_owned()),
command: None,
extra_args: Vec::new(),
matcher: Some(Matcher {
source: Some(MatchSource::Stdout),
pattern: "ok".to_owned(),
regex: false,
}),
}
}
fn cfg_with_unit() -> VerificationConfig {
crate::dtoml::parse("[verification.aliases]\nunit = [\"cargo\", \"test\"]\n")
.unwrap()
.verification
}
fn input(key: CoverageKey, status: CoverageStatus, check: Option<VtCheck>) -> RecordInput {
RecordInput {
key,
status,
check,
touched_paths: Vec::new(),
}
}
#[test]
fn load_absent_file_is_empty_store() {
let tmp = tempfile::tempdir().unwrap();
let file = load(tmp.path(), 57).unwrap();
assert_eq!(file, CoverageFile::default(), "absent ⇒ empty store");
}
#[test]
fn save_then_load_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let mut file = CoverageFile::default();
coverage::upsert(
&mut file,
CoverageEntry {
key: key("SL-057", "REQ-200", "SL-057", "VH"),
status: CoverageStatus::Verified,
git_anchor: "anchor-abc".to_owned(),
attested_date: Some("2026-06-14".to_owned()),
touched_paths: vec!["src/x.rs".to_owned()],
check: None,
},
);
save(tmp.path(), 57, &file).unwrap();
assert_eq!(
load(tmp.path(), 57).unwrap(),
file,
"save → load round-trips"
);
}
#[test]
fn save_overwrites_atomically_leaving_no_temp() {
let tmp = tempfile::tempdir().unwrap();
let mut first = CoverageFile::default();
coverage::upsert(
&mut first,
CoverageEntry {
key: key("SL-057", "REQ-200", "SL-057", "VT"),
status: CoverageStatus::Planned,
git_anchor: String::new(),
attested_date: None,
touched_paths: Vec::new(),
check: None,
},
);
save(tmp.path(), 57, &first).unwrap();
let mut second = first.clone();
coverage::upsert(
&mut second,
CoverageEntry {
key: key("SL-057", "REQ-200", "SL-057", "VT"),
status: CoverageStatus::Verified,
git_anchor: String::new(),
attested_date: None,
touched_paths: Vec::new(),
check: None,
},
);
save(tmp.path(), 57, &second).unwrap();
assert_eq!(load(tmp.path(), 57).unwrap(), second, "overwrite landed");
let dir = tmp.path().join(SLICE_DIR).join("057");
let strays: Vec<_> = fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n != "coverage.toml")
.collect();
assert!(strays.is_empty(), "no temp left behind, found: {strays:?}");
}
#[test]
fn record_touches_only_coverage_toml_not_a_sibling_entity_file() {
let tmp = tempfile::tempdir().unwrap();
let slice_dir = tmp.path().join(SLICE_DIR).join("057");
fs::create_dir_all(&slice_dir).unwrap();
let sibling = slice_dir.join("slice-057.toml");
let sibling_body = "id = 57\ntitle = \"x\"\n";
fs::write(&sibling, sibling_body).unwrap();
record(
tmp.path(),
57,
input(
key("SL-057", "REQ-200", "SL-057", "VH"),
CoverageStatus::Verified,
None,
),
&VerificationConfig::default(),
"2026-06-14",
None,
)
.unwrap();
assert!(slice_dir.join("coverage.toml").exists(), "coverage written");
assert_eq!(
fs::read_to_string(&sibling).unwrap(),
sibling_body,
"the sibling entity file is byte-identical"
);
}
#[test]
fn vt_record_leans_planned_no_date_and_persists_the_check() {
let tmp = tempfile::tempdir().unwrap();
record(
tmp.path(),
57,
input(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Verified,
Some(good_vtcheck()),
),
&cfg_with_unit(),
"2026-06-14",
None,
)
.unwrap();
let file = load(tmp.path(), 57).unwrap();
let entry = file.entry.first().unwrap();
assert_eq!(entry.status, CoverageStatus::Planned, "VT leans Planned");
assert!(entry.attested_date.is_none(), "VT carries no attested_date");
assert_eq!(
entry.check.as_ref(),
Some(&good_vtcheck()),
"check persisted"
);
}
#[test]
fn va_record_stamps_the_injected_today() {
let tmp = tempfile::tempdir().unwrap();
record(
tmp.path(),
57,
input(
key("SL-057", "REQ-200", "SL-057", "VA"),
CoverageStatus::Verified,
None,
),
&VerificationConfig::default(),
"2026-06-14",
None,
)
.unwrap();
let file = load(tmp.path(), 57).unwrap();
let entry = file.entry.first().unwrap();
assert_eq!(
entry.status,
CoverageStatus::Verified,
"VA keeps the status"
);
assert_eq!(entry.attested_date.as_deref(), Some("2026-06-14"));
assert!(entry.check.is_none(), "VA carries no check");
}
#[test]
fn attested_override_is_honoured_over_today() {
let tmp = tempfile::tempdir().unwrap();
record(
tmp.path(),
57,
input(
key("SL-057", "REQ-200", "SL-057", "VH"),
CoverageStatus::Verified,
None,
),
&VerificationConfig::default(),
"2026-06-14",
Some("2020-01-01"),
)
.unwrap();
let entry = load(tmp.path(), 57)
.unwrap()
.entry
.into_iter()
.next()
.unwrap();
assert_eq!(
entry.attested_date.as_deref(),
Some("2020-01-01"),
"the override wins over today"
);
}
#[test]
fn valid_failure_blocks_the_write() {
let tmp = tempfile::tempdir().unwrap();
let bad = VtCheck {
alias: Some("unit".to_owned()),
command: None,
extra_args: Vec::new(),
matcher: None,
};
let err = record(
tmp.path(),
57,
input(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(bad),
),
&cfg_with_unit(),
"2026-06-14",
None,
);
assert!(err.is_err(), "a valid() failure blocks the write");
assert!(
!coverage_path(tmp.path(), 57).exists(),
"no file written — store unchanged"
);
}
#[test]
fn resolve_failure_blocks_the_write() {
let tmp = tempfile::tempdir().unwrap();
let err = record(
tmp.path(),
57,
input(
key("SL-057", "REQ-200", "SL-057", "VT"),
CoverageStatus::Planned,
Some(good_vtcheck()),
),
&VerificationConfig::default(), "2026-06-14",
None,
);
assert!(err.is_err(), "a resolve() failure blocks the write");
assert!(
!coverage_path(tmp.path(), 57).exists(),
"no file written — store unchanged"
);
}
#[test]
fn record_does_not_overwrite_an_existing_store_on_blocked_write() {
let tmp = tempfile::tempdir().unwrap();
record(
tmp.path(),
57,
input(
key("SL-057", "REQ-200", "SL-057", "VA"),
CoverageStatus::Verified,
None,
),
&VerificationConfig::default(),
"2026-06-14",
None,
)
.unwrap();
let before = fs::read_to_string(coverage_path(tmp.path(), 57)).unwrap();
let _blocked = record(
tmp.path(),
57,
input(
key("SL-057", "REQ-201", "SL-057", "VT"),
CoverageStatus::Planned,
Some(good_vtcheck()),
),
&VerificationConfig::default(), "2026-06-14",
None,
);
let after = fs::read_to_string(coverage_path(tmp.path(), 57)).unwrap();
assert_eq!(before, after, "blocked write left the store byte-identical");
}
#[test]
fn injected_sentinel_date_lands_in_attested_date() {
let tmp = tempfile::tempdir().unwrap();
record(
tmp.path(),
57,
input(
key("SL-057", "REQ-200", "SL-057", "VA"),
CoverageStatus::Verified,
None,
),
&VerificationConfig::default(),
"2099-01-01", None,
)
.unwrap();
let entry = load(tmp.path(), 57)
.unwrap()
.entry
.into_iter()
.next()
.unwrap();
assert_eq!(
entry.attested_date.as_deref(),
Some("2099-01-01"),
"the injected date is what lands — record reads no clock (F-VI)"
);
}
#[test]
fn forget_removes_the_keyed_cell_and_returns_its_status() {
let tmp = tempfile::tempdir().unwrap();
record(
tmp.path(),
57,
input(
key("SL-057", "REQ-256", "SL-057", "VT"),
CoverageStatus::Planned,
Some(good_vtcheck()),
),
&cfg_with_unit(),
"2026-06-14",
None,
)
.unwrap();
let k = key("SL-057", "REQ-256", "SL-057", "VT");
let erased = forget(tmp.path(), 57, &k).unwrap();
assert_eq!(
erased,
Some((k.clone(), CoverageStatus::Planned)),
"forget returns the erased key + status"
);
assert!(load(tmp.path(), 57).unwrap().entry.is_empty(), "cell gone");
assert_eq!(forget(tmp.path(), 57, &k).unwrap(), None, "idempotent miss");
}
#[test]
fn withdrawal_line_names_key_and_erased_status() {
let line = withdrawal_line(
&key("SL-057", "REQ-256", "SL-057", "VT"),
CoverageStatus::Failed,
);
assert_eq!(line, "withdrew SL-057/REQ-256/SL-057/VT [Failed]");
}
}