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