use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use crate::volatility::{TemporalCoupling, Volatility};
use super::coupling::CouplingMetrics;
use super::dimensions::{Distance, IntegrationStrength, MetricsConfig, Visibility};
use super::module::{
BalanceClassification, DimensionStats, FunctionDefinition, ModuleMetrics, TypeDefinition,
};
#[derive(Debug, Default)]
pub struct ProjectMetrics {
pub modules: HashMap<String, ModuleMetrics>,
pub couplings: Vec<CouplingMetrics>,
pub file_changes: HashMap<String, usize>,
pub total_files: usize,
pub parse_failures: usize,
pub skipped_crates: Vec<String>,
pub boundary_skipped_files: usize,
pub dead_config_patterns: Vec<String>,
pub workspace_name: Option<String>,
pub workspace_members: Vec<String>,
pub crate_dependencies: HashMap<String, Vec<String>>,
pub type_registry: HashMap<String, (String, Visibility)>,
pub temporal_couplings: Vec<TemporalCoupling>,
}
impl ProjectMetrics {
pub fn new() -> Self {
Self::default()
}
pub fn add_module(&mut self, metrics: ModuleMetrics) {
self.modules.insert(metrics.name.clone(), metrics);
}
pub fn add_coupling(&mut self, coupling: CouplingMetrics) {
self.couplings.push(coupling);
}
pub fn register_type(
&mut self,
type_name: String,
module_name: String,
visibility: Visibility,
) {
match self.type_registry.get(&type_name) {
Some((existing_module, existing_visibility))
if should_keep_existing_type_registration(
existing_module,
*existing_visibility,
&module_name,
visibility,
) => {}
_ => {
self.type_registry
.insert(type_name, (module_name, visibility));
}
}
}
pub fn get_type_visibility(&self, type_name: &str) -> Option<Visibility> {
self.type_registry.get(type_name).map(|(_, vis)| *vis)
}
pub fn get_type_module(&self, type_name: &str) -> Option<&str> {
self.type_registry
.get(type_name)
.map(|(module, _)| module.as_str())
}
pub fn update_coupling_visibility(&mut self) {
let visibility_updates: Vec<(usize, Visibility)> = self
.couplings
.iter()
.enumerate()
.filter_map(|(idx, coupling)| {
let target_type = coupling
.target
.split("::")
.last()
.unwrap_or(&coupling.target);
self.type_registry
.get(target_type)
.map(|(_, vis)| (idx, *vis))
})
.collect();
for (idx, visibility) in visibility_updates {
self.couplings[idx].target_visibility = visibility;
}
}
pub fn module_count(&self) -> usize {
self.modules.len()
}
pub fn coupling_count(&self) -> usize {
self.couplings.len()
}
pub fn internal_coupling_count(&self) -> usize {
self.couplings
.iter()
.filter(|c| c.distance != Distance::DifferentCrate)
.count()
}
pub fn average_strength(&self) -> Option<f64> {
if self.couplings.is_empty() {
return None;
}
let sum: f64 = self.couplings.iter().map(|c| c.strength_value()).sum();
Some(sum / self.couplings.len() as f64)
}
pub fn average_distance(&self) -> Option<f64> {
if self.couplings.is_empty() {
return None;
}
let sum: f64 = self.couplings.iter().map(|c| c.distance_value()).sum();
Some(sum / self.couplings.len() as f64)
}
pub fn update_volatility_from_git(&mut self) {
if self.file_changes.is_empty() {
return;
}
#[cfg(test)]
{
eprintln!("DEBUG: file_changes = {:?}", self.file_changes);
}
let module_paths: Vec<(String, PathBuf)> = self
.modules
.iter()
.map(|(name, module)| (name.clone(), module.path.clone()))
.collect();
for coupling in &mut self.couplings {
if let Some(module_path) = target_module_path(&coupling.target, &module_paths) {
coupling.volatility = Volatility::from_count(change_count_for_module_path(
module_path,
&self.file_changes,
));
continue;
}
let target_segments: Vec<&str> = coupling.target.split("::").collect();
let mut max_target_changes = 0usize;
for (file_path, &changes) in &self.file_changes {
let file_name = file_path
.rsplit('/')
.next()
.unwrap_or(file_path)
.trim_end_matches(".rs");
let target_matches_file = target_segments.iter().any(|part| {
let part_lower = part.to_lowercase();
let file_lower = file_name.to_lowercase();
if part_lower == file_lower {
return true;
}
if file_lower == "lib" && !part.is_empty() && *part != "*" {
if target_segments.len() >= 2 && target_segments[1] == *part {
return true;
}
}
let part_normalized = part_lower.replace('-', "_");
let file_normalized = file_lower.replace('-', "_");
if part_normalized == file_normalized {
return true;
}
if file_path.to_lowercase().contains(&part_lower) {
return true;
}
false
});
if target_matches_file {
max_target_changes = max_target_changes.max(changes);
}
}
coupling.volatility = Volatility::from_count(max_target_changes);
}
}
pub fn apply_config_volatility_overrides<C: MetricsConfig>(&mut self, config: &mut C) -> usize {
if !config.has_volatility_overrides() && !config.has_subdomain_config() {
return 0;
}
let mut module_paths = HashMap::new();
let has_subdomain_config = config.has_subdomain_config();
for (name, module) in &mut self.modules {
let relative_path = path_for_config_matching(&module.path, config);
if has_subdomain_config {
module.subdomain = config.get_subdomain(&relative_path);
}
insert_module_path_aliases(&mut module_paths, name, module, &relative_path);
}
let mut override_count = 0;
for coupling in &mut self.couplings {
let target_short = coupling
.target
.rsplit("::")
.next()
.unwrap_or(&coupling.target);
let lookup = module_paths
.get(&coupling.target)
.or_else(|| module_paths.get(target_short))
.or_else(|| {
coupling
.target
.rsplit("::")
.find_map(|segment| module_paths.get(segment))
})
.map(String::as_str)
.unwrap_or(coupling.target.as_str());
if let Some(override_vol) = config.get_volatility_override(lookup) {
coupling.volatility = override_vol;
override_count += 1;
}
}
override_count
}
fn build_dependency_graph(&self) -> HashMap<String, HashSet<String>> {
let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
for coupling in &self.couplings {
if coupling.distance == Distance::DifferentCrate {
continue;
}
let source = coupling.source.clone();
let target = coupling.target.clone();
graph.entry(source).or_default().insert(target);
}
graph
}
pub fn detect_circular_dependencies(&self) -> Vec<Vec<String>> {
let graph = self.build_dependency_graph();
let mut cycles: Vec<Vec<String>> = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
let mut rec_stack: HashSet<String> = HashSet::new();
for node in graph.keys() {
if !visited.contains(node) {
let mut path = Vec::new();
self.dfs_find_cycles(
node,
&graph,
&mut visited,
&mut rec_stack,
&mut path,
&mut cycles,
);
}
}
let mut unique_cycles: Vec<Vec<String>> = Vec::new();
for cycle in cycles {
let normalized = Self::normalize_cycle(&cycle);
if !unique_cycles
.iter()
.any(|c| Self::normalize_cycle(c) == normalized)
{
unique_cycles.push(cycle);
}
}
unique_cycles
}
fn dfs_find_cycles(
&self,
node: &str,
graph: &HashMap<String, HashSet<String>>,
visited: &mut HashSet<String>,
rec_stack: &mut HashSet<String>,
path: &mut Vec<String>,
cycles: &mut Vec<Vec<String>>,
) {
visited.insert(node.to_string());
rec_stack.insert(node.to_string());
path.push(node.to_string());
if let Some(neighbors) = graph.get(node) {
for neighbor in neighbors {
if !visited.contains(neighbor) {
self.dfs_find_cycles(neighbor, graph, visited, rec_stack, path, cycles);
} else if rec_stack.contains(neighbor) {
if let Some(start_idx) = path.iter().position(|n| n == neighbor) {
let cycle: Vec<String> = path[start_idx..].to_vec();
if cycle.len() >= 2 {
cycles.push(cycle);
}
}
}
}
}
path.pop();
rec_stack.remove(node);
}
fn normalize_cycle(cycle: &[String]) -> Vec<String> {
if cycle.is_empty() {
return Vec::new();
}
let min_pos = cycle
.iter()
.enumerate()
.min_by_key(|(_, s)| s.as_str())
.map(|(i, _)| i)
.unwrap_or(0);
let mut normalized: Vec<String> = cycle[min_pos..].to_vec();
normalized.extend_from_slice(&cycle[..min_pos]);
normalized
}
pub fn circular_dependency_summary(&self) -> CircularDependencySummary {
let cycles = self.detect_circular_dependencies();
let affected_modules: HashSet<String> = cycles.iter().flatten().cloned().collect();
CircularDependencySummary {
total_cycles: cycles.len(),
affected_modules: affected_modules.len(),
cycles,
}
}
pub fn calculate_dimension_stats(&self) -> DimensionStats {
let mut stats = DimensionStats::default();
for coupling in &self.couplings {
match coupling.strength {
IntegrationStrength::Intrusive => stats.strength_counts.intrusive += 1,
IntegrationStrength::Functional => stats.strength_counts.functional += 1,
IntegrationStrength::Model => stats.strength_counts.model += 1,
IntegrationStrength::Contract => stats.strength_counts.contract += 1,
}
match coupling.distance {
Distance::SameFunction | Distance::SameModule => {
stats.distance_counts.same_module += 1
}
Distance::DifferentModule => stats.distance_counts.different_module += 1,
Distance::DifferentCrate => stats.distance_counts.different_crate += 1,
}
match coupling.volatility {
Volatility::Low => stats.volatility_counts.low += 1,
Volatility::Medium => stats.volatility_counts.medium += 1,
Volatility::High => stats.volatility_counts.high += 1,
}
let classification = BalanceClassification::classify(
coupling.strength,
coupling.distance,
coupling.volatility,
);
match classification {
BalanceClassification::HighCohesion => stats.balance_counts.high_cohesion += 1,
BalanceClassification::LooseCoupling => stats.balance_counts.loose_coupling += 1,
BalanceClassification::Acceptable => stats.balance_counts.acceptable += 1,
BalanceClassification::Pain => stats.balance_counts.pain += 1,
BalanceClassification::LocalComplexity => {
stats.balance_counts.local_complexity += 1
}
}
}
stats
}
pub fn total_newtype_count(&self) -> usize {
self.modules.values().map(|m| m.newtype_count()).sum()
}
pub fn total_type_count(&self) -> usize {
self.modules
.values()
.flat_map(|m| m.type_definitions.values())
.filter(|t| !t.is_trait)
.count()
}
pub fn newtype_ratio(&self) -> f64 {
let total = self.total_type_count();
if total == 0 {
return 0.0;
}
self.total_newtype_count() as f64 / total as f64
}
pub fn serde_types(&self) -> Vec<(&str, &TypeDefinition)> {
self.modules
.iter()
.flat_map(|(module_name, m)| {
m.type_definitions
.values()
.filter(|t| t.has_serde_derive)
.map(move |t| (module_name.as_str(), t))
})
.collect()
}
pub fn god_modules(
&self,
max_functions: usize,
max_types: usize,
max_impls: usize,
) -> Vec<&str> {
self.modules
.iter()
.filter(|(_, m)| m.is_god_module(max_functions, max_types, max_impls))
.map(|(name, _)| name.as_str())
.collect()
}
pub fn functions_with_primitive_obsession(&self) -> Vec<(&str, &FunctionDefinition)> {
self.modules
.iter()
.flat_map(|(module_name, m)| {
m.functions_with_primitive_obsession()
.into_iter()
.map(move |f| (module_name.as_str(), f))
})
.collect()
}
pub fn types_with_public_fields(&self) -> Vec<(&str, &TypeDefinition)> {
self.modules
.iter()
.flat_map(|(module_name, m)| {
m.type_definitions
.values()
.filter(|t| t.public_field_count > 0 && !t.is_trait)
.map(move |t| (module_name.as_str(), t))
})
.collect()
}
}
fn should_keep_existing_type_registration(
existing_module: &str,
existing_visibility: Visibility,
candidate_module: &str,
candidate_visibility: Visibility,
) -> bool {
match (
existing_visibility == Visibility::Public,
candidate_visibility == Visibility::Public,
) {
(true, false) => true,
(false, true) => false,
_ => existing_module <= candidate_module,
}
}
fn target_module_path<'a>(target: &str, module_paths: &'a [(String, PathBuf)]) -> Option<&'a Path> {
let target = target.trim_start_matches("crate::");
let target_without_crate = target.split_once("::").and_then(|(_, rest)| {
module_paths
.iter()
.any(|(module, _)| rest == module || rest.starts_with(&format!("{module}::")))
.then_some(rest)
});
let target = target_without_crate.unwrap_or(target);
module_paths
.iter()
.filter(|(module, _)| target == module || target.starts_with(&format!("{module}::")))
.max_by_key(|(module, _)| module.len())
.map(|(_, path)| path.as_path())
}
fn change_count_for_module_path(
module_path: &Path,
file_changes: &HashMap<String, usize>,
) -> usize {
let module_path = module_path.to_string_lossy().replace('\\', "/");
file_changes
.iter()
.filter(|(file_path, _)| module_file_paths_match(&module_path, file_path))
.map(|(_, changes)| *changes)
.max()
.unwrap_or(0)
}
fn module_file_paths_match(module_path: &str, git_path: &str) -> bool {
let git_path = git_path.replace('\\', "/");
module_path == git_path
|| module_path.ends_with(&format!("/{git_path}"))
|| git_path.ends_with(&format!("/{module_path}"))
}
fn insert_module_path_aliases(
module_paths: &mut HashMap<String, String>,
key_name: &str,
module: &ModuleMetrics,
relative_path: &str,
) {
module_paths.insert(key_name.to_string(), relative_path.to_string());
module_paths.insert(module.name.clone(), relative_path.to_string());
if let Some(short_name) = key_name.rsplit("::").next() {
module_paths.insert(short_name.to_string(), relative_path.to_string());
}
if let Some(short_name) = module.name.rsplit("::").next() {
module_paths.insert(short_name.to_string(), relative_path.to_string());
}
if let Some(file_stem) = module.path.file_stem().and_then(|stem| stem.to_str()) {
module_paths.insert(file_stem.to_string(), relative_path.to_string());
}
}
fn path_for_config_matching(file_path: &Path, config: &impl MetricsConfig) -> String {
let normalized_file = normalize_path_for_matching(file_path);
let path = config
.config_root()
.map(normalize_path_for_matching)
.and_then(|base| {
normalized_file
.strip_prefix(base)
.ok()
.map(Path::to_path_buf)
})
.unwrap_or(normalized_file);
path.to_string_lossy().replace('\\', "/")
}
fn normalize_path_for_matching(path: &Path) -> PathBuf {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
};
let mut normalized = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
#[derive(Debug, Clone)]
pub struct CircularDependencySummary {
pub total_cycles: usize,
pub affected_modules: usize,
pub cycles: Vec<Vec<String>>,
}