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}