1use std::collections::{BTreeSet, HashMap};
9
10use petgraph::{
11 Direction,
12 algo::condensation,
13 graph::{DiGraph as PetDiGraph, NodeIndex},
14 visit::{EdgeRef, IntoNodeIdentifiers},
15};
16
17use crate::graph::{
18 build::RawGraph, compute_critical_path, cycles::find_all_cycles, normalize::NormalizedGraph,
19 stats::GraphStats,
20};
21
22pub type DiGraph = PetDiGraph<String, ()>;
26
27#[derive(Debug, Clone, PartialEq)]
29pub struct HealthMetrics {
30 pub density: f64,
32 pub scc_count: usize,
34 pub critical_path_length: usize,
36 pub blocker_count: usize,
38}
39
40#[must_use]
48pub fn topological_layers(graph: &DiGraph, scope: Option<&str>) -> Vec<Vec<String>> {
49 let scoped_ids = scoped_node_ids(graph, scope);
50 if scoped_ids.is_empty() {
51 return Vec::new();
52 }
53
54 let scoped_graph = build_scoped_graph(graph, &scoped_ids);
55
56 let condensed: PetDiGraph<Vec<String>, ()> = condensation(scoped_graph, true);
59
60 let mut indegree: HashMap<NodeIndex, usize> = condensed
61 .node_identifiers()
62 .map(|idx| {
63 (
64 idx,
65 condensed
66 .neighbors_directed(idx, Direction::Incoming)
67 .count(),
68 )
69 })
70 .collect();
71
72 let mut ready: Vec<NodeIndex> = indegree
73 .iter()
74 .filter_map(|(idx, deg)| (*deg == 0).then_some(*idx))
75 .collect();
76 ready.sort_by(|a, b| representative(&condensed, *a).cmp(representative(&condensed, *b)));
77
78 let mut layers: Vec<Vec<String>> = Vec::new();
79
80 while !ready.is_empty() {
81 let current = std::mem::take(&mut ready);
82 let mut next_ready: Vec<NodeIndex> = Vec::new();
83 let mut layer: Vec<String> = Vec::new();
84
85 for idx in current {
86 let mut members = condensed.node_weight(idx).cloned().unwrap_or_default();
87 members.sort_unstable();
88 layer.extend(members);
89
90 for edge in condensed.edges_directed(idx, Direction::Outgoing) {
91 let target = edge.target();
92 if let Some(entry) = indegree.get_mut(&target)
93 && *entry > 0
94 {
95 *entry -= 1;
96 if *entry == 0 {
97 next_ready.push(target);
98 }
99 }
100 }
101
102 indegree.remove(&idx);
103 }
104
105 layer.sort_unstable();
106 if !layer.is_empty() {
107 layers.push(layer);
108 }
109
110 next_ready
111 .sort_by(|a, b| representative(&condensed, *a).cmp(representative(&condensed, *b)));
112 next_ready.dedup();
113 ready = next_ready;
114 }
115
116 if !indegree.is_empty() {
119 let mut leftovers: Vec<NodeIndex> = indegree.keys().copied().collect();
120 leftovers
121 .sort_by(|a, b| representative(&condensed, *a).cmp(representative(&condensed, *b)));
122
123 for idx in leftovers {
124 let mut layer = condensed.node_weight(idx).cloned().unwrap_or_default();
125 layer.sort_unstable();
126 if !layer.is_empty() {
127 layers.push(layer);
128 }
129 }
130 }
131
132 layers
133}
134
135#[must_use]
137pub fn health_metrics(graph: &DiGraph) -> HealthMetrics {
138 let node_map: HashMap<String, NodeIndex> = graph
139 .node_identifiers()
140 .filter_map(|idx| graph.node_weight(idx).map(|id| (id.clone(), idx)))
141 .collect();
142
143 let raw = RawGraph {
144 graph: graph.clone(),
145 node_map,
146 content_hash: graph_content_hash(graph),
147 };
148
149 let normalized = NormalizedGraph::from_raw(raw);
150 let stats = GraphStats::from_normalized(&normalized);
151 let cp = compute_critical_path(&normalized);
152
153 let blocker_count = graph
154 .node_identifiers()
155 .filter(|&idx| {
156 graph
157 .neighbors_directed(idx, Direction::Outgoing)
158 .next()
159 .is_some()
160 })
161 .count();
162
163 HealthMetrics {
164 density: stats.density,
165 scc_count: stats.scc_count,
166 critical_path_length: cp.total_length,
167 blocker_count,
168 }
169}
170
171#[must_use]
177pub fn find_sccs(graph: &DiGraph) -> Vec<Vec<String>> {
178 find_all_cycles(graph)
179}
180
181fn scoped_node_ids(graph: &DiGraph, scope: Option<&str>) -> BTreeSet<String> {
182 scope.map_or_else(
183 || graph.node_weights().cloned().collect(),
184 |scope_id| {
185 let child_prefix = format!("{scope_id}.");
186 graph
187 .node_weights()
188 .filter(|id| id.as_str() == scope_id || id.starts_with(&child_prefix))
189 .cloned()
190 .collect()
191 },
192 )
193}
194
195fn build_scoped_graph(graph: &DiGraph, scoped_ids: &BTreeSet<String>) -> DiGraph {
196 let mut scoped = DiGraph::new();
197
198 let raw_nodes: HashMap<&str, NodeIndex> = graph
199 .node_identifiers()
200 .filter_map(|idx| graph.node_weight(idx).map(|id| (id.as_str(), idx)))
201 .collect();
202
203 let mut scoped_nodes: HashMap<&str, NodeIndex> = HashMap::with_capacity(scoped_ids.len());
204
205 for item_id in scoped_ids {
206 let idx = scoped.add_node(item_id.clone());
207 scoped_nodes.insert(item_id.as_str(), idx);
208 }
209
210 for from_id in scoped_ids {
211 let Some(&from_raw_idx) = raw_nodes.get(from_id.as_str()) else {
212 continue;
213 };
214 let Some(&from_scoped_idx) = scoped_nodes.get(from_id.as_str()) else {
215 continue;
216 };
217
218 for to_raw_idx in graph.neighbors_directed(from_raw_idx, Direction::Outgoing) {
219 let Some(to_id) = graph.node_weight(to_raw_idx).map(String::as_str) else {
220 continue;
221 };
222 let Some(&to_scoped_idx) = scoped_nodes.get(to_id) else {
223 continue;
224 };
225
226 if !scoped.contains_edge(from_scoped_idx, to_scoped_idx) {
227 scoped.add_edge(from_scoped_idx, to_scoped_idx, ());
228 }
229 }
230 }
231
232 scoped
233}
234
235fn representative(graph: &PetDiGraph<Vec<String>, ()>, idx: NodeIndex) -> &str {
236 graph
237 .node_weight(idx)
238 .and_then(|members| members.iter().min())
239 .map_or("", String::as_str)
240}
241
242fn graph_content_hash(graph: &DiGraph) -> String {
243 let mut edges: Vec<(String, String)> = graph
244 .edge_references()
245 .filter_map(|edge| {
246 let source = graph.node_weight(edge.source())?;
247 let target = graph.node_weight(edge.target())?;
248 Some((source.clone(), target.clone()))
249 })
250 .collect();
251 edges.sort_unstable();
252
253 let mut hasher = blake3::Hasher::new();
254 for (source, target) in edges {
255 hasher.update(source.as_bytes());
256 hasher.update(b"\x00");
257 hasher.update(target.as_bytes());
258 hasher.update(b"\x00");
259 }
260
261 format!("blake3:{}", hasher.finalize())
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn build_graph(nodes: &[&str], edges: &[(&str, &str)]) -> DiGraph {
269 let mut graph = DiGraph::new();
270 let mut node_map: HashMap<&str, NodeIndex> = HashMap::new();
271
272 for id in nodes {
273 let idx = graph.add_node((*id).to_string());
274 node_map.insert(*id, idx);
275 }
276
277 for (from, to) in edges {
278 let from_idx = node_map[from];
279 let to_idx = node_map[to];
280 graph.add_edge(from_idx, to_idx, ());
281 }
282
283 graph
284 }
285
286 #[test]
287 fn topological_layers_linear_chain() {
288 let graph = build_graph(
289 &["bn-a", "bn-b", "bn-c"],
290 &[("bn-a", "bn-b"), ("bn-b", "bn-c")],
291 );
292
293 let layers = topological_layers(&graph, None);
294
295 assert_eq!(
296 layers,
297 vec![
298 vec!["bn-a".to_string()],
299 vec!["bn-b".to_string()],
300 vec!["bn-c".to_string()],
301 ]
302 );
303 }
304
305 #[test]
306 fn topological_layers_parallel_work() {
307 let graph = build_graph(
308 &["bn-a", "bn-b", "bn-c", "bn-d"],
309 &[("bn-a", "bn-c"), ("bn-b", "bn-c"), ("bn-c", "bn-d")],
310 );
311
312 let layers = topological_layers(&graph, None);
313
314 assert_eq!(
315 layers,
316 vec![
317 vec!["bn-a".to_string(), "bn-b".to_string()],
318 vec!["bn-c".to_string()],
319 vec!["bn-d".to_string()],
320 ]
321 );
322 }
323
324 #[test]
325 fn topological_layers_condenses_cycles() {
326 let graph = build_graph(
327 &["bn-a", "bn-b", "bn-c"],
328 &[("bn-a", "bn-b"), ("bn-b", "bn-a"), ("bn-b", "bn-c")],
329 );
330
331 let layers = topological_layers(&graph, None);
332
333 assert_eq!(
334 layers,
335 vec![
336 vec!["bn-a".to_string(), "bn-b".to_string()],
337 vec!["bn-c".to_string()],
338 ]
339 );
340 }
341
342 #[test]
343 fn find_sccs_detects_cycles_and_self_loops() {
344 let graph = build_graph(
345 &["bn-a", "bn-b", "bn-c", "bn-d"],
346 &[("bn-a", "bn-b"), ("bn-b", "bn-a"), ("bn-c", "bn-c")],
347 );
348
349 let sccs = find_sccs(&graph);
350
351 assert_eq!(
352 sccs,
353 vec![
354 vec!["bn-a".to_string(), "bn-b".to_string()],
355 vec!["bn-c".to_string()],
356 ]
357 );
358 }
359
360 #[test]
361 fn health_metrics_reports_expected_values() {
362 let graph = build_graph(
363 &["bn-a", "bn-b", "bn-c"],
364 &[("bn-a", "bn-b"), ("bn-b", "bn-c"), ("bn-a", "bn-c")],
365 );
366
367 let metrics = health_metrics(&graph);
368
369 assert!((metrics.density - 0.5).abs() < f64::EPSILON);
370 assert_eq!(metrics.scc_count, 3);
371 assert_eq!(metrics.critical_path_length, 3);
372 assert_eq!(metrics.blocker_count, 2);
373 }
374}