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