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::Path;
53use thiserror::Error;
54
55use crate::metrics::Volatility;
56
57/// Errors that can occur when loading configuration
58#[derive(Error, Debug)]
59pub enum ConfigError {
60    #[error("Failed to read config file: {0}")]
61    IoError(#[from] std::io::Error),
62
63    #[error("Failed to parse config file: {0}")]
64    ParseError(#[from] toml::de::Error),
65
66    #[error("Invalid glob pattern: {0}")]
67    PatternError(String),
68}
69
70/// Analysis configuration section
71#[derive(Debug, Clone, Deserialize, Default)]
72pub struct AnalysisConfig {
73    /// Exclude test code from analysis (#[test], #[cfg(test)], mod tests)
74    #[serde(default)]
75    pub exclude_tests: bool,
76
77    /// "Prelude-like" modules that are expected to be depended on by many modules.
78    /// These modules will not trigger "High Afferent Coupling" warnings.
79    #[serde(default)]
80    pub prelude_modules: Vec<String>,
81
82    /// Modules to completely exclude from analysis
83    #[serde(default)]
84    pub exclude: Vec<String>,
85}
86
87/// Volatility configuration section
88#[derive(Debug, Clone, Deserialize, Default)]
89pub struct VolatilityConfig {
90    /// Paths that should be considered high volatility
91    #[serde(default)]
92    pub high: Vec<String>,
93
94    /// Paths that should be considered medium volatility
95    #[serde(default)]
96    pub medium: Vec<String>,
97
98    /// Paths that should be considered low volatility
99    #[serde(default)]
100    pub low: Vec<String>,
101
102    /// Paths to ignore from analysis
103    #[serde(default)]
104    pub ignore: Vec<String>,
105}
106
107/// DDD subdomain classification for volatility assessment
108///
109/// Based on Khononov's Balanced Coupling model: volatility should be determined
110/// by business domain classification, not just git history.
111/// - Core subdomains = High volatility (competitive advantage, constantly optimized)
112/// - Supporting subdomains = Low volatility (boring CRUD/ETL, rarely changes)
113/// - Generic subdomains = Low volatility (solved problems, stable implementations)
114#[derive(Debug, Clone, Deserialize, Default)]
115pub struct SubdomainConfig {
116    /// Core subdomain modules (high volatility - competitive advantage)
117    #[serde(default)]
118    pub core: Vec<String>,
119
120    /// Supporting subdomain modules (low volatility - stable business logic)
121    #[serde(default)]
122    pub supporting: Vec<String>,
123
124    /// Generic subdomain modules (low volatility - solved problems)
125    #[serde(default)]
126    pub generic: Vec<String>,
127}
128
129/// Threshold configuration section
130#[derive(Debug, Clone, Deserialize)]
131pub struct ThresholdsConfig {
132    /// Maximum dependencies before flagging High Efferent Coupling
133    #[serde(default = "default_max_dependencies")]
134    pub max_dependencies: usize,
135
136    /// Maximum dependents before flagging High Afferent Coupling
137    #[serde(default = "default_max_dependents")]
138    pub max_dependents: usize,
139}
140
141fn default_max_dependencies() -> usize {
142    15
143}
144
145fn default_max_dependents() -> usize {
146    20
147}
148
149impl Default for ThresholdsConfig {
150    fn default() -> Self {
151        Self {
152            max_dependencies: default_max_dependencies(),
153            max_dependents: default_max_dependents(),
154        }
155    }
156}
157
158/// Root configuration structure
159#[derive(Debug, Clone, Deserialize, Default)]
160pub struct CouplingConfig {
161    /// Analysis configuration (test exclusion, prelude modules, etc.)
162    #[serde(default)]
163    pub analysis: AnalysisConfig,
164
165    /// Volatility override configuration
166    #[serde(default)]
167    pub volatility: VolatilityConfig,
168
169    /// DDD subdomain classification (affects volatility assessment)
170    #[serde(default)]
171    pub subdomains: SubdomainConfig,
172
173    /// Threshold configuration
174    #[serde(default)]
175    pub thresholds: ThresholdsConfig,
176}
177
178/// DDD subdomain type
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum Subdomain {
181    /// Core subdomain - competitive advantage, high volatility
182    Core,
183    /// Supporting subdomain - stable business logic, low volatility
184    Supporting,
185    /// Generic subdomain - solved problems, low volatility
186    Generic,
187}
188
189impl Subdomain {
190    /// Map subdomain to expected volatility level
191    pub fn expected_volatility(&self) -> Volatility {
192        match self {
193            Subdomain::Core => Volatility::High,
194            Subdomain::Supporting => Volatility::Low,
195            Subdomain::Generic => Volatility::Low,
196        }
197    }
198}
199
200impl std::fmt::Display for Subdomain {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        match self {
203            Subdomain::Core => write!(f, "Core"),
204            Subdomain::Supporting => write!(f, "Supporting"),
205            Subdomain::Generic => write!(f, "Generic"),
206        }
207    }
208}
209
210/// Compiled configuration with glob patterns
211#[derive(Debug)]
212pub struct CompiledConfig {
213    // === Analysis settings ===
214    /// Whether to exclude test code from analysis
215    pub exclude_tests: bool,
216    /// Patterns for prelude-like modules (exempt from afferent coupling warnings)
217    prelude_patterns: Vec<Pattern>,
218    /// Patterns for modules to completely exclude from analysis
219    exclude_patterns: Vec<Pattern>,
220
221    // === Volatility settings ===
222    /// Patterns for high volatility paths
223    high_patterns: Vec<Pattern>,
224    /// Patterns for medium volatility paths
225    medium_patterns: Vec<Pattern>,
226    /// Patterns for low volatility paths
227    low_patterns: Vec<Pattern>,
228    /// Patterns for ignored paths (deprecated, use exclude_patterns)
229    ignore_patterns: Vec<Pattern>,
230
231    // === Subdomain settings ===
232    /// Patterns for core subdomain (high volatility)
233    core_patterns: Vec<Pattern>,
234    /// Patterns for supporting subdomain (low volatility)
235    supporting_patterns: Vec<Pattern>,
236    /// Patterns for generic subdomain (low volatility)
237    generic_patterns: Vec<Pattern>,
238
239    // === Thresholds ===
240    /// Threshold configuration
241    pub thresholds: ThresholdsConfig,
242
243    // === Cache ===
244    /// Cache of path -> volatility mappings
245    cache: HashMap<String, Option<Volatility>>,
246}
247
248impl CompiledConfig {
249    /// Create a compiled config from raw config
250    pub fn from_config(config: CouplingConfig) -> Result<Self, ConfigError> {
251        let compile_patterns = |patterns: &[String]| -> Result<Vec<Pattern>, ConfigError> {
252            patterns
253                .iter()
254                .map(|p| {
255                    Pattern::new(p).map_err(|e| ConfigError::PatternError(format!("{}: {}", p, e)))
256                })
257                .collect()
258        };
259
260        Ok(Self {
261            // Analysis settings
262            exclude_tests: config.analysis.exclude_tests,
263            prelude_patterns: compile_patterns(&config.analysis.prelude_modules)?,
264            exclude_patterns: compile_patterns(&config.analysis.exclude)?,
265            // Volatility settings
266            high_patterns: compile_patterns(&config.volatility.high)?,
267            medium_patterns: compile_patterns(&config.volatility.medium)?,
268            low_patterns: compile_patterns(&config.volatility.low)?,
269            ignore_patterns: compile_patterns(&config.volatility.ignore)?,
270            // Subdomain settings
271            core_patterns: compile_patterns(&config.subdomains.core)?,
272            supporting_patterns: compile_patterns(&config.subdomains.supporting)?,
273            generic_patterns: compile_patterns(&config.subdomains.generic)?,
274            // Thresholds
275            thresholds: config.thresholds,
276            cache: HashMap::new(),
277        })
278    }
279
280    /// Create an empty config (no overrides)
281    pub fn empty() -> Self {
282        Self {
283            exclude_tests: false,
284            prelude_patterns: Vec::new(),
285            exclude_patterns: Vec::new(),
286            high_patterns: Vec::new(),
287            medium_patterns: Vec::new(),
288            low_patterns: Vec::new(),
289            ignore_patterns: Vec::new(),
290            core_patterns: Vec::new(),
291            supporting_patterns: Vec::new(),
292            generic_patterns: Vec::new(),
293            thresholds: ThresholdsConfig::default(),
294            cache: HashMap::new(),
295        }
296    }
297
298    /// Set exclude_tests flag (used by CLI --exclude-tests option)
299    pub fn set_exclude_tests(&mut self, exclude: bool) {
300        self.exclude_tests = exclude;
301    }
302
303    /// Check if a module is marked as "prelude-like" (exempt from afferent coupling warnings)
304    pub fn is_prelude_module(&self, path: &str) -> bool {
305        self.prelude_patterns.iter().any(|p| p.matches(path))
306    }
307
308    /// Check if a path should be completely excluded from analysis
309    pub fn should_exclude(&self, path: &str) -> bool {
310        self.exclude_patterns.iter().any(|p| p.matches(path))
311    }
312
313    /// Check if a path should be ignored (deprecated: use should_exclude)
314    pub fn should_ignore(&self, path: &str) -> bool {
315        self.ignore_patterns.iter().any(|p| p.matches(path))
316            || self.exclude_patterns.iter().any(|p| p.matches(path))
317    }
318
319    /// Get the list of prelude module patterns (for reporting)
320    pub fn prelude_module_count(&self) -> usize {
321        self.prelude_patterns.len()
322    }
323
324    /// Get the DDD subdomain classification for a path, if any
325    pub fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
326        if self.core_patterns.iter().any(|p| p.matches(path)) {
327            Some(Subdomain::Core)
328        } else if self.supporting_patterns.iter().any(|p| p.matches(path)) {
329            Some(Subdomain::Supporting)
330        } else if self.generic_patterns.iter().any(|p| p.matches(path)) {
331            Some(Subdomain::Generic)
332        } else {
333            None
334        }
335    }
336
337    /// Check if config has any subdomain classifications
338    pub fn has_subdomain_config(&self) -> bool {
339        !self.core_patterns.is_empty()
340            || !self.supporting_patterns.is_empty()
341            || !self.generic_patterns.is_empty()
342    }
343
344    /// Get overridden volatility for a path, if any
345    ///
346    /// Priority: explicit volatility override > subdomain classification
347    pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
348        // Check cache first
349        if let Some(cached) = self.cache.get(path) {
350            return *cached;
351        }
352
353        // Check explicit volatility patterns first (highest priority)
354        let result = if self.high_patterns.iter().any(|p| p.matches(path)) {
355            Some(Volatility::High)
356        } else if self.medium_patterns.iter().any(|p| p.matches(path)) {
357            Some(Volatility::Medium)
358        } else if self.low_patterns.iter().any(|p| p.matches(path)) {
359            Some(Volatility::Low)
360        } else {
361            // Fall back to subdomain-based volatility
362            self.get_subdomain(path).map(|sd| sd.expected_volatility())
363        };
364
365        // Cache the result
366        self.cache.insert(path.to_string(), result);
367        result
368    }
369
370    /// Get volatility with override, falling back to git-based value
371    pub fn get_volatility(&mut self, path: &str, git_volatility: Volatility) -> Volatility {
372        self.get_volatility_override(path).unwrap_or(git_volatility)
373    }
374
375    /// Check if config has any volatility overrides
376    pub fn has_volatility_overrides(&self) -> bool {
377        !self.high_patterns.is_empty()
378            || !self.medium_patterns.is_empty()
379            || !self.low_patterns.is_empty()
380    }
381}
382
383/// Load configuration from the project directory
384///
385/// Searches for `.coupling.toml` in the given directory and parent directories.
386pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
387    // Search for config file
388    let config_path = find_config_file(project_path);
389
390    match config_path {
391        Some(path) => {
392            let content = fs::read_to_string(&path)?;
393            let config: CouplingConfig = toml::from_str(&content)?;
394            Ok(config)
395        }
396        None => Ok(CouplingConfig::default()),
397    }
398}
399
400/// Find the config file by searching up the directory tree
401fn find_config_file(start_path: &Path) -> Option<std::path::PathBuf> {
402    let config_names = [".coupling.toml", "coupling.toml"];
403
404    let mut current = if start_path.is_file() {
405        start_path.parent()?.to_path_buf()
406    } else {
407        start_path.to_path_buf()
408    };
409
410    loop {
411        for name in &config_names {
412            let config_path = current.join(name);
413            if config_path.exists() {
414                return Some(config_path);
415            }
416        }
417
418        // Move to parent directory
419        if let Some(parent) = current.parent() {
420            current = parent.to_path_buf();
421        } else {
422            break;
423        }
424    }
425
426    None
427}
428
429/// Load and compile configuration
430pub fn load_compiled_config(project_path: &Path) -> Result<CompiledConfig, ConfigError> {
431    let config = load_config(project_path)?;
432    CompiledConfig::from_config(config)
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_default_config() {
441        let config = CouplingConfig::default();
442        assert!(config.volatility.high.is_empty());
443        assert!(config.volatility.low.is_empty());
444        assert_eq!(config.thresholds.max_dependencies, 15);
445        assert_eq!(config.thresholds.max_dependents, 20);
446    }
447
448    #[test]
449    fn test_parse_config() {
450        let toml = r#"
451            [volatility]
452            high = ["src/api/*", "src/handlers/*"]
453            low = ["src/core/*"]
454            ignore = ["tests/*"]
455
456            [thresholds]
457            max_dependencies = 20
458            max_dependents = 30
459        "#;
460
461        let config: CouplingConfig = toml::from_str(toml).unwrap();
462        assert_eq!(config.volatility.high.len(), 2);
463        assert_eq!(config.volatility.low.len(), 1);
464        assert_eq!(config.volatility.ignore.len(), 1);
465        assert_eq!(config.thresholds.max_dependencies, 20);
466        assert_eq!(config.thresholds.max_dependents, 30);
467    }
468
469    #[test]
470    fn test_compiled_config() {
471        let toml = r#"
472            [volatility]
473            high = ["src/business/*"]
474            low = ["src/core/*"]
475        "#;
476
477        let config: CouplingConfig = toml::from_str(toml).unwrap();
478        let mut compiled = CompiledConfig::from_config(config).unwrap();
479
480        assert_eq!(
481            compiled.get_volatility_override("src/business/pricing.rs"),
482            Some(Volatility::High)
483        );
484        assert_eq!(
485            compiled.get_volatility_override("src/core/types.rs"),
486            Some(Volatility::Low)
487        );
488        assert_eq!(compiled.get_volatility_override("src/other/file.rs"), None);
489    }
490
491    #[test]
492    fn test_ignore_patterns() {
493        let toml = r#"
494            [volatility]
495            ignore = ["tests/*", "benches/*"]
496        "#;
497
498        let config: CouplingConfig = toml::from_str(toml).unwrap();
499        let compiled = CompiledConfig::from_config(config).unwrap();
500
501        assert!(compiled.should_ignore("tests/integration.rs"));
502        assert!(compiled.should_ignore("benches/perf.rs"));
503        assert!(!compiled.should_ignore("src/lib.rs"));
504    }
505
506    #[test]
507    fn test_get_volatility_with_fallback() {
508        let toml = r#"
509            [volatility]
510            high = ["src/api/*"]
511        "#;
512
513        let config: CouplingConfig = toml::from_str(toml).unwrap();
514        let mut compiled = CompiledConfig::from_config(config).unwrap();
515
516        // Override wins
517        assert_eq!(
518            compiled.get_volatility("src/api/handler.rs", Volatility::Low),
519            Volatility::High
520        );
521
522        // Fallback to git volatility
523        assert_eq!(
524            compiled.get_volatility("src/other/file.rs", Volatility::Medium),
525            Volatility::Medium
526        );
527    }
528
529    #[test]
530    fn test_subdomain_config() {
531        let toml = r#"
532            [subdomains]
533            core = ["src/analyzer.rs", "src/balance.rs"]
534            supporting = ["src/report.rs", "src/cli_output.rs"]
535            generic = ["src/web/*", "src/config.rs"]
536        "#;
537
538        let config: CouplingConfig = toml::from_str(toml).unwrap();
539        let mut compiled = CompiledConfig::from_config(config).unwrap();
540
541        // Core → High volatility
542        assert_eq!(
543            compiled.get_subdomain("src/analyzer.rs"),
544            Some(Subdomain::Core)
545        );
546        assert_eq!(
547            compiled.get_volatility_override("src/analyzer.rs"),
548            Some(Volatility::High)
549        );
550
551        // Supporting → Low volatility
552        assert_eq!(
553            compiled.get_subdomain("src/report.rs"),
554            Some(Subdomain::Supporting)
555        );
556        assert_eq!(
557            compiled.get_volatility_override("src/report.rs"),
558            Some(Volatility::Low)
559        );
560
561        // Generic → Low volatility
562        assert_eq!(
563            compiled.get_subdomain("src/web/server.rs"),
564            Some(Subdomain::Generic)
565        );
566        assert_eq!(
567            compiled.get_volatility_override("src/web/server.rs"),
568            Some(Volatility::Low)
569        );
570
571        // Unclassified → None
572        assert_eq!(compiled.get_subdomain("src/other.rs"), None);
573        assert_eq!(compiled.get_volatility_override("src/other.rs"), None);
574    }
575
576    #[test]
577    fn test_subdomain_display() {
578        assert_eq!(format!("{}", Subdomain::Core), "Core");
579        assert_eq!(format!("{}", Subdomain::Supporting), "Supporting");
580        assert_eq!(format!("{}", Subdomain::Generic), "Generic");
581    }
582
583    #[test]
584    fn test_subdomain_expected_volatility() {
585        assert_eq!(Subdomain::Core.expected_volatility(), Volatility::High);
586        assert_eq!(Subdomain::Supporting.expected_volatility(), Volatility::Low);
587        assert_eq!(Subdomain::Generic.expected_volatility(), Volatility::Low);
588    }
589
590    #[test]
591    fn test_has_subdomain_config() {
592        // Empty config → no subdomain config
593        let compiled = CompiledConfig::empty();
594        assert!(!compiled.has_subdomain_config());
595
596        // With core patterns → has subdomain config
597        let toml = r#"
598            [subdomains]
599            core = ["src/analyzer.rs"]
600        "#;
601        let config: CouplingConfig = toml::from_str(toml).unwrap();
602        let compiled = CompiledConfig::from_config(config).unwrap();
603        assert!(compiled.has_subdomain_config());
604
605        // With only supporting patterns → has subdomain config
606        let toml = r#"
607            [subdomains]
608            supporting = ["src/report.rs"]
609        "#;
610        let config: CouplingConfig = toml::from_str(toml).unwrap();
611        let compiled = CompiledConfig::from_config(config).unwrap();
612        assert!(compiled.has_subdomain_config());
613
614        // With only generic patterns → has subdomain config
615        let toml = r#"
616            [subdomains]
617            generic = ["src/web/*"]
618        "#;
619        let config: CouplingConfig = toml::from_str(toml).unwrap();
620        let compiled = CompiledConfig::from_config(config).unwrap();
621        assert!(compiled.has_subdomain_config());
622    }
623
624    #[test]
625    fn test_has_volatility_overrides() {
626        // Empty config → no overrides
627        let compiled = CompiledConfig::empty();
628        assert!(!compiled.has_volatility_overrides());
629
630        // With high volatility → has overrides
631        let toml = r#"
632            [volatility]
633            high = ["src/core.rs"]
634        "#;
635        let config: CouplingConfig = toml::from_str(toml).unwrap();
636        let compiled = CompiledConfig::from_config(config).unwrap();
637        assert!(compiled.has_volatility_overrides());
638
639        // With medium volatility → has overrides
640        let toml = r#"
641            [volatility]
642            medium = ["src/mid.rs"]
643        "#;
644        let config: CouplingConfig = toml::from_str(toml).unwrap();
645        let compiled = CompiledConfig::from_config(config).unwrap();
646        assert!(compiled.has_volatility_overrides());
647
648        // With low volatility → has overrides
649        let toml = r#"
650            [volatility]
651            low = ["src/stable.rs"]
652        "#;
653        let config: CouplingConfig = toml::from_str(toml).unwrap();
654        let compiled = CompiledConfig::from_config(config).unwrap();
655        assert!(compiled.has_volatility_overrides());
656    }
657
658    #[test]
659    fn test_volatility_override_beats_subdomain() {
660        let toml = r#"
661            [volatility]
662            low = ["src/analyzer.rs"]
663
664            [subdomains]
665            core = ["src/analyzer.rs"]
666        "#;
667
668        let config: CouplingConfig = toml::from_str(toml).unwrap();
669        let mut compiled = CompiledConfig::from_config(config).unwrap();
670
671        // Explicit volatility override wins over subdomain classification
672        assert_eq!(
673            compiled.get_volatility_override("src/analyzer.rs"),
674            Some(Volatility::Low)
675        );
676    }
677}