1use glob::Pattern;
49use serde::Deserialize;
50use std::collections::HashMap;
51use std::fs;
52use std::path::Path;
53use thiserror::Error;
54
55use crate::metrics::Volatility;
56
57#[derive(Error, Debug)]
59pub enum ConfigError {
60 #[error("Failed to read config file: {0}")]
61 IoError(#[from] std::io::Error),
62
63 #[error("Failed to parse config file: {0}")]
64 ParseError(#[from] toml::de::Error),
65
66 #[error("Invalid glob pattern: {0}")]
67 PatternError(String),
68}
69
70#[derive(Debug, Clone, Deserialize, Default)]
72pub struct AnalysisConfig {
73 #[serde(default)]
75 pub exclude_tests: bool,
76
77 #[serde(default)]
80 pub prelude_modules: Vec<String>,
81
82 #[serde(default)]
84 pub exclude: Vec<String>,
85}
86
87#[derive(Debug, Clone, Deserialize, Default)]
89pub struct VolatilityConfig {
90 #[serde(default)]
92 pub high: Vec<String>,
93
94 #[serde(default)]
96 pub medium: Vec<String>,
97
98 #[serde(default)]
100 pub low: Vec<String>,
101
102 #[serde(default)]
104 pub ignore: Vec<String>,
105}
106
107#[derive(Debug, Clone, Deserialize, Default)]
115pub struct SubdomainConfig {
116 #[serde(default)]
118 pub core: Vec<String>,
119
120 #[serde(default)]
122 pub supporting: Vec<String>,
123
124 #[serde(default)]
126 pub generic: Vec<String>,
127}
128
129#[derive(Debug, Clone, Deserialize)]
131pub struct ThresholdsConfig {
132 #[serde(default = "default_max_dependencies")]
134 pub max_dependencies: usize,
135
136 #[serde(default = "default_max_dependents")]
138 pub max_dependents: usize,
139}
140
141fn default_max_dependencies() -> usize {
142 15
143}
144
145fn default_max_dependents() -> usize {
146 20
147}
148
149impl Default for ThresholdsConfig {
150 fn default() -> Self {
151 Self {
152 max_dependencies: default_max_dependencies(),
153 max_dependents: default_max_dependents(),
154 }
155 }
156}
157
158#[derive(Debug, Clone, Deserialize, Default)]
160pub struct CouplingConfig {
161 #[serde(default)]
163 pub analysis: AnalysisConfig,
164
165 #[serde(default)]
167 pub volatility: VolatilityConfig,
168
169 #[serde(default)]
171 pub subdomains: SubdomainConfig,
172
173 #[serde(default)]
175 pub thresholds: ThresholdsConfig,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum Subdomain {
181 Core,
183 Supporting,
185 Generic,
187}
188
189impl Subdomain {
190 pub fn expected_volatility(&self) -> Volatility {
192 match self {
193 Subdomain::Core => Volatility::High,
194 Subdomain::Supporting => Volatility::Low,
195 Subdomain::Generic => Volatility::Low,
196 }
197 }
198}
199
200impl std::fmt::Display for Subdomain {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 match self {
203 Subdomain::Core => write!(f, "Core"),
204 Subdomain::Supporting => write!(f, "Supporting"),
205 Subdomain::Generic => write!(f, "Generic"),
206 }
207 }
208}
209
210#[derive(Debug)]
212pub struct CompiledConfig {
213 pub exclude_tests: bool,
216 prelude_patterns: Vec<Pattern>,
218 exclude_patterns: Vec<Pattern>,
220
221 high_patterns: Vec<Pattern>,
224 medium_patterns: Vec<Pattern>,
226 low_patterns: Vec<Pattern>,
228 ignore_patterns: Vec<Pattern>,
230
231 core_patterns: Vec<Pattern>,
234 supporting_patterns: Vec<Pattern>,
236 generic_patterns: Vec<Pattern>,
238
239 pub thresholds: ThresholdsConfig,
242
243 cache: HashMap<String, Option<Volatility>>,
246}
247
248impl CompiledConfig {
249 pub fn from_config(config: CouplingConfig) -> Result<Self, ConfigError> {
251 let compile_patterns = |patterns: &[String]| -> Result<Vec<Pattern>, ConfigError> {
252 patterns
253 .iter()
254 .map(|p| {
255 Pattern::new(p).map_err(|e| ConfigError::PatternError(format!("{}: {}", p, e)))
256 })
257 .collect()
258 };
259
260 Ok(Self {
261 exclude_tests: config.analysis.exclude_tests,
263 prelude_patterns: compile_patterns(&config.analysis.prelude_modules)?,
264 exclude_patterns: compile_patterns(&config.analysis.exclude)?,
265 high_patterns: compile_patterns(&config.volatility.high)?,
267 medium_patterns: compile_patterns(&config.volatility.medium)?,
268 low_patterns: compile_patterns(&config.volatility.low)?,
269 ignore_patterns: compile_patterns(&config.volatility.ignore)?,
270 core_patterns: compile_patterns(&config.subdomains.core)?,
272 supporting_patterns: compile_patterns(&config.subdomains.supporting)?,
273 generic_patterns: compile_patterns(&config.subdomains.generic)?,
274 thresholds: config.thresholds,
276 cache: HashMap::new(),
277 })
278 }
279
280 pub fn empty() -> Self {
282 Self {
283 exclude_tests: false,
284 prelude_patterns: Vec::new(),
285 exclude_patterns: Vec::new(),
286 high_patterns: Vec::new(),
287 medium_patterns: Vec::new(),
288 low_patterns: Vec::new(),
289 ignore_patterns: Vec::new(),
290 core_patterns: Vec::new(),
291 supporting_patterns: Vec::new(),
292 generic_patterns: Vec::new(),
293 thresholds: ThresholdsConfig::default(),
294 cache: HashMap::new(),
295 }
296 }
297
298 pub fn set_exclude_tests(&mut self, exclude: bool) {
300 self.exclude_tests = exclude;
301 }
302
303 pub fn is_prelude_module(&self, path: &str) -> bool {
305 self.prelude_patterns.iter().any(|p| p.matches(path))
306 }
307
308 pub fn should_exclude(&self, path: &str) -> bool {
310 self.exclude_patterns.iter().any(|p| p.matches(path))
311 }
312
313 pub fn should_ignore(&self, path: &str) -> bool {
315 self.ignore_patterns.iter().any(|p| p.matches(path))
316 || self.exclude_patterns.iter().any(|p| p.matches(path))
317 }
318
319 pub fn prelude_module_count(&self) -> usize {
321 self.prelude_patterns.len()
322 }
323
324 pub fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
326 if self.core_patterns.iter().any(|p| p.matches(path)) {
327 Some(Subdomain::Core)
328 } else if self.supporting_patterns.iter().any(|p| p.matches(path)) {
329 Some(Subdomain::Supporting)
330 } else if self.generic_patterns.iter().any(|p| p.matches(path)) {
331 Some(Subdomain::Generic)
332 } else {
333 None
334 }
335 }
336
337 pub fn has_subdomain_config(&self) -> bool {
339 !self.core_patterns.is_empty()
340 || !self.supporting_patterns.is_empty()
341 || !self.generic_patterns.is_empty()
342 }
343
344 pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
348 if let Some(cached) = self.cache.get(path) {
350 return *cached;
351 }
352
353 let result = if self.high_patterns.iter().any(|p| p.matches(path)) {
355 Some(Volatility::High)
356 } else if self.medium_patterns.iter().any(|p| p.matches(path)) {
357 Some(Volatility::Medium)
358 } else if self.low_patterns.iter().any(|p| p.matches(path)) {
359 Some(Volatility::Low)
360 } else {
361 self.get_subdomain(path).map(|sd| sd.expected_volatility())
363 };
364
365 self.cache.insert(path.to_string(), result);
367 result
368 }
369
370 pub fn get_volatility(&mut self, path: &str, git_volatility: Volatility) -> Volatility {
372 self.get_volatility_override(path).unwrap_or(git_volatility)
373 }
374
375 pub fn has_volatility_overrides(&self) -> bool {
377 !self.high_patterns.is_empty()
378 || !self.medium_patterns.is_empty()
379 || !self.low_patterns.is_empty()
380 }
381}
382
383pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
387 let config_path = find_config_file(project_path);
389
390 match config_path {
391 Some(path) => {
392 let content = fs::read_to_string(&path)?;
393 let config: CouplingConfig = toml::from_str(&content)?;
394 Ok(config)
395 }
396 None => Ok(CouplingConfig::default()),
397 }
398}
399
400fn find_config_file(start_path: &Path) -> Option<std::path::PathBuf> {
402 let config_names = [".coupling.toml", "coupling.toml"];
403
404 let mut current = if start_path.is_file() {
405 start_path.parent()?.to_path_buf()
406 } else {
407 start_path.to_path_buf()
408 };
409
410 loop {
411 for name in &config_names {
412 let config_path = current.join(name);
413 if config_path.exists() {
414 return Some(config_path);
415 }
416 }
417
418 if let Some(parent) = current.parent() {
420 current = parent.to_path_buf();
421 } else {
422 break;
423 }
424 }
425
426 None
427}
428
429pub fn load_compiled_config(project_path: &Path) -> Result<CompiledConfig, ConfigError> {
431 let config = load_config(project_path)?;
432 CompiledConfig::from_config(config)
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn test_default_config() {
441 let config = CouplingConfig::default();
442 assert!(config.volatility.high.is_empty());
443 assert!(config.volatility.low.is_empty());
444 assert_eq!(config.thresholds.max_dependencies, 15);
445 assert_eq!(config.thresholds.max_dependents, 20);
446 }
447
448 #[test]
449 fn test_parse_config() {
450 let toml = r#"
451 [volatility]
452 high = ["src/api/*", "src/handlers/*"]
453 low = ["src/core/*"]
454 ignore = ["tests/*"]
455
456 [thresholds]
457 max_dependencies = 20
458 max_dependents = 30
459 "#;
460
461 let config: CouplingConfig = toml::from_str(toml).unwrap();
462 assert_eq!(config.volatility.high.len(), 2);
463 assert_eq!(config.volatility.low.len(), 1);
464 assert_eq!(config.volatility.ignore.len(), 1);
465 assert_eq!(config.thresholds.max_dependencies, 20);
466 assert_eq!(config.thresholds.max_dependents, 30);
467 }
468
469 #[test]
470 fn test_compiled_config() {
471 let toml = r#"
472 [volatility]
473 high = ["src/business/*"]
474 low = ["src/core/*"]
475 "#;
476
477 let config: CouplingConfig = toml::from_str(toml).unwrap();
478 let mut compiled = CompiledConfig::from_config(config).unwrap();
479
480 assert_eq!(
481 compiled.get_volatility_override("src/business/pricing.rs"),
482 Some(Volatility::High)
483 );
484 assert_eq!(
485 compiled.get_volatility_override("src/core/types.rs"),
486 Some(Volatility::Low)
487 );
488 assert_eq!(compiled.get_volatility_override("src/other/file.rs"), None);
489 }
490
491 #[test]
492 fn test_ignore_patterns() {
493 let toml = r#"
494 [volatility]
495 ignore = ["tests/*", "benches/*"]
496 "#;
497
498 let config: CouplingConfig = toml::from_str(toml).unwrap();
499 let compiled = CompiledConfig::from_config(config).unwrap();
500
501 assert!(compiled.should_ignore("tests/integration.rs"));
502 assert!(compiled.should_ignore("benches/perf.rs"));
503 assert!(!compiled.should_ignore("src/lib.rs"));
504 }
505
506 #[test]
507 fn test_get_volatility_with_fallback() {
508 let toml = r#"
509 [volatility]
510 high = ["src/api/*"]
511 "#;
512
513 let config: CouplingConfig = toml::from_str(toml).unwrap();
514 let mut compiled = CompiledConfig::from_config(config).unwrap();
515
516 assert_eq!(
518 compiled.get_volatility("src/api/handler.rs", Volatility::Low),
519 Volatility::High
520 );
521
522 assert_eq!(
524 compiled.get_volatility("src/other/file.rs", Volatility::Medium),
525 Volatility::Medium
526 );
527 }
528
529 #[test]
530 fn test_subdomain_config() {
531 let toml = r#"
532 [subdomains]
533 core = ["src/analyzer.rs", "src/balance.rs"]
534 supporting = ["src/report.rs", "src/cli_output.rs"]
535 generic = ["src/web/*", "src/config.rs"]
536 "#;
537
538 let config: CouplingConfig = toml::from_str(toml).unwrap();
539 let mut compiled = CompiledConfig::from_config(config).unwrap();
540
541 assert_eq!(
543 compiled.get_subdomain("src/analyzer.rs"),
544 Some(Subdomain::Core)
545 );
546 assert_eq!(
547 compiled.get_volatility_override("src/analyzer.rs"),
548 Some(Volatility::High)
549 );
550
551 assert_eq!(
553 compiled.get_subdomain("src/report.rs"),
554 Some(Subdomain::Supporting)
555 );
556 assert_eq!(
557 compiled.get_volatility_override("src/report.rs"),
558 Some(Volatility::Low)
559 );
560
561 assert_eq!(
563 compiled.get_subdomain("src/web/server.rs"),
564 Some(Subdomain::Generic)
565 );
566 assert_eq!(
567 compiled.get_volatility_override("src/web/server.rs"),
568 Some(Volatility::Low)
569 );
570
571 assert_eq!(compiled.get_subdomain("src/other.rs"), None);
573 assert_eq!(compiled.get_volatility_override("src/other.rs"), None);
574 }
575
576 #[test]
577 fn test_subdomain_display() {
578 assert_eq!(format!("{}", Subdomain::Core), "Core");
579 assert_eq!(format!("{}", Subdomain::Supporting), "Supporting");
580 assert_eq!(format!("{}", Subdomain::Generic), "Generic");
581 }
582
583 #[test]
584 fn test_subdomain_expected_volatility() {
585 assert_eq!(Subdomain::Core.expected_volatility(), Volatility::High);
586 assert_eq!(Subdomain::Supporting.expected_volatility(), Volatility::Low);
587 assert_eq!(Subdomain::Generic.expected_volatility(), Volatility::Low);
588 }
589
590 #[test]
591 fn test_has_subdomain_config() {
592 let compiled = CompiledConfig::empty();
594 assert!(!compiled.has_subdomain_config());
595
596 let toml = r#"
598 [subdomains]
599 core = ["src/analyzer.rs"]
600 "#;
601 let config: CouplingConfig = toml::from_str(toml).unwrap();
602 let compiled = CompiledConfig::from_config(config).unwrap();
603 assert!(compiled.has_subdomain_config());
604
605 let toml = r#"
607 [subdomains]
608 supporting = ["src/report.rs"]
609 "#;
610 let config: CouplingConfig = toml::from_str(toml).unwrap();
611 let compiled = CompiledConfig::from_config(config).unwrap();
612 assert!(compiled.has_subdomain_config());
613
614 let toml = r#"
616 [subdomains]
617 generic = ["src/web/*"]
618 "#;
619 let config: CouplingConfig = toml::from_str(toml).unwrap();
620 let compiled = CompiledConfig::from_config(config).unwrap();
621 assert!(compiled.has_subdomain_config());
622 }
623
624 #[test]
625 fn test_has_volatility_overrides() {
626 let compiled = CompiledConfig::empty();
628 assert!(!compiled.has_volatility_overrides());
629
630 let toml = r#"
632 [volatility]
633 high = ["src/core.rs"]
634 "#;
635 let config: CouplingConfig = toml::from_str(toml).unwrap();
636 let compiled = CompiledConfig::from_config(config).unwrap();
637 assert!(compiled.has_volatility_overrides());
638
639 let toml = r#"
641 [volatility]
642 medium = ["src/mid.rs"]
643 "#;
644 let config: CouplingConfig = toml::from_str(toml).unwrap();
645 let compiled = CompiledConfig::from_config(config).unwrap();
646 assert!(compiled.has_volatility_overrides());
647
648 let toml = r#"
650 [volatility]
651 low = ["src/stable.rs"]
652 "#;
653 let config: CouplingConfig = toml::from_str(toml).unwrap();
654 let compiled = CompiledConfig::from_config(config).unwrap();
655 assert!(compiled.has_volatility_overrides());
656 }
657
658 #[test]
659 fn test_volatility_override_beats_subdomain() {
660 let toml = r#"
661 [volatility]
662 low = ["src/analyzer.rs"]
663
664 [subdomains]
665 core = ["src/analyzer.rs"]
666 "#;
667
668 let config: CouplingConfig = toml::from_str(toml).unwrap();
669 let mut compiled = CompiledConfig::from_config(config).unwrap();
670
671 assert_eq!(
673 compiled.get_volatility_override("src/analyzer.rs"),
674 Some(Volatility::Low)
675 );
676 }
677}