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, Vec<usize>> = IndexMap::new();
44 for (idx, node) in ctx.snapshot().nodes.iter().enumerate() {
45 let Some(parent) = node.parent else { continue };
46 groups.entry(parent).or_default().push(idx);
47 }
48
49 let nodes = &ctx.snapshot().nodes;
50
51 for siblings in groups.values() {
52 if siblings.len() < 2 {
53 continue;
54 }
55
56 for prop in PADDING_PROPERTIES {
57 let parsed: Vec<(usize, f64)> = siblings
60 .iter()
61 .filter_map(|&idx| {
62 let raw = nodes[idx].computed_styles.get(*prop)?;
63 let val = parse_px(raw)?;
64 Some((idx, val))
65 })
66 .collect();
67
68 if parsed.len() < 2 {
69 continue;
70 }
71
72 let median = median_f64(&parsed.iter().map(|(_, v)| *v).collect::<Vec<_>>());
73
74 for &(idx, val) in &parsed {
75 let dev = (val - median).abs();
76 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
77 let dev_u32 = dev.round() as u32;
78 if dev_u32 <= PADDING_DEVIATION_PX {
79 continue;
80 }
81
82 let node = &nodes[idx];
83 let mut metadata: IndexMap<String, serde_json::Value> = IndexMap::new();
84 metadata.insert(
85 "property".to_owned(),
86 serde_json::Value::String((*prop).to_owned()),
87 );
88 metadata.insert(
89 "rendered_padding_px".to_owned(),
90 serde_json::Value::from(val),
91 );
92 metadata.insert(
93 "sibling_median_px".to_owned(),
94 serde_json::Value::from(median),
95 );
96 metadata.insert("deviation_px".to_owned(), serde_json::Value::from(dev_u32));
97
98 sink.push(Violation {
99 rule_id: self.id().to_owned(),
100 severity: self.default_severity(),
101 message: format!(
102 "`{selector}` has {prop} {val}px; sibling median is {median}px ({dev_u32}px drift).",
103 selector = node.selector,
104 ),
105 selector: node.selector.clone(),
106 viewport: ctx.snapshot().viewport.clone(),
107 rect: ctx.rect_for(node.dom_order),
108 dom_order: node.dom_order,
109 fix: Some(Fix {
110 kind: FixKind::Description {
111 text: format!(
112 "Match sibling {prop} ({median}px) to keep padding consistent. Drift: {dev_u32}px.",
113 ),
114 },
115 description: format!(
116 "Bring `{selector}` {prop} in line with its siblings ({median}px).",
117 selector = node.selector,
118 ),
119 confidence: Confidence::Low,
120 }),
121 doc_url: "https://plumb.aramhammoudeh.com/rules/sibling-padding-consistency"
122 .to_owned(),
123 metadata,
124 });
125 }
126 }
127 }
128 }
129}
130
131fn median_f64(values: &[f64]) -> f64 {
142 let mut sorted: Vec<f64> = values.to_vec();
143 sorted.sort_by(f64::total_cmp);
144 let mid = sorted.len() / 2;
145 if sorted.len().is_multiple_of(2) {
146 sorted[mid - 1]
147 } else {
148 sorted[mid]
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::median_f64;
155
156 #[test]
157 fn median_odd_count_picks_middle() {
158 assert!((median_f64(&[1.0, 5.0, 3.0]) - 3.0).abs() < f64::EPSILON);
159 }
160
161 #[test]
162 fn median_even_count_picks_lower_middle() {
163 assert!((median_f64(&[1.0, 7.0, 3.0, 5.0]) - 3.0).abs() < f64::EPSILON);
166 }
167
168 #[test]
169 fn median_with_nan_is_total_and_does_not_panic() {
170 let values = [1.0_f64, f64::NAN, 3.0, 2.0];
177 let result = median_f64(&values);
178 assert!((result - 2.0).abs() < f64::EPSILON);
179 }
180}