plumb_core/rules/sibling/
padding_consistency.rs1use indexmap::IndexMap;
5
6use crate::config::Config;
7use crate::report::{Confidence, Fix, FixKind, Severity, Violation, ViolationSink};
8use crate::rules::Rule;
9use crate::rules::util::parse_px;
10use crate::snapshot::SnapshotCtx;
11
12const PADDING_PROPERTIES: &[&str] = &[
14 "padding-top",
15 "padding-right",
16 "padding-bottom",
17 "padding-left",
18];
19
20const PADDING_DEVIATION_PX: u32 = 4;
23
24#[derive(Debug, Clone, Copy)]
26pub struct PaddingConsistency;
27
28impl Rule for PaddingConsistency {
29 fn id(&self) -> &'static str {
30 "sibling/padding-consistency"
31 }
32
33 fn default_severity(&self) -> Severity {
34 Severity::Info
35 }
36
37 fn summary(&self) -> &'static str {
38 "Flags sibling elements with inconsistent padding."
39 }
40
41 fn check(&self, ctx: &SnapshotCtx<'_>, _config: &Config, sink: &mut ViolationSink<'_>) {
42 let mut groups: IndexMap<(u64, String), Vec<usize>> = IndexMap::new();
47 for (idx, node) in ctx.snapshot().nodes.iter().enumerate() {
48 let Some(parent) = node.parent else { continue };
49 let Some(rect) = ctx.rect_for(node.dom_order) else {
52 continue;
53 };
54 if rect.width == 0 || rect.height == 0 {
55 continue;
56 }
57 groups
58 .entry((parent, node.tag.clone()))
59 .or_default()
60 .push(idx);
61 }
62
63 let nodes = &ctx.snapshot().nodes;
64
65 for siblings in groups.values() {
66 if siblings.len() < 2 {
67 continue;
68 }
69
70 for prop in PADDING_PROPERTIES {
71 let parsed: Vec<(usize, f64)> = siblings
74 .iter()
75 .filter_map(|&idx| {
76 let raw = nodes[idx].computed_styles.get(*prop)?;
77 let val = parse_px(raw)?;
78 Some((idx, val))
79 })
80 .collect();
81
82 if parsed.len() < 2 {
83 continue;
84 }
85
86 let median = median_f64(&parsed.iter().map(|(_, v)| *v).collect::<Vec<_>>());
87
88 for &(idx, val) in &parsed {
89 let dev = (val - median).abs();
90 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
91 let dev_u32 = dev.round() as u32;
92 if dev_u32 <= PADDING_DEVIATION_PX {
93 continue;
94 }
95
96 let node = &nodes[idx];
97 let mut metadata: IndexMap<String, serde_json::Value> = IndexMap::new();
98 metadata.insert(
99 "property".to_owned(),
100 serde_json::Value::String((*prop).to_owned()),
101 );
102 metadata.insert(
103 "rendered_padding_px".to_owned(),
104 serde_json::Value::from(val),
105 );
106 metadata.insert(
107 "sibling_median_px".to_owned(),
108 serde_json::Value::from(median),
109 );
110 metadata.insert("deviation_px".to_owned(), serde_json::Value::from(dev_u32));
111
112 sink.push(Violation {
113 rule_id: self.id().to_owned(),
114 severity: self.default_severity(),
115 message: format!(
116 "`{selector}` has {prop} {val}px; sibling median is {median}px ({dev_u32}px drift).",
117 selector = node.selector,
118 ),
119 selector: node.selector.clone(),
120 viewport: ctx.snapshot().viewport.clone(),
121 rect: ctx.rect_for(node.dom_order),
122 dom_order: node.dom_order,
123 fix: Some(Fix {
124 kind: FixKind::Description {
125 text: format!(
126 "Match sibling {prop} ({median}px) to keep padding consistent. Drift: {dev_u32}px.",
127 ),
128 },
129 description: format!(
130 "Bring `{selector}` {prop} in line with its siblings ({median}px).",
131 selector = node.selector,
132 ),
133 confidence: Confidence::Low,
134 }),
135 doc_url: "https://plumb.aramhammoudeh.com/rules/sibling-padding-consistency"
136 .to_owned(),
137 metadata,
138 });
139 }
140 }
141 }
142 }
143}
144
145fn median_f64(values: &[f64]) -> f64 {
156 let mut sorted: Vec<f64> = values.to_vec();
157 sorted.sort_by(f64::total_cmp);
158 let mid = sorted.len() / 2;
159 if sorted.len().is_multiple_of(2) {
160 sorted[mid - 1]
161 } else {
162 sorted[mid]
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::median_f64;
169
170 #[test]
171 fn median_odd_count_picks_middle() {
172 assert!((median_f64(&[1.0, 5.0, 3.0]) - 3.0).abs() < f64::EPSILON);
173 }
174
175 #[test]
176 fn median_even_count_picks_lower_middle() {
177 assert!((median_f64(&[1.0, 7.0, 3.0, 5.0]) - 3.0).abs() < f64::EPSILON);
180 }
181
182 #[test]
183 fn median_with_nan_is_total_and_does_not_panic() {
184 let values = [1.0_f64, f64::NAN, 3.0, 2.0];
191 let result = median_f64(&values);
192 assert!((result - 2.0).abs() < f64::EPSILON);
193 }
194}