use std::path::Path;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::relation::{self, RelationEdge, RelationLabel};
use crate::state::PhaseRollup;
#[derive(Debug, Serialize)]
pub(crate) struct Brief {
pub(crate) meta: BriefMeta,
pub(crate) entities: Vec<Entity>,
pub(crate) types: Vec<TypeDef>,
}
#[derive(Debug, Serialize)]
pub(crate) struct BriefMeta {
project: String,
generated_at: String,
doctrine_version: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct Entity {
id: String,
kind: String,
title: String,
status: String,
author: String,
date: String,
tags: Vec<String>,
related: Vec<Relation>,
body: String,
#[serde(rename = "virtual")]
is_virtual: bool,
validate_ignore: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct Relation {
#[serde(rename = "type")]
rel_type: String,
target: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct TypeDef {
name: String,
plural: String,
dir: String,
prefix: String,
icon: String,
}
#[derive(Debug, Clone)]
pub(crate) struct EntityRecord {
pub(crate) id: String,
pub(crate) kind: String,
pub(crate) title: String,
pub(crate) status: String,
pub(crate) author: String,
pub(crate) date: String,
pub(crate) tags: Vec<String>,
pub(crate) body: String,
pub(crate) edges: Vec<RelationEdge>,
}
#[derive(Debug, Clone)]
pub(crate) struct SpecRecord {
pub(crate) base: EntityRecord,
pub(crate) descends_from: Option<String>,
pub(crate) parent: Option<String>,
pub(crate) interactions: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct SliceRecord {
pub(crate) base: EntityRecord,
pub(crate) plan: Option<(String, PhaseRollup)>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Corpus {
pub(crate) project: String,
pub(crate) slices: Vec<SliceRecord>,
pub(crate) specs: Vec<SpecRecord>,
pub(crate) others: Vec<EntityRecord>,
}
fn map_status(kind: &str, status: &str) -> &'static str {
match kind {
"slice" => match status {
"ready" => "accepted",
"started" | "audit" | "reconcile" => "in-progress",
"done" => "complete",
"abandoned" => "rejected",
_ => "draft",
},
"product-spec" | "tech-spec" => match status {
"active" => "accepted",
"deprecated" | "superseded" => "superseded",
_ => "draft",
},
"adr" => match status {
"proposed" => "review",
"accepted" => "accepted",
"rejected" => "rejected",
"superseded" | "deprecated" => "superseded",
_ => "draft",
},
"issue" | "improvement" | "chore" | "risk" | "idea" => match status {
"triaged" => "review",
"started" => "in-progress",
"resolved" | "closed" => "complete",
_ => "draft",
},
_ => "draft",
}
}
fn plan_status(rollup: &PhaseRollup) -> &'static str {
if rollup.anomalies() == 0 && rollup.total() > 0 && rollup.completed == rollup.total() {
"complete"
} else if rollup.completed > 0 {
"in-progress"
} else {
"draft"
}
}
fn map_edge(label: RelationLabel) -> &'static str {
match label {
RelationLabel::DescendsFrom | RelationLabel::Parent => "implements",
RelationLabel::Supersedes => "supersedes",
_ => "related-to",
}
}
fn project_edges(edges: &[RelationEdge]) -> Vec<Relation> {
edges
.iter()
.filter(|e| !e.target.starts_with("REQ-"))
.map(|e| Relation {
rel_type: map_edge(e.label).to_string(),
target: e.target.clone(),
})
.collect()
}
fn type_manifest() -> Vec<TypeDef> {
let rows: &[(&str, &str, &str, &str, &str)] = &[
("slice", "slices", "slices", "SL", "🔪"),
(
"product-spec",
"product-specs",
"specs/product",
"PRD",
"📦",
),
("tech-spec", "tech-specs", "specs/tech", "SPEC", "⚙"),
("adr", "adrs", "adrs", "ADR", "🏛"),
("issue", "issues", "issues", "ISS", "🐛"),
("improvement", "improvements", "improvements", "IMP", "✨"),
("chore", "chores", "chores", "CHR", "🧹"),
("risk", "risks", "risks", "RSK", "⚠"),
("idea", "ideas", "ideas", "IDE", "💡"),
("plan", "plans", "plans", "PLAN", "🗺"),
];
let mut types: Vec<TypeDef> = rows
.iter()
.map(|(name, plural, dir, prefix, icon)| TypeDef {
name: (*name).to_string(),
plural: (*plural).to_string(),
dir: (*dir).to_string(),
prefix: (*prefix).to_string(),
icon: (*icon).to_string(),
})
.collect();
types.sort_by(|a, b| a.name.cmp(&b.name));
types
}
fn project_record(rec: &EntityRecord, is_virtual: bool) -> Entity {
Entity {
id: rec.id.clone(),
kind: rec.kind.clone(),
title: rec.title.clone(),
status: map_status(&rec.kind, &rec.status).to_string(),
author: rec.author.clone(),
date: rec.date.clone(),
tags: rec.tags.clone(),
related: project_edges(&rec.edges),
body: rec.body.clone(),
is_virtual,
validate_ignore: true,
}
}
fn project_spec(spec: &SpecRecord) -> Entity {
let mut node = project_record(&spec.base, true);
let typed = spec
.descends_from
.iter()
.map(|t| (RelationLabel::DescendsFrom, t))
.chain(spec.parent.iter().map(|t| (RelationLabel::Parent, t)))
.chain(
spec.interactions
.iter()
.map(|t| (RelationLabel::Interactions, t)),
);
for (label, target) in typed {
node.related.push(Relation {
rel_type: map_edge(label).to_string(),
target: target.clone(),
});
}
node
}
fn plan_id_for(slice_id: &str) -> Option<String> {
slice_id
.strip_prefix("SL-")
.map(|nnn| format!("PLAN-{nnn}"))
}
fn project_plan(slice: &SliceRecord, plan_body: &str, rollup: &PhaseRollup) -> Option<Entity> {
let id = plan_id_for(&slice.base.id)?;
Some(Entity {
id,
kind: "plan".to_string(),
title: format!("Plan for {}", slice.base.id),
status: plan_status(rollup).to_string(),
author: String::new(),
date: slice.base.date.clone(),
tags: Vec::new(),
related: vec![Relation {
rel_type: "implements".to_string(),
target: slice.base.id.clone(),
}],
body: plan_body.to_string(),
is_virtual: false,
validate_ignore: true,
})
}
pub(crate) fn project(corpus: &Corpus, now: &str, version: &str) -> Brief {
let mut entities: Vec<Entity> = Vec::new();
for slice in &corpus.slices {
entities.push(project_record(&slice.base, false));
if let Some((plan_body, rollup)) = &slice.plan
&& let Some(plan_node) = project_plan(slice, plan_body, rollup)
{
entities.push(plan_node);
}
}
for spec in &corpus.specs {
entities.push(project_spec(spec));
}
for rec in &corpus.others {
entities.push(project_record(rec, false));
}
entities.sort_by(|a, b| a.id.cmp(&b.id));
Brief {
meta: BriefMeta {
project: corpus.project.clone(),
generated_at: now.to_string(),
doctrine_version: version.to_string(),
},
entities,
types: type_manifest(),
}
}
#[derive(Debug, Default, Deserialize)]
struct AuthoredHead {
#[serde(default)]
created: Option<String>,
#[serde(default)]
tags: Vec<String>,
}
impl AuthoredHead {
fn parse(toml_text: &str) -> Self {
toml::from_str(toml_text).unwrap_or_default()
}
}
fn read_entity_toml(tree_root: &Path, stem: &str, id: u32) -> anyhow::Result<String> {
let name = format!("{id:03}");
let path = tree_root.join(&name).join(format!("{stem}-{name}.toml"));
std::fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))
}
fn read_prose_body(tree_root: &Path, file_stem: &str, id: u32) -> anyhow::Result<String> {
let name = format!("{id:03}");
let path = tree_root.join(&name).join(format!("{file_stem}-{name}.md"));
match std::fs::read_to_string(&path) {
Ok(b) => Ok(b),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
}
}
fn load_entity_record(
tree_root: &Path,
engine_kind: &crate::entity::Kind,
stem: &str,
file_stem: &str,
lazyspec_kind: &str,
id: u32,
) -> anyhow::Result<EntityRecord> {
let meta = crate::meta::read_meta(tree_root, stem, id, engine_kind.prefix)?;
let toml_text = read_entity_toml(tree_root, stem, id)?;
let head = AuthoredHead::parse(&toml_text);
let edges = relation::tier1_edges(engine_kind, &toml_text)?;
Ok(EntityRecord {
id: listing_canonical(engine_kind.prefix, id),
kind: lazyspec_kind.to_string(),
title: meta.title,
status: meta.status,
author: String::new(),
date: head.created.unwrap_or_default(),
tags: head.tags,
body: read_prose_body(tree_root, file_stem, id)?,
edges,
})
}
fn listing_canonical(prefix: &str, id: u32) -> String {
crate::listing::canonical_id(prefix, id)
}
fn load_slices(root: &Path) -> anyhow::Result<Vec<SliceRecord>> {
let tree = root.join(crate::slice::SLICE_KIND.dir);
let mut out = Vec::new();
for id in crate::entity::scan_ids(&tree)? {
let base = load_entity_record(
&tree,
&crate::slice::SLICE_KIND,
"slice",
"slice",
"slice",
id,
)?;
let plan = load_plan(root, &tree, id)?;
out.push(SliceRecord { base, plan });
}
Ok(out)
}
fn load_plan(root: &Path, tree: &Path, id: u32) -> anyhow::Result<Option<(String, PhaseRollup)>> {
let name = format!("{id:03}");
let slice_dir = tree.join(&name);
if !slice_dir.join("plan.toml").exists() {
return Ok(None);
}
let body = match std::fs::read_to_string(slice_dir.join("plan.md")) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
return Err(e).with_context(|| {
format!("Failed to read {}", slice_dir.join("plan.md").display())
});
}
};
let rollup = crate::state::phase_rollup(root, id)?.unwrap_or_default();
Ok(Some((body, rollup)))
}
fn spec_date(head: &AuthoredHead, spec_toml: &Path) -> String {
head.created.clone().unwrap_or_else(|| {
std::fs::metadata(spec_toml)
.and_then(|m| m.modified())
.map_or_else(|_| crate::clock::today(), crate::clock::date_of_system_time)
})
}
fn load_specs(root: &Path) -> anyhow::Result<Vec<SpecRecord>> {
use crate::spec::SpecSubtype;
let mut out = Vec::new();
for (subtype, kind, lazyspec_kind) in [
(
SpecSubtype::Product,
&crate::spec::PRODUCT_SPEC_KIND,
"product-spec",
),
(SpecSubtype::Tech, &crate::spec::TECH_SPEC_KIND, "tech-spec"),
] {
let tree = root.join(kind.dir);
for id in crate::entity::scan_ids(&tree)? {
out.push(load_spec(root, &tree, subtype, kind, lazyspec_kind, id)?);
}
}
Ok(out)
}
fn load_spec(
root: &Path,
tree: &Path,
subtype: crate::spec::SpecSubtype,
kind: &crate::entity::Kind,
lazyspec_kind: &str,
id: u32,
) -> anyhow::Result<SpecRecord> {
let name = format!("{id:03}");
let dir = tree.join(&name);
let (spec, spec_text, prose_body) = crate::spec::read_spec(subtype, root, id)?;
let members = crate::spec::read_members(&dir.join("members.toml"))?;
let mut resolved = Vec::with_capacity(members.len());
let mut req_bodies: Vec<Option<String>> = Vec::with_capacity(members.len());
for member in members {
let (req, prose) = crate::requirement::load_with_prose(root, &member.requirement)?;
req_bodies.push(prose);
resolved.push((member, req));
}
let interactions = crate::spec::read_interactions(&dir.join("interactions.toml"))?;
let body = crate::spec::render(&spec, &prose_body, &resolved, &req_bodies, &interactions);
let head = AuthoredHead::parse(&spec_text);
let base = EntityRecord {
id: listing_canonical(kind.prefix, id),
kind: lazyspec_kind.to_string(),
title: spec.title.clone(),
status: spec.status.as_str().to_string(),
author: String::new(),
date: spec_date(
&head,
&dir.join(format!("{}-{name}.toml", crate::spec::SPEC_STEM)),
),
tags: head.tags,
body,
edges: relation::tier1_edges(kind, &spec_text)?,
};
Ok(SpecRecord {
base,
descends_from: spec.descends_from,
parent: spec.parent,
interactions: interactions.into_iter().map(|i| i.target).collect(),
})
}
fn load_adrs(root: &Path) -> anyhow::Result<Vec<EntityRecord>> {
let kind = &crate::adr::ADR_KIND.kind;
let tree = root.join(kind.dir);
let mut out = Vec::new();
for id in crate::entity::scan_ids(&tree)? {
out.push(load_entity_record(&tree, kind, "adr", "adr", "adr", id)?);
}
Ok(out)
}
fn load_backlog(root: &Path) -> anyhow::Result<Vec<EntityRecord>> {
let mut out = Vec::new();
for item in crate::backlog::read_all(root)? {
let kind = item.kind.kind();
let tree = root.join(kind.dir);
let toml_text = read_entity_toml(&tree, "backlog", item.id)?;
let head = AuthoredHead::parse(&toml_text);
out.push(EntityRecord {
id: item.kind.canonical_id(item.id),
kind: item.kind.as_str().to_string(),
title: item.title.clone(),
status: item.status.as_str().to_string(),
author: String::new(),
date: head.created.unwrap_or_default(),
tags: head.tags,
body: read_prose_body(&tree, "backlog", item.id)?,
edges: relation::tier1_edges(kind, &toml_text)?,
});
}
Ok(out)
}
pub(crate) fn load_corpus(root: &Path) -> anyhow::Result<Corpus> {
let project = root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
Ok(Corpus {
project,
slices: load_slices(root)?,
specs: load_specs(root)?,
others: {
let mut others = load_adrs(root)?;
others.extend(load_backlog(root)?);
others
},
})
}
pub(crate) fn run_export_lazyspec(root: &Path, now: &str, version: &str) -> anyhow::Result<String> {
let corpus = load_corpus(root)?;
let brief = project(&corpus, now, version);
Ok(serde_json::to_string_pretty(&brief)?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::relation::Role;
fn rec(id: &str, kind: &str, status: &str) -> EntityRecord {
EntityRecord {
id: id.to_string(),
kind: kind.to_string(),
title: format!("title {id}"),
status: status.to_string(),
author: String::new(),
date: "2026-06-19".to_string(),
tags: Vec::new(),
body: String::new(),
edges: Vec::new(),
}
}
fn slice(id: &str, status: &str) -> SliceRecord {
SliceRecord {
base: rec(id, "slice", status),
plan: None,
}
}
fn all_completed(n: u32) -> PhaseRollup {
PhaseRollup {
completed: n,
..PhaseRollup::default()
}
}
fn brief_of(corpus: &Corpus) -> Brief {
project(corpus, "2026-06-19T10:00:00Z", "9.9.9")
}
#[test]
fn unknown_slice_status_takes_the_draft_default() {
assert_eq!(map_status("slice", "wat-is-this"), "draft");
assert_eq!(map_status("slice", ""), "draft");
let mut corpus = Corpus::default();
corpus.slices.push(slice("SL-001", "totally-not-a-state"));
let brief = brief_of(&corpus);
assert_eq!(brief.entities[0].status, "draft");
}
#[test]
fn each_kind_status_default_is_draft() {
for kind in ["product-spec", "tech-spec", "adr", "issue", "idea", "wat"] {
assert_eq!(map_status(kind, "off-vocab"), "draft", "{kind}");
}
}
#[test]
fn exotic_edge_label_takes_the_related_to_default() {
assert_eq!(map_edge(RelationLabel::Drift), "related-to");
assert_eq!(map_edge(RelationLabel::Consumes), "related-to");
assert_eq!(map_edge(RelationLabel::GovernedBy), "related-to");
assert_eq!(map_edge(RelationLabel::Fulfils), "related-to");
}
#[test]
fn every_entity_has_validate_ignore_true_and_iso_date() {
let mut corpus = Corpus::default();
corpus.slices.push(SliceRecord {
base: rec("SL-002", "slice", "started"),
plan: Some(("plan body".to_string(), all_completed(2))),
});
corpus.specs.push(SpecRecord {
base: rec("SPEC-001", "tech-spec", "active"),
descends_from: Some("PRD-001".to_string()),
parent: None,
interactions: Vec::new(),
});
corpus.others.push(rec("ADR-001", "adr", "accepted"));
corpus.others.push(rec("ISS-001", "issue", "open"));
let brief = brief_of(&corpus);
assert!(!brief.entities.is_empty());
for e in &brief.entities {
assert!(e.validate_ignore, "{} validate_ignore", e.id);
assert_eq!(e.date.len(), 10, "{} date YYYY-MM-DD", e.id);
assert_eq!(e.date.matches('-').count(), 2, "{} date dashes", e.id);
assert!(!e.date.contains('T'), "{} date not a datetime", e.id);
}
}
#[test]
fn no_req_node_even_when_a_spec_members_requirements() {
let mut corpus = Corpus::default();
let mut base = rec("SL-003", "slice", "started");
base.edges.push(RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"REQ-009".to_string(),
));
base.edges.push(RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"SPEC-001".to_string(),
));
corpus.slices.push(SliceRecord { base, plan: None });
let brief = brief_of(&corpus);
assert!(brief.entities.iter().all(|e| !e.id.starts_with("REQ-")));
let sl = &brief.entities[0];
assert_eq!(sl.related.len(), 1);
assert_eq!(sl.related[0].rel_type, "related-to");
assert_eq!(sl.related[0].target, "SPEC-001");
}
#[test]
fn synthetic_plan_id_and_only_plans_are_synthetic() {
let mut corpus = Corpus::default();
corpus.slices.push(SliceRecord {
base: rec("SL-026", "slice", "started"),
plan: Some(("body".to_string(), all_completed(1))),
});
let brief = brief_of(&corpus);
let plan = brief
.entities
.iter()
.find(|e| e.kind == "plan")
.expect("plan node");
assert_eq!(plan.id, "PLAN-026");
assert_eq!(plan.related.len(), 1);
assert_eq!(plan.related[0].rel_type, "implements");
assert_eq!(plan.related[0].target, "SL-026");
assert_eq!(plan.date, "2026-06-19");
}
#[test]
fn malformed_phase_suppresses_complete() {
let rollup = PhaseRollup {
completed: 3,
missing_toml: 1,
..PhaseRollup::default()
};
assert_eq!(plan_status(&rollup), "in-progress");
assert_eq!(plan_status(&all_completed(3)), "complete");
let with_unknown = PhaseRollup {
completed: 2,
unknown: 1,
..PhaseRollup::default()
};
assert_eq!(plan_status(&with_unknown), "in-progress");
assert_eq!(plan_status(&PhaseRollup::default()), "draft");
}
#[test]
fn shuffled_corpus_yields_identically_ordered_output() {
let build = |order: &[&str]| {
let mut corpus = Corpus::default();
corpus.project = "doctrine".to_string();
for id in order {
if let Some(nnn) = id.strip_prefix("SL-") {
corpus.slices.push(SliceRecord {
base: rec(id, "slice", "started"),
plan: Some((format!("plan {nnn}"), all_completed(1))),
});
} else {
corpus.others.push(rec(id, "adr", "accepted"));
}
}
brief_of(&corpus)
};
let a = build(&["SL-003", "ADR-001", "SL-001"]);
let b = build(&["ADR-001", "SL-001", "SL-003"]);
let ids_a: Vec<&str> = a.entities.iter().map(|e| e.id.as_str()).collect();
let ids_b: Vec<&str> = b.entities.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids_a, ids_b);
assert_eq!(
ids_a,
vec!["ADR-001", "PLAN-001", "PLAN-003", "SL-001", "SL-003"]
);
let types_a: Vec<&str> = a.types.iter().map(|t| t.name.as_str()).collect();
let types_b: Vec<&str> = b.types.iter().map(|t| t.name.as_str()).collect();
assert_eq!(types_a, types_b);
let mut sorted = types_a.clone();
sorted.sort_unstable();
assert_eq!(types_a, sorted);
}
#[test]
fn type_manifest_is_full_even_for_an_empty_corpus() {
let brief = brief_of(&Corpus::default());
assert!(brief.entities.is_empty());
let names: Vec<&str> = brief.types.iter().map(|t| t.name.as_str()).collect();
for want in [
"slice",
"product-spec",
"tech-spec",
"adr",
"issue",
"improvement",
"chore",
"risk",
"idea",
"plan",
] {
assert!(names.contains(&want), "manifest missing {want}");
}
assert_eq!(brief.types.len(), 10);
}
#[test]
fn serde_renames_emit_virtual_and_type() {
let entity = Entity {
id: "SPEC-001".to_string(),
kind: "tech-spec".to_string(),
title: "t".to_string(),
status: "draft".to_string(),
author: String::new(),
date: "2026-06-19".to_string(),
tags: Vec::new(),
related: vec![Relation {
rel_type: "implements".to_string(),
target: "PRD-001".to_string(),
}],
body: String::new(),
is_virtual: true,
validate_ignore: true,
};
let json = serde_json::to_string(&entity).expect("serialize entity");
assert!(json.contains("\"virtual\":true"), "virtual rename: {json}");
assert!(!json.contains("is_virtual"), "no raw field name: {json}");
assert!(
json.contains("\"type\":\"implements\""),
"type rename: {json}"
);
assert!(!json.contains("rel_type"), "no raw rel field name: {json}");
}
#[test]
fn meta_carries_injected_now_and_version_verbatim() {
let mut corpus = Corpus::default();
corpus.project = "doctrine".to_string();
let brief = project(&corpus, "2026-06-19T10:00:00Z", "1.2.3");
assert_eq!(brief.meta.project, "doctrine");
assert_eq!(brief.meta.generated_at, "2026-06-19T10:00:00Z");
assert_eq!(brief.meta.doctrine_version, "1.2.3");
}
fn conformance_corpus() -> Corpus {
let mut corpus = Corpus::default();
corpus.project = "doctrine".to_string();
let mut slice_base = rec("SL-026", "slice", "totally-drifted-status");
slice_base.author = "ada".to_string();
slice_base.tags = vec!["wire".to_string()];
slice_base.edges.push(RelationEdge::new(
RelationLabel::Supersedes,
"SL-001".to_string(),
));
slice_base.edges.push(RelationEdge::new(
RelationLabel::Drift,
"SL-099".to_string(),
));
slice_base.edges.push(RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"REQ-005".to_string(),
));
corpus.slices.push(SliceRecord {
base: slice_base,
plan: Some(("plan body".to_string(), all_completed(1))),
});
corpus.specs.push(SpecRecord {
base: rec("SPEC-002", "tech-spec", "active"),
descends_from: Some("PRD-001".to_string()),
parent: None,
interactions: vec!["SPEC-003".to_string()],
});
corpus.others.push(rec("ADR-001", "adr", "off-vocab"));
corpus.others.push(rec("ISS-007", "issue", "off-vocab"));
corpus
}
const WIRE_STATUSES: [&str; 7] = [
"draft",
"review",
"accepted",
"in-progress",
"complete",
"rejected",
"superseded",
];
const WIRE_REL_TYPES: [&str; 4] = ["implements", "supersedes", "blocks", "related-to"];
#[test]
fn conformance_asserts_invariants_across_kinds_and_fields() {
let brief = brief_of(&conformance_corpus());
for e in &brief.entities {
assert!(e.validate_ignore, "INV-1 {} validate_ignore", e.id);
}
for e in &brief.entities {
assert!(
WIRE_STATUSES.contains(&e.status.as_str()),
"INV-6 {} status {:?} off-vocab",
e.id,
e.status
);
}
assert_eq!(
node(&brief, "SL-026").status,
"draft",
"drifted slice → draft"
);
assert_eq!(
node(&brief, "ADR-001").status,
"draft",
"off-vocab adr → draft"
);
assert_eq!(
node(&brief, "ISS-007").status,
"draft",
"off-vocab issue → draft"
);
for e in &brief.entities {
assert_eq!(e.date.len(), 10, "INV-7 {} date len", e.id);
assert_eq!(e.date.matches('-').count(), 2, "INV-7 {} date dashes", e.id);
assert!(!e.date.contains('T'), "INV-7 {} not a datetime", e.id);
}
let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for e in &brief.entities {
for r in &e.related {
assert!(
WIRE_REL_TYPES.contains(&r.rel_type.as_str()),
"INV-2 {} rel_type {:?} off-vocab",
e.id,
r.rel_type
);
seen.insert(r.rel_type.as_str());
}
}
for want in ["supersedes", "implements", "related-to"] {
assert!(
seen.contains(want),
"INV-2 missing emitted rel_type {want}: {seen:?}"
);
}
let sl = node(&brief, "SL-026");
assert!(
related(&brief, "SL-026", "related-to", "SL-099"),
"exotic label → related-to default: {:?}",
sl.related
);
assert!(
sl.related.iter().all(|r| !r.target.starts_with("REQ-")),
"INV-4 references→REQ edge dropped: {:?}",
sl.related
);
assert!(
brief.entities.iter().all(|e| !e.id.starts_with("REQ-")),
"INV-4 REQ must inline, never a node"
);
let synthetic: Vec<&str> = brief
.entities
.iter()
.filter(|e| e.kind == "plan")
.map(|e| e.id.as_str())
.collect();
assert_eq!(
synthetic,
vec!["PLAN-026"],
"INV-5 only PLAN-NNN are synthetic"
);
assert_eq!(node(&brief, "PLAN-026").related.len(), 1);
assert!(
related(&brief, "PLAN-026", "implements", "SL-026"),
"INV-5 plan implements its slice"
);
for e in &brief.entities {
let want = e.kind == "tech-spec" || e.kind == "product-spec";
assert_eq!(e.is_virtual, want, "{} is_virtual", e.id);
}
let ids: Vec<&str> = brief.entities.iter().map(|e| e.id.as_str()).collect();
let mut sorted = ids.clone();
sorted.sort_unstable();
assert_eq!(ids, sorted, "entities id-sorted");
let types: Vec<&str> = brief.types.iter().map(|t| t.name.as_str()).collect();
let mut sorted_types = types.clone();
sorted_types.sort_unstable();
assert_eq!(types, sorted_types, "types name-sorted");
let json = serde_json::to_string(&brief).expect("serialize brief");
assert!(json.contains("\"virtual\":"), "virtual rename present");
assert!(!json.contains("\"is_virtual\""), "no raw is_virtual");
assert!(
json.contains("\"type\":\"supersedes\""),
"type rename present"
);
assert!(!json.contains("\"rel_type\""), "no raw rel_type");
}
#[test]
fn entity_field_map_is_exactly_the_agreed_keys() {
let mut base = rec("ADR-001", "adr", "accepted");
base.edges.push(RelationEdge::new(
RelationLabel::Supersedes,
"ADR-000".to_string(),
));
let entity = project_record(&base, false);
let value = serde_json::to_value(&entity).expect("serialize entity");
let obj = value.as_object().expect("entity is a JSON object");
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"author",
"body",
"date",
"id",
"kind",
"related",
"status",
"tags",
"title",
"validate_ignore",
"virtual",
]
);
let rel = obj
.get("related")
.and_then(|r| r.as_array())
.and_then(|a| a.first())
.and_then(|r| r.as_object())
.expect("one relation object");
let mut rel_keys: Vec<&str> = rel.keys().map(String::as_str).collect();
rel_keys.sort_unstable();
assert_eq!(rel_keys, vec!["target", "type"]);
let mut corpus = Corpus::default();
corpus.project = "p".to_string();
let brief = project(&corpus, "2026-06-19T10:00:00Z", "1.2.3");
let meta_val = serde_json::to_value(&brief.meta).expect("serialize meta");
let mut meta_keys: Vec<&str> = meta_val
.as_object()
.expect("meta object")
.keys()
.map(String::as_str)
.collect();
meta_keys.sort_unstable();
assert_eq!(
meta_keys,
vec!["doctrine_version", "generated_at", "project"]
);
let td = serde_json::to_value(type_manifest().into_iter().next().expect("a typedef"))
.expect("serialize typedef");
let mut td_keys: Vec<&str> = td
.as_object()
.expect("typedef object")
.keys()
.map(String::as_str)
.collect();
td_keys.sort_unstable();
assert_eq!(td_keys, vec!["dir", "icon", "name", "plural", "prefix"]);
}
#[test]
fn golden_brief_value_matches() {
let mut corpus = Corpus::default();
corpus.project = "demo".to_string();
let mut adr = rec("ADR-001", "adr", "accepted");
adr.title = "First decision".to_string();
adr.date = "2026-01-02".to_string();
adr.body = "## Context\nbody\n".to_string();
adr.edges.push(RelationEdge::new(
RelationLabel::Drift,
"ADR-002".to_string(),
));
corpus.others.push(adr);
let brief = project(&corpus, "2026-06-19T10:00:00Z", "1.2.3");
let produced = serde_json::to_value(&brief).expect("serialize brief");
assert_eq!(produced, golden_brief(), "golden Brief drift");
}
fn golden_brief() -> serde_json::Value {
serde_json::json!({
"meta": {
"project": "demo",
"generated_at": "2026-06-19T10:00:00Z",
"doctrine_version": "1.2.3"
},
"entities": [
{
"id": "ADR-001",
"kind": "adr",
"title": "First decision",
"status": "accepted",
"author": "",
"date": "2026-01-02",
"tags": [],
"related": [
{ "type": "related-to", "target": "ADR-002" }
],
"body": "## Context\nbody\n",
"virtual": false,
"validate_ignore": true
}
],
"types": [
{ "name": "adr", "plural": "adrs", "dir": "adrs", "prefix": "ADR", "icon": "🏛" },
{ "name": "chore", "plural": "chores", "dir": "chores", "prefix": "CHR", "icon": "🧹" },
{ "name": "idea", "plural": "ideas", "dir": "ideas", "prefix": "IDE", "icon": "💡" },
{ "name": "improvement", "plural": "improvements", "dir": "improvements", "prefix": "IMP", "icon": "✨" },
{ "name": "issue", "plural": "issues", "dir": "issues", "prefix": "ISS", "icon": "🐛" },
{ "name": "plan", "plural": "plans", "dir": "plans", "prefix": "PLAN", "icon": "🗺" },
{ "name": "product-spec", "plural": "product-specs", "dir": "specs/product", "prefix": "PRD", "icon": "📦" },
{ "name": "risk", "plural": "risks", "dir": "risks", "prefix": "RSK", "icon": "⚠" },
{ "name": "slice", "plural": "slices", "dir": "slices", "prefix": "SL", "icon": "🔪" },
{ "name": "tech-spec", "plural": "tech-specs", "dir": "specs/tech", "prefix": "SPEC", "icon": "⚙" }
]
})
}
fn node<'a>(brief: &'a Brief, id: &str) -> &'a Entity {
brief
.entities
.iter()
.find(|e| e.id == id)
.unwrap_or_else(|| panic!("node {id} present"))
}
fn related(brief: &Brief, id: &str, rel_type: &str, target: &str) -> bool {
node(brief, id)
.related
.iter()
.any(|r| r.rel_type == rel_type && r.target == target)
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
mod loader_tests {
use super::*;
use crate::catalog::test_helpers::{seed_adr, seed_requirement, seed_slice, seed_spec};
use crate::spec::SpecSubtype;
use crate::test_support::SCHEMA_PLAN;
#[test]
fn load_corpus_assembles_every_kind_from_a_seeded_tree() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_requirement(root, 5);
seed_spec(root, SpecSubtype::Product, 1, &["REQ-005"], &[], &[]);
seed_spec(
root,
SpecSubtype::Tech,
2,
&["REQ-005"],
&["SPEC-003"],
&[("descends_from", "PRD-001"), ("parent", "SPEC-004")],
);
seed_adr(root, 1, &[("related", &["ADR-002"])]);
crate::backlog::test_support::write_fixture(
root,
crate::backlog::test_support::Fixture {
kind: crate::backlog::ItemKind::Issue,
id: 7,
slug: "round-trip",
title: "Round trip",
status: "open",
resolution: "",
tags: &[],
facet: None,
rels: None,
},
);
seed_slice(root, 9, &[("references(implements)", &["SPEC-002"])]);
seed_slice(root, 3, &[]);
write_plan(root, 9);
write_phase(root, 9, "phase-01", "completed");
let corpus = load_corpus(root).unwrap();
let brief = project(&corpus, "2026-06-19T10:00:00Z", "9.9.9");
assert_eq!(brief.meta.project, corpus.project);
assert!(!corpus.project.is_empty());
let ids: Vec<&str> = brief.entities.iter().map(|e| e.id.as_str()).collect();
for want in [
"SL-003", "SL-009", "PRD-001", "SPEC-002", "ADR-001", "ISS-007",
] {
assert!(ids.contains(&want), "missing {want}: {ids:?}");
}
assert!(
brief.entities.iter().all(|e| !e.id.starts_with("REQ-")),
"requirement must inline, never a node: {ids:?}"
);
let spec = node(&brief, "SPEC-002");
assert_eq!(spec.kind, "tech-spec");
assert!(spec.is_virtual, "specs are virtual nodes");
assert!(
spec.body.contains("REQ-005"),
"spec body must inline its member: {}",
spec.body
);
assert!(
related(spec, "implements", "PRD-001"),
"descends_from → implements: {:?}",
spec.related
);
assert!(
related(spec, "implements", "SPEC-004"),
"parent → implements: {:?}",
spec.related
);
assert!(
related(spec, "related-to", "SPEC-003"),
"interaction → related-to: {:?}",
spec.related
);
let adr = node(&brief, "ADR-001");
assert_eq!(adr.kind, "adr");
assert!(
related(adr, "related-to", "ADR-002"),
"ADR related edge: {:?}",
adr.related
);
let iss = node(&brief, "ISS-007");
assert_eq!(iss.kind, "issue");
assert_eq!(iss.title, "Round trip");
let plan = node(&brief, "PLAN-009");
assert_eq!(plan.kind, "plan");
assert_eq!(plan.status, "complete");
assert!(plan.body.contains("plan body"), "plan body: {}", plan.body);
assert!(
related(plan, "implements", "SL-009"),
"plan implements its slice: {:?}",
plan.related
);
assert!(!ids.contains(&"PLAN-003"), "SL-003 has no plan: {ids:?}");
let sl9 = node(&brief, "SL-009");
assert!(
related(sl9, "related-to", "SPEC-002"),
"slice specs edge: {:?}",
sl9.related
);
}
#[test]
fn dateless_spec_loads_a_parseable_date_from_toml_mtime() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_requirement(root, 5);
seed_spec(root, SpecSubtype::Product, 1, &["REQ-005"], &[], &[]);
let corpus = load_corpus(root).unwrap();
let brief = project(&corpus, "2026-06-19T10:00:00Z", "9.9.9");
let spec = node(&brief, "PRD-001");
assert!(!spec.date.is_empty(), "INV-7 spec date not empty");
assert_eq!(spec.date.len(), 10, "INV-7 date len: {:?}", spec.date);
assert_eq!(
spec.date.matches('-').count(),
2,
"INV-7 dashes: {:?}",
spec.date
);
assert!(
!spec.date.contains('T'),
"INV-7 not a datetime: {:?}",
spec.date
);
}
#[test]
fn export_lazyspec_command_emits_parseable_brief_json() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_adr(root, 1, &[("related", &["ADR-002"])]);
seed_slice(root, 9, &[]);
let json = run_export_lazyspec(root, "2026-06-19T10:00:00Z", "9.9.9")
.expect("export lazyspec Ok (exit 0)");
let value: serde_json::Value = serde_json::from_str(&json).expect("parseable Brief JSON");
assert_eq!(value["meta"]["generated_at"], "2026-06-19T10:00:00Z");
assert_eq!(value["meta"]["doctrine_version"], "9.9.9");
let ids: Vec<&str> = value["entities"]
.as_array()
.expect("entities array")
.iter()
.map(|e| e["id"].as_str().expect("id string"))
.collect();
assert!(ids.contains(&"ADR-001"), "ADR-001 present: {ids:?}");
assert!(ids.contains(&"SL-009"), "SL-009 present: {ids:?}");
let adr = &value["entities"][0];
assert_eq!(adr["validate_ignore"], true);
assert!(adr.get("virtual").is_some(), "wire `virtual` key present");
}
fn write_plan(root: &std::path::Path, slice_id: u32) {
let name = format!("{slice_id:03}");
let dir = root.join(".doctrine/slice").join(&name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("plan.toml"),
format!(
"schema = \"{SCHEMA_PLAN}\"\nversion = 1\n\
[[phase]]\nid = \"PHASE-01\"\nname = \"p\"\nobjective = \"o\"\n"
),
)
.unwrap();
std::fs::write(dir.join("plan.md"), "plan body\n").unwrap();
}
fn write_phase(root: &std::path::Path, slice_id: u32, stem: &str, status: &str) {
let name = format!("{slice_id:03}");
let dir = root
.join(".doctrine/state/slice")
.join(&name)
.join("phases");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("{stem}.toml")),
format!("status = \"{status}\"\n"),
)
.unwrap();
}
fn node<'a>(brief: &'a Brief, id: &str) -> &'a Entity {
brief
.entities
.iter()
.find(|e| e.id == id)
.unwrap_or_else(|| panic!("node {id} present"))
}
fn related(entity: &Entity, rel_type: &str, target: &str) -> bool {
entity
.related
.iter()
.any(|r| r.rel_type == rel_type && r.target == target)
}
}