Skip to main content

cargo_coupling/metrics/
project.rs

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    /// All module metrics
15    pub modules: HashMap<String, ModuleMetrics>,
16    /// All detected couplings
17    pub couplings: Vec<CouplingMetrics>,
18    /// File change counts (for volatility)
19    pub file_changes: HashMap<String, usize>,
20    /// Total files analyzed
21    pub total_files: usize,
22    /// Source files that failed to parse or analyze and were skipped.
23    pub parse_failures: usize,
24    /// Workspace members with no discoverable source files.
25    pub skipped_crates: Vec<String>,
26    /// Module references skipped because they cross analyzed package/workspace boundaries.
27    pub boundary_skipped_files: usize,
28    /// Config patterns that matched no paths in the analysis candidate set.
29    pub dead_config_patterns: Vec<String>,
30    /// Workspace name (if available from cargo metadata)
31    pub workspace_name: Option<String>,
32    /// Workspace member crate names
33    pub workspace_members: Vec<String>,
34    /// Crate-level dependencies (crate name -> list of dependencies)
35    pub crate_dependencies: HashMap<String, Vec<String>>,
36    /// Global type registry: type name -> (module name, visibility)
37    pub type_registry: HashMap<String, (String, Visibility)>,
38    /// Temporal coupling data (files that co-change frequently)
39    pub temporal_couplings: Vec<TemporalCoupling>,
40}
41
42impl ProjectMetrics {
43    /// Create an empty project metrics accumulator.
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Add module metrics
49    pub fn add_module(&mut self, metrics: ModuleMetrics) {
50        self.modules.insert(metrics.name.clone(), metrics);
51    }
52
53    /// Add coupling
54    pub fn add_coupling(&mut self, coupling: CouplingMetrics) {
55        self.couplings.push(coupling);
56    }
57
58    /// Register a type definition in the global registry
59    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    /// Look up visibility of a type by name
81    pub fn get_type_visibility(&self, type_name: &str) -> Option<Visibility> {
82        self.type_registry.get(type_name).map(|(_, vis)| *vis)
83    }
84
85    /// Look up the module where a type is defined
86    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    /// Update visibility information for existing couplings
93    ///
94    /// This should be called after all modules have been analyzed
95    /// to populate the target_visibility field of couplings.
96    pub fn update_coupling_visibility(&mut self) {
97        // First collect all the visibility lookups
98        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        // Then apply the updates
115        for (idx, visibility) in visibility_updates {
116            self.couplings[idx].target_visibility = visibility;
117        }
118    }
119
120    /// Get total module count
121    pub fn module_count(&self) -> usize {
122        self.modules.len()
123    }
124
125    /// Get total coupling count
126    pub fn coupling_count(&self) -> usize {
127        self.couplings.len()
128    }
129
130    /// Get internal coupling count (excludes external crate dependencies)
131    pub fn internal_coupling_count(&self) -> usize {
132        self.couplings
133            .iter()
134            .filter(|c| c.distance != Distance::DifferentCrate)
135            .count()
136    }
137
138    /// Calculate average strength across all couplings
139    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    /// Calculate average distance across all couplings
148    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    /// Update volatility for all couplings based on file changes
157    ///
158    /// This should be called after git history analysis to update
159    /// the volatility of each coupling based on how often the target
160    /// module/file has changed.
161    pub fn update_volatility_from_git(&mut self) {
162        if self.file_changes.is_empty() {
163            return;
164        }
165
166        // Debug: print file changes for troubleshooting
167        #[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            // Try to find the target file in file_changes
188            // The target is like "crate::module" or "crate::module::Type"
189            // We need to match this against file paths like "src/module.rs"
190            //
191            // Special cases in Rust module system:
192            // - crate root "crate::crate_name" or "crate_name::crate_name" -> lib.rs
193            // - binary entry point -> main.rs
194            // - glob imports "crate::*" -> don't match specific files
195
196            // Extract all path components from target
197            let target_segments: Vec<&str> = coupling.target.split("::").collect();
198
199            // Find the best matching file
200            let mut max_target_changes = 0usize;
201            for (file_path, &changes) in &self.file_changes {
202                // Get file name without .rs extension (e.g., "balance" from "src/balance.rs")
203                let file_name = file_path
204                    .rsplit('/')
205                    .next()
206                    .unwrap_or(file_path)
207                    .trim_end_matches(".rs");
208
209                // Check if any target path component matches the file name
210                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                    // Direct match: "balance" == "balance"
215                    if part_lower == file_lower {
216                        return true;
217                    }
218
219                    // Handle crate root: if the part matches the crate name and file is lib.rs
220                    // e.g., "cargo_coupling" matches "lib" (lib.rs is the crate root)
221                    if file_lower == "lib" && !part.is_empty() && *part != "*" {
222                        // This could be the crate root reference
223                        // We also match if the part is the crate name (same as first path component)
224                        if target_segments.len() >= 2 && target_segments[1] == *part {
225                            return true;
226                        }
227                    }
228
229                    // Handle underscore vs hyphen in crate names
230                    // e.g., "cargo-coupling" might appear as "cargo_coupling" in code
231                    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                    // Path contains match: "web" matches "src/web/graph.rs"
238                    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    /// Apply config-derived subdomain classifications and volatility overrides.
255    ///
256    /// Config patterns are path-based while couplings are module-name-based, so
257    /// this resolves coupling targets through known module file paths before
258    /// querying the config.
259    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    /// Build a dependency graph from couplings
304    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            // Only consider internal couplings (not external crates)
309            if coupling.distance == Distance::DifferentCrate {
310                continue;
311            }
312
313            // Extract module names (remove crate prefix for cleaner cycles)
314            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    /// Detect circular dependencies in the project
324    ///
325    /// Returns a list of cycles, where each cycle is a list of module names
326    /// forming the circular dependency chain.
327    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        // Deduplicate cycles (same cycle can be detected from different starting points)
348        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    /// DFS helper for cycle detection
363    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                    // Found a cycle - extract the cycle from path
382                    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    /// Normalize a cycle for deduplication
397    /// Rotates the cycle so the lexicographically smallest element is first
398    fn normalize_cycle(cycle: &[String]) -> Vec<String> {
399        if cycle.is_empty() {
400            return Vec::new();
401        }
402
403        // Find the position of the minimum element
404        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        // Rotate the cycle
412        let mut normalized: Vec<String> = cycle[min_pos..].to_vec();
413        normalized.extend_from_slice(&cycle[..min_pos]);
414        normalized
415    }
416
417    /// Get circular dependency summary
418    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    /// Calculate 3-dimensional coupling statistics
430    ///
431    /// Computes distribution of couplings across Strength, Distance,
432    /// Volatility, and Balance Classification dimensions.
433    pub fn calculate_dimension_stats(&self) -> DimensionStats {
434        let mut stats = DimensionStats::default();
435
436        for coupling in &self.couplings {
437            // Count strength distribution
438            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            // Count distance distribution
446            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            // Count volatility distribution
455            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            // Classify and count balance
462            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    /// Get total newtype count across all modules
482    pub fn total_newtype_count(&self) -> usize {
483        self.modules.values().map(|m| m.newtype_count()).sum()
484    }
485
486    /// Get total type count across all modules (excluding traits)
487    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    /// Calculate project-wide newtype usage ratio
496    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    /// Get types with serde derives (potential DTO exposure)
505    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    /// Identify potential God Modules
518    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    /// Get all functions with potential Primitive Obsession
532    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    /// Get types with exposed public fields
544    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/// Summary of circular dependencies
671#[derive(Debug, Clone)]
672pub struct CircularDependencySummary {
673    /// Total number of circular dependency cycles
674    pub total_cycles: usize,
675    /// Number of modules involved in cycles
676    pub affected_modules: usize,
677    /// The actual cycles (list of module names)
678    pub cycles: Vec<Vec<String>>,
679}