Skip to main content

plumb_core/
report.rs

1//! Violation reporting types — the public shape of Plumb's output.
2//!
3//! These types are serialized directly to JSON, SARIF, and the MCP-compact
4//! structured block. Every field is `#[serde(deny_unknown_fields)]` at the
5//! config boundary, but violations tolerate forward-compatible additions.
6
7use indexmap::IndexMap;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11/// How severe a violation is. Maps to CLI exit-code thresholds and to the
12/// SARIF `level` field.
13#[derive(
14    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
15)]
16#[serde(rename_all = "snake_case")]
17pub enum Severity {
18    /// Suggestion — ignored by default CI thresholds.
19    Info,
20    /// Warning — CI-configurable.
21    Warning,
22    /// Error — fails CI by default.
23    Error,
24}
25
26impl Severity {
27    /// Human-readable label used in the pretty formatter.
28    #[must_use]
29    pub const fn label(self) -> &'static str {
30        match self {
31            Self::Info => "info",
32            Self::Warning => "warning",
33            Self::Error => "error",
34        }
35    }
36}
37
38/// How confident the rule engine is that a suggested fix is safe to apply.
39/// Mirrors ESLint's suggestion/fix distinction.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
41#[serde(rename_all = "snake_case")]
42pub enum Confidence {
43    /// Automated fix would be safe.
44    High,
45    /// Fix is plausible but needs human review.
46    Medium,
47    /// Fix is speculative.
48    Low,
49}
50
51/// The kind of fix a rule proposes.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
53#[serde(tag = "kind", rename_all = "snake_case")]
54pub enum FixKind {
55    /// Replace a CSS property value.
56    CssPropertyReplace {
57        /// Property name (e.g. `padding-inline`).
58        property: String,
59        /// Current value.
60        from: String,
61        /// Proposed value.
62        to: String,
63    },
64    /// Remove a CSS property entirely.
65    CssPropertyRemove {
66        /// Property name.
67        property: String,
68    },
69    /// Wrap the current element in a new element.
70    WrapElement {
71        /// The tag to wrap with.
72        tag: String,
73    },
74    /// Insert an attribute on an element.
75    AddAttribute {
76        /// Attribute name.
77        name: String,
78        /// Attribute value.
79        value: String,
80    },
81    /// Free-form suggestion with no structured patch.
82    Description {
83        /// Human-readable guidance.
84        text: String,
85    },
86}
87
88/// A single fix proposal.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
90pub struct Fix {
91    /// The kind and payload.
92    pub kind: FixKind,
93    /// Human-readable description.
94    pub description: String,
95    /// How confident the rule is in this fix.
96    pub confidence: Confidence,
97}
98
99/// Integer pixel rectangle in the viewport's coordinate space.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
101pub struct Rect {
102    /// X offset in CSS pixels.
103    pub x: i32,
104    /// Y offset in CSS pixels.
105    pub y: i32,
106    /// Width in CSS pixels.
107    pub width: u32,
108    /// Height in CSS pixels.
109    pub height: u32,
110}
111
112/// Named viewport the snapshot was taken at. Matches the config's `viewports`
113/// map keys.
114#[derive(
115    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
116)]
117pub struct ViewportKey(pub String);
118
119impl ViewportKey {
120    /// Construct from a string slice.
121    #[must_use]
122    pub fn new(key: impl Into<String>) -> Self {
123        Self(key.into())
124    }
125
126    /// The underlying string.
127    #[must_use]
128    pub fn as_str(&self) -> &str {
129        &self.0
130    }
131}
132
133/// Deterministic run identifier. Derived from a content hash of the config
134/// and snapshot inputs — never from a clock or random source.
135#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
136pub struct RunId(pub String);
137
138impl RunId {
139    /// Construct a `RunId` from an already-computed hash string.
140    #[must_use]
141    pub fn new(hash: impl Into<String>) -> Self {
142        Self(hash.into())
143    }
144}
145
146/// A single rule violation. This is the canonical unit of Plumb's output.
147///
148/// Sort key for deterministic output is `(rule_id, viewport, selector, dom_order)`.
149///
150/// `Eq` and `Hash` aren't derived: `metadata` carries `serde_json::Value`,
151/// which is `PartialEq` only (floats). Engine dedup uses `PartialEq`.
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
153pub struct Violation {
154    /// Stable rule identifier, e.g. `spacing/hard-coded-gap`.
155    pub rule_id: String,
156    /// Severity — drives exit code.
157    pub severity: Severity,
158    /// Human-readable summary.
159    pub message: String,
160    /// CSS selector path to the offending node.
161    pub selector: String,
162    /// Which viewport the violation was detected in.
163    pub viewport: ViewportKey,
164    /// Bounding rect in viewport pixels, if applicable.
165    pub rect: Option<Rect>,
166    /// DOM document order — used as a stable tiebreaker.
167    pub dom_order: u64,
168    /// Proposed fix, if the rule has one.
169    pub fix: Option<Fix>,
170    /// Documentation URL — `plumb explain` uses this for a deep link.
171    pub doc_url: String,
172    /// Arbitrary rule-specific metadata. Must round-trip through JSON.
173    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
174    pub metadata: IndexMap<String, serde_json::Value>,
175}
176
177impl Violation {
178    /// The deterministic sort key tuple. Public so downstream tools can
179    /// compose it into their own orderings.
180    #[must_use]
181    pub fn sort_key(&self) -> (&str, &str, &str, u64) {
182        (
183            self.rule_id.as_str(),
184            self.viewport.as_str(),
185            self.selector.as_str(),
186            self.dom_order,
187        )
188    }
189}
190
191/// A bounded accumulator passed to rules during evaluation. Rules push
192/// violations here instead of allocating their own `Vec` — this gives the
193/// engine a single place to enforce per-rule budgets in the future.
194#[derive(Debug)]
195pub struct ViolationSink<'a> {
196    buffer: &'a mut Vec<Violation>,
197}
198
199impl<'a> ViolationSink<'a> {
200    /// Wrap a mutable `Vec`. The engine is the only caller.
201    #[must_use]
202    pub fn new(buffer: &'a mut Vec<Violation>) -> Self {
203        Self { buffer }
204    }
205
206    /// Record a violation.
207    pub fn push(&mut self, violation: Violation) {
208        self.buffer.push(violation);
209    }
210
211    /// How many violations have been recorded so far.
212    #[must_use]
213    pub fn len(&self) -> usize {
214        self.buffer.len()
215    }
216
217    /// Whether no violations have been recorded.
218    #[must_use]
219    pub fn is_empty(&self) -> bool {
220        self.buffer.is_empty()
221    }
222}