codelens_engine/
circular.rs1use crate::import_graph::{GraphCache, build_graph_pub};
2use crate::project::ProjectRoot;
3use anyhow::Result;
4use petgraph::algo::tarjan_scc;
5use petgraph::graph::DiGraph;
6use serde::Serialize;
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize)]
10pub struct CircularDependency {
11 pub cycle: Vec<String>,
12 pub length: usize,
13}
14
15pub fn find_circular_dependencies(
17 project: &ProjectRoot,
18 max_results: usize,
19 cache: &GraphCache,
20) -> Result<Vec<CircularDependency>> {
21 let graph = build_graph_pub(project, cache)?;
22
23 let mut digraph: DiGraph<String, ()> = DiGraph::new();
25 let mut node_indices: HashMap<String, petgraph::graph::NodeIndex> = HashMap::new();
26
27 for file in graph.as_ref().keys() {
28 let idx = digraph.add_node(file.clone());
29 node_indices.insert(file.clone(), idx);
30 }
31
32 for (file, node) in graph.iter() {
33 let from_idx = node_indices[file];
34 for import in &node.imports {
35 if let Some(&to_idx) = node_indices.get(import) {
36 digraph.add_edge(from_idx, to_idx, ());
37 }
38 }
39 }
40
41 let sccs = tarjan_scc(&digraph);
43 let mut cycles: Vec<CircularDependency> = sccs
44 .into_iter()
45 .filter(|scc| scc.len() > 1)
46 .map(|scc| {
47 let mut cycle: Vec<String> = scc.iter().map(|&idx| digraph[idx].clone()).collect();
48 cycle.sort();
49 let length = cycle.len();
50 CircularDependency { cycle, length }
51 })
52 .collect();
53
54 cycles.sort_by(|a, b| b.length.cmp(&a.length).then(a.cycle.cmp(&b.cycle)));
56
57 if max_results > 0 && cycles.len() > max_results {
58 cycles.truncate(max_results);
59 }
60
61 Ok(cycles)
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67 use crate::import_graph::GraphCache;
68 use std::fs;
69
70 fn temp_project_dir(name: &str) -> std::path::PathBuf {
71 let dir = std::env::temp_dir().join(format!(
72 "codelens-core-circular-{name}-{}",
73 std::time::SystemTime::now()
74 .duration_since(std::time::UNIX_EPOCH)
75 .expect("time")
76 .as_nanos()
77 ));
78 fs::create_dir_all(&dir).expect("create tempdir");
79 dir
80 }
81
82 #[test]
83 fn detects_simple_cycle() {
84 let dir = temp_project_dir("simple");
85 fs::write(dir.join("a.py"), "from b import foo\n").expect("write a");
87 fs::write(dir.join("b.py"), "from a import bar\n").expect("write b");
88 fs::write(dir.join("c.py"), "import os\n").expect("write c (no cycle)");
89
90 let project = ProjectRoot::new(&dir).expect("project");
91 let cache = GraphCache::new(0);
92 let cycles = find_circular_dependencies(&project, 50, &cache).expect("cycles");
93 assert!(!cycles.is_empty(), "should find at least one cycle");
94 let first = &cycles[0];
95 assert_eq!(first.length, 2);
96 assert!(first.cycle.contains(&"a.py".to_owned()));
97 assert!(first.cycle.contains(&"b.py".to_owned()));
98 }
99
100 #[test]
101 fn no_cycles_in_dag() {
102 let dir = temp_project_dir("dag");
103 fs::write(dir.join("main.py"), "from utils import greet\n").expect("write main");
104 fs::write(dir.join("utils.py"), "from models import User\n").expect("write utils");
105 fs::write(dir.join("models.py"), "class User:\n pass\n").expect("write models");
106
107 let project = ProjectRoot::new(&dir).expect("project");
108 let cache = GraphCache::new(0);
109 let cycles = find_circular_dependencies(&project, 50, &cache).expect("cycles");
110 assert!(cycles.is_empty(), "DAG should have no cycles");
111 }
112}