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