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