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::connascence::ConnascenceStats;
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)]
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/// Metrics for a single coupling relationship
163#[derive(Debug, Clone)]
164pub struct CouplingMetrics {
165    /// Source component
166    pub source: String,
167    /// Target component
168    pub target: String,
169    /// Integration strength
170    pub strength: IntegrationStrength,
171    /// Distance between components
172    pub distance: Distance,
173    /// Volatility of the target
174    pub volatility: Volatility,
175    /// Source crate name (when workspace analysis is available)
176    pub source_crate: Option<String>,
177    /// Target crate name (when workspace analysis is available)
178    pub target_crate: Option<String>,
179    /// Visibility of the target item (for intrusive detection)
180    pub target_visibility: Visibility,
181}
182
183impl CouplingMetrics {
184    /// Create new coupling metrics
185    pub fn new(
186        source: String,
187        target: String,
188        strength: IntegrationStrength,
189        distance: Distance,
190        volatility: Volatility,
191    ) -> Self {
192        Self {
193            source,
194            target,
195            strength,
196            distance,
197            volatility,
198            source_crate: None,
199            target_crate: None,
200            target_visibility: Visibility::default(),
201        }
202    }
203
204    /// Create new coupling metrics with visibility
205    pub fn with_visibility(
206        source: String,
207        target: String,
208        strength: IntegrationStrength,
209        distance: Distance,
210        volatility: Volatility,
211        visibility: Visibility,
212    ) -> Self {
213        Self {
214            source,
215            target,
216            strength,
217            distance,
218            volatility,
219            source_crate: None,
220            target_crate: None,
221            target_visibility: visibility,
222        }
223    }
224
225    /// Check if this coupling represents intrusive access based on visibility
226    ///
227    /// Returns true if the target's visibility suggests this is access to
228    /// internal implementation details rather than a public API.
229    pub fn is_visibility_intrusive(&self) -> bool {
230        let same_crate = self.source_crate == self.target_crate;
231        let same_module =
232            self.distance == Distance::SameModule || self.distance == Distance::SameFunction;
233        self.target_visibility
234            .is_intrusive_from(same_crate, same_module)
235    }
236
237    /// Get effective strength considering visibility
238    ///
239    /// If the target is not publicly visible and being accessed from outside,
240    /// the coupling is considered more intrusive.
241    pub fn effective_strength(&self) -> IntegrationStrength {
242        if self.is_visibility_intrusive() && self.strength != IntegrationStrength::Intrusive {
243            // Upgrade to more intrusive if accessing non-public items
244            match self.strength {
245                IntegrationStrength::Contract => IntegrationStrength::Model,
246                IntegrationStrength::Model => IntegrationStrength::Functional,
247                IntegrationStrength::Functional => IntegrationStrength::Intrusive,
248                IntegrationStrength::Intrusive => IntegrationStrength::Intrusive,
249            }
250        } else {
251            self.strength
252        }
253    }
254
255    /// Get effective strength value considering visibility
256    pub fn effective_strength_value(&self) -> f64 {
257        self.effective_strength().value()
258    }
259
260    /// Get numeric strength value
261    pub fn strength_value(&self) -> f64 {
262        self.strength.value()
263    }
264
265    /// Get numeric distance value
266    pub fn distance_value(&self) -> f64 {
267        self.distance.value()
268    }
269
270    /// Get numeric volatility value
271    pub fn volatility_value(&self) -> f64 {
272        self.volatility.value()
273    }
274}
275
276/// Information about a type definition in a module
277#[derive(Debug, Clone)]
278pub struct TypeDefinition {
279    /// Name of the type
280    pub name: String,
281    /// Visibility of the type
282    pub visibility: Visibility,
283    /// Whether this is a trait (vs struct/enum)
284    pub is_trait: bool,
285}
286
287/// Aggregated metrics for a module
288#[derive(Debug, Clone, Default)]
289pub struct ModuleMetrics {
290    /// Module path
291    pub path: PathBuf,
292    /// Module name
293    pub name: String,
294    /// Number of trait implementations (contract coupling)
295    pub trait_impl_count: usize,
296    /// Number of inherent implementations (intrusive coupling)
297    pub inherent_impl_count: usize,
298    /// Number of function calls
299    pub function_call_count: usize,
300    /// Number of struct/enum usages
301    pub type_usage_count: usize,
302    /// External crate dependencies
303    pub external_deps: Vec<String>,
304    /// Internal module dependencies
305    pub internal_deps: Vec<String>,
306    /// Type definitions in this module with visibility info
307    pub type_definitions: HashMap<String, TypeDefinition>,
308}
309
310impl ModuleMetrics {
311    pub fn new(path: PathBuf, name: String) -> Self {
312        Self {
313            path,
314            name,
315            ..Default::default()
316        }
317    }
318
319    /// Add a type definition to this module
320    pub fn add_type_definition(&mut self, name: String, visibility: Visibility, is_trait: bool) {
321        self.type_definitions.insert(
322            name.clone(),
323            TypeDefinition {
324                name,
325                visibility,
326                is_trait,
327            },
328        );
329    }
330
331    /// Get visibility of a type defined in this module
332    pub fn get_type_visibility(&self, name: &str) -> Option<Visibility> {
333        self.type_definitions.get(name).map(|t| t.visibility)
334    }
335
336    /// Count public types
337    pub fn public_type_count(&self) -> usize {
338        self.type_definitions
339            .values()
340            .filter(|t| t.visibility == Visibility::Public)
341            .count()
342    }
343
344    /// Count non-public types
345    pub fn private_type_count(&self) -> usize {
346        self.type_definitions
347            .values()
348            .filter(|t| t.visibility != Visibility::Public)
349            .count()
350    }
351
352    /// Calculate average integration strength
353    pub fn average_strength(&self) -> f64 {
354        let total = self.trait_impl_count + self.inherent_impl_count;
355        if total == 0 {
356            return 0.0;
357        }
358
359        let contract_weight = self.trait_impl_count as f64 * IntegrationStrength::Contract.value();
360        let intrusive_weight =
361            self.inherent_impl_count as f64 * IntegrationStrength::Intrusive.value();
362
363        (contract_weight + intrusive_weight) / total as f64
364    }
365}
366
367/// Project-wide analysis results
368#[derive(Debug, Default)]
369pub struct ProjectMetrics {
370    /// All module metrics
371    pub modules: HashMap<String, ModuleMetrics>,
372    /// All detected couplings
373    pub couplings: Vec<CouplingMetrics>,
374    /// File change counts (for volatility)
375    pub file_changes: HashMap<String, usize>,
376    /// Total files analyzed
377    pub total_files: usize,
378    /// Workspace name (if available from cargo metadata)
379    pub workspace_name: Option<String>,
380    /// Workspace member crate names
381    pub workspace_members: Vec<String>,
382    /// Crate-level dependencies (crate name -> list of dependencies)
383    pub crate_dependencies: HashMap<String, Vec<String>>,
384    /// Global type registry: type name -> (module name, visibility)
385    pub type_registry: HashMap<String, (String, Visibility)>,
386    /// Connascence analysis statistics
387    pub connascence_stats: ConnascenceStats,
388}
389
390impl ProjectMetrics {
391    pub fn new() -> Self {
392        Self::default()
393    }
394
395    /// Add module metrics
396    pub fn add_module(&mut self, metrics: ModuleMetrics) {
397        self.modules.insert(metrics.name.clone(), metrics);
398    }
399
400    /// Add coupling
401    pub fn add_coupling(&mut self, coupling: CouplingMetrics) {
402        self.couplings.push(coupling);
403    }
404
405    /// Register a type definition in the global registry
406    pub fn register_type(
407        &mut self,
408        type_name: String,
409        module_name: String,
410        visibility: Visibility,
411    ) {
412        self.type_registry
413            .insert(type_name, (module_name, visibility));
414    }
415
416    /// Look up visibility of a type by name
417    pub fn get_type_visibility(&self, type_name: &str) -> Option<Visibility> {
418        self.type_registry.get(type_name).map(|(_, vis)| *vis)
419    }
420
421    /// Look up the module where a type is defined
422    pub fn get_type_module(&self, type_name: &str) -> Option<&str> {
423        self.type_registry
424            .get(type_name)
425            .map(|(module, _)| module.as_str())
426    }
427
428    /// Update visibility information for existing couplings
429    ///
430    /// This should be called after all modules have been analyzed
431    /// to populate the target_visibility field of couplings.
432    pub fn update_coupling_visibility(&mut self) {
433        // First collect all the visibility lookups
434        let visibility_updates: Vec<(usize, Visibility)> = self
435            .couplings
436            .iter()
437            .enumerate()
438            .filter_map(|(idx, coupling)| {
439                let target_type = coupling
440                    .target
441                    .split("::")
442                    .last()
443                    .unwrap_or(&coupling.target);
444                self.type_registry
445                    .get(target_type)
446                    .map(|(_, vis)| (idx, *vis))
447            })
448            .collect();
449
450        // Then apply the updates
451        for (idx, visibility) in visibility_updates {
452            self.couplings[idx].target_visibility = visibility;
453        }
454    }
455
456    /// Get total module count
457    pub fn module_count(&self) -> usize {
458        self.modules.len()
459    }
460
461    /// Get total coupling count
462    pub fn coupling_count(&self) -> usize {
463        self.couplings.len()
464    }
465
466    /// Calculate average strength across all couplings
467    pub fn average_strength(&self) -> Option<f64> {
468        if self.couplings.is_empty() {
469            return None;
470        }
471        let sum: f64 = self.couplings.iter().map(|c| c.strength_value()).sum();
472        Some(sum / self.couplings.len() as f64)
473    }
474
475    /// Calculate average distance across all couplings
476    pub fn average_distance(&self) -> Option<f64> {
477        if self.couplings.is_empty() {
478            return None;
479        }
480        let sum: f64 = self.couplings.iter().map(|c| c.distance_value()).sum();
481        Some(sum / self.couplings.len() as f64)
482    }
483
484    /// Update volatility for all couplings based on file changes
485    ///
486    /// This should be called after git history analysis to update
487    /// the volatility of each coupling based on how often the target
488    /// module/file has changed.
489    pub fn update_volatility_from_git(&mut self) {
490        if self.file_changes.is_empty() {
491            return;
492        }
493
494        for coupling in &mut self.couplings {
495            // Try to find the target file in file_changes
496            // The target is like "crate::module" or "crate::submodule::item"
497            // We need to match this against file paths like "src/module.rs"
498
499            let target_module = coupling
500                .target
501                .split("::")
502                .last()
503                .unwrap_or(&coupling.target);
504
505            // Find the best matching file
506            let mut max_changes = 0usize;
507            for (file_path, &changes) in &self.file_changes {
508                // Check if file matches the target module
509                let file_name = file_path
510                    .rsplit('/')
511                    .next()
512                    .unwrap_or(file_path)
513                    .trim_end_matches(".rs");
514
515                if file_name == target_module || file_path.contains(target_module) {
516                    max_changes = max_changes.max(changes);
517                }
518            }
519
520            coupling.volatility = Volatility::from_count(max_changes);
521        }
522    }
523
524    /// Build a dependency graph from couplings
525    fn build_dependency_graph(&self) -> HashMap<String, HashSet<String>> {
526        let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
527
528        for coupling in &self.couplings {
529            // Only consider internal couplings (not external crates)
530            if coupling.distance == Distance::DifferentCrate {
531                continue;
532            }
533
534            // Extract module names (remove crate prefix for cleaner cycles)
535            let source = coupling.source.clone();
536            let target = coupling.target.clone();
537
538            graph.entry(source).or_default().insert(target);
539        }
540
541        graph
542    }
543
544    /// Detect circular dependencies in the project
545    ///
546    /// Returns a list of cycles, where each cycle is a list of module names
547    /// forming the circular dependency chain.
548    pub fn detect_circular_dependencies(&self) -> Vec<Vec<String>> {
549        let graph = self.build_dependency_graph();
550        let mut cycles: Vec<Vec<String>> = Vec::new();
551        let mut visited: HashSet<String> = HashSet::new();
552        let mut rec_stack: HashSet<String> = HashSet::new();
553
554        for node in graph.keys() {
555            if !visited.contains(node) {
556                let mut path = Vec::new();
557                self.dfs_find_cycles(
558                    node,
559                    &graph,
560                    &mut visited,
561                    &mut rec_stack,
562                    &mut path,
563                    &mut cycles,
564                );
565            }
566        }
567
568        // Deduplicate cycles (same cycle can be detected from different starting points)
569        let mut unique_cycles: Vec<Vec<String>> = Vec::new();
570        for cycle in cycles {
571            let normalized = Self::normalize_cycle(&cycle);
572            if !unique_cycles
573                .iter()
574                .any(|c| Self::normalize_cycle(c) == normalized)
575            {
576                unique_cycles.push(cycle);
577            }
578        }
579
580        unique_cycles
581    }
582
583    /// DFS helper for cycle detection
584    fn dfs_find_cycles(
585        &self,
586        node: &str,
587        graph: &HashMap<String, HashSet<String>>,
588        visited: &mut HashSet<String>,
589        rec_stack: &mut HashSet<String>,
590        path: &mut Vec<String>,
591        cycles: &mut Vec<Vec<String>>,
592    ) {
593        visited.insert(node.to_string());
594        rec_stack.insert(node.to_string());
595        path.push(node.to_string());
596
597        if let Some(neighbors) = graph.get(node) {
598            for neighbor in neighbors {
599                if !visited.contains(neighbor) {
600                    self.dfs_find_cycles(neighbor, graph, visited, rec_stack, path, cycles);
601                } else if rec_stack.contains(neighbor) {
602                    // Found a cycle - extract the cycle from path
603                    if let Some(start_idx) = path.iter().position(|n| n == neighbor) {
604                        let cycle: Vec<String> = path[start_idx..].to_vec();
605                        if cycle.len() >= 2 {
606                            cycles.push(cycle);
607                        }
608                    }
609                }
610            }
611        }
612
613        path.pop();
614        rec_stack.remove(node);
615    }
616
617    /// Normalize a cycle for deduplication
618    /// Rotates the cycle so the lexicographically smallest element is first
619    fn normalize_cycle(cycle: &[String]) -> Vec<String> {
620        if cycle.is_empty() {
621            return Vec::new();
622        }
623
624        // Find the position of the minimum element
625        let min_pos = cycle
626            .iter()
627            .enumerate()
628            .min_by_key(|(_, s)| s.as_str())
629            .map(|(i, _)| i)
630            .unwrap_or(0);
631
632        // Rotate the cycle
633        let mut normalized: Vec<String> = cycle[min_pos..].to_vec();
634        normalized.extend_from_slice(&cycle[..min_pos]);
635        normalized
636    }
637
638    /// Get circular dependency summary
639    pub fn circular_dependency_summary(&self) -> CircularDependencySummary {
640        let cycles = self.detect_circular_dependencies();
641        let affected_modules: HashSet<String> = cycles.iter().flatten().cloned().collect();
642
643        CircularDependencySummary {
644            total_cycles: cycles.len(),
645            affected_modules: affected_modules.len(),
646            cycles,
647        }
648    }
649}
650
651/// Summary of circular dependencies
652#[derive(Debug, Clone)]
653pub struct CircularDependencySummary {
654    /// Total number of circular dependency cycles
655    pub total_cycles: usize,
656    /// Number of modules involved in cycles
657    pub affected_modules: usize,
658    /// The actual cycles (list of module names)
659    pub cycles: Vec<Vec<String>>,
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn test_integration_strength_values() {
668        assert_eq!(IntegrationStrength::Intrusive.value(), 1.0);
669        assert_eq!(IntegrationStrength::Contract.value(), 0.25);
670    }
671
672    #[test]
673    fn test_distance_values() {
674        assert_eq!(Distance::SameFunction.value(), 0.0);
675        assert_eq!(Distance::DifferentCrate.value(), 1.0);
676    }
677
678    #[test]
679    fn test_volatility_from_count() {
680        assert_eq!(Volatility::from_count(0), Volatility::Low);
681        assert_eq!(Volatility::from_count(5), Volatility::Medium);
682        assert_eq!(Volatility::from_count(15), Volatility::High);
683    }
684
685    #[test]
686    fn test_module_metrics_average_strength() {
687        let mut metrics = ModuleMetrics::new(PathBuf::from("test.rs"), "test".to_string());
688        metrics.trait_impl_count = 3;
689        metrics.inherent_impl_count = 1;
690
691        let avg = metrics.average_strength();
692        assert!(avg > 0.0 && avg < 1.0);
693    }
694
695    #[test]
696    fn test_project_metrics() {
697        let mut project = ProjectMetrics::new();
698
699        let module = ModuleMetrics::new(PathBuf::from("lib.rs"), "lib".to_string());
700        project.add_module(module);
701
702        assert_eq!(project.module_count(), 1);
703        assert_eq!(project.coupling_count(), 0);
704    }
705
706    #[test]
707    fn test_circular_dependency_detection() {
708        let mut project = ProjectMetrics::new();
709
710        // Create a cycle: A -> B -> C -> A
711        project.add_coupling(CouplingMetrics::new(
712            "module_a".to_string(),
713            "module_b".to_string(),
714            IntegrationStrength::Model,
715            Distance::DifferentModule,
716            Volatility::Low,
717        ));
718        project.add_coupling(CouplingMetrics::new(
719            "module_b".to_string(),
720            "module_c".to_string(),
721            IntegrationStrength::Model,
722            Distance::DifferentModule,
723            Volatility::Low,
724        ));
725        project.add_coupling(CouplingMetrics::new(
726            "module_c".to_string(),
727            "module_a".to_string(),
728            IntegrationStrength::Model,
729            Distance::DifferentModule,
730            Volatility::Low,
731        ));
732
733        let cycles = project.detect_circular_dependencies();
734        assert_eq!(cycles.len(), 1);
735        assert_eq!(cycles[0].len(), 3);
736    }
737
738    #[test]
739    fn test_no_circular_dependencies() {
740        let mut project = ProjectMetrics::new();
741
742        // Linear dependency: A -> B -> C (no cycle)
743        project.add_coupling(CouplingMetrics::new(
744            "module_a".to_string(),
745            "module_b".to_string(),
746            IntegrationStrength::Model,
747            Distance::DifferentModule,
748            Volatility::Low,
749        ));
750        project.add_coupling(CouplingMetrics::new(
751            "module_b".to_string(),
752            "module_c".to_string(),
753            IntegrationStrength::Model,
754            Distance::DifferentModule,
755            Volatility::Low,
756        ));
757
758        let cycles = project.detect_circular_dependencies();
759        assert!(cycles.is_empty());
760    }
761
762    #[test]
763    fn test_external_crates_excluded_from_cycles() {
764        let mut project = ProjectMetrics::new();
765
766        // External crate dependency should be ignored
767        project.add_coupling(CouplingMetrics::new(
768            "module_a".to_string(),
769            "serde::Serialize".to_string(),
770            IntegrationStrength::Contract,
771            Distance::DifferentCrate, // External
772            Volatility::Low,
773        ));
774        project.add_coupling(CouplingMetrics::new(
775            "serde::Serialize".to_string(),
776            "module_a".to_string(),
777            IntegrationStrength::Contract,
778            Distance::DifferentCrate, // External
779            Volatility::Low,
780        ));
781
782        let cycles = project.detect_circular_dependencies();
783        assert!(cycles.is_empty());
784    }
785
786    #[test]
787    fn test_circular_dependency_summary() {
788        let mut project = ProjectMetrics::new();
789
790        // Create a simple cycle: A <-> B
791        project.add_coupling(CouplingMetrics::new(
792            "module_a".to_string(),
793            "module_b".to_string(),
794            IntegrationStrength::Functional,
795            Distance::DifferentModule,
796            Volatility::Low,
797        ));
798        project.add_coupling(CouplingMetrics::new(
799            "module_b".to_string(),
800            "module_a".to_string(),
801            IntegrationStrength::Functional,
802            Distance::DifferentModule,
803            Volatility::Low,
804        ));
805
806        let summary = project.circular_dependency_summary();
807        assert!(summary.total_cycles > 0);
808        assert!(summary.affected_modules >= 2);
809    }
810
811    #[test]
812    fn test_visibility_intrusive_detection() {
813        // Public items are never intrusive
814        assert!(!Visibility::Public.is_intrusive_from(true, false));
815        assert!(!Visibility::Public.is_intrusive_from(false, false));
816
817        // PubCrate is intrusive only from different crate
818        assert!(!Visibility::PubCrate.is_intrusive_from(true, false));
819        assert!(Visibility::PubCrate.is_intrusive_from(false, false));
820
821        // Private is always intrusive from outside
822        assert!(Visibility::Private.is_intrusive_from(true, false));
823        assert!(Visibility::Private.is_intrusive_from(false, false));
824
825        // Same module access is never intrusive
826        assert!(!Visibility::Private.is_intrusive_from(true, true));
827        assert!(!Visibility::Private.is_intrusive_from(false, true));
828    }
829
830    #[test]
831    fn test_visibility_penalty() {
832        assert_eq!(Visibility::Public.intrusive_penalty(), 0.0);
833        assert_eq!(Visibility::PubCrate.intrusive_penalty(), 0.25);
834        assert_eq!(Visibility::Private.intrusive_penalty(), 1.0);
835    }
836
837    #[test]
838    fn test_effective_strength() {
839        // Public target - no upgrade
840        let coupling = CouplingMetrics::with_visibility(
841            "source".to_string(),
842            "target".to_string(),
843            IntegrationStrength::Model,
844            Distance::DifferentModule,
845            Volatility::Low,
846            Visibility::Public,
847        );
848        assert_eq!(coupling.effective_strength(), IntegrationStrength::Model);
849
850        // Private target from different module - upgraded
851        let coupling = CouplingMetrics::with_visibility(
852            "source".to_string(),
853            "target".to_string(),
854            IntegrationStrength::Model,
855            Distance::DifferentModule,
856            Volatility::Low,
857            Visibility::Private,
858        );
859        assert_eq!(
860            coupling.effective_strength(),
861            IntegrationStrength::Functional
862        );
863    }
864
865    #[test]
866    fn test_type_registry() {
867        let mut project = ProjectMetrics::new();
868
869        project.register_type(
870            "MyStruct".to_string(),
871            "my_module".to_string(),
872            Visibility::Public,
873        );
874        project.register_type(
875            "InternalType".to_string(),
876            "my_module".to_string(),
877            Visibility::PubCrate,
878        );
879
880        assert_eq!(
881            project.get_type_visibility("MyStruct"),
882            Some(Visibility::Public)
883        );
884        assert_eq!(
885            project.get_type_visibility("InternalType"),
886            Some(Visibility::PubCrate)
887        );
888        assert_eq!(project.get_type_visibility("Unknown"), None);
889
890        assert_eq!(project.get_type_module("MyStruct"), Some("my_module"));
891    }
892
893    #[test]
894    fn test_module_type_definitions() {
895        let mut module = ModuleMetrics::new(PathBuf::from("test.rs"), "test".to_string());
896
897        module.add_type_definition("PublicStruct".to_string(), Visibility::Public, false);
898        module.add_type_definition("PrivateStruct".to_string(), Visibility::Private, false);
899        module.add_type_definition("PublicTrait".to_string(), Visibility::Public, true);
900
901        assert_eq!(module.public_type_count(), 2);
902        assert_eq!(module.private_type_count(), 1);
903        assert_eq!(
904            module.get_type_visibility("PublicStruct"),
905            Some(Visibility::Public)
906        );
907    }
908}