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//! [thresholds]
33//! # Maximum dependencies before flagging High Efferent Coupling
34//! max_dependencies = 15
35//!
36//! # Maximum dependents before flagging High Afferent Coupling
37//! max_dependents = 20
38//! ```
39
40use glob::Pattern;
41use serde::Deserialize;
42use std::collections::HashMap;
43use std::fs;
44use std::path::Path;
45use thiserror::Error;
46
47use crate::metrics::Volatility;
48
49/// Errors that can occur when loading configuration
50#[derive(Error, Debug)]
51pub enum ConfigError {
52    #[error("Failed to read config file: {0}")]
53    IoError(#[from] std::io::Error),
54
55    #[error("Failed to parse config file: {0}")]
56    ParseError(#[from] toml::de::Error),
57
58    #[error("Invalid glob pattern: {0}")]
59    PatternError(String),
60}
61
62/// Analysis configuration section
63#[derive(Debug, Clone, Deserialize, Default)]
64pub struct AnalysisConfig {
65    /// Exclude test code from analysis (#[test], #[cfg(test)], mod tests)
66    #[serde(default)]
67    pub exclude_tests: bool,
68
69    /// "Prelude-like" modules that are expected to be depended on by many modules.
70    /// These modules will not trigger "High Afferent Coupling" warnings.
71    #[serde(default)]
72    pub prelude_modules: Vec<String>,
73
74    /// Modules to completely exclude from analysis
75    #[serde(default)]
76    pub exclude: Vec<String>,
77}
78
79/// Volatility configuration section
80#[derive(Debug, Clone, Deserialize, Default)]
81pub struct VolatilityConfig {
82    /// Paths that should be considered high volatility
83    #[serde(default)]
84    pub high: Vec<String>,
85
86    /// Paths that should be considered medium volatility
87    #[serde(default)]
88    pub medium: Vec<String>,
89
90    /// Paths that should be considered low volatility
91    #[serde(default)]
92    pub low: Vec<String>,
93
94    /// Paths to ignore from analysis
95    #[serde(default)]
96    pub ignore: Vec<String>,
97}
98
99/// Threshold configuration section
100#[derive(Debug, Clone, Deserialize)]
101pub struct ThresholdsConfig {
102    /// Maximum dependencies before flagging High Efferent Coupling
103    #[serde(default = "default_max_dependencies")]
104    pub max_dependencies: usize,
105
106    /// Maximum dependents before flagging High Afferent Coupling
107    #[serde(default = "default_max_dependents")]
108    pub max_dependents: usize,
109}
110
111fn default_max_dependencies() -> usize {
112    15
113}
114
115fn default_max_dependents() -> usize {
116    20
117}
118
119impl Default for ThresholdsConfig {
120    fn default() -> Self {
121        Self {
122            max_dependencies: default_max_dependencies(),
123            max_dependents: default_max_dependents(),
124        }
125    }
126}
127
128/// Root configuration structure
129#[derive(Debug, Clone, Deserialize, Default)]
130pub struct CouplingConfig {
131    /// Analysis configuration (test exclusion, prelude modules, etc.)
132    #[serde(default)]
133    pub analysis: AnalysisConfig,
134
135    /// Volatility override configuration
136    #[serde(default)]
137    pub volatility: VolatilityConfig,
138
139    /// Threshold configuration
140    #[serde(default)]
141    pub thresholds: ThresholdsConfig,
142}
143
144/// Compiled configuration with glob patterns
145#[derive(Debug)]
146pub struct CompiledConfig {
147    // === Analysis settings ===
148    /// Whether to exclude test code from analysis
149    pub exclude_tests: bool,
150    /// Patterns for prelude-like modules (exempt from afferent coupling warnings)
151    prelude_patterns: Vec<Pattern>,
152    /// Patterns for modules to completely exclude from analysis
153    exclude_patterns: Vec<Pattern>,
154
155    // === Volatility settings ===
156    /// Patterns for high volatility paths
157    high_patterns: Vec<Pattern>,
158    /// Patterns for medium volatility paths
159    medium_patterns: Vec<Pattern>,
160    /// Patterns for low volatility paths
161    low_patterns: Vec<Pattern>,
162    /// Patterns for ignored paths (deprecated, use exclude_patterns)
163    ignore_patterns: Vec<Pattern>,
164
165    // === Thresholds ===
166    /// Threshold configuration
167    pub thresholds: ThresholdsConfig,
168
169    // === Cache ===
170    /// Cache of path -> volatility mappings
171    cache: HashMap<String, Option<Volatility>>,
172}
173
174impl CompiledConfig {
175    /// Create a compiled config from raw config
176    pub fn from_config(config: CouplingConfig) -> Result<Self, ConfigError> {
177        let compile_patterns = |patterns: &[String]| -> Result<Vec<Pattern>, ConfigError> {
178            patterns
179                .iter()
180                .map(|p| {
181                    Pattern::new(p).map_err(|e| ConfigError::PatternError(format!("{}: {}", p, e)))
182                })
183                .collect()
184        };
185
186        Ok(Self {
187            // Analysis settings
188            exclude_tests: config.analysis.exclude_tests,
189            prelude_patterns: compile_patterns(&config.analysis.prelude_modules)?,
190            exclude_patterns: compile_patterns(&config.analysis.exclude)?,
191            // Volatility settings
192            high_patterns: compile_patterns(&config.volatility.high)?,
193            medium_patterns: compile_patterns(&config.volatility.medium)?,
194            low_patterns: compile_patterns(&config.volatility.low)?,
195            ignore_patterns: compile_patterns(&config.volatility.ignore)?,
196            // Thresholds
197            thresholds: config.thresholds,
198            cache: HashMap::new(),
199        })
200    }
201
202    /// Create an empty config (no overrides)
203    pub fn empty() -> Self {
204        Self {
205            exclude_tests: false,
206            prelude_patterns: Vec::new(),
207            exclude_patterns: Vec::new(),
208            high_patterns: Vec::new(),
209            medium_patterns: Vec::new(),
210            low_patterns: Vec::new(),
211            ignore_patterns: Vec::new(),
212            thresholds: ThresholdsConfig::default(),
213            cache: HashMap::new(),
214        }
215    }
216
217    /// Set exclude_tests flag (used by CLI --exclude-tests option)
218    pub fn set_exclude_tests(&mut self, exclude: bool) {
219        self.exclude_tests = exclude;
220    }
221
222    /// Check if a module is marked as "prelude-like" (exempt from afferent coupling warnings)
223    pub fn is_prelude_module(&self, path: &str) -> bool {
224        self.prelude_patterns.iter().any(|p| p.matches(path))
225    }
226
227    /// Check if a path should be completely excluded from analysis
228    pub fn should_exclude(&self, path: &str) -> bool {
229        self.exclude_patterns.iter().any(|p| p.matches(path))
230    }
231
232    /// Check if a path should be ignored (deprecated: use should_exclude)
233    pub fn should_ignore(&self, path: &str) -> bool {
234        self.ignore_patterns.iter().any(|p| p.matches(path))
235            || self.exclude_patterns.iter().any(|p| p.matches(path))
236    }
237
238    /// Get the list of prelude module patterns (for reporting)
239    pub fn prelude_module_count(&self) -> usize {
240        self.prelude_patterns.len()
241    }
242
243    /// Get overridden volatility for a path, if any
244    pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
245        // Check cache first
246        if let Some(cached) = self.cache.get(path) {
247            return *cached;
248        }
249
250        // Check patterns in order of specificity (high > medium > low)
251        let result = if self.high_patterns.iter().any(|p| p.matches(path)) {
252            Some(Volatility::High)
253        } else if self.medium_patterns.iter().any(|p| p.matches(path)) {
254            Some(Volatility::Medium)
255        } else if self.low_patterns.iter().any(|p| p.matches(path)) {
256            Some(Volatility::Low)
257        } else {
258            None
259        };
260
261        // Cache the result
262        self.cache.insert(path.to_string(), result);
263        result
264    }
265
266    /// Get volatility with override, falling back to git-based value
267    pub fn get_volatility(&mut self, path: &str, git_volatility: Volatility) -> Volatility {
268        self.get_volatility_override(path).unwrap_or(git_volatility)
269    }
270
271    /// Check if config has any volatility overrides
272    pub fn has_volatility_overrides(&self) -> bool {
273        !self.high_patterns.is_empty()
274            || !self.medium_patterns.is_empty()
275            || !self.low_patterns.is_empty()
276    }
277}
278
279/// Load configuration from the project directory
280///
281/// Searches for `.coupling.toml` in the given directory and parent directories.
282pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
283    // Search for config file
284    let config_path = find_config_file(project_path);
285
286    match config_path {
287        Some(path) => {
288            let content = fs::read_to_string(&path)?;
289            let config: CouplingConfig = toml::from_str(&content)?;
290            Ok(config)
291        }
292        None => Ok(CouplingConfig::default()),
293    }
294}
295
296/// Find the config file by searching up the directory tree
297fn find_config_file(start_path: &Path) -> Option<std::path::PathBuf> {
298    let config_names = [".coupling.toml", "coupling.toml"];
299
300    let mut current = if start_path.is_file() {
301        start_path.parent()?.to_path_buf()
302    } else {
303        start_path.to_path_buf()
304    };
305
306    loop {
307        for name in &config_names {
308            let config_path = current.join(name);
309            if config_path.exists() {
310                return Some(config_path);
311            }
312        }
313
314        // Move to parent directory
315        if let Some(parent) = current.parent() {
316            current = parent.to_path_buf();
317        } else {
318            break;
319        }
320    }
321
322    None
323}
324
325/// Load and compile configuration
326pub fn load_compiled_config(project_path: &Path) -> Result<CompiledConfig, ConfigError> {
327    let config = load_config(project_path)?;
328    CompiledConfig::from_config(config)
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_default_config() {
337        let config = CouplingConfig::default();
338        assert!(config.volatility.high.is_empty());
339        assert!(config.volatility.low.is_empty());
340        assert_eq!(config.thresholds.max_dependencies, 15);
341        assert_eq!(config.thresholds.max_dependents, 20);
342    }
343
344    #[test]
345    fn test_parse_config() {
346        let toml = r#"
347            [volatility]
348            high = ["src/api/*", "src/handlers/*"]
349            low = ["src/core/*"]
350            ignore = ["tests/*"]
351
352            [thresholds]
353            max_dependencies = 20
354            max_dependents = 30
355        "#;
356
357        let config: CouplingConfig = toml::from_str(toml).unwrap();
358        assert_eq!(config.volatility.high.len(), 2);
359        assert_eq!(config.volatility.low.len(), 1);
360        assert_eq!(config.volatility.ignore.len(), 1);
361        assert_eq!(config.thresholds.max_dependencies, 20);
362        assert_eq!(config.thresholds.max_dependents, 30);
363    }
364
365    #[test]
366    fn test_compiled_config() {
367        let toml = r#"
368            [volatility]
369            high = ["src/business/*"]
370            low = ["src/core/*"]
371        "#;
372
373        let config: CouplingConfig = toml::from_str(toml).unwrap();
374        let mut compiled = CompiledConfig::from_config(config).unwrap();
375
376        assert_eq!(
377            compiled.get_volatility_override("src/business/pricing.rs"),
378            Some(Volatility::High)
379        );
380        assert_eq!(
381            compiled.get_volatility_override("src/core/types.rs"),
382            Some(Volatility::Low)
383        );
384        assert_eq!(compiled.get_volatility_override("src/other/file.rs"), None);
385    }
386
387    #[test]
388    fn test_ignore_patterns() {
389        let toml = r#"
390            [volatility]
391            ignore = ["tests/*", "benches/*"]
392        "#;
393
394        let config: CouplingConfig = toml::from_str(toml).unwrap();
395        let compiled = CompiledConfig::from_config(config).unwrap();
396
397        assert!(compiled.should_ignore("tests/integration.rs"));
398        assert!(compiled.should_ignore("benches/perf.rs"));
399        assert!(!compiled.should_ignore("src/lib.rs"));
400    }
401
402    #[test]
403    fn test_get_volatility_with_fallback() {
404        let toml = r#"
405            [volatility]
406            high = ["src/api/*"]
407        "#;
408
409        let config: CouplingConfig = toml::from_str(toml).unwrap();
410        let mut compiled = CompiledConfig::from_config(config).unwrap();
411
412        // Override wins
413        assert_eq!(
414            compiled.get_volatility("src/api/handler.rs", Volatility::Low),
415            Volatility::High
416        );
417
418        // Fallback to git volatility
419        assert_eq!(
420            compiled.get_volatility("src/other/file.rs", Volatility::Medium),
421            Volatility::Medium
422        );
423    }
424}