1use glob::Pattern;
49use serde::Deserialize;
50use std::collections::HashMap;
51use std::fs;
52use std::path::{Component, Path, PathBuf};
53use thiserror::Error;
54
55use crate::metrics::dimensions::MetricsConfig;
56pub use crate::metrics::dimensions::Subdomain;
57use crate::volatility::Volatility;
58
59#[derive(Error, Debug)]
61pub enum ConfigError {
62 #[error("Failed to read config file: {0}")]
63 IoError(#[from] std::io::Error),
64
65 #[error("Failed to parse config file: {0}")]
66 ParseError(#[from] toml::de::Error),
67
68 #[error("Invalid glob pattern: {0}")]
69 PatternError(String),
70}
71
72#[derive(Debug, Clone, Deserialize, Default)]
74pub struct AnalysisConfig {
75 #[serde(default)]
77 pub exclude_tests: bool,
78
79 #[serde(default)]
82 pub prelude_modules: Vec<String>,
83
84 #[serde(default)]
86 pub exclude: Vec<String>,
87}
88
89#[derive(Debug, Clone, Deserialize, Default)]
91pub struct VolatilityConfig {
92 #[serde(default)]
94 pub high: Vec<String>,
95
96 #[serde(default)]
98 pub medium: Vec<String>,
99
100 #[serde(default)]
102 pub low: Vec<String>,
103
104 #[serde(default)]
106 pub ignore: Vec<String>,
107}
108
109#[derive(Debug, Clone, Deserialize, Default)]
117pub struct SubdomainConfig {
118 #[serde(default)]
120 pub core: Vec<String>,
121
122 #[serde(default)]
124 pub supporting: Vec<String>,
125
126 #[serde(default)]
128 pub generic: Vec<String>,
129}
130
131#[derive(Debug, Clone, Deserialize)]
133pub struct ThresholdsConfig {
134 #[serde(default = "default_max_dependencies")]
136 pub max_dependencies: usize,
137
138 #[serde(default = "default_max_dependents")]
140 pub max_dependents: usize,
141}
142
143fn default_max_dependencies() -> usize {
144 15
145}
146
147fn default_max_dependents() -> usize {
148 20
149}
150
151impl Default for ThresholdsConfig {
152 fn default() -> Self {
153 Self {
154 max_dependencies: default_max_dependencies(),
155 max_dependents: default_max_dependents(),
156 }
157 }
158}
159
160#[derive(Debug, Clone, Deserialize, Default)]
162pub struct CouplingConfig {
163 #[serde(default)]
165 pub analysis: AnalysisConfig,
166
167 #[serde(default)]
169 pub volatility: VolatilityConfig,
170
171 #[serde(default)]
173 pub subdomains: SubdomainConfig,
174
175 #[serde(default)]
177 pub thresholds: ThresholdsConfig,
178}
179
180#[derive(Debug, Clone)]
182pub struct CompiledConfig {
183 pub exclude_tests: bool,
186 config_root: Option<PathBuf>,
188 prelude_patterns: Vec<Pattern>,
190 exclude_patterns: Vec<Pattern>,
192
193 high_patterns: Vec<Pattern>,
196 medium_patterns: Vec<Pattern>,
198 low_patterns: Vec<Pattern>,
200 ignore_patterns: Vec<Pattern>,
202
203 core_patterns: Vec<Pattern>,
206 supporting_patterns: Vec<Pattern>,
208 generic_patterns: Vec<Pattern>,
210
211 pub thresholds: ThresholdsConfig,
214
215 cache: HashMap<String, Option<Volatility>>,
218}
219
220impl CompiledConfig {
221 pub fn from_config(config: CouplingConfig) -> Result<Self, ConfigError> {
223 Self::from_config_with_root(config, None)
224 }
225
226 fn from_config_with_root(
228 config: CouplingConfig,
229 config_root: Option<&Path>,
230 ) -> Result<Self, ConfigError> {
231 let compile_patterns = |patterns: &[String]| -> Result<Vec<Pattern>, ConfigError> {
232 patterns
233 .iter()
234 .map(|pattern| {
235 Pattern::new(pattern)
236 .map_err(|err| ConfigError::PatternError(format!("{}: {}", pattern, err)))
237 })
238 .collect()
239 };
240
241 Ok(Self {
242 exclude_tests: config.analysis.exclude_tests,
244 config_root: config_root.map(Path::to_path_buf),
245 prelude_patterns: compile_patterns(&config.analysis.prelude_modules)?,
246 exclude_patterns: compile_patterns(&config.analysis.exclude)?,
247 high_patterns: compile_patterns(&config.volatility.high)?,
249 medium_patterns: compile_patterns(&config.volatility.medium)?,
250 low_patterns: compile_patterns(&config.volatility.low)?,
251 ignore_patterns: compile_patterns(&config.volatility.ignore)?,
252 core_patterns: compile_patterns(&config.subdomains.core)?,
254 supporting_patterns: compile_patterns(&config.subdomains.supporting)?,
255 generic_patterns: compile_patterns(&config.subdomains.generic)?,
256 thresholds: config.thresholds,
258 cache: HashMap::new(),
259 })
260 }
261
262 pub fn empty() -> Self {
264 Self {
265 exclude_tests: false,
266 config_root: None,
267 prelude_patterns: Vec::new(),
268 exclude_patterns: Vec::new(),
269 high_patterns: Vec::new(),
270 medium_patterns: Vec::new(),
271 low_patterns: Vec::new(),
272 ignore_patterns: Vec::new(),
273 core_patterns: Vec::new(),
274 supporting_patterns: Vec::new(),
275 generic_patterns: Vec::new(),
276 thresholds: ThresholdsConfig::default(),
277 cache: HashMap::new(),
278 }
279 }
280
281 pub fn set_exclude_tests(&mut self, exclude: bool) {
283 self.exclude_tests = exclude;
284 }
285
286 pub fn config_root(&self) -> Option<&Path> {
288 self.config_root.as_deref()
289 }
290
291 pub(crate) fn set_config_root(&mut self, config_root: Option<PathBuf>) {
293 self.config_root = config_root;
294 }
295
296 pub fn is_prelude_module(&self, path: &str) -> bool {
298 self.prelude_patterns.iter().any(|p| p.matches(path))
299 }
300
301 pub fn should_exclude(&self, path: &str) -> bool {
303 self.exclude_patterns.iter().any(|p| p.matches(path))
304 }
305
306 pub fn should_ignore(&self, path: &str) -> bool {
308 self.ignore_patterns.iter().any(|p| p.matches(path))
309 || self.exclude_patterns.iter().any(|p| p.matches(path))
310 }
311
312 pub fn prelude_module_count(&self) -> usize {
314 self.prelude_patterns.len()
315 }
316
317 pub fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
319 if self.core_patterns.iter().any(|p| p.matches(path)) {
320 Some(Subdomain::Core)
321 } else if self.supporting_patterns.iter().any(|p| p.matches(path)) {
322 Some(Subdomain::Supporting)
323 } else if self.generic_patterns.iter().any(|p| p.matches(path)) {
324 Some(Subdomain::Generic)
325 } else {
326 None
327 }
328 }
329
330 pub fn has_subdomain_config(&self) -> bool {
332 !self.core_patterns.is_empty()
333 || !self.supporting_patterns.is_empty()
334 || !self.generic_patterns.is_empty()
335 }
336
337 pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
341 if let Some(cached) = self.cache.get(path) {
343 return *cached;
344 }
345
346 let result = if self.high_patterns.iter().any(|p| p.matches(path)) {
348 Some(Volatility::High)
349 } else if self.medium_patterns.iter().any(|p| p.matches(path)) {
350 Some(Volatility::Medium)
351 } else if self.low_patterns.iter().any(|p| p.matches(path)) {
352 Some(Volatility::Low)
353 } else {
354 self.get_subdomain(path).map(|sd| sd.expected_volatility())
356 };
357
358 self.cache.insert(path.to_string(), result);
360 result
361 }
362
363 pub fn get_volatility(&mut self, path: &str, git_volatility: Volatility) -> Volatility {
365 self.get_volatility_override(path).unwrap_or(git_volatility)
366 }
367
368 pub fn has_volatility_overrides(&self) -> bool {
370 !self.high_patterns.is_empty()
371 || !self.medium_patterns.is_empty()
372 || !self.low_patterns.is_empty()
373 }
374}
375
376impl MetricsConfig for CompiledConfig {
377 fn config_root(&self) -> Option<&Path> {
378 CompiledConfig::config_root(self)
379 }
380
381 fn has_volatility_overrides(&self) -> bool {
382 CompiledConfig::has_volatility_overrides(self)
383 }
384
385 fn has_subdomain_config(&self) -> bool {
386 CompiledConfig::has_subdomain_config(self)
387 }
388
389 fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
390 CompiledConfig::get_subdomain(self, path)
391 }
392
393 fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
394 CompiledConfig::get_volatility_override(self, path)
395 }
396}
397
398pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
402 let config_path = find_config_file(project_path);
404
405 match config_path {
406 Some(path) => {
407 let content = fs::read_to_string(&path)?;
408 let config: CouplingConfig = toml::from_str(&content)?;
409 Ok(config)
410 }
411 None => Ok(CouplingConfig::default()),
412 }
413}
414
415fn find_config_file(start_path: &Path) -> Option<std::path::PathBuf> {
417 let config_names = [".coupling.toml", "coupling.toml"];
418
419 let mut current = if start_path.is_file() {
420 start_path.parent()?.to_path_buf()
421 } else {
422 start_path.to_path_buf()
423 };
424
425 loop {
426 for name in &config_names {
427 let config_path = current.join(name);
428 if config_path.exists() {
429 return Some(config_path);
430 }
431 }
432
433 if let Some(parent) = current.parent() {
435 current = parent.to_path_buf();
436 } else {
437 break;
438 }
439 }
440
441 None
442}
443
444pub fn load_compiled_config(project_path: &Path) -> Result<CompiledConfig, ConfigError> {
446 match find_config_file(project_path) {
447 Some(path) => {
448 let content = fs::read_to_string(&path)?;
449 let config: CouplingConfig = toml::from_str(&content)?;
450 let absolute_path = absolute_normalized_path(&path)?;
451 CompiledConfig::from_config_with_root(config, absolute_path.parent())
452 }
453 None => Ok(CompiledConfig::empty()),
454 }
455}
456
457fn absolute_normalized_path(path: &Path) -> Result<PathBuf, std::io::Error> {
458 let absolute = if path.is_absolute() {
459 path.to_path_buf()
460 } else {
461 std::env::current_dir()?.join(path)
462 };
463
464 let mut normalized = PathBuf::new();
465 for component in absolute.components() {
466 match component {
467 Component::CurDir => {}
468 Component::ParentDir => {
469 normalized.pop();
470 }
471 other => normalized.push(other.as_os_str()),
472 }
473 }
474
475 Ok(normalized)
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn test_default_config() {
484 let config = CouplingConfig::default();
485 assert!(config.volatility.high.is_empty());
486 assert!(config.volatility.low.is_empty());
487 assert_eq!(config.thresholds.max_dependencies, 15);
488 assert_eq!(config.thresholds.max_dependents, 20);
489 }
490
491 #[test]
492 fn test_parse_config() {
493 let toml = r#"
494 [volatility]
495 high = ["src/api/*", "src/handlers/*"]
496 low = ["src/core/*"]
497 ignore = ["tests/*"]
498
499 [thresholds]
500 max_dependencies = 20
501 max_dependents = 30
502 "#;
503
504 let config: CouplingConfig = toml::from_str(toml).unwrap();
505 assert_eq!(config.volatility.high.len(), 2);
506 assert_eq!(config.volatility.low.len(), 1);
507 assert_eq!(config.volatility.ignore.len(), 1);
508 assert_eq!(config.thresholds.max_dependencies, 20);
509 assert_eq!(config.thresholds.max_dependents, 30);
510 }
511
512 #[test]
513 fn test_compiled_config() {
514 let toml = r#"
515 [volatility]
516 high = ["src/business/*"]
517 low = ["src/core/*"]
518 "#;
519
520 let config: CouplingConfig = toml::from_str(toml).unwrap();
521 let mut compiled = CompiledConfig::from_config(config).unwrap();
522
523 assert_eq!(
524 compiled.get_volatility_override("src/business/pricing.rs"),
525 Some(Volatility::High)
526 );
527 assert_eq!(
528 compiled.get_volatility_override("src/core/types.rs"),
529 Some(Volatility::Low)
530 );
531 assert_eq!(compiled.get_volatility_override("src/other/file.rs"), None);
532 }
533
534 #[test]
535 fn test_ignore_patterns() {
536 let toml = r#"
537 [volatility]
538 ignore = ["tests/*", "benches/*"]
539 "#;
540
541 let config: CouplingConfig = toml::from_str(toml).unwrap();
542 let compiled = CompiledConfig::from_config(config).unwrap();
543
544 assert!(compiled.should_ignore("tests/integration.rs"));
545 assert!(compiled.should_ignore("benches/perf.rs"));
546 assert!(!compiled.should_ignore("src/lib.rs"));
547 }
548
549 #[test]
550 fn test_get_volatility_with_fallback() {
551 let toml = r#"
552 [volatility]
553 high = ["src/api/*"]
554 "#;
555
556 let config: CouplingConfig = toml::from_str(toml).unwrap();
557 let mut compiled = CompiledConfig::from_config(config).unwrap();
558
559 assert_eq!(
561 compiled.get_volatility("src/api/handler.rs", Volatility::Low),
562 Volatility::High
563 );
564
565 assert_eq!(
567 compiled.get_volatility("src/other/file.rs", Volatility::Medium),
568 Volatility::Medium
569 );
570 }
571
572 #[test]
573 fn test_subdomain_config() {
574 let toml = r#"
575 [subdomains]
576 core = ["src/analyzer.rs", "src/balance.rs"]
577 supporting = ["src/report.rs", "src/cli_output.rs"]
578 generic = ["src/web/*", "src/config.rs"]
579 "#;
580
581 let config: CouplingConfig = toml::from_str(toml).unwrap();
582 let mut compiled = CompiledConfig::from_config(config).unwrap();
583
584 assert_eq!(
586 compiled.get_subdomain("src/analyzer.rs"),
587 Some(Subdomain::Core)
588 );
589 assert_eq!(
590 compiled.get_volatility_override("src/analyzer.rs"),
591 Some(Volatility::High)
592 );
593
594 assert_eq!(
596 compiled.get_subdomain("src/report.rs"),
597 Some(Subdomain::Supporting)
598 );
599 assert_eq!(
600 compiled.get_volatility_override("src/report.rs"),
601 Some(Volatility::Low)
602 );
603
604 assert_eq!(
606 compiled.get_subdomain("src/web/server.rs"),
607 Some(Subdomain::Generic)
608 );
609 assert_eq!(
610 compiled.get_volatility_override("src/web/server.rs"),
611 Some(Volatility::Low)
612 );
613
614 assert_eq!(compiled.get_subdomain("src/other.rs"), None);
616 assert_eq!(compiled.get_volatility_override("src/other.rs"), None);
617 }
618
619 #[test]
620 fn test_subdomain_display() {
621 assert_eq!(format!("{}", Subdomain::Core), "Core");
622 assert_eq!(format!("{}", Subdomain::Supporting), "Supporting");
623 assert_eq!(format!("{}", Subdomain::Generic), "Generic");
624 }
625
626 #[test]
627 fn test_subdomain_expected_volatility() {
628 assert_eq!(Subdomain::Core.expected_volatility(), Volatility::High);
629 assert_eq!(Subdomain::Supporting.expected_volatility(), Volatility::Low);
630 assert_eq!(Subdomain::Generic.expected_volatility(), Volatility::Low);
631 }
632
633 #[test]
634 fn test_has_subdomain_config() {
635 let compiled = CompiledConfig::empty();
637 assert!(!compiled.has_subdomain_config());
638
639 let toml = r#"
641 [subdomains]
642 core = ["src/analyzer.rs"]
643 "#;
644 let config: CouplingConfig = toml::from_str(toml).unwrap();
645 let compiled = CompiledConfig::from_config(config).unwrap();
646 assert!(compiled.has_subdomain_config());
647
648 let toml = r#"
650 [subdomains]
651 supporting = ["src/report.rs"]
652 "#;
653 let config: CouplingConfig = toml::from_str(toml).unwrap();
654 let compiled = CompiledConfig::from_config(config).unwrap();
655 assert!(compiled.has_subdomain_config());
656
657 let toml = r#"
659 [subdomains]
660 generic = ["src/web/*"]
661 "#;
662 let config: CouplingConfig = toml::from_str(toml).unwrap();
663 let compiled = CompiledConfig::from_config(config).unwrap();
664 assert!(compiled.has_subdomain_config());
665 }
666
667 #[test]
668 fn test_has_volatility_overrides() {
669 let compiled = CompiledConfig::empty();
671 assert!(!compiled.has_volatility_overrides());
672
673 let toml = r#"
675 [volatility]
676 high = ["src/core.rs"]
677 "#;
678 let config: CouplingConfig = toml::from_str(toml).unwrap();
679 let compiled = CompiledConfig::from_config(config).unwrap();
680 assert!(compiled.has_volatility_overrides());
681
682 let toml = r#"
684 [volatility]
685 medium = ["src/mid.rs"]
686 "#;
687 let config: CouplingConfig = toml::from_str(toml).unwrap();
688 let compiled = CompiledConfig::from_config(config).unwrap();
689 assert!(compiled.has_volatility_overrides());
690
691 let toml = r#"
693 [volatility]
694 low = ["src/stable.rs"]
695 "#;
696 let config: CouplingConfig = toml::from_str(toml).unwrap();
697 let compiled = CompiledConfig::from_config(config).unwrap();
698 assert!(compiled.has_volatility_overrides());
699 }
700
701 #[test]
702 fn test_volatility_override_beats_subdomain() {
703 let toml = r#"
704 [volatility]
705 low = ["src/analyzer.rs"]
706
707 [subdomains]
708 core = ["src/analyzer.rs"]
709 "#;
710
711 let config: CouplingConfig = toml::from_str(toml).unwrap();
712 let mut compiled = CompiledConfig::from_config(config).unwrap();
713
714 assert_eq!(
716 compiled.get_volatility_override("src/analyzer.rs"),
717 Some(Volatility::Low)
718 );
719 }
720}