use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::entity;
use crate::integrity;
use crate::relation::RelationLabel;
use super::diagnostic::{CatalogDiagnostic, Severity};
use super::scan::{EntityKey, ScannedEntity};
#[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: EntityKey,
pub(crate) kind: &'static entity::Kind,
pub(crate) path: PathBuf,
pub(crate) title: String,
pub(crate) status: Option<String>,
pub(crate) source: SourceSpan,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct CatalogEdge {
pub(crate) source: EntityKey,
pub(crate) label: RelationLabel,
pub(crate) target: EdgeTarget,
pub(crate) origin: EdgeOrigin,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) enum EdgeTarget {
Resolved(EntityKey),
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]) -> Self {
let key_set: BTreeSet<EntityKey> = scanned.iter().map(|e| e.key).collect();
let mut entities = Vec::with_capacity(scanned.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: se.key,
kind: se.kind,
path: entity_dir.clone(),
title: se.title.clone(),
status: se.status.clone(),
source: SourceSpan {
file: entity_dir.clone(),
field: None,
},
});
for edge in &se.outbound {
let target = classify_target(&edge.target, &key_set);
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(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(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: se.key,
label: edge.label,
target,
origin,
});
}
}
Self {
entities,
edges,
diagnostics,
}
}
}
fn classify_target(raw: &str, key_set: &BTreeSet<EntityKey>) -> EdgeTarget {
match integrity::parse_canonical_ref(raw) {
Ok((kref, id)) => {
let key = EntityKey {
prefix: kref.kind.prefix,
id,
};
if key_set.contains(&key) {
EdgeTarget::Resolved(key)
} else {
EdgeTarget::UnresolvedRef {
raw: raw.to_string(),
}
}
}
Err(_) => EdgeTarget::UnvalidatedText {
raw: raw.to_string(),
},
}
}
pub(crate) fn scan_catalog(root: &Path) -> anyhow::Result<Catalog> {
let scanned = super::scan::scan_entities(root)?;
Ok(Catalog::from_scanned(root, &scanned))
}
#[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.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(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(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.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 expected = root
.join(entity.kind.dir)
.join(format!("{:03}", entity.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<EntityKey> = BTreeSet::new();
let result = classify_target("ZZ-001", &empty_set);
assert_eq!(
result,
EdgeTarget::UnvalidatedText {
raw: "ZZ-001".to_string()
}
);
}
#[test]
fn classify_target_no_dash_is_unvalidated() {
let empty_set: BTreeSet<EntityKey> = BTreeSet::new();
let result = classify_target("just_text", &empty_set);
assert_eq!(
result,
EdgeTarget::UnvalidatedText {
raw: "just_text".to_string()
}
);
}
#[test]
fn classify_target_parses_but_absent_is_unresolved() {
let empty_set: BTreeSet<EntityKey> = BTreeSet::new();
let result = classify_target("SL-999", &empty_set);
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(key);
let result = classify_target("SL-001", &set);
assert_eq!(result, EdgeTarget::Resolved(key));
}
}