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