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