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    /// Banned attribute patterns (e.g., `allow(dead_code)`).
164    #[serde(default)]
165    pub forbid_attributes: PatternCheck,
166    /// Banned type patterns (e.g., `Arc<String>`).
167    #[serde(default)]
168    pub forbid_types: PatternCheck,
169    /// Banned method call patterns (e.g., `.unwrap()`).
170    #[serde(default)]
171    pub forbid_calls: PatternCheck,
172    /// Banned macro patterns (e.g., `panic!`).
173    #[serde(default)]
174    pub forbid_macros: PatternCheck,
175    /// Thresholds for the generic-naming check.
176    #[serde(default)]
177    pub check_naming: NamingCheck,
178    /// Flag `if` inside `if`.
179    #[serde(default = "default_true")]
180    pub check_nested_if: bool,
181    /// Flag `if` inside `match` arm.
182    #[serde(default = "default_true")]
183    pub check_if_in_match: bool,
184    /// Flag `match` inside `match`.
185    #[serde(default = "default_true")]
186    pub check_nested_match: bool,
187    /// Flag `match` inside `if` branch.
188    #[serde(default = "default_true")]
189    pub check_match_in_if: bool,
190    /// Flag long `if/else if` chains.
191    #[serde(default = "default_true")]
192    pub check_else_chain: bool,
193    /// Flag any use of the `else` keyword.
194    #[serde(default)]
195    pub forbid_else: bool,
196    /// Flag any `unsafe` block.
197    #[serde(default = "default_true")]
198    pub forbid_unsafe: bool,
199    /// Flag dynamic dispatch in return types.
200    #[serde(default)]
201    pub check_dyn_return: bool,
202    /// Flag dynamic dispatch in function parameters.
203    #[serde(default)]
204    pub check_dyn_param: bool,
205    /// Flag `Vec<Box<dyn T>>` anywhere.
206    #[serde(default)]
207    pub check_vec_box_dyn: bool,
208    /// Flag dynamic dispatch in struct fields.
209    #[serde(default)]
210    pub check_dyn_field: bool,
211    /// Flag `.clone()` inside loop bodies.
212    #[serde(default)]
213    pub check_clone_in_loop: bool,
214    /// Flag `HashMap`/`HashSet` with default SipHash hasher.
215    #[serde(default)]
216    pub check_default_hasher: bool,
217    /// Flag disconnected type groups in a single file.
218    #[serde(default)]
219    pub check_mixed_concerns: bool,
220    /// Flag `#[cfg(test)] mod` blocks embedded in source files.
221    #[serde(default)]
222    pub check_inline_tests: bool,
223    /// Flag `let _ = expr` that discards a Result.
224    #[serde(default)]
225    pub check_let_underscore_result: bool,
226    /// Per-path configuration overrides keyed by glob pattern.
227    #[serde(default)]
228    pub overrides: BTreeMap<Box<str>, PathOverride>,
229}
230
231/// Per-path overrides (e.g., for `tests/**`). `None` inherits from base config.
232#[derive(Debug, Deserialize, Default)]
233pub struct PathOverride {
234    /// `Some(false)` disables all checks for matched paths.
235    pub enabled: Option<bool>,
236    /// Replace nesting depth limit.
237    pub max_depth: Option<usize>,
238    /// Replace forbidden attribute patterns.
239    pub forbid_attributes: Option<PatternOverride>,
240    /// Replace forbidden type patterns.
241    pub forbid_types: Option<PatternOverride>,
242    /// Replace forbidden call patterns.
243    pub forbid_calls: Option<PatternOverride>,
244    /// Replace forbidden macro patterns.
245    pub forbid_macros: Option<PatternOverride>,
246    /// Replace generic naming thresholds.
247    pub check_naming: Option<NamingOverride>,
248    /// Replace nested-if check state.
249    pub check_nested_if: Option<bool>,
250    /// Replace if-in-match check state.
251    pub check_if_in_match: Option<bool>,
252    /// Replace nested-match check state.
253    pub check_nested_match: Option<bool>,
254    /// Replace match-in-if check state.
255    pub check_match_in_if: Option<bool>,
256    /// Replace else-chain check state.
257    pub check_else_chain: Option<bool>,
258    /// Replace `else` keyword ban state.
259    pub forbid_else: Option<bool>,
260    /// Replace `unsafe` block ban state.
261    pub forbid_unsafe: Option<bool>,
262    /// Replace dyn-return check state.
263    pub check_dyn_return: Option<bool>,
264    /// Replace dyn-param check state.
265    pub check_dyn_param: Option<bool>,
266    /// Replace `Vec<Box<dyn T>>` check state.
267    pub check_vec_box_dyn: Option<bool>,
268    /// Replace dyn-field check state.
269    pub check_dyn_field: Option<bool>,
270    /// Replace clone-in-loop check state.
271    pub check_clone_in_loop: Option<bool>,
272    /// Replace default-hasher check state.
273    pub check_default_hasher: Option<bool>,
274    /// Replace mixed-concerns check state.
275    pub check_mixed_concerns: Option<bool>,
276    /// Replace inline-tests check state.
277    pub check_inline_tests: Option<bool>,
278    /// Replace let-underscore-result check state.
279    pub check_let_underscore_result: Option<bool>,
280}
281
282fn default_max_depth() -> usize {
283    3
284}
285
286fn default_else_chain_threshold() -> usize {
287    3
288}
289
290fn default_true() -> bool {
291    true
292}
293
294/// Find the first `[overrides]` entry whose glob matches `file_path`.
295pub fn check_path_override<'a>(
296    file_path: &str,
297    config: &'a ConfigFile,
298) -> Option<&'a PathOverride> {
299    for (pattern, override_config) in &config.overrides {
300        if matches_glob(pattern, file_path) {
301            return Some(override_config);
302        }
303    }
304    None
305}
306
307/// Single source of truth for boolean check fields.
308///
309/// Each entry: `"doc", field_name, default_value;`
310///
311/// Adding a new boolean check requires:
312/// 1. Add one line here
313/// 2. Add the field to `ConfigFile` (bool) and `PathOverride` (Option<bool>)
314///
315/// The macro generates `CheckConfig` fields + Default + from_config_file +
316/// merge_bool_overrides. A compile-time assertion (`assert_bool_fields_in_sync`)
317/// catches missing fields in `ConfigFile` or `PathOverride`.
318///
319/// Non-boolean fields (max_depth, forbid_*, check_naming, etc.) stay hand-written.
320macro_rules! for_each_bool_check {
321    ($callback:ident!) => {
322        $callback! {
323            "Flag `if` inside `if`.", check_nested_if, true;
324            "Flag `if` inside `match` arm.", check_if_in_match, true;
325            "Flag `match` inside `match`.", check_nested_match, true;
326            "Flag `match` inside `if` branch.", check_match_in_if, true;
327            "Flag long `if/else if` chains.", check_else_chain, true;
328            "Flag any use of the `else` keyword.", forbid_else, false;
329            "Flag any `unsafe` block.", forbid_unsafe, true;
330            "Flag dynamic dispatch in return types.", check_dyn_return, false;
331            "Flag dynamic dispatch in function parameters.", check_dyn_param, false;
332            "Flag `Vec<Box<dyn T>>`.", check_vec_box_dyn, false;
333            "Flag dynamic dispatch in struct fields.", check_dyn_field, false;
334            "Flag `.clone()` inside loop bodies.", check_clone_in_loop, false;
335            "Flag `HashMap`/`HashSet` with default hasher.", check_default_hasher, false;
336            "Flag disconnected type groups in a single file.", check_mixed_concerns, false;
337            "Flag `#[cfg(test)] mod` blocks in source files.", check_inline_tests, false;
338            "Flag `let _ = expr` that discards a Result.", check_let_underscore_result, false;
339        }
340    };
341}
342
343/// Generates `CheckConfig` struct (boolean fields + non-boolean fields),
344/// `Default`, `from_config_file`, and `merge_bool_overrides`.
345macro_rules! impl_check_config {
346    ($($doc:literal, $field:ident, $default:expr;)*) => {
347        /// Configuration controlling which checks are enabled and their thresholds.
348        #[derive(Debug, Clone)]
349        pub struct CheckConfig {
350            /// Maximum allowed nesting depth.
351            pub max_depth: usize,
352            /// Minimum branches to trigger `else-chain`.
353            pub else_chain_threshold: usize,
354            /// Banned attribute patterns.
355            pub forbid_attributes: PatternCheck,
356            /// Banned type patterns.
357            pub forbid_types: PatternCheck,
358            /// Banned method call patterns.
359            pub forbid_calls: PatternCheck,
360            /// Banned macro patterns.
361            pub forbid_macros: PatternCheck,
362            /// Generic naming check configuration.
363            pub check_naming: NamingCheck,
364            $(
365                #[doc = $doc]
366                pub $field: bool,
367            )*
368        }
369
370        impl Default for CheckConfig {
371            fn default() -> Self {
372                Self {
373                    max_depth: 3,
374                    else_chain_threshold: 3,
375                    forbid_attributes: PatternCheck::default(),
376                    forbid_types: PatternCheck::default(),
377                    forbid_calls: PatternCheck::default(),
378                    forbid_macros: PatternCheck::default(),
379                    check_naming: NamingCheck::default(),
380                    $( $field: $default, )*
381                }
382            }
383        }
384
385        impl CheckConfig {
386            /// Build from a [`ConfigFile`], copying all fields.
387            pub fn from_config_file(fc: &ConfigFile) -> Self {
388                Self {
389                    max_depth: fc.max_depth,
390                    else_chain_threshold: fc.else_chain_threshold,
391                    forbid_attributes: fc.forbid_attributes.clone(),
392                    forbid_types: fc.forbid_types.clone(),
393                    forbid_calls: fc.forbid_calls.clone(),
394                    forbid_macros: fc.forbid_macros.clone(),
395                    check_naming: fc.check_naming.clone(),
396                    $( $field: fc.$field, )*
397                }
398            }
399
400            /// Apply `Option<bool>` overrides from a [`PathOverride`].
401            pub fn merge_bool_overrides(&mut self, ovr: &PathOverride) {
402                $(
403                    if let Some(v) = ovr.$field {
404                        self.$field = v;
405                    }
406                )*
407            }
408        }
409    };
410}
411
412for_each_bool_check!(impl_check_config!);
413
414/// Compile-time assertion: every boolean check field in `for_each_bool_check!`
415/// must exist in `ConfigFile` (as `bool`) and `PathOverride` (as `Option<bool>`).
416/// Adding a field to the macro without updating these structs is a compile error.
417macro_rules! assert_bool_fields_in_sync {
418    ($($doc:literal, $field:ident, $default:expr;)*) => {
419        #[cfg(test)]
420        const _: () = {
421            $(
422                const fn $field(cf: &ConfigFile, po: &PathOverride) -> (bool, Option<bool>) {
423                    (cf.$field, po.$field)
424                }
425            )*
426        };
427    };
428}
429
430for_each_bool_check!(assert_bool_fields_in_sync!);
431
432impl CheckConfig {
433    /// Returns the effective config for a file path.
434    ///
435    /// Borrows `self` when no overrides match (zero clones).
436    /// Clones and mutates only when a path override applies.
437    /// Returns `None` when the override disables analysis for this path.
438    pub fn resolve_for_path<'a>(
439        &'a self,
440        file_path: &str,
441        file_config: Option<&ConfigFile>,
442    ) -> Option<Cow<'a, Self>> {
443        let Some(fc) = file_config else {
444            return Some(Cow::Borrowed(self));
445        };
446
447        let Some(override_cfg) = check_path_override(file_path, fc) else {
448            return Some(Cow::Borrowed(self));
449        };
450
451        if override_cfg.enabled == Some(false) {
452            return None;
453        }
454
455        let mut config = self.clone();
456        if let Some(max_depth) = override_cfg.max_depth {
457            config.max_depth = max_depth;
458        }
459
460        config.merge_bool_overrides(override_cfg);
461
462        macro_rules! apply {
463            ($field:ident) => {
464                if let Some(ref ovr) = override_cfg.$field {
465                    config.$field.apply_override(ovr);
466                }
467            };
468        }
469        apply!(forbid_attributes);
470        apply!(forbid_types);
471        apply!(forbid_calls);
472        apply!(forbid_macros);
473        apply!(check_naming);
474
475        Some(Cow::Owned(config))
476    }
477}
478
479/// Failure modes when loading `.pedant.toml`.
480#[derive(Debug, thiserror::Error)]
481pub enum ConfigError {
482    /// Disk I/O failure reading the config file.
483    #[error("failed to read config file: {0}")]
484    Read(#[from] std::io::Error),
485    /// TOML syntax or schema error in the config file.
486    #[error("failed to parse config file: {0}")]
487    Parse(#[from] toml::de::Error),
488}
489
490/// Read and deserialize a `.pedant.toml` from the given path.
491pub fn load_config_file(path: &Path) -> Result<ConfigFile, ConfigError> {
492    let content = fs::read_to_string(path)?;
493    Ok(toml::from_str(&content)?)
494}
495
496/// Search `.pedant.toml` in the project root, then `$XDG_CONFIG_HOME/pedant/config.toml`.
497pub fn find_config_file() -> Option<std::path::PathBuf> {
498    find_project_config_file().or_else(find_global_config_file)
499}
500
501fn find_project_config_file() -> Option<std::path::PathBuf> {
502    let config_path = std::env::current_dir().ok()?.join(".pedant.toml");
503    config_path.exists().then_some(config_path)
504}
505
506fn find_global_config_file() -> Option<std::path::PathBuf> {
507    let config_dir = std::env::var_os("XDG_CONFIG_HOME")
508        .map(std::path::PathBuf::from)
509        .or_else(|| {
510            std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
511        })?;
512    let config_path = config_dir.join("pedant").join("config.toml");
513    config_path.exists().then_some(config_path)
514}
515
516/// Per-rule override from the `[gate]` TOML section.
517#[derive(Debug)]
518pub enum GateRuleOverride {
519    /// Suppresses the rule entirely.
520    Disabled,
521    /// Changes the rule's effective severity.
522    Severity(crate::gate::GateSeverity),
523}
524
525/// Deserialized `[gate]` section of `.pedant.toml`.
526///
527/// Keys are either `enabled` (master switch) or rule names mapped to
528/// `false` (disabled) or a severity string (`"deny"`, `"warn"`, `"info"`).
529#[derive(Debug)]
530pub struct GateConfig {
531    /// Master switch; `false` disables all gate rules.
532    pub enabled: bool,
533    /// Per-rule overrides keyed by rule name.
534    pub overrides: BTreeMap<Box<str>, GateRuleOverride>,
535}
536
537impl Default for GateConfig {
538    fn default() -> Self {
539        Self {
540            enabled: true,
541            overrides: BTreeMap::new(),
542        }
543    }
544}
545
546impl<'de> Deserialize<'de> for GateConfig {
547    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
548    where
549        D: serde::Deserializer<'de>,
550    {
551        use serde::de::Error;
552
553        #[derive(Deserialize)]
554        #[serde(untagged)]
555        enum GateTomlValue {
556            Bool(bool),
557            String(String),
558        }
559
560        let raw: BTreeMap<Box<str>, GateTomlValue> = BTreeMap::deserialize(deserializer)?;
561        let mut enabled = true;
562        let mut overrides = BTreeMap::new();
563
564        for (key, value) in raw {
565            match (&*key, value) {
566                ("enabled", GateTomlValue::Bool(b)) => enabled = b,
567                ("enabled", GateTomlValue::String(_)) => {
568                    return Err(D::Error::custom("'enabled' must be a boolean"));
569                }
570                (_, GateTomlValue::Bool(false)) => {
571                    overrides.insert(key, GateRuleOverride::Disabled);
572                }
573                (_, GateTomlValue::Bool(true)) => {} // true = use default, no override
574                (_, GateTomlValue::String(s)) => {
575                    let severity = parse_gate_severity(&s).ok_or_else(|| {
576                        D::Error::custom(format!(
577                            "invalid gate severity '{s}': expected \"deny\", \"warn\", or \"info\""
578                        ))
579                    })?;
580                    overrides.insert(key, GateRuleOverride::Severity(severity));
581                }
582            }
583        }
584
585        Ok(GateConfig { enabled, overrides })
586    }
587}
588
589fn parse_gate_severity(s: &str) -> Option<crate::gate::GateSeverity> {
590    use crate::gate::GateSeverity;
591    match s {
592        "deny" => Some(GateSeverity::Deny),
593        "warn" => Some(GateSeverity::Warn),
594        "info" => Some(GateSeverity::Info),
595        _ => None,
596    }
597}