use std::collections::BTreeSet;
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use anyhow::Context;
use clap::ValueEnum;
use serde::Deserialize;
use toml_edit::Item;
use crate::boundary::{BoundaryRow, Provenance};
use crate::fsutil;
use crate::plan::{Plan, PlanPhase};
const STATE_SLICE_DIR: &str = ".doctrine/state/slice";
const SLICE_DIR: &str = ".doctrine/slice";
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum PhaseStatus {
Planned,
#[value(name = "in_progress")]
InProgress,
Completed,
Blocked,
}
impl PhaseStatus {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Planned => "planned",
Self::InProgress => "in_progress",
Self::Completed => "completed",
Self::Blocked => "blocked",
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum StemStatus<'a> {
Toml(&'a str),
MissingToml,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct PhaseRollup {
pub(crate) planned: u32,
pub(crate) in_progress: u32,
pub(crate) completed: u32,
pub(crate) blocked: u32,
pub(crate) unknown: u32,
pub(crate) missing_toml: u32,
}
impl PhaseRollup {
pub(crate) fn total(&self) -> u32 {
self.planned
+ self.in_progress
+ self.completed
+ self.blocked
+ self.unknown
+ self.missing_toml
}
pub(crate) fn anomalies(&self) -> u32 {
self.unknown + self.missing_toml
}
}
pub(crate) fn fold_rollup(stems: &[StemStatus<'_>]) -> PhaseRollup {
let mut r = PhaseRollup::default();
for stem in stems {
match stem {
StemStatus::MissingToml => r.missing_toml += 1,
StemStatus::Toml(s) => match PhaseStatus::from_str(s, false) {
Ok(PhaseStatus::Planned) => r.planned += 1,
Ok(PhaseStatus::InProgress) => r.in_progress += 1,
Ok(PhaseStatus::Completed) => r.completed += 1,
Ok(PhaseStatus::Blocked) => r.blocked += 1,
Err(_) => r.unknown += 1,
},
}
}
r
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct InitReport {
pub created: Vec<String>,
pub orphan: Vec<String>,
pub pruned: Vec<String>,
}
pub(crate) fn phases_dir(project_root: &Path, slice_id: u32) -> PathBuf {
project_root
.join(STATE_SLICE_DIR)
.join(format!("{slice_id:03}"))
.join("phases")
}
pub(crate) fn phase_stem(phase_id: &str) -> anyhow::Result<String> {
let digits = phase_id
.strip_prefix("PHASE-")
.filter(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
.with_context(|| format!("Phase id {phase_id:?} must match PHASE-<digits>"))?;
Ok(format!("phase-{digits}"))
}
fn phase_id_from_stem(stem: &str) -> String {
format!("PHASE-{}", stem.strip_prefix("phase-").unwrap_or(stem))
}
pub(crate) fn init_phases(
project_root: &Path,
slice_id: u32,
plan: &Plan,
prune: bool,
) -> anyhow::Result<InitReport> {
let stems = plan
.phases
.iter()
.map(|phase| Ok((phase, phase_stem(&phase.id)?)))
.collect::<anyhow::Result<Vec<_>>>()?;
let dir = phases_dir(project_root, slice_id);
fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?;
let mut report = InitReport::default();
let mut plan_stems = BTreeSet::new();
for (phase, stem) in &stems {
plan_stems.insert(stem.clone());
let wrote_toml = write_if_absent(
&dir.join(format!("{stem}.toml")),
&render_tracking(&phase.id),
)?;
let wrote_md =
write_if_absent(&dir.join(format!("{stem}.md")), &render_phase_sheet(phase)?)?;
if wrote_toml || wrote_md {
report.created.push(phase.id.clone());
}
}
for stem in existing_phase_stems(&dir)? {
if plan_stems.contains(&stem) {
continue;
}
if prune {
drop(fs::remove_file(dir.join(format!("{stem}.toml"))));
drop(fs::remove_file(dir.join(format!("{stem}.md"))));
report.pruned.push(phase_id_from_stem(&stem));
} else {
report.orphan.push(phase_id_from_stem(&stem));
}
}
refresh_symlink(project_root, slice_id)?;
Ok(report)
}
fn write_if_absent(path: &Path, body: &str) -> anyhow::Result<bool> {
match fsutil::create_new_file(path) {
Ok(mut f) => {
f.write_all(body.as_bytes())
.with_context(|| format!("Failed to write {}", path.display()))?;
Ok(true)
}
Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(false),
Err(e) => Err(e).with_context(|| format!("Failed to create {}", path.display())),
}
}
pub(crate) fn existing_phase_stems(dir: &Path) -> anyhow::Result<BTreeSet<String>> {
let mut stems = BTreeSet::new();
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(stems),
Err(e) => return Err(e).with_context(|| format!("Failed to read {}", dir.display())),
};
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if let Some(stem) = name
.strip_suffix(".toml")
.or_else(|| name.strip_suffix(".md"))
&& stem.starts_with("phase-")
{
stems.insert(stem.to_string());
}
}
Ok(stems)
}
#[derive(Deserialize)]
struct TrackingStatus {
status: Option<String>,
}
pub(crate) fn read_phase_status(dir: &Path, stem: &str) -> anyhow::Result<Option<String>> {
let path = dir.join(format!("{stem}.toml"));
let text = match fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).with_context(|| format!("Failed to read {}", path.display())),
};
Ok(toml::from_str::<TrackingStatus>(&text)
.ok()
.and_then(|t| t.status))
}
pub(crate) fn phase_rollup(
project_root: &Path,
slice_id: u32,
) -> anyhow::Result<Option<PhaseRollup>> {
let dir = phases_dir(project_root, slice_id);
let stems = existing_phase_stems(&dir)?;
if stems.is_empty() {
return Ok(None);
}
let statuses: Vec<Option<String>> = stems
.iter()
.map(|stem| read_phase_status(&dir, stem))
.collect::<anyhow::Result<_>>()?;
let stem_statuses: Vec<StemStatus<'_>> = statuses
.iter()
.map(|s| match s {
Some(status) => StemStatus::Toml(status),
None => StemStatus::MissingToml,
})
.collect();
Ok(Some(fold_rollup(&stem_statuses)))
}
fn render_tracking(phase_id: &str) -> String {
format!(
"schema = \"doctrine.phase.tracking\"\n\
version = 1\n\
phase = \"{phase_id}\"\n\
status = \"planned\" # planned | in_progress | completed | blocked\n\
started = \"\"\n\
completed = \"\"\n\
last_updated = \"\"\n\
\n\
# Append-only runtime progress log, written by `doctrine slice phase`.\n"
)
}
fn render_phase_sheet(phase: &PlanPhase) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/phase.md")?
.replace("{{phase_id}}", &phase.id)
.replace("{{name}}", &phase.name)
.replace("{{objective}}", &phase.objective))
}
fn refresh_symlink(project_root: &Path, slice_id: u32) -> anyhow::Result<()> {
let name = format!("{slice_id:03}");
let link = project_root.join(SLICE_DIR).join(&name).join("phases");
let target = PathBuf::from(format!("../../state/slice/{name}/phases"));
fsutil::set_symlink(&link, &target)
}
#[expect(
clippy::disallowed_methods,
reason = "runtime phase sheet — disposable, atomicity not required"
)]
pub(crate) fn set_phase_status(
project_root: &Path,
slice_id: u32,
phase_id: &str,
status: PhaseStatus,
note: Option<&str>,
now: &str,
) -> anyhow::Result<()> {
let stem = phase_stem(phase_id)?;
let path = phases_dir(project_root, slice_id).join(format!("{stem}.toml"));
let text = fs::read_to_string(&path).with_context(|| {
format!(
"Phase tracking not found at {} — run `doctrine slice phases` first",
path.display()
)
})?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", path.display()))?;
let table = doc.as_table_mut();
let was_completed =
table.get("status").and_then(Item::as_str) == Some(PhaseStatus::Completed.as_str());
table.insert("status", toml_edit::value(status.as_str()));
table.insert("last_updated", toml_edit::value(now));
if status == PhaseStatus::InProgress && table.get("started").and_then(Item::as_str) == Some("")
{
table.insert("started", toml_edit::value(now));
}
if status == PhaseStatus::Completed {
if table.get("completed").and_then(Item::as_str) == Some("") {
table.insert("completed", toml_edit::value(now));
}
} else {
table.insert("completed", toml_edit::value(""));
if was_completed {
table.insert("code_start_oid", toml_edit::value(""));
}
}
let capture_end = capture_phase_boundary(project_root, slice_id, phase_id, status, table);
let mut row = toml_edit::Table::new();
row.insert("timestamp", toml_edit::value(now));
row.insert("status", toml_edit::value(status.as_str()));
if let Some(note) = note {
row.insert("note", toml_edit::value(note));
}
table
.entry("progress")
.or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()))
.as_array_of_tables_mut()
.context("`progress` exists but is not an array of tables")?
.push(row);
fs::write(&path, doc.to_string())
.with_context(|| format!("Failed to write {}", path.display()))?;
if was_completed
&& status != PhaseStatus::Completed
&& let Err(e) = forget_source_delta(project_root, slice_id, phase_id)
{
warn_capture(phase_id, &format!("reopen eviction failed: {e:#}"));
}
if let Some(CaptureCompletion { start, end }) = capture_end
&& let Err(e) = record_source_delta(
project_root,
slice_id,
BoundaryRow {
phase: phase_id.to_string(),
code_start_oid: start,
code_end_oid: end,
provenance: Provenance::Solo,
},
)
{
warn_capture(phase_id, &format!("recording source delta failed: {e:#}"));
}
Ok(())
}
struct CaptureCompletion {
start: String,
end: String,
}
fn capture_phase_boundary(
project_root: &Path,
slice_id: u32,
phase_id: &str,
status: PhaseStatus,
table: &mut toml_edit::Table,
) -> Option<CaptureCompletion> {
if status != PhaseStatus::InProgress && status != PhaseStatus::Completed {
return None;
}
match crate::git::live_worktree_for_ref(
project_root,
&format!("refs/heads/dispatch/{slice_id:03}"),
) {
Ok(Some(_)) => return None,
Ok(None) => {}
Err(e) => {
warn_capture(phase_id, &format!("coord worktree probe failed: {e}"));
return None;
}
}
let head = match crate::git::resolve_ref(project_root, "HEAD") {
Ok(Some(oid)) => oid,
Ok(None) => {
warn_capture(phase_id, "HEAD does not resolve (unborn/detached)");
return None;
}
Err(e) => {
warn_capture(phase_id, &format!("HEAD probe failed: {e}"));
return None;
}
};
if status == PhaseStatus::InProgress {
if table
.get("code_start_oid")
.and_then(Item::as_str)
.is_none_or(str::is_empty)
{
table.insert("code_start_oid", toml_edit::value(&head));
}
return None;
}
let Some(start) = table
.get("code_start_oid")
.and_then(Item::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned)
else {
warn_capture(
phase_id,
"no code_start_oid stamped (phase never entered in_progress under the binding) — no boundary recorded",
);
return None;
};
Some(CaptureCompletion { start, end: head })
}
fn warn_capture(phase_id: &str, detail: &str) {
let _ignored = writeln!(
std::io::stderr(),
"warning: phase-binding capture skipped for {phase_id}: {detail}"
);
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub(crate) struct SourceDeltas {
#[serde(default, rename = "boundary")]
pub rows: Vec<BoundaryRow>,
}
pub(crate) fn boundaries_path(cwd: &Path, slice_id: u32) -> anyhow::Result<PathBuf> {
let primary = crate::git::primary_worktree(cwd)?;
Ok(primary
.join(STATE_SLICE_DIR)
.join(format!("{slice_id:03}"))
.join("boundaries.toml"))
}
fn read_registry(path: &Path) -> anyhow::Result<SourceDeltas> {
match fs::read_to_string(path) {
Ok(text) => {
toml::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))
}
Err(e) if e.kind() == ErrorKind::NotFound => Ok(SourceDeltas::default()),
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
}
}
#[expect(
clippy::disallowed_methods,
reason = "runtime registry — disposable gitignored state, atomicity not required"
)]
fn write_registry(path: &Path, registry: &SourceDeltas) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let body = toml::to_string(registry).context("serialize source-delta registry")?;
fs::write(path, body).with_context(|| format!("Failed to write {}", path.display()))
}
pub(crate) fn read_source_deltas(cwd: &Path, slice_id: u32) -> anyhow::Result<Vec<BoundaryRow>> {
Ok(read_registry(&boundaries_path(cwd, slice_id)?)?.rows)
}
pub(crate) fn record_source_delta(
cwd: &Path,
slice_id: u32,
row: BoundaryRow,
) -> anyhow::Result<()> {
if !crate::git::is_ancestor(cwd, &row.code_start_oid, &row.code_end_oid)? {
anyhow::bail!(
"record_source_delta: code_start {} is not an ancestor of code_end {} (not a forward delta)",
row.code_start_oid,
row.code_end_oid
);
}
if crate::git::parents(cwd, &row.code_end_oid)?.len() > 1 {
anyhow::bail!(
"record_source_delta: code_end {} is a merge commit (boundary must be a non-merge code tip)",
row.code_end_oid
);
}
let path = boundaries_path(cwd, slice_id)?;
let mut registry = read_registry(&path)?;
match registry.rows.iter_mut().find(|r| r.phase == row.phase) {
Some(existing) => {
let keep = match row.provenance {
Provenance::Solo | Provenance::Funnel => row.provenance,
Provenance::Manual | Provenance::Unknown => existing.provenance,
};
*existing = row;
existing.provenance = keep;
}
None => registry.rows.push(row),
}
write_registry(&path, ®istry)
}
pub(crate) fn forget_source_delta(cwd: &Path, slice_id: u32, phase: &str) -> anyhow::Result<bool> {
let path = boundaries_path(cwd, slice_id)?;
let mut registry = read_registry(&path)?;
let before = registry.rows.len();
registry.rows.retain(|r| r.phase != phase);
if registry.rows.len() == before {
return Ok(false);
}
write_registry(&path, ®istry)?;
Ok(true)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Completeness {
Complete,
Incomplete { gaps: Vec<CompletenessGap> },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CompletenessGap {
Missing { phase: String },
Extra { phase: String },
Duplicate { phase: String },
}
impl CompletenessGap {
pub(crate) fn describe(&self) -> String {
match self {
CompletenessGap::Missing { phase } => {
format!("completed phase {phase} has no recorded source-delta row")
}
CompletenessGap::Extra { phase } => {
format!("recorded row for {phase}, which is not a completed phase")
}
CompletenessGap::Duplicate { phase } => {
format!("recorded more than one row for phase {phase}")
}
}
}
}
pub(crate) fn check_completeness(
completed: &BTreeSet<String>,
recorded: &[String],
) -> Completeness {
let mut counts: std::collections::BTreeMap<&str, u32> = std::collections::BTreeMap::new();
for phase in recorded {
*counts.entry(phase.as_str()).or_insert(0) += 1;
}
let mut gaps = Vec::new();
for phase in completed {
if !counts.contains_key(phase.as_str()) {
gaps.push(CompletenessGap::Missing {
phase: phase.clone(),
});
}
}
for (phase, count) in &counts {
if !completed.contains(*phase) {
gaps.push(CompletenessGap::Extra {
phase: (*phase).to_string(),
});
} else if *count > 1 {
gaps.push(CompletenessGap::Duplicate {
phase: (*phase).to_string(),
});
}
}
if gaps.is_empty() {
Completeness::Complete
} else {
Completeness::Incomplete { gaps }
}
}
pub(crate) fn completed_phase_ids(
project_root: &Path,
slice_id: u32,
) -> anyhow::Result<BTreeSet<String>> {
let dir = phases_dir(project_root, slice_id);
let mut completed = BTreeSet::new();
for stem in existing_phase_stems(&dir)? {
if let Some(status) = read_phase_status(&dir, &stem)?
&& PhaseStatus::from_str(&status, false) == Ok(PhaseStatus::Completed)
{
completed.insert(phase_id_from_stem(&stem));
}
}
Ok(completed)
}
pub(crate) fn registry_completeness(
cwd: &Path,
project_root: &Path,
slice_id: u32,
) -> anyhow::Result<Completeness> {
let recorded: Vec<String> = read_source_deltas(cwd, slice_id)?
.into_iter()
.map(|row| row.phase)
.collect();
let completed = completed_phase_ids(project_root, slice_id)?;
Ok(check_completeness(&completed, &recorded))
}
#[cfg(test)]
mod tests {
use super::*;
fn plan(ids: &[&str]) -> Plan {
Plan {
phases: ids
.iter()
.map(|id| PlanPhase {
id: (*id).to_string(),
name: String::new(),
objective: String::new(),
entrance_criteria: Vec::new(),
exit_criteria: Vec::new(),
verification: Vec::new(),
})
.collect(),
}
}
fn make_slice_dir(root: &Path, slice_id: u32) {
fs::create_dir_all(root.join(SLICE_DIR).join(format!("{slice_id:03}"))).unwrap();
}
#[test]
fn phase_stem_derives_and_validates() {
assert_eq!(phase_stem("PHASE-01").unwrap(), "phase-01");
assert_eq!(phase_stem("PHASE-137").unwrap(), "phase-137");
}
#[test]
fn phase_stem_rejects_malformed_ids() {
for bad in [
"",
"PHASE-",
"phase-01",
"PHASE-1a",
"PHASE-../x",
"PHASE-0/1",
".PHASE-01",
"P01",
] {
assert!(phase_stem(bad).is_err(), "{bad:?} should be rejected");
}
}
#[test]
fn init_phases_materialises_each_declared_phase() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice_dir(root, 4);
let report = init_phases(root, 4, &plan(&["PHASE-01", "PHASE-02"]), false).unwrap();
assert_eq!(report.created, vec!["PHASE-01", "PHASE-02"]);
let phases = phases_dir(root, 4);
for stem in ["phase-01", "phase-02"] {
assert!(phases.join(format!("{stem}.toml")).is_file());
assert!(phases.join(format!("{stem}.md")).is_file());
}
let tracking = fs::read_to_string(phases.join("phase-01.toml")).unwrap();
assert!(tracking.contains("phase = \"PHASE-01\""));
assert!(tracking.contains("status = \"planned\""));
assert!(!root.join(SLICE_DIR).join("004/phase-01.toml").exists());
}
#[test]
fn init_phases_is_idempotent_and_preserves_edits() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice_dir(root, 4);
let p = plan(&["PHASE-01"]);
init_phases(root, 4, &p, false).unwrap();
let sheet = phases_dir(root, 4).join("phase-01.md");
fs::write(&sheet, "EDITED").unwrap();
let report = init_phases(root, 4, &p, false).unwrap();
assert!(
report.created.is_empty(),
"no re-creation on the no-drift path"
);
assert_eq!(
fs::read_to_string(&sheet).unwrap(),
"EDITED",
"edits survive"
);
}
#[test]
fn init_phases_completes_a_crash_partial_phase() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice_dir(root, 4);
let phases = phases_dir(root, 4);
fs::create_dir_all(&phases).unwrap();
fs::write(phases.join("phase-01.toml"), "partial").unwrap();
let report = init_phases(root, 4, &plan(&["PHASE-01"]), false).unwrap();
assert_eq!(
report.created,
vec!["PHASE-01"],
"the missing file completes the phase"
);
assert!(phases.join("phase-01.md").is_file());
assert_eq!(
fs::read_to_string(phases.join("phase-01.toml")).unwrap(),
"partial"
);
}
#[test]
fn init_phases_reports_orphans_without_removing_them() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice_dir(root, 4);
init_phases(root, 4, &plan(&["PHASE-01", "PHASE-02"]), false).unwrap();
let report = init_phases(root, 4, &plan(&["PHASE-01", "PHASE-03"]), false).unwrap();
assert_eq!(report.created, vec!["PHASE-03"]);
assert_eq!(report.orphan, vec!["PHASE-02"]);
assert!(report.pruned.is_empty());
assert!(phases_dir(root, 4).join("phase-02.toml").is_file());
}
#[test]
fn init_phases_rejects_a_malformed_id_before_writing_anything() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice_dir(root, 4);
let err = init_phases(root, 4, &plan(&["PHASE-01", "bad", "PHASE-03"]), false).unwrap_err();
assert!(err.to_string().contains("must match PHASE-<digits>"));
assert!(!phases_dir(root, 4).join("phase-01.toml").exists());
}
#[test]
fn init_phases_prunes_orphans_only_when_asked() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice_dir(root, 4);
init_phases(root, 4, &plan(&["PHASE-01", "PHASE-02"]), false).unwrap();
let report = init_phases(root, 4, &plan(&["PHASE-01"]), true).unwrap();
assert_eq!(report.pruned, vec!["PHASE-02"]);
assert!(report.orphan.is_empty());
let phases = phases_dir(root, 4);
assert!(!phases.join("phase-02.toml").exists());
assert!(!phases.join("phase-02.md").exists());
assert!(phases.join("phase-01.toml").is_file());
}
#[test]
fn init_phases_refreshes_the_convenience_symlink() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice_dir(root, 4);
init_phases(root, 4, &plan(&["PHASE-01"]), false).unwrap();
let link = root.join(SLICE_DIR).join("004/phases");
assert_eq!(
fs::canonicalize(&link).unwrap(),
fs::canonicalize(phases_dir(root, 4)).unwrap(),
);
}
#[test]
fn init_phases_creates_the_state_parent_on_demand() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice_dir(root, 4);
assert!(!root.join(STATE_SLICE_DIR).exists());
init_phases(root, 4, &plan(&["PHASE-01"]), false).unwrap();
assert!(phases_dir(root, 4).is_dir());
}
fn init_one(root: &Path) {
make_slice_dir(root, 4);
init_phases(root, 4, &plan(&["PHASE-01"]), false).unwrap();
}
#[test]
fn set_phase_status_sets_fields_and_appends_progress() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_one(root);
set_phase_status(
root,
4,
"PHASE-01",
PhaseStatus::InProgress,
Some("kickoff"),
"2026-06-04T10:00:00Z",
)
.unwrap();
let doc = fs::read_to_string(phases_dir(root, 4).join("phase-01.toml"))
.unwrap()
.parse::<toml_edit::DocumentMut>()
.unwrap();
assert_eq!(doc["status"].as_str(), Some("in_progress"));
assert_eq!(doc["started"].as_str(), Some("2026-06-04T10:00:00Z"));
assert_eq!(doc["last_updated"].as_str(), Some("2026-06-04T10:00:00Z"));
assert_eq!(doc["completed"].as_str(), Some("")); let progress = doc["progress"].as_array_of_tables().unwrap();
assert_eq!(progress.len(), 1);
let row = progress.get(0).unwrap();
assert_eq!(row["status"].as_str(), Some("in_progress"));
assert_eq!(row["note"].as_str(), Some("kickoff"));
}
#[test]
fn set_phase_status_stamps_started_once_and_completed_on_completion() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_one(root);
set_phase_status(root, 4, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
set_phase_status(root, 4, "PHASE-01", PhaseStatus::InProgress, None, "T2").unwrap();
set_phase_status(root, 4, "PHASE-01", PhaseStatus::Completed, None, "T3").unwrap();
let doc = fs::read_to_string(phases_dir(root, 4).join("phase-01.toml"))
.unwrap()
.parse::<toml_edit::DocumentMut>()
.unwrap();
assert_eq!(
doc["started"].as_str(),
Some("T1"),
"started stamped once, not overwritten"
);
assert_eq!(doc["completed"].as_str(), Some("T3"));
assert_eq!(doc["status"].as_str(), Some("completed"));
assert_eq!(doc["progress"].as_array_of_tables().unwrap().len(), 3);
}
#[test]
fn set_phase_status_clears_completed_on_reopen() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_one(root);
set_phase_status(root, 4, "PHASE-01", PhaseStatus::Completed, None, "T1").unwrap();
set_phase_status(root, 4, "PHASE-01", PhaseStatus::InProgress, None, "T2").unwrap();
let doc = fs::read_to_string(phases_dir(root, 4).join("phase-01.toml"))
.unwrap()
.parse::<toml_edit::DocumentMut>()
.unwrap();
assert_eq!(doc["status"].as_str(), Some("in_progress"));
assert_eq!(
doc["completed"].as_str(),
Some(""),
"reopen clears the stale completion stamp"
);
}
#[test]
fn set_phase_status_preserves_comments_and_unknown_keys() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_one(root);
let path = phases_dir(root, 4).join("phase-01.toml");
let edited = format!(
"{}\n# a hand-written note\nowner = \"alice\"\n",
fs::read_to_string(&path).unwrap()
);
fs::write(&path, edited).unwrap();
set_phase_status(root, 4, "PHASE-01", PhaseStatus::Blocked, None, "T1").unwrap();
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains("# a hand-written note"), "comment survives");
assert!(after.contains("owner = \"alice\""), "unknown key survives");
assert!(after.contains("status = \"blocked\"") || after.contains("status = \"blocked\""));
}
#[test]
fn set_phase_status_resolves_by_id_blind_to_the_symlink() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_one(root);
fs::remove_file(root.join(SLICE_DIR).join("004/phases")).unwrap();
set_phase_status(root, 4, "PHASE-01", PhaseStatus::Completed, None, "T1").unwrap();
let doc = fs::read_to_string(phases_dir(root, 4).join("phase-01.toml"))
.unwrap()
.parse::<toml_edit::DocumentMut>()
.unwrap();
assert_eq!(doc["status"].as_str(), Some("completed"));
}
#[test]
fn set_phase_status_errors_when_tracking_absent() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice_dir(root, 4);
let err =
set_phase_status(root, 4, "PHASE-99", PhaseStatus::InProgress, None, "T1").unwrap_err();
assert!(err.to_string().contains("Phase tracking not found"));
}
#[test]
fn fold_rollup_empty_is_all_zero() {
assert_eq!(fold_rollup(&[]), PhaseRollup::default());
}
#[test]
fn fold_rollup_counts_each_known_status_into_its_bucket() {
let stems = [
StemStatus::Toml("completed"),
StemStatus::Toml("completed"),
StemStatus::Toml("in_progress"),
StemStatus::Toml("planned"),
StemStatus::Toml("blocked"),
];
let r = fold_rollup(&stems);
assert_eq!(
r,
PhaseRollup {
planned: 1,
in_progress: 1,
completed: 2,
blocked: 1,
unknown: 0,
missing_toml: 0,
}
);
}
#[test]
fn fold_rollup_buckets_unknown_status_and_missing_toml_separately() {
let stems = [
StemStatus::Toml("completed"),
StemStatus::Toml("garbage"), StemStatus::MissingToml, ];
let r = fold_rollup(&stems);
assert_eq!(r.completed, 1);
assert_eq!(r.unknown, 1);
assert_eq!(r.missing_toml, 1);
let counted =
r.planned + r.in_progress + r.completed + r.blocked + r.unknown + r.missing_toml;
assert_eq!(counted, 3);
}
fn write_phase_toml(dir: &Path, stem: &str, status: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(
dir.join(format!("{stem}.toml")),
format!("status = \"{status}\"\n"),
)
.unwrap();
}
#[test]
fn phase_rollup_is_none_when_untracked() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
assert_eq!(phase_rollup(root, 9).unwrap(), None);
fs::create_dir_all(phases_dir(root, 9)).unwrap();
assert_eq!(phase_rollup(root, 9).unwrap(), None);
}
#[test]
fn phase_rollup_counts_materialised_phases() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phases = phases_dir(root, 9);
write_phase_toml(&phases, "phase-01", "completed");
write_phase_toml(&phases, "phase-02", "completed");
write_phase_toml(&phases, "phase-03", "in_progress");
let r = phase_rollup(root, 9).unwrap().unwrap();
assert_eq!(r.completed, 2);
assert_eq!(r.in_progress, 1);
}
#[test]
fn phase_rollup_md_only_stem_is_missing_toml_not_dropped() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phases = phases_dir(root, 9);
write_phase_toml(&phases, "phase-01", "completed");
fs::write(phases.join("phase-02.md"), "# sheet\n").unwrap();
let r = phase_rollup(root, 9).unwrap().unwrap();
assert_eq!(r.completed, 1);
assert_eq!(r.missing_toml, 1);
}
#[test]
fn phase_rollup_unparseable_or_typo_status_is_surfaced() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phases = phases_dir(root, 9);
write_phase_toml(&phases, "phase-01", "donezo"); fs::write(phases.join("phase-02.toml"), "this is not = valid toml\n").unwrap();
let r = phase_rollup(root, 9).unwrap().unwrap();
assert_eq!(r.unknown, 1, "typo status → unknown");
assert_eq!(r.missing_toml, 1, "unparseable .toml → missing_toml");
}
fn git(dir: &Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env("GIT_AUTHOR_NAME", "Doctrine Test")
.env("GIT_AUTHOR_EMAIL", "test@doctrine.invalid")
.env("GIT_COMMITTER_NAME", "Doctrine Test")
.env("GIT_COMMITTER_EMAIL", "test@doctrine.invalid")
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn init_repo(dir: &Path) -> PathBuf {
fs::create_dir_all(dir).unwrap();
git(dir, &["init", "-q", "-b", "main"]);
git(dir, &["commit", "-q", "--allow-empty", "-m", "root"]);
fs::canonicalize(dir).unwrap()
}
fn row(phase: &str, start: &str, end: &str) -> BoundaryRow {
BoundaryRow {
phase: phase.into(),
code_start_oid: start.into(),
code_end_oid: end.into(),
provenance: Provenance::Unknown,
}
}
#[test]
fn source_deltas_round_trip_through_disk() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
let a = git(&repo, &["rev-parse", "HEAD"]);
git(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
let head = git(&repo, &["rev-parse", "HEAD"]);
assert!(read_source_deltas(&repo, 147).unwrap().is_empty());
record_source_delta(&repo, 147, row("PHASE-01", &a, &a)).unwrap();
record_source_delta(&repo, 147, row("PHASE-02", &a, &head)).unwrap();
let rows = read_source_deltas(&repo, 147).unwrap();
assert_eq!(
rows,
vec![row("PHASE-01", &a, &a), row("PHASE-02", &a, &head)]
);
}
#[test]
fn source_deltas_path_is_slice_scoped_runtime_state() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
let head = git(&repo, &["rev-parse", "HEAD"]);
record_source_delta(&repo, 147, row("PHASE-01", &head, &head)).unwrap();
let path = boundaries_path(&repo, 147).unwrap();
assert!(
path.ends_with(".doctrine/state/slice/147/boundaries.toml"),
"{path:?}"
);
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains("[[boundary]]"), "table header: {text}");
assert!(text.contains("phase = \"PHASE-01\""), "{text}");
}
#[test]
fn record_upserts_by_phase() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
let a = git(&repo, &["rev-parse", "HEAD"]);
git(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
let head = git(&repo, &["rev-parse", "HEAD"]);
record_source_delta(&repo, 147, row("PHASE-01", &a, &a)).unwrap();
record_source_delta(&repo, 147, row("PHASE-01", &a, &head)).unwrap();
let rows = read_source_deltas(&repo, 147).unwrap();
assert_eq!(rows, vec![row("PHASE-01", &a, &head)], "one row, replaced");
}
#[test]
fn record_source_delta_merges_provenance_keyed_on_incoming() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
let a = git(&repo, &["rev-parse", "HEAD"]);
git(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
let b = git(&repo, &["rev-parse", "HEAD"]);
let prov_row = |phase: &str, p: Provenance| BoundaryRow {
phase: phase.into(),
code_start_oid: a.clone(),
code_end_oid: b.clone(),
provenance: p,
};
let recorded = |phase: &str| -> Provenance {
read_source_deltas(&repo, 200)
.unwrap()
.into_iter()
.find(|r| r.phase == phase)
.expect("row present")
.provenance
};
for (phase, existing) in [
("PHASE-01", Provenance::Funnel),
("PHASE-02", Provenance::Solo),
("PHASE-03", Provenance::Unknown),
] {
record_source_delta(&repo, 200, prov_row(phase, existing)).unwrap();
record_source_delta(&repo, 200, prov_row(phase, Provenance::Manual)).unwrap();
assert_eq!(
recorded(phase),
existing,
"Manual must not reclassify {existing:?}"
);
}
record_source_delta(&repo, 200, prov_row("PHASE-04", Provenance::Manual)).unwrap();
assert_eq!(
recorded("PHASE-04"),
Provenance::Manual,
"fresh row keeps incoming"
);
record_source_delta(&repo, 200, prov_row("PHASE-05", Provenance::Solo)).unwrap();
record_source_delta(&repo, 200, prov_row("PHASE-05", Provenance::Funnel)).unwrap();
assert_eq!(
recorded("PHASE-05"),
Provenance::Funnel,
"incoming Funnel overwrites"
);
record_source_delta(&repo, 200, prov_row("PHASE-06", Provenance::Funnel)).unwrap();
record_source_delta(&repo, 200, prov_row("PHASE-06", Provenance::Solo)).unwrap();
assert_eq!(
recorded("PHASE-06"),
Provenance::Solo,
"incoming Solo overwrites"
);
}
#[test]
fn forget_source_delta_evicts_then_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
let a = git(&repo, &["rev-parse", "HEAD"]);
record_source_delta(&repo, 200, row("PHASE-01", &a, &a)).unwrap();
record_source_delta(&repo, 200, row("PHASE-02", &a, &a)).unwrap();
assert!(forget_source_delta(&repo, 200, "PHASE-01").unwrap());
assert_eq!(
read_source_deltas(&repo, 200).unwrap(),
vec![row("PHASE-02", &a, &a)],
"only PHASE-01 evicted"
);
assert!(!forget_source_delta(&repo, 200, "PHASE-01").unwrap());
assert!(!forget_source_delta(&repo, 999, "PHASE-01").unwrap());
}
#[test]
fn record_from_linked_worktree_targets_primary_tree() {
let tmp = tempfile::tempdir().unwrap();
let primary = init_repo(&tmp.path().join("primary"));
let head = git(&primary, &["rev-parse", "HEAD"]);
let fork = tmp.path().join("fork");
git(
&primary,
&[
"worktree",
"add",
"-q",
"-b",
"feat",
fork.to_str().unwrap(),
],
);
let fork = fs::canonicalize(&fork).unwrap();
record_source_delta(&fork, 147, row("PHASE-01", &head, &head)).unwrap();
assert!(
primary
.join(".doctrine/state/slice/147/boundaries.toml")
.exists()
);
assert!(
!fork
.join(".doctrine/state/slice/147/boundaries.toml")
.exists()
);
assert_eq!(
read_source_deltas(&fork, 147).unwrap(),
vec![row("PHASE-01", &head, &head)]
);
assert_eq!(
read_source_deltas(&primary, 147).unwrap(),
vec![row("PHASE-01", &head, &head)]
);
}
#[test]
fn guard_rejects_non_ancestor_range() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
let a = git(&repo, &["rev-parse", "HEAD"]);
git(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
let head = git(&repo, &["rev-parse", "HEAD"]);
let err = record_source_delta(&repo, 147, row("PHASE-01", &head, &a)).unwrap_err();
assert!(format!("{err:#}").contains("not an ancestor"), "{err:#}");
assert!(read_source_deltas(&repo, 147).unwrap().is_empty());
}
#[test]
fn guard_rejects_merge_code_end() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
let base = git(&repo, &["rev-parse", "HEAD"]);
git(&repo, &["commit", "-q", "--allow-empty", "-m", "a"]);
let a = git(&repo, &["rev-parse", "HEAD"]);
git(&repo, &["checkout", "-q", "-b", "side", &base]);
git(&repo, &["commit", "-q", "--allow-empty", "-m", "b"]);
git(&repo, &["checkout", "-q", "main"]);
git(&repo, &["merge", "-q", "--no-ff", "--no-edit", "side"]);
let merge = git(&repo, &["rev-parse", "HEAD"]);
assert_eq!(crate::git::parents(&repo, &merge).unwrap().len(), 2);
let err = record_source_delta(&repo, 147, row("PHASE-01", &a, &merge)).unwrap_err();
assert!(format!("{err:#}").contains("merge commit"), "{err:#}");
assert!(read_source_deltas(&repo, 147).unwrap().is_empty());
}
#[test]
fn guard_accepts_valid_ancestor_non_merge_pair() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
let a = git(&repo, &["rev-parse", "HEAD"]);
git(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
let head = git(&repo, &["rev-parse", "HEAD"]);
assert!(crate::git::parents(&repo, &head).unwrap().len() <= 1);
record_source_delta(&repo, 147, row("PHASE-01", &a, &head)).unwrap();
assert_eq!(
read_source_deltas(&repo, 147).unwrap(),
vec![row("PHASE-01", &a, &head)]
);
}
#[test]
fn record_in_non_repo_is_a_named_error() {
let tmp = tempfile::tempdir().unwrap();
let err = record_source_delta(tmp.path(), 147, row("PHASE-01", "x", "y")).unwrap_err();
let msg = format!("{err:#}");
assert!(!msg.is_empty(), "named error, not a panic: {msg}");
}
fn seed_phase_sheet(repo: &Path, slice: u32, stem: &str) {
let dir = phases_dir(repo, slice);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join(format!("{stem}.toml")),
"status = \"planned\"\nstarted = \"\"\ncompleted = \"\"\n",
)
.unwrap();
}
#[test]
fn binding_records_boundary_then_evicts_and_refreshes_on_reopen() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
seed_phase_sheet(&repo, 147, "phase-01");
let start = git(&repo, &["rev-parse", "HEAD"]);
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
git(&repo, &["commit", "-q", "--allow-empty", "-m", "code"]);
let end1 = git(&repo, &["rev-parse", "HEAD"]);
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();
assert_eq!(
read_source_deltas(&repo, 147).unwrap(),
vec![BoundaryRow {
provenance: Provenance::Solo,
..row("PHASE-01", &start, &end1)
}],
"one boundary spanning the phase"
);
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T3").unwrap();
assert!(
read_source_deltas(&repo, 147).unwrap().is_empty(),
"reopen evicts the recorded row — no stale row survives the reopen"
);
let restart = git(&repo, &["rev-parse", "HEAD"]);
git(&repo, &["commit", "-q", "--allow-empty", "-m", "more"]);
let end2 = git(&repo, &["rev-parse", "HEAD"]);
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T4").unwrap();
assert_eq!(
read_source_deltas(&repo, 147).unwrap(),
vec![BoundaryRow {
provenance: Provenance::Solo,
..row("PHASE-01", &restart, &end2)
}],
"re-completion records exactly one FRESH row (start re-stamped at reopen, not preserved)"
);
}
#[test]
fn binding_records_zero_delta_for_a_no_commit_phase() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
seed_phase_sheet(&repo, 147, "phase-01");
let head = git(&repo, &["rev-parse", "HEAD"]);
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();
assert_eq!(
read_source_deltas(&repo, 147).unwrap(),
vec![BoundaryRow {
provenance: Provenance::Solo,
..row("PHASE-01", &head, &head)
}],
"zero-delta row, start == end"
);
}
#[test]
fn binding_degrades_without_blocking_when_git_unavailable() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path(); init_one(root);
set_phase_status(root, 4, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
set_phase_status(root, 4, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();
let doc = fs::read_to_string(phases_dir(root, 4).join("phase-01.toml"))
.unwrap()
.parse::<toml_edit::DocumentMut>()
.unwrap();
assert_eq!(
doc["status"].as_str(),
Some("completed"),
"flip still landed"
);
assert!(read_source_deltas(root, 4).is_err());
}
#[test]
fn binding_stands_down_under_a_live_coord_worktree() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
seed_phase_sheet(&repo, 147, "phase-01");
let coord = tmp.path().join("coord");
git(
&repo,
&[
"worktree",
"add",
"-b",
"dispatch/147",
&coord.to_string_lossy(),
],
);
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
git(&repo, &["commit", "-q", "--allow-empty", "-m", "code"]);
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();
assert!(
read_source_deltas(&repo, 147).unwrap().is_empty(),
"live coord worktree: the binding records nothing (the funnel recorder owns it)"
);
}
#[test]
fn binding_records_when_coord_worktree_is_prunable() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
seed_phase_sheet(&repo, 147, "phase-01");
let coord = tmp.path().join("coord");
git(
&repo,
&[
"worktree",
"add",
"-b",
"dispatch/147",
&coord.to_string_lossy(),
],
);
std::fs::remove_dir_all(&coord).unwrap();
let start = git(&repo, &["rev-parse", "HEAD"]);
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
git(&repo, &["commit", "-q", "--allow-empty", "-m", "code"]);
let end = git(&repo, &["rev-parse", "HEAD"]);
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();
assert_eq!(
read_source_deltas(&repo, 147).unwrap(),
vec![BoundaryRow {
provenance: Provenance::Solo,
..row("PHASE-01", &start, &end)
}],
"a prunable coord entry is not live → the solo binding records"
);
}
#[test]
fn binding_captures_on_a_solo_feature_branch() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
seed_phase_sheet(&repo, 147, "phase-01");
git(&repo, &["checkout", "-q", "-b", "feat/solo-work"]);
let start = git(&repo, &["rev-parse", "HEAD"]);
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
git(&repo, &["commit", "-q", "--allow-empty", "-m", "code"]);
let end = git(&repo, &["rev-parse", "HEAD"]);
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();
assert_eq!(
read_source_deltas(&repo, 147).unwrap(),
vec![BoundaryRow {
provenance: Provenance::Solo,
..row("PHASE-01", &start, &end)
}],
"solo context: exactly one boundary"
);
}
#[test]
fn binding_absent_start_records_nothing_and_completeness_flags_incomplete() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
seed_phase_sheet(&repo, 147, "phase-01");
set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T1").unwrap();
assert!(
read_source_deltas(&repo, 147).unwrap().is_empty(),
"no stamped start → no boundary recorded (no garbage row)"
);
assert_eq!(
registry_completeness(&repo, &repo, 147).unwrap(),
Completeness::Incomplete {
gaps: vec![CompletenessGap::Missing {
phase: "PHASE-01".to_string()
}]
},
"the floor surfaces as Incomplete end-to-end, never a silent pass"
);
}
fn completed_set(ids: &[&str]) -> BTreeSet<String> {
ids.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn completeness_is_complete_when_rows_cover_completed_phases() {
let completed = completed_set(&["PHASE-01", "PHASE-02"]);
let recorded = vec!["PHASE-01".to_string(), "PHASE-02".to_string()];
assert_eq!(
check_completeness(&completed, &recorded),
Completeness::Complete
);
}
#[test]
fn completeness_flags_a_missing_row_for_a_completed_phase() {
let completed = completed_set(&["PHASE-01", "PHASE-02"]);
let recorded = vec!["PHASE-01".to_string()];
let Completeness::Incomplete { gaps } = check_completeness(&completed, &recorded) else {
panic!("expected incomplete");
};
assert_eq!(
gaps,
vec![CompletenessGap::Missing {
phase: "PHASE-02".to_string()
}]
);
assert!(gaps[0].describe().contains("PHASE-02"));
}
#[test]
fn completeness_flags_an_extra_row() {
let completed = completed_set(&["PHASE-01"]);
let recorded = vec!["PHASE-01".to_string(), "PHASE-09".to_string()];
let Completeness::Incomplete { gaps } = check_completeness(&completed, &recorded) else {
panic!("expected incomplete");
};
assert_eq!(
gaps,
vec![CompletenessGap::Extra {
phase: "PHASE-09".to_string()
}]
);
}
#[test]
fn completeness_flags_a_duplicate_row() {
let completed = completed_set(&["PHASE-01"]);
let recorded = vec!["PHASE-01".to_string(), "PHASE-01".to_string()];
assert_eq!(
check_completeness(&completed, &recorded),
Completeness::Incomplete {
gaps: vec![CompletenessGap::Duplicate {
phase: "PHASE-01".to_string()
}]
}
);
}
#[test]
fn completed_phase_ids_returns_only_completed_phases() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phases = phases_dir(root, 147);
write_phase_toml(&phases, "phase-01", "completed");
write_phase_toml(&phases, "phase-02", "in_progress");
write_phase_toml(&phases, "phase-03", "completed");
let completed = completed_phase_ids(root, 147).unwrap();
assert_eq!(completed, completed_set(&["PHASE-01", "PHASE-03"]));
}
#[test]
fn registry_completeness_fails_closed_on_unrecorded_completed_phase() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("repo"));
let head = git(&repo, &["rev-parse", "HEAD"]);
let phases = phases_dir(&repo, 147);
write_phase_toml(&phases, "phase-01", "completed");
write_phase_toml(&phases, "phase-02", "completed");
record_source_delta(&repo, 147, row("PHASE-01", &head, &head)).unwrap();
let verdict = registry_completeness(&repo, &repo, 147).unwrap();
assert_eq!(
verdict,
Completeness::Incomplete {
gaps: vec![CompletenessGap::Missing {
phase: "PHASE-02".to_string()
}]
}
);
}
}