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        self.type_registry
66            .insert(type_name, (module_name, visibility));
67    }
68
69    /// Look up visibility of a type by name
70    pub fn get_type_visibility(&self, type_name: &str) -> Option<Visibility> {
71        self.type_registry.get(type_name).map(|(_, vis)| *vis)
72    }
73
74    /// Look up the module where a type is defined
75    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    /// Update visibility information for existing couplings
82    ///
83    /// This should be called after all modules have been analyzed
84    /// to populate the target_visibility field of couplings.
85    pub fn update_coupling_visibility(&mut self) {
86        // First collect all the visibility lookups
87        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        // Then apply the updates
104        for (idx, visibility) in visibility_updates {
105            self.couplings[idx].target_visibility = visibility;
106        }
107    }
108
109    /// Get total module count
110    pub fn module_count(&self) -> usize {
111        self.modules.len()
112    }
113
114    /// Get total coupling count
115    pub fn coupling_count(&self) -> usize {
116        self.couplings.len()
117    }
118
119    /// Get internal coupling count (excludes external crate dependencies)
120    pub fn internal_coupling_count(&self) -> usize {
121        self.couplings
122            .iter()
123            .filter(|c| c.distance != Distance::DifferentCrate)
124            .count()
125    }
126
127    /// Calculate average strength across all couplings
128    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    /// Calculate average distance across all couplings
137    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    /// Update volatility for all couplings based on file changes
146    ///
147    /// This should be called after git history analysis to update
148    /// the volatility of each coupling based on how often the target
149    /// module/file has changed.
150    pub fn update_volatility_from_git(&mut self) {
151        if self.file_changes.is_empty() {
152            return;
153        }
154
155        // Debug: print file changes for troubleshooting
156        #[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            // Try to find the target file in file_changes
177            // The target is like "crate::module" or "crate::module::Type"
178            // We need to match this against file paths like "src/module.rs"
179            //
180            // Special cases in Rust module system:
181            // - crate root "crate::crate_name" or "crate_name::crate_name" -> lib.rs
182            // - binary entry point -> main.rs
183            // - glob imports "crate::*" -> don't match specific files
184
185            // Extract all path components from target
186            let target_segments: Vec<&str> = coupling.target.split("::").collect();
187
188            // Find the best matching file
189            let mut max_target_changes = 0usize;
190            for (file_path, &changes) in &self.file_changes {
191                // Get file name without .rs extension (e.g., "balance" from "src/balance.rs")
192                let file_name = file_path
193                    .rsplit('/')
194                    .next()
195                    .unwrap_or(file_path)
196                    .trim_end_matches(".rs");
197
198                // Check if any target path component matches the file name
199                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                    // Direct match: "balance" == "balance"
204                    if part_lower == file_lower {
205                        return true;
206                    }
207
208                    // Handle crate root: if the part matches the crate name and file is lib.rs
209                    // e.g., "cargo_coupling" matches "lib" (lib.rs is the crate root)
210                    if file_lower == "lib" && !part.is_empty() && *part != "*" {
211                        // This could be the crate root reference
212                        // We also match if the part is the crate name (same as first path component)
213                        if target_segments.len() >= 2 && target_segments[1] == *part {
214                            return true;
215                        }
216                    }
217
218                    // Handle underscore vs hyphen in crate names
219                    // e.g., "cargo-coupling" might appear as "cargo_coupling" in code
220                    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                    // Path contains match: "web" matches "src/web/graph.rs"
227                    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    /// Apply config-derived subdomain classifications and volatility overrides.
244    ///
245    /// Config patterns are path-based while couplings are module-name-based, so
246    /// this resolves coupling targets through known module file paths before
247    /// querying the config.
248    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    /// Build a dependency graph from couplings
293    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            // Only consider internal couplings (not external crates)
298            if coupling.distance == Distance::DifferentCrate {
299                continue;
300            }
301
302            // Extract module names (remove crate prefix for cleaner cycles)
303            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    /// Detect circular dependencies in the project
313    ///
314    /// Returns a list of cycles, where each cycle is a list of module names
315    /// forming the circular dependency chain.
316    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        // Deduplicate cycles (same cycle can be detected from different starting points)
337        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    /// DFS helper for cycle detection
352    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                    // Found a cycle - extract the cycle from path
371                    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    /// Normalize a cycle for deduplication
386    /// Rotates the cycle so the lexicographically smallest element is first
387    fn normalize_cycle(cycle: &[String]) -> Vec<String> {
388        if cycle.is_empty() {
389            return Vec::new();
390        }
391
392        // Find the position of the minimum element
393        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        // Rotate the cycle
401        let mut normalized: Vec<String> = cycle[min_pos..].to_vec();
402        normalized.extend_from_slice(&cycle[..min_pos]);
403        normalized
404    }
405
406    /// Get circular dependency summary
407    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    /// Calculate 3-dimensional coupling statistics
419    ///
420    /// Computes distribution of couplings across Strength, Distance,
421    /// Volatility, and Balance Classification dimensions.
422    pub fn calculate_dimension_stats(&self) -> DimensionStats {
423        let mut stats = DimensionStats::default();
424
425        for coupling in &self.couplings {
426            // Count strength distribution
427            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            // Count distance distribution
435            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            // Count volatility distribution
444            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            // Classify and count balance
451            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    /// Get total newtype count across all modules
471    pub fn total_newtype_count(&self) -> usize {
472        self.modules.values().map(|m| m.newtype_count()).sum()
473    }
474
475    /// Get total type count across all modules (excluding traits)
476    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    /// Calculate project-wide newtype usage ratio
485    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    /// Get types with serde derives (potential DTO exposure)
494    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    /// Identify potential God Modules
507    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    /// Get all functions with potential Primitive Obsession
521    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    /// Get types with exposed public fields
533    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/// Summary of circular dependencies
644#[derive(Debug, Clone)]
645pub struct CircularDependencySummary {
646    /// Total number of circular dependency cycles
647    pub total_cycles: usize,
648    /// Number of modules involved in cycles
649    pub affected_modules: usize,
650    /// The actual cycles (list of module names)
651    pub cycles: Vec<Vec<String>>,
652}