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//! [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::{Component, Path, PathBuf};
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    /// Directory containing the loaded config file, if any.
151    config_root: Option<PathBuf>,
152    /// Patterns for prelude-like modules (exempt from afferent coupling warnings)
153    prelude_patterns: Vec<Pattern>,
154    /// Patterns for modules to completely exclude from analysis
155    exclude_patterns: Vec<Pattern>,
156
157    // === Volatility settings ===
158    /// Patterns for high volatility paths
159    high_patterns: Vec<Pattern>,
160    /// Patterns for medium volatility paths
161    medium_patterns: Vec<Pattern>,
162    /// Patterns for low volatility paths
163    low_patterns: Vec<Pattern>,
164    /// Patterns for ignored paths (deprecated, use exclude_patterns)
165    ignore_patterns: Vec<Pattern>,
166
167    // === Thresholds ===
168    /// Threshold configuration
169    pub thresholds: ThresholdsConfig,
170
171    // === Cache ===
172    /// Cache of path -> volatility mappings
173    cache: HashMap<String, Option<Volatility>>,
174}
175
176impl CompiledConfig {
177    /// Create a compiled config from raw config
178    pub fn from_config(config: CouplingConfig) -> Result<Self, ConfigError> {
179        Self::from_config_with_root(config, None)
180    }
181
182    /// Create a compiled config from raw config and the directory containing that config file.
183    fn from_config_with_root(
184        config: CouplingConfig,
185        config_root: Option<&Path>,
186    ) -> Result<Self, ConfigError> {
187        let compile_patterns = |patterns: &[String]| -> Result<Vec<Pattern>, ConfigError> {
188            patterns
189                .iter()
190                .map(|p| {
191                    Pattern::new(p).map_err(|e| ConfigError::PatternError(format!("{}: {}", p, e)))
192                })
193                .collect()
194        };
195
196        Ok(Self {
197            // Analysis settings
198            exclude_tests: config.analysis.exclude_tests,
199            config_root: config_root.map(Path::to_path_buf),
200            prelude_patterns: compile_patterns(&config.analysis.prelude_modules)?,
201            exclude_patterns: compile_patterns(&config.analysis.exclude)?,
202            // Volatility settings
203            high_patterns: compile_patterns(&config.volatility.high)?,
204            medium_patterns: compile_patterns(&config.volatility.medium)?,
205            low_patterns: compile_patterns(&config.volatility.low)?,
206            ignore_patterns: compile_patterns(&config.volatility.ignore)?,
207            // Thresholds
208            thresholds: config.thresholds,
209            cache: HashMap::new(),
210        })
211    }
212
213    /// Create an empty config (no overrides)
214    pub fn empty() -> Self {
215        Self {
216            exclude_tests: false,
217            config_root: None,
218            prelude_patterns: Vec::new(),
219            exclude_patterns: Vec::new(),
220            high_patterns: Vec::new(),
221            medium_patterns: Vec::new(),
222            low_patterns: Vec::new(),
223            ignore_patterns: Vec::new(),
224            thresholds: ThresholdsConfig::default(),
225            cache: HashMap::new(),
226        }
227    }
228
229    /// Set exclude_tests flag (used by CLI --exclude-tests option)
230    pub fn set_exclude_tests(&mut self, exclude: bool) {
231        self.exclude_tests = exclude;
232    }
233
234    /// Get the directory the config was loaded from, if known.
235    pub fn config_root(&self) -> Option<&Path> {
236        self.config_root.as_deref()
237    }
238
239    /// Check if a module is marked as "prelude-like" (exempt from afferent coupling warnings)
240    pub fn is_prelude_module(&self, path: &str) -> bool {
241        self.prelude_patterns.iter().any(|p| p.matches(path))
242    }
243
244    /// Check if a path should be completely excluded from analysis
245    pub fn should_exclude(&self, path: &str) -> bool {
246        self.exclude_patterns.iter().any(|p| p.matches(path))
247    }
248
249    /// Check if a path should be ignored (deprecated: use should_exclude)
250    pub fn should_ignore(&self, path: &str) -> bool {
251        self.ignore_patterns.iter().any(|p| p.matches(path))
252            || self.exclude_patterns.iter().any(|p| p.matches(path))
253    }
254
255    /// Get the list of prelude module patterns (for reporting)
256    pub fn prelude_module_count(&self) -> usize {
257        self.prelude_patterns.len()
258    }
259
260    /// Get overridden volatility for a path, if any
261    pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
262        // Check cache first
263        if let Some(cached) = self.cache.get(path) {
264            return *cached;
265        }
266
267        // Check patterns in order of specificity (high > medium > low)
268        let result = if self.high_patterns.iter().any(|p| p.matches(path)) {
269            Some(Volatility::High)
270        } else if self.medium_patterns.iter().any(|p| p.matches(path)) {
271            Some(Volatility::Medium)
272        } else if self.low_patterns.iter().any(|p| p.matches(path)) {
273            Some(Volatility::Low)
274        } else {
275            None
276        };
277
278        // Cache the result
279        self.cache.insert(path.to_string(), result);
280        result
281    }
282
283    /// Get volatility with override, falling back to git-based value
284    pub fn get_volatility(&mut self, path: &str, git_volatility: Volatility) -> Volatility {
285        self.get_volatility_override(path).unwrap_or(git_volatility)
286    }
287
288    /// Check if config has any volatility overrides
289    pub fn has_volatility_overrides(&self) -> bool {
290        !self.high_patterns.is_empty()
291            || !self.medium_patterns.is_empty()
292            || !self.low_patterns.is_empty()
293    }
294}
295
296/// Load configuration from the project directory
297///
298/// Searches for `.coupling.toml` in the given directory and parent directories.
299pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
300    // Search for config file
301    let config_path = find_config_file(project_path);
302
303    match config_path {
304        Some(path) => {
305            let content = fs::read_to_string(&path)?;
306            let config: CouplingConfig = toml::from_str(&content)?;
307            Ok(config)
308        }
309        None => Ok(CouplingConfig::default()),
310    }
311}
312
313/// Find the config file by searching up the directory tree
314fn find_config_file(start_path: &Path) -> Option<std::path::PathBuf> {
315    let config_names = [".coupling.toml", "coupling.toml"];
316
317    let mut current = if start_path.is_file() {
318        start_path.parent()?.to_path_buf()
319    } else {
320        start_path.to_path_buf()
321    };
322
323    loop {
324        for name in &config_names {
325            let config_path = current.join(name);
326            if config_path.exists() {
327                return Some(config_path);
328            }
329        }
330
331        // Move to parent directory
332        if let Some(parent) = current.parent() {
333            current = parent.to_path_buf();
334        } else {
335            break;
336        }
337    }
338
339    None
340}
341
342/// Load and compile configuration
343pub fn load_compiled_config(project_path: &Path) -> Result<CompiledConfig, ConfigError> {
344    match find_config_file(project_path) {
345        Some(path) => {
346            let content = fs::read_to_string(&path)?;
347            let config: CouplingConfig = toml::from_str(&content)?;
348            let absolute_path = absolute_normalized_path(&path)?;
349            CompiledConfig::from_config_with_root(config, absolute_path.parent())
350        }
351        None => Ok(CompiledConfig::empty()),
352    }
353}
354
355fn absolute_normalized_path(path: &Path) -> Result<PathBuf, std::io::Error> {
356    let absolute = if path.is_absolute() {
357        path.to_path_buf()
358    } else {
359        std::env::current_dir()?.join(path)
360    };
361
362    let mut normalized = PathBuf::new();
363    for component in absolute.components() {
364        match component {
365            Component::CurDir => {}
366            Component::ParentDir => {
367                normalized.pop();
368            }
369            other => normalized.push(other.as_os_str()),
370        }
371    }
372
373    Ok(normalized)
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_default_config() {
382        let config = CouplingConfig::default();
383        assert!(config.volatility.high.is_empty());
384        assert!(config.volatility.low.is_empty());
385        assert_eq!(config.thresholds.max_dependencies, 15);
386        assert_eq!(config.thresholds.max_dependents, 20);
387    }
388
389    #[test]
390    fn test_parse_config() {
391        let toml = r#"
392            [volatility]
393            high = ["src/api/*", "src/handlers/*"]
394            low = ["src/core/*"]
395            ignore = ["tests/*"]
396
397            [thresholds]
398            max_dependencies = 20
399            max_dependents = 30
400        "#;
401
402        let config: CouplingConfig = toml::from_str(toml).unwrap();
403        assert_eq!(config.volatility.high.len(), 2);
404        assert_eq!(config.volatility.low.len(), 1);
405        assert_eq!(config.volatility.ignore.len(), 1);
406        assert_eq!(config.thresholds.max_dependencies, 20);
407        assert_eq!(config.thresholds.max_dependents, 30);
408    }
409
410    #[test]
411    fn test_compiled_config() {
412        let toml = r#"
413            [volatility]
414            high = ["src/business/*"]
415            low = ["src/core/*"]
416        "#;
417
418        let config: CouplingConfig = toml::from_str(toml).unwrap();
419        let mut compiled = CompiledConfig::from_config(config).unwrap();
420
421        assert_eq!(
422            compiled.get_volatility_override("src/business/pricing.rs"),
423            Some(Volatility::High)
424        );
425        assert_eq!(
426            compiled.get_volatility_override("src/core/types.rs"),
427            Some(Volatility::Low)
428        );
429        assert_eq!(compiled.get_volatility_override("src/other/file.rs"), None);
430    }
431
432    #[test]
433    fn test_ignore_patterns() {
434        let toml = r#"
435            [volatility]
436            ignore = ["tests/*", "benches/*"]
437        "#;
438
439        let config: CouplingConfig = toml::from_str(toml).unwrap();
440        let compiled = CompiledConfig::from_config(config).unwrap();
441
442        assert!(compiled.should_ignore("tests/integration.rs"));
443        assert!(compiled.should_ignore("benches/perf.rs"));
444        assert!(!compiled.should_ignore("src/lib.rs"));
445    }
446
447    #[test]
448    fn test_get_volatility_with_fallback() {
449        let toml = r#"
450            [volatility]
451            high = ["src/api/*"]
452        "#;
453
454        let config: CouplingConfig = toml::from_str(toml).unwrap();
455        let mut compiled = CompiledConfig::from_config(config).unwrap();
456
457        // Override wins
458        assert_eq!(
459            compiled.get_volatility("src/api/handler.rs", Volatility::Low),
460            Volatility::High
461        );
462
463        // Fallback to git volatility
464        assert_eq!(
465            compiled.get_volatility("src/other/file.rs", Volatility::Medium),
466            Volatility::Medium
467        );
468    }
469}