use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::entity::{
self, Artifact, Fileset, Inputs, Kind, LocalFs, MaterialiseRequest, ScaffoldCtx,
};
use crate::listing::{self, Format, ListArgs};
use crate::meta::{self, Meta};
use crate::registry::{
BuildFinding, DescentEdge, InteractionEdge, MemberEdge, ParentEdge, Registry,
};
use crate::requirement::{self, ReqKind, ReqStatus, Requirement};
use crate::tomlfmt::toml_string;
const SPEC_STEM: &str = "spec";
pub(crate) const PRODUCT_SPEC_KIND: Kind = Kind {
dir: ".doctrine/spec/product",
prefix: "PRD",
scaffold: product_spec_scaffold,
};
pub(crate) const TECH_SPEC_KIND: Kind = Kind {
dir: ".doctrine/spec/tech",
prefix: "SPEC",
scaffold: tech_spec_scaffold,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum SpecSubtype {
Product,
Tech,
}
impl SpecSubtype {
const fn kind(self) -> &'static Kind {
match self {
SpecSubtype::Product => &PRODUCT_SPEC_KIND,
SpecSubtype::Tech => &TECH_SPEC_KIND,
}
}
const fn toml_template(self) -> &'static str {
match self {
SpecSubtype::Product => "templates/spec-product.toml",
SpecSubtype::Tech => "templates/spec-tech.toml",
}
}
const fn md_template(self) -> &'static str {
match self {
SpecSubtype::Product => "templates/spec-product.md",
SpecSubtype::Tech => "templates/spec-tech.md",
}
}
fn canonical_id(self, id: u32) -> String {
format!("{}-{id:03}", self.kind().prefix)
}
const fn label(self) -> &'static str {
match self {
SpecSubtype::Product => "product",
SpecSubtype::Tech => "tech",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub(crate) struct Source {
pub(crate) language: String,
pub(crate) identifier: String,
#[serde(default)]
pub(crate) module: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum SpecStatus {
Draft,
Active,
Deprecated,
Superseded,
}
impl SpecStatus {
const fn as_str(self) -> &'static str {
match self {
SpecStatus::Draft => "draft",
SpecStatus::Active => "active",
SpecStatus::Deprecated => "deprecated",
SpecStatus::Superseded => "superseded",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum C4Level {
Context,
Container,
Component,
Code,
}
impl C4Level {
const fn as_str(self) -> &'static str {
match self {
C4Level::Context => "context",
C4Level::Container => "container",
C4Level::Component => "component",
C4Level::Code => "code",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ProductLevel {
Domain,
Capability,
Feature,
Story,
}
impl ProductLevel {
const fn as_str(self) -> &'static str {
match self {
ProductLevel::Domain => "domain",
ProductLevel::Capability => "capability",
ProductLevel::Feature => "feature",
ProductLevel::Story => "story",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub(crate) struct Spec {
pub(crate) id: u32,
pub(crate) slug: String,
pub(crate) title: String,
pub(crate) status: SpecStatus,
pub(crate) kind: SpecSubtype,
#[serde(default)]
pub(crate) tags: Vec<String>,
#[serde(default)]
pub(crate) category: Option<String>,
#[serde(default)]
pub(crate) c4_level: Option<C4Level>,
#[serde(default)]
pub(crate) product_level: Option<ProductLevel>,
#[serde(default)]
pub(crate) responsibilities: Vec<String>,
#[serde(default, rename = "source")]
pub(crate) sources: Vec<Source>,
#[serde(default)]
pub(crate) descends_from: Option<String>,
#[serde(default)]
pub(crate) parent: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub(crate) struct Member {
pub(crate) requirement: String,
pub(crate) label: String,
pub(crate) order: u32,
}
#[derive(Debug, Default, Deserialize)]
struct MembersDoc {
#[serde(default)]
member: Vec<Member>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub(crate) struct Interaction {
pub(crate) target: String,
#[serde(rename = "type")]
pub(crate) kind: String,
#[serde(default)]
pub(crate) notes: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct InteractionsDoc {
#[serde(default)]
edge: Vec<Interaction>,
}
fn render_spec_toml(
subtype: SpecSubtype,
id: u32,
slug: &str,
title: &str,
) -> anyhow::Result<String> {
Ok(crate::install::asset_text(subtype.toml_template())?
.replace("{{id}}", &id.to_string())
.replace("{{slug}}", &toml_string(slug))
.replace("{{title}}", &toml_string(title)))
}
fn render_spec_md(subtype: SpecSubtype, canonical_id: &str, title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text(subtype.md_template())?
.replace("{{ref}}", canonical_id)
.replace("{{title}}", title))
}
fn members_seed() -> anyhow::Result<String> {
crate::install::asset_text("templates/members.toml")
}
fn interactions_seed() -> anyhow::Result<String> {
crate::install::asset_text("templates/interactions.toml")
}
fn product_spec_scaffold(ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
spec_scaffold(SpecSubtype::Product, ctx)
}
fn tech_spec_scaffold(ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
spec_scaffold(SpecSubtype::Tech, ctx)
}
fn spec_scaffold(subtype: SpecSubtype, ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
let id = ctx.id;
let name = format!("{id:03}");
let mut fileset = vec![
Artifact::File {
rel_path: PathBuf::from(format!("{name}/{SPEC_STEM}-{name}.toml")),
body: render_spec_toml(subtype, id, ctx.slug, ctx.title)?,
},
Artifact::File {
rel_path: PathBuf::from(format!("{name}/{SPEC_STEM}-{name}.md")),
body: render_spec_md(subtype, ctx.canonical, ctx.title)?,
},
Artifact::File {
rel_path: PathBuf::from(format!("{name}/members.toml")),
body: members_seed()?,
},
];
if subtype == SpecSubtype::Tech {
fileset.push(Artifact::File {
rel_path: PathBuf::from(format!("{name}/interactions.toml")),
body: interactions_seed()?,
});
}
fileset.push(Artifact::Symlink {
rel_path: PathBuf::from(format!("{name}-{}", ctx.slug)),
target: name,
});
Ok(fileset)
}
fn render(
spec: &Spec,
prose_body: &str,
members: &[(Member, Requirement)],
interactions: &[Interaction],
) -> String {
let canonical_ref = spec.kind.canonical_id(spec.id);
let mut parts: Vec<String> = Vec::new();
parts.push(format!("`{canonical_ref}` — {}\n", spec.title));
parts.push(format!(
"{} · {} · {}\n",
spec.slug,
spec.status.as_str(),
spec.kind.label(),
));
if !spec.tags.is_empty() {
parts.push(format!("tags: {}\n", spec.tags.join(", ")));
}
if let Some(category) = &spec.category {
parts.push(format!("category: {category}\n"));
}
match spec.kind {
SpecSubtype::Tech => {
if let Some(level) = spec.c4_level {
parts.push(format!("c4 level: {}\n", level.as_str()));
}
if let Some(d) = &spec.descends_from {
parts.push(format!("descends from: {d}\n"));
}
if let Some(p) = &spec.parent {
parts.push(format!("parent: {p}\n"));
}
}
SpecSubtype::Product => {
if let Some(level) = spec.product_level {
parts.push(format!("product level: {}\n", level.as_str()));
}
if let Some(p) = &spec.parent {
parts.push(format!("parent: {p}\n"));
}
}
}
if !spec.responsibilities.is_empty() {
parts.push("responsibilities:\n".to_string());
for r in &spec.responsibilities {
parts.push(format!(" - {r}\n"));
}
}
if !spec.sources.is_empty() {
parts.push("sources:\n".to_string());
for s in &spec.sources {
let module = match &s.module {
Some(m) => format!(" ({m})"),
None => String::new(),
};
parts.push(format!(" - {} {}{module}\n", s.language, s.identifier));
}
}
parts.push("\n".to_string());
parts.push(prose_body.to_string());
if !prose_body.ends_with('\n') {
parts.push("\n".to_string());
}
parts.push("\n## Requirements\n".to_string());
let mut ordered: Vec<&(Member, Requirement)> = members.iter().collect();
ordered.sort_by_key(|(m, _)| m.order);
for (member, req) in ordered {
let req_ref = requirement::canonical_id(req.id);
parts.push(format!(
"\n### {} ({req_ref}) — {}\n\n",
member.label, req.title
));
parts.push(format!(
"{} · {} · {}\n",
req.slug,
req.kind.as_str(),
req.status.as_str(),
));
if !req.tags.is_empty() {
parts.push(format!("tags: {}\n", req.tags.join(", ")));
}
if let Some(statement) = &req.description {
parts.push(format!("\n{statement}\n"));
}
if !req.acceptance_criteria.is_empty() {
parts.push("\nacceptance criteria:\n".to_string());
for c in &req.acceptance_criteria {
parts.push(format!(" - {c}\n"));
}
}
}
if !interactions.is_empty() {
parts.push("\n## Interactions\n\n".to_string());
for i in interactions {
let notes = match &i.notes {
Some(n) => format!(": {n}"),
None => String::new(),
};
parts.push(format!("- {} — {}{notes}\n", i.target, i.kind));
}
}
parts.concat()
}
fn read_members(members_path: &Path) -> anyhow::Result<Vec<Member>> {
let text = match std::fs::read_to_string(members_path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(e).with_context(|| format!("Failed to read {}", members_path.display()));
}
};
let doc: MembersDoc = toml::from_str(&text)
.with_context(|| format!("Failed to parse {}", members_path.display()))?;
Ok(doc.member)
}
fn member_count(spec_dir: &Path) -> anyhow::Result<usize> {
Ok(read_members(&spec_dir.join("members.toml"))?.len())
}
fn read_interactions(interactions_path: &Path) -> anyhow::Result<Vec<Interaction>> {
let text = match std::fs::read_to_string(interactions_path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(e)
.with_context(|| format!("Failed to read {}", interactions_path.display()));
}
};
let doc: InteractionsDoc = toml::from_str(&text)
.with_context(|| format!("Failed to parse {}", interactions_path.display()))?;
Ok(doc.edge)
}
pub(crate) fn relation_edges(
subtype: SpecSubtype,
root: &Path,
id: u32,
) -> anyhow::Result<Vec<crate::relation::RelationEdge>> {
use crate::relation::{RelationEdge, RelationLabel};
let name = format!("{id:03}");
let spec_dir = root.join(subtype.kind().dir).join(&name);
let spec_toml = spec_dir.join(format!("{SPEC_STEM}-{name}.toml"));
let spec_text = std::fs::read_to_string(&spec_toml)
.with_context(|| format!("Failed to read {}", spec_toml.display()))?;
let spec: Spec = toml::from_str(&spec_text)
.with_context(|| format!("Failed to parse {}", spec_toml.display()))?;
let mut edges = Vec::new();
if let Some(d) = &spec.descends_from {
edges.push(RelationEdge::new(RelationLabel::DescendsFrom, d.clone()));
}
if let Some(p) = &spec.parent {
edges.push(RelationEdge::new(RelationLabel::Parent, p.clone()));
}
for m in read_members(&spec_dir.join("members.toml"))? {
edges.push(RelationEdge::new(RelationLabel::Members, m.requirement));
}
for i in read_interactions(&spec_dir.join("interactions.toml"))? {
edges.push(RelationEdge::new(RelationLabel::Interactions, i.target));
}
edges.extend(crate::relation::tier1_edges(subtype.kind(), &spec_text)?);
Ok(edges)
}
pub(crate) fn interaction_types(
root: &Path,
id: u32,
) -> anyhow::Result<std::collections::BTreeMap<String, String>> {
let name = format!("{id:03}");
let dir = root.join(TECH_SPEC_KIND.dir).join(&name);
let mut by_target = std::collections::BTreeMap::new();
for i in read_interactions(&dir.join("interactions.toml"))? {
by_target.insert(i.target, i.kind);
}
Ok(by_target)
}
fn resolve_spec_ref(spec_ref: &str) -> anyhow::Result<(SpecSubtype, u32)> {
let (prefix, num) = spec_ref.rsplit_once('-').with_context(|| {
format!("`{spec_ref}` is not a canonical spec ref (expected PRD-NNN or SPEC-NNN)")
})?;
let subtype = [SpecSubtype::Product, SpecSubtype::Tech]
.into_iter()
.find(|s| s.kind().prefix == prefix)
.with_context(|| {
format!("unknown spec prefix `{prefix}` in `{spec_ref}` (expected PRD or SPEC)")
})?;
let id: u32 = num
.parse()
.with_context(|| format!("`{num}` is not a numeric id in `{spec_ref}`"))?;
Ok((subtype, id))
}
fn canonicalize_spec_ref(raw: &str) -> String {
resolve_spec_ref(raw).map_or_else(|_| raw.to_string(), |(s, n)| s.canonical_id(n))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MemberReq {
pub(crate) label: String,
pub(crate) requirement: String,
}
pub(crate) fn member_reqs(root: &Path, spec_ref: &str) -> anyhow::Result<Vec<MemberReq>> {
let (subtype, spec_id) = resolve_spec_ref(spec_ref)?;
let spec_dir = root.join(subtype.kind().dir).join(format!("{spec_id:03}"));
anyhow::ensure!(
spec_dir.is_dir(),
"no {} spec {spec_ref} at {}",
subtype.label(),
spec_dir.display()
);
let mut members = read_members(&spec_dir.join("members.toml"))?;
members.sort_by_key(|m| m.order);
Ok(members
.into_iter()
.map(|m| MemberReq {
label: m.label,
requirement: requirement::canonicalize_fk(&m.requirement),
})
.collect())
}
fn enclosing_line(src: &str, byte: usize) -> &str {
let byte = byte.min(src.len());
let start = src
.get(..byte)
.and_then(|s| s.rfind('\n'))
.map_or(0, |i| i + 1);
let end = src
.get(byte..)
.and_then(|s| s.find('\n'))
.map_or(src.len(), |i| byte + i);
src.get(start..end).unwrap_or("")
}
fn is_second_parent(err: &toml::de::Error, src: &str) -> bool {
let Some(span) = err.span() else {
return false;
};
let on_parent_key = enclosing_line(src, span.start)
.trim_start()
.split('=')
.next()
.map(str::trim)
== Some("parent");
if !on_parent_key {
return false;
}
let msg = err.message();
msg.contains("duplicate key") || msg.contains("invalid type: sequence")
}
fn label_prefix(kind: ReqKind) -> &'static str {
match kind {
ReqKind::Functional => "FR",
ReqKind::Quality => "NF",
}
}
fn next_label(members: &[Member], kind: ReqKind) -> String {
let prefix = label_prefix(kind);
let max = members
.iter()
.filter_map(|m| {
m.label
.strip_prefix(prefix)?
.strip_prefix('-')?
.parse::<u32>()
.ok()
})
.max()
.unwrap_or(0);
format!("{prefix}-{:03}", max + 1)
}
fn next_order(members: &[Member]) -> u32 {
members.iter().map(|m| m.order).max().unwrap_or(0) + 1
}
fn append_member(
members_path: &Path,
requirement_fk: &str,
label: &str,
order: u32,
) -> anyhow::Result<()> {
let text = std::fs::read_to_string(members_path)
.with_context(|| format!("Failed to read {}", members_path.display()))?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", members_path.display()))?;
let members = doc
.entry("member")
.or_insert(toml_edit::Item::ArrayOfTables(
toml_edit::ArrayOfTables::new(),
))
.as_array_of_tables_mut()
.context("`member` is not an array of tables")?;
let mut row = toml_edit::Table::new();
row.insert("requirement", toml_edit::value(requirement_fk));
row.insert("label", toml_edit::value(label));
row.insert("order", toml_edit::value(i64::from(order)));
members.push(row);
std::fs::write(members_path, doc.to_string())
.with_context(|| format!("Failed to write {}", members_path.display()))
}
pub(crate) fn run_new(
path: Option<PathBuf>,
subtype: SpecSubtype,
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, subtype.kind().dir)?;
let out = entity::materialise(
subtype.kind(),
&LocalFs,
&root,
&MaterialiseRequest::Fresh,
&Inputs {
slug: &slug,
title: &title,
date: &date,
},
&trunk_ids,
)?;
let id = out
.eid
.numeric_id()
.context("spec kind must yield a numeric id")?;
writeln!(
io::stdout(),
"Created {}: {}",
subtype.canonical_id(id),
out.dir.display()
)?;
Ok(())
}
pub(crate) fn run_req_add(
path: Option<PathBuf>,
spec_ref: &str,
title: Option<String>,
kind: ReqKind,
label: Option<String>,
slug: Option<String>,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let (subtype, spec_id) = resolve_spec_ref(spec_ref)?;
let spec_dir = root.join(subtype.kind().dir).join(format!("{spec_id:03}"));
anyhow::ensure!(
spec_dir.is_dir(),
"no {} spec {spec_ref} at {}",
subtype.label(),
spec_dir.display()
);
let members_path = spec_dir.join("members.toml");
let members = read_members(&members_path)?;
let label = match label {
Some(l) => l,
None => next_label(&members, kind),
};
let order = next_order(&members);
let title = crate::input::resolve_title(title)?;
let slug = crate::input::resolve_slug(&title, slug)?;
let date = crate::clock::today();
let reserved = requirement::reserve(&root, &slug, &title, &date)?;
let req_id = reserved
.eid
.numeric_id()
.context("requirement kind must yield a numeric id")?;
let fk = requirement::canonical_id(req_id);
requirement::set_kind(&root, req_id, kind)?;
append_member(&members_path, &fk, &label, order)?;
writeln!(io::stdout(), "Added {label} ({fk}) to {spec_ref}")?;
Ok(())
}
pub(crate) fn run_req_status(
path: Option<PathBuf>,
req_ref: &str,
to: ReqStatus,
_note: Option<String>,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let id = requirement::id_from_fk(req_ref)?;
requirement::set_status(&root, id, to)?;
writeln!(io::stdout(), "Set {} status to {}", req_ref, to.as_str())?;
Ok(())
}
pub(crate) fn run_show(
path: Option<PathBuf>,
spec_ref: &str,
format: Format,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let (subtype, spec_id) = resolve_spec_ref(spec_ref)?;
let name = format!("{spec_id:03}");
let spec_dir = root.join(subtype.kind().dir).join(&name);
anyhow::ensure!(
spec_dir.is_dir(),
"no {} spec {spec_ref} at {}",
subtype.label(),
spec_dir.display()
);
let spec_toml = spec_dir.join(format!("{SPEC_STEM}-{name}.toml"));
let spec_text = std::fs::read_to_string(&spec_toml)
.with_context(|| format!("Failed to read {}", spec_toml.display()))?;
let spec: Spec = toml::from_str(&spec_text)
.with_context(|| format!("Failed to parse {}", spec_toml.display()))?;
let prose_path = spec_dir.join(format!("{SPEC_STEM}-{name}.md"));
let prose_body = std::fs::read_to_string(&prose_path)
.with_context(|| format!("Failed to read {}", prose_path.display()))?;
let members = read_members(&spec_dir.join("members.toml"))?;
let mut resolved = Vec::with_capacity(members.len());
for member in members {
let req = requirement::load(&root, &member.requirement)?;
resolved.push((member, req));
}
let interactions = read_interactions(&spec_dir.join("interactions.toml"))?;
let out = match format {
Format::Table => render(&spec, &prose_body, &resolved, &interactions),
Format::Json => show_json(&spec, &prose_body, &resolved, &interactions)?,
};
write!(io::stdout(), "{out}")?;
Ok(())
}
fn show_json(
spec: &Spec,
body: &str,
members: &[(Member, Requirement)],
interactions: &[Interaction],
) -> anyhow::Result<String> {
let member_rows: Vec<serde_json::Value> = members
.iter()
.map(|(m, req)| {
serde_json::json!({
"label": m.label,
"order": m.order,
"requirement": {
"id": requirement::canonical_id(req.id),
"slug": req.slug,
"title": req.title,
"kind": req.kind.as_str(),
"status": req.status.as_str(),
},
})
})
.collect();
let value = serde_json::json!({
"kind": "spec",
"spec": spec,
"id": canonical_id(spec.kind, spec.id),
"body": body,
"members": member_rows,
"interactions": interactions,
});
serde_json::to_string_pretty(&value).context("failed to serialize spec show JSON")
}
fn build_registry(root: &Path) -> anyhow::Result<Registry> {
let mut reg = Registry::default();
for id in entity::scan_ids(&requirement::tree_root(root))? {
reg.requirements.insert(requirement::canonical_id(id));
}
for subtype in [SpecSubtype::Product, SpecSubtype::Tech] {
let tree = root.join(subtype.kind().dir);
let on_product = subtype == SpecSubtype::Product;
for id in entity::scan_ids(&tree)? {
let spec_ref = subtype.canonical_id(id);
let dir = tree.join(format!("{id:03}"));
let spec_toml = dir.join(format!("{SPEC_STEM}-{id:03}.toml"));
let spec_text = std::fs::read_to_string(&spec_toml)
.with_context(|| format!("Failed to read {}", spec_toml.display()))?;
let spec: Spec = match toml::from_str::<Spec>(&spec_text) {
Ok(s) => s,
Err(e) if is_second_parent(&e, &spec_text) => {
reg.build_findings.push(BuildFinding {
spec: spec_ref.clone(),
message: format!("second parent: {spec_ref} declares more than one parent"),
});
continue;
}
Err(e) => {
return Err(anyhow::Error::new(e)
.context(format!("Failed to parse {}", spec_toml.display())));
}
};
if let Some(target) = &spec.descends_from {
reg.descents.push(DescentEdge {
spec: spec_ref.clone(),
target: canonicalize_spec_ref(target),
on_product,
});
}
if let Some(parent) = &spec.parent {
reg.parents.push(ParentEdge {
spec: spec_ref.clone(),
parent: canonicalize_spec_ref(parent),
on_product,
});
}
for m in read_members(&dir.join("members.toml"))? {
reg.members.push(MemberEdge {
spec: spec_ref.clone(),
requirement: requirement::canonicalize_fk(&m.requirement),
label: m.label,
});
}
if subtype == SpecSubtype::Tech {
reg.tech_specs.insert(spec_ref.clone());
for e in read_interactions(&dir.join("interactions.toml"))? {
reg.interactions.push(InteractionEdge {
spec: spec_ref.clone(),
target: canonicalize_spec_ref(&e.target),
});
}
} else {
reg.product_specs.insert(spec_ref.clone());
}
}
}
Ok(reg)
}
pub(crate) fn run_validate(path: Option<PathBuf>, spec_ref: Option<&str>) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let scope = match spec_ref {
Some(r) => {
let (subtype, id) = resolve_spec_ref(r)?;
let dir = root.join(subtype.kind().dir).join(format!("{id:03}"));
anyhow::ensure!(
dir.is_dir(),
"no {} spec {r} at {}",
subtype.label(),
dir.display()
);
Some(subtype.canonical_id(id))
}
None => None,
};
let registry = build_registry(&root)?;
let findings = registry.validate(scope.as_deref());
let target = scope.as_deref().unwrap_or("corpus");
if findings.is_empty() {
writeln!(io::stdout(), "validate: {target} clean")?;
return Ok(());
}
let mut lines = Vec::with_capacity(findings.len() + 1);
for f in &findings {
lines.push(format!(" {f}\n"));
}
write!(io::stdout(), "{}", lines.concat())?;
anyhow::bail!("validate: {} hard finding(s) in {target}", findings.len())
}
pub(crate) const SPEC_STATUSES: &[&str] = &["draft", "active", "deprecated", "superseded"];
fn is_hidden(status: &str) -> bool {
status == "superseded"
}
fn canonical_id(subtype: SpecSubtype, id: u32) -> String {
listing::canonical_id(subtype.kind().prefix, id)
}
fn validate_statuses(given: &[String], known: &[&str]) -> anyhow::Result<()> {
listing::validate_statuses(given, known)
}
fn key(subtype: SpecSubtype, m: &Meta) -> listing::FilterFields {
listing::FilterFields {
canonical: canonical_id(subtype, m.id),
slug: m.slug.clone(),
title: m.title.clone(),
status: m.status.clone(),
tags: Vec::new(),
}
}
fn subtype_rows(
root: &Path,
subtype: SpecSubtype,
filter: &listing::Filter,
) -> anyhow::Result<Vec<(Meta, usize)>> {
let tree = root.join(subtype.kind().dir);
let mut metas = listing::retain(
meta::read_metas(&tree, SPEC_STEM)?,
filter,
is_hidden,
|m| key(subtype, m),
);
metas.sort_by_key(|m| m.id);
let mut rows = Vec::with_capacity(metas.len());
for m in metas {
let count = member_count(&tree.join(format!("{:03}", m.id)))?;
rows.push((m, count));
}
Ok(rows)
}
#[derive(Debug, Serialize)]
struct SpecRow {
id: String,
subtype: &'static str,
status: String,
slug: String,
members: usize,
}
struct SpecListRow {
id: String,
status: String,
slug: String,
title: String,
members: usize,
}
const SPEC_COLUMNS: [listing::Column<SpecListRow>; 5] = [
listing::Column {
name: "id",
header: "id",
cell: |r| r.id.clone(),
paint: listing::ColumnPaint::Fixed(owo_colors::AnsiColors::Cyan),
},
listing::Column {
name: "status",
header: "status",
cell: |r| r.status.clone(),
paint: listing::ColumnPaint::ByValue(|r| listing::status_hue(&r.status)),
},
listing::Column {
name: "slug",
header: "slug",
cell: |r| r.slug.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "title",
header: "title",
cell: |r| r.title.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "members",
header: "#members",
cell: |r| r.members.to_string(),
paint: listing::ColumnPaint::None,
},
];
const SPEC_DEFAULT: &[&str] = &["id", "status", "title", "members"];
fn spec_list_rows(subtype: SpecSubtype, rows: &[(Meta, usize)]) -> Vec<SpecListRow> {
rows.iter()
.map(|(m, count)| SpecListRow {
id: canonical_id(subtype, m.id),
status: m.status.clone(),
slug: m.slug.clone(),
title: m.title.clone(),
members: *count,
})
.collect()
}
pub(crate) fn run_list(path: Option<PathBuf>, args: ListArgs) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
write!(io::stdout(), "{}", list_rows(&root, args)?)?;
Ok(())
}
pub(crate) fn list_rows(root: &Path, mut args: ListArgs) -> anyhow::Result<String> {
validate_statuses(&args.status, SPEC_STATUSES)?;
let render = args.render;
let columns = args.columns.take();
let (filter, format) = listing::build(args)?;
match format {
Format::Table => {
let sel = listing::select_columns(&SPEC_COLUMNS, SPEC_DEFAULT, columns.as_deref())?;
let mut blocks = Vec::new();
for subtype in [SpecSubtype::Product, SpecSubtype::Tech] {
let block_rows = spec_list_rows(subtype, &subtype_rows(root, subtype, &filter)?);
if block_rows.is_empty() {
continue;
}
blocks.push(format!(
"{}\n{}",
subtype.label(),
listing::render_columns(&block_rows, &sel, render)
));
}
Ok(blocks.concat())
}
Format::Json => {
let mut rows = Vec::new();
for subtype in [SpecSubtype::Product, SpecSubtype::Tech] {
for (m, count) in subtype_rows(root, subtype, &filter)? {
rows.push(SpecRow {
id: canonical_id(subtype, m.id),
subtype: subtype.label(),
status: m.status,
slug: m.slug,
members: count,
});
}
}
listing::json_envelope("spec", &rows)
}
}
}
struct ReqListRow {
id: String,
label: String,
kind: String,
status: String,
}
#[derive(Debug, Serialize)]
struct ReqJsonRow {
id: String,
label: String,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
dangling: bool,
#[serde(skip_serializing_if = "Option::is_none")]
load_error: Option<String>,
}
const REQ_COLUMNS: [listing::Column<ReqListRow>; 4] = [
listing::Column {
name: "id",
header: "id",
cell: |r| r.id.clone(),
paint: listing::ColumnPaint::Fixed(owo_colors::AnsiColors::Cyan),
},
listing::Column {
name: "label",
header: "label",
cell: |r| r.label.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "kind",
header: "kind",
cell: |r| r.kind.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "status",
header: "status",
cell: |r| r.status.clone(),
paint: listing::ColumnPaint::ByValue(|r| listing::status_hue(&r.status)),
},
];
const REQ_DEFAULT: &[&str] = &["id", "label", "kind", "status"];
fn req_rows(root: &Path, spec_ref: &str) -> anyhow::Result<Vec<(ReqListRow, Option<Requirement>)>> {
let members = member_reqs(root, spec_ref)?;
let mut rows = Vec::with_capacity(members.len());
for m in members {
match requirement::load(root, &m.requirement) {
Ok(req) => {
let row = ReqListRow {
id: m.requirement.clone(),
label: m.label.clone(),
kind: req.kind.as_str().to_string(),
status: req.status.as_str().to_string(),
};
rows.push((row, Some(req)));
}
Err(e) => {
let note = format!("<load error: {e}>");
let row = ReqListRow {
id: m.requirement.clone(),
label: m.label.clone(),
kind: note.clone(),
status: note,
};
rows.push((row, None));
}
}
}
Ok(rows)
}
fn req_key(id: &str, req: &Requirement) -> listing::FilterFields {
listing::FilterFields {
canonical: id.to_string(),
slug: req.slug.clone(),
title: req.title.clone(),
status: req.status.as_str().to_string(),
tags: req.tags.clone(),
}
}
fn req_list_rows(root: &Path, spec_ref: &str, mut args: ListArgs) -> anyhow::Result<String> {
validate_statuses(&args.status, requirement::REQ_STATUSES)?;
let render = args.render;
let columns = args.columns.take();
let (filter, format) = listing::build(args)?;
let rows = req_rows(root, spec_ref)?;
let resolved: Vec<(usize, &ReqListRow, &Requirement)> = rows
.iter()
.enumerate()
.filter_map(|(i, (row, req))| req.as_ref().map(|r| (i, row, r)))
.collect();
let kept_resolved: std::collections::BTreeSet<usize> =
listing::retain(resolved, &filter, is_hidden, |(_, row, req)| {
req_key(&row.id, req)
})
.into_iter()
.map(|(i, _, _)| i)
.collect();
let kept: Vec<(ReqListRow, Option<Requirement>)> = rows
.into_iter()
.enumerate()
.filter(|(i, (_, req))| req.is_none() || kept_resolved.contains(i))
.map(|(_, pair)| pair)
.collect();
match format {
Format::Table => {
let sel = listing::select_columns(&REQ_COLUMNS, REQ_DEFAULT, columns.as_deref())?;
let table_rows: Vec<ReqListRow> = kept.into_iter().map(|(row, _)| row).collect();
Ok(listing::render_columns(&table_rows, &sel, render))
}
Format::Json => {
let json_rows: Vec<ReqJsonRow> = kept
.into_iter()
.map(|(row, req)| match req {
Some(_) => ReqJsonRow {
id: row.id,
label: row.label,
kind: Some(row.kind),
status: Some(row.status),
dangling: false,
load_error: None,
},
None => ReqJsonRow {
id: row.id,
label: row.label,
kind: None,
status: None,
dangling: true,
load_error: Some(row.kind),
},
})
.collect();
listing::json_envelope("requirement", &json_rows)
}
}
}
pub(crate) fn run_req_list(
path: Option<PathBuf>,
spec_ref: &str,
args: ListArgs,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
write!(io::stdout(), "{}", req_list_rows(&root, spec_ref, args)?)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requirement::ReqStatus;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
fn list_args() -> ListArgs {
ListArgs::default()
}
fn fresh(root: &Path, subtype: SpecSubtype, slug: &str, title: &str) -> entity::Materialised {
entity::materialise(
subtype.kind(),
&LocalFs,
root,
&MaterialiseRequest::Fresh,
&Inputs {
slug,
title,
date: "2026-06-05",
},
&[],
)
.unwrap()
}
#[test]
fn build_registry_scans_all_three_trees() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "auth", "Auth"); fresh(root, SpecSubtype::Tech, "store", "Store"); fresh(root, SpecSubtype::Product, "login", "Login"); for slug in ["a", "b", "c"] {
requirement::reserve(root, slug, slug, "2026-06-05").unwrap(); }
append_member(
&root.join(".doctrine/spec/tech/001/members.toml"),
"REQ-001",
"FR-001",
1,
)
.unwrap();
append_member(
&root.join(".doctrine/spec/product/001/members.toml"),
"REQ-002",
"FR-001",
1,
)
.unwrap();
let ix = root.join(".doctrine/spec/tech/001/interactions.toml");
let mut s = fs::read_to_string(&ix).unwrap();
s.push_str("\n[[edge]]\ntarget = \"SPEC-002\"\ntype = \"calls\"\n");
fs::write(&ix, s).unwrap();
let reg = build_registry(root).unwrap();
let want_reqs: BTreeSet<String> = ["REQ-001", "REQ-002", "REQ-003"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(reg.requirements, want_reqs);
let want_techs: BTreeSet<String> = ["SPEC-001", "SPEC-002"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(reg.tech_specs, want_techs); assert_eq!(reg.members.len(), 2, "members from both subtypes");
assert!(
reg.members
.iter()
.any(|m| m.spec == "PRD-001" && m.requirement == "REQ-002"),
"the product member edge is collected"
);
assert_eq!(reg.interactions.len(), 1, "tech-only interaction edge");
assert!(
reg.interactions
.iter()
.any(|e| e.spec == "SPEC-001" && e.target == "SPEC-002")
);
}
#[test]
fn relation_edges_tech_lineage_members_interactions() {
use crate::relation::RelationLabel;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "auth", "Auth"); let toml_path = root.join(".doctrine/spec/tech/001/spec-001.toml");
let mut t = fs::read_to_string(&toml_path).unwrap();
t.push_str("descends_from = \"PRD-005\"\nparent = \"SPEC-000\"\n");
fs::write(&toml_path, t).unwrap();
append_member(
&root.join(".doctrine/spec/tech/001/members.toml"),
"REQ-009",
"FR-001",
1,
)
.unwrap();
let ix = root.join(".doctrine/spec/tech/001/interactions.toml");
let mut s = fs::read_to_string(&ix).unwrap();
s.push_str("\n[[edge]]\ntarget = \"SPEC-002\"\ntype = \"calls\"\nnotes = \"sync\"\n");
fs::write(&ix, s).unwrap();
let edges = relation_edges(SpecSubtype::Tech, root, 1).unwrap();
let got: Vec<(RelationLabel, &str)> =
edges.iter().map(|e| (e.label, e.target.as_str())).collect();
assert_eq!(
got,
vec![
(RelationLabel::DescendsFrom, "PRD-005"),
(RelationLabel::Parent, "SPEC-000"),
(RelationLabel::Members, "REQ-009"),
(RelationLabel::Interactions, "SPEC-002"),
]
);
}
#[test]
fn interactions_free_text_type_round_trips_from_source() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "auth", "Auth"); let ix = root.join(".doctrine/spec/tech/001/interactions.toml");
let mut s = fs::read_to_string(&ix).unwrap();
s.push_str("\n[[edge]]\ntarget = \"SPEC-002\"\ntype = \"depends-on\"\nnotes = \"n\"\n");
fs::write(&ix, s).unwrap();
let edges = relation_edges(SpecSubtype::Tech, root, 1).unwrap();
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].label, crate::relation::RelationLabel::Interactions);
assert_eq!(edges[0].target, "SPEC-002");
let src = read_interactions(&ix).unwrap();
assert_eq!(src.len(), 1);
assert_eq!(
src[0].kind, "depends-on",
"free-text type survives at source"
);
}
#[test]
fn product_spec_scaffold_is_light_3_files() {
let ctx = ScaffoldCtx {
id: 7,
canonical: "PRD-007",
slug: "fast-onboarding",
title: "Fast onboarding",
date: "2026-06-05",
};
let fileset = product_spec_scaffold(&ctx).unwrap();
assert_eq!(fileset.len(), 4);
assert!(matches!(&fileset[0],
Artifact::File { rel_path, body }
if rel_path == Path::new("007/spec-007.toml") && body.contains("kind = \"product\"")));
assert!(matches!(&fileset[1],
Artifact::File { rel_path, body }
if rel_path == Path::new("007/spec-007.md") && body.contains("PRD-007: Fast onboarding")));
assert!(matches!(&fileset[2],
Artifact::File { rel_path, .. } if rel_path == Path::new("007/members.toml")));
assert!(matches!(&fileset[3],
Artifact::Symlink { rel_path, target }
if rel_path == Path::new("007-fast-onboarding") && target == "007"));
assert!(
!fileset.iter().any(|a| matches!(a,
Artifact::File { rel_path, .. } if rel_path == Path::new("007/interactions.toml"))),
"product has no interactions.toml"
);
}
#[test]
fn tech_spec_scaffold_has_members_and_interactions() {
let ctx = ScaffoldCtx {
id: 3,
canonical: "SPEC-003",
slug: "cli",
title: "CLI",
date: "2026-06-05",
};
let fileset = tech_spec_scaffold(&ctx).unwrap();
assert_eq!(fileset.len(), 5);
let has = |p: &str| {
fileset
.iter()
.any(|a| matches!(a, Artifact::File { rel_path, .. } if rel_path == Path::new(p)))
};
assert!(has("003/spec-003.toml"));
assert!(has("003/spec-003.md"));
assert!(has("003/members.toml"));
assert!(has("003/interactions.toml"));
let toml_body = match &fileset[0] {
Artifact::File { body, .. } => body,
_ => panic!("first artifact is the toml"),
};
assert!(toml_body.contains("kind = \"tech\""));
assert!(toml_body.contains("responsibilities = []"));
}
#[test]
fn materialise_fresh_writes_each_subtype_in_its_own_namespace() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let p1 = fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
let t1 = fresh(root, SpecSubtype::Tech, "cli", "CLI");
assert_eq!(p1.eid.numeric_id(), Some(1));
assert_eq!(t1.eid.numeric_id(), Some(1));
assert!(
root.join(".doctrine/spec/product/001/spec-001.toml")
.is_file()
);
assert!(
root.join(".doctrine/spec/product/001/members.toml")
.is_file()
);
assert!(
!root
.join(".doctrine/spec/product/001/interactions.toml")
.exists()
);
assert!(
root.join(".doctrine/spec/tech/001/interactions.toml")
.is_file()
);
assert_eq!(
fs::read_link(root.join(".doctrine/spec/product/001-onboarding")).unwrap(),
Path::new("001")
);
let p2 = fresh(root, SpecSubtype::Product, "billing", "Billing");
assert_eq!(p2.eid.numeric_id(), Some(2));
let md = fs::read_to_string(root.join(".doctrine/spec/tech/001/spec-001.md")).unwrap();
assert!(md.contains("SPEC-001: CLI"));
}
#[test]
fn spec_list_meta_parses_scaffolded_spec_toml() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
let tree = root.join(".doctrine/spec/product");
let m = meta::read_meta(&tree, SPEC_STEM, 1).unwrap();
assert_eq!(
m,
Meta {
id: 1,
slug: "onboarding".to_string(),
title: "Onboarding".to_string(),
status: "draft".to_string(),
}
);
}
#[test]
fn render_spec_toml_escapes_hostile_title_and_slug() {
let title = crate::tomlfmt::HOSTILE_TITLE;
let slug = crate::tomlfmt::HOSTILE_SLUG;
let body = render_spec_toml(SpecSubtype::Product, 7, slug, title).unwrap();
let parsed: Meta = toml::from_str(&body).unwrap();
assert_eq!(parsed.slug, slug);
assert_eq!(parsed.title, title);
}
#[test]
fn spec_list_rows_per_subtype_with_member_count() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
fresh(root, SpecSubtype::Product, "billing", "Billing");
let out = list_rows(root, list_args()).unwrap();
assert!(out.starts_with("product\n"), "product block leads: {out}");
assert!(out.contains("#members"));
assert!(out.contains("PRD-001 │ draft │ Onboarding"), "{out}");
assert!(out.contains("PRD-002 │ draft │ Billing"), "{out}");
assert!(!out.contains("onboarding"), "slug hidden by default: {out}");
assert!(!out.contains("billing"), "slug hidden by default: {out}");
for line in out.lines().filter(|l| l.starts_with("PRD-")) {
assert!(
line.trim_end().ends_with('0'),
"row ends in #members=0: {line}"
);
}
assert!(
!out.contains("tech\n"),
"empty tech block suppressed: {out}"
);
}
#[test]
fn list_rows_columns_selects_orders_and_reveals_slug() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
let out = list_rows(
root,
ListArgs {
columns: Some(vec!["id".into(), "slug".into()]),
..ListArgs::default()
},
)
.unwrap();
assert!(out.starts_with("product\n"), "block label preserved: {out}");
assert!(out.contains("id"));
assert!(out.contains("slug"));
assert!(out.contains("PRD-001 │ onboarding"), "{out}");
assert!(!out.contains("#members"), "members dropped: {out}");
assert!(!out.contains("Onboarding"), "title dropped: {out}");
}
#[test]
fn list_rows_unknown_column_is_the_uniform_error_listing_available() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
let err = list_rows(
root,
ListArgs {
columns: Some(vec!["bogus".into()]),
..ListArgs::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("bogus"), "names the bad column: {msg}");
assert!(msg.contains("members"), "lists the available set: {msg}");
assert!(msg.contains("title"), "lists the available set: {msg}");
}
#[test]
fn list_rows_prefixed_ids_are_correct_per_subtype() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding"); fresh(root, SpecSubtype::Tech, "cli", "CLI");
let out = list_rows(root, list_args()).unwrap();
assert!(out.contains("PRD-001"), "product id prefixed PRD: {out}");
assert!(out.contains("SPEC-001"), "tech id prefixed SPEC: {out}");
}
fn spec_at(root: &Path, subtype: SpecSubtype, id: u32, status: &str, slug: &str, title: &str) {
let name = format!("{id:03}");
let dir = root.join(subtype.kind().dir).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!("{SPEC_STEM}-{name}.toml")), toml).unwrap();
}
#[test]
fn list_rows_orders_by_id_within_each_subtype_block_regardless_of_creation_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
spec_at(root, SpecSubtype::Product, 3, "draft", "pg", "ProductGamma");
spec_at(root, SpecSubtype::Product, 1, "draft", "pa", "ProductAlpha");
spec_at(root, SpecSubtype::Product, 2, "draft", "pb", "ProductBeta");
spec_at(root, SpecSubtype::Tech, 2, "draft", "tb", "TechBeta");
spec_at(root, SpecSubtype::Tech, 1, "draft", "ta", "TechAlpha");
let out = list_rows(root, list_args()).unwrap();
let off = |id: &str| {
out.find(id)
.unwrap_or_else(|| panic!("{id} present: {out}"))
};
assert!(
off("PRD-001") < off("PRD-002") && off("PRD-002") < off("PRD-003"),
"product rows ascend by id: {out}"
);
assert!(
off("SPEC-001") < off("SPEC-002"),
"tech rows ascend by id: {out}"
);
assert!(
off("PRD-003") < off("SPEC-001"),
"the product block precedes the tech block: {out}"
);
}
#[test]
fn member_count_reads_appended_rows() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI");
let spec_dir = root.join(".doctrine/spec/tech/001");
let members = spec_dir.join("members.toml");
let appended = format!(
"{}\n[[member]]\nrequirement = \"REQ-001\"\nlabel = \"FR-001\"\norder = 1\n",
fs::read_to_string(&members).unwrap()
);
fs::write(&members, appended).unwrap();
assert_eq!(member_count(&spec_dir).unwrap(), 1);
}
fn append_raw_member(spec_dir: &Path, requirement: &str, label: &str, order: u32) {
let members = spec_dir.join("members.toml");
let appended = format!(
"{}\n[[member]]\nrequirement = \"{requirement}\"\nlabel = \"{label}\"\norder = {order}\n",
fs::read_to_string(&members).unwrap()
);
fs::write(&members, appended).unwrap();
}
#[test]
fn member_reqs_canonicalises_the_requirement_fk() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI"); append_raw_member(&root.join(".doctrine/spec/tech/001"), "REQ-1", "FR-001", 1);
let reqs = member_reqs(root, "SPEC-001").unwrap();
assert_eq!(reqs.len(), 1);
assert_eq!(reqs[0].requirement, "REQ-001");
}
#[test]
fn member_reqs_sorts_by_order_and_carries_labels_verbatim() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI"); let spec_dir = root.join(".doctrine/spec/tech/001");
append_raw_member(&spec_dir, "REQ-002", "FR-002", 2);
append_raw_member(&spec_dir, "REQ-001", "FR-001", 1);
let reqs = member_reqs(root, "SPEC-001").unwrap();
let labels: Vec<&str> = reqs.iter().map(|m| m.label.as_str()).collect();
let fks: Vec<&str> = reqs.iter().map(|m| m.requirement.as_str()).collect();
assert_eq!(
labels,
["FR-001", "FR-002"],
"sorted by order; labels verbatim"
);
assert_eq!(fks, ["REQ-001", "REQ-002"]);
}
#[test]
fn list_status_filter_selects_within_a_subtype() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
fresh(root, SpecSubtype::Product, "billing", "Billing");
let p = root.join(".doctrine/spec/product/002/spec-002.toml");
let flipped = fs::read_to_string(&p)
.unwrap()
.replace("status = \"draft\"", "status = \"active\"");
fs::write(&p, flipped).unwrap();
let active = list_rows(
root,
ListArgs {
status: vec!["active".into()],
..ListArgs::default()
},
)
.unwrap();
assert!(active.contains("PRD-002 │ active │ Billing"), "{active}");
assert!(!active.contains("Onboarding"));
}
fn flip_status(root: &Path, subtype: SpecSubtype, id: u32, to: &str) {
let p = root
.join(subtype.kind().dir)
.join(format!("{id:03}"))
.join(format!("spec-{id:03}.toml"));
let flipped = fs::read_to_string(&p)
.unwrap()
.replace("status = \"draft\"", &format!("status = \"{to}\""));
fs::write(&p, flipped).unwrap();
}
#[test]
fn spec_list_hides_superseded_by_default_and_all_reveals() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
fresh(root, SpecSubtype::Product, "billing", "Billing");
flip_status(root, SpecSubtype::Product, 2, "superseded");
let def = list_rows(root, list_args()).unwrap();
assert!(def.contains("PRD-001"), "{def}");
assert!(
!def.contains("PRD-002"),
"superseded hidden by default: {def}"
);
let all = list_rows(
root,
ListArgs {
all: true,
..ListArgs::default()
},
)
.unwrap();
assert!(all.contains("PRD-002"), "--all reveals superseded: {all}");
let explicit = list_rows(
root,
ListArgs {
status: vec!["superseded".into()],
..ListArgs::default()
},
)
.unwrap();
assert!(explicit.contains("PRD-002"), "{explicit}");
assert!(!explicit.contains("PRD-001"), "{explicit}");
}
#[test]
fn spec_list_filter_matches_slug_and_title_regexp_matches_canonical() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
fresh(root, SpecSubtype::Tech, "cli", "CLI");
let by_substr = list_rows(
root,
ListArgs {
substr: Some("onboard".into()),
..ListArgs::default()
},
)
.unwrap();
assert!(by_substr.contains("PRD-001"), "{by_substr}");
assert!(!by_substr.contains("SPEC-001"), "{by_substr}");
let by_regex = list_rows(
root,
ListArgs {
regexp: Some("^SPEC-".into()),
..ListArgs::default()
},
)
.unwrap();
assert!(by_regex.contains("SPEC-001"), "{by_regex}");
assert!(!by_regex.contains("PRD-001"), "{by_regex}");
}
#[test]
fn spec_list_json_is_one_envelope_with_subtype_per_row() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
fresh(root, SpecSubtype::Tech, "cli", "CLI");
let json = list_rows(
root,
ListArgs {
json: true,
..ListArgs::default()
},
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["kind"], "spec", "single envelope keyed `spec`");
let rows = v["rows"].as_array().expect("rows is an array");
assert_eq!(rows.len(), 2, "both subtypes in ONE envelope: {json}");
let prd = rows
.iter()
.find(|r| r["id"] == "PRD-001")
.expect("the product row");
assert_eq!(prd["subtype"], "product");
assert_eq!(prd["status"], "draft");
assert_eq!(prd["members"], 0);
let spec = rows
.iter()
.find(|r| r["id"] == "SPEC-001")
.expect("the tech row");
assert_eq!(spec["subtype"], "tech");
}
#[test]
fn spec_list_rejects_an_unknown_status_with_the_uniform_error() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
let err = list_rows(
root,
ListArgs {
status: vec!["bogus".into()],
..ListArgs::default()
},
)
.unwrap_err();
assert!(
err.to_string().contains("bogus"),
"names the bad value: {err}"
);
}
#[test]
fn spec_statuses_matches_the_variants() {
let from_variants: Vec<&str> = [
SpecStatus::Draft,
SpecStatus::Active,
SpecStatus::Deprecated,
SpecStatus::Superseded,
]
.iter()
.map(|s| s.as_str())
.collect();
assert_eq!(from_variants, SPEC_STATUSES.to_vec());
}
#[test]
fn spec_show_json_is_faithful_toml_as_data_plus_body_and_members() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI");
run_req_add(
Some(root.to_path_buf()),
"SPEC-001",
Some("Route subcommands".into()),
ReqKind::Functional,
None,
None,
)
.unwrap();
let spec_dir = root.join(".doctrine/spec/tech/001");
let spec_toml = spec_dir.join("spec-001.toml");
let spec: Spec = toml::from_str(&fs::read_to_string(&spec_toml).unwrap()).unwrap();
let body = fs::read_to_string(spec_dir.join("spec-001.md")).unwrap();
let members = read_members(&spec_dir.join("members.toml")).unwrap();
let mut resolved = Vec::new();
for m in members {
let req = requirement::load(root, &m.requirement).unwrap();
resolved.push((m, req));
}
let interactions = read_interactions(&spec_dir.join("interactions.toml")).unwrap();
let json = show_json(&spec, &body, &resolved, &interactions).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["kind"], "spec");
assert_eq!(v["id"], "SPEC-001");
assert_eq!(v["spec"]["title"], "CLI");
assert_eq!(v["spec"]["status"], "draft");
assert_eq!(v["body"], body, "the prose body is verbatim");
let mrows = v["members"].as_array().expect("members array");
assert_eq!(mrows.len(), 1, "the one membered requirement");
assert_eq!(mrows[0]["label"], "FR-001");
assert_eq!(mrows[0]["requirement"]["id"], "REQ-001");
assert_eq!(mrows[0]["requirement"]["title"], "Route subcommands");
}
#[test]
fn tags_and_description_round_trip_on_spec() {
let body = "\
id = 3
slug = \"cli\"
title = \"CLI\"
status = \"active\"
kind = \"tech\"
tags = [\"infra\", \"surface\"]
category = \"cli\"
c4_level = \"container\"
responsibilities = [\"route subcommands\"]
[[source]]
language = \"rust\"
identifier = \"doctrine/cli\"
module = \"doctrine::cli\"
";
let spec: Spec = toml::from_str(body).unwrap();
assert_eq!(spec.kind, SpecSubtype::Tech);
assert_eq!(spec.status, SpecStatus::Active);
assert_eq!(spec.tags, vec!["infra", "surface"]);
assert_eq!(spec.category.as_deref(), Some("cli"));
assert_eq!(spec.c4_level, Some(C4Level::Container));
assert_eq!(spec.responsibilities, vec!["route subcommands"]);
assert_eq!(spec.sources.len(), 1);
assert_eq!(spec.sources[0].language, "rust");
assert_eq!(spec.sources[0].module.as_deref(), Some("doctrine::cli"));
assert_eq!(spec.descends_from, None);
assert_eq!(spec.parent, None);
let m: Meta = toml::from_str(body).unwrap();
assert_eq!(m.title, "CLI");
}
#[test]
fn product_level_kebab_round_trips_every_variant() {
for (variant, kebab) in [
(ProductLevel::Domain, "domain"),
(ProductLevel::Capability, "capability"),
(ProductLevel::Feature, "feature"),
(ProductLevel::Story, "story"),
] {
assert_eq!(variant.as_str(), kebab);
let body = format!(
"id = 1\nslug = \"x\"\ntitle = \"X\"\nstatus = \"draft\"\nkind = \"product\"\nproduct_level = \"{kebab}\"\n"
);
let spec: Spec = toml::from_str(&body).unwrap();
assert_eq!(spec.product_level, Some(variant));
}
}
#[test]
fn product_level_rejects_unknown_variant_at_parse() {
let body = "id = 1\nslug = \"x\"\ntitle = \"X\"\nstatus = \"draft\"\nkind = \"product\"\nproduct_level = \"epic\"\n";
assert!(toml::from_str::<Spec>(body).is_err());
}
#[test]
fn product_level_absent_defaults_to_none() {
let body = "id = 1\nslug = \"x\"\ntitle = \"X\"\nstatus = \"draft\"\nkind = \"product\"\n";
let spec: Spec = toml::from_str(body).unwrap();
assert_eq!(spec.product_level, None);
}
const SPEC_BASE: &str =
"id = 1\nslug = \"x\"\ntitle = \"X\"\nstatus = \"draft\"\nkind = \"tech\"\ntags = []\n";
fn classify(doc: &str) -> bool {
let err = toml::from_str::<Spec>(doc).unwrap_err();
is_second_parent(&err, doc)
}
#[test]
fn second_parent_classifier_matches_duplicate_parent() {
assert!(classify(&format!(
"{SPEC_BASE}parent = \"SPEC-001\"\nparent = \"SPEC-002\"\n"
)));
}
#[test]
fn second_parent_classifier_matches_array_parent() {
assert!(classify(&format!(
"{SPEC_BASE}parent = [\"SPEC-001\", \"SPEC-002\"]\n"
)));
}
#[test]
fn second_parent_classifier_ignores_unrelated_parse_errors() {
assert!(!classify(&format!("{SPEC_BASE}parent = 5\n")));
assert!(!classify(&format!(
"{SPEC_BASE}category = \"a\"\ncategory = \"b\"\n"
)));
assert!(!classify(
"id = 1\nslug = []\ntitle = \"X\"\nstatus = \"draft\"\nkind = \"tech\"\ntags = []\n"
));
}
#[test]
fn product_spec_toml_defaults_tech_flat_fields() {
let body = "\
id = 1
slug = \"onboarding\"
title = \"Onboarding\"
status = \"draft\"
kind = \"product\"
tags = []
";
let spec: Spec = toml::from_str(body).unwrap();
assert_eq!(spec.kind, SpecSubtype::Product);
assert_eq!(spec.category, None);
assert_eq!(spec.c4_level, None);
assert!(spec.responsibilities.is_empty());
assert!(spec.sources.is_empty());
assert_eq!(spec.descends_from, None);
assert_eq!(spec.parent, None);
}
#[test]
fn tech_spec_parses_descent_and_parent_when_present() {
let body = "\
id = 1
slug = \"cli\"
title = \"CLI\"
status = \"active\"
kind = \"tech\"
descends_from = \"PRD-001\"
parent = \"SPEC-002\"
";
let spec: Spec = toml::from_str(body).unwrap();
assert_eq!(spec.descends_from.as_deref(), Some("PRD-001"));
assert_eq!(spec.parent.as_deref(), Some("SPEC-002"));
}
#[test]
fn member_and_interaction_parse_layer_round_trips() {
let m: Member =
toml::from_str("requirement = \"REQ-007\"\nlabel = \"FR-001\"\norder = 2\n").unwrap();
assert_eq!(m.requirement, "REQ-007");
assert_eq!(m.label, "FR-001");
assert_eq!(m.order, 2);
let i: Interaction =
toml::from_str("target = \"SPEC-002\"\ntype = \"uses\"\nnotes = \"x\"\n").unwrap();
assert_eq!(i.target, "SPEC-002");
assert_eq!(i.kind, "uses"); assert_eq!(i.notes.as_deref(), Some("x"));
}
#[test]
fn seeded_members_toml_parses_to_zero() {
let doc: MembersDoc = toml::from_str(&members_seed().unwrap()).unwrap();
assert!(doc.member.is_empty());
}
#[test]
fn req_add_resolver_rejects_bare_numeric() {
assert_eq!(
resolve_spec_ref("PRD-7").unwrap(),
(SpecSubtype::Product, 7)
);
assert_eq!(
resolve_spec_ref("SPEC-012").unwrap(),
(SpecSubtype::Tech, 12)
);
assert!(resolve_spec_ref("7").is_err());
assert!(resolve_spec_ref("REQ-1").is_err());
assert!(resolve_spec_ref("PRD-x").is_err());
}
#[test]
fn next_label_and_order_fill_per_kind_independently() {
let members = vec![
Member {
requirement: "REQ-001".into(),
label: "FR-001".into(),
order: 1,
},
Member {
requirement: "REQ-002".into(),
label: "FR-002".into(),
order: 2,
},
Member {
requirement: "REQ-003".into(),
label: "NF-001".into(),
order: 3,
},
];
assert_eq!(next_label(&members, ReqKind::Functional), "FR-003");
assert_eq!(next_label(&members, ReqKind::Quality), "NF-002");
assert_eq!(next_label(&[], ReqKind::Functional), "FR-001");
assert_eq!(next_order(&members), 4);
assert_eq!(next_order(&[]), 1);
}
#[test]
fn spec_req_add_reserves_requirement_and_appends_member() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
run_req_add(
Some(root.to_path_buf()),
"PRD-001",
Some("User can sign up".into()),
ReqKind::Functional,
None,
None,
)
.unwrap();
let req_toml = root.join(".doctrine/requirement/001/requirement-001.toml");
assert!(req_toml.is_file());
assert!(
fs::read_to_string(&req_toml)
.unwrap()
.contains("kind = \"functional\"")
);
let members = read_members(&root.join(".doctrine/spec/product/001/members.toml")).unwrap();
assert_eq!(members.len(), 1);
assert_eq!(members[0].requirement, "REQ-001");
assert_eq!(members[0].label, "FR-001");
assert_eq!(members[0].order, 1);
}
#[test]
fn spec_req_add_is_edit_preserving() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
let members_path = root.join(".doctrine/spec/product/001/members.toml");
let seeded = fs::read_to_string(&members_path).unwrap();
fs::write(
&members_path,
format!("{seeded}\n# hand-added note\nschema_hint = \"survives\"\n"),
)
.unwrap();
run_req_add(
Some(root.to_path_buf()),
"PRD-001",
Some("X".into()),
ReqKind::Functional,
None,
None,
)
.unwrap();
let after = fs::read_to_string(&members_path).unwrap();
assert!(after.contains("# hand-added note"));
assert!(after.contains("schema_hint = \"survives\""));
assert!(after.contains("[[member]]"));
assert!(after.contains("requirement = \"REQ-001\""));
}
#[test]
fn spec_req_add_auto_labels_fr_then_nf_by_kind() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI");
let add = |title: &str, kind: ReqKind, label: Option<&str>| {
run_req_add(
Some(root.to_path_buf()),
"SPEC-001",
Some(title.into()),
kind,
label.map(str::to_string),
None,
)
.unwrap();
};
add("route subcommands", ReqKind::Functional, None); add("parse flags", ReqKind::Functional, None); add("fast startup", ReqKind::Quality, None); add("explicit", ReqKind::Functional, Some("FR-099"));
let members = read_members(&root.join(".doctrine/spec/tech/001/members.toml")).unwrap();
let labels: Vec<&str> = members.iter().map(|m| m.label.as_str()).collect();
assert_eq!(labels, vec!["FR-001", "FR-002", "NF-001", "FR-099"]);
let fks: Vec<&str> = members.iter().map(|m| m.requirement.as_str()).collect();
assert_eq!(fks, vec!["REQ-001", "REQ-002", "REQ-003", "REQ-004"]);
let q = fs::read_to_string(root.join(".doctrine/requirement/003/requirement-003.toml"))
.unwrap();
assert!(q.contains("kind = \"quality\""));
}
#[test]
#[cfg(unix)]
fn spec_req_add_orphan_on_append_failure_left_uncommitted() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "onboarding", "Onboarding");
let members_path = root.join(".doctrine/spec/product/001/members.toml");
let mut perms = fs::metadata(&members_path).unwrap().permissions();
perms.set_mode(0o444);
fs::set_permissions(&members_path, perms).unwrap();
let err = run_req_add(
Some(root.to_path_buf()),
"PRD-001",
Some("X".into()),
ReqKind::Functional,
None,
None,
);
assert!(
err.is_err(),
"append must fail on the read-only members.toml"
);
assert!(
root.join(".doctrine/requirement/001/requirement-001.toml")
.is_file()
);
let members = read_members(&members_path).unwrap();
assert!(members.is_empty(), "no partial member row written");
}
fn req(id: u32, title: &str, kind: ReqKind) -> Requirement {
Requirement {
id,
title: title.to_string(),
slug: title.to_lowercase().replace(' ', "-"),
status: ReqStatus::Active,
kind,
description: Some(format!("{title} statement")),
tags: Vec::new(),
acceptance_criteria: Vec::new(),
}
}
fn member(fk: &str, label: &str, order: u32) -> Member {
Member {
requirement: fk.to_string(),
label: label.to_string(),
order,
}
}
fn tech_spec(id: u32) -> Spec {
Spec {
id,
slug: "cli".to_string(),
title: "CLI".to_string(),
status: SpecStatus::Active,
kind: SpecSubtype::Tech,
tags: Vec::new(),
category: None,
c4_level: None,
product_level: None,
responsibilities: Vec::new(),
sources: Vec::new(),
descends_from: None,
parent: None,
}
}
#[test]
fn render_reassembles_members_in_order() {
let spec = tech_spec(7);
let members = vec![
(
member("REQ-003", "FR-003", 3),
req(3, "Third", ReqKind::Functional),
),
(
member("REQ-001", "FR-001", 1),
req(1, "First", ReqKind::Functional),
),
(
member("REQ-002", "NF-001", 2),
req(2, "Second", ReqKind::Quality),
),
];
let out = render(&spec, "## Body\n\nverbatim prose\n", &members, &[]);
assert!(out.starts_with("`SPEC-007` — CLI\n"));
assert!(out.contains("cli · active · tech"));
assert!(out.contains("## Body"));
assert!(out.contains("verbatim prose"));
assert_eq!(
out.matches("\n# ").count() + usize::from(out.starts_with("# ")),
0
);
let h1 = out.find("### FR-001 (REQ-001) — First").unwrap();
let h2 = out.find("### NF-001 (REQ-002) — Second").unwrap();
let h3 = out.find("### FR-003 (REQ-003) — Third").unwrap();
assert!(
h1 < h2 && h2 < h3,
"members render sorted by order, not input order"
);
assert!(out.contains("first · functional · active"));
assert!(out.contains("First statement"));
assert!(!out.contains("## Interactions"));
}
#[test]
fn render_includes_tech_flat_fields_and_requirement_facets() {
let spec = Spec {
tags: vec!["infra".to_string()],
category: Some("cli".to_string()),
c4_level: Some(C4Level::Container),
responsibilities: vec!["route subcommands".to_string()],
sources: vec![Source {
language: "rust".to_string(),
identifier: "doctrine/cli".to_string(),
module: Some("doctrine::cli".to_string()),
}],
..tech_spec(1)
};
let mut r = req(1, "Route", ReqKind::Functional);
r.tags = vec!["core".to_string()];
r.acceptance_criteria = vec!["dispatch works".to_string()];
let members = vec![(member("REQ-001", "FR-001", 1), r)];
let out = render(&spec, "## Overview\n", &members, &[]);
assert!(out.contains("tags: infra"));
assert!(out.contains("category: cli"));
assert!(out.contains("c4 level: container"));
assert!(out.contains(" - route subcommands"));
assert!(out.contains(" - rust doctrine/cli (doctrine::cli)"));
assert!(out.contains("tags: core"));
assert!(out.contains("Route statement"));
assert!(out.contains(" - dispatch works"));
}
#[test]
fn render_omits_statement_line_when_description_absent() {
let spec = tech_spec(1);
let mut r = req(1, "Bare", ReqKind::Functional);
r.description = None; let members = vec![(member("REQ-001", "FR-001", 1), r)];
let out = render(&spec, "p\n", &members, &[]);
assert!(out.contains("### FR-001 (REQ-001) — Bare"));
assert!(!out.contains("statement"));
}
#[test]
fn render_emits_outbound_interactions_for_tech_omits_when_empty() {
let spec = tech_spec(1);
let edges = vec![
Interaction {
target: "SPEC-002".to_string(),
kind: "uses".to_string(),
notes: Some("calls boot".to_string()),
},
Interaction {
target: "SPEC-003".to_string(),
kind: "extends".to_string(),
notes: None,
},
];
let with = render(&spec, "p\n", &[], &edges);
assert!(with.contains("## Interactions"));
assert!(with.contains("- SPEC-002 — uses: calls boot"));
assert!(with.contains("- SPEC-003 — extends\n"));
let without = render(&spec, "p\n", &[], &[]);
assert!(!without.contains("## Interactions"));
}
#[test]
fn render_emits_descent_and_parent_for_tech_in_order() {
let spec = Spec {
c4_level: Some(C4Level::Component),
descends_from: Some("PRD-001".to_string()),
parent: Some("SPEC-002".to_string()),
responsibilities: vec!["route".to_string()],
..tech_spec(1)
};
let out = render(&spec, "p\n", &[], &[]);
assert!(out.contains("descends from: PRD-001\n"));
assert!(out.contains("parent: SPEC-002\n"));
assert!(!out.contains("children"));
let c4 = out.find("c4 level:").unwrap();
let descends = out.find("descends from:").unwrap();
let parent = out.find("parent:").unwrap();
let resp = out.find("responsibilities:").unwrap();
assert!(c4 < descends && descends < parent && parent < resp);
}
#[test]
fn render_omits_descent_and_parent_when_none_and_for_product() {
let tech = tech_spec(1);
let out = render(&tech, "p\n", &[], &[]);
assert!(!out.contains("descends from:"));
assert!(!out.contains("\nparent:"));
let product = Spec {
kind: SpecSubtype::Product,
descends_from: Some("PRD-001".to_string()),
parent: None,
..tech_spec(1)
};
let pout = render(&product, "p\n", &[], &[]);
assert!(!pout.contains("descends from:"));
assert!(!pout.contains("parent:"));
}
#[test]
fn render_emits_product_level_and_parent_for_product_in_order() {
let spec = Spec {
kind: SpecSubtype::Product,
product_level: Some(ProductLevel::Capability),
parent: Some("PRD-003".to_string()),
..tech_spec(1)
};
let out = render(&spec, "p\n", &[], &[]);
assert!(out.contains("product level: capability\n"));
assert!(out.contains("parent: PRD-003\n"));
assert!(!out.contains("children"));
let level = out.find("product level:").unwrap();
let parent = out.find("parent:").unwrap();
assert!(level < parent);
}
#[test]
fn render_omits_c4_level_on_a_product_spec() {
let spec = Spec {
kind: SpecSubtype::Product,
c4_level: Some(C4Level::Container),
..tech_spec(1)
};
let out = render(&spec, "p\n", &[], &[]);
assert!(!out.contains("c4 level:"));
}
#[test]
fn build_registry_canonicalizes_member_and_interaction_fks() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "a", "Spec A"); fresh(root, SpecSubtype::Tech, "b", "Spec B"); requirement::reserve(root, "x", "X", "2026-06-05").unwrap();
let members_path = root.join(".doctrine/spec/tech/001/members.toml");
append_member(&members_path, "REQ-1", "FR-001", 1).unwrap();
let ix_path = root.join(".doctrine/spec/tech/001/interactions.toml");
let seeded = fs::read_to_string(&ix_path).unwrap();
fs::write(
&ix_path,
format!("{seeded}\n[[edge]]\ntarget = \"SPEC-2\"\ntype = \"calls\"\n"),
)
.unwrap();
let reg = build_registry(root).unwrap();
let member_edge = reg
.members
.iter()
.find(|m| m.spec == "SPEC-001")
.expect("member edge for SPEC-001");
assert_eq!(
member_edge.requirement, "REQ-001",
"non-canonical REQ-1 must be canonicalized to REQ-001"
);
let ix_edge = reg
.interactions
.iter()
.find(|e| e.spec == "SPEC-001")
.expect("interaction edge for SPEC-001");
assert_eq!(
ix_edge.target, "SPEC-002",
"non-canonical SPEC-2 must be canonicalized to SPEC-002"
);
let findings = reg.validate(None);
assert!(
findings.is_empty(),
"non-canonical-but-valid FKs must not produce dangling findings: {findings:?}"
);
}
fn append_spec_fields(path: &Path, lines: &str) {
let seeded = fs::read_to_string(path).unwrap();
fs::write(path, format!("{seeded}\n{lines}\n")).unwrap();
}
#[test]
fn build_registry_harvests_product_set_and_relational_edges() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "auth", "Auth"); fresh(root, SpecSubtype::Tech, "store", "Store"); fresh(root, SpecSubtype::Product, "login", "Login"); append_spec_fields(
&root.join(".doctrine/spec/tech/001/spec-001.toml"),
"descends_from = \"PRD-001\"\nparent = \"SPEC-002\"",
);
let reg = build_registry(root).unwrap();
assert!(
reg.product_specs.contains("PRD-001"),
"product id is collected into product_specs"
);
let descent = reg
.descents
.iter()
.find(|e| e.spec == "SPEC-001")
.expect("descent edge for SPEC-001");
assert_eq!(descent.target, "PRD-001");
assert!(!descent.on_product, "tech subject → on_product false");
let parent = reg
.parents
.iter()
.find(|e| e.spec == "SPEC-001")
.expect("parent edge for SPEC-001");
assert_eq!(parent.parent, "SPEC-002");
assert!(!parent.on_product);
assert!(
reg.validate(None).is_empty(),
"well-formed spine produces no findings: {:?}",
reg.validate(None)
);
}
#[test]
fn build_registry_surfaces_a_malformed_spec_toml() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "auth", "Auth"); let spec_toml = root.join(".doctrine/spec/tech/001/spec-001.toml");
fs::write(&spec_toml, "this is not = = valid toml").unwrap();
let result = build_registry(root);
assert!(result.is_err(), "malformed spec toml must fail the build");
let err = result.err().unwrap();
assert!(
err.to_string().contains("Failed to parse"),
"malformed spec toml surfaces as a parse error: {err}"
);
}
fn assert_second_parent_end_to_end(parent_lines: &str) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "auth", "Auth"); append_spec_fields(
&root.join(".doctrine/spec/tech/001/spec-001.toml"),
parent_lines,
);
let reg = build_registry(root).unwrap();
let findings = reg.validate(None);
assert!(
findings
.iter()
.any(|f| f.contains("second parent") && f.contains("SPEC-001")),
"validate surfaces the named second-parent finding: {findings:?}"
);
assert!(
run_validate(Some(root.to_path_buf()), None).is_err(),
"run_validate exits non-zero on a second-parent corpus"
);
}
#[test]
fn second_parent_duplicate_key_surfaces_end_to_end() {
assert_second_parent_end_to_end("parent = \"SPEC-002\"\nparent = \"SPEC-003\"");
}
#[test]
fn second_parent_array_value_surfaces_end_to_end() {
assert_second_parent_end_to_end("parent = [\"SPEC-002\", \"SPEC-003\"]");
}
#[test]
fn scaffold_commented_parent_does_not_trip_second_parent() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "auth", "Auth");
let reg = build_registry(root).unwrap();
assert!(
reg.build_findings.is_empty(),
"commented # parent is not a finding"
);
assert!(
reg.validate(None).is_empty(),
"a clean scaffolded corpus has no findings: {:?}",
reg.validate(None)
);
}
fn spec_toml(root: &Path, subtype: SpecSubtype, id: u32) -> PathBuf {
root.join(subtype.kind().dir)
.join(format!("{id:03}"))
.join(format!("spec-{id:03}.toml"))
}
fn append_interaction(root: &Path, tech_id: u32, target: &str) {
let p = root
.join(SpecSubtype::Tech.kind().dir)
.join(format!("{tech_id:03}"))
.join("interactions.toml");
let seeded = fs::read_to_string(&p).unwrap();
fs::write(
&p,
format!("{seeded}\n[[edge]]\ntarget = \"{target}\"\ntype = \"uses\"\n"),
)
.unwrap();
}
fn assert_validate_flags(build: impl Fn(&Path), expect_substr: &str) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
build(root);
let findings = build_registry(root).unwrap().validate(None);
assert!(
findings.iter().any(|f| f.contains(expect_substr)),
"expected a finding containing {expect_substr:?}, got {findings:?}"
);
assert!(
run_validate(Some(root.to_path_buf()), None).is_err(),
"run_validate exits non-zero on a {expect_substr:?} corpus"
);
}
#[test]
fn sweep_descent_dangling() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Tech, "auth", "Auth"); append_spec_fields(
&spec_toml(root, SpecSubtype::Tech, 1),
"descends_from = \"PRD-099\"", );
},
"dangling descent:",
);
}
#[test]
fn sweep_descent_invalid_kind_tech_target() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Tech, "auth", "Auth"); fresh(root, SpecSubtype::Tech, "store", "Store"); append_spec_fields(
&spec_toml(root, SpecSubtype::Tech, 1),
"descends_from = \"SPEC-002\"", );
},
"which is a tech spec (must be product)",
);
}
#[test]
fn sweep_descent_on_product_subject() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Product, "login", "Login"); append_spec_fields(
&spec_toml(root, SpecSubtype::Product, 1),
"descends_from = \"PRD-002\"", );
},
"invalid descent: descends_from on product",
);
}
#[test]
fn sweep_parent_dangling() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Tech, "auth", "Auth"); append_spec_fields(
&spec_toml(root, SpecSubtype::Tech, 1),
"parent = \"SPEC-099\"", );
},
"dangling parent:",
);
}
#[test]
fn sweep_parent_invalid_kind_product_target() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Tech, "auth", "Auth"); fresh(root, SpecSubtype::Product, "login", "Login"); append_spec_fields(
&spec_toml(root, SpecSubtype::Tech, 1),
"parent = \"PRD-001\"", );
},
"is a product spec (must be tech)",
);
}
#[test]
fn sweep_parent_product_to_tech_is_invalid_kind() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Tech, "auth", "Auth"); fresh(root, SpecSubtype::Product, "login", "Login"); append_spec_fields(
&spec_toml(root, SpecSubtype::Product, 1),
"parent = \"SPEC-001\"", );
},
"is a tech spec (must be product)",
);
}
#[test]
fn sweep_self_parent() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Tech, "auth", "Auth"); append_spec_fields(
&spec_toml(root, SpecSubtype::Tech, 1),
"parent = \"SPEC-001\"", );
},
"names itself as parent",
);
}
#[test]
fn sweep_parent_cycle() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Tech, "auth", "Auth"); fresh(root, SpecSubtype::Tech, "store", "Store"); append_spec_fields(
&spec_toml(root, SpecSubtype::Tech, 1),
"parent = \"SPEC-002\"",
);
append_spec_fields(
&spec_toml(root, SpecSubtype::Tech, 2),
"parent = \"SPEC-001\"",
);
},
"parent cycle:",
);
}
#[test]
fn sweep_parent_product_to_product_is_clean() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Product, "login", "Login"); fresh(root, SpecSubtype::Product, "accounts", "Accounts"); append_spec_fields(
&spec_toml(root, SpecSubtype::Product, 1),
"parent = \"PRD-002\"",
);
assert!(
build_registry(root).unwrap().validate(None).is_empty(),
"a well-formed product spine produces no findings"
);
assert!(
run_validate(Some(root.to_path_buf()), None).is_ok(),
"run_validate exits zero on a clean product spine"
);
}
#[test]
fn sweep_parent_product_dangling() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Product, "login", "Login"); append_spec_fields(
&spec_toml(root, SpecSubtype::Product, 1),
"parent = \"PRD-099\"", );
},
"dangling parent:",
);
}
#[test]
fn sweep_self_parent_product() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Product, "login", "Login"); append_spec_fields(
&spec_toml(root, SpecSubtype::Product, 1),
"parent = \"PRD-001\"", );
},
"names itself as parent",
);
}
#[test]
fn sweep_parent_cycle_product() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Product, "login", "Login"); fresh(root, SpecSubtype::Product, "accounts", "Accounts"); append_spec_fields(
&spec_toml(root, SpecSubtype::Product, 1),
"parent = \"PRD-002\"",
);
append_spec_fields(
&spec_toml(root, SpecSubtype::Product, 2),
"parent = \"PRD-001\"",
);
},
"parent cycle:",
);
}
#[test]
fn sweep_interaction_dangling() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Tech, "auth", "Auth"); append_interaction(root, 1, "SPEC-099"); },
"dangling interaction target:",
);
}
#[test]
fn sweep_interaction_invalid_kind_product_target() {
assert_validate_flags(
|root| {
fresh(root, SpecSubtype::Tech, "auth", "Auth"); fresh(root, SpecSubtype::Product, "login", "Login"); append_interaction(root, 1, "PRD-001"); },
"is a product spec (must be tech)",
);
}
#[test]
fn sweep_clean_corpus_exits_zero() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "auth", "Auth"); fresh(root, SpecSubtype::Tech, "store", "Store"); fresh(root, SpecSubtype::Product, "login", "Login"); append_spec_fields(
&spec_toml(root, SpecSubtype::Tech, 1),
"descends_from = \"PRD-001\"\nparent = \"SPEC-002\"",
);
append_interaction(root, 1, "SPEC-002");
assert!(
build_registry(root).unwrap().validate(None).is_empty(),
"a well-formed spine produces no findings"
);
assert!(
run_validate(Some(root.to_path_buf()), None).is_ok(),
"run_validate exits zero on a clean corpus"
);
}
#[test]
fn read_interactions_parses_edges_and_tolerates_absence() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI");
let ipath = root.join(".doctrine/spec/tech/001/interactions.toml");
assert!(read_interactions(&ipath).unwrap().is_empty());
let seeded = fs::read_to_string(&ipath).unwrap();
fs::write(
&ipath,
format!("{seeded}\n[[edge]]\ntarget = \"SPEC-002\"\ntype = \"uses\"\nnotes = \"x\"\n"),
)
.unwrap();
let edges = read_interactions(&ipath).unwrap();
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].target, "SPEC-002");
assert_eq!(edges[0].kind, "uses");
fresh(root, SpecSubtype::Product, "onb", "Onboarding");
assert!(
read_interactions(&root.join(".doctrine/spec/product/001/interactions.toml"))
.unwrap()
.is_empty()
);
}
fn snapshot_tree(root: &Path) -> BTreeMap<PathBuf, String> {
let mut map = BTreeMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir).unwrap() {
let entry = entry.unwrap();
let p = entry.path();
let ft = entry.file_type().unwrap();
if ft.is_symlink() {
map.insert(
p.clone(),
format!("symlink->{}", fs::read_link(&p).unwrap().display()),
);
} else if ft.is_dir() {
stack.push(p);
} else {
map.insert(p.clone(), fs::read_to_string(&p).unwrap_or_default());
}
}
}
map
}
#[test]
fn render_is_pure_no_write() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI");
run_req_add(
Some(root.to_path_buf()),
"SPEC-001",
Some("Route".into()),
ReqKind::Functional,
None,
None,
)
.unwrap();
let before = snapshot_tree(&root.join(".doctrine"));
run_show(Some(root.to_path_buf()), "SPEC-001", Format::Table).unwrap();
let after = snapshot_tree(&root.join(".doctrine"));
assert_eq!(before, after, "spec show mutates nothing on disk");
assert!(
!after
.keys()
.any(|p| p.to_string_lossy().ends_with(".rendered.md")),
"no rendered file written"
);
}
fn member_a_requirement(
root: &Path,
spec_dir: &Path,
slug: &str,
title: &str,
kind: ReqKind,
status: ReqStatus,
label: &str,
order: u32,
) -> String {
let reserved = requirement::reserve(root, slug, title, "2026-06-05").unwrap();
let id = reserved.eid.numeric_id().unwrap();
requirement::set_kind(root, id, kind).unwrap();
requirement::set_status(root, id, status).unwrap();
let fk = requirement::canonical_id(id);
append_raw_member(spec_dir, &fk, label, order);
fk
}
#[test]
fn req_list_is_authored_only_no_observed_column() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI"); let spec_dir = root.join(".doctrine/spec/tech/001");
member_a_requirement(
root,
&spec_dir,
"route",
"Route",
ReqKind::Functional,
ReqStatus::Active,
"FR-001",
1,
);
let out = req_list_rows(root, "SPEC-001", ListArgs::default()).unwrap();
assert!(
out.starts_with("id"),
"authored columns head the table: {out}"
);
assert!(out.contains("label"));
assert!(out.contains("kind"));
assert!(out.contains("status"));
assert!(out.contains("REQ-001"), "the canonical FK: {out}");
assert!(out.contains("FR-001"), "the membership label: {out}");
assert!(out.contains("functional"), "authored kind: {out}");
assert!(out.contains("active"), "authored status: {out}");
for forbidden in ["observed", "verdict", "coverage", "verified"] {
assert!(
!out.contains(forbidden),
"no observed/verdict column (`{forbidden}`): {out}"
);
}
}
#[test]
fn req_list_dangling_member_degrades_and_continues() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI"); let spec_dir = root.join(".doctrine/spec/tech/001");
member_a_requirement(
root,
&spec_dir,
"route",
"Route",
ReqKind::Functional,
ReqStatus::Active,
"FR-001",
1,
);
append_raw_member(&spec_dir, "REQ-099", "FR-099", 2);
let out = req_list_rows(root, "SPEC-001", ListArgs::default()).unwrap();
assert!(out.contains("REQ-001"), "resolved row present: {out}");
assert!(out.contains("functional"));
assert!(out.contains("REQ-099"), "dangling row present: {out}");
assert!(out.contains("FR-099"), "dangling label present: {out}");
assert!(out.contains("load error"), "inline load-error note: {out}");
let json = req_list_rows(
root,
"SPEC-001",
ListArgs {
json: true,
..ListArgs::default()
},
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["kind"], "requirement");
let rows = v["rows"].as_array().unwrap();
let dangling = rows.iter().find(|r| r["id"] == "REQ-099").unwrap();
assert_eq!(dangling["dangling"], true);
assert!(
dangling["load_error"].is_string(),
"load_error surfaced: {json}"
);
assert!(
dangling.get("kind").is_none(),
"no kind on a dangling row: {json}"
);
}
#[test]
fn req_list_status_filter_never_drops_a_dangling_row() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI"); let spec_dir = root.join(".doctrine/spec/tech/001");
member_a_requirement(
root,
&spec_dir,
"route",
"Route",
ReqKind::Functional,
ReqStatus::Active,
"FR-001",
1,
);
append_raw_member(&spec_dir, "REQ-099", "FR-099", 2);
let out = req_list_rows(
root,
"SPEC-001",
ListArgs {
status: vec!["pending".into()],
..ListArgs::default()
},
)
.unwrap();
assert!(!out.contains("REQ-001"), "active row filtered out: {out}");
assert!(out.contains("REQ-099"), "dangling row retained: {out}");
}
#[test]
fn run_req_list_writes_the_roster() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI"); member_a_requirement(
root,
&root.join(".doctrine/spec/tech/001"),
"route",
"Route",
ReqKind::Functional,
ReqStatus::Active,
"FR-001",
1,
);
run_req_list(Some(root.to_path_buf()), "SPEC-001", ListArgs::default()).unwrap();
}
#[test]
fn req_list_status_and_columns_honoured_unknown_column_errors() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI"); let spec_dir = root.join(".doctrine/spec/tech/001");
member_a_requirement(
root,
&spec_dir,
"route",
"Route",
ReqKind::Functional,
ReqStatus::Active,
"FR-001",
1,
);
member_a_requirement(
root,
&spec_dir,
"store",
"Store",
ReqKind::Quality,
ReqStatus::Pending,
"NF-001",
2,
);
let filtered = req_list_rows(
root,
"SPEC-001",
ListArgs {
status: vec!["active".into()],
..ListArgs::default()
},
)
.unwrap();
assert!(filtered.contains("REQ-001"), "active kept: {filtered}");
assert!(!filtered.contains("REQ-002"), "pending dropped: {filtered}");
let projected = req_list_rows(
root,
"SPEC-001",
ListArgs {
columns: Some(vec!["id".into(), "label".into()]),
..ListArgs::default()
},
)
.unwrap();
assert!(projected.contains("REQ-001"));
assert!(projected.contains("FR-001"));
assert!(
!projected.contains("functional"),
"kind column dropped: {projected}"
);
let err = req_list_rows(
root,
"SPEC-001",
ListArgs {
columns: Some(vec!["bogus".into()]),
..ListArgs::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("bogus"), "names the bad column: {msg}");
assert!(msg.contains("id"), "lists the available set: {msg}");
assert!(msg.contains("status"), "lists the available set: {msg}");
}
#[test]
fn req_list_rejects_an_unknown_status_with_the_uniform_error() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fresh(root, SpecSubtype::Tech, "cli", "CLI"); member_a_requirement(
root,
&root.join(".doctrine/spec/tech/001"),
"route",
"Route",
ReqKind::Functional,
ReqStatus::Active,
"FR-001",
1,
);
let err = req_list_rows(
root,
"SPEC-001",
ListArgs {
status: vec!["bogus".into()],
..ListArgs::default()
},
)
.unwrap_err();
assert!(
err.to_string().contains("bogus"),
"names the bad value: {err}"
);
}
}