1use glob::Pattern;
30use serde::Deserialize;
31use std::collections::HashMap;
32use std::fs;
33use std::path::Path;
34use thiserror::Error;
35
36use crate::metrics::Volatility;
37
38#[derive(Error, Debug)]
40pub enum ConfigError {
41 #[error("Failed to read config file: {0}")]
42 IoError(#[from] std::io::Error),
43
44 #[error("Failed to parse config file: {0}")]
45 ParseError(#[from] toml::de::Error),
46
47 #[error("Invalid glob pattern: {0}")]
48 PatternError(String),
49}
50
51#[derive(Debug, Clone, Deserialize, Default)]
53pub struct VolatilityConfig {
54 #[serde(default)]
56 pub high: Vec<String>,
57
58 #[serde(default)]
60 pub medium: Vec<String>,
61
62 #[serde(default)]
64 pub low: Vec<String>,
65
66 #[serde(default)]
68 pub ignore: Vec<String>,
69}
70
71#[derive(Debug, Clone, Deserialize)]
73pub struct ThresholdsConfig {
74 #[serde(default = "default_max_dependencies")]
76 pub max_dependencies: usize,
77
78 #[serde(default = "default_max_dependents")]
80 pub max_dependents: usize,
81}
82
83fn default_max_dependencies() -> usize {
84 15
85}
86
87fn default_max_dependents() -> usize {
88 20
89}
90
91impl Default for ThresholdsConfig {
92 fn default() -> Self {
93 Self {
94 max_dependencies: default_max_dependencies(),
95 max_dependents: default_max_dependents(),
96 }
97 }
98}
99
100#[derive(Debug, Clone, Deserialize, Default)]
102pub struct CouplingConfig {
103 #[serde(default)]
105 pub volatility: VolatilityConfig,
106
107 #[serde(default)]
109 pub thresholds: ThresholdsConfig,
110}
111
112#[derive(Debug)]
114pub struct CompiledConfig {
115 high_patterns: Vec<Pattern>,
117 medium_patterns: Vec<Pattern>,
119 low_patterns: Vec<Pattern>,
121 ignore_patterns: Vec<Pattern>,
123 pub thresholds: ThresholdsConfig,
125 cache: HashMap<String, Option<Volatility>>,
127}
128
129impl CompiledConfig {
130 pub fn from_config(config: CouplingConfig) -> Result<Self, ConfigError> {
132 let compile_patterns = |patterns: &[String]| -> Result<Vec<Pattern>, ConfigError> {
133 patterns
134 .iter()
135 .map(|p| {
136 Pattern::new(p).map_err(|e| ConfigError::PatternError(format!("{}: {}", p, e)))
137 })
138 .collect()
139 };
140
141 Ok(Self {
142 high_patterns: compile_patterns(&config.volatility.high)?,
143 medium_patterns: compile_patterns(&config.volatility.medium)?,
144 low_patterns: compile_patterns(&config.volatility.low)?,
145 ignore_patterns: compile_patterns(&config.volatility.ignore)?,
146 thresholds: config.thresholds,
147 cache: HashMap::new(),
148 })
149 }
150
151 pub fn empty() -> Self {
153 Self {
154 high_patterns: Vec::new(),
155 medium_patterns: Vec::new(),
156 low_patterns: Vec::new(),
157 ignore_patterns: Vec::new(),
158 thresholds: ThresholdsConfig::default(),
159 cache: HashMap::new(),
160 }
161 }
162
163 pub fn should_ignore(&self, path: &str) -> bool {
165 self.ignore_patterns.iter().any(|p| p.matches(path))
166 }
167
168 pub fn get_volatility_override(&mut self, path: &str) -> Option<Volatility> {
170 if let Some(cached) = self.cache.get(path) {
172 return *cached;
173 }
174
175 let result = if self.high_patterns.iter().any(|p| p.matches(path)) {
177 Some(Volatility::High)
178 } else if self.medium_patterns.iter().any(|p| p.matches(path)) {
179 Some(Volatility::Medium)
180 } else if self.low_patterns.iter().any(|p| p.matches(path)) {
181 Some(Volatility::Low)
182 } else {
183 None
184 };
185
186 self.cache.insert(path.to_string(), result);
188 result
189 }
190
191 pub fn get_volatility(&mut self, path: &str, git_volatility: Volatility) -> Volatility {
193 self.get_volatility_override(path).unwrap_or(git_volatility)
194 }
195
196 pub fn has_volatility_overrides(&self) -> bool {
198 !self.high_patterns.is_empty()
199 || !self.medium_patterns.is_empty()
200 || !self.low_patterns.is_empty()
201 }
202}
203
204pub fn load_config(project_path: &Path) -> Result<CouplingConfig, ConfigError> {
208 let config_path = find_config_file(project_path);
210
211 match config_path {
212 Some(path) => {
213 let content = fs::read_to_string(&path)?;
214 let config: CouplingConfig = toml::from_str(&content)?;
215 Ok(config)
216 }
217 None => Ok(CouplingConfig::default()),
218 }
219}
220
221fn find_config_file(start_path: &Path) -> Option<std::path::PathBuf> {
223 let config_names = [".coupling.toml", "coupling.toml"];
224
225 let mut current = if start_path.is_file() {
226 start_path.parent()?.to_path_buf()
227 } else {
228 start_path.to_path_buf()
229 };
230
231 loop {
232 for name in &config_names {
233 let config_path = current.join(name);
234 if config_path.exists() {
235 return Some(config_path);
236 }
237 }
238
239 if let Some(parent) = current.parent() {
241 current = parent.to_path_buf();
242 } else {
243 break;
244 }
245 }
246
247 None
248}
249
250pub fn load_compiled_config(project_path: &Path) -> Result<CompiledConfig, ConfigError> {
252 let config = load_config(project_path)?;
253 CompiledConfig::from_config(config)
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn test_default_config() {
262 let config = CouplingConfig::default();
263 assert!(config.volatility.high.is_empty());
264 assert!(config.volatility.low.is_empty());
265 assert_eq!(config.thresholds.max_dependencies, 15);
266 assert_eq!(config.thresholds.max_dependents, 20);
267 }
268
269 #[test]
270 fn test_parse_config() {
271 let toml = r#"
272 [volatility]
273 high = ["src/api/*", "src/handlers/*"]
274 low = ["src/core/*"]
275 ignore = ["tests/*"]
276
277 [thresholds]
278 max_dependencies = 20
279 max_dependents = 30
280 "#;
281
282 let config: CouplingConfig = toml::from_str(toml).unwrap();
283 assert_eq!(config.volatility.high.len(), 2);
284 assert_eq!(config.volatility.low.len(), 1);
285 assert_eq!(config.volatility.ignore.len(), 1);
286 assert_eq!(config.thresholds.max_dependencies, 20);
287 assert_eq!(config.thresholds.max_dependents, 30);
288 }
289
290 #[test]
291 fn test_compiled_config() {
292 let toml = r#"
293 [volatility]
294 high = ["src/business/*"]
295 low = ["src/core/*"]
296 "#;
297
298 let config: CouplingConfig = toml::from_str(toml).unwrap();
299 let mut compiled = CompiledConfig::from_config(config).unwrap();
300
301 assert_eq!(
302 compiled.get_volatility_override("src/business/pricing.rs"),
303 Some(Volatility::High)
304 );
305 assert_eq!(
306 compiled.get_volatility_override("src/core/types.rs"),
307 Some(Volatility::Low)
308 );
309 assert_eq!(compiled.get_volatility_override("src/other/file.rs"), None);
310 }
311
312 #[test]
313 fn test_ignore_patterns() {
314 let toml = r#"
315 [volatility]
316 ignore = ["tests/*", "benches/*"]
317 "#;
318
319 let config: CouplingConfig = toml::from_str(toml).unwrap();
320 let compiled = CompiledConfig::from_config(config).unwrap();
321
322 assert!(compiled.should_ignore("tests/integration.rs"));
323 assert!(compiled.should_ignore("benches/perf.rs"));
324 assert!(!compiled.should_ignore("src/lib.rs"));
325 }
326
327 #[test]
328 fn test_get_volatility_with_fallback() {
329 let toml = r#"
330 [volatility]
331 high = ["src/api/*"]
332 "#;
333
334 let config: CouplingConfig = toml::from_str(toml).unwrap();
335 let mut compiled = CompiledConfig::from_config(config).unwrap();
336
337 assert_eq!(
339 compiled.get_volatility("src/api/handler.rs", Volatility::Low),
340 Volatility::High
341 );
342
343 assert_eq!(
345 compiled.get_volatility("src/other/file.rs", Volatility::Medium),
346 Volatility::Medium
347 );
348 }
349}