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::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)]
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 thresholds: ThresholdsConfig,
}
#[derive(Debug)]
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>,
pub thresholds: ThresholdsConfig,
cache: HashMap<String, Option<Volatility>>,
}
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(|p| {
Pattern::new(p).map_err(|e| ConfigError::PatternError(format!("{}: {}", p, e)))
})
.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)?,
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(),
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 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 prelude_module_count(&self) -> usize {
self.prelude_patterns.len()
}
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 {
None
};
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()
}
}
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
);
}
}