Skip to main content

lean_ctx/core/graph_analysis/
cycles.rs

1//! Import-cycle detection via strongly-connected components.
2//!
3//! A cycle is an SCC of size >= 2 in the directed dependency graph: a group of
4//! files that (transitively) import each other. Tarjan's algorithm is used in an
5//! *iterative* form so deep dependency chains in large repos cannot overflow the
6//! stack.
7
8use std::collections::HashMap;
9
10use serde::Serialize;
11
12use super::dependency_edges;
13use crate::core::graph_provider::EdgeInfo;
14
15/// A circular dependency: a set of files that import each other.
16#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
17pub struct ImportCycle {
18    pub files: Vec<String>,
19    pub size: usize,
20}
21
22/// Finds import cycles (SCCs of size >= 2), largest first, capped at `limit`.
23/// Output is deterministic.
24pub 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    // Intern node names to dense indices and build the adjacency list.
31    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
69/// Iterative Tarjan strongly-connected-components.
70fn 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        // Explicit DFS stack of (node, next-neighbour-index).
86        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                // v fully explored: if it is an SCC root, pop the component.
106                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"), // d is a non-cyclic dependency
149        ];
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"),   // self-loop: not a cycle
174            e("b.rs", "c.rs", "sibling"),  // heuristic, ignored
175            e("c.rs", "b.rs", "cochange"), // heuristic, ignored
176        ];
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            // 2-cycle
184            e("x.rs", "y.rs", "import"),
185            e("y.rs", "x.rs", "import"),
186            // 3-cycle
187            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); // largest first
194        assert_eq!(cycles[1].size, 2);
195    }
196}