1use 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#[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#[derive(Debug, Clone, Deserialize, Default)]
64pub struct AnalysisConfig {
65 #[serde(default)]
67 pub exclude_tests: bool,
68
69 #[serde(default)]
72 pub prelude_modules: Vec<String>,
73
74 #[serde(default)]
76 pub exclude: Vec<String>,
77}
78
79#[derive(Debug, Clone, Deserialize, Default)]
81pub struct VolatilityConfig {
82 #[serde(default)]
84 pub high: Vec<String>,
85
86 #[serde(default)]
88 pub medium: Vec<String>,
89
90 #[serde(default)]
92 pub low: Vec<String>,
93
94 #[serde(default)]
96 pub ignore: Vec<String>,
97}
98
99#[derive(Debug, Clone, Deserialize)]
101pub struct ThresholdsConfig {
102 #[serde(default = "default_max_dependencies")]
104 pub max_dependencies: usize,
105
106 #[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#[derive(Debug, Clone, Deserialize, Default)]
130pub struct CouplingConfig {
131 #[serde(default)]
133 pub analysis: AnalysisConfig,
134
135 #[serde(default)]
137 pub volatility: VolatilityConfig,
138
139 #[serde(default)]
141 pub thresholds: ThresholdsConfig,
142}
143
144#[derive(Debug)]
146pub struct CompiledConfig {
147 pub exclude_tests: bool,
150 prelude_patterns: Vec<Pattern>,
152 exclude_patterns: Vec<Pattern>,
154
155 high_patterns: Vec<Pattern>,
158 medium_patterns: Vec<Pattern>,
160 low_patterns: Vec<Pattern>,
162 ignore_patterns: Vec<Pattern>,
164
165 pub thresholds: ThresholdsConfig,
168
169 cache: HashMap<String, Option<Volatility>>,
172}
173
174impl CompiledConfig {
175 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 exclude_tests: config.analysis.exclude_tests,
189 prelude_patterns: compile_patterns(&config.analysis.prelude_modules)?,
190 exclude_patterns: compile_patterns(&config.analysis.exclude)?,
191 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: config.thresholds,
198 cache: HashMap::new(),
199 })
200 }
201
202 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 pub fn set_exclude_tests(&mut self, exclude: bool) {
219 self.exclude_tests = exclude;
220 }
221
222 pub fn is_prelude_module(&self, path: &str) -> bool {
224 self.prelude_patterns.iter().any(|p| p.matches(path))
225 }
226
227 pub fn should_exclude(&self, path: &str) -> bool {
229 self.exclude_patterns.iter().any(|p| p.matches(path))
230 }
231
232 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 pub fn prelude_module_count(&self) -> usize {
240 self.prelude_patterns.len()
241 }
242
243 pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
245 if let Some(cached) = self.cache.get(path) {
247 return *cached;
248 }
249
250 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 self.cache.insert(path.to_string(), result);
263 result
264 }
265
266 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 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
279pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
283 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
296fn 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 if let Some(parent) = current.parent() {
316 current = parent.to_path_buf();
317 } else {
318 break;
319 }
320 }
321
322 None
323}
324
325pub 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 assert_eq!(
414 compiled.get_volatility("src/api/handler.rs", Volatility::Low),
415 Volatility::High
416 );
417
418 assert_eq!(
420 compiled.get_volatility("src/other/file.rs", Volatility::Medium),
421 Volatility::Medium
422 );
423 }
424}