drft/analyses/
impact_radius.rs1use super::{Analysis, AnalysisContext};
2use std::collections::{HashSet, VecDeque};
3
4#[derive(Debug, Clone, serde::Serialize)]
5pub struct ImpactRadiusNode {
6 pub node: String,
7 pub radius: usize,
9 pub direct_dependents: usize,
11 pub max_depth: usize,
13}
14
15#[derive(Debug, Clone, serde::Serialize)]
16pub struct ImpactRadiusResult {
17 pub nodes: Vec<ImpactRadiusNode>,
18}
19
20pub struct ImpactRadius;
21
22impl Analysis for ImpactRadius {
23 type Output = ImpactRadiusResult;
24
25 fn name(&self) -> &str {
26 "impact-radius"
27 }
28
29 fn run(&self, ctx: &AnalysisContext) -> ImpactRadiusResult {
30 let graph = ctx.graph;
31
32 let mut nodes: Vec<ImpactRadiusNode> = graph
33 .nodes
34 .keys()
35 .filter(|path| graph.is_file_node(path))
36 .map(|path| {
37 let mut visited = HashSet::new();
39 let mut queue = VecDeque::new();
40 visited.insert(path.as_str());
41 queue.push_back((path.as_str(), 0usize));
42
43 let mut direct_dependents = 0usize;
44 let mut max_depth = 0usize;
45 let mut radius = 0usize;
46
47 while let Some((current, depth)) = queue.pop_front() {
48 if let Some(edge_indices) = graph.reverse.get(current) {
49 for &idx in edge_indices {
50 let dependent = graph.edges[idx].source.as_str();
51 if !graph.is_file_node(dependent) {
52 continue;
53 }
54 if visited.insert(dependent) {
55 let next_depth = depth + 1;
56 radius += 1;
57 if next_depth == 1 {
58 direct_dependents += 1;
59 }
60 if next_depth > max_depth {
61 max_depth = next_depth;
62 }
63 queue.push_back((dependent, next_depth));
64 }
65 }
66 }
67 }
68
69 ImpactRadiusNode {
70 node: path.clone(),
71 radius,
72 direct_dependents,
73 max_depth,
74 }
75 })
76 .collect();
77
78 nodes.sort_by(|a, b| a.node.cmp(&b.node));
79
80 ImpactRadiusResult { nodes }
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87 use crate::analyses::AnalysisContext;
88 use crate::config::Config;
89 use crate::graph::test_helpers::{make_edge, make_node};
90 use crate::graph::{Graph, Node, NodeType};
91 use std::collections::HashMap;
92 use std::path::Path;
93
94 fn make_ctx<'a>(graph: &'a Graph, config: &'a Config) -> AnalysisContext<'a> {
95 AnalysisContext {
96 graph,
97 root: Path::new("."),
98 config,
99 lockfile: None,
100 }
101 }
102
103 #[test]
104 fn empty_graph() {
105 let graph = Graph::new();
106 let config = Config::defaults();
107 let result = ImpactRadius.run(&make_ctx(&graph, &config));
108 assert!(result.nodes.is_empty());
109 }
110
111 #[test]
112 fn single_node_no_edges() {
113 let mut graph = Graph::new();
114 graph.add_node(make_node("a.md"));
115
116 let config = Config::defaults();
117 let result = ImpactRadius.run(&make_ctx(&graph, &config));
118 assert_eq!(result.nodes.len(), 1);
119 assert_eq!(result.nodes[0].radius, 0);
120 assert_eq!(result.nodes[0].direct_dependents, 0);
121 assert_eq!(result.nodes[0].max_depth, 0);
122 }
123
124 #[test]
125 fn linear_chain() {
126 let mut graph = Graph::new();
132 graph.add_node(make_node("a.md"));
133 graph.add_node(make_node("b.md"));
134 graph.add_node(make_node("c.md"));
135 graph.add_node(make_node("d.md"));
136 graph.add_edge(make_edge("a.md", "b.md"));
137 graph.add_edge(make_edge("b.md", "c.md"));
138 graph.add_edge(make_edge("c.md", "d.md"));
139
140 let config = Config::defaults();
141 let result = ImpactRadius.run(&make_ctx(&graph, &config));
142
143 let get = |name: &str| result.nodes.iter().find(|n| n.node == name).unwrap();
144
145 assert_eq!(get("d.md").radius, 3);
146 assert_eq!(get("d.md").direct_dependents, 1);
147 assert_eq!(get("d.md").max_depth, 3);
148
149 assert_eq!(get("c.md").radius, 2);
150 assert_eq!(get("c.md").direct_dependents, 1);
151 assert_eq!(get("c.md").max_depth, 2);
152
153 assert_eq!(get("b.md").radius, 1);
154 assert_eq!(get("b.md").direct_dependents, 1);
155 assert_eq!(get("b.md").max_depth, 1);
156
157 assert_eq!(get("a.md").radius, 0);
158 assert_eq!(get("a.md").direct_dependents, 0);
159 assert_eq!(get("a.md").max_depth, 0);
160 }
161
162 #[test]
163 fn diamond_graph() {
164 let mut graph = Graph::new();
168 graph.add_node(make_node("a.md"));
169 graph.add_node(make_node("b.md"));
170 graph.add_node(make_node("c.md"));
171 graph.add_node(make_node("d.md"));
172 graph.add_edge(make_edge("a.md", "b.md"));
173 graph.add_edge(make_edge("a.md", "c.md"));
174 graph.add_edge(make_edge("b.md", "d.md"));
175 graph.add_edge(make_edge("c.md", "d.md"));
176
177 let config = Config::defaults();
178 let result = ImpactRadius.run(&make_ctx(&graph, &config));
179
180 let get = |name: &str| result.nodes.iter().find(|n| n.node == name).unwrap();
181
182 assert_eq!(get("d.md").radius, 3);
183 assert_eq!(get("d.md").direct_dependents, 2);
184 assert_eq!(get("d.md").max_depth, 2);
185
186 assert_eq!(get("b.md").radius, 1);
187 assert_eq!(get("b.md").direct_dependents, 1);
188
189 assert_eq!(get("a.md").radius, 0);
190 }
191
192 #[test]
193 fn cycle() {
194 let mut graph = Graph::new();
197 graph.add_node(make_node("a.md"));
198 graph.add_node(make_node("b.md"));
199 graph.add_node(make_node("c.md"));
200 graph.add_edge(make_edge("a.md", "b.md"));
201 graph.add_edge(make_edge("b.md", "c.md"));
202 graph.add_edge(make_edge("c.md", "a.md"));
203
204 let config = Config::defaults();
205 let result = ImpactRadius.run(&make_ctx(&graph, &config));
206
207 let get = |name: &str| result.nodes.iter().find(|n| n.node == name).unwrap();
208
209 assert_eq!(get("a.md").radius, 2);
210 assert_eq!(get("b.md").radius, 2);
211 assert_eq!(get("c.md").radius, 2);
212 }
213
214 #[test]
215 fn excludes_external_nodes() {
216 let mut graph = Graph::new();
217 graph.add_node(make_node("a.md"));
218 graph.add_node(Node {
219 path: "https://example.com".into(),
220 node_type: NodeType::External,
221 hash: None,
222 graph: None,
223 is_graph: false,
224 metadata: HashMap::new(),
225 });
226 graph.add_edge(make_edge("a.md", "https://example.com"));
227
228 let config = Config::defaults();
229 let result = ImpactRadius.run(&make_ctx(&graph, &config));
230 assert_eq!(result.nodes.len(), 1);
231 assert_eq!(result.nodes[0].radius, 0);
232 }
233}