Skip to main content

drft/analyses/
impact_radius.rs

1use super::{Analysis, AnalysisContext};
2use std::collections::{HashSet, VecDeque};
3
4#[derive(Debug, Clone, serde::Serialize)]
5pub struct ImpactRadiusNode {
6    pub node: String,
7    /// Count of transitive dependents (nodes reachable via reverse edges).
8    pub radius: usize,
9    /// Count of direct dependents (reverse neighbors).
10    pub direct_dependents: usize,
11    /// Longest reverse path from this node to a dependent.
12    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                // BFS on reverse edges to find all transitive dependents
38                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        // a -> b -> c -> d
127        // d has radius 3 (a, b, c depend on it transitively)
128        // c has radius 2 (a, b)
129        // b has radius 1 (a)
130        // a has radius 0
131        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        // a -> b, a -> c, b -> d, c -> d
165        // d has radius 3 (b, c, a)
166        // b has radius 1 (a), c has radius 1 (a)
167        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        // a -> b -> c -> a (cycle)
195        // Each node has radius 2 (can reach the other two via reverse edges)
196        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}