use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::Serialize;
use crate::entity::{
self, Artifact, Fileset, Inputs, Kind, LocalFs, MaterialiseRequest, ScaffoldCtx,
};
use crate::lifecycle::{Transition, classify, crosses_closure_seam};
use crate::listing::{self, Format, ListArgs};
use crate::meta::{self, Meta};
use crate::plan::Plan;
use crate::tomlfmt::toml_string;
const SLICE_DIR: &str = ".doctrine/slice";
pub(crate) const SLICE_KIND: Kind = Kind {
dir: SLICE_DIR,
prefix: crate::kinds::SL,
scaffold: slice_scaffold,
};
const DESIGN_KIND: Kind = Kind {
dir: SLICE_DIR,
prefix: crate::kinds::SL,
scaffold: design_scaffold,
};
const PLAN_KIND: Kind = Kind {
dir: SLICE_DIR,
prefix: crate::kinds::SL,
scaffold: plan_scaffold,
};
const NOTES_KIND: Kind = Kind {
dir: SLICE_DIR,
prefix: crate::kinds::SL,
scaffold: notes_scaffold,
};
fn render_toml(id: u32, slug: &str, title: &str, date: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/slice.toml")?
.replace("{{id}}", &id.to_string())
.replace("{{slug}}", &toml_string(slug))
.replace("{{title}}", &toml_string(title))
.replace("{{date}}", date))
}
fn render_md(title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/slice.md")?.replace("{{title}}", title))
}
fn render_design(canonical_id: &str, title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/design.md")?
.replace("{{ref}}", canonical_id)
.replace("{{title}}", title))
}
fn slice_scaffold(ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
let id = ctx.id;
let name = format!("{id:03}");
Ok(vec![
Artifact::File {
rel_path: PathBuf::from(format!("{name}/slice-{name}.toml")),
body: render_toml(id, ctx.slug, ctx.title, ctx.date)?,
},
Artifact::File {
rel_path: PathBuf::from(format!("{name}/slice-{name}.md")),
body: render_md(ctx.title)?,
},
Artifact::Symlink {
rel_path: PathBuf::from(format!("{name}-{}", ctx.slug)),
target: name,
},
])
}
fn design_scaffold(ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
let (id, canonical) = (ctx.id, ctx.canonical);
let name = format!("{id:03}");
Ok(vec![Artifact::File {
rel_path: PathBuf::from(format!("{name}/design.md")),
body: render_design(canonical, ctx.title)?,
}])
}
fn render_plan_toml(canonical_id: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/plan.toml")?.replace("{{ref}}", canonical_id))
}
fn render_plan_md(canonical_id: &str, title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/plan.md")?
.replace("{{ref}}", canonical_id)
.replace("{{title}}", title))
}
fn plan_scaffold(ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
let (id, canonical) = (ctx.id, ctx.canonical);
let name = format!("{id:03}");
Ok(vec![
Artifact::File {
rel_path: PathBuf::from(format!("{name}/plan.toml")),
body: render_plan_toml(canonical)?,
},
Artifact::File {
rel_path: PathBuf::from(format!("{name}/plan.md")),
body: render_plan_md(canonical, ctx.title)?,
},
])
}
fn render_notes(canonical_id: &str, title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/notes.md")?
.replace("{{ref}}", canonical_id)
.replace("{{title}}", title))
}
fn notes_scaffold(ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
let (id, canonical) = (ctx.id, ctx.canonical);
let name = format!("{id:03}");
Ok(vec![Artifact::File {
rel_path: PathBuf::from(format!("{name}/notes.md")),
body: render_notes(canonical, ctx.title)?,
}])
}
pub(crate) fn read_plan(slice_root: &Path, id: u32) -> anyhow::Result<Plan> {
let name = format!("{id:03}");
let path = slice_root.join(&name).join("plan.toml");
let text = fs::read_to_string(&path)
.with_context(|| format!("Plan for slice {name} not found at {}", path.display()))?;
Plan::parse(&text)
}
pub(crate) fn run_new(
path: Option<PathBuf>,
title: Option<String>,
slug: Option<String>,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let title = crate::input::resolve_title(title)?;
let slug = crate::input::resolve_slug(&title, slug)?;
let date = crate::clock::today();
let trunk_ids = crate::git::trunk_entity_ids(&root, SLICE_KIND.dir)?;
let out = entity::materialise(
&SLICE_KIND,
&LocalFs,
&root,
&MaterialiseRequest::Fresh,
&Inputs {
slug: &slug,
title: &title,
date: &date,
},
&trunk_ids,
)?;
let id = out
.eid
.numeric_id()
.context("slice kind must yield a numeric id")?;
writeln!(io::stdout(), "Created slice {id:03}: {}", out.dir.display())?;
Ok(())
}
pub(crate) fn run_design(path: Option<PathBuf>, id: u32) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let slice_root = root.join(SLICE_DIR);
let meta = meta::read_meta(&slice_root, "slice", id)?;
let date = crate::clock::today();
let out = entity::materialise(
&DESIGN_KIND,
&LocalFs,
&root,
&MaterialiseRequest::InExisting { id },
&Inputs {
slug: "",
title: &meta.title,
date: &date,
},
&[], )?;
writeln!(
io::stdout(),
"Created design doc: {}",
out.dir.join("design.md").display()
)?;
Ok(())
}
pub(crate) fn run_plan(path: Option<PathBuf>, id: u32) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let slice_root = root.join(SLICE_DIR);
let meta = meta::read_meta(&slice_root, "slice", id)?;
let date = crate::clock::today();
let out = entity::materialise(
&PLAN_KIND,
&LocalFs,
&root,
&MaterialiseRequest::InExisting { id },
&Inputs {
slug: "",
title: &meta.title,
date: &date,
},
&[], )?;
writeln!(
io::stdout(),
"Created implementation plan: {}",
out.dir.join("plan.toml").display()
)?;
Ok(())
}
pub(crate) fn run_phases(path: Option<PathBuf>, id: u32, prune: bool) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let slice_root = root.join(SLICE_DIR);
let plan = read_plan(&slice_root, id)?;
let report = crate::state::init_phases(&root, id, &plan, prune)?;
let mut out = io::stdout();
for phase_id in &report.created {
writeln!(out, " materialised {phase_id}")?;
}
for phase_id in &report.orphan {
writeln!(
out,
" orphan {phase_id} (plan phase gone; --prune to remove)"
)?;
}
for phase_id in &report.pruned {
writeln!(out, " pruned {phase_id}")?;
}
if report.created.is_empty() && report.orphan.is_empty() && report.pruned.is_empty() {
writeln!(out, "Phases up to date.")?;
}
Ok(())
}
pub(crate) fn run_notes(path: Option<PathBuf>, id: u32) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let slice_root = root.join(SLICE_DIR);
let meta = meta::read_meta(&slice_root, "slice", id)?;
let date = crate::clock::today();
let out = entity::materialise(
&NOTES_KIND,
&LocalFs,
&root,
&MaterialiseRequest::InExisting { id },
&Inputs {
slug: "",
title: &meta.title,
date: &date,
},
&[], )?;
writeln!(
io::stdout(),
"Created notes: {}",
out.dir.join("notes.md").display()
)?;
Ok(())
}
pub(crate) fn run_phase(
path: Option<PathBuf>,
id: u32,
phase_id: &str,
status: crate::state::PhaseStatus,
note: Option<&str>,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let now = crate::clock::now_timestamp()?;
crate::state::set_phase_status(&root, id, phase_id, status, note, &now)?;
writeln!(io::stdout(), "Updated {phase_id}: {}", status.as_str())?;
Ok(())
}
pub(crate) fn run_status(
path: Option<PathBuf>,
id: u32,
state: SliceStatus,
note: Option<&str>,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let slice_root = root.join(SLICE_DIR);
let from = read_status(&slice_root, id)?;
let to = state.as_str();
let kind = classify(&from, to);
if crosses_closure_seam(&from, to) {
let blockers = crate::review::unresolved_blockers_for(&root, &canonical_id(id))?;
if !blockers.is_empty() {
let listed = blockers
.iter()
.map(|b| format!("{}/{}", b.rv, b.finding))
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"slice {} → {to}: refused — unresolved blocker review finding(s): {listed} \
(resolve via `review verify`/`review withdraw`, then retry)",
canonical_id(id)
);
}
}
if from == "reconcile" && to == "done" {
let undischarged = undischarged_drift(&root, id)?;
if !undischarged.is_empty() {
anyhow::bail!(
"slice {} → {to}: refused — undischarged residual drift on \
requirement(s): {} (reconcile each via an `accept` REC whose evidence \
covers the current drift, or resolve the drift, then retry)",
canonical_id(id),
undischarged.join(", "),
);
}
}
set_slice_status(&slice_root, id, &from, state, &crate::clock::today())?;
let cfg = load_conduct(&root)?;
let posture = crate::conduct::resolve(&cfg, &from);
writeln!(
io::stdout(),
"{}",
status_line(&from, to, kind, posture, note)
)?;
Ok(())
}
const DOCTRINE_TOML: &str = "doctrine.toml";
fn load_conduct(root: &Path) -> anyhow::Result<crate::conduct::ConductConfig> {
let path = root.join(DOCTRINE_TOML);
match fs::read_to_string(&path) {
Ok(text) => crate::dtoml::parse(&text)
.map(|doc| doc.conduct)
.with_context(|| format!("Failed to parse {}", path.display())),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
Ok(crate::conduct::ConductConfig::default())
}
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
}
}
fn status_line(
from: &str,
to: &str,
kind: Transition,
posture: crate::conduct::Conduct,
note: Option<&str>,
) -> String {
let suffix = note.map(|n| format!(" — {n}")).unwrap_or_default();
format!(
"{from} → {to} [{}] [{}]{suffix}",
transition_label(kind),
posture.label()
)
}
fn transition_label(kind: Transition) -> &'static str {
match kind {
Transition::Advance => "advance",
Transition::BackEdge => "back-edge",
Transition::Skip => "skip",
Transition::Abandon => "abandon",
Transition::Noop => "no-op",
Transition::FromTerminal => "from-terminal",
Transition::SeamBreach => "seam-breach",
}
}
fn read_status(slice_root: &Path, id: u32) -> anyhow::Result<String> {
let name = format!("{id:03}");
let path = slice_root.join(&name).join(format!("slice-{name}.toml"));
let text = fs::read_to_string(&path)
.with_context(|| format!("slice {name} not found at {}", path.display()))?;
let doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", path.display()))?;
doc.get("status")
.and_then(toml_edit::Item::as_str)
.map(str::to_string)
.with_context(|| format!("malformed slice {name}: missing `status`"))
}
fn set_slice_status(
slice_root: &Path,
id: u32,
from: &str,
state: SliceStatus,
today: &str,
) -> anyhow::Result<()> {
let to = state.as_str();
let name = format!("{id:03}");
match classify(from, to) {
Transition::FromTerminal => anyhow::bail!(
"slice {name}: refusing to leave terminal status `{from}` (reopening is deferred)"
),
Transition::SeamBreach => anyhow::bail!(
"slice {name}: `{to}` is reachable only across the closure seam \
(→ reconcile from audit, → done from reconcile), not from `{from}`"
),
_ => {}
}
let path = slice_root.join(&name).join(format!("slice-{name}.toml"));
let hint = format!(
"malformed slice {name}: missing `status`/`updated` — restore the missing keys and retry; the file is left untouched"
);
crate::dep_seq::set_authored_status(&path, &[("status", to), ("updated", today)], &hint)?;
Ok(())
}
pub(crate) const SLICE_STATUSES: &[&str] = &[
"proposed",
"design",
"plan",
"ready",
"started",
"audit",
"reconcile",
"done",
"abandoned",
];
fn is_hidden(status: &str) -> bool {
matches!(status, "done" | "abandoned")
}
fn is_drifted(status: &str) -> bool {
!SLICE_STATUSES.contains(&status)
}
fn is_terminal_status(authored: &str) -> bool {
authored == "done"
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum SliceStatus {
Proposed,
Design,
Plan,
Ready,
Started,
Audit,
Reconcile,
Done,
Abandoned,
}
impl SliceStatus {
fn as_str(self) -> &'static str {
match self {
Self::Proposed => "proposed",
Self::Design => "design",
Self::Plan => "plan",
Self::Ready => "ready",
Self::Started => "started",
Self::Audit => "audit",
Self::Reconcile => "reconcile",
Self::Done => "done",
Self::Abandoned => "abandoned",
}
}
}
fn is_divergent(authored: &str, rollup: Option<&crate::state::PhaseRollup>) -> bool {
let Some(r) = rollup else { return false };
if r.anomalies() > 0 {
return false;
}
let terminal = is_terminal_status(authored);
(terminal && r.completed < r.total())
|| (!terminal && r.total() > 0 && r.completed == r.total())
}
fn phases_cell(rollup: Option<&crate::state::PhaseRollup>) -> String {
let Some(r) = rollup else {
return "—".to_string();
};
let blocked = if r.blocked > 0 {
format!(" !{}", r.blocked)
} else {
String::new()
};
let anomalies = if r.anomalies() > 0 {
format!(" ?{}", r.anomalies())
} else {
String::new()
};
format!("{}/{}{blocked}{anomalies}", r.completed, r.total())
}
fn decorated_status(status: &str, rollup: Option<&crate::state::PhaseRollup>) -> String {
let drift = if is_drifted(status) { "?" } else { "" };
let divergence = if is_divergent(status, rollup) {
" ⚠"
} else {
""
};
format!("{status}{drift}{divergence}")
}
type SliceRowTuple = (Meta, Option<crate::state::PhaseRollup>);
const SLICE_COLUMNS: [listing::Column<SliceRowTuple>; 5] = [
listing::Column {
name: "id",
header: "id",
cell: |(m, _)| canonical_id(m.id),
paint: listing::ColumnPaint::Fixed(owo_colors::DynColors::Ansi(
owo_colors::AnsiColors::Cyan,
)),
},
listing::Column {
name: "status",
header: "status",
cell: |(m, r)| decorated_status(&m.status, r.as_ref()),
paint: listing::ColumnPaint::ByValue(|(m, _)| listing::status_hue(&m.status)),
},
listing::Column {
name: "phases",
header: "phases",
cell: |(_, r)| phases_cell(r.as_ref()),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "slug",
header: "slug",
cell: |(m, _)| m.slug.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "title",
header: "title",
cell: |(m, _)| m.title.clone(),
paint: listing::ColumnPaint::Alternate([listing::TITLE_EVEN, listing::TITLE_ODD]),
},
];
const SLICE_DEFAULT: &[&str] = &["id", "status", "phases", "title"];
#[derive(Debug, Serialize)]
struct SliceRow {
id: String,
status: String,
slug: String,
title: String,
phases: Option<PhasesJson>,
}
#[derive(Debug, Serialize)]
struct PhasesJson {
completed: u32,
total: u32,
blocked: u32,
}
fn key(m: &Meta) -> listing::FilterFields {
listing::FilterFields {
canonical: canonical_id(m.id),
slug: m.slug.clone(),
title: m.title.clone(),
status: m.status.clone(),
tags: Vec::new(),
}
}
fn canonical_id(id: u32) -> String {
listing::canonical_id(SLICE_KIND.prefix, id)
}
fn read_gate_extra_reqs(slice_root: &Path, id: u32) -> anyhow::Result<Vec<String>> {
let name = format!("{id:03}");
let path = slice_root.join(&name).join(format!("slice-{name}.toml"));
let text = fs::read_to_string(&path)
.with_context(|| format!("slice {name} not found at {}", path.display()))?;
let doc: SliceDoc =
toml::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(doc.gate.extra_reqs)
}
fn gate_requirement_set(
root: &Path,
id: u32,
owned_recs: &[crate::rec::RecDoc],
) -> anyhow::Result<Vec<String>> {
let canonical = canonical_id(id);
let slice_root = root.join(SLICE_DIR);
let mut set = std::collections::BTreeSet::new();
set.extend(crate::coverage_scan::slice_local_covered_reqs(
root, id, &canonical,
)?);
set.extend(read_gate_extra_reqs(&slice_root, id)?);
set.extend(
owned_recs
.iter()
.flat_map(|rec| rec.status_delta.iter().map(|d| d.requirement.clone())),
);
Ok(set.into_iter().collect())
}
fn undischarged_drift(root: &Path, id: u32) -> anyhow::Result<Vec<String>> {
let canonical = canonical_id(id);
let owned_recs = crate::rec::recs_owned_by(root, &canonical)?;
let mut undischarged = Vec::new();
for req in gate_requirement_set(root, id, &owned_recs)? {
let entries = crate::coverage_scan::scan_coverage(root, &req);
let composite = crate::coverage::composite(&entries);
let authored = crate::requirement::load(root, &req)
.with_context(|| format!("closure gate: requirement {req} not found"))?
.status;
if matches!(
crate::coverage::drift(authored, &composite),
crate::coverage::Verdict::Coherent
) {
continue;
}
let residual_keys = crate::coverage::distinct_keys(entries.into_iter().map(|(e, _)| e.key));
let latest = latest_owning_rec_for(&owned_recs, &req);
if !rec_discharges(latest, &req, authored, &residual_keys) {
undischarged.push(req);
}
}
Ok(undischarged)
}
fn latest_owning_rec_for<'a>(
owned_recs: &'a [crate::rec::RecDoc],
req: &str,
) -> Option<&'a crate::rec::RecDoc> {
owned_recs
.iter()
.filter(|rec| rec.status_delta.iter().any(|d| d.requirement == req))
.max_by_key(|rec| rec.id)
}
fn rec_discharges(
latest: Option<&crate::rec::RecDoc>,
req: &str,
authored: crate::requirement::ReqStatus,
residual_keys: &[crate::coverage::CoverageKey],
) -> bool {
let Some(rec) = latest else { return false };
if rec.rec.r#move != "accept" {
return false;
}
let affirmed_at_current = rec
.status_delta
.iter()
.any(|d| d.requirement == req && d.to == authored.as_str());
if !affirmed_at_current {
return false;
}
residual_keys
.iter()
.all(|k| rec.evidence_ref.iter().any(|e| e == k))
}
fn validate_statuses(given: &[String], known: &[&str]) -> anyhow::Result<()> {
listing::validate_statuses(given, known)
}
pub(crate) fn list_rows(root: &Path, mut args: ListArgs) -> anyhow::Result<String> {
validate_statuses(&args.status, SLICE_STATUSES)?;
let render = args.render;
let columns = args.columns.take();
let (filter, format) = listing::build(args)?;
let slice_root = root.join(SLICE_DIR);
let mut metas = listing::retain(
meta::read_metas(&slice_root, "slice")?,
&filter,
is_hidden,
key,
);
metas.sort_by_key(|m| m.id);
let rows: Vec<(Meta, Option<crate::state::PhaseRollup>)> = metas
.into_iter()
.map(|m| {
let rollup = crate::state::phase_rollup(root, m.id)?;
Ok((m, rollup))
})
.collect::<anyhow::Result<_>>()?;
match format {
Format::Table => {
let sel = listing::select_columns(&SLICE_COLUMNS, SLICE_DEFAULT, columns.as_deref())?;
Ok(listing::render_columns(&rows, &sel, render))
}
Format::Json => listing::json_envelope("slice", &json_rows(&rows)),
}
}
fn json_rows(rows: &[(Meta, Option<crate::state::PhaseRollup>)]) -> Vec<SliceRow> {
rows.iter()
.map(|(m, rollup)| SliceRow {
id: canonical_id(m.id),
status: m.status.clone(),
slug: m.slug.clone(),
title: m.title.clone(),
phases: rollup.as_ref().map(|r| PhasesJson {
completed: r.completed,
total: r.total(),
blocked: r.blocked,
}),
})
.collect()
}
pub(crate) fn run_list(path: Option<PathBuf>, args: ListArgs) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let mut out = io::stdout();
write!(out, "{}", list_rows(&root, args)?)?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, serde::Deserialize, Serialize)]
struct SliceDoc {
id: u32,
slug: String,
title: String,
status: String,
created: String,
updated: String,
#[serde(default)]
gate: Gate,
#[serde(default)]
estimate: Option<crate::estimate::EstimateFacet>,
#[serde(default)]
value: Option<crate::value::ValueFacet>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Deserialize, Serialize)]
struct Gate {
#[serde(default)]
extra_reqs: Vec<String>,
}
pub(crate) fn parse_ref(reference: &str) -> anyhow::Result<u32> {
let digits = reference
.strip_prefix("SL-")
.or_else(|| reference.strip_prefix("sl-"))
.unwrap_or(reference);
digits.parse::<u32>().with_context(|| {
format!("not a slice reference: `{reference}` (expected `SL-025` or `25`)")
})
}
pub(crate) fn run_show(
path: Option<PathBuf>,
reference: &str,
format: Format,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let id = parse_ref(reference)?;
let (doc, toml_text, body) = read_slice(&root.join(SLICE_DIR), id)?;
let tier1 = crate::relation::tier1_edges(&SLICE_KIND, &toml_text)?;
let dep_seq = crate::dep_seq::read(&slice_toml_path(&root.join(SLICE_DIR), id))?;
let out = match format {
Format::Table => {
let cfg = load_conduct(&root)?;
let posture = crate::conduct::resolve(&cfg, &doc.status);
format_show(&doc, &tier1, &dep_seq, &body, posture)
}
Format::Json => show_json(&doc, &tier1, &dep_seq, &body)?,
};
write!(io::stdout(), "{out}")?;
Ok(())
}
fn slice_toml_path(slice_root: &Path, id: u32) -> PathBuf {
let name = format!("{id:03}");
slice_root.join(&name).join(format!("slice-{name}.toml"))
}
fn read_slice(slice_root: &Path, id: u32) -> anyhow::Result<(SliceDoc, String, String)> {
let name = format!("{id:03}");
let dir = slice_root.join(&name);
let toml_path = slice_toml_path(slice_root, id);
let text = fs::read_to_string(&toml_path)
.with_context(|| format!("slice {name} not found at {}", toml_path.display()))?;
let doc: SliceDoc = toml::from_str(&text)
.with_context(|| format!("Failed to parse {}", toml_path.display()))?;
let md_path = dir.join(format!("slice-{name}.md"));
let body = fs::read_to_string(&md_path)
.with_context(|| format!("Failed to read {}", md_path.display()))?;
Ok((doc, text, body))
}
pub(crate) fn relation_edges(
root: &Path,
id: u32,
) -> anyhow::Result<Vec<crate::relation::RelationEdge>> {
let (_doc, toml_text, _body) = read_slice(&root.join(SLICE_DIR), id)?;
crate::relation::tier1_edges(&SLICE_KIND, &toml_text)
}
fn format_show(
doc: &SliceDoc,
tier1: &[crate::relation::RelationEdge],
dep_seq: &crate::dep_seq::DepSeq,
body: &str,
posture: crate::conduct::Conduct,
) -> String {
use crate::relation::{RelationLabel, targets_for};
let mut parts: Vec<String> = Vec::new();
parts.push(format!("{} — {}\n", canonical_id(doc.id), doc.title));
parts.push(format!("{} · {}\n", doc.slug, doc.status));
parts.push(format!("conduct: {}\n", posture.label()));
parts.push(format!(
"created {} · updated {}\n",
doc.created, doc.updated
));
let axes = [
("specs", targets_for(tier1, RelationLabel::Specs)),
(
"requirements",
targets_for(tier1, RelationLabel::Requirements),
),
("supersedes", targets_for(tier1, RelationLabel::Supersedes)),
("governed_by", targets_for(tier1, RelationLabel::GovernedBy)),
];
let after_line = dep_seq
.after
.iter()
.map(|e| {
if e.rank == 0 {
e.to.clone()
} else {
format!("{} (rank {})", e.to, e.rank)
}
})
.collect::<Vec<_>>();
let any_tier1 = axes.iter().any(|(_, refs)| !refs.is_empty());
let any_dep_seq = !dep_seq.needs.is_empty() || !after_line.is_empty();
if any_tier1 || any_dep_seq {
parts.push("\nrelationships:\n".to_string());
for (label, refs) in &axes {
if !refs.is_empty() {
parts.push(format!(" {label}: {}\n", refs.join(", ")));
}
}
if !dep_seq.needs.is_empty() {
parts.push(format!(" needs: {}\n", dep_seq.needs.join(", ")));
}
if !after_line.is_empty() {
parts.push(format!(" after: {}\n", after_line.join(", ")));
}
}
parts.push(format!("\n{body}"));
parts.concat()
}
fn show_json(
doc: &SliceDoc,
tier1: &[crate::relation::RelationEdge],
dep_seq: &crate::dep_seq::DepSeq,
body: &str,
) -> anyhow::Result<String> {
use crate::relation::{RelationLabel, targets_for};
let mut relationships = serde_json::Map::new();
relationships.insert(
"specs".to_string(),
serde_json::json!(targets_for(tier1, RelationLabel::Specs)),
);
relationships.insert(
"requirements".to_string(),
serde_json::json!(targets_for(tier1, RelationLabel::Requirements)),
);
relationships.insert(
"supersedes".to_string(),
serde_json::json!(targets_for(tier1, RelationLabel::Supersedes)),
);
let governed_by = targets_for(tier1, RelationLabel::GovernedBy);
if !governed_by.is_empty() {
relationships.insert("governed_by".to_string(), serde_json::json!(governed_by));
}
if !dep_seq.needs.is_empty() {
relationships.insert("needs".to_string(), serde_json::json!(dep_seq.needs));
}
if !dep_seq.after.is_empty() {
relationships.insert("after".to_string(), serde_json::json!(dep_seq.after));
}
let mut slice = serde_json::to_value(doc).context("failed to serialize slice doc")?;
if let Some(obj) = slice.as_object_mut() {
obj.insert(
"relationships".to_string(),
serde_json::Value::Object(relationships),
);
}
let value = serde_json::json!({ "kind": "slice", "slice": slice, "body": body });
serde_json::to_string_pretty(&value).context("failed to serialize slice show JSON")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lifecycle::is_transition_terminal;
use crate::meta::Meta;
fn meta(id: u32, status: &str, slug: &str, title: &str) -> Meta {
Meta {
id,
slug: slug.to_string(),
title: title.to_string(),
status: status.to_string(),
}
}
use crate::state::PhaseRollup;
fn rollup(completed: u32, planned: u32) -> PhaseRollup {
PhaseRollup {
completed,
planned,
..Default::default()
}
}
#[test]
fn divergence_flags_the_two_unambiguous_mismatches() {
assert!(is_divergent("done", Some(&rollup(2, 4))));
assert!(is_divergent("proposed", Some(&rollup(6, 0))));
}
#[test]
fn divergence_is_quiet_when_consistent_untracked_or_anomalous() {
assert!(!is_divergent("done", Some(&rollup(6, 0))));
assert!(!is_divergent("proposed", Some(&rollup(2, 4))));
assert!(!is_divergent("done", None));
let anomalous = PhaseRollup {
completed: 2,
planned: 3,
unknown: 1,
..Default::default()
};
assert!(!is_divergent("done", Some(&anomalous)));
}
#[test]
fn phases_cell_renders_markers() {
assert_eq!(phases_cell(None), "—");
assert_eq!(phases_cell(Some(&rollup(4, 2))), "4/6");
let blocked = PhaseRollup {
completed: 2,
planned: 3,
blocked: 1,
..Default::default()
};
assert_eq!(phases_cell(Some(&blocked)), "2/6 !1");
let anomalous = PhaseRollup {
completed: 3,
planned: 2,
unknown: 1,
..Default::default()
};
assert_eq!(phases_cell(Some(&anomalous)), "3/6 ?1");
}
fn render_default(rows: &[SliceRowTuple]) -> String {
let sel = listing::select_columns(&SLICE_COLUMNS, SLICE_DEFAULT, None).unwrap();
listing::render_columns(rows, &sel, listing::RenderOpts::default())
}
fn render_cols(rows: &[SliceRowTuple], cols: &[&str]) -> String {
let owned: Vec<String> = cols.iter().map(|s| (*s).to_string()).collect();
let sel = listing::select_columns(&SLICE_COLUMNS, SLICE_DEFAULT, Some(&owned)).unwrap();
listing::render_columns(rows, &sel, listing::RenderOpts::default())
}
#[test]
fn slice_list_empty_suppresses_the_header() {
assert_eq!(render_default(&[]), "");
}
#[test]
fn slice_list_default_renders_prefixed_ids_rollup_and_divergence() {
let rows = vec![
(
meta(1, "done", "entity-v1", "Entity v1"),
Some(rollup(6, 0)),
),
(
meta(7, "done", "anchoring", "Anchoring"),
Some(rollup(2, 4)),
),
(meta(9, "proposed", "rollup", "Rollup"), None),
];
let out = render_default(&rows);
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].starts_with("id"), "header: {:?}", lines[0]);
assert!(lines[0].contains("phases"), "phases column: {:?}", lines[0]);
assert!(lines[1].starts_with("SL-001 │ done"), "{:?}", lines[1]);
assert!(lines[1].contains("6/6"));
assert!(lines[2].starts_with("SL-007 │ done ⚠"), "{:?}", lines[2]);
assert!(lines[2].contains("2/6"));
assert!(lines[3].starts_with("SL-009 │ proposed"), "{:?}", lines[3]);
assert!(lines[3].contains("—"));
assert!(!out.contains("\n001 "), "no bare numeric id: {out}");
}
#[test]
fn slice_list_default_omits_slug() {
let rows = vec![(meta(1, "proposed", "entity-v1", "Entity v1"), None)];
let out = render_default(&rows);
let header = out.lines().next().unwrap();
assert!(
!header.contains("slug"),
"default header omits slug: {header:?}"
);
assert!(
!out.contains("entity-v1"),
"slug value hidden by default: {out}"
);
assert!(header.contains("title"), "default keeps title: {header:?}");
}
#[test]
fn slice_list_columns_reveals_slug_and_preserves_markers() {
let rows = vec![(
meta(7, "done", "anchoring", "Anchoring"),
Some(rollup(2, 4)),
)];
let out = render_cols(&rows, &["id", "status", "phases", "slug"]);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(
lines[0].split_whitespace().collect::<Vec<_>>(),
vec!["id", "│", "status", "│", "phases", "│", "slug"]
);
assert!(
out.contains("anchoring"),
"slug revealed by --columns: {out}"
);
assert!(
lines[1].contains("done ⚠"),
"⚠ marker preserved: {:?}",
lines[1]
);
assert!(
lines[1].contains("2/6"),
"phases cell intact: {:?}",
lines[1]
);
}
#[test]
fn decorated_status_composes_drift_and_divergence() {
assert_eq!(decorated_status("proposed", None), "proposed");
assert_eq!(decorated_status("done", Some(&rollup(2, 4))), "done ⚠");
assert_eq!(decorated_status("bogus", None), "bogus?");
assert_eq!(decorated_status("bogus", Some(&rollup(6, 0))), "bogus? ⚠");
assert_eq!(
decorated_status("abandoned", Some(&rollup(2, 4))),
"abandoned"
);
}
#[test]
fn is_drifted_flags_only_out_of_vocab() {
for s in SLICE_STATUSES {
assert!(!is_drifted(s), "in-vocab `{s}` is not drift");
}
assert!(is_drifted("bogus"));
assert!(is_drifted("superseded")); }
#[test]
fn is_hidden_is_the_terminal_presentation_set_not_divergence() {
assert!(is_hidden("done"));
assert!(is_hidden("abandoned"));
assert!(!is_hidden("proposed"));
assert!(!is_hidden("bogus"));
assert!(is_terminal_status("done"));
assert!(!is_terminal_status("abandoned"));
}
fn make_slice(root: &Path, slug: &str, title: &str, date: &str) -> entity::Materialised {
entity::materialise(
&SLICE_KIND,
&LocalFs,
root,
&MaterialiseRequest::Fresh,
&Inputs { slug, title, date },
&[],
)
.unwrap()
}
#[test]
fn render_toml_round_trips_to_metadata() {
let body = render_toml(7, "my-slug", "My Title", "2026-06-03").unwrap();
let parsed: Meta = toml::from_str(&body).unwrap();
assert_eq!(parsed, meta(7, "proposed", "my-slug", "My Title"));
assert!(body.contains("created = \"2026-06-03\""));
}
#[test]
fn render_toml_escapes_hostile_title_and_slug() {
let title = crate::tomlfmt::HOSTILE_TITLE;
let slug = crate::tomlfmt::HOSTILE_SLUG;
let body = render_toml(7, slug, title, "2026-06-03").unwrap();
let parsed: Meta = toml::from_str(&body).unwrap();
assert_eq!(parsed.slug, slug);
assert_eq!(parsed.title, title);
}
#[test]
fn render_md_substitutes_title() {
let body = render_md("My Title").unwrap();
assert!(body.contains("My Title"));
assert!(!body.contains("{{title}}"));
}
#[test]
fn render_design_substitutes_ref_and_title() {
let body = render_design("SL-003", "My Title").unwrap();
assert!(body.contains("Design SL-003: My Title"));
assert!(!body.contains("{{ref}}"));
assert!(!body.contains("{{title}}"));
}
#[test]
fn slice_scaffold_lays_out_two_files_and_a_symlink() {
let ctx = ScaffoldCtx {
id: 3,
canonical: "SL-003",
slug: "vendor-skills",
title: "Vendor skills",
date: "2026-06-03",
};
let fileset = slice_scaffold(&ctx).unwrap();
assert_eq!(fileset.len(), 3);
assert!(matches!(&fileset[0],
Artifact::File { rel_path, body }
if rel_path == Path::new("003/slice-003.toml") && body.contains("2026-06-03")));
assert!(matches!(&fileset[1],
Artifact::File { rel_path, body }
if rel_path == Path::new("003/slice-003.md") && body.contains("Vendor skills")));
assert!(matches!(&fileset[2],
Artifact::Symlink { rel_path, target }
if rel_path == Path::new("003-vendor-skills") && target == "003"));
}
#[test]
fn render_plan_toml_substitutes_ref_and_parses() {
let body = render_plan_toml("SL-004").unwrap();
assert!(body.contains("slice = \"SL-004\""));
assert!(!body.contains("{{ref}}"));
let doc: toml::Value = toml::from_str(&body).unwrap();
assert_eq!(doc["schema"].as_str(), Some("doctrine.plan.overview"));
assert_eq!(doc["version"].as_integer(), Some(1));
assert_eq!(doc["phase"][0]["id"].as_str(), Some("PHASE-01"));
}
#[test]
fn render_plan_md_substitutes_ref_and_title() {
let body = render_plan_md("SL-004", "My Title").unwrap();
assert!(body.contains("Implementation Plan SL-004: My Title"));
assert!(!body.contains("{{ref}}"));
assert!(!body.contains("{{title}}"));
}
#[test]
fn plan_scaffold_lays_out_toml_and_md() {
let ctx = ScaffoldCtx {
id: 4,
canonical: "SL-004",
slug: "",
title: "Plan title",
date: "2026-06-04",
};
let fileset = plan_scaffold(&ctx).unwrap();
assert_eq!(fileset.len(), 2);
assert!(matches!(&fileset[0],
Artifact::File { rel_path, body }
if rel_path == Path::new("004/plan.toml") && body.contains("SL-004")));
assert!(matches!(&fileset[1],
Artifact::File { rel_path, body }
if rel_path == Path::new("004/plan.md") && body.contains("Plan title")));
}
#[test]
fn plan_parse_accepts_the_scaffold_template() {
let body = render_plan_toml("SL-004").unwrap();
let plan = Plan::parse(&body).unwrap();
assert_eq!(plan.phases.len(), 1);
assert_eq!(plan.phases[0].id, "PHASE-01");
}
#[test]
fn design_scaffold_is_a_single_file_no_symlink() {
let ctx = ScaffoldCtx {
id: 3,
canonical: "SL-003",
slug: "",
title: "Vendor skills",
date: "2026-06-03",
};
let fileset = design_scaffold(&ctx).unwrap();
assert_eq!(fileset.len(), 1);
assert!(matches!(&fileset[0],
Artifact::File { rel_path, body }
if rel_path == Path::new("003/design.md") && body.contains("Design SL-003: Vendor skills")));
}
#[test]
fn materialise_writes_well_formed_slice() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let s = make_slice(root, "my-slug", "My Title", "2026-06-03");
let slice_root = root.join(SLICE_DIR);
assert_eq!(s.eid.numeric_id(), Some(1));
assert!(slice_root.join("001").is_dir());
assert!(slice_root.join("001/slice-001.toml").is_file());
assert!(slice_root.join("001/slice-001.md").is_file());
assert_eq!(
fs::read_link(slice_root.join("001-my-slug")).unwrap(),
Path::new("001")
);
let toml_body = fs::read_to_string(slice_root.join("001/slice-001.toml")).unwrap();
assert!(toml_body.contains("id = 1"));
assert!(toml_body.contains("2026-06-03"));
}
#[test]
fn meta_read_metas_round_trips_a_created_slice() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "my-slug", "My Title", "2026-06-03");
let metas = meta::read_metas(&root.join(SLICE_DIR), "slice").unwrap();
assert_eq!(metas, vec![meta(1, "proposed", "my-slug", "My Title")]);
}
#[test]
fn design_materialises_under_an_existing_slice_with_no_symlink() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "my-slug", "My Title", "2026-06-03");
let slice_root = root.join(SLICE_DIR);
let out = entity::materialise(
&DESIGN_KIND,
&LocalFs,
root,
&MaterialiseRequest::InExisting { id: 1 },
&Inputs {
slug: "",
title: "My Title",
date: "2026-06-03",
},
&[],
)
.unwrap();
assert_eq!(out.eid.numeric_id(), Some(1));
let body = fs::read_to_string(slice_root.join("001/design.md")).unwrap();
assert!(body.contains("Design SL-001: My Title"));
assert!(!slice_root.join("002").exists());
}
#[test]
fn design_refuses_to_clobber_an_existing_doc() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "my-slug", "My Title", "2026-06-03");
let slice_root = root.join(SLICE_DIR);
fs::write(slice_root.join("001/design.md"), "hand-written").unwrap();
let err = entity::materialise(
&DESIGN_KIND,
&LocalFs,
root,
&MaterialiseRequest::InExisting { id: 1 },
&Inputs {
slug: "",
title: "My Title",
date: "2026-06-03",
},
&[],
)
.unwrap_err();
assert!(err.to_string().contains("Refusing to overwrite"));
assert_eq!(
fs::read_to_string(slice_root.join("001/design.md")).unwrap(),
"hand-written"
);
}
#[test]
fn plan_materialises_two_files_under_an_existing_slice() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "my-slug", "My Title", "2026-06-04");
let slice_root = root.join(SLICE_DIR);
let out = entity::materialise(
&PLAN_KIND,
&LocalFs,
root,
&MaterialiseRequest::InExisting { id: 1 },
&Inputs {
slug: "",
title: "My Title",
date: "2026-06-04",
},
&[],
)
.unwrap();
assert_eq!(out.eid.numeric_id(), Some(1));
let toml_body = fs::read_to_string(slice_root.join("001/plan.toml")).unwrap();
assert!(toml_body.contains("slice = \"SL-001\""));
let md_body = fs::read_to_string(slice_root.join("001/plan.md")).unwrap();
assert!(md_body.contains("Implementation Plan SL-001: My Title"));
assert!(!slice_root.join("002").exists());
}
#[test]
fn plan_refuses_to_clobber_an_existing_plan() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "my-slug", "My Title", "2026-06-04");
let slice_root = root.join(SLICE_DIR);
fs::write(slice_root.join("001/plan.toml"), "hand-written").unwrap();
let err = entity::materialise(
&PLAN_KIND,
&LocalFs,
root,
&MaterialiseRequest::InExisting { id: 1 },
&Inputs {
slug: "",
title: "My Title",
date: "2026-06-04",
},
&[],
)
.unwrap_err();
assert!(err.to_string().contains("Refusing to overwrite"));
assert_eq!(
fs::read_to_string(slice_root.join("001/plan.toml")).unwrap(),
"hand-written"
);
assert!(!slice_root.join("001/plan.md").exists());
}
#[test]
fn notes_materialises_under_an_existing_slice() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "my-slug", "My Title", "2026-06-04");
let slice_root = root.join(SLICE_DIR);
entity::materialise(
&NOTES_KIND,
&LocalFs,
root,
&MaterialiseRequest::InExisting { id: 1 },
&Inputs {
slug: "",
title: "My Title",
date: "2026-06-04",
},
&[],
)
.unwrap();
let body = fs::read_to_string(slice_root.join("001/notes.md")).unwrap();
assert!(body.contains("Notes SL-001: My Title"));
}
#[test]
fn notes_refuses_to_clobber() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "my-slug", "My Title", "2026-06-04");
let slice_root = root.join(SLICE_DIR);
fs::write(slice_root.join("001/notes.md"), "hand-written").unwrap();
let err = entity::materialise(
&NOTES_KIND,
&LocalFs,
root,
&MaterialiseRequest::InExisting { id: 1 },
&Inputs {
slug: "",
title: "My Title",
date: "2026-06-04",
},
&[],
)
.unwrap_err();
assert!(err.to_string().contains("Refusing to overwrite"));
assert_eq!(
fs::read_to_string(slice_root.join("001/notes.md")).unwrap(),
"hand-written"
);
}
fn slice_root(root: &Path) -> PathBuf {
root.join(SLICE_DIR)
}
fn set_status_raw(root: &Path, id: u32, status: &str) {
let name = format!("{id:03}");
let p = slice_root(root)
.join(&name)
.join(format!("slice-{name}.toml"));
let flipped = fs::read_to_string(&p)
.unwrap()
.replace("status = \"proposed\"", &format!("status = \"{status}\""));
fs::write(&p, flipped).unwrap();
}
fn list_args() -> ListArgs {
ListArgs::default()
}
#[test]
fn list_rows_emits_prefixed_ids_and_a_header() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "first", "First", "2026-06-04");
make_slice(root, "second", "Second", "2026-06-04");
let out = list_rows(root, list_args()).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].starts_with("id"), "header row: {:?}", lines[0]);
assert!(lines[0].contains("phases"), "phases column named");
assert!(out.contains("SL-001 │ proposed"), "prefixed id: {out}");
assert!(out.contains("SL-002"), "second slice present: {out}");
assert!(!out.contains("\n001 "), "no bare numeric id: {out}");
}
#[test]
fn list_rows_hide_set_drops_done_and_abandoned_by_default() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "live", "Live", "2026-06-04");
make_slice(root, "shipped", "Shipped", "2026-06-04");
make_slice(root, "dropped", "Dropped", "2026-06-04");
set_status_raw(root, 2, "done");
set_status_raw(root, 3, "abandoned");
let out = list_rows(root, list_args()).unwrap();
assert!(out.contains("SL-001"), "live slice kept: {out}");
assert!(!out.contains("SL-002"), "done hidden by default: {out}");
assert!(
!out.contains("SL-003"),
"abandoned hidden by default: {out}"
);
}
#[test]
fn list_rows_all_and_explicit_status_reveal_the_hide_set() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "live", "Live", "2026-06-04");
make_slice(root, "dropped", "Dropped", "2026-06-04");
set_status_raw(root, 2, "abandoned");
let all = list_rows(
root,
ListArgs {
all: true,
..Default::default()
},
)
.unwrap();
assert!(all.contains("SL-002"), "--all reveals abandoned: {all}");
let by_status = list_rows(
root,
ListArgs {
status: vec!["abandoned".into()],
..Default::default()
},
)
.unwrap();
assert!(
by_status.contains("SL-002"),
"explicit status reveals: {by_status}"
);
assert!(
!by_status.contains("SL-001"),
"and filters to it: {by_status}"
);
}
#[test]
fn list_rows_out_of_vocab_stored_status_is_never_hidden_and_drift_marked() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "weird", "Weird", "2026-06-04");
set_status_raw(root, 1, "bogus");
let out = list_rows(root, list_args()).unwrap();
assert!(out.contains("SL-001"), "drifted slice not hidden: {out}");
assert!(out.contains("bogus?"), "drift `?` marker present: {out}");
assert!(!out.contains("⚠"), "no spurious divergence marker: {out}");
}
#[test]
fn list_rows_filter_matches_slug_and_title() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "use-rust", "Use Rust", "2026-06-04");
make_slice(root, "adopt-ci", "Adopt CI", "2026-06-04");
let out = list_rows(
root,
ListArgs {
substr: Some("adopt".into()),
..Default::default()
},
)
.unwrap();
assert!(out.contains("SL-002"), "substr matches adopt-ci: {out}");
assert!(!out.contains("SL-001"), "use-rust filtered out: {out}");
}
#[test]
fn list_rows_regexp_matches_canonical_id() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "one", "One", "2026-06-04");
make_slice(root, "two", "Two", "2026-06-04");
let out = list_rows(
root,
ListArgs {
regexp: Some("SL-002".into()),
..Default::default()
},
)
.unwrap();
assert!(out.contains("SL-002"), "regex matches canonical: {out}");
assert!(!out.contains("SL-001"), "non-matching dropped: {out}");
}
#[test]
fn list_rows_json_is_the_shared_envelope_with_structured_phases() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "first", "First", "2026-06-04");
let out = list_rows(
root,
ListArgs {
json: true,
..Default::default()
},
)
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["kind"], "slice");
let rows = parsed["rows"].as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["id"], "SL-001");
assert_eq!(rows[0]["status"], "proposed");
assert_eq!(rows[0]["slug"], "first");
assert!(
rows[0]["phases"].is_null(),
"untracked phases → null: {out}"
);
assert!(!out.contains("4/6"), "no rendered phase cell in json");
}
#[test]
fn list_rows_empty_tree_is_the_empty_string() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(list_rows(dir.path(), list_args()).unwrap(), "");
}
#[test]
fn list_rows_rejects_an_unknown_status_with_the_uniform_error() {
let dir = tempfile::tempdir().unwrap();
let err = list_rows(
dir.path(),
ListArgs {
status: vec!["bogus".into()],
..Default::default()
},
)
.unwrap_err()
.to_string();
assert!(err.contains("bogus"), "names the bad value: {err}");
assert!(err.contains("abandoned"), "lists the known set: {err}");
}
#[test]
fn list_rows_accepts_every_known_status() {
let dir = tempfile::tempdir().unwrap();
for s in SLICE_STATUSES {
assert!(
list_rows(
dir.path(),
ListArgs {
status: vec![(*s).to_string()],
..Default::default()
},
)
.is_ok(),
"known status `{s}` accepted"
);
}
}
#[test]
fn list_rows_accepts_abandoned_and_rejects_superseded() {
let dir = tempfile::tempdir().unwrap();
assert!(
list_rows(
dir.path(),
ListArgs {
status: vec!["abandoned".into()],
..Default::default()
},
)
.is_ok()
);
let err = list_rows(
dir.path(),
ListArgs {
status: vec!["superseded".into()],
..Default::default()
},
)
.unwrap_err()
.to_string();
assert!(err.contains("superseded"), "superseded rejected: {err}");
}
#[test]
fn slice_statuses_matches_the_spec_vocabulary() {
assert_eq!(
SLICE_STATUSES,
&[
"proposed",
"design",
"plan",
"ready",
"started",
"audit",
"reconcile",
"done",
"abandoned"
]
);
}
fn slice_at(root: &Path, id: u32, status: &str, slug: &str, title: &str) {
let name = format!("{id:03}");
let dir = slice_root(root).join(&name);
fs::create_dir_all(&dir).unwrap();
let toml = format!(
"id = {id}\nslug = \"{slug}\"\ntitle = \"{title}\"\nstatus = \"{status}\"\ncreated = \"2026-06-04\"\nupdated = \"2026-06-04\"\n"
);
fs::write(dir.join(format!("slice-{name}.toml")), toml).unwrap();
}
#[test]
fn list_rows_orders_by_id_ascending_regardless_of_creation_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
slice_at(root, 3, "proposed", "gamma", "Gamma");
slice_at(root, 1, "proposed", "alpha", "Alpha");
slice_at(root, 2, "proposed", "beta", "Beta");
let out = list_rows(root, list_args()).unwrap();
let off = |id: &str| {
out.find(id)
.unwrap_or_else(|| panic!("{id} present: {out}"))
};
assert!(
off("SL-001") < off("SL-002") && off("SL-002") < off("SL-003"),
"slice rows must render in ascending id order (sort, not read order): {out}"
);
}
#[test]
fn parse_ref_accepts_prefixed_padded_and_bare_ids() {
assert_eq!(parse_ref("SL-025").unwrap(), 25);
assert_eq!(parse_ref("sl-25").unwrap(), 25);
assert_eq!(parse_ref("25").unwrap(), 25);
assert_eq!(parse_ref("002").unwrap(), 2);
assert!(parse_ref("nope").is_err());
}
#[test]
fn read_slice_reassembles_toml_as_data_and_md_scope_body() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "my-slug", "My Title", "2026-06-04");
let (doc, toml_text, body) = read_slice(&slice_root(root), 1).unwrap();
assert_eq!(doc.id, 1);
assert_eq!(doc.slug, "my-slug");
assert_eq!(doc.status, "proposed");
let edges = crate::relation::tier1_edges(&SLICE_KIND, &toml_text).unwrap();
assert!(edges.is_empty());
assert!(body.contains("My Title"));
}
#[test]
fn format_show_renders_identity_and_scope_body() {
use crate::relation::{RelationEdge, RelationLabel};
let doc = SliceDoc {
id: 25,
slug: "uniform-cli".into(),
title: "Uniform CLI".into(),
status: "started".into(),
created: "2026-06-01".into(),
updated: "2026-06-08".into(),
gate: Gate::default(),
estimate: None,
value: None,
};
let tier1 = vec![RelationEdge::new(RelationLabel::Specs, "PRD-010".into())];
let posture =
crate::conduct::resolve(&crate::conduct::ConductConfig::default(), &doc.status);
let out = format_show(
&doc,
&tier1,
&crate::dep_seq::DepSeq::default(),
"# Scope\n\nthe scope body.\n",
posture,
);
assert!(out.contains("SL-025 — Uniform CLI"), "identity: {out}");
assert!(out.contains("uniform-cli · started"), "flat fields: {out}");
assert!(out.contains("conduct: self/auto"), "conduct posture: {out}");
assert!(out.contains("created 2026-06-01 · updated 2026-06-08"));
assert!(out.contains("specs: PRD-010"), "relationships axis: {out}");
assert!(
out.contains("the scope body."),
"scope body appended: {out}"
);
}
#[test]
fn show_does_not_fold_in_design_plan_or_notes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "my-slug", "My Title", "2026-06-04");
let sr = slice_root(root);
fs::write(sr.join("001/design.md"), "DESIGN_SECRET").unwrap();
fs::write(sr.join("001/plan.md"), "PLAN_SECRET").unwrap();
fs::write(sr.join("001/notes.md"), "NOTES_SECRET").unwrap();
let (doc, toml_text, body) = read_slice(&sr, 1).unwrap();
let tier1 = crate::relation::tier1_edges(&SLICE_KIND, &toml_text).unwrap();
let posture =
crate::conduct::resolve(&crate::conduct::ConductConfig::default(), &doc.status);
let ds = crate::dep_seq::DepSeq::default();
let table = format_show(&doc, &tier1, &ds, &body, posture);
let json = show_json(&doc, &tier1, &ds, &body).unwrap();
for needle in ["DESIGN_SECRET", "PLAN_SECRET", "NOTES_SECRET"] {
assert!(!table.contains(needle), "table leaked {needle}: {table}");
assert!(!json.contains(needle), "json leaked {needle}: {json}");
}
}
#[test]
fn show_json_is_faithful_toml_as_data_plus_scope_body() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "my-slug", "My Title", "2026-06-04");
let (doc, toml_text, body) = read_slice(&slice_root(root), 1).unwrap();
let tier1 = crate::relation::tier1_edges(&SLICE_KIND, &toml_text).unwrap();
let out = show_json(&doc, &tier1, &crate::dep_seq::DepSeq::default(), &body).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["kind"], "slice");
assert_eq!(parsed["slice"]["id"], 1);
assert_eq!(parsed["slice"]["slug"], "my-slug");
assert_eq!(parsed["slice"]["status"], "proposed");
assert!(parsed["slice"]["relationships"]["specs"].is_array());
assert!(parsed["slice"]["relationships"]["requirements"].is_array());
assert!(parsed["slice"]["relationships"]["supersedes"].is_array());
assert!(parsed["body"].as_str().unwrap().contains("My Title"));
}
#[test]
fn show_surfaces_dep_seq_axes_in_table_and_json() {
use crate::dep_seq::{AfterEdge, DepSeq};
let doc = SliceDoc {
id: 60,
slug: "dep-seq".into(),
title: "Dep Seq".into(),
status: "proposed".into(),
created: "2026-06-01".into(),
updated: "2026-06-01".into(),
gate: Gate::default(),
estimate: None,
value: None,
};
let tier1 = Vec::new();
let ds = DepSeq {
needs: vec!["SL-047".into()],
after: vec![
AfterEdge {
to: "SL-002".into(),
rank: 0,
},
AfterEdge {
to: "SL-003".into(),
rank: 5,
},
],
};
let posture =
crate::conduct::resolve(&crate::conduct::ConductConfig::default(), &doc.status);
let table = format_show(&doc, &tier1, &ds, "# body\n", posture);
assert!(table.contains("relationships:"), "block renders: {table}");
assert!(table.contains("needs: SL-047"), "needs axis: {table}");
assert!(
table.contains("after: SL-002, SL-003 (rank 5)"),
"after axis with rank suffix: {table}"
);
let json = show_json(&doc, &tier1, &ds, "# body\n").unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
v["slice"]["relationships"]["needs"],
serde_json::json!(["SL-047"])
);
assert_eq!(
v["slice"]["relationships"]["after"],
serde_json::json!([
{ "to": "SL-002", "rank": 0 },
{ "to": "SL-003", "rank": 5 },
])
);
}
#[test]
fn show_omits_dep_seq_keys_when_unauthored() {
let doc = SliceDoc {
id: 1,
slug: "v".into(),
title: "V".into(),
status: "proposed".into(),
created: "2026-06-01".into(),
updated: "2026-06-01".into(),
gate: Gate::default(),
estimate: None,
value: None,
};
let json = show_json(&doc, &[], &crate::dep_seq::DepSeq::default(), "# body\n").unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let rel = &v["slice"]["relationships"];
assert!(rel.get("needs").is_none(), "no needs key when unauthored");
assert!(rel.get("after").is_none(), "no after key when unauthored");
}
#[test]
fn slice_doc_round_trips_estimate_facet() {
let doc = SliceDoc {
id: 17,
slug: "estimate-facet".into(),
title: "Estimate Facet".into(),
status: "proposed".into(),
created: "2026-06-18".into(),
updated: "2026-06-18".into(),
gate: Gate::default(),
estimate: Some(crate::estimate::EstimateFacet {
lower: 2.0,
upper: 8.0,
}),
value: None,
};
let toml = toml::to_string_pretty(&doc).unwrap();
let parsed: SliceDoc = toml::from_str(&toml).unwrap();
let estimate = parsed.estimate.expect("estimate facet present");
assert_eq!(estimate.lower, 2.0);
assert_eq!(estimate.upper, 8.0);
assert_eq!(parsed.value, None);
}
#[test]
fn slice_doc_round_trips_value_facet() {
let doc = SliceDoc {
id: 17,
slug: "value-facet".into(),
title: "Value Facet".into(),
status: "proposed".into(),
created: "2026-06-18".into(),
updated: "2026-06-18".into(),
gate: Gate::default(),
estimate: None,
value: Some(crate::value::ValueFacet { value: 5.0 }),
};
let toml = toml::to_string_pretty(&doc).unwrap();
let parsed: SliceDoc = toml::from_str(&toml).unwrap();
assert_eq!(parsed.estimate, None);
assert_eq!(
parsed.value.expect("value facet present"),
crate::value::ValueFacet { value: 5.0 }
);
}
#[test]
fn slice_doc_serde_omits_absent_facets() {
let doc = SliceDoc {
id: 18,
slug: "no-facets".into(),
title: "No Facets".into(),
status: "proposed".into(),
created: "2026-06-18".into(),
updated: "2026-06-18".into(),
gate: Gate::default(),
estimate: None,
value: None,
};
let toml = toml::to_string_pretty(&doc).unwrap();
assert!(
!toml.contains("[estimate]"),
"unexpected estimate table: {toml}"
);
assert!(!toml.contains("[value]"), "unexpected value table: {toml}");
let parsed: SliceDoc = toml::from_str(&toml).unwrap();
assert_eq!(parsed.estimate, None);
assert_eq!(parsed.value, None);
}
#[test]
fn slice_doc_malformed_facet_errors_at_parse() {
let text = "id = 1\nslug = \"x\"\ntitle = \"X\"\nstatus = \"proposed\"\n\
created = \"2026-06-18\"\nupdated = \"2026-06-18\"\n[value]\nvalue = nan\n";
let err = toml::from_str::<SliceDoc>(text).unwrap_err().to_string();
assert!(err.contains("must be finite"), "got: {err}");
}
#[test]
fn load_conduct_absent_file_is_baked_defaults() {
let dir = tempfile::tempdir().unwrap();
let cfg = load_conduct(dir.path()).unwrap();
assert_eq!(cfg, crate::conduct::ConductConfig::default());
assert_eq!(crate::conduct::resolve(&cfg, "plan").label(), "self/gate");
assert_eq!(
crate::conduct::resolve(&cfg, "started").label(),
"self/auto"
);
}
#[test]
fn load_conduct_reflects_a_root_override() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join(DOCTRINE_TOML),
"[conduct]\ndefault-actor = \"agent\"\n[conduct.ready]\nautonomy = \"gate\"\n",
)
.unwrap();
let cfg = load_conduct(dir.path()).unwrap();
assert_eq!(crate::conduct::resolve(&cfg, "ready").label(), "agent/gate");
}
#[test]
fn load_conduct_refuses_malformed_doctrine_toml() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(DOCTRINE_TOML), "[conduct\nbroken =").unwrap();
assert!(load_conduct(dir.path()).is_err());
}
#[test]
fn run_show_on_a_missing_slice_errors() {
let dir = tempfile::tempdir().unwrap();
let err = run_show(Some(dir.path().to_path_buf()), "SL-009", Format::Table).unwrap_err();
assert!(err.to_string().contains("not found"), "got: {err}");
}
#[test]
fn classify_forward_chain_is_advance() {
for (from, to) in [
("proposed", "design"),
("design", "plan"),
("plan", "ready"),
("ready", "started"),
("started", "audit"),
] {
assert_eq!(classify(from, to), Transition::Advance, "{from} → {to}");
}
}
#[test]
fn classify_legit_closure_seam_path_is_advance() {
assert_eq!(classify("audit", "reconcile"), Transition::Advance);
assert_eq!(classify("reconcile", "done"), Transition::Advance);
}
#[test]
fn classify_named_back_edges() {
for (from, to) in [
("audit", "started"),
("audit", "design"),
("reconcile", "audit"),
("reconcile", "design"),
] {
assert_eq!(classify(from, to), Transition::BackEdge, "{from} → {to}");
}
}
#[test]
fn classify_abandon_from_each_non_terminal() {
for from in [
"proposed",
"design",
"plan",
"ready",
"started",
"audit",
"reconcile",
] {
assert_eq!(
classify(from, "abandoned"),
Transition::Abandon,
"{from} → abandoned"
);
}
}
#[test]
fn classify_noop_when_unchanged() {
assert_eq!(classify("started", "started"), Transition::Noop);
assert_eq!(classify("done", "done"), Transition::Noop);
}
#[test]
fn classify_from_terminal_refused() {
for from in ["done", "abandoned"] {
assert_eq!(
classify(from, "design"),
Transition::FromTerminal,
"{from} → design"
);
}
}
#[test]
fn classify_seam_breach_to_reconcile_from_non_audit() {
for from in ["proposed", "design", "plan", "ready", "started"] {
assert_eq!(
classify(from, "reconcile"),
Transition::SeamBreach,
"{from} → reconcile"
);
}
}
#[test]
fn classify_seam_breach_to_done_from_non_reconcile() {
for from in ["proposed", "design", "plan", "ready", "started", "audit"] {
assert_eq!(
classify(from, "done"),
Transition::SeamBreach,
"{from} → done"
);
}
}
#[test]
fn classify_seam_binds_even_from_a_drifted_source() {
assert_eq!(classify("bogus", "reconcile"), Transition::SeamBreach);
assert_eq!(classify("bogus", "done"), Transition::SeamBreach);
}
#[test]
fn classify_move_out_of_drift_is_skip_not_refused() {
assert_eq!(classify("bogus", "started"), Transition::Skip);
}
#[test]
fn classify_non_chain_move_is_skip() {
assert_eq!(classify("proposed", "started"), Transition::Skip);
assert_eq!(classify("design", "started"), Transition::Skip);
}
#[test]
fn is_transition_terminal_is_a_distinct_third_predicate() {
assert!(is_transition_terminal("done"));
assert!(is_transition_terminal("abandoned"));
assert!(!is_transition_terminal("started"));
assert!(is_transition_terminal("abandoned") && !is_terminal_status("abandoned"));
assert_eq!(is_transition_terminal("done"), is_hidden("done"));
}
#[test]
fn slice_status_enum_matches_the_vocabulary() {
let variants = [
SliceStatus::Proposed,
SliceStatus::Design,
SliceStatus::Plan,
SliceStatus::Ready,
SliceStatus::Started,
SliceStatus::Audit,
SliceStatus::Reconcile,
SliceStatus::Done,
SliceStatus::Abandoned,
];
let from_variants: Vec<&str> = variants.iter().map(|v| v.as_str()).collect();
assert_eq!(from_variants, SLICE_STATUSES.to_vec());
}
fn slice_text(root: &Path, id: u32) -> String {
let name = format!("{id:03}");
fs::read_to_string(
slice_root(root)
.join(&name)
.join(format!("slice-{name}.toml")),
)
.unwrap()
}
#[test]
fn set_slice_status_advances_and_preserves_comments_and_relationships() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "s", "S", "2026-06-04");
let before = slice_text(root, 1);
assert!(
before.contains("[relationships]"),
"fixture has relationships"
);
let comment = before
.lines()
.find(|l| l.trim_start().starts_with('#'))
.is_some();
set_slice_status(
&slice_root(root),
1,
"proposed",
SliceStatus::Design,
"2099-01-01",
)
.unwrap();
let after = slice_text(root, 1);
assert!(
after.contains("status = \"design\""),
"status written: {after}"
);
assert!(
after.contains("updated = \"2099-01-01\""),
"date stamped: {after}"
);
assert!(
after.contains("[relationships]"),
"relationships survive: {after}"
);
if comment {
assert!(after.contains('#'), "comments survive: {after}");
}
}
#[test]
fn set_slice_status_noop_holds_content_and_mtime() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "s", "S", "2026-06-04");
let p = slice_root(root).join("001").join("slice-001.toml");
let before = fs::read_to_string(&p).unwrap();
let mtime_before = fs::metadata(&p).unwrap().modified().unwrap();
set_slice_status(
&slice_root(root),
1,
"proposed",
SliceStatus::Proposed,
"2099-01-01",
)
.unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), before, "content held");
assert_eq!(
fs::metadata(&p).unwrap().modified().unwrap(),
mtime_before,
"mtime held"
);
}
#[test]
fn set_slice_status_refuses_from_terminal() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "s", "S", "2026-06-04");
for from in [SliceStatus::Done, SliceStatus::Abandoned] {
let err = set_slice_status(
&slice_root(root),
1,
from.as_str(),
SliceStatus::Design,
"x",
)
.unwrap_err()
.to_string();
assert!(err.contains("terminal"), "{}: {err}", from.as_str());
}
assert!(slice_text(root, 1).contains("status = \"proposed\""));
}
#[test]
fn set_slice_status_refuses_seam_breach() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "s", "S", "2026-06-04");
let err = set_slice_status(&slice_root(root), 1, "started", SliceStatus::Done, "x")
.unwrap_err()
.to_string();
assert!(err.contains("closure seam"), "skip-to-done refused: {err}");
let err2 = set_slice_status(&slice_root(root), 1, "design", SliceStatus::Reconcile, "x")
.unwrap_err()
.to_string();
assert!(
err2.contains("closure seam"),
"non-audit → reconcile refused: {err2}"
);
assert!(
slice_text(root, 1).contains("status = \"proposed\""),
"disk untouched"
);
}
#[test]
fn set_slice_status_seam_breach_from_a_drifted_source() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "s", "S", "2026-06-04");
set_status_raw(root, 1, "bogus");
let err = set_slice_status(&slice_root(root), 1, "bogus", SliceStatus::Done, "x")
.unwrap_err()
.to_string();
assert!(
err.contains("closure seam"),
"drifted → done refused: {err}"
);
}
#[test]
fn set_slice_status_refuses_malformed_toml() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = slice_root(root).join("001");
fs::create_dir_all(&d).unwrap();
fs::write(
d.join("slice-001.toml"),
"id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"started\"\n",
)
.unwrap();
let err = set_slice_status(&slice_root(root), 1, "started", SliceStatus::Audit, "x")
.unwrap_err()
.to_string();
assert!(err.contains("malformed"), "missing key refused: {err}");
}
#[test]
fn run_status_prints_classification_with_note() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "s", "S", "2026-06-04");
set_status_raw(root, 1, "started");
run_status(
Some(root.to_path_buf()),
1,
SliceStatus::Audit,
Some("done impl"),
)
.unwrap();
assert!(
slice_text(root, 1).contains("status = \"audit\""),
"write landed"
);
}
#[test]
fn status_line_carries_the_source_exit_posture() {
let cfg = crate::conduct::ConductConfig::default();
let line = status_line(
"reconcile",
"done",
classify("reconcile", "done"),
crate::conduct::resolve(&cfg, "reconcile"),
None,
);
assert_eq!(line, "reconcile → done [advance] [self/gate]");
}
#[test]
fn status_line_appends_the_note_after_the_posture() {
let cfg = crate::conduct::ConductConfig::default();
let line = status_line(
"started",
"audit",
classify("started", "audit"),
crate::conduct::resolve(&cfg, "started"),
Some("done impl"),
);
assert_eq!(line, "started → audit [advance] [self/auto] — done impl");
}
#[test]
fn read_status_surfaces_the_current_authored_status() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "s", "S", "2026-06-04");
set_status_raw(root, 1, "reconcile");
assert_eq!(read_status(&slice_root(root), 1).unwrap(), "reconcile");
}
#[test]
fn vt4_crosses_closure_seam_is_only_the_two_terminal_advances() {
assert!(crosses_closure_seam("audit", "reconcile"));
assert!(crosses_closure_seam("reconcile", "done"));
for (from, to) in [
("started", "audit"),
("ready", "started"),
("plan", "ready"),
("audit", "started"), ("reconcile", "audit"), ("started", "abandoned"),
("audit", "audit"), ] {
assert!(
!crosses_closure_seam(from, to),
"{from} → {to} must NOT be a closure-seam crossing"
);
}
}
fn raise_blocker_rv(root: &Path, target_id: u32) {
let target = canonical_id(target_id);
crate::review::run_new(
Some(root.to_path_buf()),
&crate::review::NewArgs {
facet: crate::review::Facet::Reconciliation,
target: target.clone(),
phase: None,
title: None,
raiser: None,
responder: None,
},
)
.unwrap();
crate::review::run_raise(
Some(root.to_path_buf()),
&crate::review::RaiseArgs {
reference: "RV-001".to_owned(),
severity: crate::review::Severity::Blocker,
title: "must fix".to_owned(),
detail: "d".to_owned(),
},
crate::review::Role::Raiser,
)
.unwrap();
}
#[test]
fn vt2_close_seam_refused_on_an_unresolved_blocker() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "s", "S", "2026-06-04");
set_status_raw(root, 1, "audit");
raise_blocker_rv(root, 1);
let err = run_status(Some(root.to_path_buf()), 1, SliceStatus::Reconcile, None)
.unwrap_err()
.to_string();
assert!(err.contains("RV-001/F-1"), "names the blocker: {err}");
assert!(err.contains("refused"), "refusal wording: {err}");
assert_eq!(read_status(&slice_root(root), 1).unwrap(), "audit");
}
#[test]
fn vt2_close_seam_passes_after_the_blocker_is_verified() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "s", "S", "2026-06-04");
set_status_raw(root, 1, "audit");
raise_blocker_rv(root, 1);
crate::review::run_dispose(
Some(root.to_path_buf()),
&crate::review::DisposeArgs {
reference: "RV-001".to_owned(),
finding: "F-1".to_owned(),
disposition: "fixed".to_owned(),
response: "done".to_owned(),
},
crate::review::Role::Responder,
)
.unwrap();
crate::review::run_verify(
Some(root.to_path_buf()),
"RV-001",
"F-1",
None,
crate::review::Role::Raiser,
)
.unwrap();
run_status(Some(root.to_path_buf()), 1, SliceStatus::Reconcile, None).unwrap();
assert_eq!(read_status(&slice_root(root), 1).unwrap(), "reconcile");
}
#[test]
fn vt2_close_seam_passes_after_the_blocker_is_withdrawn() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "s", "S", "2026-06-04");
set_status_raw(root, 1, "audit");
raise_blocker_rv(root, 1);
crate::review::run_withdraw(
Some(root.to_path_buf()),
"RV-001",
"F-1",
crate::review::Role::Raiser,
)
.unwrap();
run_status(Some(root.to_path_buf()), 1, SliceStatus::Reconcile, None).unwrap();
assert_eq!(read_status(&slice_root(root), 1).unwrap(), "reconcile");
}
#[test]
fn vt4_non_seam_transition_is_not_gated() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_slice(root, "s", "S", "2026-06-04");
set_status_raw(root, 1, "started");
raise_blocker_rv(root, 1);
run_status(Some(root.to_path_buf()), 1, SliceStatus::Audit, None).unwrap();
assert_eq!(read_status(&slice_root(root), 1).unwrap(), "audit");
}
#[test]
fn vt5_close_shell_is_the_sole_seam_crossing_caller_of_set_slice_status() {
let src = include_str!("slice.rs");
let production = src.split_once("#[cfg(test)]").map_or(src, |(head, _)| head);
let call_sites = production
.match_indices("set_slice_status(")
.filter(|(i, _)| {
!production.get(..*i).unwrap_or("").ends_with("fn ")
})
.count();
assert_eq!(
call_sites, 1,
"exactly ONE production caller may cross the closure seam (the close \
shell `run_status`); a second `set_slice_status(` call site bypasses \
the close-gate (design §7 Charge VIII — re-invoke the gate, or move \
it into the FSM writer)"
);
}
use crate::coverage::CoverageKey;
use crate::rec::{RecDoc, RecMeta, StatusDelta};
use crate::requirement::{self, ReqKind, ReqStatus};
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 drift_repo() -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
git(root, &["init", "-q", "-b", "main"]);
std::fs::write(root.join("src.rs"), "fn a() {}\n").unwrap();
git(root, &["add", "src.rs"]);
git(root, &["commit", "-q", "-m", "seed"]);
let anchor = git(root, &["rev-parse", "HEAD"]);
(dir, anchor)
}
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 write_own_coverage(root: &Path, dir_id: u32, cov_slice: &str, req: &str, anchor: &str) {
let d = root.join(SLICE_DIR).join(format!("{dir_id:03}"));
fs::create_dir_all(&d).unwrap();
let body = format!(
"[[entry]]\nslice = \"{cov_slice}\"\nrequirement = \"{req}\"\n\
contributing_change = \"{cov_slice}\"\nmode = \"VT\"\n\
status = \"verified\"\ngit_anchor = \"{anchor}\"\n\
touched_paths = [\"src.rs\"]\n"
);
fs::write(d.join("coverage.toml"), body).unwrap();
}
fn cov_key(cov_slice: &str, req: &str) -> CoverageKey {
CoverageKey {
slice: cov_slice.to_owned(),
requirement: req.to_owned(),
contributing_change: cov_slice.to_owned(),
mode: "VT".to_owned(),
}
}
fn mint_rec(
root: &Path,
owning: &str,
r#move: &str,
req: &str,
from: ReqStatus,
to: ReqStatus,
evidence: Vec<CoverageKey>,
) -> u32 {
let doc = RecDoc {
id: 0,
slug: format!("{move}-{}", req.to_lowercase()),
title: format!("{move} {req}"),
rec: RecMeta {
r#move: r#move.to_owned(),
owning_slice: Some(owning.to_owned()),
decision_ref: None,
},
status_delta: vec![StatusDelta {
requirement: req.to_owned(),
from: from.as_str().to_owned(),
to: to.as_str().to_owned(),
}],
evidence_ref: evidence,
};
crate::rec::materialise_populated(root, &doc).unwrap()
}
fn slice_at_reconcile(root: &Path) {
make_slice(root, "s", "S", "2026-06-12");
set_status_raw(root, 1, "reconcile");
}
fn expect_close_refused(root: &Path) -> String {
run_status(Some(root.to_path_buf()), 1, SliceStatus::Done, None)
.expect_err("reconcile → done should be refused")
.to_string()
}
#[test]
fn vt1_covered_req_residual_drift_refuses_close() {
let (dir, anchor) = drift_repo();
let root = dir.path();
slice_at_reconcile(root);
let req = mint_req(root, ReqStatus::Pending);
write_own_coverage(root, 1, "SL-001", &req, &anchor);
let err = expect_close_refused(root);
assert!(err.contains("undischarged residual drift"), "{err}");
assert!(err.contains(&req), "names the offending req: {err}");
assert_eq!(read_status(&slice_root(root), 1).unwrap(), "reconcile");
}
#[test]
fn vt1_f12_topology_refuses_out_of_seam_independent_of_drift() {
let (dir, _anchor) = drift_repo();
let root = dir.path();
make_slice(root, "s", "S", "2026-06-12");
set_status_raw(root, 1, "started");
let err = run_status(Some(root.to_path_buf()), 1, SliceStatus::Done, None)
.unwrap_err()
.to_string();
assert!(
err.contains("reconcile") && !err.contains("residual drift"),
"F12 topology refusal, not the drift gate: {err}"
);
assert_eq!(read_status(&slice_root(root), 1).unwrap(), "started");
}
#[test]
fn vt2_declared_extra_req_blocks_on_residual_drift() {
let (dir, anchor) = drift_repo();
let root = dir.path();
slice_at_reconcile(root);
let req = mint_req(root, ReqStatus::Pending);
write_own_coverage(root, 999, "SL-999", &req, &anchor);
let p = slice_root(root).join("001").join("slice-001.toml");
let mut toml = fs::read_to_string(&p).unwrap();
toml.push_str(&format!("\n[gate]\nextra_reqs = [\"{req}\"]\n"));
fs::write(&p, toml).unwrap();
let err = expect_close_refused(root);
assert!(err.contains(&req), "declared req gates: {err}");
}
#[test]
fn vt2_reconciled_req_blocks_on_residual_drift() {
let (dir, anchor) = drift_repo();
let root = dir.path();
slice_at_reconcile(root);
let req = mint_req(root, ReqStatus::Pending);
write_own_coverage(root, 999, "SL-999", &req, &anchor);
mint_rec(
root,
"SL-001",
"revise",
&req,
ReqStatus::Pending,
ReqStatus::Pending,
vec![cov_key("SL-999", &req)],
);
let err = expect_close_refused(root);
assert!(err.contains(&req), "reconciled req gates: {err}");
}
#[test]
fn vt2_no_gate_table_runs_on_covered_union_reconciled() {
let (dir, anchor) = drift_repo();
let root = dir.path();
slice_at_reconcile(root);
let req = mint_req(root, ReqStatus::Pending);
write_own_coverage(root, 1, "SL-001", &req, &anchor);
let toml = fs::read_to_string(slice_root(root).join("001").join("slice-001.toml")).unwrap();
assert!(!toml.contains("[gate]"), "fixture has no [gate] table");
let err = expect_close_refused(root);
assert!(err.contains(&req), "{err}");
}
#[test]
fn vt3_slice_local_reader_returns_distinct_reqs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = root.join(SLICE_DIR).join("001");
fs::create_dir_all(&d).unwrap();
let body = "\
[[entry]]\nslice = \"SL-001\"\nrequirement = \"REQ-001\"\n\
contributing_change = \"SL-001\"\nmode = \"VT\"\nstatus = \"planned\"\n\
git_anchor = \"a\"\n\
[[entry]]\nslice = \"SL-001\"\nrequirement = \"REQ-001\"\n\
contributing_change = \"SL-001\"\nmode = \"VA\"\nstatus = \"planned\"\n\
git_anchor = \"a\"\n\
[[entry]]\nslice = \"SL-001\"\nrequirement = \"REQ-002\"\n\
contributing_change = \"SL-001\"\nmode = \"VT\"\nstatus = \"planned\"\n\
git_anchor = \"a\"\n";
fs::write(d.join("coverage.toml"), body).unwrap();
let reqs = crate::coverage_scan::slice_local_covered_reqs(root, 1, "SL-001").unwrap();
assert_eq!(reqs, vec!["REQ-001".to_owned(), "REQ-002".to_owned()]);
}
#[test]
fn vt3_foreign_slice_in_own_coverage_is_an_integrity_error() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = root.join(SLICE_DIR).join("001");
fs::create_dir_all(&d).unwrap();
let body = "[[entry]]\nslice = \"SL-042\"\nrequirement = \"REQ-001\"\n\
contributing_change = \"SL-042\"\nmode = \"VT\"\nstatus = \"planned\"\n\
git_anchor = \"a\"\n";
fs::write(d.join("coverage.toml"), body).unwrap();
let err = crate::coverage_scan::slice_local_covered_reqs(root, 1, "SL-001")
.unwrap_err()
.to_string();
assert!(err.contains("integrity error"), "{err}");
assert!(err.contains("SL-042"), "names the foreign slice: {err}");
}
#[test]
fn vt3_absent_coverage_is_empty_not_error() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
assert!(
crate::coverage_scan::slice_local_covered_reqs(root, 1, "SL-001")
.unwrap()
.is_empty()
);
}
#[test]
fn multi_delta_rec_does_not_discharge_via_foreign_requirement() {
let rec = RecDoc {
id: 1,
slug: "accept-req-001".to_owned(),
title: "accept REQ-001".to_owned(),
rec: RecMeta {
r#move: "accept".to_owned(),
owning_slice: Some("SL-001".to_owned()),
decision_ref: None,
},
status_delta: vec![
StatusDelta {
requirement: "REQ-001".to_owned(),
from: "pending".to_owned(),
to: "active".to_owned(),
},
StatusDelta {
requirement: "REQ-002".to_owned(),
from: "active".to_owned(),
to: "pending".to_owned(),
},
],
evidence_ref: Vec::new(),
};
assert!(
!rec_discharges(Some(&rec), "REQ-001", ReqStatus::Pending, &[]),
"a foreign requirement's coinciding `to` laundered R1's drift"
);
let affirm = RecDoc {
status_delta: vec![StatusDelta {
requirement: "REQ-001".to_owned(),
from: "pending".to_owned(),
to: "pending".to_owned(),
}],
..rec
};
assert!(
rec_discharges(Some(&affirm), "REQ-001", ReqStatus::Pending, &[]),
"an accept REC affirming R1 at its current status should discharge"
);
}
#[test]
fn vt4_matching_accept_rec_discharges_the_drift() {
let (dir, anchor) = drift_repo();
let root = dir.path();
slice_at_reconcile(root);
let req = mint_req(root, ReqStatus::Pending);
write_own_coverage(root, 1, "SL-001", &req, &anchor);
mint_rec(
root,
"SL-001",
"accept",
&req,
ReqStatus::Pending,
ReqStatus::Pending,
vec![cov_key("SL-001", &req)],
);
run_status(Some(root.to_path_buf()), 1, SliceStatus::Done, None).unwrap();
assert_eq!(read_status(&slice_root(root), 1).unwrap(), "done");
}
#[test]
fn vt5_post_rec_fresh_evidence_undischarges() {
let (dir, anchor) = drift_repo();
let root = dir.path();
slice_at_reconcile(root);
let req = mint_req(root, ReqStatus::Pending);
write_own_coverage(root, 1, "SL-001", &req, &anchor);
mint_rec(
root,
"SL-001",
"accept",
&req,
ReqStatus::Pending,
ReqStatus::Pending,
vec![cov_key("SL-001", &req)],
);
let d = root.join(SLICE_DIR).join("001");
let extra = format!(
"\n[[entry]]\nslice = \"SL-001\"\nrequirement = \"{req}\"\n\
contributing_change = \"SL-002\"\nmode = \"VT\"\nstatus = \"verified\"\n\
git_anchor = \"{anchor}\"\ntouched_paths = [\"src.rs\"]\n"
);
let p = d.join("coverage.toml");
let mut body = fs::read_to_string(&p).unwrap();
body.push_str(&extra);
fs::write(&p, body).unwrap();
let err = expect_close_refused(root);
assert!(
err.contains(&req),
"stale REC cannot excuse fresh evidence: {err}"
);
}
#[test]
fn vt6_revise_rec_does_not_discharge() {
let (dir, anchor) = drift_repo();
let root = dir.path();
slice_at_reconcile(root);
let req = mint_req(root, ReqStatus::Pending);
write_own_coverage(root, 1, "SL-001", &req, &anchor);
mint_rec(
root,
"SL-001",
"revise",
&req,
ReqStatus::Pending,
ReqStatus::Pending,
vec![cov_key("SL-001", &req)],
);
let err = expect_close_refused(root);
assert!(err.contains(&req), "revise REC does not discharge: {err}");
}
#[test]
fn vt6_foreign_owning_slice_rec_does_not_discharge() {
let (dir, anchor) = drift_repo();
let root = dir.path();
slice_at_reconcile(root);
let req = mint_req(root, ReqStatus::Pending);
write_own_coverage(root, 1, "SL-001", &req, &anchor);
mint_rec(
root,
"SL-777",
"accept",
&req,
ReqStatus::Pending,
ReqStatus::Pending,
vec![cov_key("SL-001", &req)],
);
let err = expect_close_refused(root);
assert!(
err.contains(&req),
"foreign-slice REC does not discharge: {err}"
);
}
#[test]
fn vt7_blocker_and_drift_each_independently_refuse() {
let (dir, anchor) = drift_repo();
let root = dir.path();
slice_at_reconcile(root);
let req = mint_req(root, ReqStatus::Pending);
write_own_coverage(root, 1, "SL-001", &req, &anchor);
let rec_id = mint_rec(
root,
"SL-001",
"accept",
&req,
ReqStatus::Pending,
ReqStatus::Pending,
vec![cov_key("SL-001", &req)],
);
let _ = rec_id;
raise_blocker_rv(root, 1);
let err_blocker = expect_close_refused(root);
assert!(
err_blocker.contains("blocker review finding"),
"blocker gate refuses independently: {err_blocker}"
);
let (dir2, anchor2) = drift_repo();
let root2 = dir2.path();
slice_at_reconcile(root2);
let req2 = mint_req(root2, ReqStatus::Pending);
write_own_coverage(root2, 1, "SL-001", &req2, &anchor2);
let err_drift = expect_close_refused(root2);
assert!(
err_drift.contains("residual drift"),
"drift gate refuses independently: {err_drift}"
);
}
}