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)]
101#[serde(deny_unknown_fields)]
102pub struct NamingOverride {
103    /// Replace the enabled state.
104    pub enabled: Option<bool>,
105    /// Replace the generic names list.
106    #[serde(default, deserialize_with = "deserialize_option_arc_str_slice")]
107    pub generic_names: Option<Arc<[Arc<str>]>>,
108    /// Replace the maximum generic ratio threshold.
109    pub max_generic_ratio: Option<f64>,
110    /// Replace the minimum generic count threshold.
111    pub min_generic_count: Option<usize>,
112}
113
114static GENERIC_NAMES_ARC: LazyLock<Arc<[Arc<str>]>> = LazyLock::new(|| {
115    DEFAULT_GENERIC_NAMES
116        .iter()
117        .map(|s| Arc::from(*s))
118        .collect()
119});
120
121fn default_generic_names() -> Arc<[Arc<str>]> {
122    Arc::clone(&GENERIC_NAMES_ARC)
123}
124
125type ArcStrSlice = Arc<[Arc<str>]>;
126
127fn deserialize_option_arc_str_slice<'de, D: serde::Deserializer<'de>>(
128    deserializer: D,
129) -> Result<Option<ArcStrSlice>, D::Error> {
130    let opt: Option<Vec<String>> = Option::deserialize(deserializer)?;
131    Ok(opt.map(|v| v.into_iter().map(Arc::from).collect()))
132}
133
134fn default_max_generic_ratio() -> f64 {
135    0.3
136}
137
138fn default_min_generic_count() -> usize {
139    2
140}
141
142/// Path-specific overrides for a pattern check. `None` inherits from base config.
143#[derive(Debug, Deserialize, Default, Clone)]
144#[serde(deny_unknown_fields)]
145pub struct PatternOverride {
146    /// Replace the enabled state.
147    pub enabled: Option<bool>,
148    /// Replace the pattern list. Empty slice inherits from base.
149    #[serde(default, deserialize_with = "deserialize_arc_str_slice")]
150    pub patterns: Arc<[Arc<str>]>,
151}
152
153/// One `item-visibility-policy` rule: a named item at a path must have an
154/// exact visibility.
155#[derive(Debug, Deserialize, Clone)]
156#[serde(deny_unknown_fields)]
157pub struct ItemVisibilityRule {
158    /// Repository-relative source path the item must live in.
159    pub path: Box<str>,
160    /// Item kind: `struct`, `enum`, `union`, `trait`, or `fn`.
161    pub kind: Box<str>,
162    /// Exact item name.
163    pub name: Box<str>,
164    /// Required visibility: `private`, `pub`, `pub(crate)`, `pub(super)`,
165    /// or `pub(in <path>)`.
166    pub visibility: Box<str>,
167}
168
169/// One `feature-boundary` rule: a package feature must obey the named invariant.
170#[derive(Debug, Deserialize, Clone)]
171#[serde(deny_unknown_fields)]
172pub struct FeatureBoundaryRule {
173    /// The package whose feature is constrained.
174    pub package: Box<str>,
175    /// The feature name the rule applies to.
176    pub feature: Box<str>,
177    /// `no-default` (must not be reachable from any default feature) or
178    /// `dev-only` (may be enabled only through dev-dependency edges).
179    pub rule: Box<str>,
180}
181
182/// One `flat-module-family` rule: a prefixed module family under `parent` must
183/// live below `parent/package_root/`.
184#[derive(Debug, Deserialize, Clone)]
185#[serde(deny_unknown_fields)]
186pub struct FlatModuleFamily {
187    /// Directory (repo-relative) whose direct children are checked.
188    pub parent: Box<str>,
189    /// Sub-directory of `parent` where the family must live.
190    pub package_root: Box<str>,
191    /// Module-name prefix identifying family members.
192    pub prefix: Box<str>,
193}
194
195/// Deserialized `.pedant.toml` file with all check settings.
196#[derive(Debug, Deserialize, Default)]
197#[serde(deny_unknown_fields)]
198pub struct ConfigFile {
199    /// Security gate rules configuration.
200    #[serde(default)]
201    pub gate: GateConfig,
202    /// Depth limit for nesting checks (default: 3).
203    #[serde(default = "default_max_depth")]
204    pub max_depth: usize,
205    /// Branch count that triggers `else-chain` (default: 3).
206    #[serde(default = "default_else_chain_threshold")]
207    pub else_chain_threshold: usize,
208    /// Maximum parameter count before `high-param-count` fires (default: 5).
209    #[serde(default = "default_max_params")]
210    pub max_params: usize,
211    /// Body line count before `long-function-body` fires (default: 120).
212    #[serde(default = "default_max_function_body_lines")]
213    pub max_function_body_lines: usize,
214    /// File names treated as module roots by `module-root-definitions`
215    /// (default: `mod.rs`, `lib.rs`).
216    #[serde(
217        default = "default_module_root_files",
218        deserialize_with = "deserialize_arc_str_slice"
219    )]
220    pub module_root_files: Arc<[Arc<str>]>,
221    /// Line count at which `large-source-file` emits a `Warn` (default: 500).
222    #[serde(default = "default_source_file_warn_lines")]
223    pub source_file_warn_lines: usize,
224    /// Line count at which `large-source-file` emits a `Deny` (default: 1000).
225    #[serde(default = "default_source_file_deny_lines")]
226    pub source_file_deny_lines: usize,
227    /// Inherent-method count before `high-method-count` fires (default: 40).
228    #[serde(default = "default_max_methods")]
229    pub max_methods: usize,
230    /// Banned attribute patterns (e.g., `allow(dead_code)`).
231    #[serde(default)]
232    pub forbid_attributes: PatternCheck,
233    /// Banned type patterns (e.g., `Arc<String>`).
234    #[serde(default)]
235    pub forbid_types: PatternCheck,
236    /// Banned method call patterns (e.g., `.unwrap()`).
237    #[serde(default)]
238    pub forbid_calls: PatternCheck,
239    /// Banned macro patterns (e.g., `panic!`).
240    #[serde(default)]
241    pub forbid_macros: PatternCheck,
242    /// Thresholds for the generic-naming check.
243    #[serde(default)]
244    pub check_naming: NamingCheck,
245    /// Flag `if` inside `if`.
246    #[serde(default = "default_true")]
247    pub check_nested_if: bool,
248    /// Flag `if` inside `match` arm.
249    #[serde(default = "default_true")]
250    pub check_if_in_match: bool,
251    /// Flag `match` inside `match`.
252    #[serde(default = "default_true")]
253    pub check_nested_match: bool,
254    /// Flag `match` inside `if` branch.
255    #[serde(default = "default_true")]
256    pub check_match_in_if: bool,
257    /// Flag long `if/else if` chains.
258    #[serde(default = "default_true")]
259    pub check_else_chain: bool,
260    /// Flag any use of the `else` keyword.
261    #[serde(default)]
262    pub forbid_else: bool,
263    /// Flag any `unsafe` block.
264    #[serde(default = "default_true")]
265    pub forbid_unsafe: bool,
266    /// Flag dynamic dispatch in return types.
267    #[serde(default)]
268    pub check_dyn_return: bool,
269    /// Flag dynamic dispatch in function parameters.
270    #[serde(default)]
271    pub check_dyn_param: bool,
272    /// Flag `Vec<Box<dyn T>>` anywhere.
273    #[serde(default)]
274    pub check_vec_box_dyn: bool,
275    /// Flag dynamic dispatch in struct fields.
276    #[serde(default)]
277    pub check_dyn_field: bool,
278    /// Flag `.clone()` inside loop bodies.
279    #[serde(default)]
280    pub check_clone_in_loop: bool,
281    /// Flag `HashMap`/`HashSet` with default SipHash hasher.
282    #[serde(default)]
283    pub check_default_hasher: bool,
284    /// Flag disconnected type groups in a single file.
285    #[serde(default)]
286    pub check_mixed_concerns: bool,
287    /// Flag `#[cfg(test)] mod` blocks embedded in source files.
288    #[serde(default)]
289    pub check_inline_tests: bool,
290    /// Flag `let _ = expr` that discards a Result.
291    #[serde(default)]
292    pub check_let_underscore_result: bool,
293    /// Flag functions with too many parameters.
294    #[serde(default)]
295    pub check_high_param_count: bool,
296    /// Flag function bodies that exceed the line ceiling.
297    #[serde(default)]
298    pub check_long_function_body: bool,
299    /// Flag item definitions in module-root files.
300    #[serde(default)]
301    pub check_module_root_definitions: bool,
302    /// Flag source files that exceed the line ceiling.
303    #[serde(default)]
304    pub check_large_source_file: bool,
305    /// Flag god-object types by inherent-method count.
306    #[serde(default)]
307    pub check_high_method_count: bool,
308    /// Count pure forwarders toward `high-method-count`.
309    #[serde(default)]
310    pub count_forwarders: bool,
311    /// Enforce configured item-visibility policies.
312    #[serde(default = "default_true")]
313    pub check_item_visibility_policy: bool,
314    /// Item-visibility policy rules.
315    #[serde(default)]
316    pub item_visibility_policy: Vec<ItemVisibilityRule>,
317    /// Flag ungated test-only APIs under `src/`.
318    #[serde(default)]
319    pub check_ungated_test_api: bool,
320    /// Flag sibling `<stem>.rs` and `<stem>/` module roots.
321    #[serde(default)]
322    pub check_conflicting_module_root: bool,
323    /// Name globs that mark test-only APIs (default: `*_for_tests`).
324    #[serde(
325        default = "default_test_api_patterns",
326        deserialize_with = "deserialize_arc_str_slice"
327    )]
328    pub test_api_patterns: Arc<[Arc<str>]>,
329    /// Feature that must gate a test-only API (default: `test-support`).
330    #[serde(default = "default_test_support_feature")]
331    pub test_support_feature: Box<str>,
332    /// Enforce configured flat-module-family layout rules.
333    #[serde(default = "default_true")]
334    pub check_flat_module_family: bool,
335    /// Flat-module-family layout rules.
336    #[serde(default)]
337    pub flat_module_families: Vec<FlatModuleFamily>,
338    /// Enforce configured Cargo feature-boundary invariants.
339    #[serde(default = "default_true")]
340    pub check_feature_boundary: bool,
341    /// Cargo feature-boundary invariants.
342    #[serde(default)]
343    pub feature_boundaries: Vec<FeatureBoundaryRule>,
344    /// Per-path configuration overrides keyed by glob pattern.
345    #[serde(default)]
346    pub overrides: BTreeMap<Box<str>, PathOverride>,
347}
348
349/// Per-path overrides (e.g., for `tests/**`). `None` inherits from base config.
350#[derive(Debug, Deserialize, Default)]
351#[serde(deny_unknown_fields)]
352pub struct PathOverride {
353    /// `Some(false)` disables all checks for matched paths.
354    pub enabled: Option<bool>,
355    /// Replace nesting depth limit.
356    pub max_depth: Option<usize>,
357    /// Replace maximum parameter count.
358    pub max_params: Option<usize>,
359    /// Replace function body line ceiling.
360    pub max_function_body_lines: Option<usize>,
361    /// Replace the `large-source-file` warning line ceiling. Pair with a
362    /// TOML comment recording why this path is allowed to be large.
363    pub source_file_warn_lines: Option<usize>,
364    /// Replace the `large-source-file` denial line ceiling.
365    pub source_file_deny_lines: Option<usize>,
366    /// Replace the `high-method-count` method ceiling.
367    pub max_methods: Option<usize>,
368    /// Replace forbidden attribute patterns.
369    pub forbid_attributes: Option<PatternOverride>,
370    /// Replace forbidden type patterns.
371    pub forbid_types: Option<PatternOverride>,
372    /// Replace forbidden call patterns.
373    pub forbid_calls: Option<PatternOverride>,
374    /// Replace forbidden macro patterns.
375    pub forbid_macros: Option<PatternOverride>,
376    /// Replace generic naming thresholds.
377    pub check_naming: Option<NamingOverride>,
378    /// Replace nested-if check state.
379    pub check_nested_if: Option<bool>,
380    /// Replace if-in-match check state.
381    pub check_if_in_match: Option<bool>,
382    /// Replace nested-match check state.
383    pub check_nested_match: Option<bool>,
384    /// Replace match-in-if check state.
385    pub check_match_in_if: Option<bool>,
386    /// Replace else-chain check state.
387    pub check_else_chain: Option<bool>,
388    /// Replace `else` keyword ban state.
389    pub forbid_else: Option<bool>,
390    /// Replace `unsafe` block ban state.
391    pub forbid_unsafe: Option<bool>,
392    /// Replace dyn-return check state.
393    pub check_dyn_return: Option<bool>,
394    /// Replace dyn-param check state.
395    pub check_dyn_param: Option<bool>,
396    /// Replace `Vec<Box<dyn T>>` check state.
397    pub check_vec_box_dyn: Option<bool>,
398    /// Replace dyn-field check state.
399    pub check_dyn_field: Option<bool>,
400    /// Replace clone-in-loop check state.
401    pub check_clone_in_loop: Option<bool>,
402    /// Replace default-hasher check state.
403    pub check_default_hasher: Option<bool>,
404    /// Replace mixed-concerns check state.
405    pub check_mixed_concerns: Option<bool>,
406    /// Replace inline-tests check state.
407    pub check_inline_tests: Option<bool>,
408    /// Replace let-underscore-result check state.
409    pub check_let_underscore_result: Option<bool>,
410    /// Replace high-param-count check state.
411    pub check_high_param_count: Option<bool>,
412    /// Replace long-function-body check state.
413    pub check_long_function_body: Option<bool>,
414    /// Replace module-root-definitions check state.
415    pub check_module_root_definitions: Option<bool>,
416    /// Replace large-source-file check state.
417    pub check_large_source_file: Option<bool>,
418    /// Replace high-method-count check state.
419    pub check_high_method_count: Option<bool>,
420    /// Replace the forwarder-counting policy.
421    pub count_forwarders: Option<bool>,
422    /// Replace item-visibility-policy check state.
423    pub check_item_visibility_policy: Option<bool>,
424    /// Replace ungated-test-api check state.
425    pub check_ungated_test_api: Option<bool>,
426    /// Replace conflicting-module-root check state.
427    pub check_conflicting_module_root: Option<bool>,
428    /// Replace flat-module-family check state.
429    pub check_flat_module_family: Option<bool>,
430    /// Replace feature-boundary check state.
431    pub check_feature_boundary: Option<bool>,
432}
433
434fn default_max_depth() -> usize {
435    3
436}
437
438fn default_else_chain_threshold() -> usize {
439    3
440}
441
442fn default_max_params() -> usize {
443    5
444}
445
446fn default_max_function_body_lines() -> usize {
447    120
448}
449
450static MODULE_ROOT_FILES_ARC: LazyLock<Arc<[Arc<str>]>> =
451    LazyLock::new(|| [Arc::from("mod.rs"), Arc::from("lib.rs")].into());
452
453fn default_module_root_files() -> Arc<[Arc<str>]> {
454    Arc::clone(&MODULE_ROOT_FILES_ARC)
455}
456
457fn default_source_file_warn_lines() -> usize {
458    500
459}
460
461fn default_source_file_deny_lines() -> usize {
462    1000
463}
464
465fn default_max_methods() -> usize {
466    40
467}
468
469static TEST_API_PATTERNS_ARC: LazyLock<Arc<[Arc<str>]>> =
470    LazyLock::new(|| [Arc::from("*_for_tests")].into());
471
472fn default_test_api_patterns() -> Arc<[Arc<str>]> {
473    Arc::clone(&TEST_API_PATTERNS_ARC)
474}
475
476fn default_test_support_feature() -> Box<str> {
477    "test-support".into()
478}
479
480fn default_true() -> bool {
481    true
482}
483
484/// Find the most specific `[overrides]` entry whose glob matches `file_path`.
485///
486/// When multiple patterns match, the longest pattern wins. If two patterns
487/// have the same length, lexicographic (sorted) order breaks the tie.
488/// This makes override precedence deterministic and independent of
489/// declaration order in the config file.
490pub fn check_path_override<'a>(
491    file_path: &str,
492    config: &'a ConfigFile,
493) -> Option<&'a PathOverride> {
494    config
495        .overrides
496        .iter()
497        .filter(|(pattern, _)| matches_glob(pattern, file_path))
498        .max_by_key(|(pattern, _)| pattern.len())
499        .map(|(_, override_config)| override_config)
500}
501
502/// Single source of truth for boolean check fields.
503///
504/// Each entry: `"doc", field_name, default_value;`
505///
506/// Adding a new boolean check requires:
507/// 1. Add one line here
508/// 2. Add the field to `ConfigFile` (bool) and `PathOverride` (Option<bool>)
509///
510/// The macro generates `CheckConfig` fields + Default + from_config_file +
511/// merge_bool_overrides. A compile-time assertion (`assert_bool_fields_in_sync`)
512/// catches missing fields in `ConfigFile` or `PathOverride`.
513///
514/// Non-boolean fields (max_depth, forbid_*, check_naming, etc.) stay hand-written.
515macro_rules! for_each_bool_check {
516    ($callback:ident!) => {
517        $callback! {
518            "Flag `if` inside `if`.", check_nested_if, true;
519            "Flag `if` inside `match` arm.", check_if_in_match, true;
520            "Flag `match` inside `match`.", check_nested_match, true;
521            "Flag `match` inside `if` branch.", check_match_in_if, true;
522            "Flag long `if/else if` chains.", check_else_chain, true;
523            "Flag any use of the `else` keyword.", forbid_else, false;
524            "Flag any `unsafe` block.", forbid_unsafe, true;
525            "Flag dynamic dispatch in return types.", check_dyn_return, false;
526            "Flag dynamic dispatch in function parameters.", check_dyn_param, false;
527            "Flag `Vec<Box<dyn T>>`.", check_vec_box_dyn, false;
528            "Flag dynamic dispatch in struct fields.", check_dyn_field, false;
529            "Flag `.clone()` inside loop bodies.", check_clone_in_loop, false;
530            "Flag `HashMap`/`HashSet` with default hasher.", check_default_hasher, false;
531            "Flag disconnected type groups in a single file.", check_mixed_concerns, false;
532            "Flag `#[cfg(test)] mod` blocks in source files.", check_inline_tests, false;
533            "Flag `let _ = expr` that discards a Result.", check_let_underscore_result, false;
534            "Flag functions with too many parameters.", check_high_param_count, false;
535            "Flag function bodies that exceed the line ceiling.", check_long_function_body, false;
536            "Flag item definitions in module-root files.", check_module_root_definitions, false;
537            "Flag source files that exceed the line ceiling.", check_large_source_file, false;
538            "Flag god-object types by inherent-method count.", check_high_method_count, false;
539            "Count pure forwarders toward `high-method-count`.", count_forwarders, false;
540            "Enforce configured item-visibility policies.", check_item_visibility_policy, true;
541            "Flag ungated test-only APIs under `src/`.", check_ungated_test_api, false;
542            "Flag sibling `<stem>.rs` and `<stem>/` module roots.", check_conflicting_module_root, false;
543            "Enforce configured flat-module-family layout rules.", check_flat_module_family, true;
544            "Enforce configured Cargo feature-boundary invariants.", check_feature_boundary, true;
545        }
546    };
547}
548
549/// Generates `CheckConfig` struct (boolean fields + non-boolean fields),
550/// `Default`, `from_config_file`, and `merge_bool_overrides`.
551macro_rules! impl_check_config {
552    ($($doc:literal, $field:ident, $default:expr;)*) => {
553        /// Configuration controlling which checks are enabled and their thresholds.
554        #[derive(Debug, Clone)]
555        pub struct CheckConfig {
556            /// Maximum allowed nesting depth.
557            pub max_depth: usize,
558            /// Minimum branches to trigger `else-chain`.
559            pub else_chain_threshold: usize,
560            /// Maximum parameter count before `high-param-count` fires.
561            pub max_params: usize,
562            /// Body line count before `long-function-body` fires.
563            pub max_function_body_lines: usize,
564            /// File names treated as module roots by `module-root-definitions`.
565            pub module_root_files: Arc<[Arc<str>]>,
566            /// Line count at which `large-source-file` emits a `Warn`.
567            pub source_file_warn_lines: usize,
568            /// Line count at which `large-source-file` emits a `Deny`.
569            pub source_file_deny_lines: usize,
570            /// Inherent-method count before `high-method-count` fires.
571            pub max_methods: usize,
572            /// Item-visibility policy rules.
573            pub item_visibility_policy: Arc<[ItemVisibilityRule]>,
574            /// Flat-module-family layout rules.
575            pub flat_module_families: Arc<[FlatModuleFamily]>,
576            /// Cargo feature-boundary invariants.
577            pub feature_boundaries: Arc<[FeatureBoundaryRule]>,
578            /// Name globs marking test-only APIs for `ungated-test-api`.
579            pub test_api_patterns: Arc<[Arc<str>]>,
580            /// Feature that must gate a test-only API.
581            pub test_support_feature: Arc<str>,
582            /// Banned attribute patterns.
583            pub forbid_attributes: PatternCheck,
584            /// Banned type patterns.
585            pub forbid_types: PatternCheck,
586            /// Banned method call patterns.
587            pub forbid_calls: PatternCheck,
588            /// Banned macro patterns.
589            pub forbid_macros: PatternCheck,
590            /// Generic naming check configuration.
591            pub check_naming: NamingCheck,
592            $(
593                #[doc = $doc]
594                pub $field: bool,
595            )*
596        }
597
598        impl Default for CheckConfig {
599            fn default() -> Self {
600                Self {
601                    max_depth: default_max_depth(),
602                    else_chain_threshold: default_else_chain_threshold(),
603                    max_params: default_max_params(),
604                    max_function_body_lines: default_max_function_body_lines(),
605                    module_root_files: default_module_root_files(),
606                    source_file_warn_lines: default_source_file_warn_lines(),
607                    source_file_deny_lines: default_source_file_deny_lines(),
608                    max_methods: default_max_methods(),
609                    item_visibility_policy: Arc::from([]),
610                    flat_module_families: Arc::from([]),
611                    feature_boundaries: Arc::from([]),
612                    test_api_patterns: default_test_api_patterns(),
613                    test_support_feature: Arc::from(default_test_support_feature()),
614                    forbid_attributes: PatternCheck::default(),
615                    forbid_types: PatternCheck::default(),
616                    forbid_calls: PatternCheck::default(),
617                    forbid_macros: PatternCheck::default(),
618                    check_naming: NamingCheck::default(),
619                    $( $field: $default, )*
620                }
621            }
622        }
623
624        impl CheckConfig {
625            /// Build from a [`ConfigFile`], copying all fields.
626            pub fn from_config_file(fc: &ConfigFile) -> Self {
627                Self {
628                    max_depth: fc.max_depth,
629                    else_chain_threshold: fc.else_chain_threshold,
630                    max_params: fc.max_params,
631                    max_function_body_lines: fc.max_function_body_lines,
632                    module_root_files: fc.module_root_files.clone(),
633                    source_file_warn_lines: fc.source_file_warn_lines,
634                    source_file_deny_lines: fc.source_file_deny_lines,
635                    max_methods: fc.max_methods,
636                    item_visibility_policy: fc.item_visibility_policy.iter().cloned().collect(),
637                    flat_module_families: fc.flat_module_families.iter().cloned().collect(),
638                    feature_boundaries: fc.feature_boundaries.iter().cloned().collect(),
639                    test_api_patterns: fc.test_api_patterns.clone(),
640                    test_support_feature: Arc::from(&*fc.test_support_feature),
641                    forbid_attributes: fc.forbid_attributes.clone(),
642                    forbid_types: fc.forbid_types.clone(),
643                    forbid_calls: fc.forbid_calls.clone(),
644                    forbid_macros: fc.forbid_macros.clone(),
645                    check_naming: fc.check_naming.clone(),
646                    $( $field: fc.$field, )*
647                }
648            }
649
650            /// Apply `Option<bool>` overrides from a [`PathOverride`].
651            pub fn merge_bool_overrides(&mut self, ovr: &PathOverride) {
652                $(
653                    if let Some(v) = ovr.$field {
654                        self.$field = v;
655                    }
656                )*
657            }
658        }
659    };
660}
661
662for_each_bool_check!(impl_check_config!);
663
664/// Compile-time assertion: every boolean check field in `for_each_bool_check!`
665/// must exist in `ConfigFile` (as `bool`) and `PathOverride` (as `Option<bool>`).
666/// Adding a field to the macro without updating these structs is a compile error.
667macro_rules! assert_bool_fields_in_sync {
668    ($($doc:literal, $field:ident, $default:expr;)*) => {
669        const _: () = {
670            // Access each field on both structs. If a field is missing
671            // from either, this block fails to compile.
672            const fn _check(cf: &ConfigFile, po: &PathOverride) {
673                $( let _ = (cf.$field, po.$field); )*
674            }
675        };
676    };
677}
678
679for_each_bool_check!(assert_bool_fields_in_sync!);
680
681impl CheckConfig {
682    /// Returns the effective config for a file path.
683    ///
684    /// Borrows `self` when no overrides match (zero clones).
685    /// Clones and mutates only when a path override applies.
686    /// Returns `None` when the override disables analysis for this path.
687    pub fn resolve_for_path<'a>(
688        &'a self,
689        file_path: &str,
690        file_config: Option<&ConfigFile>,
691    ) -> Option<Cow<'a, Self>> {
692        let Some(fc) = file_config else {
693            return Some(Cow::Borrowed(self));
694        };
695
696        let Some(override_cfg) = check_path_override(file_path, fc) else {
697            return Some(Cow::Borrowed(self));
698        };
699
700        if override_cfg.enabled == Some(false) {
701            return None;
702        }
703
704        let mut config = self.clone();
705        if let Some(max_depth) = override_cfg.max_depth {
706            config.max_depth = max_depth;
707        }
708        if let Some(max_params) = override_cfg.max_params {
709            config.max_params = max_params;
710        }
711        if let Some(max_body) = override_cfg.max_function_body_lines {
712            config.max_function_body_lines = max_body;
713        }
714        if let Some(warn) = override_cfg.source_file_warn_lines {
715            config.source_file_warn_lines = warn;
716        }
717        if let Some(deny) = override_cfg.source_file_deny_lines {
718            config.source_file_deny_lines = deny;
719        }
720        if let Some(max_methods) = override_cfg.max_methods {
721            config.max_methods = max_methods;
722        }
723
724        config.merge_bool_overrides(override_cfg);
725
726        macro_rules! apply {
727            ($field:ident) => {
728                if let Some(ref ovr) = override_cfg.$field {
729                    config.$field.apply_override(ovr);
730                }
731            };
732        }
733        apply!(forbid_attributes);
734        apply!(forbid_types);
735        apply!(forbid_calls);
736        apply!(forbid_macros);
737        apply!(check_naming);
738
739        Some(Cow::Owned(config))
740    }
741}
742
743/// Failure modes when loading `.pedant.toml`.
744#[derive(Debug, thiserror::Error)]
745pub enum ConfigError {
746    /// Disk I/O failure reading the config file.
747    #[error("failed to read config file: {0}")]
748    Read(#[from] std::io::Error),
749    /// TOML syntax or schema error in the config file.
750    #[error("failed to parse config file: {0}")]
751    Parse(#[from] toml::de::Error),
752}
753
754/// Read and deserialize a `.pedant.toml` from the given path.
755pub fn load_config_file(path: &Path) -> Result<ConfigFile, ConfigError> {
756    let content = fs::read_to_string(path)?;
757    Ok(toml::from_str(&content)?)
758}
759
760/// Search `.pedant.toml` in the project root, then `$XDG_CONFIG_HOME/pedant/config.toml`.
761pub fn find_config_file() -> Result<Option<std::path::PathBuf>, ConfigError> {
762    let project_config = find_project_config_file()?;
763    Ok(project_config.or_else(find_global_config_file))
764}
765
766fn find_project_config_file() -> Result<Option<std::path::PathBuf>, ConfigError> {
767    let config_path = std::env::current_dir()?.join(".pedant.toml");
768    Ok(config_path.exists().then_some(config_path))
769}
770
771fn find_global_config_file() -> Option<std::path::PathBuf> {
772    let config_dir = std::env::var_os("XDG_CONFIG_HOME")
773        .map(std::path::PathBuf::from)
774        .or_else(|| {
775            std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
776        })?;
777    let config_path = config_dir.join("pedant").join("config.toml");
778    config_path.exists().then_some(config_path)
779}
780
781/// Per-rule override from the `[gate]` TOML section.
782#[derive(Debug)]
783pub enum GateRuleOverride {
784    /// Suppresses the rule entirely.
785    Disabled,
786    /// Changes the rule's effective severity.
787    Severity(crate::gate::GateSeverity),
788}
789
790/// Deserialized `[gate]` section of `.pedant.toml`.
791///
792/// Keys are either `enabled` (master switch) or rule names mapped to
793/// `false` (disabled) or a severity string (`"deny"`, `"warn"`, `"info"`).
794#[derive(Debug)]
795pub struct GateConfig {
796    /// Master switch; `false` disables all gate rules.
797    pub enabled: bool,
798    /// Per-rule overrides keyed by rule name.
799    pub overrides: BTreeMap<Box<str>, GateRuleOverride>,
800}
801
802impl Default for GateConfig {
803    fn default() -> Self {
804        Self {
805            enabled: true,
806            overrides: BTreeMap::new(),
807        }
808    }
809}
810
811impl<'de> Deserialize<'de> for GateConfig {
812    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
813    where
814        D: serde::Deserializer<'de>,
815    {
816        use serde::de::Error;
817
818        #[derive(Deserialize)]
819        #[serde(untagged)]
820        enum GateTomlValue {
821            Bool(bool),
822            String(String),
823        }
824
825        let raw: BTreeMap<Box<str>, GateTomlValue> = BTreeMap::deserialize(deserializer)?;
826        let mut enabled = true;
827        let mut overrides = BTreeMap::new();
828
829        for (key, value) in raw {
830            match (&*key, value) {
831                ("enabled", GateTomlValue::Bool(b)) => enabled = b,
832                ("enabled", GateTomlValue::String(_)) => {
833                    return Err(D::Error::custom("'enabled' must be a boolean"));
834                }
835                (_, _) if !is_known_gate_rule(&key) => {
836                    return Err(D::Error::custom(format!("unknown gate rule '{key}'")));
837                }
838                (_, GateTomlValue::Bool(false)) => {
839                    overrides.insert(key, GateRuleOverride::Disabled);
840                }
841                (_, GateTomlValue::Bool(true)) => {} // true = use default, no override
842                (_, GateTomlValue::String(s)) => {
843                    let severity = parse_gate_severity(&s).ok_or_else(|| {
844                        D::Error::custom(format!(
845                            "invalid gate severity '{s}': expected \"deny\", \"warn\", or \"info\""
846                        ))
847                    })?;
848                    overrides.insert(key, GateRuleOverride::Severity(severity));
849                }
850            }
851        }
852
853        Ok(GateConfig { enabled, overrides })
854    }
855}
856
857fn is_known_gate_rule(rule_name: &str) -> bool {
858    crate::gate::all_gate_rules()
859        .iter()
860        .any(|rule| rule.name == rule_name)
861}
862
863fn parse_gate_severity(s: &str) -> Option<crate::gate::GateSeverity> {
864    use crate::gate::GateSeverity;
865    match s {
866        "deny" => Some(GateSeverity::Deny),
867        "warn" => Some(GateSeverity::Warn),
868        "info" => Some(GateSeverity::Info),
869        _ => None,
870    }
871}