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
187            // applied below, after each rule's local emissions are
188            // collected — rules are pure and emit with their
189            // `default_severity()`; the engine owns the remap so the
190            // CLI's exit-code logic and every formatter see a single
191            // post-override view.
192            config.rules.get(rule.id()).is_none_or(|over| over.enabled)
193        })
194        .flat_map(|rule| {
195            let mut local = Vec::new();
196            let mut sink = ViolationSink::new(&mut local);
197            rule.check(&ctx, config, &mut sink);
198            // Apply [rules."<id>"].severity, if set. Lookup is a single
199            // IndexMap probe per rule, regardless of how many
200            // violations it emitted.
201            if let Some(override_severity) =
202                config.rules.get(rule.id()).and_then(|over| over.severity)
203            {
204                for violation in &mut local {
205                    violation.severity = override_severity;
206                }
207            }
208            local
209        })
210        .collect();
211
212    // Deterministic sort.
213    buffer.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
214
215    // Dedup exact matches — different rules may independently flag the
216    // same node; keep the first occurrence in sort order.
217    buffer.dedup();
218
219    buffer
220}
221
222#[cfg(test)]
223mod tests {
224    use crate::config::{Config, IgnoreRule};
225    use crate::report::{Severity, ViewportKey, Violation, ViolationSink};
226    use crate::rules::Rule;
227    use crate::snapshot::{PlumbSnapshot, SnapshotCtx};
228    use indexmap::IndexMap;
229
230    use super::{apply_ignores, run_report, run_rules};
231
232    #[derive(Debug, Clone, Copy)]
233    struct Emission {
234        selector: &'static str,
235        dom_order: u64,
236    }
237
238    #[derive(Debug)]
239    struct OutOfOrderRule {
240        id: &'static str,
241        emissions: &'static [Emission],
242    }
243
244    impl Rule for OutOfOrderRule {
245        fn id(&self) -> &'static str {
246            self.id
247        }
248
249        fn default_severity(&self) -> Severity {
250            Severity::Warning
251        }
252
253        fn summary(&self) -> &'static str {
254            "Test-only rule that emits fixed violations."
255        }
256
257        fn check(&self, ctx: &SnapshotCtx<'_>, _config: &Config, sink: &mut ViolationSink<'_>) {
258            for emission in self.emissions {
259                sink.push(test_violation(
260                    self.id(),
261                    emission.selector,
262                    ctx.snapshot().viewport.clone(),
263                    emission.dom_order,
264                ));
265            }
266        }
267    }
268
269    fn test_violation(
270        rule_id: &str,
271        selector: &str,
272        viewport: ViewportKey,
273        dom_order: u64,
274    ) -> Violation {
275        Violation {
276            rule_id: rule_id.to_owned(),
277            severity: Severity::Warning,
278            message: "test violation".to_owned(),
279            selector: selector.to_owned(),
280            viewport,
281            rect: None,
282            dom_order,
283            fix: None,
284            doc_url: "https://plumb.aramhammoudeh.com/rules/test-only".to_owned(),
285            metadata: IndexMap::new(),
286        }
287    }
288
289    #[test]
290    fn run_rules_sorts_parallel_rule_output() {
291        const ALPHA_EMISSIONS: &[Emission] = &[
292            Emission {
293                selector: "html > zed",
294                dom_order: 9,
295            },
296            Emission {
297                selector: "html > alpha",
298                dom_order: 1,
299            },
300        ];
301        const ZED_EMISSIONS: &[Emission] = &[
302            Emission {
303                selector: "html > body",
304                dom_order: 2,
305            },
306            Emission {
307                selector: "html",
308                dom_order: 0,
309            },
310        ];
311
312        let snapshot = PlumbSnapshot::canned();
313        let config = Config::default();
314        let rules: Vec<Box<dyn Rule>> = vec![
315            Box::new(OutOfOrderRule {
316                id: "z/rule",
317                emissions: ZED_EMISSIONS,
318            }),
319            Box::new(OutOfOrderRule {
320                id: "a/rule",
321                emissions: ALPHA_EMISSIONS,
322            }),
323        ];
324
325        let first = run_rules(&snapshot, &config, &rules);
326        let second = run_rules(&snapshot, &config, &rules);
327
328        assert_eq!(first, second);
329        assert_eq!(
330            first.iter().map(Violation::sort_key).collect::<Vec<_>>(),
331            vec![
332                ("a/rule", "desktop", "html > alpha", 1),
333                ("a/rule", "desktop", "html > zed", 9),
334                ("z/rule", "desktop", "html", 0),
335                ("z/rule", "desktop", "html > body", 2),
336            ],
337        );
338    }
339
340    fn fixture_violation(rule_id: &str, selector: &str, dom_order: u64) -> Violation {
341        Violation {
342            rule_id: rule_id.to_owned(),
343            severity: Severity::Warning,
344            message: "test".to_owned(),
345            selector: selector.to_owned(),
346            viewport: ViewportKey::new("desktop"),
347            rect: None,
348            dom_order,
349            fix: None,
350            doc_url: format!(
351                "https://plumb.aramhammoudeh.com/rules/{}",
352                rule_id.replace('/', "-")
353            ),
354            metadata: IndexMap::new(),
355        }
356    }
357
358    #[test]
359    fn apply_ignores_passthrough_when_empty() {
360        let v = vec![
361            fixture_violation("spacing/grid-conformance", "html > body", 2),
362            fixture_violation("color/palette-conformance", "main", 5),
363        ];
364        let report = apply_ignores(v.clone(), &[]);
365        assert_eq!(report.reported, v);
366        assert!(report.ignored.is_empty());
367    }
368
369    #[test]
370    fn apply_ignores_selector_only_match_suppresses_all_rules() {
371        let v = vec![
372            fixture_violation("spacing/grid-conformance", "html > body", 2),
373            fixture_violation("color/palette-conformance", "html > body", 2),
374            fixture_violation("spacing/grid-conformance", "main", 5),
375        ];
376        let ignores = vec![IgnoreRule {
377            selector: "html > body".to_owned(),
378            rule_id: None,
379            reason: "test".to_owned(),
380        }];
381        let report = apply_ignores(v, &ignores);
382        assert_eq!(report.reported.len(), 1);
383        assert_eq!(report.reported[0].selector, "main");
384        assert_eq!(report.ignored.len(), 2);
385    }
386
387    #[test]
388    fn apply_ignores_selector_plus_rule_id_filters_one_rule_only() {
389        let v = vec![
390            fixture_violation("spacing/grid-conformance", "html > body", 2),
391            fixture_violation("color/palette-conformance", "html > body", 2),
392        ];
393        let ignores = vec![IgnoreRule {
394            selector: "html > body".to_owned(),
395            rule_id: Some("spacing/grid-conformance".to_owned()),
396            reason: "test".to_owned(),
397        }];
398        let report = apply_ignores(v, &ignores);
399        assert_eq!(report.reported.len(), 1);
400        assert_eq!(report.reported[0].rule_id, "color/palette-conformance");
401        assert_eq!(report.ignored.len(), 1);
402        assert_eq!(report.ignored[0].rule_id, "spacing/grid-conformance");
403    }
404
405    #[test]
406    fn apply_ignores_selector_mismatch_does_not_filter() {
407        let v = vec![fixture_violation(
408            "spacing/grid-conformance",
409            "html > body",
410            2,
411        )];
412        let ignores = vec![IgnoreRule {
413            selector: "html > body > div".to_owned(),
414            rule_id: None,
415            reason: "test".to_owned(),
416        }];
417        let report = apply_ignores(v.clone(), &ignores);
418        assert_eq!(report.reported, v);
419        assert!(report.ignored.is_empty());
420    }
421
422    #[test]
423    fn apply_ignores_is_deterministic_across_runs() {
424        let v = vec![
425            fixture_violation("a/rule", "html > body", 1),
426            fixture_violation("a/rule", "html > body", 2),
427            fixture_violation("b/rule", "main", 3),
428        ];
429        let ignores = vec![IgnoreRule {
430            selector: "html > body".to_owned(),
431            rule_id: None,
432            reason: "x".to_owned(),
433        }];
434        let first = apply_ignores(v.clone(), &ignores);
435        let second = apply_ignores(v, &ignores);
436        assert_eq!(first, second);
437    }
438
439    #[test]
440    fn run_report_applies_ignores_against_real_engine_output() {
441        let snapshot = PlumbSnapshot::canned();
442        // The canned snapshot has one violation: spacing/grid-conformance
443        // on `html > body` (padding-top: 13px is off-grid against base
444        // unit 4).
445        let mut config = Config::default();
446        config.ignore.push(IgnoreRule {
447            selector: "html > body".to_owned(),
448            rule_id: Some("spacing/grid-conformance".to_owned()),
449            reason: "canned snapshot exemption".to_owned(),
450        });
451        let report = run_report([&snapshot], &config);
452        assert!(report.reported.is_empty());
453        assert_eq!(report.ignored.len(), 1);
454        assert_eq!(report.ignored[0].rule_id, "spacing/grid-conformance");
455        assert_eq!(report.ignored[0].selector, "html > body");
456    }
457}