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    /// Workspace name (if available from cargo metadata)
29    pub workspace_name: Option<String>,
30    /// Workspace member crate names
31    pub workspace_members: Vec<String>,
32    /// Crate-level dependencies (crate name -> list of dependencies)
33    pub crate_dependencies: HashMap<String, Vec<String>>,
34    /// Global type registry: type name -> (module name, visibility)
35    pub type_registry: HashMap<String, (String, Visibility)>,
36    /// Temporal coupling data (files that co-change frequently)
37    pub temporal_couplings: Vec<TemporalCoupling>,
38}
39
40impl ProjectMetrics {
41    /// Create an empty project metrics accumulator.
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// Add module metrics
47    pub fn add_module(&mut self, metrics: ModuleMetrics) {
48        self.modules.insert(metrics.name.clone(), metrics);
49    }
50
51    /// Add coupling
52    pub fn add_coupling(&mut self, coupling: CouplingMetrics) {
53        self.couplings.push(coupling);
54    }
55
56    /// Register a type definition in the global registry
57    pub fn register_type(
58        &mut self,
59        type_name: String,
60        module_name: String,
61        visibility: Visibility,
62    ) {
63        self.type_registry
64            .insert(type_name, (module_name, visibility));
65    }
66
67    /// Look up visibility of a type by name
68    pub fn get_type_visibility(&self, type_name: &str) -> Option<Visibility> {
69        self.type_registry.get(type_name).map(|(_, vis)| *vis)
70    }
71
72    /// Look up the module where a type is defined
73    pub fn get_type_module(&self, type_name: &str) -> Option<&str> {
74        self.type_registry
75            .get(type_name)
76            .map(|(module, _)| module.as_str())
77    }
78
79    /// Update visibility information for existing couplings
80    ///
81    /// This should be called after all modules have been analyzed
82    /// to populate the target_visibility field of couplings.
83    pub fn update_coupling_visibility(&mut self) {
84        // First collect all the visibility lookups
85        let visibility_updates: Vec<(usize, Visibility)> = self
86            .couplings
87            .iter()
88            .enumerate()
89            .filter_map(|(idx, coupling)| {
90                let target_type = coupling
91                    .target
92                    .split("::")
93                    .last()
94                    .unwrap_or(&coupling.target);
95                self.type_registry
96                    .get(target_type)
97                    .map(|(_, vis)| (idx, *vis))
98            })
99            .collect();
100
101        // Then apply the updates
102        for (idx, visibility) in visibility_updates {
103            self.couplings[idx].target_visibility = visibility;
104        }
105    }
106
107    /// Get total module count
108    pub fn module_count(&self) -> usize {
109        self.modules.len()
110    }
111
112    /// Get total coupling count
113    pub fn coupling_count(&self) -> usize {
114        self.couplings.len()
115    }
116
117    /// Get internal coupling count (excludes external crate dependencies)
118    pub fn internal_coupling_count(&self) -> usize {
119        self.couplings
120            .iter()
121            .filter(|c| c.distance != Distance::DifferentCrate)
122            .count()
123    }
124
125    /// Calculate average strength across all couplings
126    pub fn average_strength(&self) -> Option<f64> {
127        if self.couplings.is_empty() {
128            return None;
129        }
130        let sum: f64 = self.couplings.iter().map(|c| c.strength_value()).sum();
131        Some(sum / self.couplings.len() as f64)
132    }
133
134    /// Calculate average distance across all couplings
135    pub fn average_distance(&self) -> Option<f64> {
136        if self.couplings.is_empty() {
137            return None;
138        }
139        let sum: f64 = self.couplings.iter().map(|c| c.distance_value()).sum();
140        Some(sum / self.couplings.len() as f64)
141    }
142
143    /// Update volatility for all couplings based on file changes
144    ///
145    /// This should be called after git history analysis to update
146    /// the volatility of each coupling based on how often the target
147    /// module/file has changed.
148    pub fn update_volatility_from_git(&mut self) {
149        if self.file_changes.is_empty() {
150            return;
151        }
152
153        // Debug: print file changes for troubleshooting
154        #[cfg(test)]
155        {
156            eprintln!("DEBUG: file_changes = {:?}", self.file_changes);
157        }
158
159        let module_paths: Vec<(String, PathBuf)> = self
160            .modules
161            .iter()
162            .map(|(name, module)| (name.clone(), module.path.clone()))
163            .collect();
164
165        for coupling in &mut self.couplings {
166            if let Some(module_path) = target_module_path(&coupling.target, &module_paths) {
167                coupling.volatility = Volatility::from_count(change_count_for_module_path(
168                    module_path,
169                    &self.file_changes,
170                ));
171                continue;
172            }
173
174            // Try to find the target file in file_changes
175            // The target is like "crate::module" or "crate::module::Type"
176            // We need to match this against file paths like "src/module.rs"
177            //
178            // Special cases in Rust module system:
179            // - crate root "crate::crate_name" or "crate_name::crate_name" -> lib.rs
180            // - binary entry point -> main.rs
181            // - glob imports "crate::*" -> don't match specific files
182
183            // Extract all path components from target
184            let target_segments: Vec<&str> = coupling.target.split("::").collect();
185
186            // Find the best matching file
187            let mut max_target_changes = 0usize;
188            for (file_path, &changes) in &self.file_changes {
189                // Get file name without .rs extension (e.g., "balance" from "src/balance.rs")
190                let file_name = file_path
191                    .rsplit('/')
192                    .next()
193                    .unwrap_or(file_path)
194                    .trim_end_matches(".rs");
195
196                // Check if any target path component matches the file name
197                let target_matches_file = target_segments.iter().any(|part| {
198                    let part_lower = part.to_lowercase();
199                    let file_lower = file_name.to_lowercase();
200
201                    // Direct match: "balance" == "balance"
202                    if part_lower == file_lower {
203                        return true;
204                    }
205
206                    // Handle crate root: if the part matches the crate name and file is lib.rs
207                    // e.g., "cargo_coupling" matches "lib" (lib.rs is the crate root)
208                    if file_lower == "lib" && !part.is_empty() && *part != "*" {
209                        // This could be the crate root reference
210                        // We also match if the part is the crate name (same as first path component)
211                        if target_segments.len() >= 2 && target_segments[1] == *part {
212                            return true;
213                        }
214                    }
215
216                    // Handle underscore vs hyphen in crate names
217                    // e.g., "cargo-coupling" might appear as "cargo_coupling" in code
218                    let part_normalized = part_lower.replace('-', "_");
219                    let file_normalized = file_lower.replace('-', "_");
220                    if part_normalized == file_normalized {
221                        return true;
222                    }
223
224                    // Path contains match: "web" matches "src/web/graph.rs"
225                    if file_path.to_lowercase().contains(&part_lower) {
226                        return true;
227                    }
228
229                    false
230                });
231
232                if target_matches_file {
233                    max_target_changes = max_target_changes.max(changes);
234                }
235            }
236
237            coupling.volatility = Volatility::from_count(max_target_changes);
238        }
239    }
240
241    /// Apply config-derived subdomain classifications and volatility overrides.
242    ///
243    /// Config patterns are path-based while couplings are module-name-based, so
244    /// this resolves coupling targets through known module file paths before
245    /// querying the config.
246    pub fn apply_config_volatility_overrides<C: MetricsConfig>(&mut self, config: &mut C) -> usize {
247        if !config.has_volatility_overrides() && !config.has_subdomain_config() {
248            return 0;
249        }
250
251        let mut module_paths = HashMap::new();
252        let has_subdomain_config = config.has_subdomain_config();
253        for (name, module) in &mut self.modules {
254            let relative_path = path_for_config_matching(&module.path, config);
255            if has_subdomain_config {
256                module.subdomain = config.get_subdomain(&relative_path);
257            }
258
259            insert_module_path_aliases(&mut module_paths, name, module, &relative_path);
260        }
261
262        let mut override_count = 0;
263        for coupling in &mut self.couplings {
264            let target_short = coupling
265                .target
266                .rsplit("::")
267                .next()
268                .unwrap_or(&coupling.target);
269            let lookup = module_paths
270                .get(&coupling.target)
271                .or_else(|| module_paths.get(target_short))
272                .or_else(|| {
273                    coupling
274                        .target
275                        .rsplit("::")
276                        .find_map(|segment| module_paths.get(segment))
277                })
278                .map(String::as_str)
279                .unwrap_or(coupling.target.as_str());
280
281            if let Some(override_vol) = config.get_volatility_override(lookup) {
282                coupling.volatility = override_vol;
283                override_count += 1;
284            }
285        }
286
287        override_count
288    }
289
290    /// Build a dependency graph from couplings
291    fn build_dependency_graph(&self) -> HashMap<String, HashSet<String>> {
292        let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
293
294        for coupling in &self.couplings {
295            // Only consider internal couplings (not external crates)
296            if coupling.distance == Distance::DifferentCrate {
297                continue;
298            }
299
300            // Extract module names (remove crate prefix for cleaner cycles)
301            let source = coupling.source.clone();
302            let target = coupling.target.clone();
303
304            graph.entry(source).or_default().insert(target);
305        }
306
307        graph
308    }
309
310    /// Detect circular dependencies in the project
311    ///
312    /// Returns a list of cycles, where each cycle is a list of module names
313    /// forming the circular dependency chain.
314    pub fn detect_circular_dependencies(&self) -> Vec<Vec<String>> {
315        let graph = self.build_dependency_graph();
316        let mut cycles: Vec<Vec<String>> = Vec::new();
317        let mut visited: HashSet<String> = HashSet::new();
318        let mut rec_stack: HashSet<String> = HashSet::new();
319
320        for node in graph.keys() {
321            if !visited.contains(node) {
322                let mut path = Vec::new();
323                self.dfs_find_cycles(
324                    node,
325                    &graph,
326                    &mut visited,
327                    &mut rec_stack,
328                    &mut path,
329                    &mut cycles,
330                );
331            }
332        }
333
334        // Deduplicate cycles (same cycle can be detected from different starting points)
335        let mut unique_cycles: Vec<Vec<String>> = Vec::new();
336        for cycle in cycles {
337            let normalized = Self::normalize_cycle(&cycle);
338            if !unique_cycles
339                .iter()
340                .any(|c| Self::normalize_cycle(c) == normalized)
341            {
342                unique_cycles.push(cycle);
343            }
344        }
345
346        unique_cycles
347    }
348
349    /// DFS helper for cycle detection
350    fn dfs_find_cycles(
351        &self,
352        node: &str,
353        graph: &HashMap<String, HashSet<String>>,
354        visited: &mut HashSet<String>,
355        rec_stack: &mut HashSet<String>,
356        path: &mut Vec<String>,
357        cycles: &mut Vec<Vec<String>>,
358    ) {
359        visited.insert(node.to_string());
360        rec_stack.insert(node.to_string());
361        path.push(node.to_string());
362
363        if let Some(neighbors) = graph.get(node) {
364            for neighbor in neighbors {
365                if !visited.contains(neighbor) {
366                    self.dfs_find_cycles(neighbor, graph, visited, rec_stack, path, cycles);
367                } else if rec_stack.contains(neighbor) {
368                    // Found a cycle - extract the cycle from path
369                    if let Some(start_idx) = path.iter().position(|n| n == neighbor) {
370                        let cycle: Vec<String> = path[start_idx..].to_vec();
371                        if cycle.len() >= 2 {
372                            cycles.push(cycle);
373                        }
374                    }
375                }
376            }
377        }
378
379        path.pop();
380        rec_stack.remove(node);
381    }
382
383    /// Normalize a cycle for deduplication
384    /// Rotates the cycle so the lexicographically smallest element is first
385    fn normalize_cycle(cycle: &[String]) -> Vec<String> {
386        if cycle.is_empty() {
387            return Vec::new();
388        }
389
390        // Find the position of the minimum element
391        let min_pos = cycle
392            .iter()
393            .enumerate()
394            .min_by_key(|(_, s)| s.as_str())
395            .map(|(i, _)| i)
396            .unwrap_or(0);
397
398        // Rotate the cycle
399        let mut normalized: Vec<String> = cycle[min_pos..].to_vec();
400        normalized.extend_from_slice(&cycle[..min_pos]);
401        normalized
402    }
403
404    /// Get circular dependency summary
405    pub fn circular_dependency_summary(&self) -> CircularDependencySummary {
406        let cycles = self.detect_circular_dependencies();
407        let affected_modules: HashSet<String> = cycles.iter().flatten().cloned().collect();
408
409        CircularDependencySummary {
410            total_cycles: cycles.len(),
411            affected_modules: affected_modules.len(),
412            cycles,
413        }
414    }
415
416    /// Calculate 3-dimensional coupling statistics
417    ///
418    /// Computes distribution of couplings across Strength, Distance,
419    /// Volatility, and Balance Classification dimensions.
420    pub fn calculate_dimension_stats(&self) -> DimensionStats {
421        let mut stats = DimensionStats::default();
422
423        for coupling in &self.couplings {
424            // Count strength distribution
425            match coupling.strength {
426                IntegrationStrength::Intrusive => stats.strength_counts.intrusive += 1,
427                IntegrationStrength::Functional => stats.strength_counts.functional += 1,
428                IntegrationStrength::Model => stats.strength_counts.model += 1,
429                IntegrationStrength::Contract => stats.strength_counts.contract += 1,
430            }
431
432            // Count distance distribution
433            match coupling.distance {
434                Distance::SameFunction | Distance::SameModule => {
435                    stats.distance_counts.same_module += 1
436                }
437                Distance::DifferentModule => stats.distance_counts.different_module += 1,
438                Distance::DifferentCrate => stats.distance_counts.different_crate += 1,
439            }
440
441            // Count volatility distribution
442            match coupling.volatility {
443                Volatility::Low => stats.volatility_counts.low += 1,
444                Volatility::Medium => stats.volatility_counts.medium += 1,
445                Volatility::High => stats.volatility_counts.high += 1,
446            }
447
448            // Classify and count balance
449            let classification = BalanceClassification::classify(
450                coupling.strength,
451                coupling.distance,
452                coupling.volatility,
453            );
454            match classification {
455                BalanceClassification::HighCohesion => stats.balance_counts.high_cohesion += 1,
456                BalanceClassification::LooseCoupling => stats.balance_counts.loose_coupling += 1,
457                BalanceClassification::Acceptable => stats.balance_counts.acceptable += 1,
458                BalanceClassification::Pain => stats.balance_counts.pain += 1,
459                BalanceClassification::LocalComplexity => {
460                    stats.balance_counts.local_complexity += 1
461                }
462            }
463        }
464
465        stats
466    }
467
468    /// Get total newtype count across all modules
469    pub fn total_newtype_count(&self) -> usize {
470        self.modules.values().map(|m| m.newtype_count()).sum()
471    }
472
473    /// Get total type count across all modules (excluding traits)
474    pub fn total_type_count(&self) -> usize {
475        self.modules
476            .values()
477            .flat_map(|m| m.type_definitions.values())
478            .filter(|t| !t.is_trait)
479            .count()
480    }
481
482    /// Calculate project-wide newtype usage ratio
483    pub fn newtype_ratio(&self) -> f64 {
484        let total = self.total_type_count();
485        if total == 0 {
486            return 0.0;
487        }
488        self.total_newtype_count() as f64 / total as f64
489    }
490
491    /// Get types with serde derives (potential DTO exposure)
492    pub fn serde_types(&self) -> Vec<(&str, &TypeDefinition)> {
493        self.modules
494            .iter()
495            .flat_map(|(module_name, m)| {
496                m.type_definitions
497                    .values()
498                    .filter(|t| t.has_serde_derive)
499                    .map(move |t| (module_name.as_str(), t))
500            })
501            .collect()
502    }
503
504    /// Identify potential God Modules
505    pub fn god_modules(
506        &self,
507        max_functions: usize,
508        max_types: usize,
509        max_impls: usize,
510    ) -> Vec<&str> {
511        self.modules
512            .iter()
513            .filter(|(_, m)| m.is_god_module(max_functions, max_types, max_impls))
514            .map(|(name, _)| name.as_str())
515            .collect()
516    }
517
518    /// Get all functions with potential Primitive Obsession
519    pub fn functions_with_primitive_obsession(&self) -> Vec<(&str, &FunctionDefinition)> {
520        self.modules
521            .iter()
522            .flat_map(|(module_name, m)| {
523                m.functions_with_primitive_obsession()
524                    .into_iter()
525                    .map(move |f| (module_name.as_str(), f))
526            })
527            .collect()
528    }
529
530    /// Get types with exposed public fields
531    pub fn types_with_public_fields(&self) -> Vec<(&str, &TypeDefinition)> {
532        self.modules
533            .iter()
534            .flat_map(|(module_name, m)| {
535                m.type_definitions
536                    .values()
537                    .filter(|t| t.public_field_count > 0 && !t.is_trait)
538                    .map(move |t| (module_name.as_str(), t))
539            })
540            .collect()
541    }
542}
543
544fn target_module_path<'a>(target: &str, module_paths: &'a [(String, PathBuf)]) -> Option<&'a Path> {
545    let target = target.trim_start_matches("crate::");
546    let target_without_crate = target.split_once("::").and_then(|(_, rest)| {
547        module_paths
548            .iter()
549            .any(|(module, _)| rest == module || rest.starts_with(&format!("{module}::")))
550            .then_some(rest)
551    });
552    let target = target_without_crate.unwrap_or(target);
553
554    module_paths
555        .iter()
556        .filter(|(module, _)| target == module || target.starts_with(&format!("{module}::")))
557        .max_by_key(|(module, _)| module.len())
558        .map(|(_, path)| path.as_path())
559}
560
561fn change_count_for_module_path(
562    module_path: &Path,
563    file_changes: &HashMap<String, usize>,
564) -> usize {
565    let module_path = module_path.to_string_lossy().replace('\\', "/");
566    file_changes
567        .iter()
568        .filter(|(file_path, _)| module_file_paths_match(&module_path, file_path))
569        .map(|(_, changes)| *changes)
570        .max()
571        .unwrap_or(0)
572}
573
574fn module_file_paths_match(module_path: &str, git_path: &str) -> bool {
575    let git_path = git_path.replace('\\', "/");
576    module_path == git_path
577        || module_path.ends_with(&format!("/{git_path}"))
578        || git_path.ends_with(&format!("/{module_path}"))
579}
580
581fn insert_module_path_aliases(
582    module_paths: &mut HashMap<String, String>,
583    key_name: &str,
584    module: &ModuleMetrics,
585    relative_path: &str,
586) {
587    module_paths.insert(key_name.to_string(), relative_path.to_string());
588    module_paths.insert(module.name.clone(), relative_path.to_string());
589
590    if let Some(short_name) = key_name.rsplit("::").next() {
591        module_paths.insert(short_name.to_string(), relative_path.to_string());
592    }
593
594    if let Some(short_name) = module.name.rsplit("::").next() {
595        module_paths.insert(short_name.to_string(), relative_path.to_string());
596    }
597
598    if let Some(file_stem) = module.path.file_stem().and_then(|stem| stem.to_str()) {
599        module_paths.insert(file_stem.to_string(), relative_path.to_string());
600    }
601}
602
603fn path_for_config_matching(file_path: &Path, config: &impl MetricsConfig) -> String {
604    let normalized_file = normalize_path_for_matching(file_path);
605    let path = config
606        .config_root()
607        .map(normalize_path_for_matching)
608        .and_then(|base| {
609            normalized_file
610                .strip_prefix(base)
611                .ok()
612                .map(Path::to_path_buf)
613        })
614        .unwrap_or(normalized_file);
615
616    path.to_string_lossy().replace('\\', "/")
617}
618
619fn normalize_path_for_matching(path: &Path) -> PathBuf {
620    let absolute = if path.is_absolute() {
621        path.to_path_buf()
622    } else {
623        std::env::current_dir()
624            .map(|cwd| cwd.join(path))
625            .unwrap_or_else(|_| path.to_path_buf())
626    };
627
628    let mut normalized = PathBuf::new();
629    for component in absolute.components() {
630        match component {
631            Component::CurDir => {}
632            Component::ParentDir => {
633                normalized.pop();
634            }
635            other => normalized.push(other.as_os_str()),
636        }
637    }
638    normalized
639}
640
641/// Summary of circular dependencies
642#[derive(Debug, Clone)]
643pub struct CircularDependencySummary {
644    /// Total number of circular dependency cycles
645    pub total_cycles: usize,
646    /// Number of modules involved in cycles
647    pub affected_modules: usize,
648    /// The actual cycles (list of module names)
649    pub cycles: Vec<Vec<String>>,
650}