use std::collections::BTreeMap;
use super::hydrate::{Catalog, CatalogEdge, EdgeTarget};
use super::scan::EntityKey;
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct CatalogGraph {
pub(crate) nodes: BTreeMap<NodeKey, CatalogNode>,
pub(crate) edges: Vec<CatalogEdge>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum NodeKey {
Entity(EntityKey),
}
impl serde::Serialize for NodeKey {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
NodeKey::Entity(key) => key.canonical().serialize(serializer),
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct CatalogNode {
pub(crate) title: String,
pub(crate) status: Option<String>,
pub(crate) kind_label: &'static str,
}
impl CatalogGraph {
pub(crate) fn from_catalog(catalog: &Catalog) -> Self {
let mut nodes = BTreeMap::new();
for entity in &catalog.entities {
let key = NodeKey::Entity(entity.key);
nodes.insert(
key,
CatalogNode {
title: entity.title.clone(),
status: entity.status.clone(),
kind_label: entity.kind.prefix,
},
);
}
Self {
nodes,
edges: catalog.edges.clone(),
}
}
#[cfg_attr(not(test), expect(dead_code, reason = "tested; future consumer"))]
pub(crate) fn outgoing(&self, node: &NodeKey) -> Vec<&CatalogEdge> {
let NodeKey::Entity(key) = node;
self.edges.iter().filter(|e| &e.source == key).collect()
}
#[cfg_attr(not(test), expect(dead_code, reason = "tested; future consumer"))]
pub(crate) fn incoming(&self, node: &NodeKey) -> Vec<&CatalogEdge> {
let NodeKey::Entity(key) = node;
self.edges
.iter()
.filter(|e| match &e.target {
EdgeTarget::Resolved(tgt) => tgt == key,
EdgeTarget::UnresolvedRef { .. } | EdgeTarget::UnvalidatedText { .. } => false,
})
.collect()
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
mod tests {
use super::*;
use crate::catalog::test_helpers::*;
use std::path::Path;
fn build_graph(root: &Path) -> CatalogGraph {
let catalog =
crate::catalog::hydrate::scan_catalog(root).expect("scan_catalog should succeed");
CatalogGraph::from_catalog(&catalog)
}
#[test]
fn graph_from_catalog_node_edge_counts() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[("requirements", &["REQ-005"])]);
seed_requirement(root, 5);
seed_adr(root, 2, &["ADR-001"]);
seed_adr(root, 1, &[]);
let graph = build_graph(root);
assert_eq!(graph.nodes.len(), 4, "expected 4 nodes");
assert_eq!(graph.edges.len(), 2, "expected 2 edges");
let sl001_node = graph.nodes.get(&NodeKey::Entity(EntityKey {
prefix: "SL",
id: 1,
}));
assert!(sl001_node.is_some());
let node = sl001_node.unwrap();
assert_eq!(node.title, "S1");
assert_eq!(node.status.as_deref(), Some("proposed"));
assert_eq!(node.kind_label, "SL");
}
#[test]
fn outgoing_includes_unresolved_targets() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[("requirements", &["REQ-999"])]);
let graph = build_graph(root);
assert_eq!(graph.nodes.len(), 1);
assert_eq!(graph.edges.len(), 1);
let sl_key = NodeKey::Entity(EntityKey {
prefix: "SL",
id: 1,
});
let outgoing = graph.outgoing(&sl_key);
assert_eq!(outgoing.len(), 1, "outgoing must include the dangling edge");
let edge = outgoing[0];
assert_eq!(
edge.target,
EdgeTarget::UnresolvedRef {
raw: "REQ-999".to_string()
}
);
}
#[test]
fn incoming_excludes_unresolved_and_unvalidated() {
let dir = tmp();
let root = dir.path();
write(
root,
".doctrine/slice/001/slice-001.toml",
"id = 1\nslug = \"s1\"\ntitle = \"S1\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[[relation]]\nlabel = \"requirements\"\ntarget = \"REQ-999\"\n\
[[relation]]\nlabel = \"drift\"\ntarget = \"loose talk\"\n",
);
write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
let graph = build_graph(root);
let absent_key = NodeKey::Entity(EntityKey {
prefix: "REQ",
id: 999,
});
let incoming_absent = graph.incoming(&absent_key);
assert!(
incoming_absent.is_empty(),
"incoming must be empty for a target with only UnresolvedRef edges pointing at it"
);
let sl_key = NodeKey::Entity(EntityKey {
prefix: "SL",
id: 1,
});
let incoming_sl = graph.incoming(&sl_key);
assert!(incoming_sl.is_empty(), "SL-001 has no incoming edges");
}
#[test]
fn incoming_resolved_entity() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[("requirements", &["REQ-005"])]);
seed_slice(root, 3, &[("requirements", &["REQ-005"])]);
seed_requirement(root, 5);
let graph = build_graph(root);
assert_eq!(graph.nodes.len(), 3);
assert_eq!(graph.edges.len(), 2);
let req_key = NodeKey::Entity(EntityKey {
prefix: "REQ",
id: 5,
});
let incoming = graph.incoming(&req_key);
assert_eq!(incoming.len(), 2, "REQ-005 should have 2 incoming edges");
let sources: Vec<String> = incoming.iter().map(|e| e.source.canonical()).collect();
assert!(sources.contains(&"SL-001".to_string()), "missing SL-001");
assert!(sources.contains(&"SL-003".to_string()), "missing SL-003");
for edge in &incoming {
match &edge.target {
EdgeTarget::Resolved(key) => {
assert_eq!(key.prefix, "REQ");
assert_eq!(key.id, 5);
}
other => panic!("expected Resolved target, got {other:?}"),
}
}
let sl001_key = NodeKey::Entity(EntityKey {
prefix: "SL",
id: 1,
});
let sl001_out = graph.outgoing(&sl001_key);
assert_eq!(sl001_out.len(), 1);
assert_eq!(sl001_out[0].source.canonical(), "SL-001");
}
}