use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::ast::extractor::AstExtractor;
use crate::ast::types::{ClassInfo, ImportInfo};
use crate::callgraph::scanner::{ProjectScanner, ScanConfig};
use crate::callgraph::{indexer, resolver};
#[derive(Debug, Error)]
pub enum CircularError {
#[error("Failed to scan project: {0}")]
ScanError(String),
#[error("Failed to parse file {path}: {message}")]
ParseError { path: String, message: String },
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Invalid configuration: {0}")]
ConfigError(String),
}
pub type Result<T> = std::result::Result<T, CircularError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum DependencyLevel {
Package,
#[default]
Module,
Class,
Function,
}
impl std::fmt::Display for DependencyLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Package => write!(f, "package"),
Self::Module => write!(f, "module"),
Self::Class => write!(f, "class"),
Self::Function => write!(f, "function"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Low => write!(f, "low"),
Self::Medium => write!(f, "medium"),
Self::High => write!(f, "high"),
Self::Critical => write!(f, "critical"),
}
}
}
impl Default for Severity {
fn default() -> Self {
Self::Low
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyCycle {
pub level: DependencyLevel,
pub participants: Vec<String>,
pub cycle_path: Vec<(String, String)>,
pub severity: Severity,
pub files: Vec<String>,
pub involves_tests: bool,
pub likely_intentional: bool,
}
impl DependencyCycle {
fn new(level: DependencyLevel, participants: Vec<String>) -> Self {
let cycle_path = Self::build_cycle_path(&participants);
let severity = Self::calculate_severity(&participants, false);
Self {
level,
participants,
cycle_path,
severity,
files: Vec::new(),
involves_tests: false,
likely_intentional: false,
}
}
fn build_cycle_path(participants: &[String]) -> Vec<(String, String)> {
if participants.is_empty() {
return Vec::new();
}
let mut path = Vec::with_capacity(participants.len());
for i in 0..participants.len() {
let from = &participants[i];
let to = &participants[(i + 1) % participants.len()];
path.push((from.clone(), to.clone()));
}
path
}
fn calculate_severity(participants: &[String], is_nested: bool) -> Severity {
if is_nested {
Severity::Critical
} else {
match participants.len() {
0 | 1 => Severity::Low,
2 => Severity::Medium,
3..=5 => Severity::High,
_ => Severity::Critical,
}
}
}
pub fn mark_as_nested(&mut self) {
self.severity = Severity::Critical;
}
fn is_test_participant(name: &str) -> bool {
let lower = name.to_lowercase();
lower.starts_with("test_")
|| lower.ends_with("_test")
|| lower.ends_with("_tests")
|| lower.contains("/test/")
|| lower.contains("/tests/")
|| lower.contains("\\test\\")
|| lower.contains("\\tests\\")
|| lower.starts_with("test::")
}
pub fn update_test_involvement(&mut self) {
self.involves_tests = self.participants.iter().any(|s| Self::is_test_participant(s))
|| self.files.iter().any(|s| Self::is_test_participant(s));
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BreakingSuggestion {
pub remove_edge: (String, String),
pub technique: String,
pub description: String,
pub impact: u32,
pub confidence: f64,
pub effort: String,
}
impl BreakingSuggestion {
fn new(edge: (String, String), technique: &str, description: &str, impact: u32) -> Self {
Self {
remove_edge: edge,
technique: technique.to_string(),
description: description.to_string(),
impact,
confidence: 0.7,
effort: "medium".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CircularDependencyReport {
pub cycles: Vec<DependencyCycle>,
pub total_participants_in_cycles: usize,
pub total_files_in_cycles: usize,
pub suggestions: Vec<BreakingSuggestion>,
pub stats: CircularStats,
pub config: CircularConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CircularStats {
pub total_nodes: usize,
pub total_edges: usize,
pub cycle_count: usize,
pub critical_count: usize,
pub high_count: usize,
pub medium_count: usize,
pub low_count: usize,
pub test_cycles: usize,
pub max_cycle_size: usize,
pub avg_cycle_size: f64,
pub analysis_time_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CircularConfig {
pub level: DependencyLevel,
pub min_severity: Severity,
pub include_tests: bool,
pub exclude_intentional: bool,
pub language: Option<String>,
pub max_cycles: usize,
pub max_suggestions: usize,
pub max_files: usize,
}
impl Default for CircularConfig {
fn default() -> Self {
Self {
level: DependencyLevel::Module,
min_severity: Severity::Low,
include_tests: false,
exclude_intentional: false,
language: None,
max_cycles: 100,
max_suggestions: 20,
max_files: 5000,
}
}
}
impl CircularConfig {
pub fn module_level() -> Self {
Self {
level: DependencyLevel::Module,
..Default::default()
}
}
pub fn class_level() -> Self {
Self {
level: DependencyLevel::Class,
..Default::default()
}
}
pub fn function_level() -> Self {
Self {
level: DependencyLevel::Function,
..Default::default()
}
}
pub fn package_level() -> Self {
Self {
level: DependencyLevel::Package,
..Default::default()
}
}
pub fn with_level(mut self, level: DependencyLevel) -> Self {
self.level = level;
self
}
pub fn with_min_severity(mut self, severity: Severity) -> Self {
self.min_severity = severity;
self
}
pub fn with_tests(mut self) -> Self {
self.include_tests = true;
self
}
pub fn with_language(mut self, lang: &str) -> Self {
self.language = Some(lang.to_string());
self
}
}
struct TarjanState {
index: u32,
stack: Vec<usize>,
on_stack: HashSet<usize>,
indices: HashMap<usize, u32>,
lowlinks: HashMap<usize, u32>,
sccs: Vec<Vec<usize>>,
}
impl TarjanState {
fn new() -> Self {
Self {
index: 0,
stack: Vec::new(),
on_stack: HashSet::new(),
indices: HashMap::new(),
lowlinks: HashMap::new(),
sccs: Vec::new(),
}
}
}
struct DependencyGraph {
nodes: Vec<String>,
node_indices: HashMap<String, usize>,
edges: HashMap<usize, HashSet<usize>>,
node_files: HashMap<usize, HashSet<String>>,
}
impl DependencyGraph {
fn new() -> Self {
Self {
nodes: Vec::new(),
node_indices: HashMap::new(),
edges: HashMap::new(),
node_files: HashMap::new(),
}
}
fn add_node(&mut self, name: &str) -> usize {
if let Some(&idx) = self.node_indices.get(name) {
return idx;
}
let idx = self.nodes.len();
self.nodes.push(name.to_string());
self.node_indices.insert(name.to_string(), idx);
self.edges.insert(idx, HashSet::new());
idx
}
fn add_edge(&mut self, from: usize, to: usize) {
if from != to {
self.edges.entry(from).or_default().insert(to);
}
}
fn add_file(&mut self, node_idx: usize, file: &str) {
self.node_files
.entry(node_idx)
.or_default()
.insert(file.to_string());
}
fn node_name(&self, idx: usize) -> &str {
&self.nodes[idx]
}
fn node_files_list(&self, idx: usize) -> Vec<String> {
self.node_files
.get(&idx)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default()
}
fn node_count(&self) -> usize {
self.nodes.len()
}
fn edge_count(&self) -> usize {
self.edges.values().map(|e| e.len()).sum()
}
fn find_sccs(&self) -> Vec<Vec<usize>> {
let mut state = TarjanState::new();
for start in 0..self.nodes.len() {
if !state.indices.contains_key(&start) {
self.tarjan_iterative(start, &mut state);
}
}
state.sccs
}
fn tarjan_iterative(&self, start: usize, state: &mut TarjanState) {
let mut work_stack: Vec<(usize, u8, usize)> = vec![(start, 0, 0)];
while let Some((v, phase, neighbor_pos)) = work_stack.pop() {
match phase {
0 => {
state.indices.insert(v, state.index);
state.lowlinks.insert(v, state.index);
state.index += 1;
state.stack.push(v);
state.on_stack.insert(v);
work_stack.push((v, 1, 0));
}
1 => {
let neighbors: Vec<usize> = self
.edges
.get(&v)
.map(|e| e.iter().copied().collect())
.unwrap_or_default();
if neighbor_pos < neighbors.len() {
let w = neighbors[neighbor_pos];
work_stack.push((v, 1, neighbor_pos + 1));
if !state.indices.contains_key(&w) {
work_stack.push((w, 0, 0));
} else if state.on_stack.contains(&w) {
let w_index = state.indices[&w];
let v_lowlink = state.lowlinks.get_mut(&v).unwrap();
*v_lowlink = (*v_lowlink).min(w_index);
}
} else {
work_stack.push((v, 2, 0));
}
}
2 => {
let neighbors: Vec<usize> = self
.edges
.get(&v)
.map(|e| e.iter().copied().collect())
.unwrap_or_default();
for &w in &neighbors {
if let Some(&w_lowlink) = state.lowlinks.get(&w) {
let v_lowlink = state.lowlinks.get_mut(&v).unwrap();
*v_lowlink = (*v_lowlink).min(w_lowlink);
}
}
let v_index = state.indices[&v];
let v_lowlink = state.lowlinks[&v];
if v_lowlink == v_index {
let mut scc = Vec::new();
loop {
let w = state.stack.pop().unwrap();
state.on_stack.remove(&w);
scc.push(w);
if w == v {
break;
}
}
state.sccs.push(scc);
}
}
_ => unreachable!(),
}
}
}
}
fn build_module_graph(
project_path: &Path,
config: &CircularConfig,
) -> Result<DependencyGraph> {
let scanner = ProjectScanner::new(project_path.to_str().unwrap_or("."))
.map_err(|e| CircularError::ScanError(e.to_string()))?;
let scan_config = match &config.language {
Some(lang) if lang != "all" => ScanConfig::for_language(lang),
_ => ScanConfig::default(),
};
let scan_result = scanner
.scan_with_config(&scan_config)
.map_err(|e| CircularError::ScanError(e.to_string()))?;
let mut graph = DependencyGraph::new();
let project_root = project_path.canonicalize().unwrap_or_else(|_| project_path.to_path_buf());
for file_path in scan_result.files.iter().take(config.max_files) {
if !config.include_tests && is_test_file(file_path) {
continue;
}
let module_info = match AstExtractor::extract_file(file_path) {
Ok(info) => info,
Err(_) => continue, };
let module_name = path_to_module_name(file_path, &project_root);
let source_idx = graph.add_node(&module_name);
graph.add_file(source_idx, &file_path.to_string_lossy());
for import in &module_info.imports {
let target_module = resolve_import_to_module(import, file_path, &project_root);
if let Some(target) = target_module {
if is_internal_module(&target, &project_root) {
let target_idx = graph.add_node(&target);
graph.add_edge(source_idx, target_idx);
}
}
}
}
Ok(graph)
}
fn build_package_graph(
project_path: &Path,
config: &CircularConfig,
) -> Result<DependencyGraph> {
let module_graph = build_module_graph(project_path, config)?;
let mut package_graph = DependencyGraph::new();
let mut module_to_package: HashMap<String, String> = HashMap::new();
for node_name in &module_graph.nodes {
let package = module_to_package_name(node_name);
module_to_package.insert(node_name.clone(), package);
}
for (source_idx, targets) in &module_graph.edges {
let source_module = module_graph.node_name(*source_idx);
let source_package = module_to_package.get(source_module).unwrap();
let source_pkg_idx = package_graph.add_node(source_package);
for file in module_graph.node_files_list(*source_idx) {
package_graph.add_file(source_pkg_idx, &file);
}
for &target_idx in targets {
let target_module = module_graph.node_name(target_idx);
let target_package = module_to_package.get(target_module).unwrap();
if source_package != target_package {
let target_pkg_idx = package_graph.add_node(target_package);
package_graph.add_edge(source_pkg_idx, target_pkg_idx);
}
}
}
Ok(package_graph)
}
fn build_class_graph(
project_path: &Path,
config: &CircularConfig,
) -> Result<DependencyGraph> {
let scanner = ProjectScanner::new(project_path.to_str().unwrap_or("."))
.map_err(|e| CircularError::ScanError(e.to_string()))?;
let scan_config = match &config.language {
Some(lang) if lang != "all" => ScanConfig::for_language(lang),
_ => ScanConfig::default(),
};
let scan_result = scanner
.scan_with_config(&scan_config)
.map_err(|e| CircularError::ScanError(e.to_string()))?;
let mut graph = DependencyGraph::new();
let project_root = project_path.canonicalize().unwrap_or_else(|_| project_path.to_path_buf());
let mut class_to_file: HashMap<String, String> = HashMap::new();
let mut file_classes: HashMap<String, Vec<ClassInfo>> = HashMap::new();
for file_path in scan_result.files.iter().take(config.max_files) {
if !config.include_tests && is_test_file(file_path) {
continue;
}
let module_info = match AstExtractor::extract_file(file_path) {
Ok(info) => info,
Err(_) => continue,
};
let module_name = path_to_module_name(file_path, &project_root);
let file_str = file_path.to_string_lossy().to_string();
for class in &module_info.classes {
let fqn = format!("{}.{}", module_name, class.name);
class_to_file.insert(fqn.clone(), file_str.clone());
class_to_file.insert(class.name.clone(), file_str.clone());
}
file_classes.insert(file_str, module_info.classes);
}
for (file_path, classes) in &file_classes {
for class in classes {
let file_p = Path::new(file_path);
let module_name = path_to_module_name(file_p, &project_root);
let source_fqn = format!("{}.{}", module_name, class.name);
let source_idx = graph.add_node(&source_fqn);
graph.add_file(source_idx, file_path);
for base in &class.bases {
if let Some(target_file) = class_to_file.get(base) {
let target_path = Path::new(target_file);
let target_module = path_to_module_name(target_path, &project_root);
let target_fqn = format!("{}.{}", target_module, base);
let target_idx = graph.add_node(&target_fqn);
graph.add_file(target_idx, target_file);
graph.add_edge(source_idx, target_idx);
}
}
for method in &class.methods {
if let Some(ref return_type) = method.return_type {
let type_name = extract_class_name(return_type);
if let Some(target_file) = class_to_file.get(&type_name) {
let target_path = Path::new(target_file);
let target_module = path_to_module_name(target_path, &project_root);
let target_fqn = format!("{}.{}", target_module, type_name);
let target_idx = graph.add_node(&target_fqn);
graph.add_file(target_idx, target_file);
graph.add_edge(source_idx, target_idx);
}
}
}
}
}
Ok(graph)
}
fn extract_class_name(type_str: &str) -> String {
let trimmed = type_str.trim();
if let Some(inner_start) = trimmed.find('[') {
if let Some(inner_end) = trimmed.rfind(']') {
return trimmed[inner_start + 1..inner_end].to_string();
}
}
trimmed.to_string()
}
fn build_function_graph(
project_path: &Path,
config: &CircularConfig,
) -> Result<DependencyGraph> {
let scanner = ProjectScanner::new(project_path.to_str().unwrap_or("."))
.map_err(|e| CircularError::ScanError(e.to_string()))?;
let scan_config = match &config.language {
Some(lang) if lang != "all" => ScanConfig::for_language(lang),
_ => ScanConfig::default(),
};
let scan_result = scanner
.scan_with_config(&scan_config)
.map_err(|e| CircularError::ScanError(e.to_string()))?;
let files: Vec<PathBuf> = scan_result
.files
.into_iter()
.take(config.max_files)
.filter(|f| config.include_tests || !is_test_file(f))
.collect();
let index = indexer::FunctionIndex::build(&files)
.map_err(|e| CircularError::ScanError(e.to_string()))?;
let call_graph = resolver::resolve_calls(&files, &index, project_path)
.map_err(|e| CircularError::ScanError(e.to_string()))?;
let mut graph = DependencyGraph::new();
for edge in &call_graph.edges {
let caller_name = edge
.caller
.qualified_name
.as_deref()
.unwrap_or(&edge.caller.name);
let callee_name = edge
.callee
.qualified_name
.as_deref()
.unwrap_or(&edge.callee.name);
let caller_idx = graph.add_node(caller_name);
let callee_idx = graph.add_node(callee_name);
if !edge.caller.file.is_empty() {
graph.add_file(caller_idx, &edge.caller.file);
}
if !edge.callee.file.is_empty() {
graph.add_file(callee_idx, &edge.callee.file);
}
graph.add_edge(caller_idx, callee_idx);
}
Ok(graph)
}
fn is_test_file(path: &Path) -> bool {
let path_str = path.to_string_lossy().to_lowercase();
path_str.contains("/test/")
|| path_str.contains("/tests/")
|| path_str.contains("\\test\\")
|| path_str.contains("\\tests\\")
|| path_str.contains("_test.")
|| path_str.contains("_tests.")
|| path_str.contains("test_")
|| path_str.ends_with("_test.py")
|| path_str.ends_with("_test.rs")
|| path_str.ends_with("_test.go")
|| path_str.ends_with(".test.ts")
|| path_str.ends_with(".test.js")
|| path_str.ends_with(".spec.ts")
|| path_str.ends_with(".spec.js")
}
fn path_to_module_name(path: &Path, project_root: &Path) -> String {
let relative = path
.strip_prefix(project_root)
.unwrap_or(path);
let module_path = relative
.with_extension("")
.to_string_lossy()
.replace(['/', '\\'], ".");
module_path.trim_start_matches('.').to_string()
}
fn module_to_package_name(module: &str) -> String {
match module.rsplit_once('.') {
Some((package, _)) => package.to_string(),
None => module.to_string(), }
}
fn resolve_import_to_module(
import: &ImportInfo,
source_file: &Path,
project_root: &Path,
) -> Option<String> {
if import.level > 0 {
let source_module = path_to_module_name(source_file, project_root);
let parts: Vec<&str> = source_module.split('.').collect();
if import.level > parts.len() {
return None; }
let base_parts = &parts[..parts.len().saturating_sub(import.level)];
let base = base_parts.join(".");
if import.module.is_empty() {
if !import.names.is_empty() {
Some(format!("{}.{}", base, import.names[0]))
} else {
Some(base)
}
} else {
Some(format!("{}.{}", base, import.module))
}
} else {
Some(import.module.clone())
}
}
fn is_internal_module(module: &str, _project_root: &Path) -> bool {
const EXTERNAL_PREFIXES: &[&str] = &[
"std", "core", "alloc", "os", "sys", "io", "re", "json", "time", "datetime", "collections", "typing", "fmt", "net", "http", "context", "sync", "encoding", "java.", "javax.", "sun.", "com.google", "org.apache", "react", "vue", "angular", "express", "lodash", "axios", ];
let lower = module.to_lowercase();
for prefix in EXTERNAL_PREFIXES {
if lower.starts_with(prefix) || lower == *prefix {
return false;
}
}
true
}
fn sccs_to_cycles(
sccs: Vec<Vec<usize>>,
graph: &DependencyGraph,
level: DependencyLevel,
) -> Vec<DependencyCycle> {
let mut cycles = Vec::new();
for scc in sccs {
if scc.len() > 1 {
let participants: Vec<String> = scc
.iter()
.map(|&idx| graph.node_name(idx).to_string())
.collect();
let mut cycle = DependencyCycle::new(level, participants);
let mut all_files = HashSet::new();
for &idx in &scc {
for file in graph.node_files_list(idx) {
all_files.insert(file);
}
}
cycle.files = all_files.into_iter().collect();
cycle.update_test_involvement();
if level == DependencyLevel::Module || level == DependencyLevel::Class {
let packages: HashSet<String> = cycle
.participants
.iter()
.map(|p| module_to_package_name(p))
.collect();
cycle.likely_intentional = packages.len() == 1;
}
cycles.push(cycle);
}
}
cycles.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then_with(|| b.participants.len().cmp(&a.participants.len()))
});
cycles
}
fn detect_nested_cycles(cycles: &mut [DependencyCycle]) {
let participant_sets: Vec<HashSet<String>> = cycles
.iter()
.map(|c| c.participants.iter().cloned().collect())
.collect();
let mut nested_indices: HashSet<usize> = HashSet::new();
for i in 0..participant_sets.len() {
for j in (i + 1)..participant_sets.len() {
let has_overlap = participant_sets[i]
.intersection(&participant_sets[j])
.next()
.is_some();
if has_overlap {
nested_indices.insert(i);
nested_indices.insert(j);
}
}
}
for idx in nested_indices {
cycles[idx].mark_as_nested();
}
}
fn generate_suggestions(
cycles: &[DependencyCycle],
_graph: &DependencyGraph,
max_suggestions: usize,
) -> Vec<BreakingSuggestion> {
let mut edge_impact: HashMap<(String, String), u32> = HashMap::new();
for cycle in cycles {
for (from, to) in &cycle.cycle_path {
*edge_impact.entry((from.clone(), to.clone())).or_insert(0) += 1;
}
}
let mut edges: Vec<_> = edge_impact.into_iter().collect();
edges.sort_by(|a, b| b.1.cmp(&a.1));
let mut suggestions = Vec::new();
for ((from, to), impact) in edges.into_iter().take(max_suggestions) {
let technique = determine_breaking_technique(&from, &to);
let description = generate_suggestion_description(&from, &to, &technique);
let mut suggestion = BreakingSuggestion::new(
(from, to),
&technique,
&description,
impact,
);
suggestion.effort = match technique.as_str() {
"Extract interface" => "medium",
"Move to common module" => "high",
"Dependency injection" => "medium",
"Lazy import" => "low",
"Event-based decoupling" => "high",
_ => "medium",
}
.to_string();
suggestions.push(suggestion);
}
suggestions
}
fn determine_breaking_technique(from: &str, to: &str) -> String {
let from_lower = from.to_lowercase();
let to_lower = to.to_lowercase();
if from_lower.contains("service") || to_lower.contains("service") {
"Dependency injection".to_string()
} else if from_lower.contains("handler") || to_lower.contains("handler") {
"Event-based decoupling".to_string()
} else if from_lower.contains("model") || to_lower.contains("model") {
"Move to common module".to_string()
} else if from_lower.contains("utils") || to_lower.contains("utils") {
"Move to common module".to_string()
} else {
"Extract interface".to_string()
}
}
fn generate_suggestion_description(from: &str, to: &str, technique: &str) -> String {
match technique {
"Extract interface" => {
format!(
"Create an interface/protocol for '{}' that '{}' can depend on instead of the concrete implementation",
to, from
)
}
"Move to common module" => {
format!(
"Extract shared types/functions used by both '{}' and '{}' into a separate module",
from, to
)
}
"Dependency injection" => {
format!(
"Pass '{}' as a parameter to '{}' instead of importing it directly",
to, from
)
}
"Lazy import" => {
format!(
"Use lazy/deferred import of '{}' in '{}' to break initialization-time cycle",
to, from
)
}
"Event-based decoupling" => {
format!(
"Replace direct calls from '{}' to '{}' with event emission and subscription",
from, to
)
}
_ => format!("Refactor dependency from '{}' to '{}'", from, to),
}
}
pub fn detect_circular_dependencies(
path: &str,
config: Option<CircularConfig>,
) -> Result<CircularDependencyReport> {
let start_time = std::time::Instant::now();
let config = config.unwrap_or_default();
let project_path = Path::new(path);
let graph = match config.level {
DependencyLevel::Package => build_package_graph(project_path, &config)?,
DependencyLevel::Module => build_module_graph(project_path, &config)?,
DependencyLevel::Class => build_class_graph(project_path, &config)?,
DependencyLevel::Function => build_function_graph(project_path, &config)?,
};
let sccs = graph.find_sccs();
let mut cycles = sccs_to_cycles(sccs, &graph, config.level);
detect_nested_cycles(&mut cycles);
if !config.include_tests {
cycles.retain(|c| !c.involves_tests);
}
if config.exclude_intentional {
cycles.retain(|c| !c.likely_intentional);
}
cycles.retain(|c| c.severity >= config.min_severity);
cycles.truncate(config.max_cycles);
let suggestions = generate_suggestions(&cycles, &graph, config.max_suggestions);
let mut participants_set: HashSet<String> = HashSet::new();
let mut files_set: HashSet<String> = HashSet::new();
for cycle in &cycles {
for p in &cycle.participants {
participants_set.insert(p.clone());
}
for f in &cycle.files {
files_set.insert(f.clone());
}
}
let total_participants = participants_set.len();
let total_files = files_set.len();
let cycle_sizes: Vec<usize> = cycles.iter().map(|c| c.participants.len()).collect();
let max_cycle_size = cycle_sizes.iter().copied().max().unwrap_or(0);
let avg_cycle_size = if cycle_sizes.is_empty() {
0.0
} else {
cycle_sizes.iter().sum::<usize>() as f64 / cycle_sizes.len() as f64
};
let stats = CircularStats {
total_nodes: graph.node_count(),
total_edges: graph.edge_count(),
cycle_count: cycles.len(),
critical_count: cycles.iter().filter(|c| c.severity == Severity::Critical).count(),
high_count: cycles.iter().filter(|c| c.severity == Severity::High).count(),
medium_count: cycles.iter().filter(|c| c.severity == Severity::Medium).count(),
low_count: cycles.iter().filter(|c| c.severity == Severity::Low).count(),
test_cycles: cycles.iter().filter(|c| c.involves_tests).count(),
max_cycle_size,
avg_cycle_size,
analysis_time_ms: start_time.elapsed().as_millis() as u64,
};
Ok(CircularDependencyReport {
cycles,
total_participants_in_cycles: total_participants,
total_files_in_cycles: total_files,
suggestions,
stats,
config,
})
}
pub fn format_circular_report(report: &CircularDependencyReport) -> String {
let mut output = String::new();
output.push_str(&format!(
"Circular Dependency Analysis ({} level)\n",
report.config.level
));
output.push_str(&format!(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
));
output.push_str("Summary:\n");
output.push_str(&format!(" Total cycles: {}\n", report.stats.cycle_count));
output.push_str(&format!(" Critical: {}\n", report.stats.critical_count));
output.push_str(&format!(" High: {}\n", report.stats.high_count));
output.push_str(&format!(" Medium: {}\n", report.stats.medium_count));
output.push_str(&format!(" Low: {}\n", report.stats.low_count));
output.push_str(&format!(
" Participants in cycles: {}\n",
report.total_participants_in_cycles
));
output.push_str(&format!(
" Files involved: {}\n",
report.total_files_in_cycles
));
output.push_str(&format!(
" Analysis time: {}ms\n\n",
report.stats.analysis_time_ms
));
if !report.cycles.is_empty() {
output.push_str("Detected Cycles:\n");
output.push_str("────────────────\n");
for (i, cycle) in report.cycles.iter().enumerate() {
let severity_marker = match cycle.severity {
Severity::Critical => "[CRITICAL]",
Severity::High => "[HIGH]",
Severity::Medium => "[MEDIUM]",
Severity::Low => "[LOW]",
};
output.push_str(&format!(
"\n{}. {} {} (size: {})\n",
i + 1,
severity_marker,
if cycle.likely_intentional {
"(likely intentional)"
} else {
""
},
cycle.participants.len()
));
output.push_str(" Path: ");
for (j, (from, to)) in cycle.cycle_path.iter().enumerate() {
if j == 0 {
output.push_str(&format!("{} -> {}", from, to));
} else {
output.push_str(&format!(" -> {}", to));
}
}
output.push('\n');
if cycle.files.len() <= 5 {
output.push_str(" Files: ");
output.push_str(&cycle.files.join(", "));
output.push('\n');
} else {
output.push_str(&format!(" Files: {} files involved\n", cycle.files.len()));
}
}
} else {
output.push_str("No circular dependencies detected.\n");
}
if !report.suggestions.is_empty() {
output.push_str("\nBreaking Suggestions:\n");
output.push_str("────────────────────\n");
for (i, suggestion) in report.suggestions.iter().enumerate() {
output.push_str(&format!(
"\n{}. {} (breaks {} cycle{})\n",
i + 1,
suggestion.technique,
suggestion.impact,
if suggestion.impact == 1 { "" } else { "s" }
));
output.push_str(&format!(
" Remove: {} -> {}\n",
suggestion.remove_edge.0, suggestion.remove_edge.1
));
output.push_str(&format!(" {}\n", suggestion.description));
output.push_str(&format!(" Effort: {}\n", suggestion.effort));
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tarjan_simple_cycle() {
let mut graph = DependencyGraph::new();
let a = graph.add_node("A");
let b = graph.add_node("B");
let c = graph.add_node("C");
graph.add_edge(a, b);
graph.add_edge(b, c);
graph.add_edge(c, a);
let sccs = graph.find_sccs();
assert_eq!(sccs.len(), 1);
assert_eq!(sccs[0].len(), 3);
}
#[test]
fn test_tarjan_no_cycle() {
let mut graph = DependencyGraph::new();
let a = graph.add_node("A");
let b = graph.add_node("B");
let c = graph.add_node("C");
graph.add_edge(a, b);
graph.add_edge(b, c);
let sccs = graph.find_sccs();
assert_eq!(sccs.len(), 3);
assert!(sccs.iter().all(|scc| scc.len() == 1));
}
#[test]
fn test_tarjan_multiple_cycles() {
let mut graph = DependencyGraph::new();
let a = graph.add_node("A");
let b = graph.add_node("B");
let c = graph.add_node("C");
let d = graph.add_node("D");
graph.add_edge(a, b);
graph.add_edge(b, a);
graph.add_edge(c, d);
graph.add_edge(d, c);
let sccs = graph.find_sccs();
let large_sccs: Vec<_> = sccs.iter().filter(|scc| scc.len() > 1).collect();
assert_eq!(large_sccs.len(), 2);
}
#[test]
fn test_tarjan_self_loop_ignored() {
let mut graph = DependencyGraph::new();
let a = graph.add_node("A");
graph.add_edge(a, a);
let sccs = graph.find_sccs();
assert_eq!(sccs.len(), 1);
assert_eq!(sccs[0].len(), 1);
}
#[test]
fn test_cycle_severity() {
let cycle2 = DependencyCycle::new(
DependencyLevel::Module,
vec!["A".to_string(), "B".to_string()],
);
assert_eq!(cycle2.severity, Severity::Medium);
let cycle3 = DependencyCycle::new(
DependencyLevel::Module,
vec!["A".to_string(), "B".to_string(), "C".to_string()],
);
assert_eq!(cycle3.severity, Severity::High);
let cycle6 = DependencyCycle::new(
DependencyLevel::Module,
vec![
"A".to_string(),
"B".to_string(),
"C".to_string(),
"D".to_string(),
"E".to_string(),
"F".to_string(),
],
);
assert_eq!(cycle6.severity, Severity::Critical);
}
#[test]
fn test_path_to_module_name() {
let root = Path::new("/project");
let file = Path::new("/project/src/utils/helpers.py");
let module = path_to_module_name(file, root);
assert_eq!(module, "src.utils.helpers");
}
#[test]
fn test_module_to_package_name() {
assert_eq!(module_to_package_name("src.utils.helpers"), "src.utils");
assert_eq!(module_to_package_name("main"), "main");
}
#[test]
fn test_is_test_file() {
assert!(is_test_file(Path::new("/project/tests/test_utils.py")));
assert!(is_test_file(Path::new("/project/src/utils_test.py")));
assert!(is_test_file(Path::new("/project/src/utils.test.ts")));
assert!(is_test_file(Path::new("/project/src/utils.spec.js")));
assert!(!is_test_file(Path::new("/project/src/utils.py")));
}
#[test]
fn test_config_builder() {
let config = CircularConfig::default()
.with_level(DependencyLevel::Class)
.with_min_severity(Severity::High)
.with_tests()
.with_language("python");
assert_eq!(config.level, DependencyLevel::Class);
assert_eq!(config.min_severity, Severity::High);
assert!(config.include_tests);
assert_eq!(config.language, Some("python".to_string()));
}
#[test]
fn test_cycle_path_building() {
let participants = vec!["A".to_string(), "B".to_string(), "C".to_string()];
let path = DependencyCycle::build_cycle_path(&participants);
assert_eq!(path.len(), 3);
assert_eq!(path[0], ("A".to_string(), "B".to_string()));
assert_eq!(path[1], ("B".to_string(), "C".to_string()));
assert_eq!(path[2], ("C".to_string(), "A".to_string()));
}
#[test]
fn test_breaking_technique_selection() {
assert_eq!(
determine_breaking_technique("UserService", "UserRepository"),
"Dependency injection"
);
assert_eq!(
determine_breaking_technique("EventHandler", "Logger"),
"Event-based decoupling"
);
assert_eq!(
determine_breaking_technique("UserModel", "BaseModel"),
"Move to common module"
);
}
}