1use std::collections::{HashMap, HashSet};
2use std::path::{Component, Path, PathBuf};
3
4use crate::volatility::{TemporalCoupling, Volatility};
5
6use super::coupling::CouplingMetrics;
7use super::dimensions::{Distance, IntegrationStrength, MetricsConfig, Visibility};
8use super::module::{
9 BalanceClassification, DimensionStats, FunctionDefinition, ModuleMetrics, TypeDefinition,
10};
11
12#[derive(Debug, Default)]
13pub struct ProjectMetrics {
14 pub modules: HashMap<String, ModuleMetrics>,
16 pub couplings: Vec<CouplingMetrics>,
18 pub file_changes: HashMap<String, usize>,
20 pub total_files: usize,
22 pub parse_failures: usize,
24 pub skipped_crates: Vec<String>,
26 pub boundary_skipped_files: usize,
28 pub dead_config_patterns: Vec<String>,
30 pub workspace_name: Option<String>,
32 pub workspace_members: Vec<String>,
34 pub crate_dependencies: HashMap<String, Vec<String>>,
36 pub type_registry: HashMap<String, (String, Visibility)>,
38 pub temporal_couplings: Vec<TemporalCoupling>,
40}
41
42impl ProjectMetrics {
43 pub fn new() -> Self {
45 Self::default()
46 }
47
48 pub fn add_module(&mut self, metrics: ModuleMetrics) {
50 self.modules.insert(metrics.name.clone(), metrics);
51 }
52
53 pub fn add_coupling(&mut self, coupling: CouplingMetrics) {
55 self.couplings.push(coupling);
56 }
57
58 pub fn register_type(
60 &mut self,
61 type_name: String,
62 module_name: String,
63 visibility: Visibility,
64 ) {
65 self.type_registry
66 .insert(type_name, (module_name, visibility));
67 }
68
69 pub fn get_type_visibility(&self, type_name: &str) -> Option<Visibility> {
71 self.type_registry.get(type_name).map(|(_, vis)| *vis)
72 }
73
74 pub fn get_type_module(&self, type_name: &str) -> Option<&str> {
76 self.type_registry
77 .get(type_name)
78 .map(|(module, _)| module.as_str())
79 }
80
81 pub fn update_coupling_visibility(&mut self) {
86 let visibility_updates: Vec<(usize, Visibility)> = self
88 .couplings
89 .iter()
90 .enumerate()
91 .filter_map(|(idx, coupling)| {
92 let target_type = coupling
93 .target
94 .split("::")
95 .last()
96 .unwrap_or(&coupling.target);
97 self.type_registry
98 .get(target_type)
99 .map(|(_, vis)| (idx, *vis))
100 })
101 .collect();
102
103 for (idx, visibility) in visibility_updates {
105 self.couplings[idx].target_visibility = visibility;
106 }
107 }
108
109 pub fn module_count(&self) -> usize {
111 self.modules.len()
112 }
113
114 pub fn coupling_count(&self) -> usize {
116 self.couplings.len()
117 }
118
119 pub fn internal_coupling_count(&self) -> usize {
121 self.couplings
122 .iter()
123 .filter(|c| c.distance != Distance::DifferentCrate)
124 .count()
125 }
126
127 pub fn average_strength(&self) -> Option<f64> {
129 if self.couplings.is_empty() {
130 return None;
131 }
132 let sum: f64 = self.couplings.iter().map(|c| c.strength_value()).sum();
133 Some(sum / self.couplings.len() as f64)
134 }
135
136 pub fn average_distance(&self) -> Option<f64> {
138 if self.couplings.is_empty() {
139 return None;
140 }
141 let sum: f64 = self.couplings.iter().map(|c| c.distance_value()).sum();
142 Some(sum / self.couplings.len() as f64)
143 }
144
145 pub fn update_volatility_from_git(&mut self) {
151 if self.file_changes.is_empty() {
152 return;
153 }
154
155 #[cfg(test)]
157 {
158 eprintln!("DEBUG: file_changes = {:?}", self.file_changes);
159 }
160
161 let module_paths: Vec<(String, PathBuf)> = self
162 .modules
163 .iter()
164 .map(|(name, module)| (name.clone(), module.path.clone()))
165 .collect();
166
167 for coupling in &mut self.couplings {
168 if let Some(module_path) = target_module_path(&coupling.target, &module_paths) {
169 coupling.volatility = Volatility::from_count(change_count_for_module_path(
170 module_path,
171 &self.file_changes,
172 ));
173 continue;
174 }
175
176 let target_segments: Vec<&str> = coupling.target.split("::").collect();
187
188 let mut max_target_changes = 0usize;
190 for (file_path, &changes) in &self.file_changes {
191 let file_name = file_path
193 .rsplit('/')
194 .next()
195 .unwrap_or(file_path)
196 .trim_end_matches(".rs");
197
198 let target_matches_file = target_segments.iter().any(|part| {
200 let part_lower = part.to_lowercase();
201 let file_lower = file_name.to_lowercase();
202
203 if part_lower == file_lower {
205 return true;
206 }
207
208 if file_lower == "lib" && !part.is_empty() && *part != "*" {
211 if target_segments.len() >= 2 && target_segments[1] == *part {
214 return true;
215 }
216 }
217
218 let part_normalized = part_lower.replace('-', "_");
221 let file_normalized = file_lower.replace('-', "_");
222 if part_normalized == file_normalized {
223 return true;
224 }
225
226 if file_path.to_lowercase().contains(&part_lower) {
228 return true;
229 }
230
231 false
232 });
233
234 if target_matches_file {
235 max_target_changes = max_target_changes.max(changes);
236 }
237 }
238
239 coupling.volatility = Volatility::from_count(max_target_changes);
240 }
241 }
242
243 pub fn apply_config_volatility_overrides<C: MetricsConfig>(&mut self, config: &mut C) -> usize {
249 if !config.has_volatility_overrides() && !config.has_subdomain_config() {
250 return 0;
251 }
252
253 let mut module_paths = HashMap::new();
254 let has_subdomain_config = config.has_subdomain_config();
255 for (name, module) in &mut self.modules {
256 let relative_path = path_for_config_matching(&module.path, config);
257 if has_subdomain_config {
258 module.subdomain = config.get_subdomain(&relative_path);
259 }
260
261 insert_module_path_aliases(&mut module_paths, name, module, &relative_path);
262 }
263
264 let mut override_count = 0;
265 for coupling in &mut self.couplings {
266 let target_short = coupling
267 .target
268 .rsplit("::")
269 .next()
270 .unwrap_or(&coupling.target);
271 let lookup = module_paths
272 .get(&coupling.target)
273 .or_else(|| module_paths.get(target_short))
274 .or_else(|| {
275 coupling
276 .target
277 .rsplit("::")
278 .find_map(|segment| module_paths.get(segment))
279 })
280 .map(String::as_str)
281 .unwrap_or(coupling.target.as_str());
282
283 if let Some(override_vol) = config.get_volatility_override(lookup) {
284 coupling.volatility = override_vol;
285 override_count += 1;
286 }
287 }
288
289 override_count
290 }
291
292 fn build_dependency_graph(&self) -> HashMap<String, HashSet<String>> {
294 let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
295
296 for coupling in &self.couplings {
297 if coupling.distance == Distance::DifferentCrate {
299 continue;
300 }
301
302 let source = coupling.source.clone();
304 let target = coupling.target.clone();
305
306 graph.entry(source).or_default().insert(target);
307 }
308
309 graph
310 }
311
312 pub fn detect_circular_dependencies(&self) -> Vec<Vec<String>> {
317 let graph = self.build_dependency_graph();
318 let mut cycles: Vec<Vec<String>> = Vec::new();
319 let mut visited: HashSet<String> = HashSet::new();
320 let mut rec_stack: HashSet<String> = HashSet::new();
321
322 for node in graph.keys() {
323 if !visited.contains(node) {
324 let mut path = Vec::new();
325 self.dfs_find_cycles(
326 node,
327 &graph,
328 &mut visited,
329 &mut rec_stack,
330 &mut path,
331 &mut cycles,
332 );
333 }
334 }
335
336 let mut unique_cycles: Vec<Vec<String>> = Vec::new();
338 for cycle in cycles {
339 let normalized = Self::normalize_cycle(&cycle);
340 if !unique_cycles
341 .iter()
342 .any(|c| Self::normalize_cycle(c) == normalized)
343 {
344 unique_cycles.push(cycle);
345 }
346 }
347
348 unique_cycles
349 }
350
351 fn dfs_find_cycles(
353 &self,
354 node: &str,
355 graph: &HashMap<String, HashSet<String>>,
356 visited: &mut HashSet<String>,
357 rec_stack: &mut HashSet<String>,
358 path: &mut Vec<String>,
359 cycles: &mut Vec<Vec<String>>,
360 ) {
361 visited.insert(node.to_string());
362 rec_stack.insert(node.to_string());
363 path.push(node.to_string());
364
365 if let Some(neighbors) = graph.get(node) {
366 for neighbor in neighbors {
367 if !visited.contains(neighbor) {
368 self.dfs_find_cycles(neighbor, graph, visited, rec_stack, path, cycles);
369 } else if rec_stack.contains(neighbor) {
370 if let Some(start_idx) = path.iter().position(|n| n == neighbor) {
372 let cycle: Vec<String> = path[start_idx..].to_vec();
373 if cycle.len() >= 2 {
374 cycles.push(cycle);
375 }
376 }
377 }
378 }
379 }
380
381 path.pop();
382 rec_stack.remove(node);
383 }
384
385 fn normalize_cycle(cycle: &[String]) -> Vec<String> {
388 if cycle.is_empty() {
389 return Vec::new();
390 }
391
392 let min_pos = cycle
394 .iter()
395 .enumerate()
396 .min_by_key(|(_, s)| s.as_str())
397 .map(|(i, _)| i)
398 .unwrap_or(0);
399
400 let mut normalized: Vec<String> = cycle[min_pos..].to_vec();
402 normalized.extend_from_slice(&cycle[..min_pos]);
403 normalized
404 }
405
406 pub fn circular_dependency_summary(&self) -> CircularDependencySummary {
408 let cycles = self.detect_circular_dependencies();
409 let affected_modules: HashSet<String> = cycles.iter().flatten().cloned().collect();
410
411 CircularDependencySummary {
412 total_cycles: cycles.len(),
413 affected_modules: affected_modules.len(),
414 cycles,
415 }
416 }
417
418 pub fn calculate_dimension_stats(&self) -> DimensionStats {
423 let mut stats = DimensionStats::default();
424
425 for coupling in &self.couplings {
426 match coupling.strength {
428 IntegrationStrength::Intrusive => stats.strength_counts.intrusive += 1,
429 IntegrationStrength::Functional => stats.strength_counts.functional += 1,
430 IntegrationStrength::Model => stats.strength_counts.model += 1,
431 IntegrationStrength::Contract => stats.strength_counts.contract += 1,
432 }
433
434 match coupling.distance {
436 Distance::SameFunction | Distance::SameModule => {
437 stats.distance_counts.same_module += 1
438 }
439 Distance::DifferentModule => stats.distance_counts.different_module += 1,
440 Distance::DifferentCrate => stats.distance_counts.different_crate += 1,
441 }
442
443 match coupling.volatility {
445 Volatility::Low => stats.volatility_counts.low += 1,
446 Volatility::Medium => stats.volatility_counts.medium += 1,
447 Volatility::High => stats.volatility_counts.high += 1,
448 }
449
450 let classification = BalanceClassification::classify(
452 coupling.strength,
453 coupling.distance,
454 coupling.volatility,
455 );
456 match classification {
457 BalanceClassification::HighCohesion => stats.balance_counts.high_cohesion += 1,
458 BalanceClassification::LooseCoupling => stats.balance_counts.loose_coupling += 1,
459 BalanceClassification::Acceptable => stats.balance_counts.acceptable += 1,
460 BalanceClassification::Pain => stats.balance_counts.pain += 1,
461 BalanceClassification::LocalComplexity => {
462 stats.balance_counts.local_complexity += 1
463 }
464 }
465 }
466
467 stats
468 }
469
470 pub fn total_newtype_count(&self) -> usize {
472 self.modules.values().map(|m| m.newtype_count()).sum()
473 }
474
475 pub fn total_type_count(&self) -> usize {
477 self.modules
478 .values()
479 .flat_map(|m| m.type_definitions.values())
480 .filter(|t| !t.is_trait)
481 .count()
482 }
483
484 pub fn newtype_ratio(&self) -> f64 {
486 let total = self.total_type_count();
487 if total == 0 {
488 return 0.0;
489 }
490 self.total_newtype_count() as f64 / total as f64
491 }
492
493 pub fn serde_types(&self) -> Vec<(&str, &TypeDefinition)> {
495 self.modules
496 .iter()
497 .flat_map(|(module_name, m)| {
498 m.type_definitions
499 .values()
500 .filter(|t| t.has_serde_derive)
501 .map(move |t| (module_name.as_str(), t))
502 })
503 .collect()
504 }
505
506 pub fn god_modules(
508 &self,
509 max_functions: usize,
510 max_types: usize,
511 max_impls: usize,
512 ) -> Vec<&str> {
513 self.modules
514 .iter()
515 .filter(|(_, m)| m.is_god_module(max_functions, max_types, max_impls))
516 .map(|(name, _)| name.as_str())
517 .collect()
518 }
519
520 pub fn functions_with_primitive_obsession(&self) -> Vec<(&str, &FunctionDefinition)> {
522 self.modules
523 .iter()
524 .flat_map(|(module_name, m)| {
525 m.functions_with_primitive_obsession()
526 .into_iter()
527 .map(move |f| (module_name.as_str(), f))
528 })
529 .collect()
530 }
531
532 pub fn types_with_public_fields(&self) -> Vec<(&str, &TypeDefinition)> {
534 self.modules
535 .iter()
536 .flat_map(|(module_name, m)| {
537 m.type_definitions
538 .values()
539 .filter(|t| t.public_field_count > 0 && !t.is_trait)
540 .map(move |t| (module_name.as_str(), t))
541 })
542 .collect()
543 }
544}
545
546fn target_module_path<'a>(target: &str, module_paths: &'a [(String, PathBuf)]) -> Option<&'a Path> {
547 let target = target.trim_start_matches("crate::");
548 let target_without_crate = target.split_once("::").and_then(|(_, rest)| {
549 module_paths
550 .iter()
551 .any(|(module, _)| rest == module || rest.starts_with(&format!("{module}::")))
552 .then_some(rest)
553 });
554 let target = target_without_crate.unwrap_or(target);
555
556 module_paths
557 .iter()
558 .filter(|(module, _)| target == module || target.starts_with(&format!("{module}::")))
559 .max_by_key(|(module, _)| module.len())
560 .map(|(_, path)| path.as_path())
561}
562
563fn change_count_for_module_path(
564 module_path: &Path,
565 file_changes: &HashMap<String, usize>,
566) -> usize {
567 let module_path = module_path.to_string_lossy().replace('\\', "/");
568 file_changes
569 .iter()
570 .filter(|(file_path, _)| module_file_paths_match(&module_path, file_path))
571 .map(|(_, changes)| *changes)
572 .max()
573 .unwrap_or(0)
574}
575
576fn module_file_paths_match(module_path: &str, git_path: &str) -> bool {
577 let git_path = git_path.replace('\\', "/");
578 module_path == git_path
579 || module_path.ends_with(&format!("/{git_path}"))
580 || git_path.ends_with(&format!("/{module_path}"))
581}
582
583fn insert_module_path_aliases(
584 module_paths: &mut HashMap<String, String>,
585 key_name: &str,
586 module: &ModuleMetrics,
587 relative_path: &str,
588) {
589 module_paths.insert(key_name.to_string(), relative_path.to_string());
590 module_paths.insert(module.name.clone(), relative_path.to_string());
591
592 if let Some(short_name) = key_name.rsplit("::").next() {
593 module_paths.insert(short_name.to_string(), relative_path.to_string());
594 }
595
596 if let Some(short_name) = module.name.rsplit("::").next() {
597 module_paths.insert(short_name.to_string(), relative_path.to_string());
598 }
599
600 if let Some(file_stem) = module.path.file_stem().and_then(|stem| stem.to_str()) {
601 module_paths.insert(file_stem.to_string(), relative_path.to_string());
602 }
603}
604
605fn path_for_config_matching(file_path: &Path, config: &impl MetricsConfig) -> String {
606 let normalized_file = normalize_path_for_matching(file_path);
607 let path = config
608 .config_root()
609 .map(normalize_path_for_matching)
610 .and_then(|base| {
611 normalized_file
612 .strip_prefix(base)
613 .ok()
614 .map(Path::to_path_buf)
615 })
616 .unwrap_or(normalized_file);
617
618 path.to_string_lossy().replace('\\', "/")
619}
620
621fn normalize_path_for_matching(path: &Path) -> PathBuf {
622 let absolute = if path.is_absolute() {
623 path.to_path_buf()
624 } else {
625 std::env::current_dir()
626 .map(|cwd| cwd.join(path))
627 .unwrap_or_else(|_| path.to_path_buf())
628 };
629
630 let mut normalized = PathBuf::new();
631 for component in absolute.components() {
632 match component {
633 Component::CurDir => {}
634 Component::ParentDir => {
635 normalized.pop();
636 }
637 other => normalized.push(other.as_os_str()),
638 }
639 }
640 normalized
641}
642
643#[derive(Debug, Clone)]
645pub struct CircularDependencySummary {
646 pub total_cycles: usize,
648 pub affected_modules: usize,
650 pub cycles: Vec<Vec<String>>,
652}