use std::path::Path;
use crate::entity;
use crate::integrity;
use crate::listing;
use crate::relation::RelationEdge;
pub(crate) fn outbound_for(
root: &Path,
kind: &entity::Kind,
id: u32,
) -> anyhow::Result<Vec<RelationEdge>> {
match kind.prefix {
"SL" => crate::slice::relation_edges(root, id),
"ADR" => crate::governance::relation_edges(&crate::adr::ADR_KIND, root, id),
"POL" => crate::governance::relation_edges(&crate::policy::POLICY_KIND, root, id),
"STD" => crate::governance::relation_edges(&crate::standard::STANDARD_KIND, root, id),
"PRD" => crate::spec::relation_edges(crate::spec::SpecSubtype::Product, root, id),
"SPEC" => crate::spec::relation_edges(crate::spec::SpecSubtype::Tech, root, id),
"REQ" | "CM" => Ok(Vec::new()),
#[expect(
clippy::match_same_arms,
reason = "SL-059 L7: distinct from the REQ arm — Slice B replaces this empty body with knowledge::relation_edges; REQ stays empty forever"
)]
"ASM" | "DEC" | "QUE" | "CON" => Ok(Vec::new()),
"RV" => crate::review::relation_edges(root, id),
"REC" => crate::rec::relation_edges(root, id),
"REV" => crate::revision::relation_edges(root, id),
other => {
if let Some(item_kind) = crate::backlog::kind_from_prefix(other) {
crate::backlog::relation_edges(root, item_kind, id)
} else {
debug_assert!(false, "outbound_for: unrouted KINDS prefix `{other}`");
Ok(Vec::new())
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
pub(crate) struct EntityKey {
pub(crate) prefix: &'static str,
pub(crate) id: u32,
}
impl EntityKey {
pub(crate) fn canonical(self) -> String {
listing::canonical_id(self.prefix, self.id)
}
}
pub(crate) struct ScannedEntity {
pub(crate) key: EntityKey,
pub(crate) kind: &'static entity::Kind,
pub(crate) status: Option<String>,
pub(crate) title: String,
pub(crate) outbound: Vec<RelationEdge>,
}
pub(crate) fn scan_entities(root: &Path) -> anyhow::Result<Vec<ScannedEntity>> {
let mut out = Vec::new();
for kref in integrity::KINDS {
let prefix = kref.kind.prefix;
let mut ids = entity::scan_ids(&root.join(kref.kind.dir))?;
ids.sort_unstable();
for id in ids {
let (status, title) = status_and_title_for(root, kref, id)?;
out.push(ScannedEntity {
key: EntityKey { prefix, id },
kind: kref.kind,
status,
title,
outbound: outbound_for(root, kref.kind, id)?,
});
}
}
Ok(out)
}
fn status_and_title_for(
root: &Path,
kref: &integrity::KindRef,
id: u32,
) -> anyhow::Result<(Option<String>, String)> {
match kref.kind.prefix {
"REC" => Ok((None, title_for(root, kref, id)?)),
"RV" => Ok((
Some(crate::review::derived_status_string(root, id)?),
title_for(root, kref, id)?,
)),
_ => {
let tree_root = root.join(kref.kind.dir);
let m = crate::meta::read_meta(&tree_root, kref.stem, id)?;
Ok((Some(m.status), m.title))
}
}
}
fn title_for(root: &Path, kref: &integrity::KindRef, id: u32) -> anyhow::Result<String> {
#[derive(serde::Deserialize)]
struct TitleOnly {
title: String,
}
let name = format!("{id:03}");
let path = root
.join(kref.kind.dir)
.join(&name)
.join(format!("{}-{name}.toml", kref.stem));
let text = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("read {} for title: {e}", path.display()))?;
let parsed: TitleOnly = toml::from_str(&text)
.map_err(|e| anyhow::anyhow!("parse title from {}: {e}", path.display()))?;
Ok(parsed.title)
}
#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
mod tests {
use super::*;
use crate::catalog::test_helpers::*;
fn seed_fixture(root: &Path) {
seed_slice(root, 1, &[("requirements", &["REQ-005"])]);
seed_slice(root, 3, &[]);
seed_adr(root, 2, &["ADR-001"]);
seed_requirement(root, 5);
}
fn canonical_keys(entities: &[ScannedEntity]) -> Vec<String> {
entities.iter().map(|e| e.key.canonical()).collect()
}
#[test]
fn scan_order_follows_kinds_table_then_id_ascending() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 3, &[]);
seed_slice(root, 1, &[]);
seed_adr(root, 2, &[]);
let scanned = scan_entities(root).unwrap();
let keys = canonical_keys(&scanned);
assert_eq!(
keys,
vec!["SL-001", "SL-003", "ADR-002"],
"scan order must be KINDS-table order, ids ascending per kind"
);
}
#[test]
fn scan_entity_shape_matches_expected() {
let dir = tmp();
let root = dir.path();
seed_fixture(root);
let scanned = scan_entities(root).unwrap();
let sl001 = &scanned[0];
assert_eq!(sl001.key.canonical(), "SL-001");
assert_eq!(sl001.key.prefix, "SL");
assert_eq!(sl001.kind.prefix, "SL");
assert_eq!(sl001.status.as_deref(), Some("proposed"));
assert_eq!(sl001.title, "S1");
assert_eq!(sl001.outbound.len(), 1);
assert_eq!(
sl001.outbound[0].label,
crate::relation::RelationLabel::Requirements
);
assert_eq!(sl001.outbound[0].target, "REQ-005");
let sl003 = &scanned[1];
assert_eq!(sl003.key.canonical(), "SL-003");
assert_eq!(sl003.kind.prefix, "SL");
assert_eq!(sl003.status.as_deref(), Some("proposed"));
assert_eq!(sl003.title, "S3");
assert!(sl003.outbound.is_empty());
let adr002 = &scanned[2];
assert_eq!(adr002.key.canonical(), "ADR-002");
assert_eq!(adr002.kind.prefix, "ADR");
assert_eq!(adr002.status.as_deref(), Some("accepted"));
assert_eq!(adr002.title, "A2");
assert_eq!(adr002.outbound.len(), 1);
assert_eq!(
adr002.outbound[0].label,
crate::relation::RelationLabel::Supersedes
);
assert_eq!(adr002.outbound[0].target, "ADR-001");
let req005 = &scanned[3];
assert_eq!(req005.key.canonical(), "REQ-005");
assert_eq!(req005.kind.prefix, "REQ");
assert_eq!(req005.status.as_deref(), Some("active"));
assert_eq!(req005.title, "R5");
assert!(req005.outbound.is_empty());
}
#[test]
fn priority_graph_node_set_matches_scanned() {
let dir = tmp();
let root = dir.path();
seed_fixture(root);
let pg = crate::priority::graph::build(root).unwrap();
let scanned = scan_entities(root).unwrap();
assert_eq!(pg.attrs.len(), scanned.len());
let scanned_keys: std::collections::BTreeSet<EntityKey> =
scanned.iter().map(|e| e.key).collect();
for k in &scanned_keys {
assert!(
pg.projection.resolve(*k).is_some(),
"{} must resolve in the priority graph",
k.canonical()
);
}
let _ = pg.dep_overlay;
let _ = pg.seq_overlay;
let sl001_node = pg.projection.resolve(scanned[0].key).unwrap();
let sl001_out: usize = [pg.dep_overlay, pg.seq_overlay]
.iter()
.map(|&ov| pg.graph.out_edges(ov, sl001_node).count())
.sum();
let _ = sl001_out;
}
#[test]
fn validate_reports_dangling_edge_and_ignores_free_text() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[("requirements", &["REQ-999"])]);
write(
root,
".doctrine/backlog/issue/001/backlog-001.toml",
"schema = \"doctrine.backlog\"\nversion = 1\n\
id = 1\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\ntags = []\n\
[[relation]]\nlabel = \"drift\"\ntarget = \"loose talk\"\n",
);
write(root, ".doctrine/backlog/issue/001/backlog-001.md", "i\n");
let findings = crate::relation_graph::validate_relations(root).unwrap();
let joined = findings.join("\n");
assert!(
joined.contains("SL-001") && joined.contains("REQ-999") && joined.contains("dangling"),
"dangling REQ-999 must be reported: {joined}"
);
assert!(
!joined.contains("loose talk"),
"Unvalidated drift target must not be reported: {joined}"
);
}
}