use std::path::PathBuf;
use anyhow::Context;
use crate::coverage::{self, CoverageKey, Verdict};
use crate::coverage_scan;
use crate::rec::{RecDoc, RecMeta, RecMove, StatusDelta};
use crate::requirement::{self, ReqStatus};
pub(crate) struct ReconcileArgs {
pub(crate) req: String,
pub(crate) slice: String,
pub(crate) r#move: RecMove,
pub(crate) to: Option<ReqStatus>,
pub(crate) note: Option<String>,
}
fn select_status(to: ReqStatus, prior: ReqStatus) -> ReqStatus {
let _ = prior;
to
}
fn build_prompt(verdict: Verdict) -> String {
let reading = match verdict {
Verdict::Coherent => "coherent — authored status agrees with observed coverage".to_owned(),
Verdict::Indeterminate => "indeterminate — not enough live evidence to judge".to_owned(),
Verdict::Divergent(reason) => {
format!("divergent — {}", divergent_label(reason))
}
};
format!("drift: {reading}")
}
fn divergent_label(reason: coverage::DivergentReason) -> &'static str {
match reason {
coverage::DivergentReason::ObservedContradiction => {
"observed evidence contradicts the authored status (failed/blocked cell)"
}
coverage::DivergentReason::EvidenceOutrunsAuthored => {
"live confirming evidence exists while authored status trails it"
}
}
}
fn require_to(r#move: RecMove, to: Option<ReqStatus>) -> anyhow::Result<Option<ReqStatus>> {
match (r#move, to) {
(RecMove::Accept | RecMove::Revise, Some(s)) => Ok(Some(s)),
(RecMove::Accept | RecMove::Revise, None) => anyhow::bail!(
"`--to <state>` is required for `--move {}`",
r#move.as_str()
),
(RecMove::Redesign, None) => Ok(None),
(RecMove::Redesign, Some(_)) => {
anyhow::bail!(
"`--to` is not valid for `--move redesign` (it writes no requirement status, F7)"
)
}
}
}
fn compose_status_rec(
req: &str,
slice: &str,
r#move: RecMove,
prior: ReqStatus,
written: ReqStatus,
evidence: Vec<CoverageKey>,
) -> RecDoc {
let title = format!("{} {req}", r#move.as_str());
RecDoc {
id: 0,
slug: rec_slug(r#move, req),
title,
rec: RecMeta {
r#move: r#move.as_str().to_owned(),
owning_slice: Some(slice.to_owned()),
decision_ref: None,
},
status_delta: vec![StatusDelta {
requirement: req.to_owned(),
from: prior.as_str().to_owned(),
to: written.as_str().to_owned(),
}],
evidence_ref: evidence,
tags: Vec::new(),
}
}
fn compose_redesign_rec(req: &str, slice: &str, evidence: Vec<CoverageKey>) -> RecDoc {
RecDoc {
id: 0,
slug: rec_slug(RecMove::Redesign, req),
title: format!("redesign {req}"),
rec: RecMeta {
r#move: RecMove::Redesign.as_str().to_owned(),
owning_slice: Some(slice.to_owned()),
decision_ref: None,
},
status_delta: Vec::new(),
evidence_ref: evidence,
tags: Vec::new(),
}
}
fn rec_slug(r#move: RecMove, req: &str) -> String {
format!("{}-{}", r#move.as_str(), req.to_lowercase())
}
pub(crate) fn run(path: Option<PathBuf>, args: &ReconcileArgs) -> anyhow::Result<()> {
use std::io::Write as _;
let root = crate::root::find(path, &crate::root::default_markers())?;
crate::integrity::ensure_ref_resolves(&root, &args.slice)?;
let prior = requirement::load(&root, &args.req)
.with_context(|| format!("reconcile target {} not found", args.req))?
.status;
let req_id = requirement::id_from_fk(&args.req)?;
let to = require_to(args.r#move, args.to)?;
let entries = coverage_scan::scan_coverage(&root, &args.req);
let composite = coverage::composite(&entries);
let verdict = coverage::drift(prior, &composite);
let evidence = coverage::distinct_keys(entries.into_iter().map(|(e, _)| e.key));
let mut out = std::io::stdout();
writeln!(out, "{}", build_prompt(verdict))?;
let rec_id = match args.r#move {
RecMove::Accept | RecMove::Revise => {
let written =
select_status(to.context("accept/revise require --to (validated)")?, prior);
let doc = compose_status_rec(
&args.req,
&args.slice,
args.r#move,
prior,
written,
evidence,
);
let rec_id = crate::rec::materialise_populated(&root, &doc)?; requirement::set_status(&root, req_id, written)?; rec_id
}
RecMove::Redesign => {
let slice_id = crate::slice::parse_ref(&args.slice)?;
crate::slice::run_status(
Some(root.clone()),
slice_id,
crate::slice::SliceStatus::Design,
args.note.as_deref(),
)?;
let doc = compose_redesign_rec(&args.req, &args.slice, evidence);
crate::rec::materialise_populated(&root, &doc)?
}
};
writeln!(
out,
"Recorded rec {rec_id:03}: {} {}",
args.r#move.as_str(),
args.req
)?;
if let Some(note) = &args.note {
writeln!(out, "note: {note}")?;
}
Ok(())
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests: fail-fast unwrap on disk/round-trip setup is idiomatic"
)]
mod tests {
use super::*;
use crate::requirement::{self, ReqKind};
use std::fs;
use std::path::Path;
fn repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init", "-q", "-b", "main"]);
dir
}
fn git(root: &Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
])
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap().trim().to_owned()
}
fn mint_req(root: &Path, status: ReqStatus) -> String {
let id = requirement::reserve(root, "fast-boot", "Fast boot", "2026-06-12")
.unwrap()
.eid
.numeric_id()
.unwrap();
requirement::set_kind(root, id, ReqKind::Functional).unwrap();
requirement::set_status(root, id, status).unwrap();
requirement::canonical_id(id)
}
fn mint_slice(root: &Path) -> String {
crate::slice::run_new(Some(root.to_path_buf()), Some("recon".to_owned()), None).unwrap();
"SL-001".to_owned()
}
fn write_coverage(root: &Path, slice_num: u32, req: &str, status: &str) {
let dir = root.join(".doctrine/slice").join(format!("{slice_num:03}"));
fs::create_dir_all(&dir).unwrap();
let body = format!(
"[[entry]]\nslice = \"SL-{slice_num:03}\"\nrequirement = \"{req}\"\n\
contributing_change = \"SL-{slice_num:03}\"\nmode = \"VT\"\n\
status = \"{status}\"\ngit_anchor = \"deadbeef\"\n"
);
fs::write(dir.join("coverage.toml"), body).unwrap();
}
fn read_rec_status(root: &Path) -> ReqStatus {
requirement::load(root, "REQ-001").unwrap().status
}
fn rec_ids(root: &Path) -> Vec<u32> {
let rec_root = root.join(".doctrine/rec");
if !rec_root.is_dir() {
return Vec::new();
}
crate::entity::scan_ids(&rec_root).unwrap()
}
fn read_rec_doc(root: &Path, id: u32) -> RecDoc {
let name = format!("{id:03}");
let p = root
.join(".doctrine/rec")
.join(&name)
.join(format!("rec-{name}.toml"));
toml::from_str(&fs::read_to_string(p).unwrap()).unwrap()
}
#[test]
fn accept_writes_status_and_one_rec_with_delta_and_evidence() {
let dir = repo();
let root = dir.path();
let req = mint_req(root, ReqStatus::Pending);
let slice = mint_slice(root);
write_coverage(root, 1, &req, "verified");
run(
Some(root.to_path_buf()),
&ReconcileArgs {
req: req.clone(),
slice,
r#move: RecMove::Accept,
to: Some(ReqStatus::Active),
note: None,
},
)
.unwrap();
assert_eq!(read_rec_status(root), ReqStatus::Active);
let ids = rec_ids(root);
assert_eq!(ids.len(), 1, "exactly one atomic REC");
let doc = read_rec_doc(root, *ids.first().unwrap());
assert_eq!(doc.rec.r#move, "accept");
assert_eq!(doc.status_delta.len(), 1);
let d = doc.status_delta.first().unwrap();
assert_eq!(
(d.requirement.as_str(), d.from.as_str(), d.to.as_str()),
(req.as_str(), "pending", "active")
);
assert_eq!(doc.evidence_ref.len(), 1);
assert_eq!(doc.evidence_ref.first().unwrap().requirement, req);
}
#[test]
fn revise_moves_status_with_one_rec() {
let dir = repo();
let root = dir.path();
let req = mint_req(root, ReqStatus::Active);
let slice = mint_slice(root);
write_coverage(root, 1, &req, "failed");
run(
Some(root.to_path_buf()),
&ReconcileArgs {
req: req.clone(),
slice,
r#move: RecMove::Revise,
to: Some(ReqStatus::Deprecated),
note: None,
},
)
.unwrap();
assert_eq!(read_rec_status(root), ReqStatus::Deprecated);
let ids = rec_ids(root);
assert_eq!(ids.len(), 1);
let doc = read_rec_doc(root, *ids.first().unwrap());
assert_eq!(doc.rec.r#move, "revise");
assert_eq!(doc.status_delta.first().unwrap().to, "deprecated");
}
#[test]
fn redesign_escalates_with_empty_delta_and_no_instance_write() {
let dir = repo();
let root = dir.path();
let req = mint_req(root, ReqStatus::Active);
let slice = mint_slice(root);
write_coverage(root, 1, &req, "failed");
crate::slice::run_status(
Some(root.to_path_buf()),
1,
crate::slice::SliceStatus::Design,
None,
)
.unwrap();
crate::slice::run_status(
Some(root.to_path_buf()),
1,
crate::slice::SliceStatus::Plan,
None,
)
.unwrap();
crate::slice::run_status(
Some(root.to_path_buf()),
1,
crate::slice::SliceStatus::Ready,
None,
)
.unwrap();
crate::slice::run_status(
Some(root.to_path_buf()),
1,
crate::slice::SliceStatus::Started,
None,
)
.unwrap();
run(
Some(root.to_path_buf()),
&ReconcileArgs {
req: req.clone(),
slice,
r#move: RecMove::Redesign,
to: None,
note: Some("escalating".to_owned()),
},
)
.unwrap();
assert_eq!(read_rec_status(root), ReqStatus::Active);
let slice_toml =
fs::read_to_string(root.join(".doctrine/slice/001/slice-001.toml")).unwrap();
assert!(
slice_toml.contains("status = \"design\""),
"back-edge to design: {slice_toml}"
);
let ids = rec_ids(root);
assert_eq!(ids.len(), 1);
let doc = read_rec_doc(root, *ids.first().unwrap());
assert_eq!(doc.rec.r#move, "redesign");
assert!(
doc.status_delta.is_empty(),
"redesign carries empty delta (F7)"
);
}
#[test]
fn distinct_keys_dedupes_repeated_4tuples() {
let k = |slice: &str| CoverageKey {
slice: slice.to_owned(),
requirement: "REQ-001".to_owned(),
contributing_change: slice.to_owned(),
mode: "VT".to_owned(),
};
let out = coverage::distinct_keys([k("SL-001"), k("SL-001"), k("SL-002")].into_iter());
assert_eq!(out.len(), 2);
assert_eq!(out.first().unwrap().slice, "SL-001");
assert_eq!(out.get(1).unwrap().slice, "SL-002");
}
#[test]
fn redesign_rejects_a_supplied_to() {
assert!(require_to(RecMove::Redesign, Some(ReqStatus::Active)).is_err());
}
#[test]
fn accept_and_revise_require_to() {
assert!(require_to(RecMove::Accept, None).is_err());
assert!(require_to(RecMove::Revise, None).is_err());
}
#[test]
fn two_requirements_under_different_moves_emit_two_distinct_recs() {
let dir = repo();
let root = dir.path();
let req1 = mint_req(root, ReqStatus::Pending); let id2 = requirement::reserve(root, "low-latency", "Low latency", "2026-06-12")
.unwrap()
.eid
.numeric_id()
.unwrap();
requirement::set_kind(root, id2, ReqKind::Functional).unwrap();
let req2 = requirement::canonical_id(id2); let slice = mint_slice(root);
run(
Some(root.to_path_buf()),
&ReconcileArgs {
req: req1,
slice: slice.clone(),
r#move: RecMove::Accept,
to: Some(ReqStatus::Active),
note: None,
},
)
.unwrap();
run(
Some(root.to_path_buf()),
&ReconcileArgs {
req: req2,
slice,
r#move: RecMove::Revise,
to: Some(ReqStatus::Deprecated),
note: None,
},
)
.unwrap();
let ids = rec_ids(root);
assert_eq!(ids.len(), 2, "one REC per move = two RECs");
let moves: Vec<String> = ids
.iter()
.map(|i| read_rec_doc(root, *i).rec.r#move)
.collect();
assert!(moves.contains(&"accept".to_owned()));
assert!(moves.contains(&"revise".to_owned()));
}
#[test]
fn select_status_returns_to_independent_of_prior() {
for prior in [
ReqStatus::Pending,
ReqStatus::Active,
ReqStatus::Retired,
ReqStatus::Superseded,
ReqStatus::Deprecated,
ReqStatus::InProgress,
] {
assert_eq!(select_status(ReqStatus::Active, prior), ReqStatus::Active);
assert_eq!(select_status(ReqStatus::Pending, prior), ReqStatus::Pending);
}
}
#[test]
fn verdict_is_consumed_only_by_build_prompt() {
let p = build_prompt(Verdict::Coherent);
assert!(p.contains("coherent"));
let d = build_prompt(Verdict::Divergent(
coverage::DivergentReason::ObservedContradiction,
));
assert!(d.contains("divergent"));
}
#[test]
fn written_status_is_verdict_independent() {
let fixed_to = ReqStatus::Active;
let coverage_states = ["verified", "failed", "planned", "__none__"];
let mut seen_verdicts = std::collections::BTreeSet::new();
for state in coverage_states {
let dir = repo();
let root = dir.path();
let req = mint_req(root, ReqStatus::Pending);
let slice = mint_slice(root);
if state != "__none__" {
write_coverage(root, 1, &req, state);
}
let entries = coverage_scan::scan_coverage(root, &req);
let composite = coverage::composite(&entries);
let verdict = coverage::drift(ReqStatus::Pending, &composite);
seen_verdicts.insert(format!("{verdict:?}"));
run(
Some(root.to_path_buf()),
&ReconcileArgs {
req: req.clone(),
slice,
r#move: RecMove::Accept,
to: Some(fixed_to),
note: None,
},
)
.unwrap();
assert_eq!(
requirement::load(root, &req).unwrap().status,
fixed_to,
"written status moved with coverage {state:?} (verdict {verdict:?}) — NF-001 wall breached"
);
}
assert!(
seen_verdicts.len() >= 2,
"varied coverage must produce ≥2 distinct verdicts, got {seen_verdicts:?}"
);
}
#[test]
fn rec_and_authored_tier_reconstruct_current_status() {
let dir = repo();
let root = dir.path();
let req = mint_req(root, ReqStatus::Pending);
let slice = mint_slice(root);
write_coverage(root, 1, &req, "verified");
run(
Some(root.to_path_buf()),
&ReconcileArgs {
req: req.clone(),
slice,
r#move: RecMove::Accept,
to: Some(ReqStatus::Active),
note: None,
},
)
.unwrap();
assert_eq!(
requirement::load(root, &req).unwrap().status,
ReqStatus::Active
);
let id = *rec_ids(root).first().unwrap();
let doc = read_rec_doc(root, id);
let d = doc.status_delta.first().unwrap();
assert_eq!(d.from, "pending");
assert_eq!(d.to, "active");
assert!(
!root.join(".doctrine/state").exists(),
"reconcile created a runtime-state tree — the authored-tier reconstruction is not self-sufficient"
);
}
}