drft/analyses/
transitive_reduction.rs1use super::{Analysis, AnalysisContext};
2use crate::graph::Graph;
3use std::collections::{HashSet, VecDeque};
4
5#[derive(Debug, Clone, serde::Serialize)]
6pub struct RedundantEdge {
7 pub source: String,
8 pub target: String,
9 pub via: String,
10}
11
12#[derive(Debug, Clone, serde::Serialize)]
13pub struct TransitiveReductionResult {
14 pub redundant_edges: Vec<RedundantEdge>,
15}
16
17pub struct TransitiveReduction;
18
19impl Analysis for TransitiveReduction {
20 type Output = TransitiveReductionResult;
21
22 fn name(&self) -> &str {
23 "transitive-reduction"
24 }
25
26 fn run(&self, ctx: &AnalysisContext) -> TransitiveReductionResult {
27 let graph = ctx.graph;
28 let node_set: HashSet<&str> = graph.nodes.keys().map(|s| s.as_str()).collect();
29 let mut redundant_edges = Vec::new();
30
31 for edge in &graph.edges {
32 if !node_set.contains(edge.source.as_str()) || !node_set.contains(edge.target.as_str())
34 {
35 continue;
36 }
37
38 if edge.source == edge.target {
40 continue;
41 }
42
43 if let Some(via) =
46 reachable_without_direct(graph, &edge.source, &edge.target, &node_set)
47 {
48 redundant_edges.push(RedundantEdge {
49 source: edge.source.clone(),
50 target: edge.target.clone(),
51 via,
52 });
53 }
54 }
55
56 redundant_edges.sort_by(|a, b| {
57 a.source
58 .cmp(&b.source)
59 .then_with(|| a.target.cmp(&b.target))
60 });
61
62 TransitiveReductionResult { redundant_edges }
63 }
64}
65
66fn reachable_without_direct(
69 graph: &Graph,
70 source: &str,
71 target: &str,
72 node_set: &HashSet<&str>,
73) -> Option<String> {
74 let mut visited = HashSet::new();
75 let mut queue: VecDeque<(&str, Option<&str>)> = VecDeque::new();
77
78 visited.insert(source);
79 queue.push_back((source, None));
80
81 while let Some((current, first_hop)) = queue.pop_front() {
82 let Some(edge_indices) = graph.forward.get(current) else {
83 continue;
84 };
85
86 for &idx in edge_indices {
87 let neighbor = graph.edges[idx].target.as_str();
88
89 if current == source && neighbor == target {
91 continue;
92 }
93
94 if !node_set.contains(neighbor) {
96 continue;
97 }
98
99 if !visited.insert(neighbor) {
100 continue;
101 }
102
103 let via = first_hop.unwrap_or(neighbor);
105
106 if neighbor == target {
107 return Some(via.to_string());
108 }
109
110 queue.push_back((neighbor, Some(via)));
111 }
112 }
113
114 None
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::analyses::AnalysisContext;
121 use crate::config::Config;
122 use crate::graph::{Edge, Node, NodeType};
123 use std::collections::HashMap;
124 use std::path::Path;
125
126 fn make_node(path: &str) -> Node {
127 Node {
128 path: path.into(),
129 node_type: NodeType::File,
130 hash: None,
131 graph: None,
132 is_graph: false,
133 metadata: HashMap::new(),
134 }
135 }
136
137 fn make_edge(source: &str, target: &str) -> Edge {
138 Edge {
139 source: source.into(),
140 target: target.into(),
141 link: None,
142 parser: "markdown".into(),
143 }
144 }
145
146 fn make_ctx<'a>(graph: &'a Graph, config: &'a Config) -> AnalysisContext<'a> {
147 AnalysisContext {
148 graph,
149 root: Path::new("."),
150 config,
151 lockfile: None,
152 }
153 }
154
155 #[test]
156 fn diamond_redundancy() {
157 let mut graph = Graph::new();
158 graph.add_node(make_node("a.md"));
159 graph.add_node(make_node("b.md"));
160 graph.add_node(make_node("c.md"));
161 graph.add_edge(make_edge("a.md", "b.md"));
162 graph.add_edge(make_edge("b.md", "c.md"));
163 graph.add_edge(make_edge("a.md", "c.md"));
164
165 let config = Config::defaults();
166 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
167
168 assert_eq!(result.redundant_edges.len(), 1);
169 assert_eq!(result.redundant_edges[0].source, "a.md");
170 assert_eq!(result.redundant_edges[0].target, "c.md");
171 assert_eq!(result.redundant_edges[0].via, "b.md");
172 }
173
174 #[test]
175 fn no_redundancy() {
176 let mut graph = Graph::new();
177 graph.add_node(make_node("a.md"));
178 graph.add_node(make_node("b.md"));
179 graph.add_node(make_node("c.md"));
180 graph.add_edge(make_edge("a.md", "b.md"));
181 graph.add_edge(make_edge("b.md", "c.md"));
182
183 let config = Config::defaults();
184 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
185 assert!(result.redundant_edges.is_empty());
186 }
187
188 #[test]
189 fn multiple_redundancies() {
190 let mut graph = Graph::new();
191 graph.add_node(make_node("a.md"));
192 graph.add_node(make_node("b.md"));
193 graph.add_node(make_node("c.md"));
194 graph.add_node(make_node("d.md"));
195 graph.add_edge(make_edge("a.md", "b.md"));
196 graph.add_edge(make_edge("b.md", "c.md"));
197 graph.add_edge(make_edge("b.md", "d.md"));
198 graph.add_edge(make_edge("a.md", "c.md"));
199 graph.add_edge(make_edge("a.md", "d.md"));
200
201 let config = Config::defaults();
202 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
203
204 assert_eq!(result.redundant_edges.len(), 2);
205 let targets: Vec<&str> = result
206 .redundant_edges
207 .iter()
208 .map(|r| r.target.as_str())
209 .collect();
210 assert!(targets.contains(&"c.md"));
211 assert!(targets.contains(&"d.md"));
212 }
213
214 #[test]
215 fn longer_chain() {
216 let mut graph = Graph::new();
217 graph.add_node(make_node("a.md"));
218 graph.add_node(make_node("b.md"));
219 graph.add_node(make_node("c.md"));
220 graph.add_node(make_node("d.md"));
221 graph.add_edge(make_edge("a.md", "b.md"));
222 graph.add_edge(make_edge("b.md", "c.md"));
223 graph.add_edge(make_edge("c.md", "d.md"));
224 graph.add_edge(make_edge("a.md", "d.md"));
225
226 let config = Config::defaults();
227 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
228
229 assert_eq!(result.redundant_edges.len(), 1);
230 assert_eq!(result.redundant_edges[0].source, "a.md");
231 assert_eq!(result.redundant_edges[0].target, "d.md");
232 assert_eq!(result.redundant_edges[0].via, "b.md");
233 }
234
235 #[test]
236 fn self_loop_not_flagged() {
237 let mut graph = Graph::new();
238 graph.add_node(make_node("a.md"));
239 graph.add_edge(make_edge("a.md", "a.md"));
240
241 let config = Config::defaults();
242 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
243 assert!(result.redundant_edges.is_empty());
244 }
245
246 #[test]
247 fn disconnected_components() {
248 let mut graph = Graph::new();
249 graph.add_node(make_node("a.md"));
250 graph.add_node(make_node("b.md"));
251 graph.add_node(make_node("c.md"));
252 graph.add_node(make_node("d.md"));
253 graph.add_edge(make_edge("a.md", "b.md"));
254 graph.add_edge(make_edge("c.md", "d.md"));
255
256 let config = Config::defaults();
257 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
258 assert!(result.redundant_edges.is_empty());
259 }
260
261 #[test]
262 fn edge_to_missing_node_skipped() {
263 let mut graph = Graph::new();
264 graph.add_node(make_node("a.md"));
265 graph.add_node(make_node("b.md"));
266 graph.add_edge(make_edge("a.md", "b.md"));
267 graph.add_edge(make_edge("a.md", "missing.md"));
268
269 let config = Config::defaults();
270 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
271 assert!(result.redundant_edges.is_empty());
272 }
273}