Skip to main content

pedant_core/
check_config.rs

1use std::borrow::Cow;
2use std::collections::BTreeMap;
3use std::fs;
4use std::path::Path;
5use std::sync::{Arc, LazyLock};
6
7use serde::Deserialize;
8
9use crate::pattern::matches_glob;
10
11/// A set of glob patterns matched against rendered AST node text.
12#[derive(Debug, Deserialize, Default, Clone)]
13#[serde(deny_unknown_fields)]
14pub struct PatternCheck {
15    /// Master switch; `false` skips all patterns.
16    #[serde(default)]
17    pub enabled: bool,
18    /// Glob patterns (e.g., `.unwrap()`, `allow(dead_code)`).
19    #[serde(default, deserialize_with = "deserialize_arc_str_slice")]
20    pub patterns: Arc<[Arc<str>]>,
21}
22
23impl PatternCheck {
24    /// Merge a path-specific override, replacing fields that are set.
25    pub fn apply_override(&mut self, ovr: &PatternOverride) {
26        if let Some(enabled) = ovr.enabled {
27            self.enabled = enabled;
28        }
29        if !ovr.patterns.is_empty() {
30            self.patterns = ovr.patterns.clone();
31        }
32    }
33}
34
35fn deserialize_arc_str_slice<'de, D: serde::Deserializer<'de>>(
36    deserializer: D,
37) -> Result<Arc<[Arc<str>]>, D::Error> {
38    let strings: Vec<String> = Vec::deserialize(deserializer)?;
39    Ok(strings.into_iter().map(Arc::from).collect())
40}
41
42/// Default list of generic variable names that LLMs overuse.
43const DEFAULT_GENERIC_NAMES: &[&str] = &[
44    "tmp", "temp", "data", "val", "value", "result", "res", "ret", "buf", "buffer", "item", "elem",
45    "obj", "input", "output", "info", "ctx", "args", "params", "thing", "stuff", "foo", "bar",
46    "baz",
47];
48
49/// Thresholds for the generic-naming check (`tmp`, `val`, `data`, etc.).
50#[derive(Debug, Deserialize, Clone)]
51#[serde(deny_unknown_fields)]
52pub struct NamingCheck {
53    /// Master switch; `false` skips the naming check entirely.
54    #[serde(default)]
55    pub enabled: bool,
56    /// Words considered generic. Replaces the built-in list when provided.
57    #[serde(
58        default = "default_generic_names",
59        deserialize_with = "deserialize_arc_str_slice"
60    )]
61    pub generic_names: Arc<[Arc<str>]>,
62    /// Fraction of bindings that must be generic before flagging (0.0..=1.0).
63    #[serde(default = "default_max_generic_ratio")]
64    pub max_generic_ratio: f64,
65    /// Absolute minimum generic count before the ratio check kicks in.
66    #[serde(default = "default_min_generic_count")]
67    pub min_generic_count: usize,
68}
69
70impl Default for NamingCheck {
71    fn default() -> Self {
72        Self {
73            enabled: false,
74            generic_names: default_generic_names(),
75            max_generic_ratio: default_max_generic_ratio(),
76            min_generic_count: default_min_generic_count(),
77        }
78    }
79}
80
81impl NamingCheck {
82    /// Merge a path-specific override, replacing fields that are set.
83    pub fn apply_override(&mut self, ovr: &NamingOverride) {
84        if let Some(enabled) = ovr.enabled {
85            self.enabled = enabled;
86        }
87        if let Some(ref names) = ovr.generic_names {
88            self.generic_names = names.clone();
89        }
90        if let Some(ratio) = ovr.max_generic_ratio {
91            self.max_generic_ratio = ratio;
92        }
93        if let Some(count) = ovr.min_generic_count {
94            self.min_generic_count = count;
95        }
96    }
97}
98
99/// Path-specific overrides for the naming check. `None` inherits from base config.
100#[derive(Debug, Deserialize, Default, Clone)]
101pub struct NamingOverride {
102    /// Replace the enabled state.
103    pub enabled: Option<bool>,
104    /// Replace the generic names list.
105    #[serde(default, deserialize_with = "deserialize_option_arc_str_slice")]
106    pub generic_names: Option<Arc<[Arc<str>]>>,
107    /// Replace the maximum generic ratio threshold.
108    pub max_generic_ratio: Option<f64>,
109    /// Replace the minimum generic count threshold.
110    pub min_generic_count: Option<usize>,
111}
112
113static GENERIC_NAMES_ARC: LazyLock<Arc<[Arc<str>]>> = LazyLock::new(|| {
114    DEFAULT_GENERIC_NAMES
115        .iter()
116        .map(|s| Arc::from(*s))
117        .collect()
118});
119
120fn default_generic_names() -> Arc<[Arc<str>]> {
121    Arc::clone(&GENERIC_NAMES_ARC)
122}
123
124type ArcStrSlice = Arc<[Arc<str>]>;
125
126fn deserialize_option_arc_str_slice<'de, D: serde::Deserializer<'de>>(
127    deserializer: D,
128) -> Result<Option<ArcStrSlice>, D::Error> {
129    let opt: Option<Vec<String>> = Option::deserialize(deserializer)?;
130    Ok(opt.map(|v| v.into_iter().map(Arc::from).collect()))
131}
132
133fn default_max_generic_ratio() -> f64 {
134    0.3
135}
136
137fn default_min_generic_count() -> usize {
138    2
139}
140
141/// Path-specific overrides for a pattern check. `None` inherits from base config.
142#[derive(Debug, Deserialize, Default, Clone)]
143pub struct PatternOverride {
144    /// Replace the enabled state.
145    pub enabled: Option<bool>,
146    /// Replace the pattern list. Empty slice inherits from base.
147    #[serde(default, deserialize_with = "deserialize_arc_str_slice")]
148    pub patterns: Arc<[Arc<str>]>,
149}
150
151/// Deserialized `.pedant.toml` file with all check settings.
152#[derive(Debug, Deserialize, Default)]
153pub struct ConfigFile {
154    /// Security gate rules configuration.
155    #[serde(default)]
156    pub gate: GateConfig,
157    /// Depth limit for nesting checks (default: 3).
158    #[serde(default = "default_max_depth")]
159    pub max_depth: usize,
160    /// Branch count that triggers `else-chain` (default: 3).
161    #[serde(default = "default_else_chain_threshold")]
162    pub else_chain_threshold: usize,
163    /// Maximum parameter count before `high-param-count` fires (default: 5).
164    #[serde(default = "default_max_params")]
165    pub max_params: usize,
166    /// Banned attribute patterns (e.g., `allow(dead_code)`).
167    #[serde(default)]
168    pub forbid_attributes: PatternCheck,
169    /// Banned type patterns (e.g., `Arc<String>`).
170    #[serde(default)]
171    pub forbid_types: PatternCheck,
172    /// Banned method call patterns (e.g., `.unwrap()`).
173    #[serde(default)]
174    pub forbid_calls: PatternCheck,
175    /// Banned macro patterns (e.g., `panic!`).
176    #[serde(default)]
177    pub forbid_macros: PatternCheck,
178    /// Thresholds for the generic-naming check.
179    #[serde(default)]
180    pub check_naming: NamingCheck,
181    /// Flag `if` inside `if`.
182    #[serde(default = "default_true")]
183    pub check_nested_if: bool,
184    /// Flag `if` inside `match` arm.
185    #[serde(default = "default_true")]
186    pub check_if_in_match: bool,
187    /// Flag `match` inside `match`.
188    #[serde(default = "default_true")]
189    pub check_nested_match: bool,
190    /// Flag `match` inside `if` branch.
191    #[serde(default = "default_true")]
192    pub check_match_in_if: bool,
193    /// Flag long `if/else if` chains.
194    #[serde(default = "default_true")]
195    pub check_else_chain: bool,
196    /// Flag any use of the `else` keyword.
197    #[serde(default)]
198    pub forbid_else: bool,
199    /// Flag any `unsafe` block.
200    #[serde(default = "default_true")]
201    pub forbid_unsafe: bool,
202    /// Flag dynamic dispatch in return types.
203    #[serde(default)]
204    pub check_dyn_return: bool,
205    /// Flag dynamic dispatch in function parameters.
206    #[serde(default)]
207    pub check_dyn_param: bool,
208    /// Flag `Vec<Box<dyn T>>` anywhere.
209    #[serde(default)]
210    pub check_vec_box_dyn: bool,
211    /// Flag dynamic dispatch in struct fields.
212    #[serde(default)]
213    pub check_dyn_field: bool,
214    /// Flag `.clone()` inside loop bodies.
215    #[serde(default)]
216    pub check_clone_in_loop: bool,
217    /// Flag `HashMap`/`HashSet` with default SipHash hasher.
218    #[serde(default)]
219    pub check_default_hasher: bool,
220    /// Flag disconnected type groups in a single file.
221    #[serde(default)]
222    pub check_mixed_concerns: bool,
223    /// Flag `#[cfg(test)] mod` blocks embedded in source files.
224    #[serde(default)]
225    pub check_inline_tests: bool,
226    /// Flag `let _ = expr` that discards a Result.
227    #[serde(default)]
228    pub check_let_underscore_result: bool,
229    /// Flag functions with too many parameters.
230    #[serde(default)]
231    pub check_high_param_count: bool,
232    /// Per-path configuration overrides keyed by glob pattern.
233    #[serde(default)]
234    pub overrides: BTreeMap<Box<str>, PathOverride>,
235}
236
237/// Per-path overrides (e.g., for `tests/**`). `None` inherits from base config.
238#[derive(Debug, Deserialize, Default)]
239pub struct PathOverride {
240    /// `Some(false)` disables all checks for matched paths.
241    pub enabled: Option<bool>,
242    /// Replace nesting depth limit.
243    pub max_depth: Option<usize>,
244    /// Replace maximum parameter count.
245    pub max_params: Option<usize>,
246    /// Replace forbidden attribute patterns.
247    pub forbid_attributes: Option<PatternOverride>,
248    /// Replace forbidden type patterns.
249    pub forbid_types: Option<PatternOverride>,
250    /// Replace forbidden call patterns.
251    pub forbid_calls: Option<PatternOverride>,
252    /// Replace forbidden macro patterns.
253    pub forbid_macros: Option<PatternOverride>,
254    /// Replace generic naming thresholds.
255    pub check_naming: Option<NamingOverride>,
256    /// Replace nested-if check state.
257    pub check_nested_if: Option<bool>,
258    /// Replace if-in-match check state.
259    pub check_if_in_match: Option<bool>,
260    /// Replace nested-match check state.
261    pub check_nested_match: Option<bool>,
262    /// Replace match-in-if check state.
263    pub check_match_in_if: Option<bool>,
264    /// Replace else-chain check state.
265    pub check_else_chain: Option<bool>,
266    /// Replace `else` keyword ban state.
267    pub forbid_else: Option<bool>,
268    /// Replace `unsafe` block ban state.
269    pub forbid_unsafe: Option<bool>,
270    /// Replace dyn-return check state.
271    pub check_dyn_return: Option<bool>,
272    /// Replace dyn-param check state.
273    pub check_dyn_param: Option<bool>,
274    /// Replace `Vec<Box<dyn T>>` check state.
275    pub check_vec_box_dyn: Option<bool>,
276    /// Replace dyn-field check state.
277    pub check_dyn_field: Option<bool>,
278    /// Replace clone-in-loop check state.
279    pub check_clone_in_loop: Option<bool>,
280    /// Replace default-hasher check state.
281    pub check_default_hasher: Option<bool>,
282    /// Replace mixed-concerns check state.
283    pub check_mixed_concerns: Option<bool>,
284    /// Replace inline-tests check state.
285    pub check_inline_tests: Option<bool>,
286    /// Replace let-underscore-result check state.
287    pub check_let_underscore_result: Option<bool>,
288    /// Replace high-param-count check state.
289    pub check_high_param_count: Option<bool>,
290}
291
292fn default_max_depth() -> usize {
293    3
294}
295
296fn default_else_chain_threshold() -> usize {
297    3
298}
299
300fn default_max_params() -> usize {
301    5
302}
303
304fn default_true() -> bool {
305    true
306}
307
308/// Find the first `[overrides]` entry whose glob matches `file_path`.
309pub fn check_path_override<'a>(
310    file_path: &str,
311    config: &'a ConfigFile,
312) -> Option<&'a PathOverride> {
313    for (pattern, override_config) in &config.overrides {
314        if matches_glob(pattern, file_path) {
315            return Some(override_config);
316        }
317    }
318    None
319}
320
321/// Single source of truth for boolean check fields.
322///
323/// Each entry: `"doc", field_name, default_value;`
324///
325/// Adding a new boolean check requires:
326/// 1. Add one line here
327/// 2. Add the field to `ConfigFile` (bool) and `PathOverride` (Option<bool>)
328///
329/// The macro generates `CheckConfig` fields + Default + from_config_file +
330/// merge_bool_overrides. A compile-time assertion (`assert_bool_fields_in_sync`)
331/// catches missing fields in `ConfigFile` or `PathOverride`.
332///
333/// Non-boolean fields (max_depth, forbid_*, check_naming, etc.) stay hand-written.
334macro_rules! for_each_bool_check {
335    ($callback:ident!) => {
336        $callback! {
337            "Flag `if` inside `if`.", check_nested_if, true;
338            "Flag `if` inside `match` arm.", check_if_in_match, true;
339            "Flag `match` inside `match`.", check_nested_match, true;
340            "Flag `match` inside `if` branch.", check_match_in_if, true;
341            "Flag long `if/else if` chains.", check_else_chain, true;
342            "Flag any use of the `else` keyword.", forbid_else, false;
343            "Flag any `unsafe` block.", forbid_unsafe, true;
344            "Flag dynamic dispatch in return types.", check_dyn_return, false;
345            "Flag dynamic dispatch in function parameters.", check_dyn_param, false;
346            "Flag `Vec<Box<dyn T>>`.", check_vec_box_dyn, false;
347            "Flag dynamic dispatch in struct fields.", check_dyn_field, false;
348            "Flag `.clone()` inside loop bodies.", check_clone_in_loop, false;
349            "Flag `HashMap`/`HashSet` with default hasher.", check_default_hasher, false;
350            "Flag disconnected type groups in a single file.", check_mixed_concerns, false;
351            "Flag `#[cfg(test)] mod` blocks in source files.", check_inline_tests, false;
352            "Flag `let _ = expr` that discards a Result.", check_let_underscore_result, false;
353            "Flag functions with too many parameters.", check_high_param_count, false;
354        }
355    };
356}
357
358/// Generates `CheckConfig` struct (boolean fields + non-boolean fields),
359/// `Default`, `from_config_file`, and `merge_bool_overrides`.
360macro_rules! impl_check_config {
361    ($($doc:literal, $field:ident, $default:expr;)*) => {
362        /// Configuration controlling which checks are enabled and their thresholds.
363        #[derive(Debug, Clone)]
364        pub struct CheckConfig {
365            /// Maximum allowed nesting depth.
366            pub max_depth: usize,
367            /// Minimum branches to trigger `else-chain`.
368            pub else_chain_threshold: usize,
369            /// Maximum parameter count before `high-param-count` fires.
370            pub max_params: usize,
371            /// Banned attribute patterns.
372            pub forbid_attributes: PatternCheck,
373            /// Banned type patterns.
374            pub forbid_types: PatternCheck,
375            /// Banned method call patterns.
376            pub forbid_calls: PatternCheck,
377            /// Banned macro patterns.
378            pub forbid_macros: PatternCheck,
379            /// Generic naming check configuration.
380            pub check_naming: NamingCheck,
381            $(
382                #[doc = $doc]
383                pub $field: bool,
384            )*
385        }
386
387        impl Default for CheckConfig {
388            fn default() -> Self {
389                Self {
390                    max_depth: default_max_depth(),
391                    else_chain_threshold: default_else_chain_threshold(),
392                    max_params: default_max_params(),
393                    forbid_attributes: PatternCheck::default(),
394                    forbid_types: PatternCheck::default(),
395                    forbid_calls: PatternCheck::default(),
396                    forbid_macros: PatternCheck::default(),
397                    check_naming: NamingCheck::default(),
398                    $( $field: $default, )*
399                }
400            }
401        }
402
403        impl CheckConfig {
404            /// Build from a [`ConfigFile`], copying all fields.
405            pub fn from_config_file(fc: &ConfigFile) -> Self {
406                Self {
407                    max_depth: fc.max_depth,
408                    else_chain_threshold: fc.else_chain_threshold,
409                    max_params: fc.max_params,
410                    forbid_attributes: fc.forbid_attributes.clone(),
411                    forbid_types: fc.forbid_types.clone(),
412                    forbid_calls: fc.forbid_calls.clone(),
413                    forbid_macros: fc.forbid_macros.clone(),
414                    check_naming: fc.check_naming.clone(),
415                    $( $field: fc.$field, )*
416                }
417            }
418
419            /// Apply `Option<bool>` overrides from a [`PathOverride`].
420            pub fn merge_bool_overrides(&mut self, ovr: &PathOverride) {
421                $(
422                    if let Some(v) = ovr.$field {
423                        self.$field = v;
424                    }
425                )*
426            }
427        }
428    };
429}
430
431for_each_bool_check!(impl_check_config!);
432
433/// Compile-time assertion: every boolean check field in `for_each_bool_check!`
434/// must exist in `ConfigFile` (as `bool`) and `PathOverride` (as `Option<bool>`).
435/// Adding a field to the macro without updating these structs is a compile error.
436macro_rules! assert_bool_fields_in_sync {
437    ($($doc:literal, $field:ident, $default:expr;)*) => {
438        #[cfg(test)]
439        const _: () = {
440            // Access each field on both structs. If a field is missing
441            // from either, this block fails to compile.
442            const fn _check(cf: &ConfigFile, po: &PathOverride) {
443                $( let _ = (cf.$field, po.$field); )*
444            }
445        };
446    };
447}
448
449for_each_bool_check!(assert_bool_fields_in_sync!);
450
451impl CheckConfig {
452    /// Returns the effective config for a file path.
453    ///
454    /// Borrows `self` when no overrides match (zero clones).
455    /// Clones and mutates only when a path override applies.
456    /// Returns `None` when the override disables analysis for this path.
457    pub fn resolve_for_path<'a>(
458        &'a self,
459        file_path: &str,
460        file_config: Option<&ConfigFile>,
461    ) -> Option<Cow<'a, Self>> {
462        let Some(fc) = file_config else {
463            return Some(Cow::Borrowed(self));
464        };
465
466        let Some(override_cfg) = check_path_override(file_path, fc) else {
467            return Some(Cow::Borrowed(self));
468        };
469
470        if override_cfg.enabled == Some(false) {
471            return None;
472        }
473
474        let mut config = self.clone();
475        if let Some(max_depth) = override_cfg.max_depth {
476            config.max_depth = max_depth;
477        }
478        if let Some(max_params) = override_cfg.max_params {
479            config.max_params = max_params;
480        }
481
482        config.merge_bool_overrides(override_cfg);
483
484        macro_rules! apply {
485            ($field:ident) => {
486                if let Some(ref ovr) = override_cfg.$field {
487                    config.$field.apply_override(ovr);
488                }
489            };
490        }
491        apply!(forbid_attributes);
492        apply!(forbid_types);
493        apply!(forbid_calls);
494        apply!(forbid_macros);
495        apply!(check_naming);
496
497        Some(Cow::Owned(config))
498    }
499}
500
501/// Failure modes when loading `.pedant.toml`.
502#[derive(Debug, thiserror::Error)]
503pub enum ConfigError {
504    /// Disk I/O failure reading the config file.
505    #[error("failed to read config file: {0}")]
506    Read(#[from] std::io::Error),
507    /// TOML syntax or schema error in the config file.
508    #[error("failed to parse config file: {0}")]
509    Parse(#[from] toml::de::Error),
510}
511
512/// Read and deserialize a `.pedant.toml` from the given path.
513pub fn load_config_file(path: &Path) -> Result<ConfigFile, ConfigError> {
514    let content = fs::read_to_string(path)?;
515    Ok(toml::from_str(&content)?)
516}
517
518/// Search `.pedant.toml` in the project root, then `$XDG_CONFIG_HOME/pedant/config.toml`.
519pub fn find_config_file() -> Option<std::path::PathBuf> {
520    find_project_config_file().or_else(find_global_config_file)
521}
522
523fn find_project_config_file() -> Option<std::path::PathBuf> {
524    let config_path = std::env::current_dir().ok()?.join(".pedant.toml");
525    config_path.exists().then_some(config_path)
526}
527
528fn find_global_config_file() -> Option<std::path::PathBuf> {
529    let config_dir = std::env::var_os("XDG_CONFIG_HOME")
530        .map(std::path::PathBuf::from)
531        .or_else(|| {
532            std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
533        })?;
534    let config_path = config_dir.join("pedant").join("config.toml");
535    config_path.exists().then_some(config_path)
536}
537
538/// Per-rule override from the `[gate]` TOML section.
539#[derive(Debug)]
540pub enum GateRuleOverride {
541    /// Suppresses the rule entirely.
542    Disabled,
543    /// Changes the rule's effective severity.
544    Severity(crate::gate::GateSeverity),
545}
546
547/// Deserialized `[gate]` section of `.pedant.toml`.
548///
549/// Keys are either `enabled` (master switch) or rule names mapped to
550/// `false` (disabled) or a severity string (`"deny"`, `"warn"`, `"info"`).
551#[derive(Debug)]
552pub struct GateConfig {
553    /// Master switch; `false` disables all gate rules.
554    pub enabled: bool,
555    /// Per-rule overrides keyed by rule name.
556    pub overrides: BTreeMap<Box<str>, GateRuleOverride>,
557}
558
559impl Default for GateConfig {
560    fn default() -> Self {
561        Self {
562            enabled: true,
563            overrides: BTreeMap::new(),
564        }
565    }
566}
567
568impl<'de> Deserialize<'de> for GateConfig {
569    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
570    where
571        D: serde::Deserializer<'de>,
572    {
573        use serde::de::Error;
574
575        #[derive(Deserialize)]
576        #[serde(untagged)]
577        enum GateTomlValue {
578            Bool(bool),
579            String(String),
580        }
581
582        let raw: BTreeMap<Box<str>, GateTomlValue> = BTreeMap::deserialize(deserializer)?;
583        let mut enabled = true;
584        let mut overrides = BTreeMap::new();
585
586        for (key, value) in raw {
587            match (&*key, value) {
588                ("enabled", GateTomlValue::Bool(b)) => enabled = b,
589                ("enabled", GateTomlValue::String(_)) => {
590                    return Err(D::Error::custom("'enabled' must be a boolean"));
591                }
592                (_, GateTomlValue::Bool(false)) => {
593                    overrides.insert(key, GateRuleOverride::Disabled);
594                }
595                (_, GateTomlValue::Bool(true)) => {} // true = use default, no override
596                (_, GateTomlValue::String(s)) => {
597                    let severity = parse_gate_severity(&s).ok_or_else(|| {
598                        D::Error::custom(format!(
599                            "invalid gate severity '{s}': expected \"deny\", \"warn\", or \"info\""
600                        ))
601                    })?;
602                    overrides.insert(key, GateRuleOverride::Severity(severity));
603                }
604            }
605        }
606
607        Ok(GateConfig { enabled, overrides })
608    }
609}
610
611fn parse_gate_severity(s: &str) -> Option<crate::gate::GateSeverity> {
612    use crate::gate::GateSeverity;
613    match s {
614        "deny" => Some(GateSeverity::Deny),
615        "warn" => Some(GateSeverity::Warn),
616        "info" => Some(GateSeverity::Info),
617        _ => None,
618    }
619}