use std::collections::HashMap;
use std::path::{Path, PathBuf};
use petgraph::Directed;
use petgraph::algo::kosaraju_scc;
use petgraph::graph::Graph;
use petgraph::stable_graph::NodeIndex;
use petgraph::visit::{EdgeRef, IntoEdgeReferences};
use crate::graph::{CodeGraph, edge::EdgeKind, node::GraphNode};
#[derive(Debug, Clone)]
pub struct CircularDep {
pub files: Vec<PathBuf>,
}
pub fn find_circular(graph: &CodeGraph, project_root: &Path) -> Vec<CircularDep> {
let _ = project_root;
let mut file_graph: Graph<NodeIndex, (), Directed> = Graph::new();
let mut orig_to_new: HashMap<NodeIndex, petgraph::graph::NodeIndex> = HashMap::new();
let mut new_to_orig: HashMap<petgraph::graph::NodeIndex, NodeIndex> = HashMap::new();
for &orig_idx in graph.file_index.values() {
let new_idx = file_graph.add_node(orig_idx);
orig_to_new.insert(orig_idx, new_idx);
new_to_orig.insert(new_idx, orig_idx);
}
for edge_ref in graph.graph.edge_references() {
if matches!(edge_ref.weight(), EdgeKind::ResolvedImport { .. }) {
let src_orig = edge_ref.source();
let dst_orig = edge_ref.target();
if let (Some(&src_new), Some(&dst_new)) =
(orig_to_new.get(&src_orig), orig_to_new.get(&dst_orig))
{
file_graph.add_edge(src_new, dst_new, ());
}
}
}
let sccs = kosaraju_scc(&file_graph);
let mut cycles: Vec<CircularDep> = sccs
.into_iter()
.filter(|scc| scc.len() > 1)
.filter_map(|scc| {
let mut file_paths: Vec<PathBuf> = scc
.iter()
.filter_map(|&new_idx| {
let orig_idx = new_to_orig.get(&new_idx)?;
if let GraphNode::File(ref fi) = graph.graph[*orig_idx] {
Some(fi.path.clone())
} else {
None
}
})
.collect();
if file_paths.is_empty() {
return None;
}
file_paths.sort();
let first = file_paths[0].clone();
file_paths.push(first);
Some(CircularDep { files: file_paths })
})
.collect();
cycles.sort_by(|a, b| a.files[0].cmp(&b.files[0]));
cycles
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use crate::graph::{
CodeGraph,
node::{SymbolInfo, SymbolKind},
};
#[test]
fn test_two_file_mutual_cycle_detected() {
let root = PathBuf::from("/proj");
let mut graph = CodeGraph::new();
let a_file = graph.add_file(root.join("a.ts"), "typescript");
let b_file = graph.add_file(root.join("b.ts"), "typescript");
graph.add_resolved_import(a_file, b_file, "./b");
graph.add_resolved_import(b_file, a_file, "./a");
let cycles = find_circular(&graph, &root);
assert_eq!(cycles.len(), 1, "one cycle expected");
assert_eq!(
cycles[0].files.len(),
3,
"cycle should have 3 entries (2 files + closing)"
);
let paths: Vec<_> = cycles[0]
.files
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap())
.collect();
assert!(paths.contains(&"a.ts"));
assert!(paths.contains(&"b.ts"));
assert_eq!(
cycles[0].files[0],
cycles[0].files[cycles[0].files.len() - 1]
);
}
#[test]
fn test_three_file_cycle_detected() {
let root = PathBuf::from("/proj");
let mut graph = CodeGraph::new();
let a_file = graph.add_file(root.join("a.ts"), "typescript");
let b_file = graph.add_file(root.join("b.ts"), "typescript");
let c_file = graph.add_file(root.join("c.ts"), "typescript");
graph.add_resolved_import(a_file, b_file, "./b");
graph.add_resolved_import(b_file, c_file, "./c");
graph.add_resolved_import(c_file, a_file, "./a");
let cycles = find_circular(&graph, &root);
assert_eq!(cycles.len(), 1, "one 3-cycle expected");
assert_eq!(cycles[0].files.len(), 4);
}
#[test]
fn test_no_cycle_in_acyclic_graph() {
let root = PathBuf::from("/proj");
let mut graph = CodeGraph::new();
let a_file = graph.add_file(root.join("a.ts"), "typescript");
let b_file = graph.add_file(root.join("b.ts"), "typescript");
let c_file = graph.add_file(root.join("c.ts"), "typescript");
graph.add_resolved_import(a_file, b_file, "./b");
graph.add_resolved_import(b_file, c_file, "./c");
let cycles = find_circular(&graph, &root);
assert!(cycles.is_empty(), "no cycles expected in a DAG");
}
#[test]
fn test_barrel_reexport_edges_excluded_from_cycle_detection() {
let root = PathBuf::from("/proj");
let mut graph = CodeGraph::new();
let index_file = graph.add_file(root.join("index.ts"), "typescript");
let utils_file = graph.add_file(root.join("utils.ts"), "typescript");
graph.add_barrel_reexport_all(index_file, utils_file);
graph.add_resolved_import(utils_file, index_file, "./index");
let cycles = find_circular(&graph, &root);
assert!(
cycles.is_empty(),
"BarrelReExportAll edges must not contribute to cycle detection"
);
}
#[test]
fn test_external_package_edges_excluded() {
let root = PathBuf::from("/proj");
let mut graph = CodeGraph::new();
let a_file = graph.add_file(root.join("a.ts"), "typescript");
graph.add_external_package(a_file, "react", "react");
let cycles = find_circular(&graph, &root);
assert!(
cycles.is_empty(),
"external package edges should not create cycles"
);
}
#[test]
fn test_symbol_with_child_symbol_defining_file() {
let root = PathBuf::from("/proj");
let mut graph = CodeGraph::new();
let a_file = graph.add_file(root.join("a.ts"), "typescript");
let b_file = graph.add_file(root.join("b.ts"), "typescript");
graph.add_symbol(
a_file,
SymbolInfo {
name: "Foo".into(),
kind: SymbolKind::Class,
line: 1,
is_exported: true,
..Default::default()
},
);
graph.add_resolved_import(a_file, b_file, "./b");
graph.add_resolved_import(b_file, a_file, "./a");
let cycles = find_circular(&graph, &root);
assert_eq!(
cycles.len(),
1,
"symbols should not interfere with cycle detection"
);
}
}