use glob::Pattern;
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::{Component, Path, PathBuf};
use thiserror::Error;
use crate::metrics::dimensions::MetricsConfig;
pub use crate::metrics::dimensions::Subdomain;
use crate::volatility::Volatility;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("Failed to read config file: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse config file: {0}")]
ParseError(#[from] toml::de::Error),
#[error("Invalid glob pattern: {0}")]
PatternError(String),
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct AnalysisConfig {
#[serde(default)]
pub exclude_tests: bool,
#[serde(default)]
pub prelude_modules: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct VolatilityConfig {
#[serde(default)]
pub high: Vec<String>,
#[serde(default)]
pub medium: Vec<String>,
#[serde(default)]
pub low: Vec<String>,
#[serde(default)]
pub ignore: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SubdomainConfig {
#[serde(default)]
pub core: Vec<String>,
#[serde(default)]
pub supporting: Vec<String>,
#[serde(default)]
pub generic: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ThresholdsConfig {
#[serde(default = "default_max_dependencies")]
pub max_dependencies: usize,
#[serde(default = "default_max_dependents")]
pub max_dependents: usize,
}
fn default_max_dependencies() -> usize {
15
}
fn default_max_dependents() -> usize {
20
}
impl Default for ThresholdsConfig {
fn default() -> Self {
Self {
max_dependencies: default_max_dependencies(),
max_dependents: default_max_dependents(),
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct CouplingConfig {
#[serde(default)]
pub analysis: AnalysisConfig,
#[serde(default)]
pub volatility: VolatilityConfig,
#[serde(default)]
pub subdomains: SubdomainConfig,
#[serde(default)]
pub thresholds: ThresholdsConfig,
}
#[derive(Debug, Clone)]
pub struct CompiledConfig {
pub exclude_tests: bool,
config_root: Option<PathBuf>,
prelude_patterns: Vec<Pattern>,
exclude_patterns: Vec<Pattern>,
high_patterns: Vec<Pattern>,
medium_patterns: Vec<Pattern>,
low_patterns: Vec<Pattern>,
ignore_patterns: Vec<Pattern>,
core_patterns: Vec<Pattern>,
supporting_patterns: Vec<Pattern>,
generic_patterns: Vec<Pattern>,
pub thresholds: ThresholdsConfig,
cache: HashMap<String, Option<Volatility>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeadConfigPattern {
pub section: &'static str,
pub pattern: String,
}
impl CompiledConfig {
pub fn from_config(config: CouplingConfig) -> Result<Self, ConfigError> {
Self::from_config_with_root(config, None)
}
fn from_config_with_root(
config: CouplingConfig,
config_root: Option<&Path>,
) -> Result<Self, ConfigError> {
let compile_patterns = |patterns: &[String]| -> Result<Vec<Pattern>, ConfigError> {
patterns
.iter()
.map(|pattern| {
Pattern::new(pattern)
.map_err(|err| ConfigError::PatternError(format!("{}: {}", pattern, err)))
})
.collect()
};
Ok(Self {
exclude_tests: config.analysis.exclude_tests,
config_root: config_root.map(Path::to_path_buf),
prelude_patterns: compile_patterns(&config.analysis.prelude_modules)?,
exclude_patterns: compile_patterns(&config.analysis.exclude)?,
high_patterns: compile_patterns(&config.volatility.high)?,
medium_patterns: compile_patterns(&config.volatility.medium)?,
low_patterns: compile_patterns(&config.volatility.low)?,
ignore_patterns: compile_patterns(&config.volatility.ignore)?,
core_patterns: compile_patterns(&config.subdomains.core)?,
supporting_patterns: compile_patterns(&config.subdomains.supporting)?,
generic_patterns: compile_patterns(&config.subdomains.generic)?,
thresholds: config.thresholds,
cache: HashMap::new(),
})
}
pub fn empty() -> Self {
Self {
exclude_tests: false,
config_root: None,
prelude_patterns: Vec::new(),
exclude_patterns: Vec::new(),
high_patterns: Vec::new(),
medium_patterns: Vec::new(),
low_patterns: Vec::new(),
ignore_patterns: Vec::new(),
core_patterns: Vec::new(),
supporting_patterns: Vec::new(),
generic_patterns: Vec::new(),
thresholds: ThresholdsConfig::default(),
cache: HashMap::new(),
}
}
pub fn set_exclude_tests(&mut self, exclude: bool) {
self.exclude_tests = exclude;
}
pub fn config_root(&self) -> Option<&Path> {
self.config_root.as_deref()
}
pub(crate) fn set_config_root(&mut self, config_root: Option<PathBuf>) {
self.config_root = config_root;
}
pub fn is_prelude_module(&self, path: &str) -> bool {
self.prelude_patterns.iter().any(|p| p.matches(path))
}
pub fn should_exclude(&self, path: &str) -> bool {
self.exclude_patterns.iter().any(|p| p.matches(path))
}
pub fn should_ignore(&self, path: &str) -> bool {
self.ignore_patterns.iter().any(|p| p.matches(path))
|| self.exclude_patterns.iter().any(|p| p.matches(path))
}
pub fn dead_patterns(&self, candidate_paths: &[String]) -> Vec<DeadConfigPattern> {
let mut dead = Vec::new();
Self::add_dead_patterns(
&mut dead,
"subdomains.core",
&self.core_patterns,
candidate_paths,
);
Self::add_dead_patterns(
&mut dead,
"subdomains.supporting",
&self.supporting_patterns,
candidate_paths,
);
Self::add_dead_patterns(
&mut dead,
"subdomains.generic",
&self.generic_patterns,
candidate_paths,
);
Self::add_dead_patterns(
&mut dead,
"volatility.high",
&self.high_patterns,
candidate_paths,
);
Self::add_dead_patterns(
&mut dead,
"volatility.medium",
&self.medium_patterns,
candidate_paths,
);
Self::add_dead_patterns(
&mut dead,
"volatility.low",
&self.low_patterns,
candidate_paths,
);
dead
}
fn add_dead_patterns(
dead: &mut Vec<DeadConfigPattern>,
section: &'static str,
patterns: &[Pattern],
candidate_paths: &[String],
) {
dead.extend(patterns.iter().filter_map(|pattern| {
if candidate_paths
.iter()
.any(|path| pattern.matches(path.as_str()))
{
None
} else {
Some(DeadConfigPattern {
section,
pattern: pattern.as_str().to_string(),
})
}
}));
}
pub fn prelude_module_count(&self) -> usize {
self.prelude_patterns.len()
}
pub fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
if self.core_patterns.iter().any(|p| p.matches(path)) {
Some(Subdomain::Core)
} else if self.supporting_patterns.iter().any(|p| p.matches(path)) {
Some(Subdomain::Supporting)
} else if self.generic_patterns.iter().any(|p| p.matches(path)) {
Some(Subdomain::Generic)
} else {
None
}
}
pub fn has_subdomain_config(&self) -> bool {
!self.core_patterns.is_empty()
|| !self.supporting_patterns.is_empty()
|| !self.generic_patterns.is_empty()
}
pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
if let Some(cached) = self.cache.get(path) {
return *cached;
}
let result = if self.high_patterns.iter().any(|p| p.matches(path)) {
Some(Volatility::High)
} else if self.medium_patterns.iter().any(|p| p.matches(path)) {
Some(Volatility::Medium)
} else if self.low_patterns.iter().any(|p| p.matches(path)) {
Some(Volatility::Low)
} else {
self.get_subdomain(path).map(|sd| sd.expected_volatility())
};
self.cache.insert(path.to_string(), result);
result
}
pub fn get_volatility(&mut self, path: &str, git_volatility: Volatility) -> Volatility {
self.get_volatility_override(path).unwrap_or(git_volatility)
}
pub fn has_volatility_overrides(&self) -> bool {
!self.high_patterns.is_empty()
|| !self.medium_patterns.is_empty()
|| !self.low_patterns.is_empty()
}
}
impl MetricsConfig for CompiledConfig {
fn config_root(&self) -> Option<&Path> {
CompiledConfig::config_root(self)
}
fn has_volatility_overrides(&self) -> bool {
CompiledConfig::has_volatility_overrides(self)
}
fn has_subdomain_config(&self) -> bool {
CompiledConfig::has_subdomain_config(self)
}
fn get_subdomain(&self, path: &str) -> Option<Subdomain> {
CompiledConfig::get_subdomain(self, path)
}
fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
CompiledConfig::get_volatility_override(self, path)
}
}
pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
let config_path = find_config_file(project_path);
match config_path {
Some(path) => {
let content = fs::read_to_string(&path)?;
let config: CouplingConfig = toml::from_str(&content)?;
Ok(config)
}
None => Ok(CouplingConfig::default()),
}
}
fn find_config_file(start_path: &Path) -> Option<std::path::PathBuf> {
let config_names = [".coupling.toml", "coupling.toml"];
let mut current = if start_path.is_file() {
start_path.parent()?.to_path_buf()
} else {
start_path.to_path_buf()
};
loop {
for name in &config_names {
let config_path = current.join(name);
if config_path.exists() {
return Some(config_path);
}
}
if let Some(parent) = current.parent() {
current = parent.to_path_buf();
} else {
break;
}
}
None
}
pub fn load_compiled_config(project_path: &Path) -> Result<CompiledConfig, ConfigError> {
match find_config_file(project_path) {
Some(path) => {
let content = fs::read_to_string(&path)?;
let config: CouplingConfig = toml::from_str(&content)?;
let absolute_path = absolute_normalized_path(&path)?;
CompiledConfig::from_config_with_root(config, absolute_path.parent())
}
None => Ok(CompiledConfig::empty()),
}
}
fn absolute_normalized_path(path: &Path) -> Result<PathBuf, std::io::Error> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let mut normalized = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
Ok(normalized)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = CouplingConfig::default();
assert!(config.volatility.high.is_empty());
assert!(config.volatility.low.is_empty());
assert_eq!(config.thresholds.max_dependencies, 15);
assert_eq!(config.thresholds.max_dependents, 20);
}
#[test]
fn test_parse_config() {
let toml = r#"
[volatility]
high = ["src/api/*", "src/handlers/*"]
low = ["src/core/*"]
ignore = ["tests/*"]
[thresholds]
max_dependencies = 20
max_dependents = 30
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
assert_eq!(config.volatility.high.len(), 2);
assert_eq!(config.volatility.low.len(), 1);
assert_eq!(config.volatility.ignore.len(), 1);
assert_eq!(config.thresholds.max_dependencies, 20);
assert_eq!(config.thresholds.max_dependents, 30);
}
#[test]
fn test_compiled_config() {
let toml = r#"
[volatility]
high = ["src/business/*"]
low = ["src/core/*"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let mut compiled = CompiledConfig::from_config(config).unwrap();
assert_eq!(
compiled.get_volatility_override("src/business/pricing.rs"),
Some(Volatility::High)
);
assert_eq!(
compiled.get_volatility_override("src/core/types.rs"),
Some(Volatility::Low)
);
assert_eq!(compiled.get_volatility_override("src/other/file.rs"), None);
}
#[test]
fn test_ignore_patterns() {
let toml = r#"
[volatility]
ignore = ["tests/*", "benches/*"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let compiled = CompiledConfig::from_config(config).unwrap();
assert!(compiled.should_ignore("tests/integration.rs"));
assert!(compiled.should_ignore("benches/perf.rs"));
assert!(!compiled.should_ignore("src/lib.rs"));
}
#[test]
fn test_get_volatility_with_fallback() {
let toml = r#"
[volatility]
high = ["src/api/*"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let mut compiled = CompiledConfig::from_config(config).unwrap();
assert_eq!(
compiled.get_volatility("src/api/handler.rs", Volatility::Low),
Volatility::High
);
assert_eq!(
compiled.get_volatility("src/other/file.rs", Volatility::Medium),
Volatility::Medium
);
}
#[test]
fn test_subdomain_config() {
let toml = r#"
[subdomains]
core = ["src/analyzer.rs", "src/balance.rs"]
supporting = ["src/report.rs", "src/cli_output.rs"]
generic = ["src/web/*", "src/config.rs"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let mut compiled = CompiledConfig::from_config(config).unwrap();
assert_eq!(
compiled.get_subdomain("src/analyzer.rs"),
Some(Subdomain::Core)
);
assert_eq!(
compiled.get_volatility_override("src/analyzer.rs"),
Some(Volatility::High)
);
assert_eq!(
compiled.get_subdomain("src/report.rs"),
Some(Subdomain::Supporting)
);
assert_eq!(
compiled.get_volatility_override("src/report.rs"),
Some(Volatility::Low)
);
assert_eq!(
compiled.get_subdomain("src/web/server.rs"),
Some(Subdomain::Generic)
);
assert_eq!(
compiled.get_volatility_override("src/web/server.rs"),
Some(Volatility::Low)
);
assert_eq!(compiled.get_subdomain("src/other.rs"), None);
assert_eq!(compiled.get_volatility_override("src/other.rs"), None);
}
#[test]
fn test_subdomain_display() {
assert_eq!(format!("{}", Subdomain::Core), "Core");
assert_eq!(format!("{}", Subdomain::Supporting), "Supporting");
assert_eq!(format!("{}", Subdomain::Generic), "Generic");
}
#[test]
fn test_subdomain_expected_volatility() {
assert_eq!(Subdomain::Core.expected_volatility(), Volatility::High);
assert_eq!(Subdomain::Supporting.expected_volatility(), Volatility::Low);
assert_eq!(Subdomain::Generic.expected_volatility(), Volatility::Low);
}
#[test]
fn test_has_subdomain_config() {
let compiled = CompiledConfig::empty();
assert!(!compiled.has_subdomain_config());
let toml = r#"
[subdomains]
core = ["src/analyzer.rs"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let compiled = CompiledConfig::from_config(config).unwrap();
assert!(compiled.has_subdomain_config());
let toml = r#"
[subdomains]
supporting = ["src/report.rs"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let compiled = CompiledConfig::from_config(config).unwrap();
assert!(compiled.has_subdomain_config());
let toml = r#"
[subdomains]
generic = ["src/web/*"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let compiled = CompiledConfig::from_config(config).unwrap();
assert!(compiled.has_subdomain_config());
}
#[test]
fn test_has_volatility_overrides() {
let compiled = CompiledConfig::empty();
assert!(!compiled.has_volatility_overrides());
let toml = r#"
[volatility]
high = ["src/core.rs"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let compiled = CompiledConfig::from_config(config).unwrap();
assert!(compiled.has_volatility_overrides());
let toml = r#"
[volatility]
medium = ["src/mid.rs"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let compiled = CompiledConfig::from_config(config).unwrap();
assert!(compiled.has_volatility_overrides());
let toml = r#"
[volatility]
low = ["src/stable.rs"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let compiled = CompiledConfig::from_config(config).unwrap();
assert!(compiled.has_volatility_overrides());
}
#[test]
fn test_volatility_override_beats_subdomain() {
let toml = r#"
[volatility]
low = ["src/analyzer.rs"]
[subdomains]
core = ["src/analyzer.rs"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let mut compiled = CompiledConfig::from_config(config).unwrap();
assert_eq!(
compiled.get_volatility_override("src/analyzer.rs"),
Some(Volatility::Low)
);
}
#[test]
fn dead_patterns_reports_only_unmatched_patterns_by_section() {
let toml = r#"
[analysis]
prelude_modules = ["src/lib.rs", "src/missing_prelude.rs"]
exclude = ["src/never_matches/**"]
[volatility]
high = ["src/core/**"]
medium = ["src/medium.rs"]
low = ["src/stable.rs"]
[subdomains]
core = ["src/core/**", "src/old_core.rs"]
supporting = ["src/supporting.rs"]
generic = ["src/generic.rs"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let compiled = CompiledConfig::from_config(config).unwrap();
let candidate_paths = vec![
"src/lib.rs".to_string(),
"src/core/mod.rs".to_string(),
"src/supporting.rs".to_string(),
"src/stable.rs".to_string(),
];
let dead = compiled.dead_patterns(&candidate_paths);
assert_eq!(
dead,
vec![
DeadConfigPattern {
section: "subdomains.core",
pattern: "src/old_core.rs".to_string(),
},
DeadConfigPattern {
section: "subdomains.generic",
pattern: "src/generic.rs".to_string(),
},
DeadConfigPattern {
section: "volatility.medium",
pattern: "src/medium.rs".to_string(),
},
]
);
}
#[test]
fn dead_patterns_never_reports_exclude_or_prelude() {
let toml = r#"
[analysis]
prelude_modules = ["src/missing_prelude.rs"]
exclude = ["src/never_matches/**"]
"#;
let config: CouplingConfig = toml::from_str(toml).unwrap();
let compiled = CompiledConfig::from_config(config).unwrap();
assert!(
compiled
.dead_patterns(&["src/lib.rs".to_string()])
.is_empty()
);
}
#[test]
fn empty_config_has_no_dead_patterns() {
let candidate_paths = vec!["src/lib.rs".to_string()];
assert!(
CompiledConfig::empty()
.dead_patterns(&candidate_paths)
.is_empty()
);
}
}