Skip to main content

plumb_core/
config.rs

1//! Config schema — the shape of `plumb.toml`.
2//!
3//! The real fields are spelled out in `docs/local/prd.md` §12.2. The
4//! full shape is defined up front (so the JSON Schema emitted by
5//! `plumb schema` is stable across PRs) even though most fields are
6//! unused by the rules that have shipped so far.
7
8use indexmap::IndexMap;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::report::Severity;
13
14/// Top-level Plumb configuration.
15///
16/// `Eq` is not derived — several sub-structs carry `f32` fields.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
18#[serde(deny_unknown_fields)]
19pub struct Config {
20    /// Named viewports to snapshot the page at.
21    #[serde(default)]
22    pub viewports: IndexMap<String, ViewportSpec>,
23
24    /// Spacing spec — the allowed discrete values for `gap`, `margin`,
25    /// `padding`, etc.
26    #[serde(default)]
27    pub spacing: SpacingSpec,
28
29    /// Type scale spec.
30    #[serde(default, rename = "type")]
31    #[schemars(rename = "type")]
32    pub type_scale: TypeScaleSpec,
33
34    /// Color palette spec.
35    #[serde(default)]
36    pub color: ColorSpec,
37
38    /// Border-radius spec.
39    #[serde(default)]
40    pub radius: RadiusSpec,
41
42    /// Alignment / layout spec.
43    #[serde(default)]
44    pub alignment: AlignmentSpec,
45
46    /// Box-shadow spec.
47    #[serde(default)]
48    pub shadow: ShadowSpec,
49
50    /// Z-index spec.
51    #[serde(default)]
52    pub z_index: ZIndexSpec,
53
54    /// Opacity spec.
55    #[serde(default)]
56    pub opacity: OpacitySpec,
57
58    /// Vertical rhythm spec.
59    #[serde(default)]
60    pub rhythm: RhythmSpec,
61
62    /// Accessibility spec.
63    #[serde(default)]
64    pub a11y: A11ySpec,
65
66    /// Per-rule overrides — severity bumps, enable/disable.
67    #[serde(default)]
68    pub rules: IndexMap<String, RuleOverride>,
69
70    /// Selector-scoped runtime suppressions.
71    ///
72    /// Each entry suppresses every violation whose `selector` field
73    /// matches `selector` exactly (no CSS-engine match — exact string
74    /// equality only). When `rule_id` is set, the suppression is
75    /// further constrained to that single rule; when `rule_id` is
76    /// absent, every rule fired at that selector is suppressed.
77    ///
78    /// The list MUST be applied **after** rule evaluation: the
79    /// matched violations are partitioned out of the reported set and
80    /// counted under `ignored`, never silently dropped.
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub ignore: Vec<IgnoreRule>,
83}
84
85/// A single selector-scoped suppression entry.
86///
87/// Mirrors the shape `plumb lint --suggest-ignores` emits, so a user
88/// can pipe the suggestion list back into `plumb.toml` and converge on
89/// a clean dogfood run without further editing.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
91#[serde(deny_unknown_fields)]
92pub struct IgnoreRule {
93    /// Exact CSS-selector path (`SnapshotNode::selector`) to suppress.
94    /// String equality only; this is **not** a CSS engine match.
95    pub selector: String,
96    /// Optional rule identifier (e.g. `spacing/grid-conformance`). When
97    /// `Some`, the suppression only applies to that rule. When `None`,
98    /// every rule's violation at `selector` is suppressed.
99    #[serde(default)]
100    pub rule_id: Option<String>,
101    /// Required human-readable justification. Documents why the
102    /// selector is exempt so the next reviewer understands the intent.
103    pub reason: String,
104}
105
106/// Specification of a single named viewport.
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
108#[serde(deny_unknown_fields)]
109pub struct ViewportSpec {
110    /// Width in CSS pixels.
111    pub width: u32,
112    /// Height in CSS pixels.
113    pub height: u32,
114    /// Device pixel ratio. Defaults to 1.0.
115    #[serde(default = "default_dpr")]
116    pub device_pixel_ratio: f32,
117}
118
119fn default_dpr() -> f32 {
120    1.0
121}
122
123/// Spacing spec.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
125#[serde(deny_unknown_fields)]
126pub struct SpacingSpec {
127    /// Base unit in pixels; discrete scale is multiples of this.
128    #[serde(default = "default_base_unit")]
129    pub base_unit: u32,
130    /// Allowed spacing values in pixels.
131    #[serde(default)]
132    pub scale: Vec<u32>,
133    /// Named tokens mapped to their pixel values.
134    #[serde(default)]
135    pub tokens: IndexMap<String, u32>,
136}
137
138fn default_base_unit() -> u32 {
139    4
140}
141
142impl Default for SpacingSpec {
143    fn default() -> Self {
144        Self {
145            base_unit: default_base_unit(),
146            scale: Vec::new(),
147            tokens: IndexMap::new(),
148        }
149    }
150}
151
152/// Type scale spec.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
154#[serde(deny_unknown_fields)]
155pub struct TypeScaleSpec {
156    /// Allowed font families.
157    #[serde(default)]
158    pub families: Vec<String>,
159    /// Allowed font weights.
160    #[serde(default)]
161    pub weights: Vec<u16>,
162    /// Allowed font sizes in pixels.
163    #[serde(default)]
164    pub scale: Vec<u32>,
165    /// Named type tokens mapped to their pixel values.
166    #[serde(default)]
167    pub tokens: IndexMap<String, u32>,
168}
169
170/// Color spec.
171///
172/// Tokens are flat name → hex pairs. Slash-delimited names
173/// (`"bg/canvas"`, `"fg/primary"`) namespace the palette without
174/// requiring nested tables — TOML quotes the key, the rule engine
175/// treats the slash as a hint for grouping in diagnostics.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
177#[serde(deny_unknown_fields)]
178pub struct ColorSpec {
179    /// Named tokens mapped to hex values (e.g. `#0b7285`). Slash-delimited
180    /// keys (`"bg/canvas"`) act as informal namespaces.
181    #[serde(default)]
182    pub tokens: IndexMap<String, String>,
183    /// CIEDE2000 Delta-E tolerance when matching off-palette colors.
184    #[serde(default = "default_delta_e")]
185    pub delta_e_tolerance: f32,
186}
187
188fn default_delta_e() -> f32 {
189    2.0
190}
191
192impl Default for ColorSpec {
193    fn default() -> Self {
194        Self {
195            tokens: IndexMap::new(),
196            delta_e_tolerance: default_delta_e(),
197        }
198    }
199}
200
201/// Border-radius spec.
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
203#[serde(deny_unknown_fields)]
204pub struct RadiusSpec {
205    /// Allowed border-radius values in pixels.
206    ///
207    /// Naming matches `spacing.scale` and `type.scale` for consistency.
208    #[serde(default)]
209    pub scale: Vec<u32>,
210}
211
212/// Alignment / layout spec.
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
214#[serde(deny_unknown_fields)]
215pub struct AlignmentSpec {
216    /// Grid column count, if the design uses a fixed grid.
217    #[serde(default)]
218    pub grid_columns: Option<u32>,
219    /// Container gutter in pixels.
220    #[serde(default)]
221    pub gutter_px: Option<u32>,
222    /// Edge-clustering tolerance in pixels for `edge/near-alignment`.
223    /// Defaults to 3 px.
224    #[serde(default = "default_alignment_tolerance_px")]
225    pub tolerance_px: u32,
226}
227
228fn default_alignment_tolerance_px() -> u32 {
229    3
230}
231
232impl Default for AlignmentSpec {
233    fn default() -> Self {
234        Self {
235            grid_columns: None,
236            gutter_px: None,
237            tolerance_px: default_alignment_tolerance_px(),
238        }
239    }
240}
241
242/// Box-shadow spec.
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
244#[serde(deny_unknown_fields)]
245pub struct ShadowSpec {
246    /// Allowed box-shadow values. Each entry is a complete shadow
247    /// expression as returned by `getComputedStyle`.
248    #[serde(default)]
249    pub scale: Vec<String>,
250}
251
252/// Z-index spec.
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
254#[serde(deny_unknown_fields)]
255pub struct ZIndexSpec {
256    /// Allowed z-index values.
257    #[serde(default)]
258    pub scale: Vec<i32>,
259}
260
261/// Opacity spec.
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
263#[serde(deny_unknown_fields)]
264pub struct OpacitySpec {
265    /// Allowed opacity values in the range `[0.0, 1.0]`.
266    #[serde(default)]
267    pub scale: Vec<f32>,
268}
269
270/// Vertical-rhythm spec.
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
272#[serde(deny_unknown_fields)]
273#[allow(clippy::struct_field_names)]
274pub struct RhythmSpec {
275    /// Base line-height in pixels.
276    #[serde(default)]
277    pub base_line_px: u32,
278    /// Tolerance in pixels for rhythm checks.
279    #[serde(default = "default_rhythm_tolerance_px")]
280    pub tolerance_px: u32,
281    /// Cap-height fallback in pixels when font metrics are unavailable.
282    #[serde(default)]
283    pub cap_height_fallback_px: u32,
284}
285
286fn default_rhythm_tolerance_px() -> u32 {
287    2
288}
289
290impl Default for RhythmSpec {
291    fn default() -> Self {
292        Self {
293            base_line_px: 0,
294            tolerance_px: default_rhythm_tolerance_px(),
295            cap_height_fallback_px: 0,
296        }
297    }
298}
299
300/// Accessibility spec.
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
302#[serde(deny_unknown_fields)]
303pub struct A11ySpec {
304    /// Minimum contrast ratio to enforce (e.g. `4.5` for WCAG AA body text).
305    #[serde(default)]
306    pub min_contrast_ratio: Option<f32>,
307    /// Minimum interactive-element size for `a11y/touch-target`.
308    #[serde(default)]
309    pub touch_target: TouchTargetSpec,
310}
311
312/// Touch-target threshold per WCAG 2.5.8 (Target Size, Minimum).
313///
314/// Defaults to 24×24 CSS pixels.
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
316#[serde(deny_unknown_fields)]
317pub struct TouchTargetSpec {
318    /// Minimum interactive width in CSS pixels.
319    #[serde(default = "default_touch_target_px")]
320    pub min_width_px: u32,
321    /// Minimum interactive height in CSS pixels.
322    #[serde(default = "default_touch_target_px")]
323    pub min_height_px: u32,
324}
325
326fn default_touch_target_px() -> u32 {
327    24
328}
329
330impl Default for TouchTargetSpec {
331    fn default() -> Self {
332        Self {
333            min_width_px: default_touch_target_px(),
334            min_height_px: default_touch_target_px(),
335        }
336    }
337}
338
339/// Per-rule override.
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
341#[serde(deny_unknown_fields)]
342pub struct RuleOverride {
343    /// Enable or disable the rule entirely.
344    #[serde(default = "default_enabled")]
345    pub enabled: bool,
346    /// Override the rule's default severity.
347    #[serde(default)]
348    pub severity: Option<Severity>,
349}
350
351fn default_enabled() -> bool {
352    true
353}
354
355impl Default for RuleOverride {
356    fn default() -> Self {
357        Self {
358            enabled: true,
359            severity: None,
360        }
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::{Config, IgnoreRule};
367
368    #[test]
369    fn ignore_rule_round_trips_minimal_shape() {
370        let json = r#"{ "selector": "html > body", "reason": "mdBook chrome" }"#;
371        let parsed: IgnoreRule = serde_json::from_str(json).expect("parse minimal IgnoreRule");
372        assert_eq!(parsed.selector, "html > body");
373        assert_eq!(parsed.rule_id, None);
374        assert_eq!(parsed.reason, "mdBook chrome");
375    }
376
377    #[test]
378    fn ignore_rule_round_trips_with_rule_id() {
379        let json = r#"{
380            "selector": "main > article",
381            "rule_id": "spacing/grid-conformance",
382            "reason": "code blocks padded by mdBook theme"
383        }"#;
384        let parsed: IgnoreRule = serde_json::from_str(json).expect("parse rule_id IgnoreRule");
385        assert_eq!(parsed.rule_id.as_deref(), Some("spacing/grid-conformance"));
386    }
387
388    #[test]
389    fn ignore_rule_rejects_unknown_field() {
390        let json = r#"{ "selector": "html", "reason": "x", "extra": "nope" }"#;
391        let err = serde_json::from_str::<IgnoreRule>(json)
392            .expect_err("unknown field must fail under deny_unknown_fields");
393        let msg = err.to_string();
394        assert!(msg.contains("extra"), "error mentions field: {msg}");
395    }
396
397    #[test]
398    fn ignore_rule_requires_selector() {
399        let json = r#"{ "reason": "x" }"#;
400        serde_json::from_str::<IgnoreRule>(json).expect_err("selector is required");
401    }
402
403    #[test]
404    fn ignore_rule_requires_reason() {
405        let json = r#"{ "selector": "html" }"#;
406        serde_json::from_str::<IgnoreRule>(json).expect_err("reason is required");
407    }
408
409    #[test]
410    fn config_accepts_ignore_array() {
411        let json = r#"{
412            "ignore": [
413                { "selector": "html > body", "reason": "mdBook root padding" },
414                {
415                    "selector": "main",
416                    "rule_id": "spacing/scale-conformance",
417                    "reason": "main column gutter"
418                }
419            ]
420        }"#;
421        let cfg: Config = serde_json::from_str(json).expect("parse Config with ignores");
422        assert_eq!(cfg.ignore.len(), 2);
423        assert_eq!(cfg.ignore[0].selector, "html > body");
424        assert_eq!(cfg.ignore[0].rule_id, None);
425        assert_eq!(
426            cfg.ignore[1].rule_id.as_deref(),
427            Some("spacing/scale-conformance")
428        );
429    }
430
431    #[test]
432    fn config_default_has_empty_ignore() {
433        let cfg = Config::default();
434        assert!(cfg.ignore.is_empty());
435    }
436}