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;
use super::diagnostic::{CatalogDiagnostic, Severity};
use super::scan::{EntityKey, 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>,
}
#[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>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct CatalogEdge {
pub(crate) source: CatalogKey,
pub(crate) label: CatalogEdgeLabel,
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>,
) -> 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,
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),
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()),
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()),
target,
origin: EdgeOrigin {
file: record.path.join("memory.toml"),
field: Some(relation.label.clone()),
},
});
}
}
Self {
entities,
edges,
diagnostics,
}
}
}
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) -> anyhow::Result<Catalog> {
let mut diagnostics = Vec::new();
let scanned = super::scan::scan_entities(root, &mut diagnostics)?;
let memory = super::scan::scan_memory_entities(root, &mut diagnostics)?;
let mem_key_map = build_memory_key_map(root);
let mut catalog = Catalog::from_scanned(root, &scanned, &memory, &mem_key_map);
catalog.diagnostics.extend(diagnostics);
Ok(catalog)
}
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::*;
fn seed_hydrate_fixture(root: &Path) {
seed_slice(root, 1, &[("requirements", &["REQ-005"])]);
seed_slice(root, 3, &[]);
seed_adr(root, 1, &[]);
seed_adr(root, 2, &["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).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(), "requirements");
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("requirements"));
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, &[("requirements", &["REQ-999"])]);
let catalog = scan_catalog(root).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("requirements"));
}
#[test]
fn edge_classification_unvalidated_text_produces_info() {
let dir = tmp();
let root = dir.path();
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 catalog = scan_catalog(root).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).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).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 = \"doctrine.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).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).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).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"));
}
}