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, Serialize};
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, Serialize, 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, Serialize, 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, Serialize, 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, Serialize)]
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, Serialize, 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    /// Effective settings after file loading and CLI overrides, independent of
231    /// cache state and the particular filename used to supply configuration.
232    pub fn effective_settings(&self) -> CouplingConfig {
233        let patterns = |items: &[Pattern]| {
234            items
235                .iter()
236                .map(|pattern| pattern.as_str().to_string())
237                .collect()
238        };
239        CouplingConfig {
240            analysis: AnalysisConfig {
241                exclude_tests: self.exclude_tests,
242                prelude_modules: patterns(&self.prelude_patterns),
243                exclude: patterns(&self.exclude_patterns),
244            },
245            volatility: VolatilityConfig {
246                high: patterns(&self.high_patterns),
247                medium: patterns(&self.medium_patterns),
248                low: patterns(&self.low_patterns),
249                ignore: patterns(&self.ignore_patterns),
250            },
251            subdomains: SubdomainConfig {
252                core: patterns(&self.core_patterns),
253                supporting: patterns(&self.supporting_patterns),
254                generic: patterns(&self.generic_patterns),
255            },
256            thresholds: self.thresholds.clone(),
257        }
258    }
259
260    /// Create a compiled config from raw config
261    pub fn from_config(config: CouplingConfig) -> Result<Self, ConfigError> {
262        Self::from_config_with_root(config, None)
263    }
264
265    /// Create a compiled config from raw config and the directory containing that config file.
266    fn from_config_with_root(
267        config: CouplingConfig,
268        config_root: Option<&Path>,
269    ) -> Result<Self, ConfigError> {
270        let compile_patterns = |patterns: &[String]| -> Result<Vec<Pattern>, ConfigError> {
271            patterns
272                .iter()
273                .map(|pattern| {
274                    Pattern::new(pattern)
275                        .map_err(|err| ConfigError::PatternError(format!("{}: {}", pattern, err)))
276                })
277                .collect()
278        };
279
280        Ok(Self {
281            // Analysis settings
282            exclude_tests: config.analysis.exclude_tests,
283            config_root: config_root.map(Path::to_path_buf),
284            prelude_patterns: compile_patterns(&config.analysis.prelude_modules)?,
285            exclude_patterns: compile_patterns(&config.analysis.exclude)?,
286            // Volatility settings
287            high_patterns: compile_patterns(&config.volatility.high)?,
288            medium_patterns: compile_patterns(&config.volatility.medium)?,
289            low_patterns: compile_patterns(&config.volatility.low)?,
290            ignore_patterns: compile_patterns(&config.volatility.ignore)?,
291            // Subdomain settings
292            core_patterns: compile_patterns(&config.subdomains.core)?,
293            supporting_patterns: compile_patterns(&config.subdomains.supporting)?,
294            generic_patterns: compile_patterns(&config.subdomains.generic)?,
295            // Thresholds
296            thresholds: config.thresholds,
297            cache: HashMap::new(),
298        })
299    }
300
301    /// Create an empty config (no overrides)
302    pub fn empty() -> Self {
303        Self {
304            exclude_tests: false,
305            config_root: None,
306            prelude_patterns: Vec::new(),
307            exclude_patterns: Vec::new(),
308            high_patterns: Vec::new(),
309            medium_patterns: Vec::new(),
310            low_patterns: Vec::new(),
311            ignore_patterns: Vec::new(),
312            core_patterns: Vec::new(),
313            supporting_patterns: Vec::new(),
314            generic_patterns: Vec::new(),
315            thresholds: ThresholdsConfig::default(),
316            cache: HashMap::new(),
317        }
318    }
319
320    /// Set exclude_tests flag (used by CLI --exclude-tests option)
321    pub fn set_exclude_tests(&mut self, exclude: bool) {
322        self.exclude_tests = exclude;
323    }
324
325    /// Get the directory the config was loaded from, if known.
326    pub fn config_root(&self) -> Option<&Path> {
327        self.config_root.as_deref()
328    }
329
330    /// Update the config root when analyzing the same config in a git worktree.
331    pub(crate) fn set_config_root(&mut self, config_root: Option<PathBuf>) {
332        self.config_root = config_root;
333    }
334
335    /// Check if a module is marked as "prelude-like" (exempt from afferent coupling warnings)
336    pub fn is_prelude_module(&self, path: &str) -> bool {
337        self.prelude_patterns.iter().any(|p| p.matches(path))
338    }
339
340    /// Check if a path should be completely excluded from analysis
341    pub fn should_exclude(&self, path: &str) -> bool {
342        self.exclude_patterns.iter().any(|p| p.matches(path))
343    }
344
345    /// Check if a path should be ignored (deprecated: use should_exclude)
346    pub fn should_ignore(&self, path: &str) -> bool {
347        self.ignore_patterns.iter().any(|p| p.matches(path))
348            || self.exclude_patterns.iter().any(|p| p.matches(path))
349    }
350
351    /// Scoring-affecting pattern strings (subdomains, volatility overrides) that
352    /// matched none of the given analyzed paths.
353    ///
354    /// A dead subdomain/volatility pattern silently reverts scoring to raw git churn,
355    /// so it is reported as drift. `analysis.exclude` and `prelude_modules` are NOT
356    /// reported: an unmatched exclude analyzes nothing wrongly (defensive future-proof
357    /// excludes are idiomatic), and a dead prelude exemption surfaces as visible
358    /// afferent warnings rather than silent misclassification.
359    pub fn dead_patterns(&self, candidate_paths: &[String]) -> Vec<DeadConfigPattern> {
360        let mut dead = Vec::new();
361        Self::add_dead_patterns(
362            &mut dead,
363            "subdomains.core",
364            &self.core_patterns,
365            candidate_paths,
366        );
367        Self::add_dead_patterns(
368            &mut dead,
369            "subdomains.supporting",
370            &self.supporting_patterns,
371            candidate_paths,
372        );
373        Self::add_dead_patterns(
374            &mut dead,
375            "subdomains.generic",
376            &self.generic_patterns,
377            candidate_paths,
378        );
379        Self::add_dead_patterns(
380            &mut dead,
381            "volatility.high",
382            &self.high_patterns,
383            candidate_paths,
384        );
385        Self::add_dead_patterns(
386            &mut dead,
387            "volatility.medium",
388            &self.medium_patterns,
389            candidate_paths,
390        );
391        Self::add_dead_patterns(
392            &mut dead,
393            "volatility.low",
394            &self.low_patterns,
395            candidate_paths,
396        );
397        dead
398    }
399
400    fn add_dead_patterns(
401        dead: &mut Vec<DeadConfigPattern>,
402        section: &'static str,
403        patterns: &[Pattern],
404        candidate_paths: &[String],
405    ) {
406        dead.extend(patterns.iter().filter_map(|pattern| {
407            if candidate_paths
408                .iter()
409                .any(|path| pattern.matches(path.as_str()))
410            {
411                None
412            } else {
413                Some(DeadConfigPattern {
414                    section,
415                    pattern: pattern.as_str().to_string(),
416                })
417            }
418        }));
419    }
420
421    /// Get the list of prelude module patterns (for reporting)
422    pub fn prelude_module_count(&self) -> usize {
423        self.prelude_patterns.len()
424    }
425
426    /// Get the DDD subdomain classification for a path, if any
427    pub fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
428        if self.core_patterns.iter().any(|p| p.matches(path)) {
429            Some(Subdomain::Core)
430        } else if self.supporting_patterns.iter().any(|p| p.matches(path)) {
431            Some(Subdomain::Supporting)
432        } else if self.generic_patterns.iter().any(|p| p.matches(path)) {
433            Some(Subdomain::Generic)
434        } else {
435            None
436        }
437    }
438
439    /// Check if config has any subdomain classifications
440    pub fn has_subdomain_config(&self) -> bool {
441        !self.core_patterns.is_empty()
442            || !self.supporting_patterns.is_empty()
443            || !self.generic_patterns.is_empty()
444    }
445
446    /// Get overridden volatility for a path, if any
447    ///
448    /// Priority: explicit volatility override > subdomain classification
449    pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
450        // Check cache first
451        if let Some(cached) = self.cache.get(path) {
452            return *cached;
453        }
454
455        // Check explicit volatility patterns first (highest priority)
456        let result = if self.high_patterns.iter().any(|p| p.matches(path)) {
457            Some(Volatility::High)
458        } else if self.medium_patterns.iter().any(|p| p.matches(path)) {
459            Some(Volatility::Medium)
460        } else if self.low_patterns.iter().any(|p| p.matches(path)) {
461            Some(Volatility::Low)
462        } else {
463            // Fall back to subdomain-based volatility
464            self.get_subdomain(path).map(|sd| sd.expected_volatility())
465        };
466
467        // Cache the result
468        self.cache.insert(path.to_string(), result);
469        result
470    }
471
472    /// Get volatility with override, falling back to git-based value
473    pub fn get_volatility(&mut self, path: &str, git_volatility: Volatility) -> Volatility {
474        self.get_volatility_override(path).unwrap_or(git_volatility)
475    }
476
477    /// Check if config has any volatility overrides
478    pub fn has_volatility_overrides(&self) -> bool {
479        !self.high_patterns.is_empty()
480            || !self.medium_patterns.is_empty()
481            || !self.low_patterns.is_empty()
482    }
483}
484
485impl MetricsConfig for CompiledConfig {
486    fn config_root(&self) -> Option<&Path> {
487        CompiledConfig::config_root(self)
488    }
489
490    fn has_volatility_overrides(&self) -> bool {
491        CompiledConfig::has_volatility_overrides(self)
492    }
493
494    fn has_subdomain_config(&self) -> bool {
495        CompiledConfig::has_subdomain_config(self)
496    }
497
498    fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
499        CompiledConfig::get_subdomain(self, path)
500    }
501
502    fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
503        CompiledConfig::get_volatility_override(self, path)
504    }
505}
506
507/// Load configuration from the project directory
508///
509/// Searches for `.coupling.toml` in the given directory and parent directories.
510pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
511    // Search for config file
512    let config_path = find_config_file(project_path);
513
514    match config_path {
515        Some(path) => {
516            let content = fs::read_to_string(&path)?;
517            let config: CouplingConfig = toml::from_str(&content)?;
518            Ok(config)
519        }
520        None => Ok(CouplingConfig::default()),
521    }
522}
523
524/// Find the config file by searching up the directory tree
525fn find_config_file(start_path: &Path) -> Option<std::path::PathBuf> {
526    let config_names = [".coupling.toml", "coupling.toml"];
527
528    let mut current = if start_path.is_file() {
529        start_path.parent()?.to_path_buf()
530    } else {
531        start_path.to_path_buf()
532    };
533
534    loop {
535        for name in &config_names {
536            let config_path = current.join(name);
537            if config_path.exists() {
538                return Some(config_path);
539            }
540        }
541
542        // Move to parent directory
543        if let Some(parent) = current.parent() {
544            current = parent.to_path_buf();
545        } else {
546            break;
547        }
548    }
549
550    None
551}
552
553/// Load and compile configuration
554pub fn load_compiled_config(project_path: &Path) -> Result<CompiledConfig, ConfigError> {
555    match find_config_file(project_path) {
556        Some(path) => {
557            let content = fs::read_to_string(&path)?;
558            let config: CouplingConfig = toml::from_str(&content)?;
559            let absolute_path = absolute_normalized_path(&path)?;
560            CompiledConfig::from_config_with_root(config, absolute_path.parent())
561        }
562        None => Ok(CompiledConfig::empty()),
563    }
564}
565
566fn absolute_normalized_path(path: &Path) -> Result<PathBuf, std::io::Error> {
567    let absolute = if path.is_absolute() {
568        path.to_path_buf()
569    } else {
570        std::env::current_dir()?.join(path)
571    };
572
573    let mut normalized = PathBuf::new();
574    for component in absolute.components() {
575        match component {
576            Component::CurDir => {}
577            Component::ParentDir => {
578                normalized.pop();
579            }
580            other => normalized.push(other.as_os_str()),
581        }
582    }
583
584    Ok(normalized)
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_default_config() {
593        let config = CouplingConfig::default();
594        assert!(config.volatility.high.is_empty());
595        assert!(config.volatility.low.is_empty());
596        assert_eq!(config.thresholds.max_dependencies, 15);
597        assert_eq!(config.thresholds.max_dependents, 20);
598    }
599
600    #[test]
601    fn test_parse_config() {
602        let toml = r#"
603            [volatility]
604            high = ["src/api/*", "src/handlers/*"]
605            low = ["src/core/*"]
606            ignore = ["tests/*"]
607
608            [thresholds]
609            max_dependencies = 20
610            max_dependents = 30
611        "#;
612
613        let config: CouplingConfig = toml::from_str(toml).unwrap();
614        assert_eq!(config.volatility.high.len(), 2);
615        assert_eq!(config.volatility.low.len(), 1);
616        assert_eq!(config.volatility.ignore.len(), 1);
617        assert_eq!(config.thresholds.max_dependencies, 20);
618        assert_eq!(config.thresholds.max_dependents, 30);
619    }
620
621    #[test]
622    fn test_compiled_config() {
623        let toml = r#"
624            [volatility]
625            high = ["src/business/*"]
626            low = ["src/core/*"]
627        "#;
628
629        let config: CouplingConfig = toml::from_str(toml).unwrap();
630        let mut compiled = CompiledConfig::from_config(config).unwrap();
631
632        assert_eq!(
633            compiled.get_volatility_override("src/business/pricing.rs"),
634            Some(Volatility::High)
635        );
636        assert_eq!(
637            compiled.get_volatility_override("src/core/types.rs"),
638            Some(Volatility::Low)
639        );
640        assert_eq!(compiled.get_volatility_override("src/other/file.rs"), None);
641    }
642
643    #[test]
644    fn test_ignore_patterns() {
645        let toml = r#"
646            [volatility]
647            ignore = ["tests/*", "benches/*"]
648        "#;
649
650        let config: CouplingConfig = toml::from_str(toml).unwrap();
651        let compiled = CompiledConfig::from_config(config).unwrap();
652
653        assert!(compiled.should_ignore("tests/integration.rs"));
654        assert!(compiled.should_ignore("benches/perf.rs"));
655        assert!(!compiled.should_ignore("src/lib.rs"));
656    }
657
658    #[test]
659    fn test_get_volatility_with_fallback() {
660        let toml = r#"
661            [volatility]
662            high = ["src/api/*"]
663        "#;
664
665        let config: CouplingConfig = toml::from_str(toml).unwrap();
666        let mut compiled = CompiledConfig::from_config(config).unwrap();
667
668        // Override wins
669        assert_eq!(
670            compiled.get_volatility("src/api/handler.rs", Volatility::Low),
671            Volatility::High
672        );
673
674        // Fallback to git volatility
675        assert_eq!(
676            compiled.get_volatility("src/other/file.rs", Volatility::Medium),
677            Volatility::Medium
678        );
679    }
680
681    #[test]
682    fn test_subdomain_config() {
683        let toml = r#"
684            [subdomains]
685            core = ["src/analyzer.rs", "src/balance.rs"]
686            supporting = ["src/report.rs", "src/cli_output.rs"]
687            generic = ["src/web/*", "src/config.rs"]
688        "#;
689
690        let config: CouplingConfig = toml::from_str(toml).unwrap();
691        let mut compiled = CompiledConfig::from_config(config).unwrap();
692
693        // Core → High volatility
694        assert_eq!(
695            compiled.get_subdomain("src/analyzer.rs"),
696            Some(Subdomain::Core)
697        );
698        assert_eq!(
699            compiled.get_volatility_override("src/analyzer.rs"),
700            Some(Volatility::High)
701        );
702
703        // Supporting → Low volatility
704        assert_eq!(
705            compiled.get_subdomain("src/report.rs"),
706            Some(Subdomain::Supporting)
707        );
708        assert_eq!(
709            compiled.get_volatility_override("src/report.rs"),
710            Some(Volatility::Low)
711        );
712
713        // Generic → Low volatility
714        assert_eq!(
715            compiled.get_subdomain("src/web/server.rs"),
716            Some(Subdomain::Generic)
717        );
718        assert_eq!(
719            compiled.get_volatility_override("src/web/server.rs"),
720            Some(Volatility::Low)
721        );
722
723        // Unclassified → None
724        assert_eq!(compiled.get_subdomain("src/other.rs"), None);
725        assert_eq!(compiled.get_volatility_override("src/other.rs"), None);
726    }
727
728    #[test]
729    fn test_subdomain_display() {
730        assert_eq!(format!("{}", Subdomain::Core), "Core");
731        assert_eq!(format!("{}", Subdomain::Supporting), "Supporting");
732        assert_eq!(format!("{}", Subdomain::Generic), "Generic");
733    }
734
735    #[test]
736    fn test_subdomain_expected_volatility() {
737        assert_eq!(Subdomain::Core.expected_volatility(), Volatility::High);
738        assert_eq!(Subdomain::Supporting.expected_volatility(), Volatility::Low);
739        assert_eq!(Subdomain::Generic.expected_volatility(), Volatility::Low);
740    }
741
742    #[test]
743    fn test_has_subdomain_config() {
744        // Empty config → no subdomain config
745        let compiled = CompiledConfig::empty();
746        assert!(!compiled.has_subdomain_config());
747
748        // With core patterns → has subdomain config
749        let toml = r#"
750            [subdomains]
751            core = ["src/analyzer.rs"]
752        "#;
753        let config: CouplingConfig = toml::from_str(toml).unwrap();
754        let compiled = CompiledConfig::from_config(config).unwrap();
755        assert!(compiled.has_subdomain_config());
756
757        // With only supporting patterns → has subdomain config
758        let toml = r#"
759            [subdomains]
760            supporting = ["src/report.rs"]
761        "#;
762        let config: CouplingConfig = toml::from_str(toml).unwrap();
763        let compiled = CompiledConfig::from_config(config).unwrap();
764        assert!(compiled.has_subdomain_config());
765
766        // With only generic patterns → has subdomain config
767        let toml = r#"
768            [subdomains]
769            generic = ["src/web/*"]
770        "#;
771        let config: CouplingConfig = toml::from_str(toml).unwrap();
772        let compiled = CompiledConfig::from_config(config).unwrap();
773        assert!(compiled.has_subdomain_config());
774    }
775
776    #[test]
777    fn test_has_volatility_overrides() {
778        // Empty config → no overrides
779        let compiled = CompiledConfig::empty();
780        assert!(!compiled.has_volatility_overrides());
781
782        // With high volatility → has overrides
783        let toml = r#"
784            [volatility]
785            high = ["src/core.rs"]
786        "#;
787        let config: CouplingConfig = toml::from_str(toml).unwrap();
788        let compiled = CompiledConfig::from_config(config).unwrap();
789        assert!(compiled.has_volatility_overrides());
790
791        // With medium volatility → has overrides
792        let toml = r#"
793            [volatility]
794            medium = ["src/mid.rs"]
795        "#;
796        let config: CouplingConfig = toml::from_str(toml).unwrap();
797        let compiled = CompiledConfig::from_config(config).unwrap();
798        assert!(compiled.has_volatility_overrides());
799
800        // With low volatility → has overrides
801        let toml = r#"
802            [volatility]
803            low = ["src/stable.rs"]
804        "#;
805        let config: CouplingConfig = toml::from_str(toml).unwrap();
806        let compiled = CompiledConfig::from_config(config).unwrap();
807        assert!(compiled.has_volatility_overrides());
808    }
809
810    #[test]
811    fn test_volatility_override_beats_subdomain() {
812        let toml = r#"
813            [volatility]
814            low = ["src/analyzer.rs"]
815
816            [subdomains]
817            core = ["src/analyzer.rs"]
818        "#;
819
820        let config: CouplingConfig = toml::from_str(toml).unwrap();
821        let mut compiled = CompiledConfig::from_config(config).unwrap();
822
823        // Explicit volatility override wins over subdomain classification
824        assert_eq!(
825            compiled.get_volatility_override("src/analyzer.rs"),
826            Some(Volatility::Low)
827        );
828    }
829
830    #[test]
831    fn dead_patterns_reports_only_unmatched_patterns_by_section() {
832        let toml = r#"
833            [analysis]
834            prelude_modules = ["src/lib.rs", "src/missing_prelude.rs"]
835            exclude = ["src/never_matches/**"]
836
837            [volatility]
838            high = ["src/core/**"]
839            medium = ["src/medium.rs"]
840            low = ["src/stable.rs"]
841
842            [subdomains]
843            core = ["src/core/**", "src/old_core.rs"]
844            supporting = ["src/supporting.rs"]
845            generic = ["src/generic.rs"]
846        "#;
847
848        let config: CouplingConfig = toml::from_str(toml).unwrap();
849        let compiled = CompiledConfig::from_config(config).unwrap();
850        let candidate_paths = vec![
851            "src/lib.rs".to_string(),
852            "src/core/mod.rs".to_string(),
853            "src/supporting.rs".to_string(),
854            "src/stable.rs".to_string(),
855        ];
856
857        let dead = compiled.dead_patterns(&candidate_paths);
858
859        assert_eq!(
860            dead,
861            vec![
862                DeadConfigPattern {
863                    section: "subdomains.core",
864                    pattern: "src/old_core.rs".to_string(),
865                },
866                DeadConfigPattern {
867                    section: "subdomains.generic",
868                    pattern: "src/generic.rs".to_string(),
869                },
870                DeadConfigPattern {
871                    section: "volatility.medium",
872                    pattern: "src/medium.rs".to_string(),
873                },
874            ]
875        );
876    }
877
878    #[test]
879    fn dead_patterns_never_reports_exclude_or_prelude() {
880        // A dead exclude analyzes nothing wrongly (defensive future-proof excludes are
881        // idiomatic) and a dead prelude exemption surfaces as visible warnings, so
882        // neither is drift.
883        let toml = r#"
884            [analysis]
885            prelude_modules = ["src/missing_prelude.rs"]
886            exclude = ["src/never_matches/**"]
887        "#;
888
889        let config: CouplingConfig = toml::from_str(toml).unwrap();
890        let compiled = CompiledConfig::from_config(config).unwrap();
891
892        assert!(
893            compiled
894                .dead_patterns(&["src/lib.rs".to_string()])
895                .is_empty()
896        );
897    }
898
899    #[test]
900    fn empty_config_has_no_dead_patterns() {
901        let candidate_paths = vec!["src/lib.rs".to_string()];
902        assert!(
903            CompiledConfig::empty()
904                .dead_patterns(&candidate_paths)
905                .is_empty()
906        );
907    }
908}