1use std::collections::{HashMap, HashSet, VecDeque};
10
11use crate::model::Graph;
12
13type Adjacency<'a> = HashMap<&'a str, Vec<&'a str>>;
15
16#[derive(Clone, Copy, PartialEq, Eq)]
19pub enum Direction {
20 Inbound,
21 Outbound,
22 Both,
23}
24
25#[derive(Debug, Clone, serde::Serialize)]
27pub struct Impacted {
28 pub node: String,
29 pub via: String,
31 pub depth: usize,
32 pub impact_radius: usize,
34 pub betweenness: f64,
36 pub fix: String,
37}
38
39pub fn compute(
42 graph: &Graph,
43 seeds: &[String],
44 direction: Direction,
45 max_depth: Option<usize>,
46) -> Vec<Impacted> {
47 let (forward, reverse) = adjacency(graph);
48 let betweenness = betweenness(graph);
49
50 let mut visited: HashSet<&str> = HashSet::new();
51 let mut queue: VecDeque<(&str, usize)> = VecDeque::new();
52
53 for seed in seeds {
54 if let Some((key, _)) = graph.nodes.get_key_value(seed) {
55 let seed = key.as_str();
56 if visited.insert(seed) {
57 queue.push_back((seed, 0));
58 }
59 }
60 }
61
62 let mut reached: Vec<(&str, &str, usize)> = Vec::new();
64
65 while let Some((node, depth)) = queue.pop_front() {
66 if max_depth.is_some_and(|max| depth >= max) {
67 continue;
68 }
69 let mut neighbors: Vec<&str> = Vec::new();
70 if matches!(direction, Direction::Inbound | Direction::Both) {
71 neighbors.extend(reverse.get(node).into_iter().flatten().copied());
72 }
73 if matches!(direction, Direction::Outbound | Direction::Both) {
74 neighbors.extend(forward.get(node).into_iter().flatten().copied());
75 }
76 for next in neighbors {
77 if visited.insert(next) {
78 reached.push((next, node, depth + 1));
79 queue.push_back((next, depth + 1));
80 }
81 }
82 }
83
84 let mut impacted: Vec<Impacted> = reached
85 .into_iter()
86 .map(|(node, via, depth)| Impacted {
87 node: node.to_string(),
88 via: via.to_string(),
89 depth,
90 impact_radius: reverse_reach_count(&reverse, node),
91 betweenness: betweenness.get(node).copied().unwrap_or(0.0),
92 fix: format!(
93 "{via} may change — review {node} to ensure it still accurately reflects {via}"
94 ),
95 })
96 .collect();
97
98 impacted.sort_by(|a, b| {
100 let pa = a.impact_radius as f64 / a.depth as f64 + a.betweenness;
101 let pb = b.impact_radius as f64 / b.depth as f64 + b.betweenness;
102 pb.partial_cmp(&pa)
103 .unwrap_or(std::cmp::Ordering::Equal)
104 .then_with(|| a.node.cmp(&b.node))
105 });
106 impacted
107}
108
109fn adjacency(graph: &Graph) -> (Adjacency<'_>, Adjacency<'_>) {
112 let mut forward: Adjacency = HashMap::new();
113 let mut reverse: Adjacency = HashMap::new();
114 for edge in &graph.edges {
115 forward
116 .entry(edge.source.as_str())
117 .or_default()
118 .push(edge.target.as_str());
119 reverse
120 .entry(edge.target.as_str())
121 .or_default()
122 .push(edge.source.as_str());
123 }
124 (forward, reverse)
125}
126
127fn reverse_reach_count(reverse: &Adjacency, node: &str) -> usize {
130 let mut visited: HashSet<&str> = HashSet::new();
131 visited.insert(node);
132 let mut queue: VecDeque<&str> = VecDeque::new();
133 queue.push_back(node);
134 let mut count = 0;
135 while let Some(current) = queue.pop_front() {
136 for &dependent in reverse.get(current).into_iter().flatten() {
137 if visited.insert(dependent) {
138 count += 1;
139 queue.push_back(dependent);
140 }
141 }
142 }
143 count
144}
145
146fn betweenness(graph: &Graph) -> HashMap<&str, f64> {
149 let nodes: Vec<&str> = graph.nodes.keys().map(String::as_str).collect();
150 let mut centrality: HashMap<&str, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
151
152 let n = nodes.len();
153 if n <= 2 {
154 return centrality;
155 }
156
157 let node_set: HashSet<&str> = nodes.iter().copied().collect();
159 let mut forward: HashMap<&str, Vec<&str>> = HashMap::new();
160 for edge in &graph.edges {
161 if node_set.contains(edge.target.as_str()) {
162 forward
163 .entry(edge.source.as_str())
164 .or_default()
165 .push(edge.target.as_str());
166 }
167 }
168
169 for &s in &nodes {
170 let mut stack: Vec<&str> = Vec::new();
171 let mut predecessors: HashMap<&str, Vec<&str>> =
172 nodes.iter().map(|&n| (n, Vec::new())).collect();
173 let mut sigma: HashMap<&str, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
174 let mut dist: HashMap<&str, i64> = nodes.iter().map(|&n| (n, -1)).collect();
175
176 sigma.insert(s, 1.0);
177 dist.insert(s, 0);
178
179 let mut queue = VecDeque::new();
180 queue.push_back(s);
181
182 while let Some(v) = queue.pop_front() {
183 stack.push(v);
184 let v_dist = dist[v];
185 for &w in forward.get(v).into_iter().flatten() {
186 if dist[w] < 0 {
187 dist.insert(w, v_dist + 1);
188 queue.push_back(w);
189 }
190 if dist[w] == v_dist + 1 {
191 *sigma.get_mut(w).unwrap() += sigma[v];
192 predecessors.get_mut(w).unwrap().push(v);
193 }
194 }
195 }
196
197 let mut delta: HashMap<&str, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
198 while let Some(w) = stack.pop() {
199 let preds = std::mem::take(predecessors.get_mut(w).unwrap());
202 for v in preds {
203 let contribution = (sigma[v] / sigma[w]) * (1.0 + delta[w]);
204 *delta.get_mut(v).unwrap() += contribution;
205 }
206 if w != s {
207 *centrality.get_mut(w).unwrap() += delta[w];
208 }
209 }
210 }
211
212 let norm = ((n - 1) * (n - 2)) as f64;
213 for score in centrality.values_mut() {
214 *score /= norm;
215 }
216 centrality
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::compose::compose;
223 use crate::model::{Edge, GraphSet, Node};
224 use serde_json::json;
225
226 fn fs_node() -> Node {
227 Node::new(
228 json!({ "type": "file", "hash": "b3:x" })
229 .as_object()
230 .unwrap()
231 .clone(),
232 )
233 }
234
235 fn graph_from(edges: &[(&str, &str)]) -> Graph {
238 let mut fs = Graph::labeled("fs");
239 for (s, t) in edges {
240 fs.set_node(*s, fs_node());
241 fs.set_node(*t, fs_node());
242 fs.add_edge(Edge::new(*s, *t));
243 }
244 compose(&GraphSet::new(vec![fs]))
245 }
246
247 fn impacted_nodes(impacted: &[Impacted]) -> Vec<&str> {
248 impacted.iter().map(|i| i.node.as_str()).collect()
249 }
250
251 #[test]
252 fn inbound_finds_transitive_dependents() {
253 let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
255 let impacted = compute(&graph, &["c.md".into()], Direction::Inbound, None);
256 let nodes = impacted_nodes(&impacted);
257 assert!(nodes.contains(&"b.md"));
258 assert!(nodes.contains(&"a.md"));
259 let b = impacted.iter().find(|i| i.node == "b.md").unwrap();
260 assert_eq!(b.depth, 1);
261 assert_eq!(b.via, "c.md");
262 let a = impacted.iter().find(|i| i.node == "a.md").unwrap();
263 assert_eq!(a.depth, 2);
264 }
265
266 #[test]
267 fn cycle_terminates() {
268 let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md"), ("c.md", "a.md")]);
270 let impacted = compute(&graph, &["a.md".into()], Direction::Inbound, None);
271 let nodes = impacted_nodes(&impacted);
272 assert_eq!(nodes.len(), 2, "seed excluded, others reached once");
273 assert!(nodes.contains(&"b.md") && nodes.contains(&"c.md"));
274 }
275
276 #[test]
277 fn depth_limit_truncates() {
278 let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
279 let impacted = compute(&graph, &["c.md".into()], Direction::Inbound, Some(1));
280 let nodes = impacted_nodes(&impacted);
281 assert_eq!(nodes, vec!["b.md"], "depth 1 stops before a.md");
282 }
283
284 #[test]
285 fn outbound_finds_dependencies() {
286 let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
287 let impacted = compute(&graph, &["a.md".into()], Direction::Outbound, None);
288 let nodes = impacted_nodes(&impacted);
289 assert!(nodes.contains(&"b.md") && nodes.contains(&"c.md"));
290 }
291
292 #[test]
293 fn radius_counts_transitive_dependents() {
294 let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
295 let impacted = compute(&graph, &["c.md".into()], Direction::Inbound, None);
296 let b = impacted.iter().find(|i| i.node == "b.md").unwrap();
298 assert_eq!(b.impact_radius, 1);
299 }
300}