Skip to main content

zenith_tx/engine/
mod.rs

1//! Transaction engine: [`run_transaction`] and all per-op application logic.
2//!
3//! This module is pure: it performs no file I/O and does not mutate the input
4//! document (it works on a clone). Dry-run vs. apply is the caller's concern.
5
6use zenith_core::{
7    Diagnostic, Dimension, Document, KdlAdapter, KdlSource, Node, Severity, Unit, validate,
8};
9
10use crate::op::{Op, Transaction};
11use crate::result::{TxError, TxResult, TxStatus};
12
13mod asset;
14mod flags;
15mod geometry;
16mod pattern;
17mod recipe;
18pub(crate) mod structure;
19mod style;
20mod token;
21
22use asset::{apply_add_asset, apply_set_asset};
23use flags::{apply_set_locked, apply_set_points, apply_set_visible};
24use geometry::{
25    GeometryDelta, apply_align_nodes, apply_align_to_edge, apply_distribute_nodes,
26    apply_set_geometry,
27};
28use pattern::apply_detach_pattern;
29use recipe::{RecipeScalars, apply_create_recipe, apply_delete_recipe, apply_update_recipe};
30use structure::{
31    ReorderKind, apply_add_node, apply_add_page, apply_delete_page, apply_duplicate_node,
32    apply_duplicate_page, apply_group, apply_remove_node, apply_reorder, apply_reorder_pages,
33    apply_reparent, apply_set_page_size, apply_ungroup,
34};
35use style::{
36    apply_find_replace_text, apply_replace_text, apply_set_fill, apply_set_opacity,
37    apply_set_stroke, apply_set_stroke_width, apply_set_style_property, apply_set_text_align,
38    apply_set_text_direction, apply_set_text_overflow,
39};
40use token::{apply_create_token, apply_update_token_value};
41
42// ── Public entry point ────────────────────────────────────────────────────────
43
44/// Apply `tx` to `doc` and return a structured [`TxResult`].
45///
46/// The function is **pure**: `doc` is never mutated (a clone is used for the
47/// candidate), and no I/O is performed. Both dry-run and apply callers receive
48/// the same result shape; the caller decides whether to persist `source_after`.
49pub fn run_transaction(doc: &Document, tx: &Transaction) -> Result<TxResult, TxError> {
50    let adapter = KdlAdapter;
51
52    // 1. Format the original document → source_before.
53    let source_before_bytes = adapter.format(doc).map_err(|e| TxError {
54        message: format!("failed to format source document: {e}"),
55    })?;
56    let source_before = String::from_utf8(source_before_bytes).map_err(|e| TxError {
57        message: format!("source_before is not valid UTF-8: {e}"),
58    })?;
59
60    // 2. Clone the document into a mutable candidate.
61    let mut candidate = doc.clone();
62
63    // 3. Apply each op in order, collecting diagnostics and affected ids.
64    let mut diagnostics: Vec<Diagnostic> = Vec::new();
65    let mut affected: Vec<String> = Vec::new(); // insertion-order, de-duplicated
66
67    for op in &tx.ops {
68        // Lock pre-check: a guarded op against a locked node is rejected unless
69        // the transaction carries `permissions.allow_locked`. The check reads the
70        // *candidate* state, so a `set_locked` earlier in the same transaction
71        // locks the node for later ops (and `set_locked` itself is exempt, so a
72        // node can always be unlocked). Targets are visited in order for
73        // determinism; if any target is locked the whole op is skipped, leaving
74        // the emitted `node.locked` error to reject the transaction in step 5.
75        if !tx.permissions.allow_locked {
76            let mut locked_hit = false;
77            for target in op_lock_targets(op) {
78                if node_is_locked(&candidate, target) {
79                    locked_hit = true;
80                    diagnostics.push(Diagnostic::error(
81                        "node.locked",
82                        format!(
83                            "node '{}' is locked; unlock it or set \
84                             permissions.allow_locked to edit it",
85                            target
86                        ),
87                        None,
88                        Some(target.to_owned()),
89                    ));
90                }
91            }
92            if locked_hit {
93                continue;
94            }
95        }
96
97        apply_op(op, &mut candidate, &mut diagnostics, &mut affected);
98    }
99
100    // 4. Post-apply validation.
101    let report = validate(&candidate);
102    diagnostics.extend(report.diagnostics);
103
104    // 5. Determine status and source_after.
105    let has_errors = diagnostics.iter().any(|d| d.severity == Severity::Error);
106    let has_warnings = diagnostics.iter().any(|d| d.severity == Severity::Warning);
107
108    let (status, source_after) = if has_errors {
109        // Rejected — discard candidate, source_after == source_before.
110        (TxStatus::Rejected, source_before.clone())
111    } else {
112        let after_bytes = adapter.format(&candidate).map_err(|e| TxError {
113            message: format!("failed to format candidate document: {e}"),
114        })?;
115        let after = String::from_utf8(after_bytes).map_err(|e| TxError {
116            message: format!("source_after is not valid UTF-8: {e}"),
117        })?;
118        let status = if has_warnings {
119            TxStatus::AcceptedWithWarnings
120        } else {
121            TxStatus::Accepted
122        };
123        (status, after)
124    };
125
126    Ok(TxResult {
127        status,
128        diagnostics,
129        source_before,
130        source_after,
131        affected_node_ids: affected,
132    })
133}
134
135// ── Per-op dispatch ───────────────────────────────────────────────────────────
136
137fn apply_op(
138    op: &Op,
139    doc: &mut Document,
140    diagnostics: &mut Vec<Diagnostic>,
141    affected: &mut Vec<String>,
142) {
143    match op {
144        Op::SetTextAlign {
145            node: node_id,
146            align,
147        } => {
148            apply_set_text_align(node_id, align, doc, diagnostics, affected);
149        }
150        Op::MoveForward { node: node_id } => {
151            apply_reorder(node_id, ReorderKind::Forward, doc, diagnostics, affected);
152        }
153        Op::MoveBackward { node: node_id } => {
154            apply_reorder(node_id, ReorderKind::Backward, doc, diagnostics, affected);
155        }
156        Op::MoveToFront { node: node_id } => {
157            apply_reorder(node_id, ReorderKind::ToFront, doc, diagnostics, affected);
158        }
159        Op::MoveToBack { node: node_id } => {
160            apply_reorder(node_id, ReorderKind::ToBack, doc, diagnostics, affected);
161        }
162        Op::SetFill {
163            node: node_id,
164            fill,
165        } => {
166            apply_set_fill(node_id, fill, doc, diagnostics, affected);
167        }
168        Op::SetStroke {
169            node: node_id,
170            stroke,
171        } => {
172            apply_set_stroke(node_id, stroke, doc, diagnostics, affected);
173        }
174        Op::SetStrokeWidth {
175            node: node_id,
176            stroke_width,
177        } => {
178            apply_set_stroke_width(node_id, stroke_width, doc, diagnostics, affected);
179        }
180        Op::SetVisible {
181            node: node_id,
182            visible,
183        } => {
184            apply_set_visible(node_id, *visible, doc, diagnostics, affected);
185        }
186        Op::SetLocked {
187            node: node_id,
188            locked,
189        } => {
190            apply_set_locked(node_id, *locked, doc, diagnostics, affected);
191        }
192        Op::SetGeometry {
193            node: node_id,
194            x,
195            y,
196            w,
197            h,
198            rotate,
199        } => {
200            apply_set_geometry(
201                node_id,
202                GeometryDelta {
203                    x: *x,
204                    y: *y,
205                    w: *w,
206                    h: *h,
207                    rotate: *rotate,
208                },
209                doc,
210                diagnostics,
211                affected,
212            );
213        }
214        Op::SetPoints {
215            node: node_id,
216            points,
217        } => {
218            apply_set_points(node_id, points, doc, diagnostics, affected);
219        }
220        Op::AddNode {
221            parent,
222            position,
223            source,
224        } => {
225            apply_add_node(parent, position, source, doc, diagnostics, affected);
226        }
227        Op::RemoveNode { node: node_id } => {
228            apply_remove_node(node_id, doc, diagnostics, affected);
229        }
230        Op::SetOpacity {
231            node: node_id,
232            opacity,
233        } => {
234            apply_set_opacity(node_id, *opacity, doc, diagnostics, affected);
235        }
236        Op::ReplaceText {
237            node: node_id,
238            spans,
239        } => {
240            apply_replace_text(node_id, spans, doc, diagnostics, affected);
241        }
242        Op::DuplicateNode {
243            node: node_id,
244            new_id,
245        } => {
246            apply_duplicate_node(node_id, new_id, doc, diagnostics, affected);
247        }
248        Op::DuplicatePage {
249            page,
250            new_id,
251            id_suffix,
252        } => {
253            apply_duplicate_page(page, new_id, id_suffix, doc, diagnostics, affected);
254        }
255        Op::Group { node_ids, group_id } => {
256            apply_group(node_ids, group_id, doc, diagnostics, affected);
257        }
258        Op::Ungroup { group_id } => {
259            apply_ungroup(group_id, doc, diagnostics, affected);
260        }
261        Op::Reparent {
262            node: node_id,
263            new_parent,
264            position,
265        } => {
266            apply_reparent(node_id, new_parent, position, doc, diagnostics, affected);
267        }
268        Op::AlignNodes {
269            node_ids,
270            align,
271            anchor,
272        } => {
273            apply_align_nodes(node_ids, align, anchor, doc, diagnostics, affected);
274        }
275        Op::SetTextOverflow { node_id, overflow } => {
276            apply_set_text_overflow(node_id, overflow, doc, diagnostics, affected);
277        }
278        Op::DistributeNodes { node_ids, axis } => {
279            apply_distribute_nodes(node_ids, axis, doc, diagnostics, affected);
280        }
281        Op::AddPage {
282            id,
283            w,
284            h,
285            background,
286            index,
287        } => {
288            let spec = structure::AddPageSpec {
289                id,
290                w,
291                h,
292                background: background.as_deref(),
293                index: *index,
294            };
295            apply_add_page(&spec, doc, diagnostics, affected);
296        }
297        Op::DeletePage { page } => {
298            apply_delete_page(page, doc, diagnostics, affected);
299        }
300        Op::ReorderPages { order } => {
301            apply_reorder_pages(order, doc, diagnostics, affected);
302        }
303        Op::AddAsset {
304            id,
305            kind,
306            src,
307            sha256,
308        } => {
309            apply_add_asset(id, kind, src, sha256.as_deref(), doc, diagnostics, affected);
310        }
311        Op::SetAsset { node_id, asset_id } => {
312            apply_set_asset(node_id, asset_id, doc, diagnostics, affected);
313        }
314        Op::CreateToken {
315            id,
316            token_type,
317            value,
318        } => {
319            apply_create_token(id, token_type, value, doc, diagnostics, affected);
320        }
321        Op::UpdateTokenValue { id, value } => {
322            apply_update_token_value(id, value, doc, diagnostics, affected);
323        }
324        Op::SetStyleProperty {
325            style_id,
326            property,
327            value,
328        } => {
329            apply_set_style_property(style_id, property, value, doc, diagnostics, affected);
330        }
331        Op::SetTextDirection { node, direction } => {
332            apply_set_text_direction(node, direction, doc, diagnostics, affected);
333        }
334        Op::FindReplaceText {
335            find,
336            replace,
337            node,
338        } => {
339            apply_find_replace_text(find, replace, node.as_deref(), doc, diagnostics, affected);
340        }
341        Op::SetPageSize { page, w, h } => {
342            apply_set_page_size(page, w, h, doc, diagnostics, affected);
343        }
344        Op::AlignToEdge { node, edge, margin } => {
345            apply_align_to_edge(node, edge, *margin, doc, diagnostics, affected);
346        }
347        Op::CreateRecipe {
348            id,
349            kind,
350            seed,
351            generator,
352            bounds,
353            detached,
354        } => {
355            apply_create_recipe(
356                RecipeScalars {
357                    id,
358                    kind,
359                    seed: *seed,
360                    generator: generator.as_deref(),
361                    bounds: bounds.as_deref(),
362                    detached: *detached,
363                },
364                doc,
365                diagnostics,
366                affected,
367            );
368        }
369        Op::UpdateRecipe {
370            id,
371            kind,
372            seed,
373            generator,
374            bounds,
375            detached,
376        } => {
377            apply_update_recipe(
378                RecipeScalars {
379                    id,
380                    kind,
381                    seed: *seed,
382                    generator: generator.as_deref(),
383                    bounds: bounds.as_deref(),
384                    detached: *detached,
385                },
386                doc,
387                diagnostics,
388                affected,
389            );
390        }
391        Op::DeleteRecipe { id } => {
392            apply_delete_recipe(id, doc, diagnostics, affected);
393        }
394        Op::DetachPattern { node: node_id } => {
395            apply_detach_pattern(node_id, doc, diagnostics, affected);
396        }
397    }
398}
399
400// ── Lock enforcement ──────────────────────────────────────────────────────────
401
402/// Return the node id(s) a *mutating* op would edit, for the lock-guarded ops
403/// only. Exempt ops return an empty `Vec`.
404///
405/// Guarded (return target id(s)): the property/geometry/text setters, removal,
406/// the z-order reorders, `reparent` (its `node`), and `align_nodes` (every id,
407/// in source order).
408///
409/// Exempt (empty): `set_locked` (must be able to *unlock* a locked node),
410/// `set_visible` (visibility is a view toggle), `add_node`, `duplicate_node`
411/// (the source is read-only), `group`, and `ungroup`.
412fn op_lock_targets(op: &Op) -> Vec<&str> {
413    match op {
414        Op::SetTextAlign { node, .. }
415        | Op::SetFill { node, .. }
416        | Op::SetStroke { node, .. }
417        | Op::SetStrokeWidth { node, .. }
418        | Op::SetGeometry { node, .. }
419        | Op::SetPoints { node, .. }
420        | Op::SetOpacity { node, .. }
421        | Op::ReplaceText { node, .. }
422        | Op::RemoveNode { node }
423        | Op::MoveForward { node }
424        | Op::MoveBackward { node }
425        | Op::MoveToFront { node }
426        | Op::MoveToBack { node }
427        | Op::Reparent { node, .. }
428        | Op::SetTextOverflow { node_id: node, .. }
429        | Op::SetTextDirection { node, .. }
430        | Op::AlignToEdge { node, .. }
431        | Op::DetachPattern { node } => vec![node.as_str()],
432        // Doc-wide mode returns empty (lock handling is inside apply_find_replace_text).
433        // Scoped mode: guard the named node.
434        Op::FindReplaceText { node, .. } => {
435            node.as_deref().map(|n| vec![n]).unwrap_or_default()
436        }
437        Op::AlignNodes { node_ids, .. } | Op::DistributeNodes { node_ids, .. } => {
438            node_ids.iter().map(String::as_str).collect()
439        }
440        Op::SetAsset { node_id, .. } => vec![node_id.as_str()],
441        Op::SetLocked { .. }
442        | Op::SetVisible { .. }
443        | Op::AddNode { .. }
444        | Op::DuplicateNode { .. }
445        | Op::DuplicatePage { .. }
446        | Op::Group { .. }
447        | Op::Ungroup { .. }
448        // Page-structure ops act on `Page` structs, which have no `locked`
449        // dimension (locking is a per-`Node` property). There is no node-level
450        // lock target to enforce here, so these are exempt (empty).
451        | Op::AddPage { .. }
452        | Op::DeletePage { .. }
453        | Op::ReorderPages { .. }
454        | Op::SetPageSize { .. }
455        // AddAsset creates new content and never mutates a node; exempt like AddNode.
456        | Op::AddAsset { .. }
457        // Token ops mutate the token block, not the node tree; no per-node lock target.
458        | Op::CreateToken { .. }
459        | Op::UpdateTokenValue { .. }
460        // Style ops mutate the styles block, not the node tree; no per-node lock target.
461        | Op::SetStyleProperty { .. }
462        // Recipe ops mutate the recipes block, not the node tree; no per-node lock target.
463        | Op::CreateRecipe { .. }
464        | Op::UpdateRecipe { .. }
465        | Op::DeleteRecipe { .. } => Vec::new(),
466    }
467}
468
469/// Return `true` if the node with `id` exists and has `locked == Some(true)`.
470///
471/// Missing nodes and nodes with `locked` absent/`Some(false)` return `false`;
472/// the missing-node case is left for the op's own `tx.unknown_node` path.
473/// Mirrors the variant coverage of [`node_locked_mut`] via a shared scan.
474fn node_is_locked(doc: &Document, id: &str) -> bool {
475    fn locked_of(node: &Node) -> Option<bool> {
476        match node {
477            Node::Rect(n) => n.locked,
478            Node::Ellipse(n) => n.locked,
479            Node::Line(n) => n.locked,
480            Node::Text(n) => n.locked,
481            Node::Code(n) => n.locked,
482            Node::Frame(n) => n.locked,
483            Node::Group(n) => n.locked,
484            Node::Image(n) => n.locked,
485            Node::Polygon(n) => n.locked,
486            Node::Polyline(n) => n.locked,
487            Node::Instance(n) => n.locked,
488            Node::Field(n) => n.locked,
489            Node::Toc(n) => n.locked,
490            Node::Table(n) => n.locked,
491            Node::Shape(n) => n.locked,
492            Node::Connector(n) => n.locked,
493            Node::Pattern(n) => n.locked,
494            Node::Chart(n) => n.locked,
495            Node::Light(n) => n.locked,
496            Node::Mesh(n) => n.locked,
497            // A footnote has no `locked` field; treat as unlocked.
498            Node::Footnote(_) => None,
499            Node::Unknown(_) => None,
500        }
501    }
502
503    doc.body
504        .pages
505        .iter()
506        .find_map(|page| find_node_shared(&page.children, id))
507        .and_then(locked_of)
508        == Some(true)
509}
510
511// ── Shared tree-walk helpers ──────────────────────────────────────────────────
512
513/// Returns true if `node` is, or transitively contains, a node with `id`.
514pub(super) fn subtree_contains(node: &Node, id: &str) -> bool {
515    if node_id_of(node) == Some(id) {
516        return true;
517    }
518    match node {
519        Node::Frame(f) => f.children.iter().any(|c| subtree_contains(c, id)),
520        Node::Group(g) => g.children.iter().any(|c| subtree_contains(c, id)),
521        Node::Table(t) => t.rows.iter().any(|r| {
522            r.cells
523                .iter()
524                .any(|c| c.children.iter().any(|ch| subtree_contains(ch, id)))
525        }),
526        Node::Unknown(u) => u.children.iter().any(|c| subtree_contains(c, id)),
527        Node::Rect(_)
528        | Node::Ellipse(_)
529        | Node::Line(_)
530        | Node::Text(_)
531        | Node::Code(_)
532        | Node::Image(_)
533        | Node::Polygon(_)
534        | Node::Polyline(_)
535        | Node::Instance(_)
536        | Node::Field(_)
537        | Node::Footnote(_)
538        | Node::Toc(_)
539        | Node::Shape(_)
540        | Node::Connector(_)
541        | Node::Pattern(_)
542        | Node::Chart(_)
543        | Node::Light(_)
544        | Node::Mesh(_) => false,
545    }
546}
547
548/// Walk the document tree and return a mutable reference to the node with
549/// the given `id`, or `None` if not found.
550///
551/// Two-phase approach: shared scan first (to find the page index), then a
552/// single targeted mutable borrow. This pattern avoids the borrow-checker
553/// conflict that would arise if we tried to return a mutable reference from
554/// within an `&mut`-iterating for loop.
555pub(super) fn find_node_any_mut<'doc>(doc: &'doc mut Document, id: &str) -> Option<&'doc mut Node> {
556    // Phase 1: find which page (shared borrow only).
557    let page_index = doc.body.pages.iter().enumerate().find_map(|(pi, page)| {
558        let found = page.children.iter().any(|n| subtree_contains(n, id));
559        if found { Some(pi) } else { None }
560    });
561
562    // Phase 2: act on the found page with an exclusive borrow.
563    match page_index {
564        None => None,
565        Some(pi) => match doc.body.pages.get_mut(pi) {
566            None => None,
567            Some(page) => find_in_children_any_mut(&mut page.children, id),
568        },
569    }
570}
571
572/// Descend into a children slice and return a mutable reference to the node
573/// with `id`. Returns `None` if the id is not present in this subtree.
574///
575/// Two-phase: shared scan to find the index, then exclusive borrow to act.
576///
577/// No recursion-depth guard (accepted v0 limit, consistent with
578/// `reorder_in` and `subtree_contains`).
579fn find_in_children_any_mut<'a>(children: &'a mut [Node], id: &str) -> Option<&'a mut Node> {
580    // Phase 1: find the index and how to reach it.
581    // `Direct(i)` — id matches children[i] itself.
582    // `Descend(i)` — id lives somewhere inside the container at children[i].
583    enum Hit {
584        Direct(usize),
585        Descend(usize),
586    }
587
588    let hit = children.iter().enumerate().find_map(|(i, node)| {
589        if node_id_of(node) == Some(id) {
590            return Some(Hit::Direct(i));
591        }
592        match node {
593            Node::Frame(f) if f.children.iter().any(|c| subtree_contains(c, id)) => {
594                Some(Hit::Descend(i))
595            }
596            Node::Group(g) if g.children.iter().any(|c| subtree_contains(c, id)) => {
597                Some(Hit::Descend(i))
598            }
599            Node::Table(t)
600                if t.rows.iter().any(|r| {
601                    r.cells
602                        .iter()
603                        .any(|c| c.children.iter().any(|ch| subtree_contains(ch, id)))
604                }) =>
605            {
606                Some(Hit::Descend(i))
607            }
608            Node::Unknown(u) if u.children.iter().any(|c| subtree_contains(c, id)) => {
609                Some(Hit::Descend(i))
610            }
611            Node::Frame(_)
612            | Node::Group(_)
613            | Node::Table(_)
614            | Node::Unknown(_)
615            | Node::Rect(_)
616            | Node::Ellipse(_)
617            | Node::Line(_)
618            | Node::Text(_)
619            | Node::Code(_)
620            | Node::Image(_)
621            | Node::Polygon(_)
622            | Node::Polyline(_)
623            | Node::Instance(_)
624            | Node::Field(_)
625            | Node::Footnote(_)
626            | Node::Toc(_)
627            | Node::Shape(_)
628            | Node::Connector(_)
629            | Node::Pattern(_)
630            | Node::Chart(_)
631            | Node::Light(_)
632            | Node::Mesh(_) => None,
633        }
634    });
635
636    // Phase 2: take the exclusive borrow we deferred.
637    match hit {
638        None => None,
639        Some(Hit::Direct(i)) => children.get_mut(i),
640        Some(Hit::Descend(i)) => match children.get_mut(i) {
641            Some(Node::Frame(f)) => find_in_children_any_mut(&mut f.children, id),
642            Some(Node::Group(g)) => find_in_children_any_mut(&mut g.children, id),
643            Some(Node::Table(t)) => {
644                for row in &mut t.rows {
645                    for cell in &mut row.cells {
646                        if let Some(found) = find_in_children_any_mut(&mut cell.children, id) {
647                            return Some(found);
648                        }
649                    }
650                }
651                None
652            }
653            Some(Node::Unknown(u)) => find_in_children_any_mut(&mut u.children, id),
654            // unreachable: phase-1 confirmed a container at i
655            Some(Node::Rect(_))
656            | Some(Node::Ellipse(_))
657            | Some(Node::Line(_))
658            | Some(Node::Text(_))
659            | Some(Node::Code(_))
660            | Some(Node::Image(_))
661            | Some(Node::Polygon(_))
662            | Some(Node::Polyline(_))
663            | Some(Node::Instance(_))
664            | Some(Node::Field(_))
665            | Some(Node::Footnote(_))
666            | Some(Node::Toc(_))
667            | Some(Node::Shape(_))
668            | Some(Node::Connector(_))
669            | Some(Node::Pattern(_))
670            | Some(Node::Chart(_))
671            | Some(Node::Light(_))
672            | Some(Node::Mesh(_))
673            | None => None,
674        },
675    }
676}
677
678/// Shared-borrow tree walk: find a node with `id` anywhere in `children`.
679pub(super) fn find_node_shared<'a>(children: &'a [Node], id: &str) -> Option<&'a Node> {
680    for node in children {
681        if node_id_of(node) == Some(id) {
682            return Some(node);
683        }
684        match node {
685            Node::Frame(f) => {
686                if let Some(found) = find_node_shared(&f.children, id) {
687                    return Some(found);
688                }
689            }
690            Node::Group(g) => {
691                if let Some(found) = find_node_shared(&g.children, id) {
692                    return Some(found);
693                }
694            }
695            Node::Table(t) => {
696                for row in &t.rows {
697                    for cell in &row.cells {
698                        if let Some(found) = find_node_shared(&cell.children, id) {
699                            return Some(found);
700                        }
701                    }
702                }
703            }
704            Node::Unknown(u) => {
705                if let Some(found) = find_node_shared(&u.children, id) {
706                    return Some(found);
707                }
708            }
709            Node::Rect(_)
710            | Node::Ellipse(_)
711            | Node::Line(_)
712            | Node::Text(_)
713            | Node::Code(_)
714            | Node::Image(_)
715            | Node::Polygon(_)
716            | Node::Polyline(_)
717            | Node::Instance(_)
718            | Node::Field(_)
719            | Node::Footnote(_)
720            | Node::Toc(_)
721            | Node::Shape(_)
722            | Node::Connector(_)
723            | Node::Pattern(_)
724            | Node::Chart(_)
725            | Node::Light(_)
726            | Node::Mesh(_) => {}
727        }
728    }
729    None
730}
731
732/// Extract the stable id string from any [`Node`] variant, if it has one.
733pub(super) fn node_id_of(node: &Node) -> Option<&str> {
734    match node {
735        Node::Rect(r) => Some(&r.id),
736        Node::Ellipse(e) => Some(&e.id),
737        Node::Line(l) => Some(&l.id),
738        Node::Text(t) => Some(&t.id),
739        Node::Code(c) => Some(&c.id),
740        Node::Frame(f) => Some(&f.id),
741        Node::Group(g) => Some(&g.id),
742        Node::Image(i) => Some(&i.id),
743        Node::Polygon(p) => Some(&p.id),
744        Node::Polyline(p) => Some(&p.id),
745        Node::Instance(i) => Some(&i.id),
746        Node::Field(f) => Some(&f.id),
747        Node::Toc(t) => Some(&t.id),
748        Node::Footnote(f) => Some(&f.id),
749        Node::Table(t) => Some(&t.id),
750        Node::Shape(s) => Some(&s.id),
751        Node::Connector(c) => Some(&c.id),
752        Node::Pattern(p) => Some(&p.id),
753        Node::Chart(c) => Some(&c.id),
754        Node::Light(l) => Some(&l.id),
755        Node::Mesh(m) => Some(&m.id),
756        Node::Unknown(u) => u.id.as_deref(),
757    }
758}
759
760// ── Node-kind string ──────────────────────────────────────────────────────────
761
762/// Return a static string naming the variant kind of a [`Node`].
763pub(super) fn node_kind_str(node: &Node) -> &'static str {
764    match node {
765        Node::Rect(_) => "rect",
766        Node::Ellipse(_) => "ellipse",
767        Node::Line(_) => "line",
768        Node::Text(_) => "text",
769        Node::Code(_) => "code",
770        Node::Frame(_) => "frame",
771        Node::Group(_) => "group",
772        Node::Image(_) => "image",
773        Node::Polygon(_) => "polygon",
774        Node::Polyline(_) => "polyline",
775        Node::Instance(_) => "instance",
776        Node::Field(_) => "field",
777        Node::Toc(_) => "toc",
778        Node::Footnote(_) => "footnote",
779        Node::Table(_) => "table",
780        Node::Shape(_) => "shape",
781        Node::Connector(_) => "connector",
782        Node::Pattern(_) => "pattern",
783        Node::Chart(_) => "chart",
784        Node::Light(_) => "light",
785        Node::Mesh(_) => "mesh",
786        Node::Unknown(_) => "unknown",
787    }
788}
789
790/// Construct a [`Dimension`] with the `(px)` unit from a raw `f64` value.
791pub(super) fn px(v: f64) -> Dimension {
792    Dimension {
793        value: v,
794        unit: Unit::Px,
795    }
796}
797
798// ── Utility ───────────────────────────────────────────────────────────────────
799
800/// Append `id` to `affected` only if it is not already present.
801/// Uses a linear scan to maintain deterministic first-seen insertion order
802/// without HashMap (which has non-deterministic iteration).
803pub(super) fn record_affected(id: &str, affected: &mut Vec<String>) {
804    if !affected.iter().any(|s| s == id) {
805        affected.push(id.to_owned());
806    }
807}