Skip to main content

cargo_coupling/
metrics.rs

1//! Coupling metrics data structures
2//!
3//! This module defines the core data structures for measuring coupling
4//! based on Vlad Khononov's "Balancing Coupling in Software Design".
5
6use std::collections::{HashMap, HashSet};
7use std::fmt;
8use std::path::PathBuf;
9
10use crate::analyzer::ItemDependency;
11
12/// Visibility level of a Rust item
13///
14/// This is used to determine if access to an item from another module
15/// constitutes "Intrusive" coupling (access to private/internal details).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
17pub enum Visibility {
18    /// Fully public (`pub`)
19    Public,
20    /// Crate-internal (`pub(crate)`)
21    PubCrate,
22    /// Super-module visible (`pub(super)`)
23    PubSuper,
24    /// Module-path restricted (`pub(in path)`)
25    PubIn,
26    /// Private (no visibility modifier)
27    #[default]
28    Private,
29}
30
31impl Visibility {
32    /// Check if this visibility allows access from a different module
33    pub fn allows_external_access(&self) -> bool {
34        matches!(self, Visibility::Public | Visibility::PubCrate)
35    }
36
37    /// Check if access from another module would be "intrusive"
38    ///
39    /// Intrusive access means accessing something that isn't part of the public API.
40    /// This indicates tight coupling to implementation details.
41    pub fn is_intrusive_from(&self, same_crate: bool, same_module: bool) -> bool {
42        if same_module {
43            // Same module access is never intrusive
44            return false;
45        }
46
47        match self {
48            Visibility::Public => false,         // Public API, not intrusive
49            Visibility::PubCrate => !same_crate, // Intrusive if from different crate
50            Visibility::PubSuper | Visibility::PubIn => true, // Limited visibility, intrusive from outside
51            Visibility::Private => true, // Private, always intrusive from outside
52        }
53    }
54
55    /// Get a penalty multiplier for coupling strength based on visibility
56    ///
57    /// Higher penalty = more "intrusive" the access is.
58    pub fn intrusive_penalty(&self) -> f64 {
59        match self {
60            Visibility::Public => 0.0,    // No penalty for public API
61            Visibility::PubCrate => 0.25, // Small penalty for crate-internal
62            Visibility::PubSuper => 0.5,  // Medium penalty
63            Visibility::PubIn => 0.5,     // Medium penalty
64            Visibility::Private => 1.0,   // Full penalty for private access
65        }
66    }
67}
68
69impl fmt::Display for Visibility {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        match self {
72            Visibility::Public => write!(f, "pub"),
73            Visibility::PubCrate => write!(f, "pub(crate)"),
74            Visibility::PubSuper => write!(f, "pub(super)"),
75            Visibility::PubIn => write!(f, "pub(in ...)"),
76            Visibility::Private => write!(f, "private"),
77        }
78    }
79}
80
81/// Integration strength levels (how much knowledge is shared)
82#[derive(Debug, Clone, Copy, PartialEq)]
83pub enum IntegrationStrength {
84    /// Strongest coupling - direct access to internals
85    Intrusive,
86    /// Strong coupling - depends on function signatures
87    Functional,
88    /// Medium coupling - depends on data models
89    Model,
90    /// Weakest coupling - depends only on contracts/traits
91    Contract,
92}
93
94impl IntegrationStrength {
95    /// Returns the numeric value (0.0 - 1.0, higher = stronger)
96    pub fn value(&self) -> f64 {
97        match self {
98            IntegrationStrength::Intrusive => 1.0,
99            IntegrationStrength::Functional => 0.75,
100            IntegrationStrength::Model => 0.5,
101            IntegrationStrength::Contract => 0.25,
102        }
103    }
104}
105
106/// Distance levels (how far apart components are)
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub enum Distance {
109    /// Same function/block
110    SameFunction,
111    /// Same module/file
112    SameModule,
113    /// Different module in same crate
114    DifferentModule,
115    /// Different crate
116    DifferentCrate,
117}
118
119impl Distance {
120    /// Returns the numeric value (0.0 - 1.0, higher = farther)
121    pub fn value(&self) -> f64 {
122        match self {
123            Distance::SameFunction => 0.0,
124            Distance::SameModule => 0.25,
125            Distance::DifferentModule => 0.5,
126            Distance::DifferentCrate => 1.0,
127        }
128    }
129}
130
131/// Volatility levels (how often a component changes)
132#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
133pub enum Volatility {
134    /// Rarely changes (0-2 times)
135    Low,
136    /// Sometimes changes (3-10 times)
137    Medium,
138    /// Frequently changes (11+ times)
139    High,
140}
141
142impl Volatility {
143    /// Returns the numeric value (0.0 - 1.0, higher = more volatile)
144    pub fn value(&self) -> f64 {
145        match self {
146            Volatility::Low => 0.0,
147            Volatility::Medium => 0.5,
148            Volatility::High => 1.0,
149        }
150    }
151
152    /// Classify from change count
153    pub fn from_count(count: usize) -> Self {
154        match count {
155            0..=2 => Volatility::Low,
156            3..=10 => Volatility::Medium,
157            _ => Volatility::High,
158        }
159    }
160}
161
162/// Location information for a coupling
163#[derive(Debug, Clone, Default)]
164pub struct CouplingLocation {
165    /// File path where the coupling originates
166    pub file_path: Option<PathBuf>,
167    /// Line number in the source file
168    pub line: usize,
169}
170
171/// Metrics for a single coupling relationship
172#[derive(Debug, Clone)]
173pub struct CouplingMetrics {
174    /// Source component
175    pub source: String,
176    /// Target component
177    pub target: String,
178    /// Integration strength
179    pub strength: IntegrationStrength,
180    /// Distance between components
181    pub distance: Distance,
182    /// Volatility of the target
183    pub volatility: Volatility,
184    /// Source crate name (when workspace analysis is available)
185    pub source_crate: Option<String>,
186    /// Target crate name (when workspace analysis is available)
187    pub target_crate: Option<String>,
188    /// Visibility of the target item (for intrusive detection)
189    pub target_visibility: Visibility,
190    /// Location where the coupling occurs
191    pub location: CouplingLocation,
192}
193
194impl CouplingMetrics {
195    /// Create new coupling metrics
196    pub fn new(
197        source: String,
198        target: String,
199        strength: IntegrationStrength,
200        distance: Distance,
201        volatility: Volatility,
202    ) -> Self {
203        Self {
204            source,
205            target,
206            strength,
207            distance,
208            volatility,
209            source_crate: None,
210            target_crate: None,
211            target_visibility: Visibility::default(),
212            location: CouplingLocation::default(),
213        }
214    }
215
216    /// Create new coupling metrics with visibility
217    pub fn with_visibility(
218        source: String,
219        target: String,
220        strength: IntegrationStrength,
221        distance: Distance,
222        volatility: Volatility,
223        visibility: Visibility,
224    ) -> Self {
225        Self {
226            source,
227            target,
228            strength,
229            distance,
230            volatility,
231            source_crate: None,
232            target_crate: None,
233            target_visibility: visibility,
234            location: CouplingLocation::default(),
235        }
236    }
237
238    /// Create new coupling metrics with location
239    #[allow(clippy::too_many_arguments)]
240    pub fn with_location(
241        source: String,
242        target: String,
243        strength: IntegrationStrength,
244        distance: Distance,
245        volatility: Volatility,
246        visibility: Visibility,
247        file_path: PathBuf,
248        line: usize,
249    ) -> Self {
250        Self {
251            source,
252            target,
253            strength,
254            distance,
255            volatility,
256            source_crate: None,
257            target_crate: None,
258            target_visibility: visibility,
259            location: CouplingLocation {
260                file_path: Some(file_path),
261                line,
262            },
263        }
264    }
265
266    /// Check if this coupling represents intrusive access based on visibility
267    ///
268    /// Returns true if the target's visibility suggests this is access to
269    /// internal implementation details rather than a public API.
270    pub fn is_visibility_intrusive(&self) -> bool {
271        let same_crate = self.source_crate == self.target_crate;
272        let same_module =
273            self.distance == Distance::SameModule || self.distance == Distance::SameFunction;
274        self.target_visibility
275            .is_intrusive_from(same_crate, same_module)
276    }
277
278    /// Get effective strength considering visibility
279    ///
280    /// If the target is not publicly visible and being accessed from outside,
281    /// the coupling is considered more intrusive.
282    pub fn effective_strength(&self) -> IntegrationStrength {
283        if self.is_visibility_intrusive() && self.strength != IntegrationStrength::Intrusive {
284            // Upgrade to more intrusive if accessing non-public items
285            match self.strength {
286                IntegrationStrength::Contract => IntegrationStrength::Model,
287                IntegrationStrength::Model => IntegrationStrength::Functional,
288                IntegrationStrength::Functional => IntegrationStrength::Intrusive,
289                IntegrationStrength::Intrusive => IntegrationStrength::Intrusive,
290            }
291        } else {
292            self.strength
293        }
294    }
295
296    /// Get effective strength value considering visibility
297    pub fn effective_strength_value(&self) -> f64 {
298        self.effective_strength().value()
299    }
300
301    /// Get numeric strength value
302    pub fn strength_value(&self) -> f64 {
303        self.strength.value()
304    }
305
306    /// Get numeric distance value
307    pub fn distance_value(&self) -> f64 {
308        self.distance.value()
309    }
310
311    /// Get numeric volatility value
312    pub fn volatility_value(&self) -> f64 {
313        self.volatility.value()
314    }
315}
316
317/// Information about a type definition in a module
318#[derive(Debug, Clone)]
319pub struct TypeDefinition {
320    /// Name of the type
321    pub name: String,
322    /// Visibility of the type
323    pub visibility: Visibility,
324    /// Whether this is a trait (vs struct/enum)
325    pub is_trait: bool,
326    /// Whether this is a newtype pattern (tuple struct with single field)
327    pub is_newtype: bool,
328    /// Inner type for newtypes (e.g., "u64" for `struct UserId(u64)`)
329    pub inner_type: Option<String>,
330    /// Whether this type has #[derive(Serialize)] or #[derive(Deserialize)]
331    pub has_serde_derive: bool,
332    /// Number of public fields (for pub field exposure detection)
333    pub public_field_count: usize,
334    /// Total number of fields
335    pub total_field_count: usize,
336}
337
338/// Information about a function definition in a module
339#[derive(Debug, Clone)]
340pub struct FunctionDefinition {
341    /// Name of the function
342    pub name: String,
343    /// Visibility of the function
344    pub visibility: Visibility,
345    /// Number of parameters
346    pub param_count: usize,
347    /// Number of primitive type parameters (String, u32, bool, etc.)
348    pub primitive_param_count: usize,
349    /// Parameter types (for primitive obsession detection)
350    pub param_types: Vec<String>,
351}
352
353/// Khononov's balance classification for couplings
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum BalanceClassification {
356    /// High strength + Low distance = High cohesion (ideal)
357    HighCohesion,
358    /// Low strength + High distance = Loose coupling (ideal)
359    LooseCoupling,
360    /// High strength + High distance + Low volatility = Acceptable
361    Acceptable,
362    /// High strength + High distance + High volatility = Pain (needs refactoring)
363    Pain,
364    /// Low strength + Low distance = Local complexity (review needed)
365    LocalComplexity,
366}
367
368impl BalanceClassification {
369    /// Classify a coupling based on Khononov's formula
370    pub fn classify(
371        strength: IntegrationStrength,
372        distance: Distance,
373        volatility: Volatility,
374    ) -> Self {
375        let is_strong = strength.value() >= 0.5;
376        let is_far = distance.value() >= 0.5;
377        let is_volatile = volatility == Volatility::High;
378
379        match (is_strong, is_far, is_volatile) {
380            (true, false, _) => BalanceClassification::HighCohesion,
381            (false, true, _) => BalanceClassification::LooseCoupling,
382            (false, false, _) => BalanceClassification::LocalComplexity,
383            (true, true, false) => BalanceClassification::Acceptable,
384            (true, true, true) => BalanceClassification::Pain,
385        }
386    }
387
388    /// Get Japanese description
389    pub fn description_ja(&self) -> &'static str {
390        match self {
391            BalanceClassification::HighCohesion => "高凝集 (強+近)",
392            BalanceClassification::LooseCoupling => "疎結合 (弱+遠)",
393            BalanceClassification::Acceptable => "許容可能 (強+遠+安定)",
394            BalanceClassification::Pain => "要改善 (強+遠+変動)",
395            BalanceClassification::LocalComplexity => "局所複雑性 (弱+近)",
396        }
397    }
398
399    /// Get English description
400    pub fn description_en(&self) -> &'static str {
401        match self {
402            BalanceClassification::HighCohesion => "High Cohesion",
403            BalanceClassification::LooseCoupling => "Loose Coupling",
404            BalanceClassification::Acceptable => "Acceptable",
405            BalanceClassification::Pain => "Needs Refactoring",
406            BalanceClassification::LocalComplexity => "Local Complexity",
407        }
408    }
409
410    /// Is this classification ideal?
411    pub fn is_ideal(&self) -> bool {
412        matches!(
413            self,
414            BalanceClassification::HighCohesion | BalanceClassification::LooseCoupling
415        )
416    }
417
418    /// Does this need attention?
419    pub fn needs_attention(&self) -> bool {
420        matches!(
421            self,
422            BalanceClassification::Pain | BalanceClassification::LocalComplexity
423        )
424    }
425}
426
427/// Statistics for 3-dimensional coupling analysis
428#[derive(Debug, Clone, Default)]
429pub struct DimensionStats {
430    /// Strength distribution
431    pub strength_counts: StrengthCounts,
432    /// Distance distribution
433    pub distance_counts: DistanceCounts,
434    /// Volatility distribution
435    pub volatility_counts: VolatilityCounts,
436    /// Balance classification counts
437    pub balance_counts: BalanceCounts,
438}
439
440impl DimensionStats {
441    /// Total number of couplings analyzed
442    pub fn total(&self) -> usize {
443        self.strength_counts.total()
444    }
445
446    /// Get percentage of each strength level
447    pub fn strength_percentages(&self) -> (f64, f64, f64, f64) {
448        let total = self.total() as f64;
449        if total == 0.0 {
450            return (0.0, 0.0, 0.0, 0.0);
451        }
452        (
453            self.strength_counts.intrusive as f64 / total * 100.0,
454            self.strength_counts.functional as f64 / total * 100.0,
455            self.strength_counts.model as f64 / total * 100.0,
456            self.strength_counts.contract as f64 / total * 100.0,
457        )
458    }
459
460    /// Get percentage of each distance level
461    pub fn distance_percentages(&self) -> (f64, f64, f64) {
462        let total = self.total() as f64;
463        if total == 0.0 {
464            return (0.0, 0.0, 0.0);
465        }
466        (
467            self.distance_counts.same_module as f64 / total * 100.0,
468            self.distance_counts.different_module as f64 / total * 100.0,
469            self.distance_counts.different_crate as f64 / total * 100.0,
470        )
471    }
472
473    /// Get percentage of each volatility level
474    pub fn volatility_percentages(&self) -> (f64, f64, f64) {
475        let total = self.total() as f64;
476        if total == 0.0 {
477            return (0.0, 0.0, 0.0);
478        }
479        (
480            self.volatility_counts.low as f64 / total * 100.0,
481            self.volatility_counts.medium as f64 / total * 100.0,
482            self.volatility_counts.high as f64 / total * 100.0,
483        )
484    }
485
486    /// Count of ideal couplings (High Cohesion + Loose Coupling)
487    pub fn ideal_count(&self) -> usize {
488        self.balance_counts.high_cohesion + self.balance_counts.loose_coupling
489    }
490
491    /// Count of problematic couplings (Pain + Local Complexity)
492    pub fn problematic_count(&self) -> usize {
493        self.balance_counts.pain + self.balance_counts.local_complexity
494    }
495
496    /// Percentage of ideal couplings
497    pub fn ideal_percentage(&self) -> f64 {
498        let total = self.total() as f64;
499        if total == 0.0 {
500            return 0.0;
501        }
502        self.ideal_count() as f64 / total * 100.0
503    }
504}
505
506/// Counts for each strength level
507#[derive(Debug, Clone, Default)]
508pub struct StrengthCounts {
509    pub intrusive: usize,
510    pub functional: usize,
511    pub model: usize,
512    pub contract: usize,
513}
514
515impl StrengthCounts {
516    /// Total count across all strength levels
517    pub fn total(&self) -> usize {
518        self.intrusive + self.functional + self.model + self.contract
519    }
520}
521
522/// Counts for each distance level
523#[derive(Debug, Clone, Default)]
524pub struct DistanceCounts {
525    pub same_module: usize,
526    pub different_module: usize,
527    pub different_crate: usize,
528}
529
530/// Counts for each volatility level
531#[derive(Debug, Clone, Default)]
532pub struct VolatilityCounts {
533    pub low: usize,
534    pub medium: usize,
535    pub high: usize,
536}
537
538/// Counts for each balance classification
539#[derive(Debug, Clone, Default)]
540pub struct BalanceCounts {
541    pub high_cohesion: usize,
542    pub loose_coupling: usize,
543    pub acceptable: usize,
544    pub pain: usize,
545    pub local_complexity: usize,
546}
547
548/// Aggregated metrics for a module
549#[derive(Debug, Clone, Default)]
550pub struct ModuleMetrics {
551    /// Module path
552    pub path: PathBuf,
553    /// Module name
554    pub name: String,
555    /// Number of trait implementations (contract coupling)
556    pub trait_impl_count: usize,
557    /// Number of inherent implementations (intrusive coupling)
558    pub inherent_impl_count: usize,
559    /// Number of function calls
560    pub function_call_count: usize,
561    /// Number of struct/enum usages
562    pub type_usage_count: usize,
563    /// External crate dependencies
564    pub external_deps: Vec<String>,
565    /// Internal module dependencies
566    pub internal_deps: Vec<String>,
567    /// Type definitions in this module with visibility info
568    pub type_definitions: HashMap<String, TypeDefinition>,
569    /// Function definitions in this module with visibility info
570    pub function_definitions: HashMap<String, FunctionDefinition>,
571    /// Item-level dependencies (function → function, function → type, etc.)
572    pub item_dependencies: Vec<ItemDependency>,
573    /// Whether this module is a test module (mod tests or #[cfg(test)])
574    pub is_test_module: bool,
575    /// Number of test functions (#[test])
576    pub test_function_count: usize,
577}
578
579impl ModuleMetrics {
580    pub fn new(path: PathBuf, name: String) -> Self {
581        Self {
582            path,
583            name,
584            ..Default::default()
585        }
586    }
587
588    /// Add a type definition to this module (simple version for backward compatibility)
589    pub fn add_type_definition(&mut self, name: String, visibility: Visibility, is_trait: bool) {
590        self.type_definitions.insert(
591            name.clone(),
592            TypeDefinition {
593                name,
594                visibility,
595                is_trait,
596                is_newtype: false,
597                inner_type: None,
598                has_serde_derive: false,
599                public_field_count: 0,
600                total_field_count: 0,
601            },
602        );
603    }
604
605    /// Add a type definition with full details
606    #[allow(clippy::too_many_arguments)]
607    pub fn add_type_definition_full(
608        &mut self,
609        name: String,
610        visibility: Visibility,
611        is_trait: bool,
612        is_newtype: bool,
613        inner_type: Option<String>,
614        has_serde_derive: bool,
615        public_field_count: usize,
616        total_field_count: usize,
617    ) {
618        self.type_definitions.insert(
619            name.clone(),
620            TypeDefinition {
621                name,
622                visibility,
623                is_trait,
624                is_newtype,
625                inner_type,
626                has_serde_derive,
627                public_field_count,
628                total_field_count,
629            },
630        );
631    }
632
633    /// Add a function definition to this module (simple version for backward compatibility)
634    pub fn add_function_definition(&mut self, name: String, visibility: Visibility) {
635        self.function_definitions.insert(
636            name.clone(),
637            FunctionDefinition {
638                name,
639                visibility,
640                param_count: 0,
641                primitive_param_count: 0,
642                param_types: Vec::new(),
643            },
644        );
645    }
646
647    /// Add a function definition with full details
648    pub fn add_function_definition_full(
649        &mut self,
650        name: String,
651        visibility: Visibility,
652        param_count: usize,
653        primitive_param_count: usize,
654        param_types: Vec<String>,
655    ) {
656        self.function_definitions.insert(
657            name.clone(),
658            FunctionDefinition {
659                name,
660                visibility,
661                param_count,
662                primitive_param_count,
663                param_types,
664            },
665        );
666    }
667
668    /// Get visibility of a type defined in this module
669    pub fn get_type_visibility(&self, name: &str) -> Option<Visibility> {
670        self.type_definitions.get(name).map(|t| t.visibility)
671    }
672
673    /// Count public types
674    pub fn public_type_count(&self) -> usize {
675        self.type_definitions
676            .values()
677            .filter(|t| t.visibility == Visibility::Public)
678            .count()
679    }
680
681    /// Count non-public types
682    pub fn private_type_count(&self) -> usize {
683        self.type_definitions
684            .values()
685            .filter(|t| t.visibility != Visibility::Public)
686            .count()
687    }
688
689    /// Calculate average integration strength
690    pub fn average_strength(&self) -> f64 {
691        let total = self.trait_impl_count + self.inherent_impl_count;
692        if total == 0 {
693            return 0.0;
694        }
695
696        let contract_weight = self.trait_impl_count as f64 * IntegrationStrength::Contract.value();
697        let intrusive_weight =
698            self.inherent_impl_count as f64 * IntegrationStrength::Intrusive.value();
699
700        (contract_weight + intrusive_weight) / total as f64
701    }
702
703    /// Count newtypes in this module
704    pub fn newtype_count(&self) -> usize {
705        self.type_definitions
706            .values()
707            .filter(|t| t.is_newtype)
708            .count()
709    }
710
711    /// Count types with serde derives
712    pub fn serde_type_count(&self) -> usize {
713        self.type_definitions
714            .values()
715            .filter(|t| t.has_serde_derive)
716            .count()
717    }
718
719    /// Calculate newtype usage ratio (newtypes / total non-trait types)
720    pub fn newtype_ratio(&self) -> f64 {
721        let non_trait_types = self
722            .type_definitions
723            .values()
724            .filter(|t| !t.is_trait)
725            .count();
726        if non_trait_types == 0 {
727            return 0.0;
728        }
729        self.newtype_count() as f64 / non_trait_types as f64
730    }
731
732    /// Count types with public fields
733    pub fn types_with_public_fields(&self) -> usize {
734        self.type_definitions
735            .values()
736            .filter(|t| t.public_field_count > 0)
737            .count()
738    }
739
740    /// Total function count
741    pub fn function_count(&self) -> usize {
742        self.function_definitions.len()
743    }
744
745    /// Count functions with high primitive parameter ratio
746    /// (potential Primitive Obsession)
747    pub fn functions_with_primitive_obsession(&self) -> Vec<&FunctionDefinition> {
748        self.function_definitions
749            .values()
750            .filter(|f| {
751                f.param_count >= 3 && f.primitive_param_count as f64 / f.param_count as f64 >= 0.6
752            })
753            .collect()
754    }
755
756    /// Check if this module is a potential "God Module"
757    /// (too many functions, types, or implementations)
758    pub fn is_god_module(&self, max_functions: usize, max_types: usize, max_impls: usize) -> bool {
759        self.function_count() > max_functions
760            || self.type_definitions.len() > max_types
761            || (self.trait_impl_count + self.inherent_impl_count) > max_impls
762    }
763}
764
765/// Project-wide analysis results
766#[derive(Debug, Default)]
767pub struct ProjectMetrics {
768    /// All module metrics
769    pub modules: HashMap<String, ModuleMetrics>,
770    /// All detected couplings
771    pub couplings: Vec<CouplingMetrics>,
772    /// File change counts (for volatility)
773    pub file_changes: HashMap<String, usize>,
774    /// Total files analyzed
775    pub total_files: usize,
776    /// Workspace name (if available from cargo metadata)
777    pub workspace_name: Option<String>,
778    /// Workspace member crate names
779    pub workspace_members: Vec<String>,
780    /// Crate-level dependencies (crate name -> list of dependencies)
781    pub crate_dependencies: HashMap<String, Vec<String>>,
782    /// Global type registry: type name -> (module name, visibility)
783    pub type_registry: HashMap<String, (String, Visibility)>,
784    /// Temporal coupling data (files that co-change frequently)
785    pub temporal_couplings: Vec<crate::volatility::TemporalCoupling>,
786}
787
788impl ProjectMetrics {
789    pub fn new() -> Self {
790        Self::default()
791    }
792
793    /// Add module metrics
794    pub fn add_module(&mut self, metrics: ModuleMetrics) {
795        self.modules.insert(metrics.name.clone(), metrics);
796    }
797
798    /// Add coupling
799    pub fn add_coupling(&mut self, coupling: CouplingMetrics) {
800        self.couplings.push(coupling);
801    }
802
803    /// Register a type definition in the global registry
804    pub fn register_type(
805        &mut self,
806        type_name: String,
807        module_name: String,
808        visibility: Visibility,
809    ) {
810        self.type_registry
811            .insert(type_name, (module_name, visibility));
812    }
813
814    /// Look up visibility of a type by name
815    pub fn get_type_visibility(&self, type_name: &str) -> Option<Visibility> {
816        self.type_registry.get(type_name).map(|(_, vis)| *vis)
817    }
818
819    /// Look up the module where a type is defined
820    pub fn get_type_module(&self, type_name: &str) -> Option<&str> {
821        self.type_registry
822            .get(type_name)
823            .map(|(module, _)| module.as_str())
824    }
825
826    /// Update visibility information for existing couplings
827    ///
828    /// This should be called after all modules have been analyzed
829    /// to populate the target_visibility field of couplings.
830    pub fn update_coupling_visibility(&mut self) {
831        // First collect all the visibility lookups
832        let visibility_updates: Vec<(usize, Visibility)> = self
833            .couplings
834            .iter()
835            .enumerate()
836            .filter_map(|(idx, coupling)| {
837                let target_type = coupling
838                    .target
839                    .split("::")
840                    .last()
841                    .unwrap_or(&coupling.target);
842                self.type_registry
843                    .get(target_type)
844                    .map(|(_, vis)| (idx, *vis))
845            })
846            .collect();
847
848        // Then apply the updates
849        for (idx, visibility) in visibility_updates {
850            self.couplings[idx].target_visibility = visibility;
851        }
852    }
853
854    /// Get total module count
855    pub fn module_count(&self) -> usize {
856        self.modules.len()
857    }
858
859    /// Get total coupling count
860    pub fn coupling_count(&self) -> usize {
861        self.couplings.len()
862    }
863
864    /// Get internal coupling count (excludes external crate dependencies)
865    pub fn internal_coupling_count(&self) -> usize {
866        self.couplings
867            .iter()
868            .filter(|c| !crate::balance::is_external_crate(&c.target, &c.source))
869            .count()
870    }
871
872    /// Calculate average strength across all couplings
873    pub fn average_strength(&self) -> Option<f64> {
874        if self.couplings.is_empty() {
875            return None;
876        }
877        let sum: f64 = self.couplings.iter().map(|c| c.strength_value()).sum();
878        Some(sum / self.couplings.len() as f64)
879    }
880
881    /// Calculate average distance across all couplings
882    pub fn average_distance(&self) -> Option<f64> {
883        if self.couplings.is_empty() {
884            return None;
885        }
886        let sum: f64 = self.couplings.iter().map(|c| c.distance_value()).sum();
887        Some(sum / self.couplings.len() as f64)
888    }
889
890    /// Update volatility for all couplings based on file changes
891    ///
892    /// This should be called after git history analysis to update
893    /// the volatility of each coupling based on how often the target
894    /// module/file has changed.
895    pub fn update_volatility_from_git(&mut self) {
896        if self.file_changes.is_empty() {
897            return;
898        }
899
900        // Debug: print file changes for troubleshooting
901        #[cfg(test)]
902        {
903            eprintln!("DEBUG: file_changes = {:?}", self.file_changes);
904        }
905
906        for coupling in &mut self.couplings {
907            // Try to find the target file in file_changes
908            // The target is like "crate::module" or "crate::module::Type"
909            // We need to match this against file paths like "src/module.rs"
910            //
911            // Special cases in Rust module system:
912            // - crate root "crate::crate_name" or "crate_name::crate_name" -> lib.rs
913            // - binary entry point -> main.rs
914            // - glob imports "crate::*" -> don't match specific files
915
916            // Extract all path components from target
917            let target_parts: Vec<&str> = coupling.target.split("::").collect();
918
919            // Find the best matching file
920            let mut max_changes = 0usize;
921            for (file_path, &changes) in &self.file_changes {
922                // Get file name without .rs extension (e.g., "balance" from "src/balance.rs")
923                let file_name = file_path
924                    .rsplit('/')
925                    .next()
926                    .unwrap_or(file_path)
927                    .trim_end_matches(".rs");
928
929                // Check if any target path component matches the file name
930                let matches = target_parts.iter().any(|part| {
931                    let part_lower = part.to_lowercase();
932                    let file_lower = file_name.to_lowercase();
933
934                    // Direct match: "balance" == "balance"
935                    if part_lower == file_lower {
936                        return true;
937                    }
938
939                    // Handle crate root: if the part matches the crate name and file is lib.rs
940                    // e.g., "cargo_coupling" matches "lib" (lib.rs is the crate root)
941                    if file_lower == "lib" && !part.is_empty() && *part != "*" {
942                        // This could be the crate root reference
943                        // We also match if the part is the crate name (same as first path component)
944                        if target_parts.len() >= 2 && target_parts[1] == *part {
945                            return true;
946                        }
947                    }
948
949                    // Handle underscore vs hyphen in crate names
950                    // e.g., "cargo-coupling" might appear as "cargo_coupling" in code
951                    let part_normalized = part_lower.replace('-', "_");
952                    let file_normalized = file_lower.replace('-', "_");
953                    if part_normalized == file_normalized {
954                        return true;
955                    }
956
957                    // Path contains match: "web" matches "src/web/graph.rs"
958                    if file_path.to_lowercase().contains(&part_lower) {
959                        return true;
960                    }
961
962                    false
963                });
964
965                if matches {
966                    max_changes = max_changes.max(changes);
967                }
968            }
969
970            coupling.volatility = Volatility::from_count(max_changes);
971        }
972    }
973
974    /// Build a dependency graph from couplings
975    fn build_dependency_graph(&self) -> HashMap<String, HashSet<String>> {
976        let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
977
978        for coupling in &self.couplings {
979            // Only consider internal couplings (not external crates)
980            if coupling.distance == Distance::DifferentCrate {
981                continue;
982            }
983
984            // Extract module names (remove crate prefix for cleaner cycles)
985            let source = coupling.source.clone();
986            let target = coupling.target.clone();
987
988            graph.entry(source).or_default().insert(target);
989        }
990
991        graph
992    }
993
994    /// Detect circular dependencies in the project
995    ///
996    /// Returns a list of cycles, where each cycle is a list of module names
997    /// forming the circular dependency chain.
998    pub fn detect_circular_dependencies(&self) -> Vec<Vec<String>> {
999        let graph = self.build_dependency_graph();
1000        let mut cycles: Vec<Vec<String>> = Vec::new();
1001        let mut visited: HashSet<String> = HashSet::new();
1002        let mut rec_stack: HashSet<String> = HashSet::new();
1003
1004        for node in graph.keys() {
1005            if !visited.contains(node) {
1006                let mut path = Vec::new();
1007                self.dfs_find_cycles(
1008                    node,
1009                    &graph,
1010                    &mut visited,
1011                    &mut rec_stack,
1012                    &mut path,
1013                    &mut cycles,
1014                );
1015            }
1016        }
1017
1018        // Deduplicate cycles (same cycle can be detected from different starting points)
1019        let mut unique_cycles: Vec<Vec<String>> = Vec::new();
1020        for cycle in cycles {
1021            let normalized = Self::normalize_cycle(&cycle);
1022            if !unique_cycles
1023                .iter()
1024                .any(|c| Self::normalize_cycle(c) == normalized)
1025            {
1026                unique_cycles.push(cycle);
1027            }
1028        }
1029
1030        unique_cycles
1031    }
1032
1033    /// DFS helper for cycle detection
1034    fn dfs_find_cycles(
1035        &self,
1036        node: &str,
1037        graph: &HashMap<String, HashSet<String>>,
1038        visited: &mut HashSet<String>,
1039        rec_stack: &mut HashSet<String>,
1040        path: &mut Vec<String>,
1041        cycles: &mut Vec<Vec<String>>,
1042    ) {
1043        visited.insert(node.to_string());
1044        rec_stack.insert(node.to_string());
1045        path.push(node.to_string());
1046
1047        if let Some(neighbors) = graph.get(node) {
1048            for neighbor in neighbors {
1049                if !visited.contains(neighbor) {
1050                    self.dfs_find_cycles(neighbor, graph, visited, rec_stack, path, cycles);
1051                } else if rec_stack.contains(neighbor) {
1052                    // Found a cycle - extract the cycle from path
1053                    if let Some(start_idx) = path.iter().position(|n| n == neighbor) {
1054                        let cycle: Vec<String> = path[start_idx..].to_vec();
1055                        if cycle.len() >= 2 {
1056                            cycles.push(cycle);
1057                        }
1058                    }
1059                }
1060            }
1061        }
1062
1063        path.pop();
1064        rec_stack.remove(node);
1065    }
1066
1067    /// Normalize a cycle for deduplication
1068    /// Rotates the cycle so the lexicographically smallest element is first
1069    fn normalize_cycle(cycle: &[String]) -> Vec<String> {
1070        if cycle.is_empty() {
1071            return Vec::new();
1072        }
1073
1074        // Find the position of the minimum element
1075        let min_pos = cycle
1076            .iter()
1077            .enumerate()
1078            .min_by_key(|(_, s)| s.as_str())
1079            .map(|(i, _)| i)
1080            .unwrap_or(0);
1081
1082        // Rotate the cycle
1083        let mut normalized: Vec<String> = cycle[min_pos..].to_vec();
1084        normalized.extend_from_slice(&cycle[..min_pos]);
1085        normalized
1086    }
1087
1088    /// Get circular dependency summary
1089    pub fn circular_dependency_summary(&self) -> CircularDependencySummary {
1090        let cycles = self.detect_circular_dependencies();
1091        let affected_modules: HashSet<String> = cycles.iter().flatten().cloned().collect();
1092
1093        CircularDependencySummary {
1094            total_cycles: cycles.len(),
1095            affected_modules: affected_modules.len(),
1096            cycles,
1097        }
1098    }
1099
1100    /// Calculate 3-dimensional coupling statistics
1101    ///
1102    /// Computes distribution of couplings across Strength, Distance,
1103    /// Volatility, and Balance Classification dimensions.
1104    pub fn calculate_dimension_stats(&self) -> DimensionStats {
1105        let mut stats = DimensionStats::default();
1106
1107        for coupling in &self.couplings {
1108            // Count strength distribution
1109            match coupling.strength {
1110                IntegrationStrength::Intrusive => stats.strength_counts.intrusive += 1,
1111                IntegrationStrength::Functional => stats.strength_counts.functional += 1,
1112                IntegrationStrength::Model => stats.strength_counts.model += 1,
1113                IntegrationStrength::Contract => stats.strength_counts.contract += 1,
1114            }
1115
1116            // Count distance distribution
1117            match coupling.distance {
1118                Distance::SameFunction | Distance::SameModule => {
1119                    stats.distance_counts.same_module += 1
1120                }
1121                Distance::DifferentModule => stats.distance_counts.different_module += 1,
1122                Distance::DifferentCrate => stats.distance_counts.different_crate += 1,
1123            }
1124
1125            // Count volatility distribution
1126            match coupling.volatility {
1127                Volatility::Low => stats.volatility_counts.low += 1,
1128                Volatility::Medium => stats.volatility_counts.medium += 1,
1129                Volatility::High => stats.volatility_counts.high += 1,
1130            }
1131
1132            // Classify and count balance
1133            let classification = BalanceClassification::classify(
1134                coupling.strength,
1135                coupling.distance,
1136                coupling.volatility,
1137            );
1138            match classification {
1139                BalanceClassification::HighCohesion => stats.balance_counts.high_cohesion += 1,
1140                BalanceClassification::LooseCoupling => stats.balance_counts.loose_coupling += 1,
1141                BalanceClassification::Acceptable => stats.balance_counts.acceptable += 1,
1142                BalanceClassification::Pain => stats.balance_counts.pain += 1,
1143                BalanceClassification::LocalComplexity => {
1144                    stats.balance_counts.local_complexity += 1
1145                }
1146            }
1147        }
1148
1149        stats
1150    }
1151
1152    /// Get total newtype count across all modules
1153    pub fn total_newtype_count(&self) -> usize {
1154        self.modules.values().map(|m| m.newtype_count()).sum()
1155    }
1156
1157    /// Get total type count across all modules (excluding traits)
1158    pub fn total_type_count(&self) -> usize {
1159        self.modules
1160            .values()
1161            .flat_map(|m| m.type_definitions.values())
1162            .filter(|t| !t.is_trait)
1163            .count()
1164    }
1165
1166    /// Calculate project-wide newtype usage ratio
1167    pub fn newtype_ratio(&self) -> f64 {
1168        let total = self.total_type_count();
1169        if total == 0 {
1170            return 0.0;
1171        }
1172        self.total_newtype_count() as f64 / total as f64
1173    }
1174
1175    /// Get types with serde derives (potential DTO exposure)
1176    pub fn serde_types(&self) -> Vec<(&str, &TypeDefinition)> {
1177        self.modules
1178            .iter()
1179            .flat_map(|(module_name, m)| {
1180                m.type_definitions
1181                    .values()
1182                    .filter(|t| t.has_serde_derive)
1183                    .map(move |t| (module_name.as_str(), t))
1184            })
1185            .collect()
1186    }
1187
1188    /// Identify potential God Modules
1189    pub fn god_modules(
1190        &self,
1191        max_functions: usize,
1192        max_types: usize,
1193        max_impls: usize,
1194    ) -> Vec<&str> {
1195        self.modules
1196            .iter()
1197            .filter(|(_, m)| m.is_god_module(max_functions, max_types, max_impls))
1198            .map(|(name, _)| name.as_str())
1199            .collect()
1200    }
1201
1202    /// Get all functions with potential Primitive Obsession
1203    pub fn functions_with_primitive_obsession(&self) -> Vec<(&str, &FunctionDefinition)> {
1204        self.modules
1205            .iter()
1206            .flat_map(|(module_name, m)| {
1207                m.functions_with_primitive_obsession()
1208                    .into_iter()
1209                    .map(move |f| (module_name.as_str(), f))
1210            })
1211            .collect()
1212    }
1213
1214    /// Get types with exposed public fields
1215    pub fn types_with_public_fields(&self) -> Vec<(&str, &TypeDefinition)> {
1216        self.modules
1217            .iter()
1218            .flat_map(|(module_name, m)| {
1219                m.type_definitions
1220                    .values()
1221                    .filter(|t| t.public_field_count > 0 && !t.is_trait)
1222                    .map(move |t| (module_name.as_str(), t))
1223            })
1224            .collect()
1225    }
1226}
1227
1228/// Summary of circular dependencies
1229#[derive(Debug, Clone)]
1230pub struct CircularDependencySummary {
1231    /// Total number of circular dependency cycles
1232    pub total_cycles: usize,
1233    /// Number of modules involved in cycles
1234    pub affected_modules: usize,
1235    /// The actual cycles (list of module names)
1236    pub cycles: Vec<Vec<String>>,
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242
1243    #[test]
1244    fn test_integration_strength_values() {
1245        assert_eq!(IntegrationStrength::Intrusive.value(), 1.0);
1246        assert_eq!(IntegrationStrength::Contract.value(), 0.25);
1247    }
1248
1249    #[test]
1250    fn test_distance_values() {
1251        assert_eq!(Distance::SameFunction.value(), 0.0);
1252        assert_eq!(Distance::DifferentCrate.value(), 1.0);
1253    }
1254
1255    #[test]
1256    fn test_volatility_from_count() {
1257        assert_eq!(Volatility::from_count(0), Volatility::Low);
1258        assert_eq!(Volatility::from_count(5), Volatility::Medium);
1259        assert_eq!(Volatility::from_count(15), Volatility::High);
1260    }
1261
1262    #[test]
1263    fn test_module_metrics_average_strength() {
1264        let mut metrics = ModuleMetrics::new(PathBuf::from("test.rs"), "test".to_string());
1265        metrics.trait_impl_count = 3;
1266        metrics.inherent_impl_count = 1;
1267
1268        let avg = metrics.average_strength();
1269        assert!(avg > 0.0 && avg < 1.0);
1270    }
1271
1272    #[test]
1273    fn test_project_metrics() {
1274        let mut project = ProjectMetrics::new();
1275
1276        let module = ModuleMetrics::new(PathBuf::from("lib.rs"), "lib".to_string());
1277        project.add_module(module);
1278
1279        assert_eq!(project.module_count(), 1);
1280        assert_eq!(project.coupling_count(), 0);
1281    }
1282
1283    #[test]
1284    fn test_circular_dependency_detection() {
1285        let mut project = ProjectMetrics::new();
1286
1287        // Create a cycle: A -> B -> C -> A
1288        project.add_coupling(CouplingMetrics::new(
1289            "module_a".to_string(),
1290            "module_b".to_string(),
1291            IntegrationStrength::Model,
1292            Distance::DifferentModule,
1293            Volatility::Low,
1294        ));
1295        project.add_coupling(CouplingMetrics::new(
1296            "module_b".to_string(),
1297            "module_c".to_string(),
1298            IntegrationStrength::Model,
1299            Distance::DifferentModule,
1300            Volatility::Low,
1301        ));
1302        project.add_coupling(CouplingMetrics::new(
1303            "module_c".to_string(),
1304            "module_a".to_string(),
1305            IntegrationStrength::Model,
1306            Distance::DifferentModule,
1307            Volatility::Low,
1308        ));
1309
1310        let cycles = project.detect_circular_dependencies();
1311        assert_eq!(cycles.len(), 1);
1312        assert_eq!(cycles[0].len(), 3);
1313    }
1314
1315    #[test]
1316    fn test_no_circular_dependencies() {
1317        let mut project = ProjectMetrics::new();
1318
1319        // Linear dependency: A -> B -> C (no cycle)
1320        project.add_coupling(CouplingMetrics::new(
1321            "module_a".to_string(),
1322            "module_b".to_string(),
1323            IntegrationStrength::Model,
1324            Distance::DifferentModule,
1325            Volatility::Low,
1326        ));
1327        project.add_coupling(CouplingMetrics::new(
1328            "module_b".to_string(),
1329            "module_c".to_string(),
1330            IntegrationStrength::Model,
1331            Distance::DifferentModule,
1332            Volatility::Low,
1333        ));
1334
1335        let cycles = project.detect_circular_dependencies();
1336        assert!(cycles.is_empty());
1337    }
1338
1339    #[test]
1340    fn test_external_crates_excluded_from_cycles() {
1341        let mut project = ProjectMetrics::new();
1342
1343        // External crate dependency should be ignored
1344        project.add_coupling(CouplingMetrics::new(
1345            "module_a".to_string(),
1346            "serde::Serialize".to_string(),
1347            IntegrationStrength::Contract,
1348            Distance::DifferentCrate, // External
1349            Volatility::Low,
1350        ));
1351        project.add_coupling(CouplingMetrics::new(
1352            "serde::Serialize".to_string(),
1353            "module_a".to_string(),
1354            IntegrationStrength::Contract,
1355            Distance::DifferentCrate, // External
1356            Volatility::Low,
1357        ));
1358
1359        let cycles = project.detect_circular_dependencies();
1360        assert!(cycles.is_empty());
1361    }
1362
1363    #[test]
1364    fn test_circular_dependency_summary() {
1365        let mut project = ProjectMetrics::new();
1366
1367        // Create a simple cycle: A <-> B
1368        project.add_coupling(CouplingMetrics::new(
1369            "module_a".to_string(),
1370            "module_b".to_string(),
1371            IntegrationStrength::Functional,
1372            Distance::DifferentModule,
1373            Volatility::Low,
1374        ));
1375        project.add_coupling(CouplingMetrics::new(
1376            "module_b".to_string(),
1377            "module_a".to_string(),
1378            IntegrationStrength::Functional,
1379            Distance::DifferentModule,
1380            Volatility::Low,
1381        ));
1382
1383        let summary = project.circular_dependency_summary();
1384        assert!(summary.total_cycles > 0);
1385        assert!(summary.affected_modules >= 2);
1386    }
1387
1388    #[test]
1389    fn test_visibility_intrusive_detection() {
1390        // Public items are never intrusive
1391        assert!(!Visibility::Public.is_intrusive_from(true, false));
1392        assert!(!Visibility::Public.is_intrusive_from(false, false));
1393
1394        // PubCrate is intrusive only from different crate
1395        assert!(!Visibility::PubCrate.is_intrusive_from(true, false));
1396        assert!(Visibility::PubCrate.is_intrusive_from(false, false));
1397
1398        // Private is always intrusive from outside
1399        assert!(Visibility::Private.is_intrusive_from(true, false));
1400        assert!(Visibility::Private.is_intrusive_from(false, false));
1401
1402        // Same module access is never intrusive
1403        assert!(!Visibility::Private.is_intrusive_from(true, true));
1404        assert!(!Visibility::Private.is_intrusive_from(false, true));
1405    }
1406
1407    #[test]
1408    fn test_visibility_penalty() {
1409        assert_eq!(Visibility::Public.intrusive_penalty(), 0.0);
1410        assert_eq!(Visibility::PubCrate.intrusive_penalty(), 0.25);
1411        assert_eq!(Visibility::Private.intrusive_penalty(), 1.0);
1412    }
1413
1414    #[test]
1415    fn test_effective_strength() {
1416        // Public target - no upgrade
1417        let coupling = CouplingMetrics::with_visibility(
1418            "source".to_string(),
1419            "target".to_string(),
1420            IntegrationStrength::Model,
1421            Distance::DifferentModule,
1422            Volatility::Low,
1423            Visibility::Public,
1424        );
1425        assert_eq!(coupling.effective_strength(), IntegrationStrength::Model);
1426
1427        // Private target from different module - upgraded
1428        let coupling = CouplingMetrics::with_visibility(
1429            "source".to_string(),
1430            "target".to_string(),
1431            IntegrationStrength::Model,
1432            Distance::DifferentModule,
1433            Volatility::Low,
1434            Visibility::Private,
1435        );
1436        assert_eq!(
1437            coupling.effective_strength(),
1438            IntegrationStrength::Functional
1439        );
1440    }
1441
1442    #[test]
1443    fn test_type_registry() {
1444        let mut project = ProjectMetrics::new();
1445
1446        project.register_type(
1447            "MyStruct".to_string(),
1448            "my_module".to_string(),
1449            Visibility::Public,
1450        );
1451        project.register_type(
1452            "InternalType".to_string(),
1453            "my_module".to_string(),
1454            Visibility::PubCrate,
1455        );
1456
1457        assert_eq!(
1458            project.get_type_visibility("MyStruct"),
1459            Some(Visibility::Public)
1460        );
1461        assert_eq!(
1462            project.get_type_visibility("InternalType"),
1463            Some(Visibility::PubCrate)
1464        );
1465        assert_eq!(project.get_type_visibility("Unknown"), None);
1466
1467        assert_eq!(project.get_type_module("MyStruct"), Some("my_module"));
1468    }
1469
1470    #[test]
1471    fn test_module_type_definitions() {
1472        let mut module = ModuleMetrics::new(PathBuf::from("test.rs"), "test".to_string());
1473
1474        module.add_type_definition("PublicStruct".to_string(), Visibility::Public, false);
1475        module.add_type_definition("PrivateStruct".to_string(), Visibility::Private, false);
1476        module.add_type_definition("PublicTrait".to_string(), Visibility::Public, true);
1477
1478        assert_eq!(module.public_type_count(), 2);
1479        assert_eq!(module.private_type_count(), 1);
1480        assert_eq!(
1481            module.get_type_visibility("PublicStruct"),
1482            Some(Visibility::Public)
1483        );
1484    }
1485
1486    #[test]
1487    fn test_update_volatility_from_git() {
1488        let mut project = ProjectMetrics::new();
1489
1490        // Add couplings with targets matching file names
1491        project.add_coupling(CouplingMetrics::new(
1492            "crate::main".to_string(),
1493            "crate::balance".to_string(),
1494            IntegrationStrength::Functional,
1495            Distance::DifferentModule,
1496            Volatility::Low, // Initial volatility
1497        ));
1498        project.add_coupling(CouplingMetrics::new(
1499            "crate::main".to_string(),
1500            "crate::analyzer".to_string(),
1501            IntegrationStrength::Functional,
1502            Distance::DifferentModule,
1503            Volatility::Low,
1504        ));
1505        project.add_coupling(CouplingMetrics::new(
1506            "crate::main".to_string(),
1507            "crate::report".to_string(),
1508            IntegrationStrength::Functional,
1509            Distance::DifferentModule,
1510            Volatility::Low,
1511        ));
1512
1513        // Simulate git file changes
1514        project
1515            .file_changes
1516            .insert("src/balance.rs".to_string(), 15); // High
1517        project
1518            .file_changes
1519            .insert("src/analyzer.rs".to_string(), 7); // Medium
1520        project.file_changes.insert("src/report.rs".to_string(), 2); // Low
1521
1522        // Update volatility from git data
1523        project.update_volatility_from_git();
1524
1525        // Verify volatility was updated correctly
1526        let balance_coupling = project
1527            .couplings
1528            .iter()
1529            .find(|c| c.target == "crate::balance")
1530            .unwrap();
1531        assert_eq!(balance_coupling.volatility, Volatility::High);
1532
1533        let analyzer_coupling = project
1534            .couplings
1535            .iter()
1536            .find(|c| c.target == "crate::analyzer")
1537            .unwrap();
1538        assert_eq!(analyzer_coupling.volatility, Volatility::Medium);
1539
1540        let report_coupling = project
1541            .couplings
1542            .iter()
1543            .find(|c| c.target == "crate::report")
1544            .unwrap();
1545        assert_eq!(report_coupling.volatility, Volatility::Low);
1546    }
1547
1548    #[test]
1549    fn test_volatility_with_type_targets() {
1550        // Test with more realistic targets that include type names (e.g., crate::balance::BalanceScore)
1551        let mut project = ProjectMetrics::new();
1552
1553        // Add couplings with Type-level targets (common in real analysis)
1554        project.add_coupling(CouplingMetrics::new(
1555            "crate::main".to_string(),
1556            "crate::balance::BalanceScore".to_string(), // Type in balance module
1557            IntegrationStrength::Functional,
1558            Distance::DifferentModule,
1559            Volatility::Low,
1560        ));
1561        project.add_coupling(CouplingMetrics::new(
1562            "crate::main".to_string(),
1563            "cargo-coupling::analyzer::analyze_file".to_string(), // Function in analyzer module
1564            IntegrationStrength::Functional,
1565            Distance::DifferentModule,
1566            Volatility::Low,
1567        ));
1568
1569        // Simulate git file changes
1570        project
1571            .file_changes
1572            .insert("src/balance.rs".to_string(), 15); // High
1573        project
1574            .file_changes
1575            .insert("src/analyzer.rs".to_string(), 7); // Medium
1576
1577        // Update volatility from git data
1578        project.update_volatility_from_git();
1579
1580        // Verify volatility was updated correctly by matching module path component
1581        let balance_coupling = project
1582            .couplings
1583            .iter()
1584            .find(|c| c.target.contains("balance"))
1585            .unwrap();
1586        assert_eq!(
1587            balance_coupling.volatility,
1588            Volatility::High,
1589            "Expected High volatility for balance module (15 changes)"
1590        );
1591
1592        let analyzer_coupling = project
1593            .couplings
1594            .iter()
1595            .find(|c| c.target.contains("analyzer"))
1596            .unwrap();
1597        assert_eq!(
1598            analyzer_coupling.volatility,
1599            Volatility::Medium,
1600            "Expected Medium volatility for analyzer module (7 changes)"
1601        );
1602    }
1603
1604    #[test]
1605    fn test_volatility_extracted_module_targets() {
1606        // Test with extracted module names (like what the analyzer produces)
1607        // The analyzer's extract_target_module() returns just "balance" from "crate::balance::Type"
1608        let mut project = ProjectMetrics::new();
1609
1610        // Extracted module targets (single component names)
1611        project.add_coupling(CouplingMetrics::new(
1612            "cargo-coupling::main".to_string(),
1613            "balance".to_string(), // Extracted module name
1614            IntegrationStrength::Functional,
1615            Distance::DifferentModule,
1616            Volatility::Low,
1617        ));
1618        project.add_coupling(CouplingMetrics::new(
1619            "cargo-coupling::main".to_string(),
1620            "analyzer".to_string(), // Extracted module name
1621            IntegrationStrength::Functional,
1622            Distance::DifferentModule,
1623            Volatility::Low,
1624        ));
1625        project.add_coupling(CouplingMetrics::new(
1626            "cargo-coupling::main".to_string(),
1627            "cli_output".to_string(), // Extracted module name with underscore
1628            IntegrationStrength::Functional,
1629            Distance::DifferentModule,
1630            Volatility::Low,
1631        ));
1632
1633        // Simulate git file changes
1634        project
1635            .file_changes
1636            .insert("src/balance.rs".to_string(), 15); // High
1637        project
1638            .file_changes
1639            .insert("src/analyzer.rs".to_string(), 7); // Medium
1640        project
1641            .file_changes
1642            .insert("src/cli_output.rs".to_string(), 3); // Medium
1643
1644        // Update volatility from git data
1645        project.update_volatility_from_git();
1646
1647        // Verify volatility was updated
1648        let balance = project
1649            .couplings
1650            .iter()
1651            .find(|c| c.target == "balance")
1652            .unwrap();
1653        assert_eq!(
1654            balance.volatility,
1655            Volatility::High,
1656            "balance should be High (15 changes)"
1657        );
1658
1659        let analyzer = project
1660            .couplings
1661            .iter()
1662            .find(|c| c.target == "analyzer")
1663            .unwrap();
1664        assert_eq!(
1665            analyzer.volatility,
1666            Volatility::Medium,
1667            "analyzer should be Medium (7 changes)"
1668        );
1669
1670        let cli_output = project
1671            .couplings
1672            .iter()
1673            .find(|c| c.target == "cli_output")
1674            .unwrap();
1675        assert_eq!(
1676            cli_output.volatility,
1677            Volatility::Medium,
1678            "cli_output should be Medium (3 changes)"
1679        );
1680    }
1681}