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, EdgeType, Node, NodeType};
123 use std::path::Path;
124
125 fn make_node(path: &str) -> Node {
126 Node {
127 path: path.into(),
128 node_type: NodeType::Source,
129 hash: None,
130 graph: None,
131 }
132 }
133
134 fn make_edge(source: &str, target: &str) -> Edge {
135 Edge {
136 source: source.into(),
137 target: target.into(),
138 edge_type: EdgeType::new("markdown", "inline"),
139 synthetic: false,
140 }
141 }
142
143 fn make_ctx<'a>(graph: &'a Graph, config: &'a Config) -> AnalysisContext<'a> {
144 AnalysisContext {
145 graph,
146 root: Path::new("."),
147 config,
148 lockfile: None,
149 }
150 }
151
152 #[test]
153 fn diamond_redundancy() {
154 let mut graph = Graph::new();
155 graph.add_node(make_node("a.md"));
156 graph.add_node(make_node("b.md"));
157 graph.add_node(make_node("c.md"));
158 graph.add_edge(make_edge("a.md", "b.md"));
159 graph.add_edge(make_edge("b.md", "c.md"));
160 graph.add_edge(make_edge("a.md", "c.md"));
161
162 let config = Config::defaults();
163 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
164
165 assert_eq!(result.redundant_edges.len(), 1);
166 assert_eq!(result.redundant_edges[0].source, "a.md");
167 assert_eq!(result.redundant_edges[0].target, "c.md");
168 assert_eq!(result.redundant_edges[0].via, "b.md");
169 }
170
171 #[test]
172 fn no_redundancy() {
173 let mut graph = Graph::new();
174 graph.add_node(make_node("a.md"));
175 graph.add_node(make_node("b.md"));
176 graph.add_node(make_node("c.md"));
177 graph.add_edge(make_edge("a.md", "b.md"));
178 graph.add_edge(make_edge("b.md", "c.md"));
179
180 let config = Config::defaults();
181 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
182 assert!(result.redundant_edges.is_empty());
183 }
184
185 #[test]
186 fn multiple_redundancies() {
187 let mut graph = Graph::new();
188 graph.add_node(make_node("a.md"));
189 graph.add_node(make_node("b.md"));
190 graph.add_node(make_node("c.md"));
191 graph.add_node(make_node("d.md"));
192 graph.add_edge(make_edge("a.md", "b.md"));
193 graph.add_edge(make_edge("b.md", "c.md"));
194 graph.add_edge(make_edge("b.md", "d.md"));
195 graph.add_edge(make_edge("a.md", "c.md"));
196 graph.add_edge(make_edge("a.md", "d.md"));
197
198 let config = Config::defaults();
199 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
200
201 assert_eq!(result.redundant_edges.len(), 2);
202 let targets: Vec<&str> = result
203 .redundant_edges
204 .iter()
205 .map(|r| r.target.as_str())
206 .collect();
207 assert!(targets.contains(&"c.md"));
208 assert!(targets.contains(&"d.md"));
209 }
210
211 #[test]
212 fn longer_chain() {
213 let mut graph = Graph::new();
214 graph.add_node(make_node("a.md"));
215 graph.add_node(make_node("b.md"));
216 graph.add_node(make_node("c.md"));
217 graph.add_node(make_node("d.md"));
218 graph.add_edge(make_edge("a.md", "b.md"));
219 graph.add_edge(make_edge("b.md", "c.md"));
220 graph.add_edge(make_edge("c.md", "d.md"));
221 graph.add_edge(make_edge("a.md", "d.md"));
222
223 let config = Config::defaults();
224 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
225
226 assert_eq!(result.redundant_edges.len(), 1);
227 assert_eq!(result.redundant_edges[0].source, "a.md");
228 assert_eq!(result.redundant_edges[0].target, "d.md");
229 assert_eq!(result.redundant_edges[0].via, "b.md");
230 }
231
232 #[test]
233 fn self_loop_not_flagged() {
234 let mut graph = Graph::new();
235 graph.add_node(make_node("a.md"));
236 graph.add_edge(make_edge("a.md", "a.md"));
237
238 let config = Config::defaults();
239 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
240 assert!(result.redundant_edges.is_empty());
241 }
242
243 #[test]
244 fn disconnected_components() {
245 let mut graph = Graph::new();
246 graph.add_node(make_node("a.md"));
247 graph.add_node(make_node("b.md"));
248 graph.add_node(make_node("c.md"));
249 graph.add_node(make_node("d.md"));
250 graph.add_edge(make_edge("a.md", "b.md"));
251 graph.add_edge(make_edge("c.md", "d.md"));
252
253 let config = Config::defaults();
254 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
255 assert!(result.redundant_edges.is_empty());
256 }
257
258 #[test]
259 fn edge_to_missing_node_skipped() {
260 let mut graph = Graph::new();
261 graph.add_node(make_node("a.md"));
262 graph.add_node(make_node("b.md"));
263 graph.add_edge(make_edge("a.md", "b.md"));
264 graph.add_edge(make_edge("a.md", "missing.md"));
265
266 let config = Config::defaults();
267 let result = TransitiveReduction.run(&make_ctx(&graph, &config));
268 assert!(result.redundant_edges.is_empty());
269 }
270}