use std::collections::BTreeMap;
use std::path::Path;
use crate::entity;
use crate::integrity;
use crate::listing;
use crate::relation::RelationEdge;
use super::diagnostic::{CatalogDiagnostic, Severity};
use super::hydrate::CatalogKey;
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,
diagnostics: &mut Vec<CatalogDiagnostic>,
) -> 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) = match status_and_title_for(root, kref, id) {
Ok(v) => v,
Err(e) => {
diagnostics.push(CatalogDiagnostic {
file: root.join(kref.kind.dir).join(format!("{id:03}")),
entity_key: Some(CatalogKey::Numbered(EntityKey { prefix, id })),
field: None,
message: format!("failed to read {prefix}-{id:03}: {e}"),
severity: Severity::Error,
});
continue;
}
};
let outbound = match outbound_for(root, kref.kind, id) {
Ok(v) => v,
Err(e) => {
diagnostics.push(CatalogDiagnostic {
file: root.join(kref.kind.dir).join(format!("{id:03}")),
entity_key: Some(CatalogKey::Numbered(EntityKey { prefix, id })),
field: None,
message: format!("failed to read relations for {prefix}-{id:03}: {e}"),
severity: Severity::Error,
});
continue;
}
};
out.push(ScannedEntity {
key: EntityKey { prefix, id },
kind: kref.kind,
status,
title,
outbound,
});
}
}
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)
}
pub(crate) fn scan_memory_entities(
root: &Path,
diagnostics: &mut Vec<CatalogDiagnostic>,
) -> anyhow::Result<Vec<crate::memory::MemoryCatalogRecord>> {
use crate::memory::{MEMORY_ITEMS_DIR, MEMORY_SHIPPED_DIR};
let mut records: BTreeMap<String, crate::memory::MemoryCatalogRecord> = BTreeMap::new();
for (dir, fail_on_error) in [(MEMORY_SHIPPED_DIR, false), (MEMORY_ITEMS_DIR, true)] {
let base = root.join(dir);
let names = match entity::scan_named(&base) {
Ok(n) => n,
Err(_) if !fail_on_error => continue,
Err(e) => return Err(e),
};
for name in &names {
let toml_path = base.join(name).join("memory.toml");
match crate::memory::read_catalog_record(&toml_path) {
Ok(rec) => {
if rec.uid != *name {
diagnostics.push(CatalogDiagnostic {
file: toml_path,
entity_key: Some(CatalogKey::Memory(name.clone())),
field: None,
message: format!(
"memory_uid {} does not match directory name {}",
rec.uid, name
),
severity: Severity::Error,
});
continue;
}
records.insert(rec.uid.clone(), rec);
}
Err(e) => {
diagnostics.push(CatalogDiagnostic {
file: toml_path,
entity_key: Some(CatalogKey::Memory(name.clone())),
field: None,
message: format!("failed to read memory record: {e}"),
severity: Severity::Error,
});
}
}
}
}
Ok(records.into_values().collect())
}
#[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, &mut vec![]).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, &mut vec![]).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, &mut vec![]).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}"
);
}
fn seed_memory(root: &Path, tree: &str, dir: &str, body: &str) -> std::path::PathBuf {
let dir_path = root.join(".doctrine/memory").join(tree).join(dir);
std::fs::create_dir_all(&dir_path).unwrap();
let toml_path = dir_path.join("memory.toml");
std::fs::write(&toml_path, body).unwrap();
toml_path
}
#[test]
fn scan_memory_entities_valid_item_record_returned() {
let dir = tmp();
let root = dir.path();
seed_memory(
root,
"items",
"mem_11111111112222222222333333333344",
"memory_uid = \"mem_11111111112222222222333333333344\"\n\
memory_type = \"concept\"\n\
status = \"active\"\n\
title = \"Test Memory\"\n",
);
let mut diags = Vec::new();
let records = scan_memory_entities(root, &mut diags).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].uid, "mem_11111111112222222222333333333344");
assert_eq!(records[0].title, "Test Memory");
assert_eq!(records[0].status, "active");
assert_eq!(records[0].memory_type, "concept");
assert!(diags.is_empty());
}
#[test]
fn scan_memory_entities_items_overrides_shipped() {
let dir = tmp();
let root = dir.path();
let uid = "mem_11111111112222222222333333333344";
seed_memory(
root,
"shipped",
uid,
&format!(
"memory_uid = \"{uid}\"\n\
memory_type = \"concept\"\n\
status = \"draft\"\n\
title = \"Shipped Version\"\n"
),
);
seed_memory(
root,
"items",
uid,
&format!(
"memory_uid = \"{uid}\"\n\
memory_type = \"concept\"\n\
status = \"active\"\n\
title = \"Items Version\"\n"
),
);
let mut diags = Vec::new();
let records = scan_memory_entities(root, &mut diags).unwrap();
assert_eq!(records.len(), 1, "items should override shipped");
assert_eq!(records[0].title, "Items Version");
assert!(diags.is_empty());
}
#[test]
fn scan_memory_entities_uid_dirname_mismatch_diagnostic() {
let dir = tmp();
let root = dir.path();
let dirname = "mem_11111111112222222222333333333344";
let wrong_uid = "mem_aaaaaaaaaabbbbbbbbbbcccccccccccc";
seed_memory(
root,
"items",
dirname,
&format!(
"memory_uid = \"{wrong_uid}\"\n\
memory_type = \"concept\"\n\
status = \"active\"\n\
title = \"Mismatched\"\n"
),
);
let mut diags = Vec::new();
let records = scan_memory_entities(root, &mut diags).unwrap();
assert!(records.is_empty());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Error);
assert!(diags[0].message.contains("does not match directory name"));
assert!(diags[0].message.contains(wrong_uid));
assert!(diags[0].message.contains(dirname));
assert_eq!(
diags[0].entity_key.as_ref().map(|k| k.canonical()),
Some(dirname.to_string())
);
}
#[test]
fn scan_memory_entities_malformed_toml_diagnostic() {
let dir = tmp();
let root = dir.path();
let uid = "mem_11111111112222222222333333333344";
seed_memory(root, "items", uid, "this is not valid toml at all[[[\n");
let mut diags = Vec::new();
let records = scan_memory_entities(root, &mut diags).unwrap();
assert!(records.is_empty());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Error);
assert!(diags[0].message.contains("failed to read memory record"));
assert_eq!(
diags[0].entity_key.as_ref().map(|k| k.canonical()),
Some(uid.to_string())
);
}
#[test]
fn scan_memory_entities_missing_shipped_ok_empty() {
let dir = tmp();
let root = dir.path();
let mut diags = Vec::new();
let records = scan_memory_entities(root, &mut diags).unwrap();
assert!(records.is_empty());
assert!(diags.is_empty());
}
#[test]
fn scan_memory_entities_empty_both_dirs_ok_empty() {
let dir = tmp();
let root = dir.path();
std::fs::create_dir_all(root.join(".doctrine/memory/shipped")).unwrap();
std::fs::create_dir_all(root.join(".doctrine/memory/items")).unwrap();
let mut diags = Vec::new();
let records = scan_memory_entities(root, &mut diags).unwrap();
assert!(records.is_empty());
assert!(diags.is_empty());
}
#[test]
fn scan_entities_skips_malformed_meta_and_emits_diagnostic() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
write(
root,
".doctrine/slice/002/slice-002.toml",
"id = notanumber\n",
);
write(root, ".doctrine/slice/002/slice-002.md", "scope\n");
let mut diags = Vec::new();
let scanned = scan_entities(root, &mut diags).unwrap();
assert_eq!(scanned.len(), 1);
assert_eq!(scanned[0].key.canonical(), "SL-001");
assert_eq!(scanned[0].title, "S1");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Error);
assert_eq!(
diags[0].entity_key.as_ref().map(|k| k.canonical()),
Some("SL-002".to_string())
);
assert!(diags[0].file.to_string_lossy().contains("002"));
assert!(diags[0].message.contains("SL-002"));
}
#[test]
fn scan_entities_skips_malformed_relations_and_emits_diagnostic() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
write(
root,
".doctrine/slice/002/slice-002.toml",
"id = 2\nslug = \"s2\"\ntitle = \"S2\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[[relation]]\nlabel = \"supersedes\"\n",
);
write(root, ".doctrine/slice/002/slice-002.md", "scope\n");
let mut diags = Vec::new();
let scanned = scan_entities(root, &mut diags).unwrap();
assert_eq!(scanned.len(), 1);
assert_eq!(scanned[0].key.canonical(), "SL-001");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Error);
assert_eq!(
diags[0].entity_key.as_ref().map(|k| k.canonical()),
Some("SL-002".to_string())
);
assert!(diags[0].message.contains("relations"));
}
#[test]
fn scan_entities_all_malformed_returns_empty_no_panic() {
let dir = tmp();
let root = dir.path();
for id in 1..=3u32 {
write(
root,
&format!(".doctrine/slice/{id:03}/slice-{id:03}.toml"),
"id = garbage\n",
);
write(
root,
&format!(".doctrine/slice/{id:03}/slice-{id:03}.md"),
"scope\n",
);
}
let mut diags = Vec::new();
let scanned = scan_entities(root, &mut diags).unwrap();
assert!(scanned.is_empty(), "no entities should be returned");
assert_eq!(diags.len(), 3, "one diagnostic per malformed entity");
for d in &diags {
assert_eq!(d.severity, Severity::Error);
}
}
#[test]
fn scan_entities_mixed_validity_returns_good_and_skips_bad() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
write(root, ".doctrine/slice/002/slice-002.toml", "id = bogus\n");
write(root, ".doctrine/slice/002/slice-002.md", "scope\n");
seed_slice(root, 3, &[]);
let mut diags = Vec::new();
let scanned = scan_entities(root, &mut diags).unwrap();
assert_eq!(scanned.len(), 2);
let keys: Vec<String> = scanned.iter().map(|e| e.key.canonical()).collect();
assert_eq!(keys, vec!["SL-001", "SL-003"]);
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].entity_key.as_ref().map(|k| k.canonical()),
Some("SL-002".to_string())
);
}
}