use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::entity;
use crate::integrity;
use crate::memory::{self, MEMORY_ITEMS_DIR, MemoryCatalogRecord};
use crate::relation::{RelationLabel, Role};
use super::diagnostic::{CatalogDiagnostic, Severity};
use super::scan::{EntityKey, ScanMode, ScannedEntity};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum CatalogKey {
Numbered(EntityKey),
Memory(String),
}
impl CatalogKey {
pub(crate) fn canonical(&self) -> String {
match self {
CatalogKey::Numbered(key) => key.canonical(),
CatalogKey::Memory(uid) => uid.clone(),
}
}
}
impl serde::Serialize for CatalogKey {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.canonical().serialize(serializer)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) enum CatalogEdgeLabel {
Validated(RelationLabel),
Raw(String),
}
impl CatalogEdgeLabel {
pub(crate) fn name(&self) -> &str {
match self {
CatalogEdgeLabel::Validated(label) => label.name(),
CatalogEdgeLabel::Raw(label) => label.as_str(),
}
}
}
#[derive(Clone, serde::Serialize)]
pub(crate) struct Catalog {
pub(crate) entities: Vec<CatalogEntity>,
pub(crate) edges: Vec<CatalogEdge>,
pub(crate) diagnostics: Vec<CatalogDiagnostic>,
pub(crate) units: Units,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct Units {
pub(crate) estimation: String,
pub(crate) value: String,
}
#[derive(Clone, serde::Serialize)]
pub(crate) struct CatalogEntity {
pub(crate) key: CatalogKey,
pub(crate) kind_label: &'static str,
pub(crate) kind: Option<&'static entity::Kind>,
pub(crate) path: PathBuf,
pub(crate) title: String,
pub(crate) status: Option<String>,
pub(crate) source: SourceSpan,
pub(crate) memory_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) estimate: Option<crate::estimate::EstimateFacet>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) value: Option<crate::value::ValueFacet>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) body: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct CatalogEdge {
pub(crate) source: CatalogKey,
pub(crate) label: CatalogEdgeLabel,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) role: Option<Role>,
pub(crate) target: EdgeTarget,
pub(crate) origin: EdgeOrigin,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) enum EdgeTarget {
Resolved(CatalogKey),
UnresolvedRef {
raw: String,
},
UnvalidatedText {
raw: String,
},
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct EdgeOrigin {
pub(crate) file: PathBuf,
pub(crate) field: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct SourceSpan {
pub(crate) file: PathBuf,
pub(crate) field: Option<String>,
}
impl Catalog {
pub(crate) fn from_scanned(
root: &Path,
scanned: &[ScannedEntity],
memory: &[MemoryCatalogRecord],
mem_key_map: &BTreeMap<String, String>,
units: Units,
) -> Self {
let key_set: BTreeSet<CatalogKey> = scanned
.iter()
.map(|entity| CatalogKey::Numbered(entity.key))
.chain(
memory
.iter()
.map(|record| CatalogKey::Memory(record.uid.clone())),
)
.collect();
let mut entities = Vec::with_capacity(scanned.len() + memory.len());
let mut edges = Vec::new();
let mut diagnostics = Vec::new();
for se in scanned {
let entity_dir = root.join(se.kind.dir).join(format!("{:03}", se.key.id));
entities.push(CatalogEntity {
key: CatalogKey::Numbered(se.key),
kind_label: se.key.prefix,
kind: Some(se.kind),
path: entity_dir.clone(),
title: se.title.clone(),
status: se.status.clone(),
memory_type: None,
estimate: se.estimate.clone(),
value: se.value.clone(),
body: se.body.clone(),
source: SourceSpan {
file: entity_dir.clone(),
field: None,
},
});
for edge in &se.outbound {
let target = classify_target(&edge.target, &key_set, mem_key_map);
let origin = EdgeOrigin {
file: entity_dir.clone(),
field: Some(edge.label.name().to_string()),
};
match &target {
EdgeTarget::UnresolvedRef { raw } => {
diagnostics.push(CatalogDiagnostic {
file: entity_dir.clone(),
entity_key: Some(CatalogKey::Numbered(se.key)),
field: Some(edge.label.name().to_string()),
message: format!(
"dangling reference: `{raw}` does not resolve to any scanned entity"
),
severity: Severity::Warning,
});
}
EdgeTarget::UnvalidatedText { raw } => {
diagnostics.push(CatalogDiagnostic {
file: entity_dir.clone(),
entity_key: Some(CatalogKey::Numbered(se.key)),
field: Some(edge.label.name().to_string()),
message: format!(
"unvalidated target: `{raw}` is not a canonical reference"
),
severity: Severity::Info,
});
}
EdgeTarget::Resolved(_) => { }
}
edges.push(CatalogEdge {
source: CatalogKey::Numbered(se.key),
label: CatalogEdgeLabel::Validated(edge.label),
role: edge.role,
target,
origin,
});
}
}
for record in memory {
entities.push(CatalogEntity {
key: CatalogKey::Memory(record.uid.clone()),
kind_label: "MEM",
kind: None,
path: record.path.clone(),
title: record.title.clone(),
status: Some(record.status.clone()),
memory_type: Some(record.memory_type.clone()),
estimate: None,
value: None,
body: None,
source: SourceSpan {
file: record.path.clone(),
field: None,
},
});
for relation in &record.relations {
if relation.label.is_empty() {
diagnostics.push(CatalogDiagnostic {
file: record.path.clone(),
entity_key: Some(CatalogKey::Memory(record.uid.clone())),
field: None,
message: "empty relation label".to_string(),
severity: Severity::Warning,
});
continue;
}
if relation.target.is_empty() {
diagnostics.push(CatalogDiagnostic {
file: record.path.clone(),
entity_key: Some(CatalogKey::Memory(record.uid.clone())),
field: Some(relation.label.clone()),
message: "empty relation target".to_string(),
severity: Severity::Warning,
});
continue;
}
let target = classify_target(&relation.target, &key_set, mem_key_map);
if let EdgeTarget::UnresolvedRef { raw } = &target {
diagnostics.push(CatalogDiagnostic {
file: record.path.join("memory.toml"),
entity_key: Some(CatalogKey::Memory(record.uid.clone())),
field: Some(relation.label.clone()),
message: format!(
"dangling reference: memory edge target `{raw}` does not resolve"
),
severity: Severity::Warning,
});
}
edges.push(CatalogEdge {
source: CatalogKey::Memory(record.uid.clone()),
label: CatalogEdgeLabel::Raw(relation.label.clone()),
role: None,
target,
origin: EdgeOrigin {
file: record.path.join("memory.toml"),
field: Some(relation.label.clone()),
},
});
}
}
Self {
entities,
edges,
diagnostics,
units,
}
}
}
fn classify_target(
raw: &str,
key_set: &BTreeSet<CatalogKey>,
mem_key_map: &BTreeMap<String, String>,
) -> EdgeTarget {
if let Ok((kref, id)) = integrity::parse_canonical_ref(raw) {
let key = CatalogKey::Numbered(EntityKey {
prefix: kref.kind.prefix,
id,
});
if key_set.contains(&key) {
EdgeTarget::Resolved(key)
} else {
EdgeTarget::UnresolvedRef {
raw: raw.to_string(),
}
}
} else {
let uid = if memory::is_uid(raw) {
Some(raw.to_string())
} else {
mem_key_map.get(raw).cloned()
};
if let Some(uid) = uid {
let mem_key = CatalogKey::Memory(uid);
if key_set.contains(&mem_key) {
return EdgeTarget::Resolved(mem_key);
}
}
EdgeTarget::UnvalidatedText {
raw: raw.to_string(),
}
}
}
pub(crate) fn scan_catalog(root: &Path, mode: ScanMode) -> anyhow::Result<Catalog> {
let mut diagnostics = Vec::new();
let scanned = super::scan::scan_entities(root, &mut diagnostics, mode)?;
let memory = super::scan::scan_memory_entities(root, &mut diagnostics)?;
let mem_key_map = build_memory_key_map(root);
let units = resolve_units(root)?;
let mut catalog = Catalog::from_scanned(root, &scanned, &memory, &mem_key_map, units);
catalog.diagnostics.extend(diagnostics);
Ok(catalog)
}
fn resolve_units(root: &Path) -> anyhow::Result<Units> {
let cfg = match std::fs::read_to_string(root.join(crate::dtoml::DOCTRINE_TOML)) {
Ok(text) => crate::dtoml::parse(&text)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => crate::dtoml::DoctrineToml::default(),
Err(e) => return Err(e.into()),
};
Ok(Units {
estimation: crate::estimate::resolve_unit(&cfg.estimation),
value: crate::value::resolve_unit(&cfg.value),
})
}
fn build_memory_key_map(root: &Path) -> BTreeMap<String, String> {
let items = root.join(MEMORY_ITEMS_DIR);
let mut map = BTreeMap::new();
let Ok(entries) = std::fs::read_dir(&items) else {
return map;
};
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else {
continue;
};
if !ft.is_symlink() {
continue;
}
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("mem.") {
continue;
}
let Ok(target) = std::fs::read_link(entry.path()) else {
continue;
};
let Some(uid_os) = target.file_name() else {
continue;
};
let uid = uid_os.to_string_lossy().to_string();
if memory::is_uid(&uid) {
map.insert(name_str.to_string(), uid);
}
}
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, SCHEMA_MEMORY};
fn seed_hydrate_fixture(root: &Path) {
seed_slice(root, 1, &[("references(implements)", &["REQ-005"])]);
seed_slice(root, 3, &[]);
seed_adr(root, 1, &[]);
seed_adr(root, 2, &[("supersedes", &["ADR-001"])]);
seed_requirement(root, 5);
}
#[test]
fn catalog_hydrates_entities_and_resolved_edges() {
let dir = tmp();
let root = dir.path();
seed_hydrate_fixture(root);
let catalog = scan_catalog(root, ScanMode::default()).unwrap();
assert_eq!(
catalog.entities.len(),
5,
"expected 5 entities (SL-001, SL-003, ADR-001, ADR-002, REQ-005)"
);
let sl001 = catalog
.entities
.iter()
.find(|e| e.key.canonical() == "SL-001")
.unwrap();
assert_eq!(sl001.path, root.join(".doctrine/slice/001"));
assert_eq!(sl001.title, "S1");
assert_eq!(sl001.status.as_deref(), Some("proposed"));
assert_eq!(sl001.kind.unwrap().prefix, "SL");
let req005 = catalog
.entities
.iter()
.find(|e| e.key.canonical() == "REQ-005")
.unwrap();
assert_eq!(req005.path, root.join(".doctrine/requirement/005"));
assert_eq!(catalog.edges.len(), 2);
let sl001_edge = catalog
.edges
.iter()
.find(|e| e.source.canonical() == "SL-001")
.unwrap();
assert_eq!(sl001_edge.label.name(), "references");
assert_eq!(
sl001_edge.target,
EdgeTarget::Resolved(CatalogKey::Numbered(EntityKey {
prefix: "REQ",
id: 5
}))
);
assert_eq!(sl001_edge.origin.file, root.join(".doctrine/slice/001"));
assert_eq!(sl001_edge.origin.field.as_deref(), Some("references"));
let adr002_edge = catalog
.edges
.iter()
.find(|e| e.source.canonical() == "ADR-002")
.unwrap();
assert_eq!(adr002_edge.label.name(), "supersedes");
assert_eq!(
adr002_edge.target,
EdgeTarget::Resolved(CatalogKey::Numbered(EntityKey {
prefix: "ADR",
id: 1
}))
);
}
#[test]
fn edge_classification_unresolved_ref_produces_warning() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[("references(implements)", &["REQ-999"])]);
let catalog = scan_catalog(root, ScanMode::default()).unwrap();
assert_eq!(catalog.entities.len(), 1);
assert_eq!(catalog.edges.len(), 1);
let edge = &catalog.edges[0];
assert_eq!(
edge.target,
EdgeTarget::UnresolvedRef {
raw: "REQ-999".to_string()
}
);
let diags: Vec<&CatalogDiagnostic> = catalog
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Warning)
.collect();
assert_eq!(diags.len(), 1, "expected one Warning diagnostic");
let diag = diags[0];
assert!(diag.message.contains("REQ-999"));
assert!(diag.message.contains("dangling"));
assert_eq!(
diag.entity_key.as_ref().map(|k| k.canonical()),
Some("SL-001".to_string())
);
assert_eq!(diag.field.as_deref(), Some("references"));
}
#[test]
fn edge_classification_unvalidated_text_produces_info() {
let dir = tmp();
let root = dir.path();
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 catalog = scan_catalog(root, ScanMode::default()).unwrap();
assert_eq!(catalog.entities.len(), 1);
assert_eq!(catalog.edges.len(), 1);
let edge = &catalog.edges[0];
assert_eq!(
edge.target,
EdgeTarget::UnvalidatedText {
raw: "loose talk".to_string()
}
);
let diags: Vec<&CatalogDiagnostic> = catalog
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Info)
.collect();
assert_eq!(diags.len(), 1, "expected one Info diagnostic");
let diag = diags[0];
assert!(diag.message.contains("loose talk"));
assert!(diag.message.contains("not a canonical reference"));
assert_eq!(diag.field.as_deref(), Some("drift"));
}
#[test]
fn entity_path_derivation_matches_expected() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
seed_adr(root, 2, &[]);
seed_requirement(root, 5);
let catalog = scan_catalog(root, ScanMode::default()).unwrap();
for entity in &catalog.entities {
let CatalogKey::Numbered(key) = &entity.key else {
panic!("fixture should only produce numbered entities");
};
let kind = entity.kind.unwrap();
let expected = root.join(kind.dir).join(format!("{:03}", key.id));
assert_eq!(
entity.path,
expected,
"path mismatch for {}",
entity.key.canonical()
);
assert_eq!(
entity.source.file,
expected,
"source.file mismatch for {}",
entity.key.canonical()
);
assert!(
entity.source.field.is_none(),
"source.field should be None for {}",
entity.key.canonical()
);
}
}
#[test]
fn scan_catalog_integration_on_full_fixture() {
let dir = tmp();
let root = dir.path();
seed_hydrate_fixture(root);
let catalog = scan_catalog(root, ScanMode::default()).unwrap();
assert_eq!(catalog.diagnostics.len(), 0);
assert_eq!(catalog.entities.len(), 5);
assert_eq!(catalog.edges.len(), 2);
for edge in &catalog.edges {
let source_entity = catalog
.entities
.iter()
.find(|e| e.key == edge.source)
.unwrap();
assert_eq!(edge.origin.file, source_entity.path);
}
}
#[test]
fn classify_target_unknown_kind_prefix_is_unvalidated() {
let empty_set: BTreeSet<CatalogKey> = BTreeSet::new();
let empty_map: BTreeMap<String, String> = BTreeMap::new();
let result = classify_target("ZZ-001", &empty_set, &empty_map);
assert_eq!(
result,
EdgeTarget::UnvalidatedText {
raw: "ZZ-001".to_string()
}
);
}
#[test]
fn classify_target_no_dash_is_unvalidated() {
let empty_set: BTreeSet<CatalogKey> = BTreeSet::new();
let empty_map: BTreeMap<String, String> = BTreeMap::new();
let result = classify_target("just_text", &empty_set, &empty_map);
assert_eq!(
result,
EdgeTarget::UnvalidatedText {
raw: "just_text".to_string()
}
);
}
#[test]
fn classify_target_parses_but_absent_is_unresolved() {
let empty_set: BTreeSet<CatalogKey> = BTreeSet::new();
let empty_map: BTreeMap<String, String> = BTreeMap::new();
let result = classify_target("SL-999", &empty_set, &empty_map);
assert_eq!(
result,
EdgeTarget::UnresolvedRef {
raw: "SL-999".to_string()
}
);
}
#[test]
fn classify_target_parses_and_present_is_resolved() {
let key = EntityKey {
prefix: "SL",
id: 1,
};
let mut set = BTreeSet::new();
set.insert(CatalogKey::Numbered(key));
let empty_map: BTreeMap<String, String> = BTreeMap::new();
let result = classify_target("SL-001", &set, &empty_map);
assert_eq!(result, EdgeTarget::Resolved(CatalogKey::Numbered(key)));
}
fn seed_memory(root: &Path, uid: &str, title: &str, relations: &[(&str, &str)]) {
use crate::memory::MEMORY_ITEMS_DIR;
let items_dir = root.join(MEMORY_ITEMS_DIR).join(uid);
std::fs::create_dir_all(&items_dir).unwrap();
let rels: Vec<String> = relations
.iter()
.map(|(l, t)| format!("[[relation]]\nlabel = \"{l}\"\ntarget = \"{t}\"\n"))
.collect();
std::fs::write(
items_dir.join("memory.toml"),
format!(
"schema = \"{SCHEMA_MEMORY}\"\nversion = 1\nmemory_uid = \"{uid}\"\ntitle = \"{title}\"\nstatus = \"active\"\nmemory_type = \"pattern\"\n{}",
rels.concat()
),
)
.unwrap();
}
#[test]
fn memory_edge_pipeline_resolves_and_diagnoses() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
seed_memory(
root,
"mem_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"Test Memory",
&[
("references", "SL-001"),
("references", "SL-999"),
("see-also", "some free text"),
],
);
let catalog = scan_catalog(root, ScanMode::default()).unwrap();
assert_eq!(catalog.entities.len(), 2);
assert_eq!(catalog.edges.len(), 3);
let mem_entity = catalog
.entities
.iter()
.find(|e| matches!(e.key, CatalogKey::Memory(_)))
.unwrap();
assert_eq!(mem_entity.kind_label, "MEM");
assert_eq!(mem_entity.title, "Test Memory");
assert_eq!(mem_entity.memory_type.as_deref(), Some("pattern"));
assert!(mem_entity.kind.is_none());
let resolved_edge = catalog
.edges
.iter()
.find(|e| matches!(&e.target, EdgeTarget::Resolved(k) if k.canonical() == "SL-001"))
.unwrap();
assert_eq!(resolved_edge.label.name(), "references");
let unresolved_edge = catalog
.edges
.iter()
.find(|e| matches!(&e.target, EdgeTarget::UnresolvedRef { raw } if raw == "SL-999"))
.unwrap();
assert_eq!(unresolved_edge.label.name(), "references");
let free_text_edge = catalog
.edges
.iter()
.find(|e| {
matches!(&e.target, EdgeTarget::UnvalidatedText { raw } if raw == "some free text")
})
.unwrap();
assert_eq!(free_text_edge.label.name(), "see-also");
let warnings: Vec<&CatalogDiagnostic> = catalog
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Warning)
.collect();
assert_eq!(warnings.len(), 1, "expected one dangling-ref Warning");
assert!(warnings[0].message.contains("SL-999"));
assert!(warnings[0].message.contains("does not resolve"));
}
#[test]
fn memory_empty_relation_fields_surface_diagnostics_not_edges() {
let dir = tmp();
let root = dir.path();
seed_memory(
root,
"mem_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"Empty Relations",
&[("", "SL-001"), ("refs", ""), ("", "")],
);
let catalog = scan_catalog(root, ScanMode::default()).unwrap();
assert_eq!(catalog.entities.len(), 1);
assert_eq!(
catalog.edges.len(),
0,
"empty-field relations must not produce blank graph edges"
);
let warnings: Vec<&CatalogDiagnostic> = catalog
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Warning)
.collect();
assert_eq!(warnings.len(), 3);
assert!(
warnings
.iter()
.any(|d| d.message.contains("empty relation label"))
);
assert!(
warnings
.iter()
.any(|d| d.message.contains("empty relation target"))
);
}
#[test]
fn scan_catalog_propagates_entity_scan_diagnostics() {
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 catalog = scan_catalog(root, ScanMode::default()).unwrap();
assert_eq!(catalog.entities.len(), 1);
assert!(catalog.entities[0].key.canonical().contains("SL-001"));
assert!(!catalog.diagnostics.is_empty());
let entity_diags: Vec<_> = catalog
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert_eq!(entity_diags.len(), 1);
assert_eq!(
entity_diags[0].entity_key.as_ref().map(|k| k.canonical()),
Some("SL-002".to_string())
);
assert!(entity_diags[0].file.to_string_lossy().contains("002"));
assert!(entity_diags[0].message.contains("SL-002"));
}
fn test_units() -> Units {
Units {
estimation: "espresso_shots".to_string(),
value: "magic_beans".to_string(),
}
}
fn scanned_numbered(
prefix: &'static str,
id: u32,
estimate: Option<crate::estimate::EstimateFacet>,
value: Option<crate::value::ValueFacet>,
risk: Option<crate::risk::RiskFacet>,
) -> ScannedEntity {
ScannedEntity {
key: EntityKey { prefix, id },
kind: crate::integrity::kind_by_prefix(prefix).unwrap().kind,
status: Some("proposed".to_string()),
title: format!("{prefix}-{id}"),
outbound: Vec::new(),
estimate,
value,
risk,
tags: vec![],
body: None,
}
}
#[test]
fn from_scanned_carries_facets_onto_numbered_and_none_for_memory() {
let dir = tmp();
let root = dir.path();
let est = crate::estimate::EstimateFacet {
lower: 2.0,
upper: 8.0,
};
let val = crate::value::ValueFacet { value: 5.0 };
let scanned = vec![
scanned_numbered("SL", 1, Some(est.clone()), Some(val.clone()), None),
scanned_numbered("SL", 2, None, None, None),
];
let record = MemoryCatalogRecord {
uid: "mem_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
title: "M".to_string(),
status: "active".to_string(),
memory_type: "pattern".to_string(),
path: root.join("mem"),
relations: Vec::new(),
};
let catalog =
Catalog::from_scanned(root, &scanned, &[record], &BTreeMap::new(), test_units());
let sl001 = catalog
.entities
.iter()
.find(|e| e.key.canonical() == "SL-001")
.unwrap();
assert_eq!(sl001.estimate, Some(est));
assert_eq!(sl001.value, Some(val));
let sl002 = catalog
.entities
.iter()
.find(|e| e.key.canonical() == "SL-002")
.unwrap();
assert!(sl002.estimate.is_none());
assert!(sl002.value.is_none());
let mem = catalog
.entities
.iter()
.find(|e| matches!(e.key, CatalogKey::Memory(_)))
.unwrap();
assert!(mem.estimate.is_none(), "memory carries no estimate facet");
assert!(mem.value.is_none(), "memory carries no value facet");
assert_eq!(catalog.units.estimation, "espresso_shots");
assert_eq!(catalog.units.value, "magic_beans");
}
#[test]
fn resolve_units_reads_config_and_defaults() {
let dir = tmp();
let root = dir.path();
write(
root,
crate::dtoml::DOCTRINE_TOML,
"[estimation]\nunit = \"story_points\"\n[value]\nunit = \"gold\"\n",
);
let units = resolve_units(root).unwrap();
assert_eq!(units.estimation, "story_points");
assert_eq!(units.value, "gold");
let dir2 = tmp();
let root2 = dir2.path();
write(root2, crate::dtoml::DOCTRINE_TOML, "[conduct]\n");
let units2 = resolve_units(root2).unwrap();
assert_eq!(units2.estimation, "espresso_shots");
assert_eq!(units2.value, "magic_beans");
}
#[test]
fn resolve_units_notfound_defaults_but_other_errors_propagate() {
let dir = tmp();
let root = dir.path();
let units = resolve_units(root).unwrap();
assert_eq!(units.estimation, "espresso_shots");
assert_eq!(units.value, "magic_beans");
let dir2 = tmp();
let root2 = dir2.path();
write(
root2,
crate::dtoml::DOCTRINE_TOML,
"this is not [valid toml\n",
);
assert!(
resolve_units(root2).is_err(),
"a parse error must propagate, not default"
);
let dir3 = tmp();
let root3 = dir3.path();
let p = root3.join(crate::dtoml::DOCTRINE_TOML);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::create_dir(&p).unwrap();
assert!(
resolve_units(root3).is_err(),
"a non-NotFound read error must propagate, not default"
);
}
#[test]
fn scan_catalog_with_bodies_forwards_body_to_catalog_entity() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
let catalog = scan_catalog(root, ScanMode::include_bodies()).unwrap();
let e = catalog
.entities
.iter()
.find(|ce| matches!(&ce.key, CatalogKey::Numbered(k) if k.id == 1))
.unwrap();
assert!(
e.body.is_some(),
"body should be Some when include_bodies is true"
);
}
#[test]
fn catalog_entity_body_skipped_in_json_when_none() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
let catalog = scan_catalog(root, ScanMode::default()).unwrap();
let e = catalog
.entities
.iter()
.find(|ce| matches!(&ce.key, CatalogKey::Numbered(k) if k.id == 1))
.unwrap();
let json = serde_json::to_string(e).unwrap();
assert!(
!json.contains("\"body\""),
"JSON should omit body key when None"
);
}
}