1use 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#[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 config_root: Option<PathBuf>,
152 prelude_patterns: Vec<Pattern>,
154 exclude_patterns: Vec<Pattern>,
156
157 high_patterns: Vec<Pattern>,
160 medium_patterns: Vec<Pattern>,
162 low_patterns: Vec<Pattern>,
164 ignore_patterns: Vec<Pattern>,
166
167 pub thresholds: ThresholdsConfig,
170
171 cache: HashMap<String, Option<Volatility>>,
174}
175
176impl CompiledConfig {
177 pub fn from_config(config: CouplingConfig) -> Result<Self, ConfigError> {
179 Self::from_config_with_root(config, None)
180 }
181
182 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 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 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: config.thresholds,
209 cache: HashMap::new(),
210 })
211 }
212
213 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 pub fn set_exclude_tests(&mut self, exclude: bool) {
231 self.exclude_tests = exclude;
232 }
233
234 pub fn config_root(&self) -> Option<&Path> {
236 self.config_root.as_deref()
237 }
238
239 pub fn is_prelude_module(&self, path: &str) -> bool {
241 self.prelude_patterns.iter().any(|p| p.matches(path))
242 }
243
244 pub fn should_exclude(&self, path: &str) -> bool {
246 self.exclude_patterns.iter().any(|p| p.matches(path))
247 }
248
249 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 pub fn prelude_module_count(&self) -> usize {
257 self.prelude_patterns.len()
258 }
259
260 pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
262 if let Some(cached) = self.cache.get(path) {
264 return *cached;
265 }
266
267 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 self.cache.insert(path.to_string(), result);
280 result
281 }
282
283 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 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
296pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
300 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
313fn 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 if let Some(parent) = current.parent() {
333 current = parent.to_path_buf();
334 } else {
335 break;
336 }
337 }
338
339 None
340}
341
342pub 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 assert_eq!(
459 compiled.get_volatility("src/api/handler.rs", Volatility::Low),
460 Volatility::High
461 );
462
463 assert_eq!(
465 compiled.get_volatility("src/other/file.rs", Volatility::Medium),
466 Volatility::Medium
467 );
468 }
469}