1use std::collections::{HashMap, HashSet, VecDeque};
10
11use crate::model::Graph;
12
13struct Neighbor<'a> {
16 node: &'a str,
17 lines: Vec<usize>,
18}
19
20type Adjacency<'a> = HashMap<&'a str, Vec<Neighbor<'a>>>;
22
23fn lines_suffix(lines: &[usize]) -> String {
25 if lines.is_empty() {
26 return String::new();
27 }
28 let joined = lines
29 .iter()
30 .map(usize::to_string)
31 .collect::<Vec<_>>()
32 .join(",");
33 format!(":{joined}")
34}
35
36#[derive(Clone, Copy, PartialEq, Eq)]
39pub enum Direction {
40 Inbound,
41 Outbound,
42 Both,
43}
44
45#[derive(Debug, Clone, serde::Serialize)]
47pub struct Impacted {
48 pub node: String,
49 pub via: String,
51 pub depth: usize,
52 pub impact_radius: usize,
54 pub betweenness: f64,
56 pub lines: Vec<usize>,
60 pub fix: String,
61}
62
63impl Impacted {
64 pub fn location(&self) -> String {
66 format!("{}{}", self.node, lines_suffix(&self.lines))
67 }
68}
69
70pub fn compute(
73 graph: &Graph,
74 seeds: &[String],
75 direction: Direction,
76 max_depth: Option<usize>,
77) -> Vec<Impacted> {
78 let (forward, reverse) = adjacency(graph);
79 let betweenness = betweenness(graph);
80
81 let mut visited: HashSet<&str> = HashSet::new();
82 let mut queue: VecDeque<(&str, usize)> = VecDeque::new();
83
84 for seed in seeds {
85 if let Some((key, _)) = graph.nodes.get_key_value(seed) {
86 let seed = key.as_str();
87 if visited.insert(seed) {
88 queue.push_back((seed, 0));
89 }
90 }
91 }
92
93 let mut reached: Vec<(&str, &str, usize, Vec<usize>)> = Vec::new();
95
96 while let Some((node, depth)) = queue.pop_front() {
97 if max_depth.is_some_and(|max| depth >= max) {
98 continue;
99 }
100 let mut neighbors: Vec<(&str, Vec<usize>)> = Vec::new();
104 if matches!(direction, Direction::Inbound | Direction::Both) {
105 for n in reverse.get(node).into_iter().flatten() {
106 neighbors.push((n.node, n.lines.clone()));
107 }
108 }
109 if matches!(direction, Direction::Outbound | Direction::Both) {
110 for n in forward.get(node).into_iter().flatten() {
111 neighbors.push((n.node, Vec::new()));
112 }
113 }
114 for (next, lines) in neighbors {
115 if visited.insert(next) {
116 reached.push((next, node, depth + 1, lines));
117 queue.push_back((next, depth + 1));
118 }
119 }
120 }
121
122 let mut impacted: Vec<Impacted> = reached
123 .into_iter()
124 .map(|(node, via, depth, lines)| {
125 let locator = lines_suffix(&lines);
126 Impacted {
127 node: node.to_string(),
128 via: via.to_string(),
129 depth,
130 impact_radius: reverse_reach_count(&reverse, node),
131 betweenness: betweenness.get(node).copied().unwrap_or(0.0),
132 lines,
133 fix: format!(
134 "{via} may change — review {node}{locator} to ensure it still accurately reflects {via}"
135 ),
136 }
137 })
138 .collect();
139
140 impacted.sort_by(|a, b| {
142 let pa = a.impact_radius as f64 / a.depth as f64 + a.betweenness;
143 let pb = b.impact_radius as f64 / b.depth as f64 + b.betweenness;
144 pb.partial_cmp(&pa)
145 .unwrap_or(std::cmp::Ordering::Equal)
146 .then_with(|| a.node.cmp(&b.node))
147 });
148 impacted
149}
150
151fn adjacency(graph: &Graph) -> (Adjacency<'_>, Adjacency<'_>) {
154 let mut forward: Adjacency = HashMap::new();
155 let mut reverse: Adjacency = HashMap::new();
156 for edge in &graph.edges {
157 let lines = edge.lines();
158 forward
159 .entry(edge.source.as_str())
160 .or_default()
161 .push(Neighbor {
162 node: edge.target.as_str(),
163 lines: lines.clone(),
164 });
165 reverse
166 .entry(edge.target.as_str())
167 .or_default()
168 .push(Neighbor {
169 node: edge.source.as_str(),
170 lines,
171 });
172 }
173 (forward, reverse)
174}
175
176fn reverse_reach_count(reverse: &Adjacency, node: &str) -> usize {
179 let mut visited: HashSet<&str> = HashSet::new();
180 visited.insert(node);
181 let mut queue: VecDeque<&str> = VecDeque::new();
182 queue.push_back(node);
183 let mut count = 0;
184 while let Some(current) = queue.pop_front() {
185 for neighbor in reverse.get(current).into_iter().flatten() {
186 let dependent = neighbor.node;
187 if visited.insert(dependent) {
188 count += 1;
189 queue.push_back(dependent);
190 }
191 }
192 }
193 count
194}
195
196fn betweenness(graph: &Graph) -> HashMap<&str, f64> {
199 let nodes: Vec<&str> = graph.nodes.keys().map(String::as_str).collect();
200 let mut centrality: HashMap<&str, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
201
202 let n = nodes.len();
203 if n <= 2 {
204 return centrality;
205 }
206
207 let node_set: HashSet<&str> = nodes.iter().copied().collect();
209 let mut forward: HashMap<&str, Vec<&str>> = HashMap::new();
210 for edge in &graph.edges {
211 if node_set.contains(edge.target.as_str()) {
212 forward
213 .entry(edge.source.as_str())
214 .or_default()
215 .push(edge.target.as_str());
216 }
217 }
218
219 for &s in &nodes {
220 let mut stack: Vec<&str> = Vec::new();
221 let mut predecessors: HashMap<&str, Vec<&str>> =
222 nodes.iter().map(|&n| (n, Vec::new())).collect();
223 let mut sigma: HashMap<&str, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
224 let mut dist: HashMap<&str, i64> = nodes.iter().map(|&n| (n, -1)).collect();
225
226 sigma.insert(s, 1.0);
227 dist.insert(s, 0);
228
229 let mut queue = VecDeque::new();
230 queue.push_back(s);
231
232 while let Some(v) = queue.pop_front() {
233 stack.push(v);
234 let v_dist = dist[v];
235 for &w in forward.get(v).into_iter().flatten() {
236 if dist[w] < 0 {
237 dist.insert(w, v_dist + 1);
238 queue.push_back(w);
239 }
240 if dist[w] == v_dist + 1 {
241 *sigma.get_mut(w).unwrap() += sigma[v];
242 predecessors.get_mut(w).unwrap().push(v);
243 }
244 }
245 }
246
247 let mut delta: HashMap<&str, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
248 while let Some(w) = stack.pop() {
249 let preds = std::mem::take(predecessors.get_mut(w).unwrap());
252 for v in preds {
253 let contribution = (sigma[v] / sigma[w]) * (1.0 + delta[w]);
254 *delta.get_mut(v).unwrap() += contribution;
255 }
256 if w != s {
257 *centrality.get_mut(w).unwrap() += delta[w];
258 }
259 }
260 }
261
262 let norm = ((n - 1) * (n - 2)) as f64;
263 for score in centrality.values_mut() {
264 *score /= norm;
265 }
266 centrality
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use crate::compose::compose;
273 use crate::model::{Edge, GraphSet, Metadata, Node};
274 use serde_json::json;
275
276 fn fs_node() -> Node {
277 Node::new(
278 json!({ "type": "file", "hash": "b3:x" })
279 .as_object()
280 .unwrap()
281 .clone(),
282 )
283 }
284
285 fn graph_from(edges: &[(&str, &str)]) -> Graph {
288 let mut fs = Graph::labeled("fs");
289 for (s, t) in edges {
290 fs.set_node(*s, fs_node());
291 fs.set_node(*t, fs_node());
292 fs.add_edge(Edge::new(*s, *t));
293 }
294 compose(&GraphSet::new(vec![fs]))
295 }
296
297 fn impacted_nodes(impacted: &[Impacted]) -> Vec<&str> {
298 impacted.iter().map(|i| i.node.as_str()).collect()
299 }
300
301 #[test]
302 fn inbound_finds_transitive_dependents() {
303 let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
305 let impacted = compute(&graph, &["c.md".into()], Direction::Inbound, None);
306 let nodes = impacted_nodes(&impacted);
307 assert!(nodes.contains(&"b.md"));
308 assert!(nodes.contains(&"a.md"));
309 let b = impacted.iter().find(|i| i.node == "b.md").unwrap();
310 assert_eq!(b.depth, 1);
311 assert_eq!(b.via, "c.md");
312 let a = impacted.iter().find(|i| i.node == "a.md").unwrap();
313 assert_eq!(a.depth, 2);
314 }
315
316 #[test]
317 fn inbound_surfaces_link_lines() {
318 let mut g = Graph::labeled("composed");
321 g.set_node("dependent.md", fs_node());
322 g.set_node("dep.md", fs_node());
323 let mut meta = Metadata::new();
324 meta.insert("@markdown".into(), json!({ "lines": [49, 12] }));
325 g.add_edge(Edge::with_metadata("dependent.md", "dep.md", meta));
326
327 let inbound = compute(&g, &["dep.md".into()], Direction::Inbound, None);
328 let d = inbound.iter().find(|i| i.node == "dependent.md").unwrap();
329 assert_eq!(d.lines, vec![12, 49], "sorted, deduped");
330 assert_eq!(d.location(), "dependent.md:12,49");
331 assert!(
332 d.fix.contains("review dependent.md:12,49"),
333 "got: {}",
334 d.fix
335 );
336
337 let outbound = compute(&g, &["dependent.md".into()], Direction::Outbound, None);
340 let dep = outbound.iter().find(|i| i.node == "dep.md").unwrap();
341 assert!(dep.lines.is_empty());
342 }
343
344 #[test]
345 fn cycle_terminates() {
346 let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md"), ("c.md", "a.md")]);
348 let impacted = compute(&graph, &["a.md".into()], Direction::Inbound, None);
349 let nodes = impacted_nodes(&impacted);
350 assert_eq!(nodes.len(), 2, "seed excluded, others reached once");
351 assert!(nodes.contains(&"b.md") && nodes.contains(&"c.md"));
352 }
353
354 #[test]
355 fn depth_limit_truncates() {
356 let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
357 let impacted = compute(&graph, &["c.md".into()], Direction::Inbound, Some(1));
358 let nodes = impacted_nodes(&impacted);
359 assert_eq!(nodes, vec!["b.md"], "depth 1 stops before a.md");
360 }
361
362 #[test]
363 fn outbound_finds_dependencies() {
364 let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
365 let impacted = compute(&graph, &["a.md".into()], Direction::Outbound, None);
366 let nodes = impacted_nodes(&impacted);
367 assert!(nodes.contains(&"b.md") && nodes.contains(&"c.md"));
368 }
369
370 #[test]
371 fn radius_counts_transitive_dependents() {
372 let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
373 let impacted = compute(&graph, &["c.md".into()], Direction::Inbound, None);
374 let b = impacted.iter().find(|i| i.node == "b.md").unwrap();
376 assert_eq!(b.impact_radius, 1);
377 }
378}