Skip to main content

cargo_coupling/
config.rs

1//! Configuration file support for cargo-coupling
2//!
3//! This module handles parsing and applying `.coupling.toml` configuration files
4//! that allow users to override volatility predictions and customize analysis.
5//!
6//! ## Configuration File Format
7//!
8//! ```toml
9//! # .coupling.toml
10//!
11//! [analysis]
12//! # Exclude test code (#[test], #[cfg(test)], mod tests) from analysis
13//! exclude_tests = true
14//!
15//! # "Prelude-like" modules that are expected to be used by many other modules.
16//! # These modules will not trigger "High Afferent Coupling" warnings.
17//! prelude_modules = ["src/lib.rs", "src/prelude.rs", "src/core/*"]
18//!
19//! # Modules to completely exclude from analysis
20//! exclude = ["src/generated/*", "src/test_utils/*"]
21//!
22//! [volatility]
23//! # Modules expected to change frequently (High volatility)
24//! high = ["src/business_rules/*", "src/pricing/*"]
25//!
26//! # Stable modules (Low volatility)
27//! low = ["src/core/*", "src/contracts/*"]
28//!
29//! # Paths to ignore from analysis (deprecated: use [analysis].exclude instead)
30//! ignore = ["src/generated/*", "tests/*"]
31//!
32//! [subdomains]
33//! # DDD subdomain classification (Khononov's Balanced Coupling model)
34//! # Volatility is derived from business domain, not just git history.
35//! # Explicit [volatility] overrides take priority over subdomain classification.
36//! core = ["src/analyzer.rs", "src/balance.rs", "src/metrics.rs"]
37//! supporting = ["src/report.rs", "src/cli_output.rs"]
38//! generic = ["src/web/*", "src/config.rs"]
39//!
40//! [thresholds]
41//! # Maximum dependencies before flagging High Efferent Coupling
42//! max_dependencies = 15
43//!
44//! # Maximum dependents before flagging High Afferent Coupling
45//! max_dependents = 20
46//! ```
47
48use glob::Pattern;
49use serde::Deserialize;
50use std::collections::HashMap;
51use std::fs;
52use std::path::{Component, Path, PathBuf};
53use thiserror::Error;
54
55use crate::metrics::dimensions::MetricsConfig;
56pub use crate::metrics::dimensions::Subdomain;
57use crate::volatility::Volatility;
58
59/// Errors that can occur when loading configuration
60#[derive(Error, Debug)]
61pub enum ConfigError {
62    #[error("Failed to read config file: {0}")]
63    IoError(#[from] std::io::Error),
64
65    #[error("Failed to parse config file: {0}")]
66    ParseError(#[from] toml::de::Error),
67
68    #[error("Invalid glob pattern: {0}")]
69    PatternError(String),
70}
71
72/// Analysis configuration section
73#[derive(Debug, Clone, Deserialize, Default)]
74pub struct AnalysisConfig {
75    /// Exclude test code from analysis (#[test], #[cfg(test)], mod tests)
76    #[serde(default)]
77    pub exclude_tests: bool,
78
79    /// "Prelude-like" modules that are expected to be depended on by many modules.
80    /// These modules will not trigger "High Afferent Coupling" warnings.
81    #[serde(default)]
82    pub prelude_modules: Vec<String>,
83
84    /// Modules to completely exclude from analysis
85    #[serde(default)]
86    pub exclude: Vec<String>,
87}
88
89/// Volatility configuration section
90#[derive(Debug, Clone, Deserialize, Default)]
91pub struct VolatilityConfig {
92    /// Paths that should be considered high volatility
93    #[serde(default)]
94    pub high: Vec<String>,
95
96    /// Paths that should be considered medium volatility
97    #[serde(default)]
98    pub medium: Vec<String>,
99
100    /// Paths that should be considered low volatility
101    #[serde(default)]
102    pub low: Vec<String>,
103
104    /// Paths to ignore from analysis
105    #[serde(default)]
106    pub ignore: Vec<String>,
107}
108
109/// DDD subdomain classification for volatility assessment
110///
111/// Based on Khononov's Balanced Coupling model: volatility should be determined
112/// by business domain classification, not just git history.
113/// - Core subdomains = High volatility (competitive advantage, constantly optimized)
114/// - Supporting subdomains = Low volatility (boring CRUD/ETL, rarely changes)
115/// - Generic subdomains = Low volatility (solved problems, stable implementations)
116#[derive(Debug, Clone, Deserialize, Default)]
117pub struct SubdomainConfig {
118    /// Core subdomain modules (high volatility - competitive advantage)
119    #[serde(default)]
120    pub core: Vec<String>,
121
122    /// Supporting subdomain modules (low volatility - stable business logic)
123    #[serde(default)]
124    pub supporting: Vec<String>,
125
126    /// Generic subdomain modules (low volatility - solved problems)
127    #[serde(default)]
128    pub generic: Vec<String>,
129}
130
131/// Threshold configuration section
132#[derive(Debug, Clone, Deserialize)]
133pub struct ThresholdsConfig {
134    /// Maximum dependencies before flagging High Efferent Coupling
135    #[serde(default = "default_max_dependencies")]
136    pub max_dependencies: usize,
137
138    /// Maximum dependents before flagging High Afferent Coupling
139    #[serde(default = "default_max_dependents")]
140    pub max_dependents: usize,
141}
142
143fn default_max_dependencies() -> usize {
144    15
145}
146
147fn default_max_dependents() -> usize {
148    20
149}
150
151impl Default for ThresholdsConfig {
152    fn default() -> Self {
153        Self {
154            max_dependencies: default_max_dependencies(),
155            max_dependents: default_max_dependents(),
156        }
157    }
158}
159
160/// Root configuration structure
161#[derive(Debug, Clone, Deserialize, Default)]
162pub struct CouplingConfig {
163    /// Analysis configuration (test exclusion, prelude modules, etc.)
164    #[serde(default)]
165    pub analysis: AnalysisConfig,
166
167    /// Volatility override configuration
168    #[serde(default)]
169    pub volatility: VolatilityConfig,
170
171    /// DDD subdomain classification (affects volatility assessment)
172    #[serde(default)]
173    pub subdomains: SubdomainConfig,
174
175    /// Threshold configuration
176    #[serde(default)]
177    pub thresholds: ThresholdsConfig,
178}
179
180/// Compiled configuration with glob patterns
181#[derive(Debug, Clone)]
182pub struct CompiledConfig {
183    // === Analysis settings ===
184    /// Whether to exclude test code from analysis
185    pub exclude_tests: bool,
186    /// Directory containing the loaded config file, if any.
187    config_root: Option<PathBuf>,
188    /// Patterns for prelude-like modules (exempt from afferent coupling warnings)
189    prelude_patterns: Vec<Pattern>,
190    /// Patterns for modules to completely exclude from analysis
191    exclude_patterns: Vec<Pattern>,
192
193    // === Volatility settings ===
194    /// Patterns for high volatility paths
195    high_patterns: Vec<Pattern>,
196    /// Patterns for medium volatility paths
197    medium_patterns: Vec<Pattern>,
198    /// Patterns for low volatility paths
199    low_patterns: Vec<Pattern>,
200    /// Patterns for ignored paths (deprecated, use exclude_patterns)
201    ignore_patterns: Vec<Pattern>,
202
203    // === Subdomain settings ===
204    /// Patterns for core subdomain (high volatility)
205    core_patterns: Vec<Pattern>,
206    /// Patterns for supporting subdomain (low volatility)
207    supporting_patterns: Vec<Pattern>,
208    /// Patterns for generic subdomain (low volatility)
209    generic_patterns: Vec<Pattern>,
210
211    // === Thresholds ===
212    /// Threshold configuration
213    pub thresholds: ThresholdsConfig,
214
215    // === Cache ===
216    /// Cache of path -> volatility mappings
217    cache: HashMap<String, Option<Volatility>>,
218}
219
220/// A configured pattern that matched no paths during analysis.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct DeadConfigPattern {
223    /// Config section containing the pattern.
224    pub section: &'static str,
225    /// Original pattern string from the config.
226    pub pattern: String,
227}
228
229impl CompiledConfig {
230    /// Create a compiled config from raw config
231    pub fn from_config(config: CouplingConfig) -> Result<Self, ConfigError> {
232        Self::from_config_with_root(config, None)
233    }
234
235    /// Create a compiled config from raw config and the directory containing that config file.
236    fn from_config_with_root(
237        config: CouplingConfig,
238        config_root: Option<&Path>,
239    ) -> Result<Self, ConfigError> {
240        let compile_patterns = |patterns: &[String]| -> Result<Vec<Pattern>, ConfigError> {
241            patterns
242                .iter()
243                .map(|pattern| {
244                    Pattern::new(pattern)
245                        .map_err(|err| ConfigError::PatternError(format!("{}: {}", pattern, err)))
246                })
247                .collect()
248        };
249
250        Ok(Self {
251            // Analysis settings
252            exclude_tests: config.analysis.exclude_tests,
253            config_root: config_root.map(Path::to_path_buf),
254            prelude_patterns: compile_patterns(&config.analysis.prelude_modules)?,
255            exclude_patterns: compile_patterns(&config.analysis.exclude)?,
256            // Volatility settings
257            high_patterns: compile_patterns(&config.volatility.high)?,
258            medium_patterns: compile_patterns(&config.volatility.medium)?,
259            low_patterns: compile_patterns(&config.volatility.low)?,
260            ignore_patterns: compile_patterns(&config.volatility.ignore)?,
261            // Subdomain settings
262            core_patterns: compile_patterns(&config.subdomains.core)?,
263            supporting_patterns: compile_patterns(&config.subdomains.supporting)?,
264            generic_patterns: compile_patterns(&config.subdomains.generic)?,
265            // Thresholds
266            thresholds: config.thresholds,
267            cache: HashMap::new(),
268        })
269    }
270
271    /// Create an empty config (no overrides)
272    pub fn empty() -> Self {
273        Self {
274            exclude_tests: false,
275            config_root: None,
276            prelude_patterns: Vec::new(),
277            exclude_patterns: Vec::new(),
278            high_patterns: Vec::new(),
279            medium_patterns: Vec::new(),
280            low_patterns: Vec::new(),
281            ignore_patterns: Vec::new(),
282            core_patterns: Vec::new(),
283            supporting_patterns: Vec::new(),
284            generic_patterns: Vec::new(),
285            thresholds: ThresholdsConfig::default(),
286            cache: HashMap::new(),
287        }
288    }
289
290    /// Set exclude_tests flag (used by CLI --exclude-tests option)
291    pub fn set_exclude_tests(&mut self, exclude: bool) {
292        self.exclude_tests = exclude;
293    }
294
295    /// Get the directory the config was loaded from, if known.
296    pub fn config_root(&self) -> Option<&Path> {
297        self.config_root.as_deref()
298    }
299
300    /// Update the config root when analyzing the same config in a git worktree.
301    pub(crate) fn set_config_root(&mut self, config_root: Option<PathBuf>) {
302        self.config_root = config_root;
303    }
304
305    /// Check if a module is marked as "prelude-like" (exempt from afferent coupling warnings)
306    pub fn is_prelude_module(&self, path: &str) -> bool {
307        self.prelude_patterns.iter().any(|p| p.matches(path))
308    }
309
310    /// Check if a path should be completely excluded from analysis
311    pub fn should_exclude(&self, path: &str) -> bool {
312        self.exclude_patterns.iter().any(|p| p.matches(path))
313    }
314
315    /// Check if a path should be ignored (deprecated: use should_exclude)
316    pub fn should_ignore(&self, path: &str) -> bool {
317        self.ignore_patterns.iter().any(|p| p.matches(path))
318            || self.exclude_patterns.iter().any(|p| p.matches(path))
319    }
320
321    /// Scoring-affecting pattern strings (subdomains, volatility overrides) that
322    /// matched none of the given analyzed paths.
323    ///
324    /// A dead subdomain/volatility pattern silently reverts scoring to raw git churn,
325    /// so it is reported as drift. `analysis.exclude` and `prelude_modules` are NOT
326    /// reported: an unmatched exclude analyzes nothing wrongly (defensive future-proof
327    /// excludes are idiomatic), and a dead prelude exemption surfaces as visible
328    /// afferent warnings rather than silent misclassification.
329    pub fn dead_patterns(&self, candidate_paths: &[String]) -> Vec<DeadConfigPattern> {
330        let mut dead = Vec::new();
331        Self::add_dead_patterns(
332            &mut dead,
333            "subdomains.core",
334            &self.core_patterns,
335            candidate_paths,
336        );
337        Self::add_dead_patterns(
338            &mut dead,
339            "subdomains.supporting",
340            &self.supporting_patterns,
341            candidate_paths,
342        );
343        Self::add_dead_patterns(
344            &mut dead,
345            "subdomains.generic",
346            &self.generic_patterns,
347            candidate_paths,
348        );
349        Self::add_dead_patterns(
350            &mut dead,
351            "volatility.high",
352            &self.high_patterns,
353            candidate_paths,
354        );
355        Self::add_dead_patterns(
356            &mut dead,
357            "volatility.medium",
358            &self.medium_patterns,
359            candidate_paths,
360        );
361        Self::add_dead_patterns(
362            &mut dead,
363            "volatility.low",
364            &self.low_patterns,
365            candidate_paths,
366        );
367        dead
368    }
369
370    fn add_dead_patterns(
371        dead: &mut Vec<DeadConfigPattern>,
372        section: &'static str,
373        patterns: &[Pattern],
374        candidate_paths: &[String],
375    ) {
376        dead.extend(patterns.iter().filter_map(|pattern| {
377            if candidate_paths
378                .iter()
379                .any(|path| pattern.matches(path.as_str()))
380            {
381                None
382            } else {
383                Some(DeadConfigPattern {
384                    section,
385                    pattern: pattern.as_str().to_string(),
386                })
387            }
388        }));
389    }
390
391    /// Get the list of prelude module patterns (for reporting)
392    pub fn prelude_module_count(&self) -> usize {
393        self.prelude_patterns.len()
394    }
395
396    /// Get the DDD subdomain classification for a path, if any
397    pub fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
398        if self.core_patterns.iter().any(|p| p.matches(path)) {
399            Some(Subdomain::Core)
400        } else if self.supporting_patterns.iter().any(|p| p.matches(path)) {
401            Some(Subdomain::Supporting)
402        } else if self.generic_patterns.iter().any(|p| p.matches(path)) {
403            Some(Subdomain::Generic)
404        } else {
405            None
406        }
407    }
408
409    /// Check if config has any subdomain classifications
410    pub fn has_subdomain_config(&self) -> bool {
411        !self.core_patterns.is_empty()
412            || !self.supporting_patterns.is_empty()
413            || !self.generic_patterns.is_empty()
414    }
415
416    /// Get overridden volatility for a path, if any
417    ///
418    /// Priority: explicit volatility override > subdomain classification
419    pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
420        // Check cache first
421        if let Some(cached) = self.cache.get(path) {
422            return *cached;
423        }
424
425        // Check explicit volatility patterns first (highest priority)
426        let result = if self.high_patterns.iter().any(|p| p.matches(path)) {
427            Some(Volatility::High)
428        } else if self.medium_patterns.iter().any(|p| p.matches(path)) {
429            Some(Volatility::Medium)
430        } else if self.low_patterns.iter().any(|p| p.matches(path)) {
431            Some(Volatility::Low)
432        } else {
433            // Fall back to subdomain-based volatility
434            self.get_subdomain(path).map(|sd| sd.expected_volatility())
435        };
436
437        // Cache the result
438        self.cache.insert(path.to_string(), result);
439        result
440    }
441
442    /// Get volatility with override, falling back to git-based value
443    pub fn get_volatility(&mut self, path: &str, git_volatility: Volatility) -> Volatility {
444        self.get_volatility_override(path).unwrap_or(git_volatility)
445    }
446
447    /// Check if config has any volatility overrides
448    pub fn has_volatility_overrides(&self) -> bool {
449        !self.high_patterns.is_empty()
450            || !self.medium_patterns.is_empty()
451            || !self.low_patterns.is_empty()
452    }
453}
454
455impl MetricsConfig for CompiledConfig {
456    fn config_root(&self) -> Option<&Path> {
457        CompiledConfig::config_root(self)
458    }
459
460    fn has_volatility_overrides(&self) -> bool {
461        CompiledConfig::has_volatility_overrides(self)
462    }
463
464    fn has_subdomain_config(&self) -> bool {
465        CompiledConfig::has_subdomain_config(self)
466    }
467
468    fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
469        CompiledConfig::get_subdomain(self, path)
470    }
471
472    fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
473        CompiledConfig::get_volatility_override(self, path)
474    }
475}
476
477/// Load configuration from the project directory
478///
479/// Searches for `.coupling.toml` in the given directory and parent directories.
480pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
481    // Search for config file
482    let config_path = find_config_file(project_path);
483
484    match config_path {
485        Some(path) => {
486            let content = fs::read_to_string(&path)?;
487            let config: CouplingConfig = toml::from_str(&content)?;
488            Ok(config)
489        }
490        None => Ok(CouplingConfig::default()),
491    }
492}
493
494/// Find the config file by searching up the directory tree
495fn find_config_file(start_path: &Path) -> Option<std::path::PathBuf> {
496    let config_names = [".coupling.toml", "coupling.toml"];
497
498    let mut current = if start_path.is_file() {
499        start_path.parent()?.to_path_buf()
500    } else {
501        start_path.to_path_buf()
502    };
503
504    loop {
505        for name in &config_names {
506            let config_path = current.join(name);
507            if config_path.exists() {
508                return Some(config_path);
509            }
510        }
511
512        // Move to parent directory
513        if let Some(parent) = current.parent() {
514            current = parent.to_path_buf();
515        } else {
516            break;
517        }
518    }
519
520    None
521}
522
523/// Load and compile configuration
524pub fn load_compiled_config(project_path: &Path) -> Result<CompiledConfig, ConfigError> {
525    match find_config_file(project_path) {
526        Some(path) => {
527            let content = fs::read_to_string(&path)?;
528            let config: CouplingConfig = toml::from_str(&content)?;
529            let absolute_path = absolute_normalized_path(&path)?;
530            CompiledConfig::from_config_with_root(config, absolute_path.parent())
531        }
532        None => Ok(CompiledConfig::empty()),
533    }
534}
535
536fn absolute_normalized_path(path: &Path) -> Result<PathBuf, std::io::Error> {
537    let absolute = if path.is_absolute() {
538        path.to_path_buf()
539    } else {
540        std::env::current_dir()?.join(path)
541    };
542
543    let mut normalized = PathBuf::new();
544    for component in absolute.components() {
545        match component {
546            Component::CurDir => {}
547            Component::ParentDir => {
548                normalized.pop();
549            }
550            other => normalized.push(other.as_os_str()),
551        }
552    }
553
554    Ok(normalized)
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn test_default_config() {
563        let config = CouplingConfig::default();
564        assert!(config.volatility.high.is_empty());
565        assert!(config.volatility.low.is_empty());
566        assert_eq!(config.thresholds.max_dependencies, 15);
567        assert_eq!(config.thresholds.max_dependents, 20);
568    }
569
570    #[test]
571    fn test_parse_config() {
572        let toml = r#"
573            [volatility]
574            high = ["src/api/*", "src/handlers/*"]
575            low = ["src/core/*"]
576            ignore = ["tests/*"]
577
578            [thresholds]
579            max_dependencies = 20
580            max_dependents = 30
581        "#;
582
583        let config: CouplingConfig = toml::from_str(toml).unwrap();
584        assert_eq!(config.volatility.high.len(), 2);
585        assert_eq!(config.volatility.low.len(), 1);
586        assert_eq!(config.volatility.ignore.len(), 1);
587        assert_eq!(config.thresholds.max_dependencies, 20);
588        assert_eq!(config.thresholds.max_dependents, 30);
589    }
590
591    #[test]
592    fn test_compiled_config() {
593        let toml = r#"
594            [volatility]
595            high = ["src/business/*"]
596            low = ["src/core/*"]
597        "#;
598
599        let config: CouplingConfig = toml::from_str(toml).unwrap();
600        let mut compiled = CompiledConfig::from_config(config).unwrap();
601
602        assert_eq!(
603            compiled.get_volatility_override("src/business/pricing.rs"),
604            Some(Volatility::High)
605        );
606        assert_eq!(
607            compiled.get_volatility_override("src/core/types.rs"),
608            Some(Volatility::Low)
609        );
610        assert_eq!(compiled.get_volatility_override("src/other/file.rs"), None);
611    }
612
613    #[test]
614    fn test_ignore_patterns() {
615        let toml = r#"
616            [volatility]
617            ignore = ["tests/*", "benches/*"]
618        "#;
619
620        let config: CouplingConfig = toml::from_str(toml).unwrap();
621        let compiled = CompiledConfig::from_config(config).unwrap();
622
623        assert!(compiled.should_ignore("tests/integration.rs"));
624        assert!(compiled.should_ignore("benches/perf.rs"));
625        assert!(!compiled.should_ignore("src/lib.rs"));
626    }
627
628    #[test]
629    fn test_get_volatility_with_fallback() {
630        let toml = r#"
631            [volatility]
632            high = ["src/api/*"]
633        "#;
634
635        let config: CouplingConfig = toml::from_str(toml).unwrap();
636        let mut compiled = CompiledConfig::from_config(config).unwrap();
637
638        // Override wins
639        assert_eq!(
640            compiled.get_volatility("src/api/handler.rs", Volatility::Low),
641            Volatility::High
642        );
643
644        // Fallback to git volatility
645        assert_eq!(
646            compiled.get_volatility("src/other/file.rs", Volatility::Medium),
647            Volatility::Medium
648        );
649    }
650
651    #[test]
652    fn test_subdomain_config() {
653        let toml = r#"
654            [subdomains]
655            core = ["src/analyzer.rs", "src/balance.rs"]
656            supporting = ["src/report.rs", "src/cli_output.rs"]
657            generic = ["src/web/*", "src/config.rs"]
658        "#;
659
660        let config: CouplingConfig = toml::from_str(toml).unwrap();
661        let mut compiled = CompiledConfig::from_config(config).unwrap();
662
663        // Core → High volatility
664        assert_eq!(
665            compiled.get_subdomain("src/analyzer.rs"),
666            Some(Subdomain::Core)
667        );
668        assert_eq!(
669            compiled.get_volatility_override("src/analyzer.rs"),
670            Some(Volatility::High)
671        );
672
673        // Supporting → Low volatility
674        assert_eq!(
675            compiled.get_subdomain("src/report.rs"),
676            Some(Subdomain::Supporting)
677        );
678        assert_eq!(
679            compiled.get_volatility_override("src/report.rs"),
680            Some(Volatility::Low)
681        );
682
683        // Generic → Low volatility
684        assert_eq!(
685            compiled.get_subdomain("src/web/server.rs"),
686            Some(Subdomain::Generic)
687        );
688        assert_eq!(
689            compiled.get_volatility_override("src/web/server.rs"),
690            Some(Volatility::Low)
691        );
692
693        // Unclassified → None
694        assert_eq!(compiled.get_subdomain("src/other.rs"), None);
695        assert_eq!(compiled.get_volatility_override("src/other.rs"), None);
696    }
697
698    #[test]
699    fn test_subdomain_display() {
700        assert_eq!(format!("{}", Subdomain::Core), "Core");
701        assert_eq!(format!("{}", Subdomain::Supporting), "Supporting");
702        assert_eq!(format!("{}", Subdomain::Generic), "Generic");
703    }
704
705    #[test]
706    fn test_subdomain_expected_volatility() {
707        assert_eq!(Subdomain::Core.expected_volatility(), Volatility::High);
708        assert_eq!(Subdomain::Supporting.expected_volatility(), Volatility::Low);
709        assert_eq!(Subdomain::Generic.expected_volatility(), Volatility::Low);
710    }
711
712    #[test]
713    fn test_has_subdomain_config() {
714        // Empty config → no subdomain config
715        let compiled = CompiledConfig::empty();
716        assert!(!compiled.has_subdomain_config());
717
718        // With core patterns → has subdomain config
719        let toml = r#"
720            [subdomains]
721            core = ["src/analyzer.rs"]
722        "#;
723        let config: CouplingConfig = toml::from_str(toml).unwrap();
724        let compiled = CompiledConfig::from_config(config).unwrap();
725        assert!(compiled.has_subdomain_config());
726
727        // With only supporting patterns → has subdomain config
728        let toml = r#"
729            [subdomains]
730            supporting = ["src/report.rs"]
731        "#;
732        let config: CouplingConfig = toml::from_str(toml).unwrap();
733        let compiled = CompiledConfig::from_config(config).unwrap();
734        assert!(compiled.has_subdomain_config());
735
736        // With only generic patterns → has subdomain config
737        let toml = r#"
738            [subdomains]
739            generic = ["src/web/*"]
740        "#;
741        let config: CouplingConfig = toml::from_str(toml).unwrap();
742        let compiled = CompiledConfig::from_config(config).unwrap();
743        assert!(compiled.has_subdomain_config());
744    }
745
746    #[test]
747    fn test_has_volatility_overrides() {
748        // Empty config → no overrides
749        let compiled = CompiledConfig::empty();
750        assert!(!compiled.has_volatility_overrides());
751
752        // With high volatility → has overrides
753        let toml = r#"
754            [volatility]
755            high = ["src/core.rs"]
756        "#;
757        let config: CouplingConfig = toml::from_str(toml).unwrap();
758        let compiled = CompiledConfig::from_config(config).unwrap();
759        assert!(compiled.has_volatility_overrides());
760
761        // With medium volatility → has overrides
762        let toml = r#"
763            [volatility]
764            medium = ["src/mid.rs"]
765        "#;
766        let config: CouplingConfig = toml::from_str(toml).unwrap();
767        let compiled = CompiledConfig::from_config(config).unwrap();
768        assert!(compiled.has_volatility_overrides());
769
770        // With low volatility → has overrides
771        let toml = r#"
772            [volatility]
773            low = ["src/stable.rs"]
774        "#;
775        let config: CouplingConfig = toml::from_str(toml).unwrap();
776        let compiled = CompiledConfig::from_config(config).unwrap();
777        assert!(compiled.has_volatility_overrides());
778    }
779
780    #[test]
781    fn test_volatility_override_beats_subdomain() {
782        let toml = r#"
783            [volatility]
784            low = ["src/analyzer.rs"]
785
786            [subdomains]
787            core = ["src/analyzer.rs"]
788        "#;
789
790        let config: CouplingConfig = toml::from_str(toml).unwrap();
791        let mut compiled = CompiledConfig::from_config(config).unwrap();
792
793        // Explicit volatility override wins over subdomain classification
794        assert_eq!(
795            compiled.get_volatility_override("src/analyzer.rs"),
796            Some(Volatility::Low)
797        );
798    }
799
800    #[test]
801    fn dead_patterns_reports_only_unmatched_patterns_by_section() {
802        let toml = r#"
803            [analysis]
804            prelude_modules = ["src/lib.rs", "src/missing_prelude.rs"]
805            exclude = ["src/never_matches/**"]
806
807            [volatility]
808            high = ["src/core/**"]
809            medium = ["src/medium.rs"]
810            low = ["src/stable.rs"]
811
812            [subdomains]
813            core = ["src/core/**", "src/old_core.rs"]
814            supporting = ["src/supporting.rs"]
815            generic = ["src/generic.rs"]
816        "#;
817
818        let config: CouplingConfig = toml::from_str(toml).unwrap();
819        let compiled = CompiledConfig::from_config(config).unwrap();
820        let candidate_paths = vec![
821            "src/lib.rs".to_string(),
822            "src/core/mod.rs".to_string(),
823            "src/supporting.rs".to_string(),
824            "src/stable.rs".to_string(),
825        ];
826
827        let dead = compiled.dead_patterns(&candidate_paths);
828
829        assert_eq!(
830            dead,
831            vec![
832                DeadConfigPattern {
833                    section: "subdomains.core",
834                    pattern: "src/old_core.rs".to_string(),
835                },
836                DeadConfigPattern {
837                    section: "subdomains.generic",
838                    pattern: "src/generic.rs".to_string(),
839                },
840                DeadConfigPattern {
841                    section: "volatility.medium",
842                    pattern: "src/medium.rs".to_string(),
843                },
844            ]
845        );
846    }
847
848    #[test]
849    fn dead_patterns_never_reports_exclude_or_prelude() {
850        // A dead exclude analyzes nothing wrongly (defensive future-proof excludes are
851        // idiomatic) and a dead prelude exemption surfaces as visible warnings, so
852        // neither is drift.
853        let toml = r#"
854            [analysis]
855            prelude_modules = ["src/missing_prelude.rs"]
856            exclude = ["src/never_matches/**"]
857        "#;
858
859        let config: CouplingConfig = toml::from_str(toml).unwrap();
860        let compiled = CompiledConfig::from_config(config).unwrap();
861
862        assert!(
863            compiled
864                .dead_patterns(&["src/lib.rs".to_string()])
865                .is_empty()
866        );
867    }
868
869    #[test]
870    fn empty_config_has_no_dead_patterns() {
871        let candidate_paths = vec!["src/lib.rs".to_string()];
872        assert!(
873            CompiledConfig::empty()
874                .dead_patterns(&candidate_paths)
875                .is_empty()
876        );
877    }
878}