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 name (if available from cargo metadata)
25    pub workspace_name: Option<String>,
26    /// Workspace member crate names
27    pub workspace_members: Vec<String>,
28    /// Crate-level dependencies (crate name -> list of dependencies)
29    pub crate_dependencies: HashMap<String, Vec<String>>,
30    /// Global type registry: type name -> (module name, visibility)
31    pub type_registry: HashMap<String, (String, Visibility)>,
32    /// Temporal coupling data (files that co-change frequently)
33    pub temporal_couplings: Vec<TemporalCoupling>,
34}
35
36impl ProjectMetrics {
37    /// Create an empty project metrics accumulator.
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Add module metrics
43    pub fn add_module(&mut self, metrics: ModuleMetrics) {
44        self.modules.insert(metrics.name.clone(), metrics);
45    }
46
47    /// Add coupling
48    pub fn add_coupling(&mut self, coupling: CouplingMetrics) {
49        self.couplings.push(coupling);
50    }
51
52    /// Register a type definition in the global registry
53    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    /// Look up visibility of a type by name
64    pub fn get_type_visibility(&self, type_name: &str) -> Option<Visibility> {
65        self.type_registry.get(type_name).map(|(_, vis)| *vis)
66    }
67
68    /// Look up the module where a type is defined
69    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    /// Update visibility information for existing couplings
76    ///
77    /// This should be called after all modules have been analyzed
78    /// to populate the target_visibility field of couplings.
79    pub fn update_coupling_visibility(&mut self) {
80        // First collect all the visibility lookups
81        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        // Then apply the updates
98        for (idx, visibility) in visibility_updates {
99            self.couplings[idx].target_visibility = visibility;
100        }
101    }
102
103    /// Get total module count
104    pub fn module_count(&self) -> usize {
105        self.modules.len()
106    }
107
108    /// Get total coupling count
109    pub fn coupling_count(&self) -> usize {
110        self.couplings.len()
111    }
112
113    /// Get internal coupling count (excludes external crate dependencies)
114    pub fn internal_coupling_count(&self) -> usize {
115        self.couplings
116            .iter()
117            .filter(|c| c.distance != Distance::DifferentCrate)
118            .count()
119    }
120
121    /// Calculate average strength across all couplings
122    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    /// Calculate average distance across all couplings
131    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    /// Update volatility for all couplings based on file changes
140    ///
141    /// This should be called after git history analysis to update
142    /// the volatility of each coupling based on how often the target
143    /// module/file has changed.
144    pub fn update_volatility_from_git(&mut self) {
145        if self.file_changes.is_empty() {
146            return;
147        }
148
149        // Debug: print file changes for troubleshooting
150        #[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            // Try to find the target file in file_changes
171            // The target is like "crate::module" or "crate::module::Type"
172            // We need to match this against file paths like "src/module.rs"
173            //
174            // Special cases in Rust module system:
175            // - crate root "crate::crate_name" or "crate_name::crate_name" -> lib.rs
176            // - binary entry point -> main.rs
177            // - glob imports "crate::*" -> don't match specific files
178
179            // Extract all path components from target
180            let target_segments: Vec<&str> = coupling.target.split("::").collect();
181
182            // Find the best matching file
183            let mut max_target_changes = 0usize;
184            for (file_path, &changes) in &self.file_changes {
185                // Get file name without .rs extension (e.g., "balance" from "src/balance.rs")
186                let file_name = file_path
187                    .rsplit('/')
188                    .next()
189                    .unwrap_or(file_path)
190                    .trim_end_matches(".rs");
191
192                // Check if any target path component matches the file name
193                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                    // Direct match: "balance" == "balance"
198                    if part_lower == file_lower {
199                        return true;
200                    }
201
202                    // Handle crate root: if the part matches the crate name and file is lib.rs
203                    // e.g., "cargo_coupling" matches "lib" (lib.rs is the crate root)
204                    if file_lower == "lib" && !part.is_empty() && *part != "*" {
205                        // This could be the crate root reference
206                        // We also match if the part is the crate name (same as first path component)
207                        if target_segments.len() >= 2 && target_segments[1] == *part {
208                            return true;
209                        }
210                    }
211
212                    // Handle underscore vs hyphen in crate names
213                    // e.g., "cargo-coupling" might appear as "cargo_coupling" in code
214                    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                    // Path contains match: "web" matches "src/web/graph.rs"
221                    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    /// Apply config-derived subdomain classifications and volatility overrides.
238    ///
239    /// Config patterns are path-based while couplings are module-name-based, so
240    /// this resolves coupling targets through known module file paths before
241    /// querying the config.
242    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    /// Build a dependency graph from couplings
287    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            // Only consider internal couplings (not external crates)
292            if coupling.distance == Distance::DifferentCrate {
293                continue;
294            }
295
296            // Extract module names (remove crate prefix for cleaner cycles)
297            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    /// Detect circular dependencies in the project
307    ///
308    /// Returns a list of cycles, where each cycle is a list of module names
309    /// forming the circular dependency chain.
310    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        // Deduplicate cycles (same cycle can be detected from different starting points)
331        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    /// DFS helper for cycle detection
346    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                    // Found a cycle - extract the cycle from path
365                    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    /// Normalize a cycle for deduplication
380    /// Rotates the cycle so the lexicographically smallest element is first
381    fn normalize_cycle(cycle: &[String]) -> Vec<String> {
382        if cycle.is_empty() {
383            return Vec::new();
384        }
385
386        // Find the position of the minimum element
387        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        // Rotate the cycle
395        let mut normalized: Vec<String> = cycle[min_pos..].to_vec();
396        normalized.extend_from_slice(&cycle[..min_pos]);
397        normalized
398    }
399
400    /// Get circular dependency summary
401    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    /// Calculate 3-dimensional coupling statistics
413    ///
414    /// Computes distribution of couplings across Strength, Distance,
415    /// Volatility, and Balance Classification dimensions.
416    pub fn calculate_dimension_stats(&self) -> DimensionStats {
417        let mut stats = DimensionStats::default();
418
419        for coupling in &self.couplings {
420            // Count strength distribution
421            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            // Count distance distribution
429            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            // Count volatility distribution
438            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            // Classify and count balance
445            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    /// Get total newtype count across all modules
465    pub fn total_newtype_count(&self) -> usize {
466        self.modules.values().map(|m| m.newtype_count()).sum()
467    }
468
469    /// Get total type count across all modules (excluding traits)
470    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    /// Calculate project-wide newtype usage ratio
479    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    /// Get types with serde derives (potential DTO exposure)
488    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    /// Identify potential God Modules
501    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    /// Get all functions with potential Primitive Obsession
515    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    /// Get types with exposed public fields
527    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/// Summary of circular dependencies
638#[derive(Debug, Clone)]
639pub struct CircularDependencySummary {
640    /// Total number of circular dependency cycles
641    pub total_cycles: usize,
642    /// Number of modules involved in cycles
643    pub affected_modules: usize,
644    /// The actual cycles (list of module names)
645    pub cycles: Vec<Vec<String>>,
646}