1use indexmap::IndexMap;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::report::Severity;
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
18#[serde(deny_unknown_fields)]
19pub struct Config {
20 #[serde(default)]
22 pub viewports: IndexMap<String, ViewportSpec>,
23
24 #[serde(default)]
27 pub spacing: SpacingSpec,
28
29 #[serde(default, rename = "type")]
31 #[schemars(rename = "type")]
32 pub type_scale: TypeScaleSpec,
33
34 #[serde(default)]
36 pub color: ColorSpec,
37
38 #[serde(default)]
40 pub radius: RadiusSpec,
41
42 #[serde(default)]
44 pub alignment: AlignmentSpec,
45
46 #[serde(default)]
48 pub shadow: ShadowSpec,
49
50 #[serde(default)]
52 pub z_index: ZIndexSpec,
53
54 #[serde(default)]
56 pub opacity: OpacitySpec,
57
58 #[serde(default)]
60 pub rhythm: RhythmSpec,
61
62 #[serde(default)]
64 pub a11y: A11ySpec,
65
66 #[serde(default)]
68 pub rules: IndexMap<String, RuleOverride>,
69
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub ignore: Vec<IgnoreRule>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
91#[serde(deny_unknown_fields)]
92pub struct IgnoreRule {
93 pub selector: String,
96 #[serde(default)]
100 pub rule_id: Option<String>,
101 pub reason: String,
104}
105
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
108#[serde(deny_unknown_fields)]
109pub struct ViewportSpec {
110 pub width: u32,
112 pub height: u32,
114 #[serde(default = "default_dpr")]
116 pub device_pixel_ratio: f32,
117}
118
119fn default_dpr() -> f32 {
120 1.0
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
125#[serde(deny_unknown_fields)]
126pub struct SpacingSpec {
127 #[serde(default = "default_base_unit")]
129 pub base_unit: u32,
130 #[serde(default)]
132 pub scale: Vec<u32>,
133 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
154#[serde(deny_unknown_fields)]
155pub struct TypeScaleSpec {
156 #[serde(default)]
158 pub families: Vec<String>,
159 #[serde(default)]
161 pub weights: Vec<u16>,
162 #[serde(default)]
164 pub scale: Vec<u32>,
165 #[serde(default)]
167 pub tokens: IndexMap<String, u32>,
168}
169
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
177#[serde(deny_unknown_fields)]
178pub struct ColorSpec {
179 #[serde(default)]
182 pub tokens: IndexMap<String, String>,
183 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
203#[serde(deny_unknown_fields)]
204pub struct RadiusSpec {
205 #[serde(default)]
209 pub scale: Vec<u32>,
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
214#[serde(deny_unknown_fields)]
215pub struct AlignmentSpec {
216 #[serde(default)]
218 pub grid_columns: Option<u32>,
219 #[serde(default)]
221 pub gutter_px: Option<u32>,
222 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
244#[serde(deny_unknown_fields)]
245pub struct ShadowSpec {
246 #[serde(default)]
249 pub scale: Vec<String>,
250}
251
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
254#[serde(deny_unknown_fields)]
255pub struct ZIndexSpec {
256 #[serde(default)]
258 pub scale: Vec<i32>,
259}
260
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
263#[serde(deny_unknown_fields)]
264pub struct OpacitySpec {
265 #[serde(default)]
267 pub scale: Vec<f32>,
268}
269
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
272#[serde(deny_unknown_fields)]
273#[allow(clippy::struct_field_names)]
274pub struct RhythmSpec {
275 #[serde(default)]
277 pub base_line_px: u32,
278 #[serde(default = "default_rhythm_tolerance_px")]
280 pub tolerance_px: u32,
281 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
302#[serde(deny_unknown_fields)]
303pub struct A11ySpec {
304 #[serde(default)]
306 pub min_contrast_ratio: Option<f32>,
307 #[serde(default)]
309 pub touch_target: TouchTargetSpec,
310}
311
312#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
316#[serde(deny_unknown_fields)]
317pub struct TouchTargetSpec {
318 #[serde(default = "default_touch_target_px")]
320 pub min_width_px: u32,
321 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
341#[serde(deny_unknown_fields)]
342pub struct RuleOverride {
343 #[serde(default = "default_enabled")]
345 pub enabled: bool,
346 #[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}