1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::Path;
4
5#[derive(Debug, Clone, Deserialize)]
10#[serde(untagged)]
11pub enum Strictness {
12 Named(StrictnessLevel),
13 Custom(f64),
14}
15
16#[derive(Debug, Clone, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum StrictnessLevel {
19 Relaxed,
20 Default,
21 Strict,
22}
23
24impl Default for Strictness {
25 fn default() -> Self {
26 Strictness::Named(StrictnessLevel::Default)
27 }
28}
29
30impl Strictness {
31 pub fn factor(&self) -> f64 {
32 match self {
33 Strictness::Named(StrictnessLevel::Relaxed) => 2.0,
34 Strictness::Named(StrictnessLevel::Default) => 1.0,
35 Strictness::Named(StrictnessLevel::Strict) => 0.5,
36 Strictness::Custom(v) => *v,
37 }
38 }
39
40 pub fn parse(s: &str) -> Option<Self> {
42 match s {
43 "relaxed" => Some(Strictness::Named(StrictnessLevel::Relaxed)),
44 "default" => Some(Strictness::Named(StrictnessLevel::Default)),
45 "strict" => Some(Strictness::Named(StrictnessLevel::Strict)),
46 _ => s.parse::<f64>().ok().map(Strictness::Custom),
47 }
48 }
49}
50
51#[derive(Debug, Default, Clone, Deserialize)]
53pub struct LanguageConfig {
54 #[serde(default)]
55 pub plugins: HashMap<String, PluginConfig>,
56 #[serde(default)]
58 pub disabled_smells: Vec<String>,
59}
60
61#[derive(Debug, Default, Clone, Deserialize)]
63pub struct Config {
65 #[serde(default)]
66 pub plugins: HashMap<String, PluginConfig>,
67 #[serde(default)]
69 pub exclude: Vec<String>,
70 #[serde(default)]
72 pub debt_weights: DebtWeights,
73 #[serde(default)]
75 pub strictness: Strictness,
76 #[serde(default)]
78 pub languages: HashMap<String, LanguageConfig>,
79 #[serde(default)]
82 pub disabled_smells: Vec<String>,
83 #[serde(default)]
85 pub layers: LayersConfig,
86}
87
88#[derive(Debug, Default, Clone, Deserialize)]
90pub struct LayersConfig {
91 #[serde(default)]
93 pub modules: HashMap<String, Vec<String>>,
94 #[serde(default)]
96 pub tiers: Vec<TierConfig>,
97}
98
99#[derive(Debug, Clone, Deserialize)]
101pub struct TierConfig {
102 pub name: String,
103 pub modules: Vec<String>,
104}
105
106#[derive(Debug, Clone, Deserialize)]
108pub struct DebtWeights {
109 #[serde(default = "default_hint_debt")]
110 pub hint: u32,
111 #[serde(default = "default_warning_debt")]
112 pub warning: u32,
113 #[serde(default = "default_error_debt")]
114 pub error: u32,
115}
116
117fn default_hint_debt() -> u32 {
118 5
119}
120fn default_warning_debt() -> u32 {
121 15
122}
123fn default_error_debt() -> u32 {
124 30
125}
126
127impl Default for DebtWeights {
128 fn default() -> Self {
129 Self {
130 hint: 5,
131 warning: 15,
132 error: 30,
133 }
134 }
135}
136
137#[derive(Debug, Clone, Deserialize)]
139pub struct PluginConfig {
140 #[serde(default = "default_true")]
141 pub enabled: bool,
142 #[serde(flatten)]
143 pub options: HashMap<String, toml::Value>,
144}
145
146fn default_true() -> bool {
147 true
148}
149
150impl Default for PluginConfig {
151 fn default() -> Self {
152 Self {
153 enabled: true,
154 options: HashMap::new(),
155 }
156 }
157}
158
159impl Config {
160 pub fn load(dir: &Path) -> Self {
162 let path = dir.join(".cha.toml");
163 match std::fs::read_to_string(&path) {
164 Ok(content) => toml::from_str(&content).unwrap_or_default(),
165 Err(_) => Self::default(),
166 }
167 }
168
169 pub fn load_file(path: &Path) -> Self {
171 match std::fs::read_to_string(path) {
172 Ok(content) => toml::from_str(&content).unwrap_or_default(),
173 Err(_) => Self::default(),
174 }
175 }
176
177 pub fn load_for_file(file_path: &Path, project_root: &Path) -> Self {
180 let abs_file = std::fs::canonicalize(file_path).unwrap_or(file_path.to_path_buf());
181 let abs_root = std::fs::canonicalize(project_root).unwrap_or(project_root.to_path_buf());
182 let dir = abs_file.parent().unwrap_or(&abs_root);
183
184 let mut configs = collect_configs_upward(dir, &abs_root);
186 configs.reverse();
187 let mut merged = Config::default();
188 for cfg in configs {
189 merged.merge(cfg);
190 }
191 merged
192 }
193
194 pub fn merge(&mut self, other: Config) {
196 for (name, other_pc) in other.plugins {
197 let entry = self.plugins.entry(name).or_default();
198 entry.enabled = other_pc.enabled;
199 for (k, v) in other_pc.options {
200 entry.options.insert(k, v);
201 }
202 }
203 self.exclude.extend(other.exclude);
204 self.debt_weights = other.debt_weights;
205 self.strictness = other.strictness;
209 self.disabled_smells.extend(other.disabled_smells);
210 for (lang, other_lc) in other.languages {
211 let entry = self.languages.entry(lang).or_default();
212 for (name, other_pc) in other_lc.plugins {
213 let pe = entry.plugins.entry(name).or_default();
214 pe.enabled = other_pc.enabled;
215 for (k, v) in other_pc.options {
216 pe.options.insert(k, v);
217 }
218 }
219 entry.disabled_smells.extend(other_lc.disabled_smells);
220 }
221 }
222
223 pub fn resolve_for_language(&self, language: &str) -> Config {
226 let lang_key = language.to_lowercase();
227 let mut resolved = self.clone();
228 self.apply_builtin_profile(&lang_key, &mut resolved);
229 self.apply_user_language_overrides(&lang_key, &mut resolved);
230 resolved
231 }
232
233 fn apply_builtin_profile(&self, lang_key: &str, resolved: &mut Config) {
234 let Some(builtin) = builtin_language_profile(lang_key) else {
235 return;
236 };
237 for (name, enabled, options) in builtin {
238 let user_override = self
239 .languages
240 .get(lang_key)
241 .is_some_and(|lc| lc.plugins.contains_key(name));
242 if user_override {
243 continue;
244 }
245 let entry = resolved.plugins.entry(name.to_string()).or_default();
246 entry.enabled = enabled;
247 for &(k, v) in options {
248 entry
249 .options
250 .entry(k.to_string())
251 .or_insert(toml::Value::Integer(v));
252 }
253 }
254 }
255
256 fn apply_user_language_overrides(&self, lang_key: &str, resolved: &mut Config) {
257 let Some(lc) = self.languages.get(lang_key) else {
258 return;
259 };
260 for (name, lpc) in &lc.plugins {
261 let entry = resolved.plugins.entry(name.clone()).or_default();
262 entry.enabled = lpc.enabled;
263 for (k, v) in &lpc.options {
264 entry.options.insert(k.clone(), v.clone());
265 }
266 }
267 }
268
269 pub fn is_enabled(&self, name: &str) -> bool {
271 self.plugins.get(name).is_none_or(|c| c.enabled)
272 }
273
274 pub fn disabled_smells_for_language(&self, language: &str) -> Vec<String> {
278 let lang_key = language.to_lowercase();
279 let mut out = self.disabled_smells.clone();
280 if let Some(lc) = self.languages.get(&lang_key) {
281 out.extend(lc.disabled_smells.clone());
282 }
283 if let Some(builtin) = builtin_language_smell_disables(&lang_key) {
284 out.extend(builtin.iter().map(|s| s.to_string()));
285 }
286 out
287 }
288
289 pub fn is_smell_disabled(&self, smell_name: &str, language: &str) -> bool {
291 self.disabled_smells_for_language(language)
292 .iter()
293 .any(|s| s == smell_name)
294 }
295
296 pub fn get_usize(&self, plugin: &str, key: &str) -> Option<usize> {
298 self.plugins
299 .get(plugin)?
300 .options
301 .get(key)?
302 .as_integer()
303 .map(|v| {
304 let scaled = (v as f64 * self.strictness.factor()).round() as usize;
305 scaled.max(1)
306 })
307 }
308
309 pub fn get_str(&self, plugin: &str, key: &str) -> Option<String> {
311 self.plugins
312 .get(plugin)?
313 .options
314 .get(key)?
315 .as_str()
316 .map(|s| s.to_string())
317 }
318
319 pub fn set_strictness(&mut self, s: Strictness) {
321 self.strictness = s;
322 }
323
324 pub fn set_calibration_defaults(&mut self, lines: usize, complexity: usize, cognitive: usize) {
326 self.plugins
327 .entry("length".into())
328 .or_default()
329 .options
330 .entry("max_function_lines".into())
331 .or_insert(toml::Value::Integer(lines as i64));
332 self.plugins
333 .entry("complexity".into())
334 .or_default()
335 .options
336 .entry("max_complexity".into())
337 .or_insert(toml::Value::Integer(complexity as i64));
338 self.plugins
339 .entry("cognitive_complexity".into())
340 .or_default()
341 .options
342 .entry("max_cognitive_complexity".into())
343 .or_insert(toml::Value::Integer(cognitive as i64));
344 }
345}
346
347fn collect_configs_upward(start_dir: &Path, root: &Path) -> Vec<Config> {
349 let mut configs = Vec::new();
350 let mut current = start_dir.to_path_buf();
351 loop {
352 let cfg_path = current.join(".cha.toml");
353 if cfg_path.is_file()
354 && let Ok(content) = std::fs::read_to_string(&cfg_path)
355 && let Ok(cfg) = toml::from_str::<Config>(&content)
356 {
357 configs.push(cfg);
358 }
359 if current == root {
360 break;
361 }
362 match current.parent() {
363 Some(p) if p.starts_with(root) || p == root => current = p.to_path_buf(),
364 _ => break,
365 }
366 }
367 configs
368}
369
370pub type PluginProfile = (&'static str, bool, &'static [(&'static str, i64)]);
372
373pub fn builtin_language_profile(language: &str) -> Option<Vec<PluginProfile>> {
377 match language {
378 "c" | "cpp" => Some(vec![
379 ("naming", false, &[] as &[(&str, i64)]),
380 ("lazy_class", false, &[]),
385 ("data_class", false, &[]),
386 (
387 "length",
388 true,
389 &[
390 ("max_function_lines", 100),
391 ("max_file_lines", 2000),
392 ("max_class_lines", 400),
393 ],
394 ),
395 (
396 "complexity",
397 true,
398 &[("warn_threshold", 15), ("error_threshold", 30)],
399 ),
400 ("cognitive_complexity", true, &[("threshold", 25)]),
401 ("coupling", true, &[("max_imports", 25)]),
402 ("long_parameter_list", true, &[("max_params", 7)]),
403 ]),
404 _ => None,
405 }
406}
407
408pub fn builtin_language_smell_disables(language: &str) -> Option<&'static [&'static str]> {
411 match language {
412 "c" | "cpp" => Some(&[
413 "builder_pattern",
415 "null_object_pattern",
416 "strategy_pattern",
417 "data_clumps",
419 ]),
420 _ => None,
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn disabled_smells_builtin_for_c() {
430 let cfg = Config::default();
431 assert!(cfg.is_smell_disabled("builder_pattern", "c"));
432 assert!(cfg.is_smell_disabled("data_clumps", "cpp"));
433 assert!(!cfg.is_smell_disabled("builder_pattern", "rust"));
434 }
435
436 #[test]
437 fn disabled_smells_global_applies_to_any_language() {
438 let mut cfg = Config::default();
439 cfg.disabled_smells.push("long_method".into());
440 assert!(cfg.is_smell_disabled("long_method", "rust"));
441 assert!(cfg.is_smell_disabled("long_method", "typescript"));
442 }
443
444 #[test]
445 fn disabled_smells_language_level_overlay() {
446 let mut cfg = Config::default();
447 let mut lc = LanguageConfig::default();
448 lc.disabled_smells.push("observer_pattern".into());
449 cfg.languages.insert("go".into(), lc);
450 assert!(cfg.is_smell_disabled("observer_pattern", "go"));
451 assert!(!cfg.is_smell_disabled("observer_pattern", "rust"));
452 }
453
454 #[test]
455 fn disabled_smells_combines_global_language_and_builtin() {
456 let mut cfg = Config::default();
457 cfg.disabled_smells.push("global_smell".into());
458 let mut lc = LanguageConfig::default();
459 lc.disabled_smells.push("lang_smell".into());
460 cfg.languages.insert("c".into(), lc);
461 let all = cfg.disabled_smells_for_language("c");
462 assert!(all.iter().any(|s| s == "global_smell"));
463 assert!(all.iter().any(|s| s == "lang_smell"));
464 assert!(all.iter().any(|s| s == "builder_pattern")); }
466
467 #[test]
468 fn merge_appends_disabled_smells() {
469 let mut a = Config::default();
470 a.disabled_smells.push("a_smell".into());
471 let mut b = Config::default();
472 b.disabled_smells.push("b_smell".into());
473 a.merge(b);
474 assert!(a.disabled_smells.contains(&"a_smell".into()));
475 assert!(a.disabled_smells.contains(&"b_smell".into()));
476 }
477}