Skip to main content

cha_core/
config.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::Path;
4
5/// Strictness level — controls threshold scaling.
6/// `relaxed` = 2.0x (thresholds doubled, more lenient),
7/// `default` = 1.0x, `strict` = 0.5x (thresholds halved).
8/// Can also be a custom float like `0.7`.
9#[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    /// Parse from CLI string: "relaxed", "default", "strict", or a float.
41    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/// Per-language config overlay.
52#[derive(Debug, Default, Clone, Deserialize)]
53pub struct LanguageConfig {
54    #[serde(default)]
55    pub plugins: HashMap<String, PluginConfig>,
56    /// Smell names disabled for this language (appended to global disabled_smells).
57    #[serde(default)]
58    pub disabled_smells: Vec<String>,
59}
60
61/// Top-level config from `.cha.toml`.
62#[derive(Debug, Default, Clone, Deserialize)]
63// cha:ignore large_class
64pub struct Config {
65    #[serde(default)]
66    pub plugins: HashMap<String, PluginConfig>,
67    /// Glob patterns for paths to exclude from analysis.
68    #[serde(default)]
69    pub exclude: Vec<String>,
70    /// Custom remediation time weights (minutes per severity).
71    #[serde(default)]
72    pub debt_weights: DebtWeights,
73    /// Threshold scaling factor.
74    #[serde(default)]
75    pub strictness: Strictness,
76    /// Per-language plugin overrides.
77    #[serde(default)]
78    pub languages: HashMap<String, LanguageConfig>,
79    /// Smell names disabled globally (any language/plugin).
80    /// Use when a plugin produces multiple smells and you want to suppress only some.
81    #[serde(default)]
82    pub disabled_smells: Vec<String>,
83    /// Manual layer/module configuration for `cha layers`.
84    #[serde(default)]
85    pub layers: LayersConfig,
86}
87
88/// Manual module and tier definitions for architecture analysis.
89#[derive(Debug, Default, Clone, Deserialize)]
90pub struct LayersConfig {
91    /// Module name → file glob patterns.
92    #[serde(default)]
93    pub modules: HashMap<String, Vec<String>>,
94    /// Tier name → module names (ordered bottom to top in config).
95    #[serde(default)]
96    pub tiers: Vec<TierConfig>,
97}
98
99/// A single tier definition.
100#[derive(Debug, Clone, Deserialize)]
101pub struct TierConfig {
102    pub name: String,
103    pub modules: Vec<String>,
104}
105
106/// Custom debt estimation weights (minutes per severity level).
107#[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/// Per-plugin config section.
138#[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    /// Load config from `.cha.toml` in the given directory, or return default.
161    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    /// Load config from a specific file path.
170    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    /// Load merged config for a specific file by walking up from its directory to root.
178    /// Child configs override parent configs (child-wins merge).
179    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        // Merge: root is base, closest wins
185        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    /// Merge another config into self. `other` values take precedence.
195    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        // Only override strictness if the other config explicitly set it (non-default).
206        // Since we can't distinguish "not set" from "set to default" with serde,
207        // we always take the other's value during merge.
208        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    /// Produce a resolved config for a specific language.
224    /// Applies builtin language profile first, then user overrides.
225    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    /// Check if a plugin is enabled (default: true if not mentioned).
270    pub fn is_enabled(&self, name: &str) -> bool {
271        self.plugins.get(name).is_none_or(|c| c.enabled)
272    }
273
274    /// All disabled smells effective for a given file language:
275    /// global + language-level + builtin language profile's smell disables.
276    /// Duplicates are kept; callers use Vec::contains() so it's fine.
277    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    /// Check if a smell is disabled for the given file language.
290    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    /// Get a usize option for a plugin, scaled by strictness factor.
297    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    /// Get a string option for a plugin.
310    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    /// Override strictness (e.g. from CLI --strictness flag).
320    pub fn set_strictness(&mut self, s: Strictness) {
321        self.strictness = s;
322    }
323
324    /// Apply calibration defaults — only sets values not already configured by user.
325    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
347/// Walk from `start_dir` up to `root`, collecting `.cha.toml` configs (closest first).
348fn 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
370/// A builtin plugin profile entry: (name, enabled, option overrides).
371pub type PluginProfile = (&'static str, bool, &'static [(&'static str, i64)]);
372
373/// Builtin language profiles: default plugin settings for specific languages.
374/// Returns (plugin_name, enabled, options) tuples. Users can override via `[languages.xx.plugins.yy]`.
375/// Only real plugin names allowed — smell-level disables go in `builtin_language_smell_disables`.
376pub 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 / data_class stay disabled for C: they run as
381            // per-file plugins and don't see the `c_oop_enrich` enrichment
382            // (which only reaches post-analysis detectors via ProjectIndex).
383            // When these plugins get migrated to post-analysis, reopen them.
384            ("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
408/// Builtin smell-level disables for specific languages.
409/// Use when a plugin emits multiple smells but only some should be suppressed.
410pub fn builtin_language_smell_disables(language: &str) -> Option<&'static [&'static str]> {
411    match language {
412        "c" | "cpp" => Some(&[
413            // design_pattern smells — C/C++ idioms don't match these pattern shapes
414            "builder_pattern",
415            "null_object_pattern",
416            "strategy_pattern",
417            // data_clumps — C functions with many params grouped is common and intentional
418            "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")); // from builtin
465    }
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}