Skip to main content

plumb_core/rules/
mod.rs

1//! Rule engine — the [`Rule`] trait and the built-in registry.
2//!
3//! To add a new rule, see `.agents/rules/rule-engine-patterns.md`. The
4//! short version:
5//!
6//! 1. Add a module under `src/rules/` with a type implementing [`Rule`].
7//! 2. Register it in [`register_builtin`].
8//! 3. Add a golden snapshot test under `tests/`.
9//! 4. Document it at `docs/src/rules/<rule-id>.md`.
10
11pub mod a11y;
12pub mod baseline;
13pub mod color;
14pub mod edge;
15pub mod opacity;
16pub mod radius;
17pub mod shadow;
18pub mod sibling;
19pub mod spacing;
20pub mod type_;
21pub mod z;
22
23mod util;
24
25use crate::config::Config;
26use crate::report::{Severity, ViolationSink};
27use crate::snapshot::SnapshotCtx;
28
29/// Static metadata needed by output formats and rule listings.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct RuleMetadata {
32    /// Stable identifier, `<category>/<id>` (e.g. `spacing/grid-conformance`).
33    pub id: String,
34    /// One-line human-readable summary.
35    pub summary: String,
36    /// Canonical documentation URL for this rule.
37    pub doc_url: String,
38    /// Default severity if the user's config doesn't override it.
39    pub default_severity: Severity,
40}
41
42impl RuleMetadata {
43    /// Build metadata from a registered rule.
44    #[must_use]
45    pub fn from_rule(rule: &dyn Rule) -> Self {
46        Self {
47            id: rule.id().to_owned(),
48            summary: rule.summary().to_owned(),
49            doc_url: rule.doc_url(),
50            default_severity: rule.default_severity(),
51        }
52    }
53}
54
55/// A rule — the fundamental unit of work in the engine.
56///
57/// Rules are `Send + Sync` so the engine can evaluate built-in rules in
58/// parallel against one shared snapshot context. Implementations must be
59/// **pure**: given the same `ctx` and `config`, they must push the same
60/// sequence of violations into their local sink every time. Do not rely on
61/// shared mutable state, I/O, clocks, environment variables, randomness, or
62/// cross-rule ordering; each rule must be safe to run concurrently with any
63/// other rule.
64pub trait Rule: Send + Sync {
65    /// Stable identifier, `<category>/<id>` (e.g. `spacing/hard-coded-gap`).
66    fn id(&self) -> &'static str;
67
68    /// Default severity if the user's config doesn't override it.
69    fn default_severity(&self) -> Severity;
70
71    /// One-line human-readable summary. Shown in `plumb list-rules`.
72    fn summary(&self) -> &'static str;
73
74    /// Canonical documentation URL for this rule.
75    fn doc_url(&self) -> String {
76        let slug = self.id().replace('/', "-");
77        format!("https://plumb.aramhammoudeh.com/rules/{slug}")
78    }
79
80    /// Evaluate the rule against a snapshot.
81    fn check(&self, ctx: &SnapshotCtx<'_>, config: &Config, sink: &mut ViolationSink<'_>);
82}
83
84/// Return every built-in rule in registration order. Registration order
85/// is **not** part of the public contract — the engine sorts the resulting
86/// violations by `(rule_id, viewport, selector, dom_order)` before return.
87#[must_use]
88pub fn register_builtin() -> Vec<Box<dyn Rule>> {
89    vec![
90        Box::new(a11y::touch_target::TouchTarget),
91        Box::new(baseline::rhythm::Rhythm),
92        Box::new(color::contrast_aa::ContrastAa),
93        Box::new(color::palette_conformance::PaletteConformance),
94        Box::new(edge::near_alignment::NearAlignment),
95        Box::new(opacity::scale_conformance::ScaleConformance),
96        Box::new(radius::scale_conformance::ScaleConformance),
97        Box::new(shadow::scale_conformance::ScaleConformance),
98        Box::new(sibling::height_consistency::HeightConsistency),
99        Box::new(sibling::padding_consistency::PaddingConsistency),
100        Box::new(spacing::grid_conformance::GridConformance),
101        Box::new(spacing::scale_conformance::ScaleConformance),
102        Box::new(type_::family_conformance::FamilyConformance),
103        Box::new(type_::scale_conformance::ScaleConformance),
104        Box::new(type_::weight_conformance::WeightConformance),
105        Box::new(z::scale_conformance::ScaleConformance),
106    ]
107}
108
109/// Return metadata for every built-in rule, sorted by rule id.
110#[must_use]
111pub fn builtin_rule_metadata() -> Vec<RuleMetadata> {
112    let mut metadata: Vec<RuleMetadata> = register_builtin()
113        .iter()
114        .map(|rule| RuleMetadata::from_rule(rule.as_ref()))
115        .collect();
116    metadata.sort_by(|a, b| a.id.cmp(&b.id));
117    metadata
118}