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::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();
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(""));
}
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()))
}
#[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(),
})
.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");
}
}