use std::collections::BTreeMap;
use std::path::Path;
use crate::entity::{self};
use crate::estimate::{self, EstimateFacet};
use crate::integrity;
use crate::listing;
use crate::relation::RelationEdge;
use crate::risk::{self, RiskFacet};
use crate::value::{self, ValueFacet};
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()),
"RV" => crate::review::relation_edges(root, id),
"REC" => crate::rec::relation_edges(root, id),
"REV" => crate::revision::relation_edges(root, id),
"RFC" => crate::governance::relation_edges(&crate::rfc::RFC_KIND, root, id),
other => {
if let Some(record_kind) = crate::knowledge::RecordKind::from_prefix(other) {
crate::knowledge::relation_edges(root, record_kind, id)
} else 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) estimate: Option<EstimateFacet>,
pub(crate) value: Option<ValueFacet>,
pub(crate) risk: Option<RiskFacet>,
pub(crate) tags: Vec<String>,
pub(crate) body: Option<String>,
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct ScanMode {
pub include_bodies: bool,
}
impl ScanMode {
pub(crate) const fn include_bodies() -> Self {
Self {
include_bodies: true,
}
}
}
pub(crate) fn scan_entities(
root: &Path,
diagnostics: &mut Vec<CatalogDiagnostic>,
mode: ScanMode,
) -> 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;
}
};
let (estimate, value, risk, tags) = read_facets(root, kref, id, diagnostics);
let body = if mode.include_bodies {
read_body(root, kref.kind, id, diagnostics)
} else {
None
};
out.push(ScannedEntity {
key: EntityKey { prefix, id },
kind: kref.kind,
status,
title,
outbound,
estimate,
value,
risk,
tags,
body,
});
}
}
Ok(out)
}
fn read_facets(
root: &Path,
kref: &integrity::KindRef,
id: u32,
diagnostics: &mut Vec<CatalogDiagnostic>,
) -> (
Option<EstimateFacet>,
Option<ValueFacet>,
Option<RiskFacet>,
Vec<String>,
) {
let path = entity::id_path(root, kref.kind, id, entity::Ext::Toml);
let Ok(text) = std::fs::read_to_string(&path) else {
return (None, None, None, Vec::new());
};
let Ok(table) = text.parse::<toml::Table>() else {
return (None, None, None, Vec::new());
};
let tags: Vec<String> = table
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let estimate = parse_facet(
"estimate",
table.get("estimate"),
estimate::parse_optional,
root,
kref,
id,
diagnostics,
);
let value = parse_facet(
"value",
table.get("value"),
value::parse_optional,
root,
kref,
id,
diagnostics,
);
let risk = parse_facet(
"facet",
table.get("facet"),
risk::parse_optional,
root,
kref,
id,
diagnostics,
);
(estimate, value, risk, tags)
}
fn read_body(
root: &Path,
kind: &'static entity::Kind,
id: u32,
diagnostics: &mut Vec<CatalogDiagnostic>,
) -> Option<String> {
let path = entity::id_path(root, kind, id, entity::Ext::Md);
match std::fs::read_to_string(&path) {
Ok(text) => {
let trimmed = text.trim();
if trimmed.is_empty() { None } else { Some(text) }
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
diagnostics.push(CatalogDiagnostic {
file: path,
entity_key: None,
field: None,
message: format!("failed to read body: {e}"),
severity: Severity::Error,
});
None
}
}
}
fn parse_facet<F, T>(
field: &str,
raw: Option<&toml::Value>,
parse: F,
root: &Path,
kref: &integrity::KindRef,
id: u32,
diagnostics: &mut Vec<CatalogDiagnostic>,
) -> Option<T>
where
F: FnOnce(Option<&toml::value::Table>) -> anyhow::Result<Option<T>>,
{
let mut push = |message: String| {
diagnostics.push(CatalogDiagnostic {
file: root.join(kref.kind.dir).join(format!("{id:03}")),
entity_key: Some(CatalogKey::Numbered(EntityKey {
prefix: kref.kind.prefix,
id,
})),
field: Some(field.to_string()),
message,
severity: Severity::Error,
});
};
match raw {
Some(v) if v.as_table().is_none() => {
push(format!("{field} must be a table"));
None
}
other => match parse(other.and_then(toml::Value::as_table)) {
Ok(facet) => facet,
Err(e) => {
push(e.to_string());
None
}
},
}
}
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.kind.stem, id, kref.kind.prefix)?;
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 path = entity::id_path(root, kref.kind, id, entity::Ext::Toml);
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>,
BTreeMap<String, String>,
)> {
use crate::memory::{MEMORY_ITEMS_DIR, MEMORY_SHIPPED_DIR};
let mut records: BTreeMap<String, crate::memory::MemoryCatalogRecord> = BTreeMap::new();
let mut key_map: BTreeMap<String, String> = 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;
}
if let Some(k) = &rec.key {
key_map.insert(k.clone(), rec.uid.clone());
}
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(), key_map))
}
#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
mod tests {
use super::*;
use crate::catalog::test_helpers::*;
use crate::test_support::SCHEMA_BACKLOG;
fn seed_fixture(root: &Path) {
seed_slice(root, 1, &[("references(implements)", &["REQ-005"])]);
seed_slice(root, 3, &[]);
seed_adr(root, 2, &[("supersedes", &["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![], ScanMode::default()).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![], ScanMode::default()).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::References
);
assert_eq!(
sl001.outbound[0].role,
Some(crate::relation::Role::Implements)
);
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![], ScanMode::default()).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, &[("references(implements)", &["REQ-999"])]);
write(
root,
".doctrine/backlog/issue/001/backlog-001.toml",
&format!(
"schema = \"{SCHEMA_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, _key_map) = 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, _key_map) = 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_key_map_includes_shipped_keys() {
let dir = tmp();
let root = dir.path();
let uid = "mem_11111111112222222222333333333344";
let key = "mem.signpost.doctrine.lifecycle-start";
seed_memory(
root,
"shipped",
uid,
&format!(
"memory_uid = \"{uid}\"\n\
memory_key = \"{key}\"\n\
memory_type = \"signpost\"\n\
status = \"active\"\n\
title = \"Shipped\"\n"
),
);
let mut diags = Vec::new();
let (_records, key_map) = scan_memory_entities(root, &mut diags).unwrap();
assert_eq!(key_map.get(key), Some(&uid.to_string()));
assert!(diags.is_empty());
}
#[test]
fn scan_memory_entities_key_map_items_override_shipped_on_collision() {
let dir = tmp();
let root = dir.path();
let key = "mem.signpost.doctrine.overview";
let shipped_uid = "mem_11111111112222222222333333333344";
let items_uid = "mem_aaaaaaaaaabbbbbbbbbbcccccccccccc";
for (tree, uid) in [("shipped", shipped_uid), ("items", items_uid)] {
seed_memory(
root,
tree,
uid,
&format!(
"memory_uid = \"{uid}\"\n\
memory_key = \"{key}\"\n\
memory_type = \"signpost\"\n\
status = \"active\"\n\
title = \"{tree}\"\n"
),
);
}
let mut diags = Vec::new();
let (_records, key_map) = scan_memory_entities(root, &mut diags).unwrap();
assert_eq!(
key_map.get(key),
Some(&items_uid.to_string()),
"local items/ key must override the shipped key"
);
}
#[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, _key_map) = 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, _key_map) = 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, _key_map) = 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, _key_map) = 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, ScanMode::default()).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, ScanMode::default()).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, ScanMode::default()).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, ScanMode::default()).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())
);
}
fn seed_slice_with_facets(root: &Path, id: u32, facets: &str) {
write(
root,
&format!(".doctrine/slice/{id:03}/slice-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"s{id}\"\ntitle = \"S{id}\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n{facets}"
),
);
write(
root,
&format!(".doctrine/slice/{id:03}/slice-{id:03}.md"),
"scope\n",
);
}
fn seed_adr_with_facets(root: &Path, id: u32, facets: &str) {
write(
root,
&format!(".doctrine/adr/{id:03}/adr-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"a{id}\"\ntitle = \"A{id}\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n{facets}"
),
);
write(
root,
&format!(".doctrine/adr/{id:03}/adr-{id:03}.md"),
"body\n",
);
}
#[test]
fn read_facets_reads_present_and_absent() {
let dir = tmp();
let root = dir.path();
seed_slice_with_facets(
root,
1,
"[estimate]\nlower = 2\nupper = 8\n\n[value]\nvalue = 5\n",
);
seed_slice(root, 2, &[]);
let mut diags = Vec::new();
let scanned = scan_entities(root, &mut diags, ScanMode::default()).unwrap();
let sl001 = &scanned[0];
assert_eq!(sl001.key.canonical(), "SL-001");
assert_eq!(
sl001.estimate,
Some(EstimateFacet {
lower: 2.0,
upper: 8.0
})
);
assert_eq!(sl001.value, Some(ValueFacet { value: 5.0 }));
let sl002 = &scanned[1];
assert_eq!(sl002.key.canonical(), "SL-002");
assert!(sl002.estimate.is_none());
assert!(sl002.value.is_none());
assert!(diags.is_empty(), "no diagnostics expected: {diags:?}");
}
#[test]
fn read_facets_isolates_malformed_estimate_from_valid_value() {
let dir = tmp();
let root = dir.path();
seed_adr_with_facets(
root,
1,
"[estimate]\nlower = 5\nupper = 2\n\n[value]\nvalue = 7\n",
);
let mut diags = Vec::new();
let scanned = scan_entities(root, &mut diags, ScanMode::default()).unwrap();
assert_eq!(scanned.len(), 1);
let adr = &scanned[0];
assert_eq!(adr.key.canonical(), "ADR-001");
assert!(adr.estimate.is_none(), "malformed estimate drops to None");
assert_eq!(
adr.value,
Some(ValueFacet { value: 7.0 }),
"sibling value facet stays intact"
);
assert_eq!(diags.len(), 1);
let d = &diags[0];
assert_eq!(d.severity, Severity::Error);
assert_eq!(d.field.as_deref(), Some("estimate"));
assert_eq!(
d.entity_key.as_ref().map(|k| k.canonical()),
Some("ADR-001".to_string())
);
assert!(
d.message.contains("upper must be >= lower"),
"leaf message verbatim: {}",
d.message
);
}
#[test]
fn read_facets_present_non_table_is_fail_loud() {
let dir = tmp();
let root = dir.path();
seed_adr_with_facets(root, 1, "estimate = 7\n");
let mut diags = Vec::new();
let scanned = scan_entities(root, &mut diags, ScanMode::default()).unwrap();
assert_eq!(scanned.len(), 1);
assert!(scanned[0].estimate.is_none());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].field.as_deref(), Some("estimate"));
assert!(
diags[0].message.contains("must be a table"),
"{}",
diags[0].message
);
}
#[test]
fn read_facets_is_kind_agnostic_reads_adr_estimate() {
let dir = tmp();
let root = dir.path();
write(
root,
".doctrine/adr/001/adr-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[estimate]\nlower = 1\nupper = 3\n",
);
write(root, ".doctrine/adr/001/adr-001.md", "body\n");
let mut diags = Vec::new();
let scanned = scan_entities(root, &mut diags, ScanMode::default()).unwrap();
let adr = scanned
.iter()
.find(|e| e.key.canonical() == "ADR-001")
.expect("ADR-001 scanned");
assert_eq!(
adr.estimate,
Some(EstimateFacet {
lower: 1.0,
upper: 3.0
}),
"estimate read off a non-slice kind"
);
assert!(diags.is_empty(), "no diagnostics: {diags:?}");
}
#[test]
fn read_facets_isolates_malformed_risk_facet_from_valid_estimate_and_value() {
let dir = tmp();
let root = dir.path();
seed_adr_with_facets(
root,
1,
"[estimate]\nlower = 2\nupper = 8\n\n[value]\nvalue = 5\n\n[facet]\nlikelihood = \"bogus\"\nimpact = \"high\"\n",
);
let mut diags = Vec::new();
let scanned = scan_entities(root, &mut diags, ScanMode::default()).unwrap();
assert_eq!(scanned.len(), 1);
let adr = &scanned[0];
assert_eq!(adr.key.canonical(), "ADR-001");
assert!(adr.risk.is_none(), "malformed risk facet drops to None");
assert_eq!(
adr.estimate,
Some(EstimateFacet {
lower: 2.0,
upper: 8.0
}),
"sibling estimate facet stays intact"
);
assert_eq!(
adr.value,
Some(ValueFacet { value: 5.0 }),
"sibling value facet stays intact"
);
assert_eq!(diags.len(), 1);
let d = &diags[0];
assert_eq!(d.severity, Severity::Error);
assert_eq!(d.field.as_deref(), Some("facet"));
assert_eq!(
d.entity_key.as_ref().map(|k| k.canonical()),
Some("ADR-001".to_string())
);
assert!(
d.message.contains("invalid likelihood"),
"leaf message verbatim: {}",
d.message
);
}
#[test]
fn scan_mode_default_produces_no_body() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
let scanned = scan_entities(root, &mut vec![], ScanMode::default()).unwrap();
let e = scanned.iter().find(|s| s.key.id == 1).unwrap();
assert!(e.body.is_none(), "default mode should not read bodies");
}
#[test]
fn scan_mode_include_bodies_reads_body() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
let scanned = scan_entities(root, &mut vec![], ScanMode::include_bodies()).unwrap();
let e = scanned.iter().find(|s| s.key.id == 1).unwrap();
assert!(e.body.is_some(), "include_bodies mode should read body");
assert!(
e.body.as_deref().unwrap().contains("scope"),
"body should contain seeded markdown"
);
}
fn seed_slice_with_tags(root: &Path, id: u32, tags_toml: &str) {
write(
root,
&format!(".doctrine/slice/{id:03}/slice-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"s{id}\"\ntitle = \"S{id}\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n{tags_toml}"
),
);
write(
root,
&format!(".doctrine/slice/{id:03}/slice-{id:03}.md"),
"scope\n",
);
}
#[test]
fn read_facets_tags_present_string_array() {
let dir = tmp();
let root = dir.path();
seed_slice_with_tags(root, 1, "tags = [\"foo\", \"bar\"]\n");
let scanned = scan_entities(root, &mut vec![], ScanMode::default()).unwrap();
assert_eq!(scanned.len(), 1);
assert_eq!(scanned[0].tags, vec!["foo", "bar"]);
}
#[test]
fn read_facets_tags_absent_is_empty() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
let scanned = scan_entities(root, &mut vec![], ScanMode::default()).unwrap();
assert_eq!(scanned.len(), 1);
assert!(scanned[0].tags.is_empty());
}
#[test]
fn read_facets_tags_non_array_is_empty() {
let dir = tmp();
let root = dir.path();
write(
root,
".doctrine/rec/001/rec-001.toml",
"id = 1\nslug = \"r\"\ntitle = \"R\"\ntags = \"not-an-array\"\n\
[rec]\nmove = \"accept\"\nowning_slice = \"SL-001\"\n",
);
let scanned = scan_entities(root, &mut vec![], ScanMode::default()).unwrap();
assert_eq!(scanned.len(), 1);
assert!(scanned[0].tags.is_empty());
}
#[test]
fn read_facets_tags_un_normalized_passes_through() {
let dir = tmp();
let root = dir.path();
seed_slice_with_tags(root, 1, "tags = [\" unpadded \", \"\"]\n");
let scanned = scan_entities(root, &mut vec![], ScanMode::default()).unwrap();
assert_eq!(scanned.len(), 1);
assert_eq!(
scanned[0].tags,
vec![" unpadded ".to_string(), String::new()],
"tags pass through byte-identical — no normalize_tag in read path"
);
}
}