lean_ctx/core/graph_analysis/
cycles.rs1use std::collections::HashMap;
9
10use serde::Serialize;
11
12use super::dependency_edges;
13use crate::core::graph_provider::EdgeInfo;
14
15#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
17pub struct ImportCycle {
18 pub files: Vec<String>,
19 pub size: usize,
20}
21
22pub fn find_import_cycles(edges: &[EdgeInfo], limit: usize) -> Vec<ImportCycle> {
25 let deps = dependency_edges(edges);
26 if deps.is_empty() {
27 return Vec::new();
28 }
29
30 let mut idx_of: HashMap<&str, usize> = HashMap::new();
32 let mut names: Vec<&str> = Vec::new();
33 let mut adj: Vec<Vec<usize>> = Vec::new();
34 for (from, to) in &deps {
35 for s in [*from, *to] {
36 if !idx_of.contains_key(s) {
37 idx_of.insert(s, names.len());
38 names.push(s);
39 adj.push(Vec::new());
40 }
41 }
42 }
43 for (from, to) in &deps {
44 let f = idx_of[*from];
45 let t = idx_of[*to];
46 adj[f].push(t);
47 }
48
49 let sccs = tarjan_scc(&adj);
50
51 let mut cycles: Vec<ImportCycle> = sccs
52 .into_iter()
53 .filter(|c| c.len() >= 2)
54 .map(|c| {
55 let mut files: Vec<String> = c.into_iter().map(|i| names[i].to_string()).collect();
56 files.sort();
57 ImportCycle {
58 size: files.len(),
59 files,
60 }
61 })
62 .collect();
63
64 cycles.sort_by(|a, b| b.size.cmp(&a.size).then_with(|| a.files.cmp(&b.files)));
65 cycles.truncate(limit);
66 cycles
67}
68
69fn tarjan_scc(adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
71 let n = adj.len();
72 const UNVISITED: usize = usize::MAX;
73
74 let mut indices = vec![UNVISITED; n];
75 let mut lowlink = vec![0usize; n];
76 let mut on_stack = vec![false; n];
77 let mut tarjan_stack: Vec<usize> = Vec::new();
78 let mut sccs: Vec<Vec<usize>> = Vec::new();
79 let mut counter = 0usize;
80
81 for start in 0..n {
82 if indices[start] != UNVISITED {
83 continue;
84 }
85 let mut call_stack: Vec<(usize, usize)> = vec![(start, 0)];
87 while let Some(&(v, edge_i)) = call_stack.last() {
88 if edge_i == 0 {
89 indices[v] = counter;
90 lowlink[v] = counter;
91 counter += 1;
92 tarjan_stack.push(v);
93 on_stack[v] = true;
94 }
95
96 if edge_i < adj[v].len() {
97 call_stack.last_mut().unwrap().1 += 1;
98 let w = adj[v][edge_i];
99 if indices[w] == UNVISITED {
100 call_stack.push((w, 0));
101 } else if on_stack[w] {
102 lowlink[v] = lowlink[v].min(indices[w]);
103 }
104 } else {
105 if lowlink[v] == indices[v] {
107 let mut component = Vec::new();
108 loop {
109 let w = tarjan_stack.pop().expect("tarjan stack non-empty");
110 on_stack[w] = false;
111 component.push(w);
112 if w == v {
113 break;
114 }
115 }
116 sccs.push(component);
117 }
118 call_stack.pop();
119 if let Some(&(parent, _)) = call_stack.last() {
120 lowlink[parent] = lowlink[parent].min(lowlink[v]);
121 }
122 }
123 }
124 }
125
126 sccs
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 fn e(from: &str, to: &str, kind: &str) -> EdgeInfo {
134 EdgeInfo {
135 from: from.into(),
136 to: to.into(),
137 kind: kind.into(),
138 weight: 1.0,
139 }
140 }
141
142 #[test]
143 fn detects_three_node_cycle() {
144 let edges = vec![
145 e("a.rs", "b.rs", "import"),
146 e("b.rs", "c.rs", "import"),
147 e("c.rs", "a.rs", "import"),
148 e("a.rs", "d.rs", "import"), ];
150 let cycles = find_import_cycles(&edges, 10);
151 assert_eq!(cycles.len(), 1);
152 assert_eq!(cycles[0].size, 3);
153 assert_eq!(cycles[0].files, vec!["a.rs", "b.rs", "c.rs"]);
154 }
155
156 #[test]
157 fn detects_two_node_cycle() {
158 let edges = vec![e("a.rs", "b.rs", "import"), e("b.rs", "a.rs", "reexport")];
159 let cycles = find_import_cycles(&edges, 10);
160 assert_eq!(cycles.len(), 1);
161 assert_eq!(cycles[0].files, vec!["a.rs", "b.rs"]);
162 }
163
164 #[test]
165 fn acyclic_graph_has_no_cycles() {
166 let edges = vec![e("a.rs", "b.rs", "import"), e("b.rs", "c.rs", "import")];
167 assert!(find_import_cycles(&edges, 10).is_empty());
168 }
169
170 #[test]
171 fn self_loops_and_heuristics_excluded() {
172 let edges = vec![
173 e("a.rs", "a.rs", "import"), e("b.rs", "c.rs", "sibling"), e("c.rs", "b.rs", "cochange"), ];
177 assert!(find_import_cycles(&edges, 10).is_empty());
178 }
179
180 #[test]
181 fn two_separate_cycles_sorted_by_size() {
182 let edges = vec![
183 e("x.rs", "y.rs", "import"),
185 e("y.rs", "x.rs", "import"),
186 e("a.rs", "b.rs", "import"),
188 e("b.rs", "c.rs", "import"),
189 e("c.rs", "a.rs", "import"),
190 ];
191 let cycles = find_import_cycles(&edges, 10);
192 assert_eq!(cycles.len(), 2);
193 assert_eq!(cycles[0].size, 3); assert_eq!(cycles[1].size, 2);
195 }
196}