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}
57
58/// Top-level config from `.cha.toml`.
59#[derive(Debug, Default, Clone, Deserialize)]
60// cha:ignore large_class
61pub struct Config {
62    #[serde(default)]
63    pub plugins: HashMap<String, PluginConfig>,
64    /// Glob patterns for paths to exclude from analysis.
65    #[serde(default)]
66    pub exclude: Vec<String>,
67    /// Custom remediation time weights (minutes per severity).
68    #[serde(default)]
69    pub debt_weights: DebtWeights,
70    /// Threshold scaling factor.
71    #[serde(default)]
72    pub strictness: Strictness,
73    /// Per-language plugin overrides.
74    #[serde(default)]
75    pub languages: HashMap<String, LanguageConfig>,
76}
77
78/// Custom debt estimation weights (minutes per severity level).
79#[derive(Debug, Clone, Deserialize)]
80pub struct DebtWeights {
81    #[serde(default = "default_hint_debt")]
82    pub hint: u32,
83    #[serde(default = "default_warning_debt")]
84    pub warning: u32,
85    #[serde(default = "default_error_debt")]
86    pub error: u32,
87}
88
89fn default_hint_debt() -> u32 {
90    5
91}
92fn default_warning_debt() -> u32 {
93    15
94}
95fn default_error_debt() -> u32 {
96    30
97}
98
99impl Default for DebtWeights {
100    fn default() -> Self {
101        Self {
102            hint: 5,
103            warning: 15,
104            error: 30,
105        }
106    }
107}
108
109/// Per-plugin config section.
110#[derive(Debug, Clone, Deserialize)]
111pub struct PluginConfig {
112    #[serde(default = "default_true")]
113    pub enabled: bool,
114    #[serde(flatten)]
115    pub options: HashMap<String, toml::Value>,
116}
117
118fn default_true() -> bool {
119    true
120}
121
122impl Default for PluginConfig {
123    fn default() -> Self {
124        Self {
125            enabled: true,
126            options: HashMap::new(),
127        }
128    }
129}
130
131impl Config {
132    /// Load config from `.cha.toml` in the given directory, or return default.
133    pub fn load(dir: &Path) -> Self {
134        let path = dir.join(".cha.toml");
135        match std::fs::read_to_string(&path) {
136            Ok(content) => toml::from_str(&content).unwrap_or_default(),
137            Err(_) => Self::default(),
138        }
139    }
140
141    /// Load merged config for a specific file by walking up from its directory to root.
142    /// Child configs override parent configs (child-wins merge).
143    pub fn load_for_file(file_path: &Path, project_root: &Path) -> Self {
144        let abs_file = std::fs::canonicalize(file_path).unwrap_or(file_path.to_path_buf());
145        let abs_root = std::fs::canonicalize(project_root).unwrap_or(project_root.to_path_buf());
146        let dir = abs_file.parent().unwrap_or(&abs_root);
147
148        // Merge: root is base, closest wins
149        let mut configs = collect_configs_upward(dir, &abs_root);
150        configs.reverse();
151        let mut merged = Config::default();
152        for cfg in configs {
153            merged.merge(cfg);
154        }
155        merged
156    }
157
158    /// Merge another config into self. `other` values take precedence.
159    pub fn merge(&mut self, other: Config) {
160        for (name, other_pc) in other.plugins {
161            let entry = self.plugins.entry(name).or_default();
162            entry.enabled = other_pc.enabled;
163            for (k, v) in other_pc.options {
164                entry.options.insert(k, v);
165            }
166        }
167        self.exclude.extend(other.exclude);
168        self.debt_weights = other.debt_weights;
169        // Only override strictness if the other config explicitly set it (non-default).
170        // Since we can't distinguish "not set" from "set to default" with serde,
171        // we always take the other's value during merge.
172        self.strictness = other.strictness;
173        for (lang, other_lc) in other.languages {
174            let entry = self.languages.entry(lang).or_default();
175            for (name, other_pc) in other_lc.plugins {
176                let pe = entry.plugins.entry(name).or_default();
177                pe.enabled = other_pc.enabled;
178                for (k, v) in other_pc.options {
179                    pe.options.insert(k, v);
180                }
181            }
182        }
183    }
184
185    /// Produce a resolved config for a specific language.
186    /// Applies builtin language profile first, then user overrides.
187    pub fn resolve_for_language(&self, language: &str) -> Config {
188        let lang_key = language.to_lowercase();
189        let mut resolved = self.clone();
190        self.apply_builtin_profile(&lang_key, &mut resolved);
191        self.apply_user_language_overrides(&lang_key, &mut resolved);
192        resolved
193    }
194
195    fn apply_builtin_profile(&self, lang_key: &str, resolved: &mut Config) {
196        let Some(builtin) = builtin_language_profile(lang_key) else {
197            return;
198        };
199        for (name, enabled, options) in builtin {
200            let user_override = self
201                .languages
202                .get(lang_key)
203                .is_some_and(|lc| lc.plugins.contains_key(name));
204            if user_override {
205                continue;
206            }
207            let entry = resolved.plugins.entry(name.to_string()).or_default();
208            entry.enabled = enabled;
209            for &(k, v) in options {
210                entry
211                    .options
212                    .entry(k.to_string())
213                    .or_insert(toml::Value::Integer(v));
214            }
215        }
216    }
217
218    fn apply_user_language_overrides(&self, lang_key: &str, resolved: &mut Config) {
219        let Some(lc) = self.languages.get(lang_key) else {
220            return;
221        };
222        for (name, lpc) in &lc.plugins {
223            let entry = resolved.plugins.entry(name.clone()).or_default();
224            entry.enabled = lpc.enabled;
225            for (k, v) in &lpc.options {
226                entry.options.insert(k.clone(), v.clone());
227            }
228        }
229    }
230
231    /// Check if a plugin is enabled (default: true if not mentioned).
232    pub fn is_enabled(&self, name: &str) -> bool {
233        self.plugins.get(name).is_none_or(|c| c.enabled)
234    }
235
236    /// Get a usize option for a plugin, scaled by strictness factor.
237    pub fn get_usize(&self, plugin: &str, key: &str) -> Option<usize> {
238        self.plugins
239            .get(plugin)?
240            .options
241            .get(key)?
242            .as_integer()
243            .map(|v| {
244                let scaled = (v as f64 * self.strictness.factor()).round() as usize;
245                scaled.max(1)
246            })
247    }
248
249    /// Get a string option for a plugin.
250    pub fn get_str(&self, plugin: &str, key: &str) -> Option<String> {
251        self.plugins
252            .get(plugin)?
253            .options
254            .get(key)?
255            .as_str()
256            .map(|s| s.to_string())
257    }
258
259    /// Override strictness (e.g. from CLI --strictness flag).
260    pub fn set_strictness(&mut self, s: Strictness) {
261        self.strictness = s;
262    }
263
264    /// Apply calibration defaults — only sets values not already configured by user.
265    pub fn set_calibration_defaults(&mut self, lines: usize, complexity: usize, cognitive: usize) {
266        self.plugins
267            .entry("length".into())
268            .or_default()
269            .options
270            .entry("max_function_lines".into())
271            .or_insert(toml::Value::Integer(lines as i64));
272        self.plugins
273            .entry("complexity".into())
274            .or_default()
275            .options
276            .entry("max_complexity".into())
277            .or_insert(toml::Value::Integer(complexity as i64));
278        self.plugins
279            .entry("cognitive_complexity".into())
280            .or_default()
281            .options
282            .entry("max_cognitive_complexity".into())
283            .or_insert(toml::Value::Integer(cognitive as i64));
284    }
285}
286
287/// Walk from `start_dir` up to `root`, collecting `.cha.toml` configs (closest first).
288fn collect_configs_upward(start_dir: &Path, root: &Path) -> Vec<Config> {
289    let mut configs = Vec::new();
290    let mut current = start_dir.to_path_buf();
291    loop {
292        let cfg_path = current.join(".cha.toml");
293        if cfg_path.is_file()
294            && let Ok(content) = std::fs::read_to_string(&cfg_path)
295            && let Ok(cfg) = toml::from_str::<Config>(&content)
296        {
297            configs.push(cfg);
298        }
299        if current == root {
300            break;
301        }
302        match current.parent() {
303            Some(p) if p.starts_with(root) || p == root => current = p.to_path_buf(),
304            _ => break,
305        }
306    }
307    configs
308}
309
310/// A builtin plugin profile entry: (name, enabled, option overrides).
311pub type PluginProfile = (&'static str, bool, &'static [(&'static str, i64)]);
312
313/// Builtin language profiles: default plugin settings for specific languages.
314/// Returns (plugin_name, enabled, options) tuples. Users can override via `[languages.xx.plugins.yy]`.
315pub fn builtin_language_profile(language: &str) -> Option<Vec<PluginProfile>> {
316    match language {
317        "c" | "cpp" => Some(vec![
318            ("naming", false, &[] as &[(&str, i64)]),
319            ("lazy_class", false, &[]),
320            ("data_class", false, &[]),
321            ("builder_pattern", false, &[]),
322            ("null_object_pattern", false, &[]),
323            ("strategy_pattern", false, &[]),
324            (
325                "length",
326                true,
327                &[
328                    ("max_function_lines", 100),
329                    ("max_file_lines", 2000),
330                    ("max_class_lines", 400),
331                ],
332            ),
333            (
334                "complexity",
335                true,
336                &[("warn_threshold", 15), ("error_threshold", 30)],
337            ),
338            ("cognitive_complexity", true, &[("threshold", 25)]),
339            ("coupling", true, &[("max_imports", 25)]),
340            ("long_parameter_list", true, &[("max_params", 7)]),
341        ]),
342        _ => None,
343    }
344}