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 match self.type_registry.get(&type_name) {
66 Some((existing_module, existing_visibility))
67 if should_keep_existing_type_registration(
68 existing_module,
69 *existing_visibility,
70 &module_name,
71 visibility,
72 ) => {}
73 _ => {
74 self.type_registry
75 .insert(type_name, (module_name, visibility));
76 }
77 }
78 }
79
80 pub fn get_type_visibility(&self, type_name: &str) -> Option<Visibility> {
82 self.type_registry.get(type_name).map(|(_, vis)| *vis)
83 }
84
85 pub fn get_type_module(&self, type_name: &str) -> Option<&str> {
87 self.type_registry
88 .get(type_name)
89 .map(|(module, _)| module.as_str())
90 }
91
92 pub fn update_coupling_visibility(&mut self) {
97 let visibility_updates: Vec<(usize, Visibility)> = self
99 .couplings
100 .iter()
101 .enumerate()
102 .filter_map(|(idx, coupling)| {
103 let target_type = coupling
104 .target
105 .split("::")
106 .last()
107 .unwrap_or(&coupling.target);
108 self.type_registry
109 .get(target_type)
110 .map(|(_, vis)| (idx, *vis))
111 })
112 .collect();
113
114 for (idx, visibility) in visibility_updates {
116 self.couplings[idx].target_visibility = visibility;
117 }
118 }
119
120 pub fn module_count(&self) -> usize {
122 self.modules.len()
123 }
124
125 pub fn coupling_count(&self) -> usize {
127 self.couplings.len()
128 }
129
130 pub fn internal_coupling_count(&self) -> usize {
132 self.couplings
133 .iter()
134 .filter(|c| c.distance != Distance::DifferentCrate)
135 .count()
136 }
137
138 pub fn average_strength(&self) -> Option<f64> {
140 if self.couplings.is_empty() {
141 return None;
142 }
143 let sum: f64 = self.couplings.iter().map(|c| c.strength_value()).sum();
144 Some(sum / self.couplings.len() as f64)
145 }
146
147 pub fn average_distance(&self) -> Option<f64> {
149 if self.couplings.is_empty() {
150 return None;
151 }
152 let sum: f64 = self.couplings.iter().map(|c| c.distance_value()).sum();
153 Some(sum / self.couplings.len() as f64)
154 }
155
156 pub fn update_volatility_from_git(&mut self) {
162 if self.file_changes.is_empty() {
163 return;
164 }
165
166 #[cfg(test)]
168 {
169 eprintln!("DEBUG: file_changes = {:?}", self.file_changes);
170 }
171
172 let module_paths: Vec<(String, PathBuf)> = self
173 .modules
174 .iter()
175 .map(|(name, module)| (name.clone(), module.path.clone()))
176 .collect();
177
178 for coupling in &mut self.couplings {
179 if let Some(module_path) = target_module_path(&coupling.target, &module_paths) {
180 coupling.volatility = Volatility::from_count(change_count_for_module_path(
181 module_path,
182 &self.file_changes,
183 ));
184 continue;
185 }
186
187 let target_segments: Vec<&str> = coupling.target.split("::").collect();
198
199 let mut max_target_changes = 0usize;
201 for (file_path, &changes) in &self.file_changes {
202 let file_name = file_path
204 .rsplit('/')
205 .next()
206 .unwrap_or(file_path)
207 .trim_end_matches(".rs");
208
209 let target_matches_file = target_segments.iter().any(|part| {
211 let part_lower = part.to_lowercase();
212 let file_lower = file_name.to_lowercase();
213
214 if part_lower == file_lower {
216 return true;
217 }
218
219 if file_lower == "lib" && !part.is_empty() && *part != "*" {
222 if target_segments.len() >= 2 && target_segments[1] == *part {
225 return true;
226 }
227 }
228
229 let part_normalized = part_lower.replace('-', "_");
232 let file_normalized = file_lower.replace('-', "_");
233 if part_normalized == file_normalized {
234 return true;
235 }
236
237 if file_path.to_lowercase().contains(&part_lower) {
239 return true;
240 }
241
242 false
243 });
244
245 if target_matches_file {
246 max_target_changes = max_target_changes.max(changes);
247 }
248 }
249
250 coupling.volatility = Volatility::from_count(max_target_changes);
251 }
252 }
253
254 pub fn apply_config_volatility_overrides<C: MetricsConfig>(&mut self, config: &mut C) -> usize {
260 if !config.has_volatility_overrides() && !config.has_subdomain_config() {
261 return 0;
262 }
263
264 let mut module_paths = HashMap::new();
265 let has_subdomain_config = config.has_subdomain_config();
266 for (name, module) in &mut self.modules {
267 let relative_path = path_for_config_matching(&module.path, config);
268 if has_subdomain_config {
269 module.subdomain = config.get_subdomain(&relative_path);
270 }
271
272 insert_module_path_aliases(&mut module_paths, name, module, &relative_path);
273 }
274
275 let mut override_count = 0;
276 for coupling in &mut self.couplings {
277 let target_short = coupling
278 .target
279 .rsplit("::")
280 .next()
281 .unwrap_or(&coupling.target);
282 let lookup = module_paths
283 .get(&coupling.target)
284 .or_else(|| module_paths.get(target_short))
285 .or_else(|| {
286 coupling
287 .target
288 .rsplit("::")
289 .find_map(|segment| module_paths.get(segment))
290 })
291 .map(String::as_str)
292 .unwrap_or(coupling.target.as_str());
293
294 if let Some(override_vol) = config.get_volatility_override(lookup) {
295 coupling.volatility = override_vol;
296 override_count += 1;
297 }
298 }
299
300 override_count
301 }
302
303 fn build_dependency_graph(&self) -> HashMap<String, HashSet<String>> {
305 let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
306
307 for coupling in &self.couplings {
308 if coupling.distance == Distance::DifferentCrate {
310 continue;
311 }
312
313 let source = coupling.source.clone();
315 let target = coupling.target.clone();
316
317 graph.entry(source).or_default().insert(target);
318 }
319
320 graph
321 }
322
323 pub fn detect_circular_dependencies(&self) -> Vec<Vec<String>> {
328 let graph = self.build_dependency_graph();
329 let mut cycles: Vec<Vec<String>> = Vec::new();
330 let mut visited: HashSet<String> = HashSet::new();
331 let mut rec_stack: HashSet<String> = HashSet::new();
332
333 for node in graph.keys() {
334 if !visited.contains(node) {
335 let mut path = Vec::new();
336 self.dfs_find_cycles(
337 node,
338 &graph,
339 &mut visited,
340 &mut rec_stack,
341 &mut path,
342 &mut cycles,
343 );
344 }
345 }
346
347 let mut unique_cycles: Vec<Vec<String>> = Vec::new();
349 for cycle in cycles {
350 let normalized = Self::normalize_cycle(&cycle);
351 if !unique_cycles
352 .iter()
353 .any(|c| Self::normalize_cycle(c) == normalized)
354 {
355 unique_cycles.push(cycle);
356 }
357 }
358
359 unique_cycles
360 }
361
362 fn dfs_find_cycles(
364 &self,
365 node: &str,
366 graph: &HashMap<String, HashSet<String>>,
367 visited: &mut HashSet<String>,
368 rec_stack: &mut HashSet<String>,
369 path: &mut Vec<String>,
370 cycles: &mut Vec<Vec<String>>,
371 ) {
372 visited.insert(node.to_string());
373 rec_stack.insert(node.to_string());
374 path.push(node.to_string());
375
376 if let Some(neighbors) = graph.get(node) {
377 for neighbor in neighbors {
378 if !visited.contains(neighbor) {
379 self.dfs_find_cycles(neighbor, graph, visited, rec_stack, path, cycles);
380 } else if rec_stack.contains(neighbor) {
381 if let Some(start_idx) = path.iter().position(|n| n == neighbor) {
383 let cycle: Vec<String> = path[start_idx..].to_vec();
384 if cycle.len() >= 2 {
385 cycles.push(cycle);
386 }
387 }
388 }
389 }
390 }
391
392 path.pop();
393 rec_stack.remove(node);
394 }
395
396 fn normalize_cycle(cycle: &[String]) -> Vec<String> {
399 if cycle.is_empty() {
400 return Vec::new();
401 }
402
403 let min_pos = cycle
405 .iter()
406 .enumerate()
407 .min_by_key(|(_, s)| s.as_str())
408 .map(|(i, _)| i)
409 .unwrap_or(0);
410
411 let mut normalized: Vec<String> = cycle[min_pos..].to_vec();
413 normalized.extend_from_slice(&cycle[..min_pos]);
414 normalized
415 }
416
417 pub fn circular_dependency_summary(&self) -> CircularDependencySummary {
419 let cycles = self.detect_circular_dependencies();
420 let affected_modules: HashSet<String> = cycles.iter().flatten().cloned().collect();
421
422 CircularDependencySummary {
423 total_cycles: cycles.len(),
424 affected_modules: affected_modules.len(),
425 cycles,
426 }
427 }
428
429 pub fn calculate_dimension_stats(&self) -> DimensionStats {
434 let mut stats = DimensionStats::default();
435
436 for coupling in &self.couplings {
437 match coupling.strength {
439 IntegrationStrength::Intrusive => stats.strength_counts.intrusive += 1,
440 IntegrationStrength::Functional => stats.strength_counts.functional += 1,
441 IntegrationStrength::Model => stats.strength_counts.model += 1,
442 IntegrationStrength::Contract => stats.strength_counts.contract += 1,
443 }
444
445 match coupling.distance {
447 Distance::SameFunction | Distance::SameModule => {
448 stats.distance_counts.same_module += 1
449 }
450 Distance::DifferentModule => stats.distance_counts.different_module += 1,
451 Distance::DifferentCrate => stats.distance_counts.different_crate += 1,
452 }
453
454 match coupling.volatility {
456 Volatility::Low => stats.volatility_counts.low += 1,
457 Volatility::Medium => stats.volatility_counts.medium += 1,
458 Volatility::High => stats.volatility_counts.high += 1,
459 }
460
461 let classification = BalanceClassification::classify(
463 coupling.strength,
464 coupling.distance,
465 coupling.volatility,
466 );
467 match classification {
468 BalanceClassification::HighCohesion => stats.balance_counts.high_cohesion += 1,
469 BalanceClassification::LooseCoupling => stats.balance_counts.loose_coupling += 1,
470 BalanceClassification::Acceptable => stats.balance_counts.acceptable += 1,
471 BalanceClassification::Pain => stats.balance_counts.pain += 1,
472 BalanceClassification::LocalComplexity => {
473 stats.balance_counts.local_complexity += 1
474 }
475 }
476 }
477
478 stats
479 }
480
481 pub fn total_newtype_count(&self) -> usize {
483 self.modules.values().map(|m| m.newtype_count()).sum()
484 }
485
486 pub fn total_type_count(&self) -> usize {
488 self.modules
489 .values()
490 .flat_map(|m| m.type_definitions.values())
491 .filter(|t| !t.is_trait)
492 .count()
493 }
494
495 pub fn newtype_ratio(&self) -> f64 {
497 let total = self.total_type_count();
498 if total == 0 {
499 return 0.0;
500 }
501 self.total_newtype_count() as f64 / total as f64
502 }
503
504 pub fn serde_types(&self) -> Vec<(&str, &TypeDefinition)> {
506 self.modules
507 .iter()
508 .flat_map(|(module_name, m)| {
509 m.type_definitions
510 .values()
511 .filter(|t| t.has_serde_derive)
512 .map(move |t| (module_name.as_str(), t))
513 })
514 .collect()
515 }
516
517 pub fn god_modules(
519 &self,
520 max_functions: usize,
521 max_types: usize,
522 max_impls: usize,
523 ) -> Vec<&str> {
524 self.modules
525 .iter()
526 .filter(|(_, m)| m.is_god_module(max_functions, max_types, max_impls))
527 .map(|(name, _)| name.as_str())
528 .collect()
529 }
530
531 pub fn functions_with_primitive_obsession(&self) -> Vec<(&str, &FunctionDefinition)> {
533 self.modules
534 .iter()
535 .flat_map(|(module_name, m)| {
536 m.functions_with_primitive_obsession()
537 .into_iter()
538 .map(move |f| (module_name.as_str(), f))
539 })
540 .collect()
541 }
542
543 pub fn types_with_public_fields(&self) -> Vec<(&str, &TypeDefinition)> {
545 self.modules
546 .iter()
547 .flat_map(|(module_name, m)| {
548 m.type_definitions
549 .values()
550 .filter(|t| t.public_field_count > 0 && !t.is_trait)
551 .map(move |t| (module_name.as_str(), t))
552 })
553 .collect()
554 }
555}
556
557fn should_keep_existing_type_registration(
558 existing_module: &str,
559 existing_visibility: Visibility,
560 candidate_module: &str,
561 candidate_visibility: Visibility,
562) -> bool {
563 match (
564 existing_visibility == Visibility::Public,
565 candidate_visibility == Visibility::Public,
566 ) {
567 (true, false) => true,
568 (false, true) => false,
569 _ => existing_module <= candidate_module,
570 }
571}
572
573fn target_module_path<'a>(target: &str, module_paths: &'a [(String, PathBuf)]) -> Option<&'a Path> {
574 let target = target.trim_start_matches("crate::");
575 let target_without_crate = target.split_once("::").and_then(|(_, rest)| {
576 module_paths
577 .iter()
578 .any(|(module, _)| rest == module || rest.starts_with(&format!("{module}::")))
579 .then_some(rest)
580 });
581 let target = target_without_crate.unwrap_or(target);
582
583 module_paths
584 .iter()
585 .filter(|(module, _)| target == module || target.starts_with(&format!("{module}::")))
586 .max_by_key(|(module, _)| module.len())
587 .map(|(_, path)| path.as_path())
588}
589
590fn change_count_for_module_path(
591 module_path: &Path,
592 file_changes: &HashMap<String, usize>,
593) -> usize {
594 let module_path = module_path.to_string_lossy().replace('\\', "/");
595 file_changes
596 .iter()
597 .filter(|(file_path, _)| module_file_paths_match(&module_path, file_path))
598 .map(|(_, changes)| *changes)
599 .max()
600 .unwrap_or(0)
601}
602
603fn module_file_paths_match(module_path: &str, git_path: &str) -> bool {
604 let git_path = git_path.replace('\\', "/");
605 module_path == git_path
606 || module_path.ends_with(&format!("/{git_path}"))
607 || git_path.ends_with(&format!("/{module_path}"))
608}
609
610fn insert_module_path_aliases(
611 module_paths: &mut HashMap<String, String>,
612 key_name: &str,
613 module: &ModuleMetrics,
614 relative_path: &str,
615) {
616 module_paths.insert(key_name.to_string(), relative_path.to_string());
617 module_paths.insert(module.name.clone(), relative_path.to_string());
618
619 if let Some(short_name) = key_name.rsplit("::").next() {
620 module_paths.insert(short_name.to_string(), relative_path.to_string());
621 }
622
623 if let Some(short_name) = module.name.rsplit("::").next() {
624 module_paths.insert(short_name.to_string(), relative_path.to_string());
625 }
626
627 if let Some(file_stem) = module.path.file_stem().and_then(|stem| stem.to_str()) {
628 module_paths.insert(file_stem.to_string(), relative_path.to_string());
629 }
630}
631
632fn path_for_config_matching(file_path: &Path, config: &impl MetricsConfig) -> String {
633 let normalized_file = normalize_path_for_matching(file_path);
634 let path = config
635 .config_root()
636 .map(normalize_path_for_matching)
637 .and_then(|base| {
638 normalized_file
639 .strip_prefix(base)
640 .ok()
641 .map(Path::to_path_buf)
642 })
643 .unwrap_or(normalized_file);
644
645 path.to_string_lossy().replace('\\', "/")
646}
647
648fn normalize_path_for_matching(path: &Path) -> PathBuf {
649 let absolute = if path.is_absolute() {
650 path.to_path_buf()
651 } else {
652 std::env::current_dir()
653 .map(|cwd| cwd.join(path))
654 .unwrap_or_else(|_| path.to_path_buf())
655 };
656
657 let mut normalized = PathBuf::new();
658 for component in absolute.components() {
659 match component {
660 Component::CurDir => {}
661 Component::ParentDir => {
662 normalized.pop();
663 }
664 other => normalized.push(other.as_os_str()),
665 }
666 }
667 normalized
668}
669
670#[derive(Debug, Clone)]
672pub struct CircularDependencySummary {
673 pub total_cycles: usize,
675 pub affected_modules: usize,
677 pub cycles: Vec<Vec<String>>,
679}