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
220#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct DeadConfigPattern {
223 pub section: &'static str,
225 pub pattern: String,
227}
228
229impl CompiledConfig {
230 pub fn from_config(config: CouplingConfig) -> Result<Self, ConfigError> {
232 Self::from_config_with_root(config, None)
233 }
234
235 fn from_config_with_root(
237 config: CouplingConfig,
238 config_root: Option<&Path>,
239 ) -> Result<Self, ConfigError> {
240 let compile_patterns = |patterns: &[String]| -> Result<Vec<Pattern>, ConfigError> {
241 patterns
242 .iter()
243 .map(|pattern| {
244 Pattern::new(pattern)
245 .map_err(|err| ConfigError::PatternError(format!("{}: {}", pattern, err)))
246 })
247 .collect()
248 };
249
250 Ok(Self {
251 exclude_tests: config.analysis.exclude_tests,
253 config_root: config_root.map(Path::to_path_buf),
254 prelude_patterns: compile_patterns(&config.analysis.prelude_modules)?,
255 exclude_patterns: compile_patterns(&config.analysis.exclude)?,
256 high_patterns: compile_patterns(&config.volatility.high)?,
258 medium_patterns: compile_patterns(&config.volatility.medium)?,
259 low_patterns: compile_patterns(&config.volatility.low)?,
260 ignore_patterns: compile_patterns(&config.volatility.ignore)?,
261 core_patterns: compile_patterns(&config.subdomains.core)?,
263 supporting_patterns: compile_patterns(&config.subdomains.supporting)?,
264 generic_patterns: compile_patterns(&config.subdomains.generic)?,
265 thresholds: config.thresholds,
267 cache: HashMap::new(),
268 })
269 }
270
271 pub fn empty() -> Self {
273 Self {
274 exclude_tests: false,
275 config_root: None,
276 prelude_patterns: Vec::new(),
277 exclude_patterns: Vec::new(),
278 high_patterns: Vec::new(),
279 medium_patterns: Vec::new(),
280 low_patterns: Vec::new(),
281 ignore_patterns: Vec::new(),
282 core_patterns: Vec::new(),
283 supporting_patterns: Vec::new(),
284 generic_patterns: Vec::new(),
285 thresholds: ThresholdsConfig::default(),
286 cache: HashMap::new(),
287 }
288 }
289
290 pub fn set_exclude_tests(&mut self, exclude: bool) {
292 self.exclude_tests = exclude;
293 }
294
295 pub fn config_root(&self) -> Option<&Path> {
297 self.config_root.as_deref()
298 }
299
300 pub(crate) fn set_config_root(&mut self, config_root: Option<PathBuf>) {
302 self.config_root = config_root;
303 }
304
305 pub fn is_prelude_module(&self, path: &str) -> bool {
307 self.prelude_patterns.iter().any(|p| p.matches(path))
308 }
309
310 pub fn should_exclude(&self, path: &str) -> bool {
312 self.exclude_patterns.iter().any(|p| p.matches(path))
313 }
314
315 pub fn should_ignore(&self, path: &str) -> bool {
317 self.ignore_patterns.iter().any(|p| p.matches(path))
318 || self.exclude_patterns.iter().any(|p| p.matches(path))
319 }
320
321 pub fn dead_patterns(&self, candidate_paths: &[String]) -> Vec<DeadConfigPattern> {
330 let mut dead = Vec::new();
331 Self::add_dead_patterns(
332 &mut dead,
333 "subdomains.core",
334 &self.core_patterns,
335 candidate_paths,
336 );
337 Self::add_dead_patterns(
338 &mut dead,
339 "subdomains.supporting",
340 &self.supporting_patterns,
341 candidate_paths,
342 );
343 Self::add_dead_patterns(
344 &mut dead,
345 "subdomains.generic",
346 &self.generic_patterns,
347 candidate_paths,
348 );
349 Self::add_dead_patterns(
350 &mut dead,
351 "volatility.high",
352 &self.high_patterns,
353 candidate_paths,
354 );
355 Self::add_dead_patterns(
356 &mut dead,
357 "volatility.medium",
358 &self.medium_patterns,
359 candidate_paths,
360 );
361 Self::add_dead_patterns(
362 &mut dead,
363 "volatility.low",
364 &self.low_patterns,
365 candidate_paths,
366 );
367 dead
368 }
369
370 fn add_dead_patterns(
371 dead: &mut Vec<DeadConfigPattern>,
372 section: &'static str,
373 patterns: &[Pattern],
374 candidate_paths: &[String],
375 ) {
376 dead.extend(patterns.iter().filter_map(|pattern| {
377 if candidate_paths
378 .iter()
379 .any(|path| pattern.matches(path.as_str()))
380 {
381 None
382 } else {
383 Some(DeadConfigPattern {
384 section,
385 pattern: pattern.as_str().to_string(),
386 })
387 }
388 }));
389 }
390
391 pub fn prelude_module_count(&self) -> usize {
393 self.prelude_patterns.len()
394 }
395
396 pub fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
398 if self.core_patterns.iter().any(|p| p.matches(path)) {
399 Some(Subdomain::Core)
400 } else if self.supporting_patterns.iter().any(|p| p.matches(path)) {
401 Some(Subdomain::Supporting)
402 } else if self.generic_patterns.iter().any(|p| p.matches(path)) {
403 Some(Subdomain::Generic)
404 } else {
405 None
406 }
407 }
408
409 pub fn has_subdomain_config(&self) -> bool {
411 !self.core_patterns.is_empty()
412 || !self.supporting_patterns.is_empty()
413 || !self.generic_patterns.is_empty()
414 }
415
416 pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
420 if let Some(cached) = self.cache.get(path) {
422 return *cached;
423 }
424
425 let result = if self.high_patterns.iter().any(|p| p.matches(path)) {
427 Some(Volatility::High)
428 } else if self.medium_patterns.iter().any(|p| p.matches(path)) {
429 Some(Volatility::Medium)
430 } else if self.low_patterns.iter().any(|p| p.matches(path)) {
431 Some(Volatility::Low)
432 } else {
433 self.get_subdomain(path).map(|sd| sd.expected_volatility())
435 };
436
437 self.cache.insert(path.to_string(), result);
439 result
440 }
441
442 pub fn get_volatility(&mut self, path: &str, git_volatility: Volatility) -> Volatility {
444 self.get_volatility_override(path).unwrap_or(git_volatility)
445 }
446
447 pub fn has_volatility_overrides(&self) -> bool {
449 !self.high_patterns.is_empty()
450 || !self.medium_patterns.is_empty()
451 || !self.low_patterns.is_empty()
452 }
453}
454
455impl MetricsConfig for CompiledConfig {
456 fn config_root(&self) -> Option<&Path> {
457 CompiledConfig::config_root(self)
458 }
459
460 fn has_volatility_overrides(&self) -> bool {
461 CompiledConfig::has_volatility_overrides(self)
462 }
463
464 fn has_subdomain_config(&self) -> bool {
465 CompiledConfig::has_subdomain_config(self)
466 }
467
468 fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
469 CompiledConfig::get_subdomain(self, path)
470 }
471
472 fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
473 CompiledConfig::get_volatility_override(self, path)
474 }
475}
476
477pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
481 let config_path = find_config_file(project_path);
483
484 match config_path {
485 Some(path) => {
486 let content = fs::read_to_string(&path)?;
487 let config: CouplingConfig = toml::from_str(&content)?;
488 Ok(config)
489 }
490 None => Ok(CouplingConfig::default()),
491 }
492}
493
494fn find_config_file(start_path: &Path) -> Option<std::path::PathBuf> {
496 let config_names = [".coupling.toml", "coupling.toml"];
497
498 let mut current = if start_path.is_file() {
499 start_path.parent()?.to_path_buf()
500 } else {
501 start_path.to_path_buf()
502 };
503
504 loop {
505 for name in &config_names {
506 let config_path = current.join(name);
507 if config_path.exists() {
508 return Some(config_path);
509 }
510 }
511
512 if let Some(parent) = current.parent() {
514 current = parent.to_path_buf();
515 } else {
516 break;
517 }
518 }
519
520 None
521}
522
523pub fn load_compiled_config(project_path: &Path) -> Result<CompiledConfig, ConfigError> {
525 match find_config_file(project_path) {
526 Some(path) => {
527 let content = fs::read_to_string(&path)?;
528 let config: CouplingConfig = toml::from_str(&content)?;
529 let absolute_path = absolute_normalized_path(&path)?;
530 CompiledConfig::from_config_with_root(config, absolute_path.parent())
531 }
532 None => Ok(CompiledConfig::empty()),
533 }
534}
535
536fn absolute_normalized_path(path: &Path) -> Result<PathBuf, std::io::Error> {
537 let absolute = if path.is_absolute() {
538 path.to_path_buf()
539 } else {
540 std::env::current_dir()?.join(path)
541 };
542
543 let mut normalized = PathBuf::new();
544 for component in absolute.components() {
545 match component {
546 Component::CurDir => {}
547 Component::ParentDir => {
548 normalized.pop();
549 }
550 other => normalized.push(other.as_os_str()),
551 }
552 }
553
554 Ok(normalized)
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn test_default_config() {
563 let config = CouplingConfig::default();
564 assert!(config.volatility.high.is_empty());
565 assert!(config.volatility.low.is_empty());
566 assert_eq!(config.thresholds.max_dependencies, 15);
567 assert_eq!(config.thresholds.max_dependents, 20);
568 }
569
570 #[test]
571 fn test_parse_config() {
572 let toml = r#"
573 [volatility]
574 high = ["src/api/*", "src/handlers/*"]
575 low = ["src/core/*"]
576 ignore = ["tests/*"]
577
578 [thresholds]
579 max_dependencies = 20
580 max_dependents = 30
581 "#;
582
583 let config: CouplingConfig = toml::from_str(toml).unwrap();
584 assert_eq!(config.volatility.high.len(), 2);
585 assert_eq!(config.volatility.low.len(), 1);
586 assert_eq!(config.volatility.ignore.len(), 1);
587 assert_eq!(config.thresholds.max_dependencies, 20);
588 assert_eq!(config.thresholds.max_dependents, 30);
589 }
590
591 #[test]
592 fn test_compiled_config() {
593 let toml = r#"
594 [volatility]
595 high = ["src/business/*"]
596 low = ["src/core/*"]
597 "#;
598
599 let config: CouplingConfig = toml::from_str(toml).unwrap();
600 let mut compiled = CompiledConfig::from_config(config).unwrap();
601
602 assert_eq!(
603 compiled.get_volatility_override("src/business/pricing.rs"),
604 Some(Volatility::High)
605 );
606 assert_eq!(
607 compiled.get_volatility_override("src/core/types.rs"),
608 Some(Volatility::Low)
609 );
610 assert_eq!(compiled.get_volatility_override("src/other/file.rs"), None);
611 }
612
613 #[test]
614 fn test_ignore_patterns() {
615 let toml = r#"
616 [volatility]
617 ignore = ["tests/*", "benches/*"]
618 "#;
619
620 let config: CouplingConfig = toml::from_str(toml).unwrap();
621 let compiled = CompiledConfig::from_config(config).unwrap();
622
623 assert!(compiled.should_ignore("tests/integration.rs"));
624 assert!(compiled.should_ignore("benches/perf.rs"));
625 assert!(!compiled.should_ignore("src/lib.rs"));
626 }
627
628 #[test]
629 fn test_get_volatility_with_fallback() {
630 let toml = r#"
631 [volatility]
632 high = ["src/api/*"]
633 "#;
634
635 let config: CouplingConfig = toml::from_str(toml).unwrap();
636 let mut compiled = CompiledConfig::from_config(config).unwrap();
637
638 assert_eq!(
640 compiled.get_volatility("src/api/handler.rs", Volatility::Low),
641 Volatility::High
642 );
643
644 assert_eq!(
646 compiled.get_volatility("src/other/file.rs", Volatility::Medium),
647 Volatility::Medium
648 );
649 }
650
651 #[test]
652 fn test_subdomain_config() {
653 let toml = r#"
654 [subdomains]
655 core = ["src/analyzer.rs", "src/balance.rs"]
656 supporting = ["src/report.rs", "src/cli_output.rs"]
657 generic = ["src/web/*", "src/config.rs"]
658 "#;
659
660 let config: CouplingConfig = toml::from_str(toml).unwrap();
661 let mut compiled = CompiledConfig::from_config(config).unwrap();
662
663 assert_eq!(
665 compiled.get_subdomain("src/analyzer.rs"),
666 Some(Subdomain::Core)
667 );
668 assert_eq!(
669 compiled.get_volatility_override("src/analyzer.rs"),
670 Some(Volatility::High)
671 );
672
673 assert_eq!(
675 compiled.get_subdomain("src/report.rs"),
676 Some(Subdomain::Supporting)
677 );
678 assert_eq!(
679 compiled.get_volatility_override("src/report.rs"),
680 Some(Volatility::Low)
681 );
682
683 assert_eq!(
685 compiled.get_subdomain("src/web/server.rs"),
686 Some(Subdomain::Generic)
687 );
688 assert_eq!(
689 compiled.get_volatility_override("src/web/server.rs"),
690 Some(Volatility::Low)
691 );
692
693 assert_eq!(compiled.get_subdomain("src/other.rs"), None);
695 assert_eq!(compiled.get_volatility_override("src/other.rs"), None);
696 }
697
698 #[test]
699 fn test_subdomain_display() {
700 assert_eq!(format!("{}", Subdomain::Core), "Core");
701 assert_eq!(format!("{}", Subdomain::Supporting), "Supporting");
702 assert_eq!(format!("{}", Subdomain::Generic), "Generic");
703 }
704
705 #[test]
706 fn test_subdomain_expected_volatility() {
707 assert_eq!(Subdomain::Core.expected_volatility(), Volatility::High);
708 assert_eq!(Subdomain::Supporting.expected_volatility(), Volatility::Low);
709 assert_eq!(Subdomain::Generic.expected_volatility(), Volatility::Low);
710 }
711
712 #[test]
713 fn test_has_subdomain_config() {
714 let compiled = CompiledConfig::empty();
716 assert!(!compiled.has_subdomain_config());
717
718 let toml = r#"
720 [subdomains]
721 core = ["src/analyzer.rs"]
722 "#;
723 let config: CouplingConfig = toml::from_str(toml).unwrap();
724 let compiled = CompiledConfig::from_config(config).unwrap();
725 assert!(compiled.has_subdomain_config());
726
727 let toml = r#"
729 [subdomains]
730 supporting = ["src/report.rs"]
731 "#;
732 let config: CouplingConfig = toml::from_str(toml).unwrap();
733 let compiled = CompiledConfig::from_config(config).unwrap();
734 assert!(compiled.has_subdomain_config());
735
736 let toml = r#"
738 [subdomains]
739 generic = ["src/web/*"]
740 "#;
741 let config: CouplingConfig = toml::from_str(toml).unwrap();
742 let compiled = CompiledConfig::from_config(config).unwrap();
743 assert!(compiled.has_subdomain_config());
744 }
745
746 #[test]
747 fn test_has_volatility_overrides() {
748 let compiled = CompiledConfig::empty();
750 assert!(!compiled.has_volatility_overrides());
751
752 let toml = r#"
754 [volatility]
755 high = ["src/core.rs"]
756 "#;
757 let config: CouplingConfig = toml::from_str(toml).unwrap();
758 let compiled = CompiledConfig::from_config(config).unwrap();
759 assert!(compiled.has_volatility_overrides());
760
761 let toml = r#"
763 [volatility]
764 medium = ["src/mid.rs"]
765 "#;
766 let config: CouplingConfig = toml::from_str(toml).unwrap();
767 let compiled = CompiledConfig::from_config(config).unwrap();
768 assert!(compiled.has_volatility_overrides());
769
770 let toml = r#"
772 [volatility]
773 low = ["src/stable.rs"]
774 "#;
775 let config: CouplingConfig = toml::from_str(toml).unwrap();
776 let compiled = CompiledConfig::from_config(config).unwrap();
777 assert!(compiled.has_volatility_overrides());
778 }
779
780 #[test]
781 fn test_volatility_override_beats_subdomain() {
782 let toml = r#"
783 [volatility]
784 low = ["src/analyzer.rs"]
785
786 [subdomains]
787 core = ["src/analyzer.rs"]
788 "#;
789
790 let config: CouplingConfig = toml::from_str(toml).unwrap();
791 let mut compiled = CompiledConfig::from_config(config).unwrap();
792
793 assert_eq!(
795 compiled.get_volatility_override("src/analyzer.rs"),
796 Some(Volatility::Low)
797 );
798 }
799
800 #[test]
801 fn dead_patterns_reports_only_unmatched_patterns_by_section() {
802 let toml = r#"
803 [analysis]
804 prelude_modules = ["src/lib.rs", "src/missing_prelude.rs"]
805 exclude = ["src/never_matches/**"]
806
807 [volatility]
808 high = ["src/core/**"]
809 medium = ["src/medium.rs"]
810 low = ["src/stable.rs"]
811
812 [subdomains]
813 core = ["src/core/**", "src/old_core.rs"]
814 supporting = ["src/supporting.rs"]
815 generic = ["src/generic.rs"]
816 "#;
817
818 let config: CouplingConfig = toml::from_str(toml).unwrap();
819 let compiled = CompiledConfig::from_config(config).unwrap();
820 let candidate_paths = vec![
821 "src/lib.rs".to_string(),
822 "src/core/mod.rs".to_string(),
823 "src/supporting.rs".to_string(),
824 "src/stable.rs".to_string(),
825 ];
826
827 let dead = compiled.dead_patterns(&candidate_paths);
828
829 assert_eq!(
830 dead,
831 vec![
832 DeadConfigPattern {
833 section: "subdomains.core",
834 pattern: "src/old_core.rs".to_string(),
835 },
836 DeadConfigPattern {
837 section: "subdomains.generic",
838 pattern: "src/generic.rs".to_string(),
839 },
840 DeadConfigPattern {
841 section: "volatility.medium",
842 pattern: "src/medium.rs".to_string(),
843 },
844 ]
845 );
846 }
847
848 #[test]
849 fn dead_patterns_never_reports_exclude_or_prelude() {
850 let toml = r#"
854 [analysis]
855 prelude_modules = ["src/missing_prelude.rs"]
856 exclude = ["src/never_matches/**"]
857 "#;
858
859 let config: CouplingConfig = toml::from_str(toml).unwrap();
860 let compiled = CompiledConfig::from_config(config).unwrap();
861
862 assert!(
863 compiled
864 .dead_patterns(&["src/lib.rs".to_string()])
865 .is_empty()
866 );
867 }
868
869 #[test]
870 fn empty_config_has_no_dead_patterns() {
871 let candidate_paths = vec!["src/lib.rs".to_string()];
872 assert!(
873 CompiledConfig::empty()
874 .dead_patterns(&candidate_paths)
875 .is_empty()
876 );
877 }
878}