use std::collections::HashMap;
use std::path::PathBuf;
use serde::Serialize;
use crate::document::StrayMarkDocument;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum EdgeType {
RelatedTo,
Supersedes,
DocumentsAlternative,
ChangesApi,
OriginatesFrom,
}
impl EdgeType {
pub fn as_str(&self) -> &'static str {
match self {
EdgeType::RelatedTo => "RELATED_TO",
EdgeType::Supersedes => "SUPERSEDES",
EdgeType::DocumentsAlternative => "DOCUMENTS_ALTERNATIVE",
EdgeType::ChangesApi => "CHANGES_API",
EdgeType::OriginatesFrom => "ORIGINATES_FROM",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Node {
pub id: String,
pub has_explicit_id: bool,
pub doc_type: String,
pub title: String,
pub status: String,
pub risk_level: String,
pub created: Option<String>,
pub agent: Option<String>,
pub tags: Vec<String>,
pub path: PathBuf,
pub degree_in: usize,
pub degree_out: usize,
}
impl Node {
pub fn is_orphan(&self) -> bool {
self.degree_in + self.degree_out == 0
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Edge {
pub source: String,
pub target: String,
pub edge_type: EdgeType,
pub resolved: bool,
}
#[derive(Debug, Default)]
pub struct Graph {
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
node_index: HashMap<String, usize>,
out_edges: Vec<Vec<usize>>,
in_edges: Vec<Vec<usize>>,
}
fn edge_sources(
doc: &StrayMarkDocument,
) -> impl Iterator<Item = (EdgeType, &Vec<String>)> {
let fm = &doc.frontmatter;
[
(EdgeType::RelatedTo, fm.related.as_ref()),
(EdgeType::Supersedes, fm.supersedes.as_ref()),
(EdgeType::DocumentsAlternative, fm.alternatives_documented.as_ref()),
(EdgeType::ChangesApi, fm.api_changes.as_ref()),
(EdgeType::OriginatesFrom, fm.originating_ailogs.as_ref()),
]
.into_iter()
.filter_map(|(et, list)| list.map(|l| (et, l)))
}
impl Graph {
pub fn build(docs: &[&StrayMarkDocument]) -> Graph {
let mut graph = Graph::default();
for doc in docs {
let (id, has_explicit_id) = match &doc.frontmatter.id {
Some(id) => (id.clone(), true),
None => (
doc.path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&doc.filename)
.to_string(),
false,
),
};
let fm = &doc.frontmatter;
let node = Node {
id: id.clone(),
has_explicit_id,
doc_type: doc.doc_type.prefix().to_string(),
title: fm.title.clone().unwrap_or_else(|| "Untitled".into()),
status: fm.status.clone().unwrap_or_else(|| "unknown".into()),
risk_level: fm.risk_level.clone().unwrap_or_else(|| "unset".into()),
created: fm.created.clone(),
agent: fm.agent.clone(),
tags: fm.tags.clone().unwrap_or_default(),
path: doc.path.clone(),
degree_in: 0,
degree_out: 0,
};
graph.node_index.entry(id).or_insert(graph.nodes.len());
graph.nodes.push(node);
graph.out_edges.push(Vec::new());
graph.in_edges.push(Vec::new());
}
for (source_idx, doc) in docs.iter().enumerate() {
let source_id = graph.nodes[source_idx].id.clone();
for (edge_type, targets) in edge_sources(doc) {
for target in targets {
let target_idx = graph.node_index.get(target.as_str()).copied();
let edge_idx = graph.edges.len();
graph.edges.push(Edge {
source: source_id.clone(),
target: target.clone(),
edge_type,
resolved: target_idx.is_some(),
});
graph.out_edges[source_idx].push(edge_idx);
graph.nodes[source_idx].degree_out += 1;
if let Some(t) = target_idx {
graph.in_edges[t].push(edge_idx);
graph.nodes[t].degree_in += 1;
}
}
}
}
graph
}
pub fn node(&self, id: &str) -> Option<&Node> {
self.node_index.get(id).map(|&i| &self.nodes[i])
}
pub fn out_edges(&self, id: &str) -> impl Iterator<Item = &Edge> {
self.node_index
.get(id)
.map(|&i| self.out_edges[i].as_slice())
.unwrap_or(&[])
.iter()
.map(|&e| &self.edges[e])
}
pub fn in_edges(&self, id: &str) -> impl Iterator<Item = &Edge> {
self.node_index
.get(id)
.map(|&i| self.in_edges[i].as_slice())
.unwrap_or(&[])
.iter()
.map(|&e| &self.edges[e])
}
pub fn orphans(&self) -> impl Iterator<Item = &Node> {
self.nodes.iter().filter(|n| n.is_orphan())
}
pub fn dangling_edges(&self) -> impl Iterator<Item = &Edge> {
self.edges.iter().filter(|e| !e.resolved)
}
pub fn thread(&self, id: &str, depth: Option<usize>) -> Option<Thread> {
let &start = self.node_index.get(id)?;
let mut in_thread = vec![false; self.nodes.len()];
let mut node_ids = Vec::new();
in_thread[start] = true;
node_ids.push(self.nodes[start].id.clone());
for forward in [true, false] {
let mut visited = vec![false; self.nodes.len()];
visited[start] = true;
let mut frontier = vec![start];
let mut level = 0usize;
while !frontier.is_empty() && depth.map_or(true, |d| level < d) {
let mut next = Vec::new();
for idx in frontier {
let edges = if forward { &self.out_edges[idx] } else { &self.in_edges[idx] };
for &e in edges {
let edge = &self.edges[e];
let neighbor = if forward { edge.target.as_str() } else { edge.source.as_str() };
if let Some(&n) = self.node_index.get(neighbor) {
if !visited[n] {
visited[n] = true;
if !in_thread[n] {
in_thread[n] = true;
node_ids.push(self.nodes[n].id.clone());
}
next.push(n);
}
}
}
}
frontier = next;
level += 1;
}
}
let edge_ids: Vec<usize> = self
.edges
.iter()
.enumerate()
.filter(|(_, edge)| {
let src_in = self.node_index.get(edge.source.as_str()).is_some_and(|&i| in_thread[i]);
if !src_in {
return false;
}
match self.node_index.get(edge.target.as_str()) {
Some(&t) => in_thread[t],
None => !edge.resolved, }
})
.map(|(i, _)| i)
.collect();
Some(Thread { node_ids, edge_ids })
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Thread {
pub node_ids: Vec<String>,
pub edge_ids: Vec<usize>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{DocType, Frontmatter};
use std::path::PathBuf;
fn make_doc(filename: &str, doc_type: DocType, fm: Frontmatter) -> StrayMarkDocument {
StrayMarkDocument {
path: PathBuf::from(format!(".straymark/test/{}", filename)),
filename: filename.to_string(),
doc_type,
frontmatter: fm,
body: String::new(),
}
}
#[test]
fn test_empty_graph() {
let g = Graph::build(&[]);
assert!(g.nodes.is_empty());
assert!(g.edges.is_empty());
}
#[test]
fn test_typed_edges_and_bidirectional_adjacency() {
let req = make_doc(
"REQ-2026-03-01-001-login.md",
DocType::Req,
Frontmatter {
id: Some("REQ-2026-03-01-001".into()),
related: Some(vec!["ADR-2026-03-02-001".into()]),
..Default::default()
},
);
let adr = make_doc(
"ADR-2026-03-02-001-jwt.md",
DocType::Adr,
Frontmatter {
id: Some("ADR-2026-03-02-001".into()),
supersedes: Some(vec!["ADR-2026-01-01-001".into()]),
api_changes: Some(vec!["POST /login".into()]),
..Default::default()
},
);
let old_adr = make_doc(
"ADR-2026-01-01-001-old.md",
DocType::Adr,
Frontmatter {
id: Some("ADR-2026-01-01-001".into()),
..Default::default()
},
);
let docs = [&req, &adr, &old_adr];
let g = Graph::build(&docs);
assert_eq!(g.nodes.len(), 3);
assert_eq!(g.edges.len(), 3);
let types: Vec<EdgeType> = g.edges.iter().map(|e| e.edge_type).collect();
assert_eq!(
types,
vec![EdgeType::RelatedTo, EdgeType::Supersedes, EdgeType::ChangesApi]
);
let incoming: Vec<&str> = g
.in_edges("ADR-2026-01-01-001")
.map(|e| e.source.as_str())
.collect();
assert_eq!(incoming, vec!["ADR-2026-03-02-001"]);
let api_edge = g.edges.iter().find(|e| e.edge_type == EdgeType::ChangesApi).unwrap();
assert!(!api_edge.resolved);
assert_eq!(api_edge.target, "POST /login");
}
#[test]
fn test_orphans_preserved() {
let orphan = make_doc(
"TDE-2026-04-01-001-orphan.md",
DocType::Tde,
Frontmatter {
id: Some("TDE-2026-04-01-001".into()),
..Default::default()
},
);
let docs = [&orphan];
let g = Graph::build(&docs);
assert_eq!(g.nodes.len(), 1);
assert_eq!(g.orphans().count(), 1);
assert!(g.node("TDE-2026-04-01-001").unwrap().is_orphan());
}
#[test]
fn test_dangling_reference_surfaced() {
let doc = make_doc(
"ADR-2026-03-02-001-jwt.md",
DocType::Adr,
Frontmatter {
id: Some("ADR-2026-03-02-001".into()),
related: Some(vec!["MISSING-2026-01-01-001".into()]),
..Default::default()
},
);
let docs = [&doc];
let g = Graph::build(&docs);
assert_eq!(g.edges.len(), 1);
assert!(!g.edges[0].resolved);
assert_eq!(g.dangling_edges().count(), 1);
assert!(!g.node("ADR-2026-03-02-001").unwrap().is_orphan());
}
#[test]
fn test_filename_stem_fallback_id() {
let doc = make_doc(
"AILOG-2026-03-03-001-impl.md",
DocType::Ailog,
Frontmatter::default(),
);
let docs = [&doc];
let g = Graph::build(&docs);
let node = &g.nodes[0];
assert_eq!(node.id, "AILOG-2026-03-03-001-impl");
assert!(!node.has_explicit_id);
}
#[test]
fn test_originating_ailogs_edge() {
let ailog = make_doc(
"AILOG-2026-03-03-001-impl.md",
DocType::Ailog,
Frontmatter {
id: Some("AILOG-2026-03-03-001".into()),
..Default::default()
},
);
let adr = make_doc(
"ADR-2026-03-05-001-followup.md",
DocType::Adr,
Frontmatter {
id: Some("ADR-2026-03-05-001".into()),
originating_ailogs: Some(vec!["AILOG-2026-03-03-001".into()]),
..Default::default()
},
);
let docs = [&ailog, &adr];
let g = Graph::build(&docs);
let e = &g.edges[0];
assert_eq!(e.edge_type, EdgeType::OriginatesFrom);
assert!(e.resolved);
assert_eq!(e.source, "ADR-2026-03-05-001");
assert_eq!(e.target, "AILOG-2026-03-03-001");
}
#[test]
fn test_thread_full_component_and_depth() {
let req = make_doc(
"REQ-1-a.md",
DocType::Req,
Frontmatter {
id: Some("REQ-1".into()),
related: Some(vec!["ADR-1".into()]),
..Default::default()
},
);
let adr = make_doc(
"ADR-1-b.md",
DocType::Adr,
Frontmatter {
id: Some("ADR-1".into()),
related: Some(vec!["AILOG-1".into()]),
..Default::default()
},
);
let ailog = make_doc(
"AILOG-1-c.md",
DocType::Ailog,
Frontmatter {
id: Some("AILOG-1".into()),
..Default::default()
},
);
let tde = make_doc(
"TDE-1-d.md",
DocType::Tde,
Frontmatter {
id: Some("TDE-1".into()),
..Default::default()
},
);
let docs = [&req, &adr, &ailog, &tde];
let g = Graph::build(&docs);
let t = g.thread("ADR-1", None).unwrap();
let mut nodes = t.node_ids.clone();
nodes.sort();
assert_eq!(nodes, vec!["ADR-1", "AILOG-1", "REQ-1"]);
assert_eq!(t.edge_ids.len(), 2);
let t1 = g.thread("REQ-1", Some(1)).unwrap();
let mut nodes1 = t1.node_ids.clone();
nodes1.sort();
assert_eq!(nodes1, vec!["ADR-1", "REQ-1"]);
assert!(g.thread("NOPE", None).is_none());
let t_orphan = g.thread("TDE-1", None).unwrap();
assert_eq!(t_orphan.node_ids, vec!["TDE-1"]);
assert!(t_orphan.edge_ids.is_empty());
}
#[test]
fn test_thread_excludes_siblings() {
let a = make_doc(
"REQ-A-a.md",
DocType::Req,
Frontmatter {
id: Some("REQ-A".into()),
related: Some(vec!["ADR-C".into()]),
..Default::default()
},
);
let b = make_doc(
"REQ-B-b.md",
DocType::Req,
Frontmatter {
id: Some("REQ-B".into()),
related: Some(vec!["ADR-C".into()]),
..Default::default()
},
);
let c = make_doc(
"ADR-C-c.md",
DocType::Adr,
Frontmatter {
id: Some("ADR-C".into()),
..Default::default()
},
);
let docs = [&a, &b, &c];
let g = Graph::build(&docs);
let t = g.thread("REQ-A", None).unwrap();
let mut nodes = t.node_ids.clone();
nodes.sort();
assert_eq!(nodes, vec!["ADR-C", "REQ-A"]); assert_eq!(t.edge_ids, vec![0]);
let tc = g.thread("ADR-C", None).unwrap();
let mut nodes_c = tc.node_ids.clone();
nodes_c.sort();
assert_eq!(nodes_c, vec!["ADR-C", "REQ-A", "REQ-B"]);
}
#[test]
fn test_thread_includes_dangling_edges() {
let doc = make_doc(
"ADR-1-a.md",
DocType::Adr,
Frontmatter {
id: Some("ADR-1".into()),
related: Some(vec!["MISSING-1".into()]),
..Default::default()
},
);
let docs = [&doc];
let g = Graph::build(&docs);
let t = g.thread("ADR-1", None).unwrap();
assert_eq!(t.node_ids, vec!["ADR-1"]);
assert_eq!(t.edge_ids, vec![0]); }
#[test]
fn test_deterministic_order() {
let a = make_doc(
"REQ-2026-03-01-001-a.md",
DocType::Req,
Frontmatter {
id: Some("REQ-2026-03-01-001".into()),
related: Some(vec!["B-2".into(), "B-1".into()]),
..Default::default()
},
);
let docs = [&a];
let g = Graph::build(&docs);
let targets: Vec<&str> = g.edges.iter().map(|e| e.target.as_str()).collect();
assert_eq!(targets, vec!["B-2", "B-1"]);
}
}