use crate::findings::types::{Evidence, Finding, FindingCategory, Severity};
use crate::graph::v2::{build_coupling_graph_snapshot, find_cycles, shortest_cycle};
use crate::graph::{CouplingGraph, without_deferred_edges, without_rust_module_containment_edges};
use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};
pub(super) fn emit_circular_dependency_findings(
graph: &CouplingGraph,
prod_files: &HashSet<PathBuf>,
root: &Path,
findings: &mut Vec<Finding>,
) {
let full_cycle_graph = without_rust_module_containment_edges(graph);
let cycle_graph = without_deferred_edges(&full_cycle_graph);
let (cycle_snapshot, path_by_id) = build_coupling_graph_snapshot(&cycle_graph);
let cycles = find_cycles(&cycle_snapshot);
let mut seen_cycles = BTreeSet::new();
let mut eager_cycle_files: HashSet<PathBuf> = HashSet::new();
for cycle in &cycles {
let members: Vec<PathBuf> = cycle
.node_ids
.iter()
.filter_map(|id| path_by_id.get(id).cloned())
.collect();
if !is_prod_cycle(&members, prod_files) {
continue;
}
if seen_cycles.insert(members.clone()) {
eager_cycle_files.extend(members.iter().cloned());
let shortest: Vec<PathBuf> = shortest_cycle(&cycle_snapshot, cycle)
.iter()
.filter_map(|id| path_by_id.get(id).cloned())
.collect();
findings.push(circular_dependency_finding(
&members,
&shortest,
root,
Severity::High,
false,
));
}
}
if full_cycle_graph.deferred_edges.is_empty() {
return;
}
let (full_snapshot, full_path_by_id) = build_coupling_graph_snapshot(&full_cycle_graph);
let full_cycles = find_cycles(&full_snapshot);
for cycle in &full_cycles {
let members: Vec<PathBuf> = cycle
.node_ids
.iter()
.filter_map(|id| full_path_by_id.get(id).cloned())
.collect();
if !is_prod_cycle(&members, prod_files) {
continue;
}
if members.iter().all(|path| is_type_erasing_language(path)) {
continue;
}
if members.iter().any(|path| eager_cycle_files.contains(path)) {
continue;
}
if seen_cycles.insert(members.clone()) {
let shortest: Vec<PathBuf> = shortest_cycle(&full_snapshot, cycle)
.iter()
.filter_map(|id| full_path_by_id.get(id).cloned())
.collect();
findings.push(circular_dependency_finding(
&members,
&shortest,
root,
Severity::Low,
true,
));
}
}
}
fn is_prod_cycle(members: &[PathBuf], prod_files: &HashSet<PathBuf>) -> bool {
!members.is_empty() && members.iter().all(|path| prod_files.contains(path))
}
fn is_type_erasing_language(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("ts" | "tsx" | "mts" | "cts" | "js" | "jsx" | "mjs" | "cjs")
)
}
fn circular_dependency_finding(
component: &[PathBuf],
shortest: &[PathBuf],
root: &Path,
severity: Severity,
deferred_only: bool,
) -> Finding {
let component_size = component.len();
let closed: Vec<PathBuf> = if shortest.len() >= 2 {
shortest
.iter()
.map(|path| relative_path(path, root))
.collect()
} else {
component
.iter()
.map(|path| relative_path(path, root))
.collect()
};
let cycle_path = closed
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(" -> ");
let distinct: Vec<&PathBuf> = closed.iter().take(closed.len().saturating_sub(1)).collect();
let distinct = if distinct.is_empty() {
closed.iter().collect()
} else {
distinct
};
let context = if component_size > distinct.len() {
format!(" Part of a strongly-connected component of {component_size} files.")
} else {
String::new()
};
let deferred_context = if deferred_only {
" The cycle only exists through deferred imports, so it is informational in strict review."
} else {
""
};
let evidence = distinct
.iter()
.map(|path| Evidence {
path: (*path).clone(),
line_start: 1,
line_end: None,
snippet: format!("Cycle: {cycle_path}.{context}{deferred_context}"),
})
.collect();
let title = if deferred_only {
"Deferred circular dependency detected"
} else {
"Circular dependency detected"
};
let description_prefix = if deferred_only {
"A deferred-only circular dependency was detected"
} else {
"A circular dependency was detected"
};
Finding {
id: String::new(),
rule_id: "architecture.circular-dependency".to_string(),
recommendation: Finding::recommendation_for_rule_id("architecture.circular-dependency"),
title: title.to_string(),
description: format!("{description_prefix}: {cycle_path}.{context}{deferred_context}"),
category: FindingCategory::Architecture,
severity,
confidence: Default::default(),
evidence,
workspace_package: None,
docs_url: None,
provenance: Default::default(),
risk: Default::default(),
}
}
fn relative_path(path: &Path, root: &Path) -> PathBuf {
path.strip_prefix(root).unwrap_or(path).to_path_buf()
}