1use edda_ledger::Ledger;
4use edda_store::registry::ProjectEntry;
5use serde::Serialize;
6use std::collections::HashSet;
7use std::path::Path;
8
9#[derive(Debug, Clone, Serialize)]
11pub struct GraphNode {
12 pub event_id: String,
13 pub key: String,
14 pub value: String,
15 pub project: String,
16 pub ts: String,
17}
18
19#[derive(Debug, Clone, Serialize)]
21pub struct GraphEdge {
22 pub source: String,
23 pub target: String,
24 pub rel: String,
25}
26
27#[derive(Debug, Clone, Serialize)]
29pub struct DependencyGraph {
30 pub nodes: Vec<GraphNode>,
31 pub edges: Vec<GraphEdge>,
32}
33
34pub fn build_dependency_graph(projects: &[ProjectEntry]) -> DependencyGraph {
38 let mut nodes = Vec::new();
39 let mut edges = Vec::new();
40 let mut decision_ids: HashSet<String> = HashSet::new();
41
42 for entry in projects {
43 let root = Path::new(&entry.path);
44 let ledger = match Ledger::open(root) {
45 Ok(l) => l,
46 Err(_) => continue,
47 };
48
49 let events = match ledger.iter_events() {
50 Ok(e) => e,
51 Err(_) => continue,
52 };
53
54 for event in &events {
55 if edda_core::decision::is_decision(&event.payload) {
56 let dec = edda_core::decision::extract_decision(&event.payload);
57 let key = dec.as_ref().map(|d| d.key.clone()).unwrap_or_default();
58 let value = dec.as_ref().map(|d| d.value.clone()).unwrap_or_default();
59
60 nodes.push(GraphNode {
61 event_id: event.event_id.clone(),
62 key,
63 value,
64 project: entry.name.clone(),
65 ts: event.ts.clone(),
66 });
67 decision_ids.insert(event.event_id.clone());
68 }
69
70 for prov in &event.refs.provenance {
72 edges.push(GraphEdge {
73 source: event.event_id.clone(),
74 target: prov.target.clone(),
75 rel: prov.rel.clone(),
76 });
77 }
78 }
79 }
80
81 let edges = edges
83 .into_iter()
84 .filter(|e| decision_ids.contains(&e.source) || decision_ids.contains(&e.target))
85 .collect();
86
87 DependencyGraph { nodes, edges }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 fn make_decision_payload(
95 key: &str,
96 value: &str,
97 reason: Option<&str>,
98 ) -> edda_core::types::DecisionPayload {
99 edda_core::types::DecisionPayload {
100 key: key.to_string(),
101 value: value.to_string(),
102 reason: reason.map(|r| r.to_string()),
103 scope: None,
104 authority: None,
105 affected_paths: None,
106 tags: None,
107 review_after: None,
108 reversibility: None,
109 village_id: None,
110 cites: None,
111 }
112 }
113
114 #[test]
115 fn empty_projects_returns_empty_graph() {
116 let graph = build_dependency_graph(&[]);
117 assert!(graph.nodes.is_empty());
118 assert!(graph.edges.is_empty());
119 }
120
121 #[test]
122 fn single_decision_produces_node_no_edges() {
123 let tmp = tempfile::tempdir().unwrap();
124 let root = tmp.path();
125
126 let paths = edda_ledger::EddaPaths::discover(root);
127 edda_ledger::ledger::init_workspace(&paths).unwrap();
128 edda_ledger::ledger::init_head(&paths, "main").unwrap();
129
130 let ledger = Ledger::open(root).unwrap();
131 let payload = make_decision_payload("db.engine", "sqlite", Some("embedded"));
132 let event = edda_core::event::new_decision_event("main", None, "user", &payload).unwrap();
133 ledger.append_event(&event).unwrap();
134
135 let entry = ProjectEntry {
136 project_id: "test".to_string(),
137 path: root.to_string_lossy().to_string(),
138 name: "test-project".to_string(),
139 registered_at: "2026-03-01T00:00:00Z".to_string(),
140 last_seen: "2026-03-01T00:00:00Z".to_string(),
141 group: None,
142 };
143
144 let graph = build_dependency_graph(&[entry]);
145 assert_eq!(graph.nodes.len(), 1);
146 assert_eq!(graph.nodes[0].key, "db.engine");
147 assert_eq!(graph.nodes[0].value, "sqlite");
148 assert!(graph.edges.is_empty());
149 }
150
151 #[test]
152 fn superseded_decision_produces_edge() {
153 let tmp = tempfile::tempdir().unwrap();
154 let root = tmp.path();
155
156 let paths = edda_ledger::EddaPaths::discover(root);
157 edda_ledger::ledger::init_workspace(&paths).unwrap();
158 edda_ledger::ledger::init_head(&paths, "main").unwrap();
159
160 let ledger = Ledger::open(root).unwrap();
161
162 let payload1 = make_decision_payload("db.engine", "sqlite", Some("mvp"));
164 let event1 = edda_core::event::new_decision_event("main", None, "user", &payload1).unwrap();
165 ledger.append_event(&event1).unwrap();
166 let event1_id = event1.event_id.clone();
167
168 let payload2 = make_decision_payload("db.engine", "postgres", Some("scale"));
170 let mut event2 =
171 edda_core::event::new_decision_event("main", None, "user", &payload2).unwrap();
172 event2.refs.provenance.push(edda_core::types::Provenance {
173 target: event1_id,
174 rel: edda_core::types::rel::SUPERSEDES.to_string(),
175 note: Some("key 'db.engine' re-decided".to_string()),
176 });
177 event2.parent_hash = ledger.last_event_hash().unwrap();
178 edda_core::event::finalize_event(&mut event2).unwrap();
179 ledger.append_event(&event2).unwrap();
180
181 let entry = ProjectEntry {
182 project_id: "test".to_string(),
183 path: root.to_string_lossy().to_string(),
184 name: "test-project".to_string(),
185 registered_at: "2026-03-01T00:00:00Z".to_string(),
186 last_seen: "2026-03-01T00:00:00Z".to_string(),
187 group: None,
188 };
189
190 let graph = build_dependency_graph(&[entry]);
191 assert_eq!(graph.nodes.len(), 2);
192 assert!(
194 !graph.edges.is_empty(),
195 "Expected at least one edge from supersession"
196 );
197 }
198}