use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::report::Severity;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub viewports: IndexMap<String, ViewportSpec>,
#[serde(default)]
pub spacing: SpacingSpec,
#[serde(default, rename = "type")]
#[schemars(rename = "type")]
pub type_scale: TypeScaleSpec,
#[serde(default)]
pub color: ColorSpec,
#[serde(default)]
pub radius: RadiusSpec,
#[serde(default)]
pub alignment: AlignmentSpec,
#[serde(default)]
pub shadow: ShadowSpec,
#[serde(default)]
pub z_index: ZIndexSpec,
#[serde(default)]
pub opacity: OpacitySpec,
#[serde(default)]
pub rhythm: RhythmSpec,
#[serde(default)]
pub a11y: A11ySpec,
#[serde(default)]
pub rules: IndexMap<String, RuleOverride>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ignore: Vec<IgnoreRule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IgnoreRule {
pub selector: String,
#[serde(default)]
pub rule_id: Option<String>,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ViewportSpec {
pub width: u32,
pub height: u32,
#[serde(default = "default_dpr")]
pub device_pixel_ratio: f32,
}
fn default_dpr() -> f32 {
1.0
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SpacingSpec {
#[serde(default = "default_base_unit")]
pub base_unit: u32,
#[serde(default)]
pub scale: Vec<u32>,
#[serde(default)]
pub tokens: IndexMap<String, u32>,
}
fn default_base_unit() -> u32 {
4
}
impl Default for SpacingSpec {
fn default() -> Self {
Self {
base_unit: default_base_unit(),
scale: Vec::new(),
tokens: IndexMap::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(deny_unknown_fields)]
pub struct TypeScaleSpec {
#[serde(default)]
pub families: Vec<String>,
#[serde(default)]
pub weights: Vec<u16>,
#[serde(default)]
pub scale: Vec<u32>,
#[serde(default)]
pub tokens: IndexMap<String, u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ColorSpec {
#[serde(default)]
pub tokens: IndexMap<String, String>,
#[serde(default = "default_delta_e")]
pub delta_e_tolerance: f32,
}
fn default_delta_e() -> f32 {
2.0
}
impl Default for ColorSpec {
fn default() -> Self {
Self {
tokens: IndexMap::new(),
delta_e_tolerance: default_delta_e(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(deny_unknown_fields)]
pub struct RadiusSpec {
#[serde(default)]
pub scale: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AlignmentSpec {
#[serde(default)]
pub grid_columns: Option<u32>,
#[serde(default)]
pub gutter_px: Option<u32>,
#[serde(default = "default_alignment_tolerance_px")]
pub tolerance_px: u32,
}
fn default_alignment_tolerance_px() -> u32 {
3
}
impl Default for AlignmentSpec {
fn default() -> Self {
Self {
grid_columns: None,
gutter_px: None,
tolerance_px: default_alignment_tolerance_px(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(deny_unknown_fields)]
pub struct ShadowSpec {
#[serde(default)]
pub scale: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(deny_unknown_fields)]
pub struct ZIndexSpec {
#[serde(default)]
pub scale: Vec<i32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(deny_unknown_fields)]
pub struct OpacitySpec {
#[serde(default)]
pub scale: Vec<f32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[allow(clippy::struct_field_names)]
pub struct RhythmSpec {
#[serde(default)]
pub base_line_px: u32,
#[serde(default = "default_rhythm_tolerance_px")]
pub tolerance_px: u32,
#[serde(default)]
pub cap_height_fallback_px: u32,
}
fn default_rhythm_tolerance_px() -> u32 {
2
}
impl Default for RhythmSpec {
fn default() -> Self {
Self {
base_line_px: 0,
tolerance_px: default_rhythm_tolerance_px(),
cap_height_fallback_px: 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(deny_unknown_fields)]
pub struct A11ySpec {
#[serde(default)]
pub min_contrast_ratio: Option<f32>,
#[serde(default)]
pub touch_target: TouchTargetSpec,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TouchTargetSpec {
#[serde(default = "default_touch_target_px")]
pub min_width_px: u32,
#[serde(default = "default_touch_target_px")]
pub min_height_px: u32,
}
fn default_touch_target_px() -> u32 {
24
}
impl Default for TouchTargetSpec {
fn default() -> Self {
Self {
min_width_px: default_touch_target_px(),
min_height_px: default_touch_target_px(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RuleOverride {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub severity: Option<Severity>,
}
fn default_enabled() -> bool {
true
}
impl Default for RuleOverride {
fn default() -> Self {
Self {
enabled: true,
severity: None,
}
}
}
#[cfg(test)]
mod tests {
use super::{Config, IgnoreRule};
#[test]
fn ignore_rule_round_trips_minimal_shape() {
let json = r#"{ "selector": "html > body", "reason": "mdBook chrome" }"#;
let parsed: IgnoreRule = serde_json::from_str(json).expect("parse minimal IgnoreRule");
assert_eq!(parsed.selector, "html > body");
assert_eq!(parsed.rule_id, None);
assert_eq!(parsed.reason, "mdBook chrome");
}
#[test]
fn ignore_rule_round_trips_with_rule_id() {
let json = r#"{
"selector": "main > article",
"rule_id": "spacing/grid-conformance",
"reason": "code blocks padded by mdBook theme"
}"#;
let parsed: IgnoreRule = serde_json::from_str(json).expect("parse rule_id IgnoreRule");
assert_eq!(parsed.rule_id.as_deref(), Some("spacing/grid-conformance"));
}
#[test]
fn ignore_rule_rejects_unknown_field() {
let json = r#"{ "selector": "html", "reason": "x", "extra": "nope" }"#;
let err = serde_json::from_str::<IgnoreRule>(json)
.expect_err("unknown field must fail under deny_unknown_fields");
let msg = err.to_string();
assert!(msg.contains("extra"), "error mentions field: {msg}");
}
#[test]
fn ignore_rule_requires_selector() {
let json = r#"{ "reason": "x" }"#;
serde_json::from_str::<IgnoreRule>(json).expect_err("selector is required");
}
#[test]
fn ignore_rule_requires_reason() {
let json = r#"{ "selector": "html" }"#;
serde_json::from_str::<IgnoreRule>(json).expect_err("reason is required");
}
#[test]
fn config_accepts_ignore_array() {
let json = r#"{
"ignore": [
{ "selector": "html > body", "reason": "mdBook root padding" },
{
"selector": "main",
"rule_id": "spacing/scale-conformance",
"reason": "main column gutter"
}
]
}"#;
let cfg: Config = serde_json::from_str(json).expect("parse Config with ignores");
assert_eq!(cfg.ignore.len(), 2);
assert_eq!(cfg.ignore[0].selector, "html > body");
assert_eq!(cfg.ignore[0].rule_id, None);
assert_eq!(
cfg.ignore[1].rule_id.as_deref(),
Some("spacing/scale-conformance")
);
}
#[test]
fn config_default_has_empty_ignore() {
let cfg = Config::default();
assert!(cfg.ignore.is_empty());
}
}