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    /// Flag types whose inherent impls span more than one file.
345    #[serde(default)]
346    pub check_scattered_inherent_impl: bool,
347    /// Per-path configuration overrides keyed by glob pattern.
348    #[serde(default)]
349    pub overrides: BTreeMap<Box<str>, PathOverride>,
350}
351
352/// Per-path overrides (e.g., for `tests/**`). `None` inherits from base config.
353#[derive(Debug, Deserialize, Default)]
354#[serde(deny_unknown_fields)]
355pub struct PathOverride {
356    /// `Some(false)` disables all checks for matched paths.
357    pub enabled: Option<bool>,
358    /// Replace nesting depth limit.
359    pub max_depth: Option<usize>,
360    /// Replace maximum parameter count.
361    pub max_params: Option<usize>,
362    /// Replace function body line ceiling.
363    pub max_function_body_lines: Option<usize>,
364    /// Replace the `large-source-file` warning line ceiling. Pair with a
365    /// TOML comment recording why this path is allowed to be large.
366    pub source_file_warn_lines: Option<usize>,
367    /// Replace the `large-source-file` denial line ceiling.
368    pub source_file_deny_lines: Option<usize>,
369    /// Replace the `high-method-count` method ceiling.
370    pub max_methods: Option<usize>,
371    /// Replace forbidden attribute patterns.
372    pub forbid_attributes: Option<PatternOverride>,
373    /// Replace forbidden type patterns.
374    pub forbid_types: Option<PatternOverride>,
375    /// Replace forbidden call patterns.
376    pub forbid_calls: Option<PatternOverride>,
377    /// Replace forbidden macro patterns.
378    pub forbid_macros: Option<PatternOverride>,
379    /// Replace generic naming thresholds.
380    pub check_naming: Option<NamingOverride>,
381    /// Replace nested-if check state.
382    pub check_nested_if: Option<bool>,
383    /// Replace if-in-match check state.
384    pub check_if_in_match: Option<bool>,
385    /// Replace nested-match check state.
386    pub check_nested_match: Option<bool>,
387    /// Replace match-in-if check state.
388    pub check_match_in_if: Option<bool>,
389    /// Replace else-chain check state.
390    pub check_else_chain: Option<bool>,
391    /// Replace `else` keyword ban state.
392    pub forbid_else: Option<bool>,
393    /// Replace `unsafe` block ban state.
394    pub forbid_unsafe: Option<bool>,
395    /// Replace dyn-return check state.
396    pub check_dyn_return: Option<bool>,
397    /// Replace dyn-param check state.
398    pub check_dyn_param: Option<bool>,
399    /// Replace `Vec<Box<dyn T>>` check state.
400    pub check_vec_box_dyn: Option<bool>,
401    /// Replace dyn-field check state.
402    pub check_dyn_field: Option<bool>,
403    /// Replace clone-in-loop check state.
404    pub check_clone_in_loop: Option<bool>,
405    /// Replace default-hasher check state.
406    pub check_default_hasher: Option<bool>,
407    /// Replace mixed-concerns check state.
408    pub check_mixed_concerns: Option<bool>,
409    /// Replace inline-tests check state.
410    pub check_inline_tests: Option<bool>,
411    /// Replace let-underscore-result check state.
412    pub check_let_underscore_result: Option<bool>,
413    /// Replace high-param-count check state.
414    pub check_high_param_count: Option<bool>,
415    /// Replace long-function-body check state.
416    pub check_long_function_body: Option<bool>,
417    /// Replace module-root-definitions check state.
418    pub check_module_root_definitions: Option<bool>,
419    /// Replace large-source-file check state.
420    pub check_large_source_file: Option<bool>,
421    /// Replace high-method-count check state.
422    pub check_high_method_count: Option<bool>,
423    /// Replace the forwarder-counting policy.
424    pub count_forwarders: Option<bool>,
425    /// Replace item-visibility-policy check state.
426    pub check_item_visibility_policy: Option<bool>,
427    /// Replace ungated-test-api check state.
428    pub check_ungated_test_api: Option<bool>,
429    /// Replace conflicting-module-root check state.
430    pub check_conflicting_module_root: Option<bool>,
431    /// Replace flat-module-family check state.
432    pub check_flat_module_family: Option<bool>,
433    /// Replace feature-boundary check state.
434    pub check_feature_boundary: Option<bool>,
435    /// Replace scattered-inherent-impl check state.
436    pub check_scattered_inherent_impl: Option<bool>,
437}
438
439fn default_max_depth() -> usize {
440    3
441}
442
443fn default_else_chain_threshold() -> usize {
444    3
445}
446
447fn default_max_params() -> usize {
448    5
449}
450
451fn default_max_function_body_lines() -> usize {
452    120
453}
454
455static MODULE_ROOT_FILES_ARC: LazyLock<Arc<[Arc<str>]>> =
456    LazyLock::new(|| [Arc::from("mod.rs"), Arc::from("lib.rs")].into());
457
458fn default_module_root_files() -> Arc<[Arc<str>]> {
459    Arc::clone(&MODULE_ROOT_FILES_ARC)
460}
461
462fn default_source_file_warn_lines() -> usize {
463    500
464}
465
466fn default_source_file_deny_lines() -> usize {
467    1000
468}
469
470fn default_max_methods() -> usize {
471    40
472}
473
474static TEST_API_PATTERNS_ARC: LazyLock<Arc<[Arc<str>]>> =
475    LazyLock::new(|| [Arc::from("*_for_tests")].into());
476
477fn default_test_api_patterns() -> Arc<[Arc<str>]> {
478    Arc::clone(&TEST_API_PATTERNS_ARC)
479}
480
481fn default_test_support_feature() -> Box<str> {
482    "test-support".into()
483}
484
485fn default_true() -> bool {
486    true
487}
488
489/// Find the most specific `[overrides]` entry whose glob matches `file_path`.
490///
491/// When multiple patterns match, the longest pattern wins. If two patterns
492/// have the same length, lexicographic (sorted) order breaks the tie.
493/// This makes override precedence deterministic and independent of
494/// declaration order in the config file.
495pub fn check_path_override<'a>(
496    file_path: &str,
497    config: &'a ConfigFile,
498) -> Option<&'a PathOverride> {
499    config
500        .overrides
501        .iter()
502        .filter(|(pattern, _)| matches_glob(pattern, file_path))
503        .max_by_key(|(pattern, _)| pattern.len())
504        .map(|(_, override_config)| override_config)
505}
506
507/// Single source of truth for boolean check fields.
508///
509/// Each entry: `"doc", field_name, default_value;`
510///
511/// Adding a new boolean check requires:
512/// 1. Add one line here
513/// 2. Add the field to `ConfigFile` (bool) and `PathOverride` (Option<bool>)
514///
515/// The macro generates `CheckConfig` fields + Default + from_config_file +
516/// merge_bool_overrides. A compile-time assertion (`assert_bool_fields_in_sync`)
517/// catches missing fields in `ConfigFile` or `PathOverride`.
518///
519/// Non-boolean fields (max_depth, forbid_*, check_naming, etc.) stay hand-written.
520macro_rules! for_each_bool_check {
521    ($callback:ident!) => {
522        $callback! {
523            "Flag `if` inside `if`.", check_nested_if, true;
524            "Flag `if` inside `match` arm.", check_if_in_match, true;
525            "Flag `match` inside `match`.", check_nested_match, true;
526            "Flag `match` inside `if` branch.", check_match_in_if, true;
527            "Flag long `if/else if` chains.", check_else_chain, true;
528            "Flag any use of the `else` keyword.", forbid_else, false;
529            "Flag any `unsafe` block.", forbid_unsafe, true;
530            "Flag dynamic dispatch in return types.", check_dyn_return, false;
531            "Flag dynamic dispatch in function parameters.", check_dyn_param, false;
532            "Flag `Vec<Box<dyn T>>`.", check_vec_box_dyn, false;
533            "Flag dynamic dispatch in struct fields.", check_dyn_field, false;
534            "Flag `.clone()` inside loop bodies.", check_clone_in_loop, false;
535            "Flag `HashMap`/`HashSet` with default hasher.", check_default_hasher, false;
536            "Flag disconnected type groups in a single file.", check_mixed_concerns, false;
537            "Flag `#[cfg(test)] mod` blocks in source files.", check_inline_tests, false;
538            "Flag `let _ = expr` that discards a Result.", check_let_underscore_result, false;
539            "Flag functions with too many parameters.", check_high_param_count, false;
540            "Flag function bodies that exceed the line ceiling.", check_long_function_body, false;
541            "Flag item definitions in module-root files.", check_module_root_definitions, false;
542            "Flag source files that exceed the line ceiling.", check_large_source_file, false;
543            "Flag god-object types by inherent-method count.", check_high_method_count, false;
544            "Count pure forwarders toward `high-method-count`.", count_forwarders, false;
545            "Enforce configured item-visibility policies.", check_item_visibility_policy, true;
546            "Flag ungated test-only APIs under `src/`.", check_ungated_test_api, false;
547            "Flag sibling `<stem>.rs` and `<stem>/` module roots.", check_conflicting_module_root, false;
548            "Enforce configured flat-module-family layout rules.", check_flat_module_family, true;
549            "Enforce configured Cargo feature-boundary invariants.", check_feature_boundary, true;
550            "Flag types whose inherent impls span more than one file.", check_scattered_inherent_impl, false;
551        }
552    };
553}
554
555/// Generates `CheckConfig` struct (boolean fields + non-boolean fields),
556/// `Default`, `from_config_file`, and `merge_bool_overrides`.
557macro_rules! impl_check_config {
558    ($($doc:literal, $field:ident, $default:expr;)*) => {
559        /// Configuration controlling which checks are enabled and their thresholds.
560        #[derive(Debug, Clone)]
561        pub struct CheckConfig {
562            /// Maximum allowed nesting depth.
563            pub max_depth: usize,
564            /// Minimum branches to trigger `else-chain`.
565            pub else_chain_threshold: usize,
566            /// Maximum parameter count before `high-param-count` fires.
567            pub max_params: usize,
568            /// Body line count before `long-function-body` fires.
569            pub max_function_body_lines: usize,
570            /// File names treated as module roots by `module-root-definitions`.
571            pub module_root_files: Arc<[Arc<str>]>,
572            /// Line count at which `large-source-file` emits a `Warn`.
573            pub source_file_warn_lines: usize,
574            /// Line count at which `large-source-file` emits a `Deny`.
575            pub source_file_deny_lines: usize,
576            /// Inherent-method count before `high-method-count` fires.
577            pub max_methods: usize,
578            /// Item-visibility policy rules.
579            pub item_visibility_policy: Arc<[ItemVisibilityRule]>,
580            /// Flat-module-family layout rules.
581            pub flat_module_families: Arc<[FlatModuleFamily]>,
582            /// Cargo feature-boundary invariants.
583            pub feature_boundaries: Arc<[FeatureBoundaryRule]>,
584            /// Name globs marking test-only APIs for `ungated-test-api`.
585            pub test_api_patterns: Arc<[Arc<str>]>,
586            /// Feature that must gate a test-only API.
587            pub test_support_feature: Arc<str>,
588            /// Banned attribute patterns.
589            pub forbid_attributes: PatternCheck,
590            /// Banned type patterns.
591            pub forbid_types: PatternCheck,
592            /// Banned method call patterns.
593            pub forbid_calls: PatternCheck,
594            /// Banned macro patterns.
595            pub forbid_macros: PatternCheck,
596            /// Generic naming check configuration.
597            pub check_naming: NamingCheck,
598            $(
599                #[doc = $doc]
600                pub $field: bool,
601            )*
602        }
603
604        impl Default for CheckConfig {
605            fn default() -> Self {
606                Self {
607                    max_depth: default_max_depth(),
608                    else_chain_threshold: default_else_chain_threshold(),
609                    max_params: default_max_params(),
610                    max_function_body_lines: default_max_function_body_lines(),
611                    module_root_files: default_module_root_files(),
612                    source_file_warn_lines: default_source_file_warn_lines(),
613                    source_file_deny_lines: default_source_file_deny_lines(),
614                    max_methods: default_max_methods(),
615                    item_visibility_policy: Arc::from([]),
616                    flat_module_families: Arc::from([]),
617                    feature_boundaries: Arc::from([]),
618                    test_api_patterns: default_test_api_patterns(),
619                    test_support_feature: Arc::from(default_test_support_feature()),
620                    forbid_attributes: PatternCheck::default(),
621                    forbid_types: PatternCheck::default(),
622                    forbid_calls: PatternCheck::default(),
623                    forbid_macros: PatternCheck::default(),
624                    check_naming: NamingCheck::default(),
625                    $( $field: $default, )*
626                }
627            }
628        }
629
630        impl CheckConfig {
631            /// Build from a [`ConfigFile`], copying all fields.
632            pub fn from_config_file(fc: &ConfigFile) -> Self {
633                Self {
634                    max_depth: fc.max_depth,
635                    else_chain_threshold: fc.else_chain_threshold,
636                    max_params: fc.max_params,
637                    max_function_body_lines: fc.max_function_body_lines,
638                    module_root_files: fc.module_root_files.clone(),
639                    source_file_warn_lines: fc.source_file_warn_lines,
640                    source_file_deny_lines: fc.source_file_deny_lines,
641                    max_methods: fc.max_methods,
642                    item_visibility_policy: fc.item_visibility_policy.iter().cloned().collect(),
643                    flat_module_families: fc.flat_module_families.iter().cloned().collect(),
644                    feature_boundaries: fc.feature_boundaries.iter().cloned().collect(),
645                    test_api_patterns: fc.test_api_patterns.clone(),
646                    test_support_feature: Arc::from(&*fc.test_support_feature),
647                    forbid_attributes: fc.forbid_attributes.clone(),
648                    forbid_types: fc.forbid_types.clone(),
649                    forbid_calls: fc.forbid_calls.clone(),
650                    forbid_macros: fc.forbid_macros.clone(),
651                    check_naming: fc.check_naming.clone(),
652                    $( $field: fc.$field, )*
653                }
654            }
655
656            /// Apply `Option<bool>` overrides from a [`PathOverride`].
657            pub fn merge_bool_overrides(&mut self, ovr: &PathOverride) {
658                $(
659                    if let Some(v) = ovr.$field {
660                        self.$field = v;
661                    }
662                )*
663            }
664        }
665    };
666}
667
668for_each_bool_check!(impl_check_config!);
669
670/// Compile-time assertion: every boolean check field in `for_each_bool_check!`
671/// must exist in `ConfigFile` (as `bool`) and `PathOverride` (as `Option<bool>`).
672/// Adding a field to the macro without updating these structs is a compile error.
673macro_rules! assert_bool_fields_in_sync {
674    ($($doc:literal, $field:ident, $default:expr;)*) => {
675        const _: () = {
676            // Access each field on both structs. If a field is missing
677            // from either, this block fails to compile.
678            const fn _check(cf: &ConfigFile, po: &PathOverride) {
679                $( let _ = (cf.$field, po.$field); )*
680            }
681        };
682    };
683}
684
685for_each_bool_check!(assert_bool_fields_in_sync!);
686
687impl CheckConfig {
688    /// Returns the effective config for a file path.
689    ///
690    /// Borrows `self` when no overrides match (zero clones).
691    /// Clones and mutates only when a path override applies.
692    /// Returns `None` when the override disables analysis for this path.
693    pub fn resolve_for_path<'a>(
694        &'a self,
695        file_path: &str,
696        file_config: Option<&ConfigFile>,
697    ) -> Option<Cow<'a, Self>> {
698        let Some(fc) = file_config else {
699            return Some(Cow::Borrowed(self));
700        };
701
702        let Some(override_cfg) = check_path_override(file_path, fc) else {
703            return Some(Cow::Borrowed(self));
704        };
705
706        if override_cfg.enabled == Some(false) {
707            return None;
708        }
709
710        let mut config = self.clone();
711        if let Some(max_depth) = override_cfg.max_depth {
712            config.max_depth = max_depth;
713        }
714        if let Some(max_params) = override_cfg.max_params {
715            config.max_params = max_params;
716        }
717        if let Some(max_body) = override_cfg.max_function_body_lines {
718            config.max_function_body_lines = max_body;
719        }
720        if let Some(warn) = override_cfg.source_file_warn_lines {
721            config.source_file_warn_lines = warn;
722        }
723        if let Some(deny) = override_cfg.source_file_deny_lines {
724            config.source_file_deny_lines = deny;
725        }
726        if let Some(max_methods) = override_cfg.max_methods {
727            config.max_methods = max_methods;
728        }
729
730        config.merge_bool_overrides(override_cfg);
731
732        macro_rules! apply {
733            ($field:ident) => {
734                if let Some(ref ovr) = override_cfg.$field {
735                    config.$field.apply_override(ovr);
736                }
737            };
738        }
739        apply!(forbid_attributes);
740        apply!(forbid_types);
741        apply!(forbid_calls);
742        apply!(forbid_macros);
743        apply!(check_naming);
744
745        Some(Cow::Owned(config))
746    }
747}
748
749/// Failure modes when loading `.pedant.toml`.
750#[derive(Debug, thiserror::Error)]
751pub enum ConfigError {
752    /// Disk I/O failure reading the config file.
753    #[error("failed to read config file: {0}")]
754    Read(#[from] std::io::Error),
755    /// TOML syntax or schema error in the config file.
756    #[error("failed to parse config file: {0}")]
757    Parse(#[from] toml::de::Error),
758}
759
760/// Read and deserialize a `.pedant.toml` from the given path.
761pub fn load_config_file(path: &Path) -> Result<ConfigFile, ConfigError> {
762    let content = fs::read_to_string(path)?;
763    Ok(toml::from_str(&content)?)
764}
765
766/// Search `.pedant.toml` in the project root, then `$XDG_CONFIG_HOME/pedant/config.toml`.
767pub fn find_config_file() -> Result<Option<std::path::PathBuf>, ConfigError> {
768    let project_config = find_project_config_file()?;
769    Ok(project_config.or_else(find_global_config_file))
770}
771
772fn find_project_config_file() -> Result<Option<std::path::PathBuf>, ConfigError> {
773    let config_path = std::env::current_dir()?.join(".pedant.toml");
774    Ok(config_path.exists().then_some(config_path))
775}
776
777fn find_global_config_file() -> Option<std::path::PathBuf> {
778    let config_dir = std::env::var_os("XDG_CONFIG_HOME")
779        .map(std::path::PathBuf::from)
780        .or_else(|| {
781            std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
782        })?;
783    let config_path = config_dir.join("pedant").join("config.toml");
784    config_path.exists().then_some(config_path)
785}
786
787/// Per-rule override from the `[gate]` TOML section.
788#[derive(Debug)]
789pub enum GateRuleOverride {
790    /// Suppresses the rule entirely.
791    Disabled,
792    /// Changes the rule's effective severity.
793    Severity(crate::gate::GateSeverity),
794}
795
796/// Deserialized `[gate]` section of `.pedant.toml`.
797///
798/// Keys are either `enabled` (master switch) or rule names mapped to
799/// `false` (disabled) or a severity string (`"deny"`, `"warn"`, `"info"`).
800#[derive(Debug)]
801pub struct GateConfig {
802    /// Master switch; `false` disables all gate rules.
803    pub enabled: bool,
804    /// Per-rule overrides keyed by rule name.
805    pub overrides: BTreeMap<Box<str>, GateRuleOverride>,
806}
807
808impl Default for GateConfig {
809    fn default() -> Self {
810        Self {
811            enabled: true,
812            overrides: BTreeMap::new(),
813        }
814    }
815}
816
817impl<'de> Deserialize<'de> for GateConfig {
818    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
819    where
820        D: serde::Deserializer<'de>,
821    {
822        use serde::de::Error;
823
824        #[derive(Deserialize)]
825        #[serde(untagged)]
826        enum GateTomlValue {
827            Bool(bool),
828            String(String),
829        }
830
831        let raw: BTreeMap<Box<str>, GateTomlValue> = BTreeMap::deserialize(deserializer)?;
832        let mut enabled = true;
833        let mut overrides = BTreeMap::new();
834
835        for (key, value) in raw {
836            match (&*key, value) {
837                ("enabled", GateTomlValue::Bool(b)) => enabled = b,
838                ("enabled", GateTomlValue::String(_)) => {
839                    return Err(D::Error::custom("'enabled' must be a boolean"));
840                }
841                (_, _) if !is_known_gate_rule(&key) => {
842                    return Err(D::Error::custom(format!("unknown gate rule '{key}'")));
843                }
844                (_, GateTomlValue::Bool(false)) => {
845                    overrides.insert(key, GateRuleOverride::Disabled);
846                }
847                (_, GateTomlValue::Bool(true)) => {} // true = use default, no override
848                (_, GateTomlValue::String(s)) => {
849                    let severity = parse_gate_severity(&s).ok_or_else(|| {
850                        D::Error::custom(format!(
851                            "invalid gate severity '{s}': expected \"deny\", \"warn\", or \"info\""
852                        ))
853                    })?;
854                    overrides.insert(key, GateRuleOverride::Severity(severity));
855                }
856            }
857        }
858
859        Ok(GateConfig { enabled, overrides })
860    }
861}
862
863fn is_known_gate_rule(rule_name: &str) -> bool {
864    crate::gate::all_gate_rules()
865        .iter()
866        .any(|rule| rule.name == rule_name)
867}
868
869fn parse_gate_severity(s: &str) -> Option<crate::gate::GateSeverity> {
870    use crate::gate::GateSeverity;
871    match s {
872        "deny" => Some(GateSeverity::Deny),
873        "warn" => Some(GateSeverity::Warn),
874        "info" => Some(GateSeverity::Info),
875        _ => None,
876    }
877}