Skip to main content

plumb_core/
engine.rs

1//! The deterministic rule engine.
2//!
3//! Given a snapshot and a config, [`run`] evaluates every built-in rule
4//! and returns a sorted, deduplicated `Vec<Violation>`. The sort key is
5//! `(rule_id, viewport, selector, dom_order)` — see `docs/local/prd.md` §9.
6
7use crate::config::{Config, IgnoreRule};
8use crate::report::{ViewportKey, Violation, ViolationSink};
9use crate::rules::{Rule, register_builtin};
10use crate::snapshot::{PlumbSnapshot, SnapshotCtx};
11use rayon::prelude::*;
12
13/// A partitioned engine result: reported and ignored violations split
14/// according to the active `[[ignore]]` config entries.
15///
16/// Both vectors are sorted by [`Violation::sort_key`] in ascending
17/// order. `ignored` is empty when the config has no `[[ignore]]`
18/// entries or none of the active entries match the snapshot's
19/// violations.
20///
21/// This is what the CLI and MCP server consume when they need to
22/// display "N violations suppressed by config" alongside the rendered
23/// list.
24#[derive(Debug, Clone, PartialEq)]
25pub struct RunReport {
26    /// Violations that survived the ignore filter and SHOULD be
27    /// reported to the user. Sorted by [`Violation::sort_key`].
28    pub reported: Vec<Violation>,
29    /// Violations that an `[[ignore]]` entry matched. Sorted by
30    /// [`Violation::sort_key`]. Excluded from the standard output;
31    /// surfaced only in the count footer / JSON envelope so users can
32    /// audit what their config silenced.
33    pub ignored: Vec<Violation>,
34}
35
36impl RunReport {
37    /// Empty report — no violations reported, no violations ignored.
38    #[must_use]
39    pub fn empty() -> Self {
40        Self {
41            reported: Vec::new(),
42            ignored: Vec::new(),
43        }
44    }
45
46    /// Total raw violation count (reported + ignored). Useful for
47    /// the "N violations, M suppressed" status line.
48    #[must_use]
49    pub fn total(&self) -> usize {
50        self.reported.len() + self.ignored.len()
51    }
52}
53
54/// Run every built-in rule against the snapshot. Output is sorted and
55/// deduplicated before return.
56///
57/// # Determinism
58///
59/// This function is pure — no wall-clock, no RNG, no environment access.
60/// Running it twice with the same inputs yields byte-identical output.
61///
62/// This is a thin wrapper over [`run_many`] for the single-snapshot case.
63#[must_use]
64pub fn run(snapshot: &PlumbSnapshot, config: &Config) -> Vec<Violation> {
65    run_many([snapshot], config)
66}
67
68/// Run every built-in rule against each snapshot in `snapshots` and
69/// return their merged, sorted, deduplicated violation list.
70///
71/// # Determinism
72///
73/// Output is byte-identical regardless of input order. The merge is
74/// re-sorted by [`Violation::sort_key`] —
75/// `(rule_id, viewport, selector, dom_order)`, the same key the
76/// single-snapshot path uses — so a `desktop`-first config and a
77/// `mobile`-first config yield
78/// the same `Vec<Violation>`. Like [`run`], this function performs no
79/// I/O, no RNG, and no clock reads.
80///
81/// `[[ignore]]` entries in `config` partition the post-rule output;
82/// the returned `Vec` is the reported subset only. Use
83/// [`run_report`] when the caller needs the ignored count or the
84/// ignored violation list.
85#[must_use]
86pub fn run_many<'a, I>(snapshots: I, config: &Config) -> Vec<Violation>
87where
88    I: IntoIterator<Item = &'a PlumbSnapshot>,
89{
90    run_report(snapshots, config).reported
91}
92
93/// Like [`run_many`] but returns a [`RunReport`] partitioning the
94/// violation set into reported vs. ignored according to
95/// `config.ignore`.
96///
97/// # Determinism
98///
99/// Same invariants as [`run_many`]. Both vectors in the returned
100/// report are sorted by [`Violation::sort_key`]; iteration over
101/// `config.ignore` is in declaration order (it's a `Vec`).
102#[must_use]
103pub fn run_report<'a, I>(snapshots: I, config: &Config) -> RunReport
104where
105    I: IntoIterator<Item = &'a PlumbSnapshot>,
106{
107    let rules = register_builtin();
108    let mut buffer: Vec<Violation> = snapshots
109        .into_iter()
110        .flat_map(|snapshot| run_rules(snapshot, config, &rules))
111        .collect();
112
113    // Re-sort across snapshots; `run_rules` already sorts within one
114    // snapshot, but the cross-snapshot merge still needs an outer pass.
115    buffer.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
116    buffer.dedup();
117
118    apply_ignores(buffer, &config.ignore)
119}
120
121/// Partition `violations` into `(reported, ignored)` according to
122/// `ignores`.
123///
124/// Matching is exact-string on `Violation::selector`. When an entry's
125/// `rule_id` is `Some(id)`, the violation must also have
126/// `Violation::rule_id == id`. When `rule_id` is `None`, every rule's
127/// violation at the selector is suppressed.
128///
129/// Iteration over `ignores` follows declaration order; matching is
130/// short-circuited on the first hit per violation. Both the reported
131/// and ignored vectors preserve their input ordering, which is the
132/// caller's pre-sorted [`Violation::sort_key`] order.
133#[must_use]
134pub fn apply_ignores(violations: Vec<Violation>, rules: &[IgnoreRule]) -> RunReport {
135    if rules.is_empty() {
136        return RunReport {
137            reported: violations,
138            ignored: Vec::new(),
139        };
140    }
141
142    let mut reported = Vec::with_capacity(violations.len());
143    let mut suppressed = Vec::new();
144
145    for violation in violations {
146        if ignore_matches(&violation, rules) {
147            suppressed.push(violation);
148        } else {
149            reported.push(violation);
150        }
151    }
152
153    RunReport {
154        reported,
155        ignored: suppressed,
156    }
157}
158
159/// `true` when any entry in `rules` matches `violation`. Selector
160/// equality is exact-string; `rule_id` is matched only when the entry
161/// declares one.
162fn ignore_matches(violation: &Violation, rules: &[IgnoreRule]) -> bool {
163    rules.iter().any(|rule| {
164        if rule.selector != violation.selector {
165            return false;
166        }
167        match &rule.rule_id {
168            Some(id) => id == &violation.rule_id,
169            None => true,
170        }
171    })
172}
173
174fn run_rules(snapshot: &PlumbSnapshot, config: &Config, rules: &[Box<dyn Rule>]) -> Vec<Violation> {
175    let ctx = if config.viewports.is_empty() {
176        SnapshotCtx::new(snapshot)
177    } else {
178        SnapshotCtx::with_viewports(
179            snapshot,
180            config.viewports.keys().cloned().map(ViewportKey::new),
181        )
182    };
183    let mut buffer: Vec<Violation> = rules
184        .par_iter()
185        .filter(|rule| {
186            // Honor per-rule enable/disable. Severity overrides are not yet
187            // applied at engine level — a rule still emits with its default
188            // severity; the formatter layer remaps if the config asks.
189            config.rules.get(rule.id()).is_none_or(|over| over.enabled)
190        })
191        .flat_map(|rule| {
192            let mut local = Vec::new();
193            let mut sink = ViolationSink::new(&mut local);
194            rule.check(&ctx, config, &mut sink);
195            local
196        })
197        .collect();
198
199    // Deterministic sort.
200    buffer.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
201
202    // Dedup exact matches — different rules may independently flag the
203    // same node; keep the first occurrence in sort order.
204    buffer.dedup();
205
206    buffer
207}
208
209#[cfg(test)]
210mod tests {
211    use crate::config::{Config, IgnoreRule};
212    use crate::report::{Severity, ViewportKey, Violation, ViolationSink};
213    use crate::rules::Rule;
214    use crate::snapshot::{PlumbSnapshot, SnapshotCtx};
215    use indexmap::IndexMap;
216
217    use super::{apply_ignores, run_report, run_rules};
218
219    #[derive(Debug, Clone, Copy)]
220    struct Emission {
221        selector: &'static str,
222        dom_order: u64,
223    }
224
225    #[derive(Debug)]
226    struct OutOfOrderRule {
227        id: &'static str,
228        emissions: &'static [Emission],
229    }
230
231    impl Rule for OutOfOrderRule {
232        fn id(&self) -> &'static str {
233            self.id
234        }
235
236        fn default_severity(&self) -> Severity {
237            Severity::Warning
238        }
239
240        fn summary(&self) -> &'static str {
241            "Test-only rule that emits fixed violations."
242        }
243
244        fn check(&self, ctx: &SnapshotCtx<'_>, _config: &Config, sink: &mut ViolationSink<'_>) {
245            for emission in self.emissions {
246                sink.push(test_violation(
247                    self.id(),
248                    emission.selector,
249                    ctx.snapshot().viewport.clone(),
250                    emission.dom_order,
251                ));
252            }
253        }
254    }
255
256    fn test_violation(
257        rule_id: &str,
258        selector: &str,
259        viewport: ViewportKey,
260        dom_order: u64,
261    ) -> Violation {
262        Violation {
263            rule_id: rule_id.to_owned(),
264            severity: Severity::Warning,
265            message: "test violation".to_owned(),
266            selector: selector.to_owned(),
267            viewport,
268            rect: None,
269            dom_order,
270            fix: None,
271            doc_url: "https://plumb.aramhammoudeh.com/rules/test-only".to_owned(),
272            metadata: IndexMap::new(),
273        }
274    }
275
276    #[test]
277    fn run_rules_sorts_parallel_rule_output() {
278        const ALPHA_EMISSIONS: &[Emission] = &[
279            Emission {
280                selector: "html > zed",
281                dom_order: 9,
282            },
283            Emission {
284                selector: "html > alpha",
285                dom_order: 1,
286            },
287        ];
288        const ZED_EMISSIONS: &[Emission] = &[
289            Emission {
290                selector: "html > body",
291                dom_order: 2,
292            },
293            Emission {
294                selector: "html",
295                dom_order: 0,
296            },
297        ];
298
299        let snapshot = PlumbSnapshot::canned();
300        let config = Config::default();
301        let rules: Vec<Box<dyn Rule>> = vec![
302            Box::new(OutOfOrderRule {
303                id: "z/rule",
304                emissions: ZED_EMISSIONS,
305            }),
306            Box::new(OutOfOrderRule {
307                id: "a/rule",
308                emissions: ALPHA_EMISSIONS,
309            }),
310        ];
311
312        let first = run_rules(&snapshot, &config, &rules);
313        let second = run_rules(&snapshot, &config, &rules);
314
315        assert_eq!(first, second);
316        assert_eq!(
317            first.iter().map(Violation::sort_key).collect::<Vec<_>>(),
318            vec![
319                ("a/rule", "desktop", "html > alpha", 1),
320                ("a/rule", "desktop", "html > zed", 9),
321                ("z/rule", "desktop", "html", 0),
322                ("z/rule", "desktop", "html > body", 2),
323            ],
324        );
325    }
326
327    fn fixture_violation(rule_id: &str, selector: &str, dom_order: u64) -> Violation {
328        Violation {
329            rule_id: rule_id.to_owned(),
330            severity: Severity::Warning,
331            message: "test".to_owned(),
332            selector: selector.to_owned(),
333            viewport: ViewportKey::new("desktop"),
334            rect: None,
335            dom_order,
336            fix: None,
337            doc_url: format!(
338                "https://plumb.aramhammoudeh.com/rules/{}",
339                rule_id.replace('/', "-")
340            ),
341            metadata: IndexMap::new(),
342        }
343    }
344
345    #[test]
346    fn apply_ignores_passthrough_when_empty() {
347        let v = vec![
348            fixture_violation("spacing/grid-conformance", "html > body", 2),
349            fixture_violation("color/palette-conformance", "main", 5),
350        ];
351        let report = apply_ignores(v.clone(), &[]);
352        assert_eq!(report.reported, v);
353        assert!(report.ignored.is_empty());
354    }
355
356    #[test]
357    fn apply_ignores_selector_only_match_suppresses_all_rules() {
358        let v = vec![
359            fixture_violation("spacing/grid-conformance", "html > body", 2),
360            fixture_violation("color/palette-conformance", "html > body", 2),
361            fixture_violation("spacing/grid-conformance", "main", 5),
362        ];
363        let ignores = vec![IgnoreRule {
364            selector: "html > body".to_owned(),
365            rule_id: None,
366            reason: "test".to_owned(),
367        }];
368        let report = apply_ignores(v, &ignores);
369        assert_eq!(report.reported.len(), 1);
370        assert_eq!(report.reported[0].selector, "main");
371        assert_eq!(report.ignored.len(), 2);
372    }
373
374    #[test]
375    fn apply_ignores_selector_plus_rule_id_filters_one_rule_only() {
376        let v = vec![
377            fixture_violation("spacing/grid-conformance", "html > body", 2),
378            fixture_violation("color/palette-conformance", "html > body", 2),
379        ];
380        let ignores = vec![IgnoreRule {
381            selector: "html > body".to_owned(),
382            rule_id: Some("spacing/grid-conformance".to_owned()),
383            reason: "test".to_owned(),
384        }];
385        let report = apply_ignores(v, &ignores);
386        assert_eq!(report.reported.len(), 1);
387        assert_eq!(report.reported[0].rule_id, "color/palette-conformance");
388        assert_eq!(report.ignored.len(), 1);
389        assert_eq!(report.ignored[0].rule_id, "spacing/grid-conformance");
390    }
391
392    #[test]
393    fn apply_ignores_selector_mismatch_does_not_filter() {
394        let v = vec![fixture_violation(
395            "spacing/grid-conformance",
396            "html > body",
397            2,
398        )];
399        let ignores = vec![IgnoreRule {
400            selector: "html > body > div".to_owned(),
401            rule_id: None,
402            reason: "test".to_owned(),
403        }];
404        let report = apply_ignores(v.clone(), &ignores);
405        assert_eq!(report.reported, v);
406        assert!(report.ignored.is_empty());
407    }
408
409    #[test]
410    fn apply_ignores_is_deterministic_across_runs() {
411        let v = vec![
412            fixture_violation("a/rule", "html > body", 1),
413            fixture_violation("a/rule", "html > body", 2),
414            fixture_violation("b/rule", "main", 3),
415        ];
416        let ignores = vec![IgnoreRule {
417            selector: "html > body".to_owned(),
418            rule_id: None,
419            reason: "x".to_owned(),
420        }];
421        let first = apply_ignores(v.clone(), &ignores);
422        let second = apply_ignores(v, &ignores);
423        assert_eq!(first, second);
424    }
425
426    #[test]
427    fn run_report_applies_ignores_against_real_engine_output() {
428        let snapshot = PlumbSnapshot::canned();
429        // The canned snapshot has one violation: spacing/grid-conformance
430        // on `html > body` (padding-top: 13px is off-grid against base
431        // unit 4).
432        let mut config = Config::default();
433        config.ignore.push(IgnoreRule {
434            selector: "html > body".to_owned(),
435            rule_id: Some("spacing/grid-conformance".to_owned()),
436            reason: "canned snapshot exemption".to_owned(),
437        });
438        let report = run_report([&snapshot], &config);
439        assert!(report.reported.is_empty());
440        assert_eq!(report.ignored.len(), 1);
441        assert_eq!(report.ignored[0].rule_id, "spacing/grid-conformance");
442        assert_eq!(report.ignored[0].selector, "html > body");
443    }
444}