use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::entity::{self, Artifact, Fileset, Kind, ScaffoldCtx};
use crate::tomlfmt::toml_string;
const REQUIREMENT_DIR: &str = ".doctrine/requirement";
pub(crate) const REQUIREMENT_KIND: Kind = Kind {
dir: REQUIREMENT_DIR,
prefix: "REQ",
scaffold: requirement_scaffold,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ReqKind {
Functional,
Quality,
}
impl ReqKind {
pub(crate) const fn as_str(self) -> &'static str {
match self {
ReqKind::Functional => "functional",
ReqKind::Quality => "quality",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ReqStatus {
Pending,
InProgress,
Active,
Deprecated,
Retired,
Superseded,
}
impl ReqStatus {
pub(crate) const fn as_str(self) -> &'static str {
match self {
ReqStatus::Pending => "pending",
ReqStatus::InProgress => "in-progress",
ReqStatus::Active => "active",
ReqStatus::Deprecated => "deprecated",
ReqStatus::Retired => "retired",
ReqStatus::Superseded => "superseded",
}
}
}
pub(crate) const REQ_STATUSES: &[&str] = &[
"pending",
"in-progress",
"active",
"deprecated",
"retired",
"superseded",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum CoverageStatus {
Planned,
InProgress,
Verified,
Failed,
Blocked,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct Requirement {
pub(crate) id: u32,
pub(crate) title: String,
pub(crate) slug: String,
pub(crate) status: ReqStatus,
pub(crate) kind: ReqKind,
#[serde(default)]
pub(crate) description: Option<String>,
#[serde(default)]
pub(crate) tags: Vec<String>,
#[serde(default)]
pub(crate) acceptance_criteria: Vec<String>,
}
fn render_requirement_toml(id: u32, slug: &str, title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/requirement.toml")?
.replace("{{id}}", &id.to_string())
.replace("{{slug}}", &toml_string(slug))
.replace("{{title}}", &toml_string(title)))
}
fn render_requirement_md(canonical_id: &str, title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/requirement.md")?
.replace("{{ref}}", canonical_id)
.replace("{{title}}", title))
}
fn requirement_scaffold(ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
let id = ctx.id;
let name = format!("{id:03}");
Ok(vec![
Artifact::File {
rel_path: PathBuf::from(format!("{name}/requirement-{name}.toml")),
body: render_requirement_toml(id, ctx.slug, ctx.title)?,
},
Artifact::File {
rel_path: PathBuf::from(format!("{name}/requirement-{name}.md")),
body: render_requirement_md(ctx.canonical, ctx.title)?,
},
Artifact::Symlink {
rel_path: PathBuf::from(format!("{name}-{}", ctx.slug)),
target: name,
},
])
}
pub(crate) fn reserve(
root: &Path,
slug: &str,
title: &str,
date: &str,
) -> anyhow::Result<entity::Materialised> {
let trunk_ids = crate::git::trunk_entity_ids(root, REQUIREMENT_KIND.dir)?;
entity::materialise(
&REQUIREMENT_KIND,
&entity::LocalFs,
root,
&entity::MaterialiseRequest::Fresh,
&entity::Inputs { slug, title, date },
&trunk_ids,
)
}
pub(crate) fn canonical_id(id: u32) -> String {
format!("{}-{id:03}", REQUIREMENT_KIND.prefix)
}
pub(crate) fn canonicalize_fk(fk: &str) -> String {
id_from_fk(fk).map_or_else(|_| fk.to_string(), canonical_id)
}
pub(crate) fn tree_root(root: &Path) -> PathBuf {
root.join(REQUIREMENT_DIR)
}
pub(crate) fn id_from_fk(canonical_fk: &str) -> anyhow::Result<u32> {
let (prefix, num) = canonical_fk.rsplit_once('-').with_context(|| {
format!("`{canonical_fk}` is not a canonical requirement ref (expected REQ-NNN)")
})?;
anyhow::ensure!(
prefix == REQUIREMENT_KIND.prefix,
"unexpected requirement prefix `{prefix}` in `{canonical_fk}` (expected {})",
REQUIREMENT_KIND.prefix
);
num.parse()
.with_context(|| format!("`{num}` is not a numeric id in `{canonical_fk}`"))
}
pub(crate) fn load(root: &Path, canonical_fk: &str) -> anyhow::Result<Requirement> {
let id = id_from_fk(canonical_fk)?;
let name = format!("{id:03}");
let path = root
.join(REQUIREMENT_DIR)
.join(&name)
.join(format!("requirement-{name}.toml"));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("requirement {canonical_fk} not found at {}", path.display()))?;
toml::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))
}
pub(crate) fn set_kind(root: &Path, id: u32, kind: ReqKind) -> anyhow::Result<()> {
let name = format!("{id:03}");
let path = root
.join(REQUIREMENT_DIR)
.join(&name)
.join(format!("requirement-{name}.toml"));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("requirement {name} not found at {}", path.display()))?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", path.display()))?;
let table = doc.as_table_mut();
if !table.contains_key("kind") {
anyhow::bail!("malformed requirement {name}: missing `kind` (regenerate via the scaffold)");
}
table.insert("kind", toml_edit::value(kind.as_str()));
std::fs::write(&path, doc.to_string())
.with_context(|| format!("Failed to write {}", path.display()))
}
pub(crate) fn set_status(root: &Path, id: u32, status: ReqStatus) -> anyhow::Result<()> {
let name = format!("{id:03}");
let path = root
.join(REQUIREMENT_DIR)
.join(&name)
.join(format!("requirement-{name}.toml"));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("requirement {name} not found at {}", path.display()))?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", path.display()))?;
if doc.get("status").and_then(toml_edit::Item::as_str) == Some(status.as_str()) {
return Ok(());
}
let table = doc.as_table_mut();
if !table.contains_key("status") {
anyhow::bail!(
"malformed requirement {name}: missing `status` (regenerate via the scaffold)"
);
}
table.insert("status", toml_edit::value(status.as_str()));
std::fs::write(&path, doc.to_string())
.with_context(|| format!("Failed to write {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::{self, Inputs, LocalFs, MaterialiseRequest};
use crate::meta::Meta;
use std::fs;
use std::path::Path;
#[test]
fn render_requirement_toml_round_trips_to_metadata() {
let body = render_requirement_toml(7, "fast-boot", "Fast boot").unwrap();
let parsed: Meta = toml::from_str(&body).unwrap();
assert_eq!(
parsed,
Meta {
id: 7,
slug: "fast-boot".to_string(),
title: "Fast boot".to_string(),
status: "pending".to_string(),
}
);
let req: Requirement = toml::from_str(&body).unwrap();
assert_eq!(req.kind, ReqKind::Functional);
assert_eq!(req.status, ReqStatus::Pending);
assert!(!body.contains("{{"));
assert!(!body.contains("created"));
}
#[test]
fn req_statuses_matches_the_variants() {
let from_variants: Vec<&str> = [
ReqStatus::Pending,
ReqStatus::InProgress,
ReqStatus::Active,
ReqStatus::Deprecated,
ReqStatus::Retired,
ReqStatus::Superseded,
]
.iter()
.map(|s| s.as_str())
.collect();
assert_eq!(from_variants, REQ_STATUSES);
}
#[test]
fn render_requirement_toml_escapes_hostile_title_and_slug() {
let title = crate::tomlfmt::HOSTILE_TITLE;
let slug = crate::tomlfmt::HOSTILE_SLUG;
let body = render_requirement_toml(7, slug, title).unwrap();
let parsed: Meta = toml::from_str(&body).unwrap();
assert_eq!(parsed.slug, slug);
assert_eq!(parsed.title, title);
}
#[test]
fn render_requirement_md_substitutes_ref_and_title_without_frontmatter() {
let body = render_requirement_md("REQ-007", "Fast boot").unwrap();
assert!(body.starts_with("# REQ-007: Fast boot"));
assert!(!body.contains("{{ref}}"));
assert!(!body.contains("{{title}}"));
assert!(!body.starts_with("---"));
}
#[test]
fn requirement_scaffold_lays_out_toml_md() {
let ctx = ScaffoldCtx {
id: 7,
canonical: "REQ-007",
slug: "fast-boot",
title: "Fast boot",
date: "2026-06-05", };
let fileset = requirement_scaffold(&ctx).unwrap();
assert_eq!(fileset.len(), 3);
assert!(matches!(&fileset[0],
Artifact::File { rel_path, body }
if rel_path == Path::new("007/requirement-007.toml") && body.contains("status = \"pending\"")));
assert!(matches!(&fileset[1],
Artifact::File { rel_path, body }
if rel_path == Path::new("007/requirement-007.md") && body.contains("REQ-007: Fast boot")));
assert!(matches!(&fileset[2],
Artifact::Symlink { rel_path, target }
if rel_path == Path::new("007-fast-boot") && target == "007"));
}
#[test]
fn materialise_fresh_writes_the_tree_and_allocates_monotonically() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mk = |slug: &str, title: &str| {
entity::materialise(
&REQUIREMENT_KIND,
&LocalFs,
root,
&MaterialiseRequest::Fresh,
&Inputs {
slug,
title,
date: "2026-06-05",
},
&[],
)
.unwrap()
};
let first = mk("fast-boot", "Fast boot");
assert_eq!(first.eid.numeric_id(), Some(1));
let req = root.join(REQUIREMENT_DIR);
assert!(req.join("001/requirement-001.toml").is_file());
assert!(req.join("001/requirement-001.md").is_file());
assert_eq!(
fs::read_link(req.join("001-fast-boot")).unwrap(),
Path::new("001")
);
let second = mk("low-latency", "Low latency");
assert_eq!(second.eid.numeric_id(), Some(2));
assert!(req.join("002/requirement-002.toml").is_file());
let body = fs::read_to_string(req.join("001/requirement-001.md")).unwrap();
assert!(body.contains("REQ-001"));
}
#[test]
fn requirement_toml_parses_all_facets_and_into_meta() {
let body = "\
id = 3
slug = \"fast-boot\"
title = \"Fast boot\"
status = \"active\"
kind = \"quality\"
description = \"boot under 200ms\"
tags = [\"perf\", \"ux\"]
acceptance_criteria = [\"cold boot < 200ms\", \"warm boot < 50ms\"]
";
let req: Requirement = toml::from_str(body).unwrap();
assert_eq!(req.kind, ReqKind::Quality);
assert_eq!(req.status, ReqStatus::Active);
assert_eq!(req.description.as_deref(), Some("boot under 200ms"));
assert_eq!(req.tags, vec!["perf", "ux"]);
assert_eq!(req.acceptance_criteria.len(), 2);
let m: Meta = toml::from_str(body).unwrap();
assert_eq!(m.title, "Fast boot");
assert_eq!(m.status, "active");
}
#[test]
fn requirement_toml_defaults_optional_facets() {
let body = "\
id = 1
slug = \"s\"
title = \"T\"
status = \"pending\"
kind = \"functional\"
";
let req: Requirement = toml::from_str(body).unwrap();
assert_eq!(req.description, None);
assert!(req.tags.is_empty());
assert!(req.acceptance_criteria.is_empty());
}
#[test]
fn reserve_then_set_kind_overwrites_seed_edit_preservingly() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let m = reserve(root, "fast-boot", "Fast boot", "2026-06-05").unwrap();
let id = m.eid.numeric_id().unwrap();
assert_eq!(id, 1);
assert_eq!(canonical_id(id), "REQ-001");
let toml = root.join(REQUIREMENT_DIR).join("001/requirement-001.toml");
assert!(
fs::read_to_string(&toml)
.unwrap()
.contains("kind = \"functional\"")
);
set_kind(root, id, ReqKind::Quality).unwrap();
let body = fs::read_to_string(&toml).unwrap();
assert!(body.contains("kind = \"quality\""));
assert!(!body.contains("kind = \"functional\""));
assert!(body.contains("# description — optional"));
assert!(body.contains("acceptance_criteria = []"));
}
#[test]
fn load_reads_requirement_by_canonical_fk() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
reserve(root, "fast-boot", "Fast boot", "2026-06-05").unwrap();
set_kind(root, 1, ReqKind::Quality).unwrap();
let req = load(root, "REQ-001").unwrap();
assert_eq!(req.id, 1);
assert_eq!(req.title, "Fast boot");
assert_eq!(req.slug, "fast-boot");
assert_eq!(req.kind, ReqKind::Quality);
assert!(load(root, "PRD-001").is_err());
assert!(load(root, "REQ-099").is_err());
assert!(load(root, "REQ-x").is_err());
assert!(load(root, "001").is_err());
}
#[test]
fn set_kind_on_a_malformed_requirement_missing_kind_errors() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let id = reserve(root, "s", "T", "2026-06-05")
.unwrap()
.eid
.numeric_id()
.unwrap();
let toml = root.join(REQUIREMENT_DIR).join("001/requirement-001.toml");
let stripped: String = fs::read_to_string(&toml)
.unwrap()
.lines()
.filter(|l| !l.trim_start().starts_with("kind ="))
.collect::<Vec<_>>()
.join("\n");
fs::write(&toml, stripped).unwrap();
assert!(set_kind(root, id, ReqKind::Quality).is_err());
}
fn reserve_with_extras(root: &Path) -> std::path::PathBuf {
let id = reserve(root, "fast-boot", "Fast boot", "2026-06-05")
.unwrap()
.eid
.numeric_id()
.unwrap();
let toml = root
.join(REQUIREMENT_DIR)
.join(format!("{id:03}/requirement-{id:03}.toml"));
let augmented = format!(
"{}\n# hand-added note\nfuture_key = \"survives\"\n\n[relationships]\nsupersedes = \"REQ-009\"\n",
fs::read_to_string(&toml).unwrap()
);
fs::write(&toml, augmented).unwrap();
toml
}
#[test]
fn set_status_round_trips_edit_preservingly_with_no_updated_stamp() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let toml = reserve_with_extras(root);
set_status(root, 1, ReqStatus::Active).unwrap();
let body = fs::read_to_string(&toml).unwrap();
assert!(body.contains("status = \"active\""));
assert!(!body.contains("status = \"pending\""));
assert!(body.contains("# hand-added note"));
assert!(body.contains("future_key = \"survives\""));
assert!(body.contains("[relationships]"));
assert!(body.contains("supersedes = \"REQ-009\""));
assert!(!body.contains("updated"));
assert!(!body.contains("created"));
}
#[test]
fn set_status_is_free_any_to_any_backward_and_same_state() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let toml = reserve_with_extras(root);
set_status(root, 1, ReqStatus::Active).unwrap();
set_status(root, 1, ReqStatus::Pending).unwrap();
assert!(
fs::read_to_string(&toml)
.unwrap()
.contains("status = \"pending\"")
);
set_status(root, 1, ReqStatus::Retired).unwrap();
set_status(root, 1, ReqStatus::Active).unwrap();
assert!(
fs::read_to_string(&toml)
.unwrap()
.contains("status = \"active\"")
);
set_status(root, 1, ReqStatus::Active).unwrap();
assert!(
fs::read_to_string(&toml)
.unwrap()
.contains("status = \"active\"")
);
}
#[test]
fn set_status_no_op_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let toml = reserve_with_extras(root);
let before = fs::read_to_string(&toml).unwrap();
set_status(root, 1, ReqStatus::Pending).unwrap(); assert_eq!(fs::read_to_string(&toml).unwrap(), before);
}
#[test]
fn set_status_on_unknown_id_errors() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
assert!(set_status(root, 99, ReqStatus::Active).is_err());
}
#[test]
fn set_status_on_malformed_requirement_missing_status_refuses() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let toml = reserve_with_extras(root);
let stripped: String = fs::read_to_string(&toml)
.unwrap()
.lines()
.filter(|l| !l.trim_start().starts_with("status ="))
.collect::<Vec<_>>()
.join("\n");
fs::write(&toml, stripped).unwrap();
assert!(set_status(root, 1, ReqStatus::Active).is_err());
}
#[test]
fn id_from_fk_rejects_slug_and_wrong_prefix() {
assert_eq!(id_from_fk("REQ-007").unwrap(), 7);
assert_eq!(id_from_fk("REQ-1").unwrap(), 1);
assert!(id_from_fk("REQ-fast-boot").is_err()); assert!(id_from_fk("PRD-001").is_err()); assert!(id_from_fk("007").is_err()); }
#[test]
fn canonicalize_fk_normalises_and_passes_through_garbage() {
assert_eq!(canonicalize_fk("REQ-1"), "REQ-001");
assert_eq!(canonicalize_fk("REQ-007"), "REQ-007");
assert_eq!(canonicalize_fk("garbage"), "garbage");
assert_eq!(canonicalize_fk("REQ-x"), "REQ-x");
assert_eq!(canonicalize_fk("PRD-001"), "PRD-001");
}
#[test]
fn requirement_toml_is_edit_preserving_through_toml_edit() {
let body = "\
id = 1
slug = \"s\"
title = \"T\" # hand-added note
status = \"pending\"
kind = \"functional\"
future_key = \"survives\"
";
let doc = body.parse::<toml_edit::DocumentMut>().unwrap();
let rewritten = doc.to_string();
assert!(rewritten.contains("# hand-added note"));
assert!(rewritten.contains("future_key = \"survives\""));
assert!(toml::from_str::<Requirement>(&rewritten).is_ok());
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct StatusProbe<T> {
status: T,
}
fn status_round_trips<T>(value: T, kebab: &str)
where
T: std::fmt::Debug + PartialEq + Copy + Serialize + serde::de::DeserializeOwned,
{
let body = toml::to_string(&StatusProbe { status: value }).unwrap();
assert!(
body.contains(&format!("status = \"{kebab}\"")),
"expected kebab `{kebab}` in: {body}"
);
let back: StatusProbe<T> = toml::from_str(&body).unwrap();
assert_eq!(back, StatusProbe { status: value });
}
#[test]
fn req_status_new_variants_serde_round_trip_and_render() {
status_round_trips(ReqStatus::InProgress, "in-progress");
status_round_trips(ReqStatus::Retired, "retired");
assert_eq!(ReqStatus::InProgress.as_str(), "in-progress");
assert_eq!(ReqStatus::Retired.as_str(), "retired");
for s in [
ReqStatus::Pending,
ReqStatus::InProgress,
ReqStatus::Active,
ReqStatus::Deprecated,
ReqStatus::Retired,
ReqStatus::Superseded,
] {
status_round_trips(s, s.as_str());
}
}
#[test]
fn coverage_status_serde_round_trips_all_five_variants() {
status_round_trips(CoverageStatus::Planned, "planned");
status_round_trips(CoverageStatus::InProgress, "in-progress");
status_round_trips(CoverageStatus::Verified, "verified");
status_round_trips(CoverageStatus::Failed, "failed");
status_round_trips(CoverageStatus::Blocked, "blocked");
}
}