Skip to main content

pedant_core/check_config/
module_boundary.rs

1//! The `[gate.module-boundary]` section: resource ceilings, local policy
2//! thresholds, per-rule settings, and their concrete rejections.
3
4use std::collections::BTreeMap;
5
6use serde::Deserialize;
7use serde::de::IgnoredAny;
8use thiserror::Error;
9
10use super::gate::{GateRuleOverride, InvalidGateSeverity, RuleSetting, rule_override};
11
12/// One raw `[gate.module-boundary]` value, before it is validated against the
13/// field it was written under.
14#[derive(Debug, Deserialize)]
15#[serde(untagged)]
16pub(super) enum ModuleBoundaryValue {
17    /// An enablement switch or a rule toggle.
18    Bool(bool),
19    /// A ceiling, count, or percentage.
20    Integer(i64),
21    /// A rule severity name.
22    String(String),
23    /// Anything else, so every wrong type reaches a concrete rejection.
24    Other(IgnoredAny),
25}
26
27/// The raw nested table, buffered by the `[gate]` deserializer.
28pub(super) type ModuleBoundaryTable = BTreeMap<Box<str>, ModuleBoundaryValue>;
29
30/// Why one `[gate.module-boundary]` entry was refused.
31#[derive(Debug, Error)]
32pub(super) enum ModuleBoundaryConfigError {
33    /// The key names neither a known field nor a known rule.
34    #[error("unknown module-boundary field '{0}'")]
35    UnknownField(Box<str>),
36    /// A boolean field received another type.
37    #[error("module-boundary field '{field}' must be a boolean")]
38    NotBoolean {
39        /// The field that was written.
40        field: Box<str>,
41    },
42    /// A numeric field received another type.
43    #[error("module-boundary field '{field}' must be an integer")]
44    NotInteger {
45        /// The field that was written.
46        field: Box<str>,
47    },
48    /// A count or ceiling fell outside its `u32` width.
49    #[error("module-boundary field '{field}' must fit in u32, got {value}")]
50    CountOutOfRange {
51        /// The field that was written.
52        field: Box<str>,
53        /// The rejected value.
54        value: i64,
55    },
56    /// A percentage fell outside `0..=100`.
57    #[error("module-boundary field '{field}' must be 0..=100, got {value}")]
58    PercentOutOfRange {
59        /// The field that was written.
60        field: Box<str>,
61        /// The rejected value.
62        value: i64,
63    },
64    /// A rule key received neither a toggle nor a severity name.
65    #[error("module-boundary rule '{rule}' must be a boolean or a severity string")]
66    RuleNotToggleOrSeverity {
67        /// The rule that was written.
68        rule: Box<str>,
69    },
70    /// A rule key received an unknown severity name.
71    #[error(transparent)]
72    Severity(#[from] InvalidGateSeverity),
73}
74
75/// Deserialized `[gate.module-boundary]` section of `.pedant.toml`.
76///
77/// Holds the resource ceilings the graph and analysis owners apply and the
78/// local thresholds the four module-boundary rules compare against. Every
79/// field is private and read through a by-value accessor, so a threshold
80/// cannot be mutated after the configuration is loaded.
81#[derive(Debug)]
82pub struct ModuleBoundaryConfig {
83    enabled: bool,
84    rules: BTreeMap<Box<str>, GateRuleOverride>,
85    max_nodes: u32,
86    max_references: u32,
87    max_edges: u32,
88    max_selected_edges: u32,
89    max_scc_members: u32,
90    misplaced_symbol_min_foreign_edges: u32,
91    misplaced_symbol_min_affinity_percent: u8,
92    low_module_cohesion_min_outgoing_edges: u32,
93    low_module_cohesion_min_percent: u8,
94}
95
96impl Default for ModuleBoundaryConfig {
97    fn default() -> Self {
98        Self {
99            enabled: true,
100            rules: BTreeMap::new(),
101            max_nodes: 250_000,
102            max_references: 1_000_000,
103            max_edges: 1_000_000,
104            max_selected_edges: 1_000_000,
105            max_scc_members: 8,
106            misplaced_symbol_min_foreign_edges: 3,
107            misplaced_symbol_min_affinity_percent: 60,
108            low_module_cohesion_min_outgoing_edges: 4,
109            low_module_cohesion_min_percent: 50,
110        }
111    }
112}
113
114impl ModuleBoundaryConfig {
115    /// Whether the four module-boundary rules are evaluated at all.
116    pub fn enabled(&self) -> bool {
117        self.enabled
118    }
119
120    /// Node ceiling applied by graph construction and analysis.
121    pub fn max_nodes(&self) -> u32 {
122        self.max_nodes
123    }
124
125    /// Reference ceiling applied by graph construction.
126    pub fn max_references(&self) -> u32 {
127        self.max_references
128    }
129
130    /// Raw-edge ceiling applied by graph construction.
131    pub fn max_edges(&self) -> u32 {
132        self.max_edges
133    }
134
135    /// Selected-edge ceiling applied by graph analysis.
136    pub fn max_selected_edges(&self) -> u32 {
137        self.max_selected_edges
138    }
139
140    /// Largest cyclic component that does not trigger `large-scc`.
141    pub fn max_scc_members(&self) -> u32 {
142        self.max_scc_members
143    }
144
145    /// Foreign edges a symbol needs before `misplaced-symbol` can trigger.
146    pub fn misplaced_symbol_min_foreign_edges(&self) -> u32 {
147        self.misplaced_symbol_min_foreign_edges
148    }
149
150    /// Foreign-edge share, in percent, at which `misplaced-symbol` triggers.
151    pub fn misplaced_symbol_min_affinity_percent(&self) -> u8 {
152        self.misplaced_symbol_min_affinity_percent
153    }
154
155    /// Selected outgoing edges a module needs before cohesion is judged.
156    pub fn low_module_cohesion_min_outgoing_edges(&self) -> u32 {
157        self.low_module_cohesion_min_outgoing_edges
158    }
159
160    /// Internal-edge share, in percent, below which cohesion is too low.
161    pub fn low_module_cohesion_min_percent(&self) -> u8 {
162        self.low_module_cohesion_min_percent
163    }
164
165    /// Per-rule settings, read only by the module-boundary evaluator.
166    pub(crate) fn rule_overrides(&self) -> &BTreeMap<Box<str>, GateRuleOverride> {
167        &self.rules
168    }
169
170    /// Validate one raw nested table into a configuration.
171    pub(super) fn from_raw(raw: ModuleBoundaryTable) -> Result<Self, ModuleBoundaryConfigError> {
172        let mut config = Self::default();
173        for (key, value) in raw {
174            config.apply(key, value)?;
175        }
176        Ok(config)
177    }
178
179    /// The key arrives owned, so a rule setting stores the map's own allocation.
180    fn apply(
181        &mut self,
182        key: Box<str>,
183        value: ModuleBoundaryValue,
184    ) -> Result<(), ModuleBoundaryConfigError> {
185        match &*key {
186            "enabled" => self.enabled = expect_bool(&key, value)?,
187            "max-nodes" => self.max_nodes = expect_count(&key, value)?,
188            "max-references" => self.max_references = expect_count(&key, value)?,
189            "max-edges" => self.max_edges = expect_count(&key, value)?,
190            "max-selected-edges" => self.max_selected_edges = expect_count(&key, value)?,
191            "max-scc-members" => self.max_scc_members = expect_count(&key, value)?,
192            "misplaced-symbol-min-foreign-edges" => {
193                self.misplaced_symbol_min_foreign_edges = expect_count(&key, value)?;
194            }
195            "misplaced-symbol-min-affinity-percent" => {
196                self.misplaced_symbol_min_affinity_percent = expect_percent(&key, value)?;
197            }
198            "low-module-cohesion-min-outgoing-edges" => {
199                self.low_module_cohesion_min_outgoing_edges = expect_count(&key, value)?;
200            }
201            "low-module-cohesion-min-percent" => {
202                self.low_module_cohesion_min_percent = expect_percent(&key, value)?;
203            }
204            _ => return self.apply_rule(key, value),
205        }
206        Ok(())
207    }
208
209    fn apply_rule(
210        &mut self,
211        rule: Box<str>,
212        value: ModuleBoundaryValue,
213    ) -> Result<(), ModuleBoundaryConfigError> {
214        if !crate::gate::is_module_boundary_rule(&rule) {
215            return Err(ModuleBoundaryConfigError::UnknownField(rule));
216        }
217        if let Some(setting) = rule_setting(&rule, value)? {
218            self.rules.insert(rule, setting);
219        }
220        Ok(())
221    }
222}
223
224/// Interpret a rule value through the shared `false`/`true`/severity semantics.
225fn rule_setting(
226    rule: &str,
227    value: ModuleBoundaryValue,
228) -> Result<Option<GateRuleOverride>, ModuleBoundaryConfigError> {
229    match value {
230        ModuleBoundaryValue::Bool(toggle) => Ok(rule_override(RuleSetting::Toggle(toggle))?),
231        ModuleBoundaryValue::String(name) => Ok(rule_override(RuleSetting::Named(&name))?),
232        _ => Err(ModuleBoundaryConfigError::RuleNotToggleOrSeverity {
233            rule: Box::from(rule),
234        }),
235    }
236}
237
238fn expect_bool(field: &str, value: ModuleBoundaryValue) -> Result<bool, ModuleBoundaryConfigError> {
239    match value {
240        ModuleBoundaryValue::Bool(toggle) => Ok(toggle),
241        _ => Err(ModuleBoundaryConfigError::NotBoolean {
242            field: Box::from(field),
243        }),
244    }
245}
246
247fn expect_integer(
248    field: &str,
249    value: ModuleBoundaryValue,
250) -> Result<i64, ModuleBoundaryConfigError> {
251    match value {
252        ModuleBoundaryValue::Integer(number) => Ok(number),
253        _ => Err(ModuleBoundaryConfigError::NotInteger {
254            field: Box::from(field),
255        }),
256    }
257}
258
259fn expect_count(field: &str, value: ModuleBoundaryValue) -> Result<u32, ModuleBoundaryConfigError> {
260    let raw = expect_integer(field, value)?;
261    u32::try_from(raw).map_err(|_| ModuleBoundaryConfigError::CountOutOfRange {
262        field: Box::from(field),
263        value: raw,
264    })
265}
266
267fn expect_percent(
268    field: &str,
269    value: ModuleBoundaryValue,
270) -> Result<u8, ModuleBoundaryConfigError> {
271    let raw = expect_integer(field, value)?;
272    match u8::try_from(raw) {
273        Ok(percent) if percent <= 100 => Ok(percent),
274        _ => Err(ModuleBoundaryConfigError::PercentOutOfRange {
275            field: Box::from(field),
276            value: raw,
277        }),
278    }
279}
280
281impl<'de> Deserialize<'de> for ModuleBoundaryConfig {
282    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
283    where
284        D: serde::Deserializer<'de>,
285    {
286        use serde::de::Error;
287
288        let raw = ModuleBoundaryTable::deserialize(deserializer)?;
289        Self::from_raw(raw).map_err(D::Error::custom)
290    }
291}