Skip to main content

plumb_core/
snapshot.rs

1//! In-memory snapshot of a rendered page at a single viewport.
2//!
3//! The real `PlumbSnapshot` is populated by `plumb-cdp` via the Chromium
4//! DevTools Protocol. For the walking skeleton and tests, a canned
5//! constructor is available behind the `test-fake` feature.
6
7use indexmap::IndexMap;
8use serde::{Deserialize, Serialize};
9
10use crate::report::{Rect, ViewportKey};
11
12/// A single post-layout text box within a node.
13///
14/// CDP returns one text box per rendered line fragment. Multi-line text
15/// generates multiple boxes for the same `dom_order`. The bounds are
16/// absolute viewport coordinates for that line fragment.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct TextBox {
19    /// `dom_order` of the owning element node.
20    pub dom_order: u64,
21    /// Absolute bounding rect of this text fragment.
22    pub bounds: Rect,
23    /// Starting character index (UTF-16 code units).
24    pub start: u32,
25    /// Character count (UTF-16 code units).
26    pub length: u32,
27}
28
29/// A single DOM node as the engine sees it.
30///
31/// This is intentionally a narrow view: just enough to identify the element
32/// and evaluate rules against its computed styles and geometry. The full
33/// DOM tree is reconstructable via the `parent`/`children` indices.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct SnapshotNode {
36    /// Stable document-order index.
37    pub dom_order: u64,
38    /// CSS selector path from the document root.
39    pub selector: String,
40    /// HTML tag name (lowercase).
41    pub tag: String,
42    /// Attributes as an ordered map — preserves parse order.
43    pub attrs: IndexMap<String, String>,
44    /// Computed styles relevant to any rule — ordered alphabetically on
45    /// insertion.
46    pub computed_styles: IndexMap<String, String>,
47    /// Bounding rect.
48    pub rect: Option<Rect>,
49    /// Parent `dom_order`, or `None` for the root.
50    pub parent: Option<u64>,
51    /// `dom_order` of direct children, in document order.
52    pub children: Vec<u64>,
53}
54
55/// The full snapshot at a single viewport.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct PlumbSnapshot {
58    /// Source URL (may be a `plumb-fake://` URL in tests).
59    pub url: String,
60    /// The viewport this snapshot was taken at.
61    pub viewport: ViewportKey,
62    /// Viewport width in CSS pixels.
63    pub viewport_width: u32,
64    /// Viewport height in CSS pixels.
65    pub viewport_height: u32,
66    /// All nodes, ordered by `dom_order`.
67    pub nodes: Vec<SnapshotNode>,
68    /// Post-layout text boxes, sorted by `(dom_order, start)` for determinism.
69    pub text_boxes: Vec<TextBox>,
70}
71
72impl PlumbSnapshot {
73    /// Build an in-memory `hello, world` snapshot. Available only in tests
74    /// and in the `plumb-fake://` CLI code path.
75    ///
76    /// The shape is intentionally minimal: one `<html>` root with two
77    /// children (`<head>`, `<body>`). Rules that run against this snapshot
78    /// should produce deterministic output.
79    #[cfg(any(test, feature = "test-fake"))]
80    #[must_use]
81    pub fn canned() -> Self {
82        let mut html_attrs = IndexMap::new();
83        html_attrs.insert("lang".into(), "en".into());
84
85        let mut body_styles = IndexMap::new();
86        // Longhands match what `getComputedStyle` returns in the real
87        // CDP driver (PRD §10.3). `padding-top: 13px` is deliberately
88        // off-grid against the default `spacing.base_unit = 4`, so the
89        // walking-skeleton smoke path produces one violation from
90        // `spacing/grid-conformance`.
91        body_styles.insert("margin-top".into(), "0".into());
92        body_styles.insert("margin-right".into(), "0".into());
93        body_styles.insert("margin-bottom".into(), "0".into());
94        body_styles.insert("margin-left".into(), "0".into());
95        body_styles.insert("padding-top".into(), "13px".into());
96        body_styles.insert("padding-right".into(), "0".into());
97        body_styles.insert("padding-bottom".into(), "0".into());
98        body_styles.insert("padding-left".into(), "0".into());
99
100        Self {
101            url: "plumb-fake://hello".into(),
102            viewport: ViewportKey::new("desktop"),
103            viewport_width: 1280,
104            viewport_height: 800,
105            text_boxes: Vec::new(),
106            nodes: vec![
107                SnapshotNode {
108                    dom_order: 0,
109                    selector: "html".into(),
110                    tag: "html".into(),
111                    attrs: html_attrs,
112                    computed_styles: IndexMap::new(),
113                    rect: Some(Rect {
114                        x: 0,
115                        y: 0,
116                        width: 1280,
117                        height: 800,
118                    }),
119                    parent: None,
120                    children: vec![1, 2],
121                },
122                SnapshotNode {
123                    dom_order: 1,
124                    selector: "html > head".into(),
125                    tag: "head".into(),
126                    attrs: IndexMap::new(),
127                    computed_styles: IndexMap::new(),
128                    rect: None,
129                    parent: Some(0),
130                    children: vec![],
131                },
132                SnapshotNode {
133                    dom_order: 2,
134                    selector: "html > body".into(),
135                    tag: "body".into(),
136                    attrs: IndexMap::new(),
137                    computed_styles: body_styles,
138                    rect: Some(Rect {
139                        x: 0,
140                        y: 0,
141                        width: 1280,
142                        height: 800,
143                    }),
144                    parent: Some(0),
145                    children: vec![],
146                },
147            ],
148        }
149    }
150}
151
152/// A borrowed view over a snapshot, handed to rules during evaluation.
153///
154/// Keeping this a distinct type (rather than handing `&PlumbSnapshot`
155/// directly) lets us extend the engine with cross-cutting context (e.g.
156/// precomputed selector indexes) without breaking the [`crate::rules::Rule`] trait.
157#[derive(Debug)]
158pub struct SnapshotCtx<'a> {
159    snapshot: &'a PlumbSnapshot,
160    viewports: Vec<ViewportKey>,
161    rects_by_dom_order: IndexMap<u64, Rect>,
162    /// Maps `dom_order` → `(start_index, count)` into `snapshot.text_boxes`.
163    text_box_ranges: IndexMap<u64, (usize, usize)>,
164}
165
166impl<'a> SnapshotCtx<'a> {
167    /// Wrap a borrowed snapshot.
168    #[must_use]
169    pub fn new(snapshot: &'a PlumbSnapshot) -> Self {
170        Self::with_viewports(snapshot, [snapshot.viewport.clone()])
171    }
172
173    /// Wrap a borrowed snapshot with the full viewport set for this run.
174    ///
175    /// The caller-provided order is preserved.
176    #[must_use]
177    pub fn with_viewports(
178        snapshot: &'a PlumbSnapshot,
179        viewports: impl IntoIterator<Item = ViewportKey>,
180    ) -> Self {
181        Self {
182            snapshot,
183            viewports: viewports.into_iter().collect(),
184            rects_by_dom_order: rect_index(snapshot),
185            text_box_ranges: text_box_index(snapshot),
186        }
187    }
188
189    /// The underlying snapshot.
190    #[must_use]
191    pub fn snapshot(&self) -> &'a PlumbSnapshot {
192        self.snapshot
193    }
194
195    /// The viewports included in the current engine run.
196    #[must_use]
197    pub fn viewports(&self) -> &[ViewportKey] {
198        &self.viewports
199    }
200
201    /// Return the bounding rect for a node by document-order index.
202    #[must_use]
203    pub fn rect_for(&self, dom_order: u64) -> Option<Rect> {
204        self.rects_by_dom_order.get(&dom_order).copied()
205    }
206
207    /// Return text boxes for a node by document-order index.
208    ///
209    /// Returns an empty slice when no text boxes exist for `dom_order`.
210    #[must_use]
211    pub fn text_boxes_for(&self, dom_order: u64) -> &[TextBox] {
212        match self.text_box_ranges.get(&dom_order) {
213            Some(&(start, count)) => &self.snapshot.text_boxes[start..start + count],
214            None => &[],
215        }
216    }
217
218    /// Iterate nodes in document order.
219    pub fn nodes(&self) -> impl Iterator<Item = &SnapshotNode> {
220        self.snapshot.nodes.iter()
221    }
222}
223
224fn rect_index(snapshot: &PlumbSnapshot) -> IndexMap<u64, Rect> {
225    snapshot
226        .nodes
227        .iter()
228        .filter_map(|node| node.rect.map(|rect| (node.dom_order, rect)))
229        .collect()
230}
231
232/// Build a `(dom_order → (start_idx, count))` index over text boxes.
233///
234/// Requires `text_boxes` to be sorted by `(dom_order, start)`.
235fn text_box_index(snapshot: &PlumbSnapshot) -> IndexMap<u64, (usize, usize)> {
236    debug_assert!(
237        snapshot
238            .text_boxes
239            .windows(2)
240            .all(|w| { (w[0].dom_order, w[0].start) <= (w[1].dom_order, w[1].start) }),
241        "text_boxes must be sorted by (dom_order, start)"
242    );
243    let mut index: IndexMap<u64, (usize, usize)> = IndexMap::new();
244    let boxes = &snapshot.text_boxes;
245    let mut i = 0;
246    while i < boxes.len() {
247        let dom_order = boxes[i].dom_order;
248        let start = i;
249        while i < boxes.len() && boxes[i].dom_order == dom_order {
250            i += 1;
251        }
252        index.insert(dom_order, (start, i - start));
253    }
254    index
255}