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
220impl CompiledConfig {
221    /// Create a compiled config from raw config
222    pub fn from_config(config: CouplingConfig) -> Result<Self, ConfigError> {
223        Self::from_config_with_root(config, None)
224    }
225
226    /// Create a compiled config from raw config and the directory containing that config file.
227    fn from_config_with_root(
228        config: CouplingConfig,
229        config_root: Option<&Path>,
230    ) -> Result<Self, ConfigError> {
231        let compile_patterns = |patterns: &[String]| -> Result<Vec<Pattern>, ConfigError> {
232            patterns
233                .iter()
234                .map(|pattern| {
235                    Pattern::new(pattern)
236                        .map_err(|err| ConfigError::PatternError(format!("{}: {}", pattern, err)))
237                })
238                .collect()
239        };
240
241        Ok(Self {
242            // Analysis settings
243            exclude_tests: config.analysis.exclude_tests,
244            config_root: config_root.map(Path::to_path_buf),
245            prelude_patterns: compile_patterns(&config.analysis.prelude_modules)?,
246            exclude_patterns: compile_patterns(&config.analysis.exclude)?,
247            // Volatility settings
248            high_patterns: compile_patterns(&config.volatility.high)?,
249            medium_patterns: compile_patterns(&config.volatility.medium)?,
250            low_patterns: compile_patterns(&config.volatility.low)?,
251            ignore_patterns: compile_patterns(&config.volatility.ignore)?,
252            // Subdomain settings
253            core_patterns: compile_patterns(&config.subdomains.core)?,
254            supporting_patterns: compile_patterns(&config.subdomains.supporting)?,
255            generic_patterns: compile_patterns(&config.subdomains.generic)?,
256            // Thresholds
257            thresholds: config.thresholds,
258            cache: HashMap::new(),
259        })
260    }
261
262    /// Create an empty config (no overrides)
263    pub fn empty() -> Self {
264        Self {
265            exclude_tests: false,
266            config_root: None,
267            prelude_patterns: Vec::new(),
268            exclude_patterns: Vec::new(),
269            high_patterns: Vec::new(),
270            medium_patterns: Vec::new(),
271            low_patterns: Vec::new(),
272            ignore_patterns: Vec::new(),
273            core_patterns: Vec::new(),
274            supporting_patterns: Vec::new(),
275            generic_patterns: Vec::new(),
276            thresholds: ThresholdsConfig::default(),
277            cache: HashMap::new(),
278        }
279    }
280
281    /// Set exclude_tests flag (used by CLI --exclude-tests option)
282    pub fn set_exclude_tests(&mut self, exclude: bool) {
283        self.exclude_tests = exclude;
284    }
285
286    /// Get the directory the config was loaded from, if known.
287    pub fn config_root(&self) -> Option<&Path> {
288        self.config_root.as_deref()
289    }
290
291    /// Update the config root when analyzing the same config in a git worktree.
292    pub(crate) fn set_config_root(&mut self, config_root: Option<PathBuf>) {
293        self.config_root = config_root;
294    }
295
296    /// Check if a module is marked as "prelude-like" (exempt from afferent coupling warnings)
297    pub fn is_prelude_module(&self, path: &str) -> bool {
298        self.prelude_patterns.iter().any(|p| p.matches(path))
299    }
300
301    /// Check if a path should be completely excluded from analysis
302    pub fn should_exclude(&self, path: &str) -> bool {
303        self.exclude_patterns.iter().any(|p| p.matches(path))
304    }
305
306    /// Check if a path should be ignored (deprecated: use should_exclude)
307    pub fn should_ignore(&self, path: &str) -> bool {
308        self.ignore_patterns.iter().any(|p| p.matches(path))
309            || self.exclude_patterns.iter().any(|p| p.matches(path))
310    }
311
312    /// Get the list of prelude module patterns (for reporting)
313    pub fn prelude_module_count(&self) -> usize {
314        self.prelude_patterns.len()
315    }
316
317    /// Get the DDD subdomain classification for a path, if any
318    pub fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
319        if self.core_patterns.iter().any(|p| p.matches(path)) {
320            Some(Subdomain::Core)
321        } else if self.supporting_patterns.iter().any(|p| p.matches(path)) {
322            Some(Subdomain::Supporting)
323        } else if self.generic_patterns.iter().any(|p| p.matches(path)) {
324            Some(Subdomain::Generic)
325        } else {
326            None
327        }
328    }
329
330    /// Check if config has any subdomain classifications
331    pub fn has_subdomain_config(&self) -> bool {
332        !self.core_patterns.is_empty()
333            || !self.supporting_patterns.is_empty()
334            || !self.generic_patterns.is_empty()
335    }
336
337    /// Get overridden volatility for a path, if any
338    ///
339    /// Priority: explicit volatility override > subdomain classification
340    pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
341        // Check cache first
342        if let Some(cached) = self.cache.get(path) {
343            return *cached;
344        }
345
346        // Check explicit volatility patterns first (highest priority)
347        let result = if self.high_patterns.iter().any(|p| p.matches(path)) {
348            Some(Volatility::High)
349        } else if self.medium_patterns.iter().any(|p| p.matches(path)) {
350            Some(Volatility::Medium)
351        } else if self.low_patterns.iter().any(|p| p.matches(path)) {
352            Some(Volatility::Low)
353        } else {
354            // Fall back to subdomain-based volatility
355            self.get_subdomain(path).map(|sd| sd.expected_volatility())
356        };
357
358        // Cache the result
359        self.cache.insert(path.to_string(), result);
360        result
361    }
362
363    /// Get volatility with override, falling back to git-based value
364    pub fn get_volatility(&mut self, path: &str, git_volatility: Volatility) -> Volatility {
365        self.get_volatility_override(path).unwrap_or(git_volatility)
366    }
367
368    /// Check if config has any volatility overrides
369    pub fn has_volatility_overrides(&self) -> bool {
370        !self.high_patterns.is_empty()
371            || !self.medium_patterns.is_empty()
372            || !self.low_patterns.is_empty()
373    }
374}
375
376impl MetricsConfig for CompiledConfig {
377    fn config_root(&self) -> Option<&Path> {
378        CompiledConfig::config_root(self)
379    }
380
381    fn has_volatility_overrides(&self) -> bool {
382        CompiledConfig::has_volatility_overrides(self)
383    }
384
385    fn has_subdomain_config(&self) -> bool {
386        CompiledConfig::has_subdomain_config(self)
387    }
388
389    fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
390        CompiledConfig::get_subdomain(self, path)
391    }
392
393    fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
394        CompiledConfig::get_volatility_override(self, path)
395    }
396}
397
398/// Load configuration from the project directory
399///
400/// Searches for `.coupling.toml` in the given directory and parent directories.
401pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
402    // Search for config file
403    let config_path = find_config_file(project_path);
404
405    match config_path {
406        Some(path) => {
407            let content = fs::read_to_string(&path)?;
408            let config: CouplingConfig = toml::from_str(&content)?;
409            Ok(config)
410        }
411        None => Ok(CouplingConfig::default()),
412    }
413}
414
415/// Find the config file by searching up the directory tree
416fn find_config_file(start_path: &Path) -> Option<std::path::PathBuf> {
417    let config_names = [".coupling.toml", "coupling.toml"];
418
419    let mut current = if start_path.is_file() {
420        start_path.parent()?.to_path_buf()
421    } else {
422        start_path.to_path_buf()
423    };
424
425    loop {
426        for name in &config_names {
427            let config_path = current.join(name);
428            if config_path.exists() {
429                return Some(config_path);
430            }
431        }
432
433        // Move to parent directory
434        if let Some(parent) = current.parent() {
435            current = parent.to_path_buf();
436        } else {
437            break;
438        }
439    }
440
441    None
442}
443
444/// Load and compile configuration
445pub fn load_compiled_config(project_path: &Path) -> Result<CompiledConfig, ConfigError> {
446    match find_config_file(project_path) {
447        Some(path) => {
448            let content = fs::read_to_string(&path)?;
449            let config: CouplingConfig = toml::from_str(&content)?;
450            let absolute_path = absolute_normalized_path(&path)?;
451            CompiledConfig::from_config_with_root(config, absolute_path.parent())
452        }
453        None => Ok(CompiledConfig::empty()),
454    }
455}
456
457fn absolute_normalized_path(path: &Path) -> Result<PathBuf, std::io::Error> {
458    let absolute = if path.is_absolute() {
459        path.to_path_buf()
460    } else {
461        std::env::current_dir()?.join(path)
462    };
463
464    let mut normalized = PathBuf::new();
465    for component in absolute.components() {
466        match component {
467            Component::CurDir => {}
468            Component::ParentDir => {
469                normalized.pop();
470            }
471            other => normalized.push(other.as_os_str()),
472        }
473    }
474
475    Ok(normalized)
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn test_default_config() {
484        let config = CouplingConfig::default();
485        assert!(config.volatility.high.is_empty());
486        assert!(config.volatility.low.is_empty());
487        assert_eq!(config.thresholds.max_dependencies, 15);
488        assert_eq!(config.thresholds.max_dependents, 20);
489    }
490
491    #[test]
492    fn test_parse_config() {
493        let toml = r#"
494            [volatility]
495            high = ["src/api/*", "src/handlers/*"]
496            low = ["src/core/*"]
497            ignore = ["tests/*"]
498
499            [thresholds]
500            max_dependencies = 20
501            max_dependents = 30
502        "#;
503
504        let config: CouplingConfig = toml::from_str(toml).unwrap();
505        assert_eq!(config.volatility.high.len(), 2);
506        assert_eq!(config.volatility.low.len(), 1);
507        assert_eq!(config.volatility.ignore.len(), 1);
508        assert_eq!(config.thresholds.max_dependencies, 20);
509        assert_eq!(config.thresholds.max_dependents, 30);
510    }
511
512    #[test]
513    fn test_compiled_config() {
514        let toml = r#"
515            [volatility]
516            high = ["src/business/*"]
517            low = ["src/core/*"]
518        "#;
519
520        let config: CouplingConfig = toml::from_str(toml).unwrap();
521        let mut compiled = CompiledConfig::from_config(config).unwrap();
522
523        assert_eq!(
524            compiled.get_volatility_override("src/business/pricing.rs"),
525            Some(Volatility::High)
526        );
527        assert_eq!(
528            compiled.get_volatility_override("src/core/types.rs"),
529            Some(Volatility::Low)
530        );
531        assert_eq!(compiled.get_volatility_override("src/other/file.rs"), None);
532    }
533
534    #[test]
535    fn test_ignore_patterns() {
536        let toml = r#"
537            [volatility]
538            ignore = ["tests/*", "benches/*"]
539        "#;
540
541        let config: CouplingConfig = toml::from_str(toml).unwrap();
542        let compiled = CompiledConfig::from_config(config).unwrap();
543
544        assert!(compiled.should_ignore("tests/integration.rs"));
545        assert!(compiled.should_ignore("benches/perf.rs"));
546        assert!(!compiled.should_ignore("src/lib.rs"));
547    }
548
549    #[test]
550    fn test_get_volatility_with_fallback() {
551        let toml = r#"
552            [volatility]
553            high = ["src/api/*"]
554        "#;
555
556        let config: CouplingConfig = toml::from_str(toml).unwrap();
557        let mut compiled = CompiledConfig::from_config(config).unwrap();
558
559        // Override wins
560        assert_eq!(
561            compiled.get_volatility("src/api/handler.rs", Volatility::Low),
562            Volatility::High
563        );
564
565        // Fallback to git volatility
566        assert_eq!(
567            compiled.get_volatility("src/other/file.rs", Volatility::Medium),
568            Volatility::Medium
569        );
570    }
571
572    #[test]
573    fn test_subdomain_config() {
574        let toml = r#"
575            [subdomains]
576            core = ["src/analyzer.rs", "src/balance.rs"]
577            supporting = ["src/report.rs", "src/cli_output.rs"]
578            generic = ["src/web/*", "src/config.rs"]
579        "#;
580
581        let config: CouplingConfig = toml::from_str(toml).unwrap();
582        let mut compiled = CompiledConfig::from_config(config).unwrap();
583
584        // Core → High volatility
585        assert_eq!(
586            compiled.get_subdomain("src/analyzer.rs"),
587            Some(Subdomain::Core)
588        );
589        assert_eq!(
590            compiled.get_volatility_override("src/analyzer.rs"),
591            Some(Volatility::High)
592        );
593
594        // Supporting → Low volatility
595        assert_eq!(
596            compiled.get_subdomain("src/report.rs"),
597            Some(Subdomain::Supporting)
598        );
599        assert_eq!(
600            compiled.get_volatility_override("src/report.rs"),
601            Some(Volatility::Low)
602        );
603
604        // Generic → Low volatility
605        assert_eq!(
606            compiled.get_subdomain("src/web/server.rs"),
607            Some(Subdomain::Generic)
608        );
609        assert_eq!(
610            compiled.get_volatility_override("src/web/server.rs"),
611            Some(Volatility::Low)
612        );
613
614        // Unclassified → None
615        assert_eq!(compiled.get_subdomain("src/other.rs"), None);
616        assert_eq!(compiled.get_volatility_override("src/other.rs"), None);
617    }
618
619    #[test]
620    fn test_subdomain_display() {
621        assert_eq!(format!("{}", Subdomain::Core), "Core");
622        assert_eq!(format!("{}", Subdomain::Supporting), "Supporting");
623        assert_eq!(format!("{}", Subdomain::Generic), "Generic");
624    }
625
626    #[test]
627    fn test_subdomain_expected_volatility() {
628        assert_eq!(Subdomain::Core.expected_volatility(), Volatility::High);
629        assert_eq!(Subdomain::Supporting.expected_volatility(), Volatility::Low);
630        assert_eq!(Subdomain::Generic.expected_volatility(), Volatility::Low);
631    }
632
633    #[test]
634    fn test_has_subdomain_config() {
635        // Empty config → no subdomain config
636        let compiled = CompiledConfig::empty();
637        assert!(!compiled.has_subdomain_config());
638
639        // With core patterns → has subdomain config
640        let toml = r#"
641            [subdomains]
642            core = ["src/analyzer.rs"]
643        "#;
644        let config: CouplingConfig = toml::from_str(toml).unwrap();
645        let compiled = CompiledConfig::from_config(config).unwrap();
646        assert!(compiled.has_subdomain_config());
647
648        // With only supporting patterns → has subdomain config
649        let toml = r#"
650            [subdomains]
651            supporting = ["src/report.rs"]
652        "#;
653        let config: CouplingConfig = toml::from_str(toml).unwrap();
654        let compiled = CompiledConfig::from_config(config).unwrap();
655        assert!(compiled.has_subdomain_config());
656
657        // With only generic patterns → has subdomain config
658        let toml = r#"
659            [subdomains]
660            generic = ["src/web/*"]
661        "#;
662        let config: CouplingConfig = toml::from_str(toml).unwrap();
663        let compiled = CompiledConfig::from_config(config).unwrap();
664        assert!(compiled.has_subdomain_config());
665    }
666
667    #[test]
668    fn test_has_volatility_overrides() {
669        // Empty config → no overrides
670        let compiled = CompiledConfig::empty();
671        assert!(!compiled.has_volatility_overrides());
672
673        // With high volatility → has overrides
674        let toml = r#"
675            [volatility]
676            high = ["src/core.rs"]
677        "#;
678        let config: CouplingConfig = toml::from_str(toml).unwrap();
679        let compiled = CompiledConfig::from_config(config).unwrap();
680        assert!(compiled.has_volatility_overrides());
681
682        // With medium volatility → has overrides
683        let toml = r#"
684            [volatility]
685            medium = ["src/mid.rs"]
686        "#;
687        let config: CouplingConfig = toml::from_str(toml).unwrap();
688        let compiled = CompiledConfig::from_config(config).unwrap();
689        assert!(compiled.has_volatility_overrides());
690
691        // With low volatility → has overrides
692        let toml = r#"
693            [volatility]
694            low = ["src/stable.rs"]
695        "#;
696        let config: CouplingConfig = toml::from_str(toml).unwrap();
697        let compiled = CompiledConfig::from_config(config).unwrap();
698        assert!(compiled.has_volatility_overrides());
699    }
700
701    #[test]
702    fn test_volatility_override_beats_subdomain() {
703        let toml = r#"
704            [volatility]
705            low = ["src/analyzer.rs"]
706
707            [subdomains]
708            core = ["src/analyzer.rs"]
709        "#;
710
711        let config: CouplingConfig = toml::from_str(toml).unwrap();
712        let mut compiled = CompiledConfig::from_config(config).unwrap();
713
714        // Explicit volatility override wins over subdomain classification
715        assert_eq!(
716            compiled.get_volatility_override("src/analyzer.rs"),
717            Some(Volatility::Low)
718        );
719    }
720}