Skip to main content

zenith_cli/commands/inspect/
document.rs

1//! Document-level inspect logic for `zenith inspect`.
2//!
3//! The public entry point [`run`] operates entirely on in-memory source text;
4//! the caller is responsible for all filesystem I/O.
5//!
6//! The tree-building pass is decoupled from printing so it can be tested
7//! directly: [`build_doc_tree`] / [`find_node_tree`] return [`PageEntry`] /
8//! [`NodeEntry`] values that serialise to JSON and render to human-readable
9//! format.
10
11use std::collections::BTreeMap;
12
13use zenith_core::{
14    Dimension, FrameNode, GroupNode, KdlAdapter, KdlSource, Node, Page, PropertyValue,
15    ResolvedToken, ResolvedValue, Unit, resolve_tokens,
16};
17
18use crate::commands::serialize_pretty;
19use crate::json_types::RecipeInspectJson;
20
21use super::recipes;
22
23// ── Error type ────────────────────────────────────────────────────────────────
24
25/// Error produced by the inspect command.
26#[derive(Debug)]
27pub struct InspectCmdErr {
28    /// Human-readable message.
29    pub message: String,
30    /// Recommended exit code.
31    pub exit_code: u8,
32}
33
34impl InspectCmdErr {
35    fn new(msg: impl Into<String>, exit_code: u8) -> Self {
36        Self {
37            message: msg.into(),
38            exit_code,
39        }
40    }
41}
42
43// ── Tree representation ───────────────────────────────────────────────────────
44
45/// The geometry summary emitted per node.  Missing fields are `None` when the
46/// node kind does not carry that property (e.g. `polygon` has no bbox).
47#[derive(Debug, Clone, serde::Serialize)]
48pub struct NodeGeometry {
49    /// Left edge (px) for bbox nodes.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub x: Option<f64>,
52    /// Top edge (px) for bbox nodes.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub y: Option<f64>,
55    /// Width (px) for bbox nodes.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub w: Option<f64>,
58    /// Height (px) for bbox nodes.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub h: Option<f64>,
61    /// First endpoint x (px) for `line`.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub x1: Option<f64>,
64    /// First endpoint y (px) for `line`.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub y1: Option<f64>,
67    /// Second endpoint x (px) for `line`.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub x2: Option<f64>,
70    /// Second endpoint y (px) for `line`.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub y2: Option<f64>,
73    /// Point count for `polygon`/`polyline`.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub point_count: Option<usize>,
76}
77
78/// A single node in the inspect tree.
79#[derive(Debug, Clone, serde::Serialize)]
80pub struct NodeEntry {
81    pub id: String,
82    pub kind: String,
83    /// The node's `role` attribute, when authored. Surfacing it lets consumers
84    /// group same-role nodes (e.g. every `role="heading"`) and reason about
85    /// cross-page consistency without re-parsing the source.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub role: Option<String>,
88    pub geometry: Option<NodeGeometry>,
89    pub visible: Option<bool>,
90    pub locked: Option<bool>,
91    pub children: Vec<NodeEntry>,
92}
93
94/// The resolved token table used to turn `(token)"id"` dimension refs into px
95/// values. Built once per `inspect` run from the document's `tokens` block.
96type Resolved = BTreeMap<String, ResolvedToken>;
97
98/// A page in the inspect tree.
99#[derive(Debug, Clone, serde::Serialize)]
100pub struct PageEntry {
101    pub id: String,
102    pub name: Option<String>,
103    pub width: f64,
104    pub height: f64,
105    pub children: Vec<NodeEntry>,
106}
107
108/// The top-level JSON envelope for `inspect`.
109#[derive(Debug, serde::Serialize)]
110pub struct InspectOutput {
111    pub schema: &'static str,
112    pub pages: Vec<PageEntry>,
113    /// Empty when the document has no `recipes` block.
114    pub recipes: Vec<RecipeInspectJson>,
115}
116
117/// The subtree rooted at a single found node (used for `--node <ID>`).
118#[derive(Debug, serde::Serialize)]
119pub struct InspectNodeOutput {
120    pub schema: &'static str,
121    pub node: NodeEntry,
122}
123
124// ── Public entry point ────────────────────────────────────────────────────────
125
126/// Run `zenith inspect`.
127///
128/// - `src`      — raw `.zen` source text.
129/// - `node_id`  — when `Some`, restrict output to the subtree rooted at that id.
130/// - `json`     — emit JSON instead of the human-readable tree.
131///
132/// Returns a formatted string on success, or an [`InspectCmdErr`] on parse
133/// error, not-found error, etc.
134pub fn run(src: &str, node_id: Option<&str>, json: bool) -> Result<String, InspectCmdErr> {
135    // Parse ─────────────────────────────────────────────────────────────────
136    let doc = KdlAdapter
137        .parse(src.as_bytes())
138        .map_err(|e| InspectCmdErr::new(format!("error[parse.error]: {}", e.message), 2))?;
139
140    let resolved = resolve_tokens(&doc.tokens).resolved;
141
142    if let Some(id) = node_id {
143        // --node <ID>: find the subtree rooted at that node.
144        let entry = find_node_tree(&doc.body.pages, id, &resolved)
145            .ok_or_else(|| InspectCmdErr::new(format!("error: node '{}' not found", id), 2))?;
146
147        let out = if json {
148            let output = InspectNodeOutput {
149                schema: "zenith-inspect-v1",
150                node: entry,
151            };
152            serialize_pretty(&output)
153        } else {
154            render_node_human(&entry, 0).trim_end().to_owned()
155        };
156        Ok(out)
157    } else {
158        // Whole document.
159        let pages = build_doc_tree(&doc.body.pages, &resolved);
160
161        let out = if json {
162            let recipe_entries = recipes::build_recipe_entries(&doc.recipes);
163            let output = InspectOutput {
164                schema: "zenith-inspect-v1",
165                pages,
166                recipes: recipe_entries,
167            };
168            serialize_pretty(&output)
169        } else {
170            let mut text = render_pages_human(&pages);
171            let recipe_section = recipes::render_recipes_human(&doc.recipes);
172            if !recipe_section.is_empty() {
173                text.push('\n');
174                text.push('\n');
175                text.push_str(&recipe_section);
176            }
177            text
178        };
179        Ok(out)
180    }
181}
182
183// ── Token-efficient summary (MCP) ───────────────────────────────────────────────
184
185/// Build a token-minimal structured summary of a document's node tree.
186///
187/// This is the shape the MCP `zenith_inspect` tool returns: instead of the full
188/// recursive tree with geometry on every node, it returns a *shallow* view.
189///
190/// - `node`   — when `Some`, summarise only the subtree rooted at that id.
191/// - `depth`  — how many node levels below each page (or below `node`) to expand.
192///   Deeper children collapse to a `childCount`. `0` shows only the top level.
193/// - `detail` — when `true`, re-include `geometry`/`visible`/`locked` per node.
194///
195/// Returns a [`serde_json::Value`] ready to embed as the tool's structured
196/// result; the caller decides inline-vs-offload by serialized size.
197pub fn summary(
198    src: &str,
199    node: Option<&str>,
200    depth: usize,
201    detail: bool,
202) -> Result<serde_json::Value, InspectCmdErr> {
203    let doc = KdlAdapter
204        .parse(src.as_bytes())
205        .map_err(|e| InspectCmdErr::new(format!("error[parse.error]: {}", e.message), 2))?;
206
207    let resolved = resolve_tokens(&doc.tokens).resolved;
208
209    if let Some(id) = node {
210        let entry = find_node_tree(&doc.body.pages, id, &resolved)
211            .ok_or_else(|| InspectCmdErr::new(format!("error: node '{id}' not found"), 2))?;
212        Ok(serde_json::json!({
213            "schema": "zenith-inspect-summary-v1",
214            "node": trim_node(&entry, depth, detail),
215        }))
216    } else {
217        let pages = build_doc_tree(&doc.body.pages, &resolved);
218        let page_values: Vec<serde_json::Value> =
219            pages.iter().map(|p| trim_page(p, depth, detail)).collect();
220        Ok(serde_json::json!({
221            "schema": "zenith-inspect-summary-v1",
222            "pages": page_values,
223            "recipe_count": doc.recipes.len(),
224        }))
225    }
226}
227
228/// Trim a [`PageEntry`] to the shallow summary shape.
229fn trim_page(p: &PageEntry, depth: usize, detail: bool) -> serde_json::Value {
230    let mut obj = serde_json::Map::new();
231    obj.insert("id".into(), p.id.clone().into());
232    if let Some(name) = &p.name {
233        obj.insert("name".into(), name.clone().into());
234    }
235    obj.insert("width".into(), p.width.into());
236    obj.insert("height".into(), p.height.into());
237    insert_children(&mut obj, &p.children, depth, detail);
238    serde_json::Value::Object(obj)
239}
240
241/// Trim a [`NodeEntry`] to the shallow summary shape, recursing `depth` levels.
242fn trim_node(n: &NodeEntry, depth: usize, detail: bool) -> serde_json::Value {
243    let mut obj = serde_json::Map::new();
244    obj.insert("id".into(), n.id.clone().into());
245    obj.insert("kind".into(), n.kind.clone().into());
246    if detail {
247        if let Some(role) = &n.role {
248            obj.insert("role".into(), role.clone().into());
249        }
250        if let Some(g) = &n.geometry {
251            obj.insert(
252                "geometry".into(),
253                serde_json::to_value(g).unwrap_or(serde_json::Value::Null),
254            );
255        }
256        if let Some(v) = n.visible {
257            obj.insert("visible".into(), v.into());
258        }
259        if let Some(l) = n.locked {
260            obj.insert("locked".into(), l.into());
261        }
262    }
263    insert_children(&mut obj, &n.children, depth, detail);
264    serde_json::Value::Object(obj)
265}
266
267/// Insert either an expanded `children` array (when `depth > 0`) or a collapsed
268/// `child_count` (when `depth == 0`), omitting both when there are no children.
269fn insert_children(
270    obj: &mut serde_json::Map<String, serde_json::Value>,
271    children: &[NodeEntry],
272    depth: usize,
273    detail: bool,
274) {
275    if children.is_empty() {
276        return;
277    }
278    if depth == 0 {
279        obj.insert("child_count".into(), children.len().into());
280    } else {
281        let kids: Vec<serde_json::Value> = children
282            .iter()
283            .map(|c| trim_node(c, depth - 1, detail))
284            .collect();
285        obj.insert("children".into(), serde_json::Value::Array(kids));
286    }
287}
288
289// ── Tree builders ─────────────────────────────────────────────────────────────
290
291/// Build the full page tree for all pages in the document (in order).
292///
293/// `resolved` is the document's resolved token table; it turns `(token)"id"`
294/// dimension refs into px values in each node's geometry.
295pub fn build_doc_tree(pages: &[Page], resolved: &Resolved) -> Vec<PageEntry> {
296    pages
297        .iter()
298        .map(|p| build_page_entry(p, resolved))
299        .collect()
300}
301
302fn build_page_entry(page: &Page, resolved: &Resolved) -> PageEntry {
303    PageEntry {
304        id: page.id.clone(),
305        name: page.name.clone(),
306        width: dim_to_f64(&page.width),
307        height: dim_to_f64(&page.height),
308        children: page
309            .children
310            .iter()
311            .map(|n| build_node_entry(n, resolved))
312            .collect(),
313    }
314}
315
316fn build_node_entry(node: &Node, resolved: &Resolved) -> NodeEntry {
317    match node {
318        Node::Rect(n) => NodeEntry {
319            id: n.id.clone(),
320            kind: "rect".into(),
321            role: n.role.clone(),
322            geometry: bbox_geom(
323                n.x.as_ref(),
324                n.y.as_ref(),
325                n.w.as_ref(),
326                n.h.as_ref(),
327                resolved,
328            ),
329            visible: n.visible,
330            locked: n.locked,
331            children: vec![],
332        },
333        Node::Ellipse(n) => NodeEntry {
334            id: n.id.clone(),
335            kind: "ellipse".into(),
336            role: n.role.clone(),
337            geometry: bbox_geom(
338                n.x.as_ref(),
339                n.y.as_ref(),
340                n.w.as_ref(),
341                n.h.as_ref(),
342                resolved,
343            ),
344            visible: n.visible,
345            locked: n.locked,
346            children: vec![],
347        },
348        Node::Line(n) => NodeEntry {
349            id: n.id.clone(),
350            kind: "line".into(),
351            role: n.role.clone(),
352            geometry: Some(NodeGeometry {
353                x: None,
354                y: None,
355                w: None,
356                h: None,
357                x1: n.x1.as_ref().map(dim_to_f64),
358                y1: n.y1.as_ref().map(dim_to_f64),
359                x2: n.x2.as_ref().map(dim_to_f64),
360                y2: n.y2.as_ref().map(dim_to_f64),
361                point_count: None,
362            }),
363            visible: n.visible,
364            locked: n.locked,
365            children: vec![],
366        },
367        Node::Text(n) => NodeEntry {
368            id: n.id.clone(),
369            kind: "text".into(),
370            role: n.role.clone(),
371            geometry: bbox_geom(
372                n.x.as_ref(),
373                n.y.as_ref(),
374                n.w.as_ref(),
375                n.h.as_ref(),
376                resolved,
377            ),
378            visible: n.visible,
379            locked: n.locked,
380            children: vec![],
381        },
382        Node::Code(n) => NodeEntry {
383            id: n.id.clone(),
384            kind: "code".into(),
385            role: n.role.clone(),
386            geometry: bbox_geom(
387                n.x.as_ref(),
388                n.y.as_ref(),
389                n.w.as_ref(),
390                n.h.as_ref(),
391                resolved,
392            ),
393            visible: n.visible,
394            locked: n.locked,
395            children: vec![],
396        },
397        Node::Image(n) => NodeEntry {
398            id: n.id.clone(),
399            kind: "image".into(),
400            role: n.role.clone(),
401            geometry: bbox_geom(
402                n.x.as_ref(),
403                n.y.as_ref(),
404                n.w.as_ref(),
405                n.h.as_ref(),
406                resolved,
407            ),
408            visible: n.visible,
409            locked: n.locked,
410            children: vec![],
411        },
412        Node::Frame(n) => NodeEntry {
413            id: n.id.clone(),
414            kind: "frame".into(),
415            role: n.role.clone(),
416            geometry: bbox_geom(
417                n.x.as_ref(),
418                n.y.as_ref(),
419                n.w.as_ref(),
420                n.h.as_ref(),
421                resolved,
422            ),
423            visible: n.visible,
424            locked: n.locked,
425            children: n
426                .children
427                .iter()
428                .map(|c| build_node_entry(c, resolved))
429                .collect(),
430        },
431        Node::Group(n) => NodeEntry {
432            id: n.id.clone(),
433            kind: "group".into(),
434            role: n.role.clone(),
435            geometry: bbox_geom(
436                n.x.as_ref(),
437                n.y.as_ref(),
438                n.w.as_ref(),
439                n.h.as_ref(),
440                resolved,
441            ),
442            visible: n.visible,
443            locked: n.locked,
444            children: n
445                .children
446                .iter()
447                .map(|c| build_node_entry(c, resolved))
448                .collect(),
449        },
450        Node::Polygon(n) => NodeEntry {
451            id: n.id.clone(),
452            kind: "polygon".into(),
453            role: n.role.clone(),
454            geometry: Some(NodeGeometry {
455                x: None,
456                y: None,
457                w: None,
458                h: None,
459                x1: None,
460                y1: None,
461                x2: None,
462                y2: None,
463                point_count: Some(n.points.len()),
464            }),
465            visible: n.visible,
466            locked: n.locked,
467            children: vec![],
468        },
469        Node::Polyline(n) => NodeEntry {
470            id: n.id.clone(),
471            kind: "polyline".into(),
472            role: n.role.clone(),
473            geometry: Some(NodeGeometry {
474                x: None,
475                y: None,
476                w: None,
477                h: None,
478                x1: None,
479                y1: None,
480                x2: None,
481                y2: None,
482                point_count: Some(n.points.len()),
483            }),
484            visible: n.visible,
485            locked: n.locked,
486            children: vec![],
487        },
488        Node::Instance(n) => NodeEntry {
489            id: n.id.clone(),
490            kind: "instance".into(),
491            role: n.role.clone(),
492            // An instance carries only an x/y origin (no w/h box); its x/y stay
493            // raw `Dimension` (not token-ref geometry), so report them directly.
494            geometry: Some(NodeGeometry {
495                x: opt_dim_to_f64(n.x.as_ref()),
496                y: opt_dim_to_f64(n.y.as_ref()),
497                w: None,
498                h: None,
499                x1: None,
500                y1: None,
501                x2: None,
502                y2: None,
503                point_count: None,
504            }),
505            visible: n.visible,
506            locked: n.locked,
507            children: vec![],
508        },
509        Node::Field(n) => NodeEntry {
510            id: n.id.clone(),
511            kind: "field".into(),
512            role: n.role.clone(),
513            // A field carries an x/y/w/h box (any of which may be omitted, in
514            // which case it defaults to the page live area at compile time).
515            geometry: bbox_geom(
516                n.x.as_ref(),
517                n.y.as_ref(),
518                n.w.as_ref(),
519                n.h.as_ref(),
520                resolved,
521            ),
522            visible: n.visible,
523            locked: n.locked,
524            children: vec![],
525        },
526        Node::Toc(n) => NodeEntry {
527            id: n.id.clone(),
528            kind: "toc".into(),
529            role: n.role.clone(),
530            // A toc carries a real x/y/w/h box (it must declare its own
531            // geometry for correct positioning).
532            geometry: bbox_geom(
533                n.x.as_ref(),
534                n.y.as_ref(),
535                n.w.as_ref(),
536                n.h.as_ref(),
537                resolved,
538            ),
539            visible: n.visible,
540            locked: n.locked,
541            children: vec![],
542        },
543        Node::Footnote(n) => NodeEntry {
544            id: n.id.clone(),
545            kind: "footnote".into(),
546            role: n.role.clone(),
547            // A footnote has NO geometry (the renderer positions it in the
548            // bottom zone); report no geometry, visible, or locked.
549            geometry: None,
550            visible: None,
551            locked: None,
552            children: vec![],
553        },
554        Node::Table(n) => NodeEntry {
555            id: n.id.clone(),
556            kind: "table".into(),
557            role: n.role.clone(),
558            geometry: bbox_geom(
559                n.x.as_ref(),
560                n.y.as_ref(),
561                n.w.as_ref(),
562                n.h.as_ref(),
563                resolved,
564            ),
565            visible: n.visible,
566            locked: n.locked,
567            // Report each cell's child nodes (flattened in row→cell order) so a
568            // table's content is visible in the inspect tree.
569            children: n
570                .rows
571                .iter()
572                .flat_map(|row| row.cells.iter())
573                .flat_map(|cell| cell.children.iter())
574                .map(|c| build_node_entry(c, resolved))
575                .collect(),
576        },
577        Node::Shape(n) => NodeEntry {
578            id: n.id.clone(),
579            kind: "shape".into(),
580            role: n.role.clone(),
581            geometry: bbox_geom(
582                n.x.as_ref(),
583                n.y.as_ref(),
584                n.w.as_ref(),
585                n.h.as_ref(),
586                resolved,
587            ),
588            visible: n.visible,
589            locked: n.locked,
590            // A shape owns label spans (TextSpans), not child Nodes, so it has
591            // no child entries in the inspect tree.
592            children: vec![],
593        },
594        Node::Connector(n) => NodeEntry {
595            id: n.id.clone(),
596            kind: "connector".into(),
597            role: n.role.clone(),
598            // A connector has no authored bbox — its endpoints are derived from
599            // its targets' boxes at compile time.
600            geometry: None,
601            visible: n.visible,
602            locked: n.locked,
603            children: vec![],
604        },
605        Node::Pattern(n) => NodeEntry {
606            id: n.id.clone(),
607            kind: "pattern".into(),
608            role: n.role.clone(),
609            geometry: bbox_geom(
610                n.x.as_ref(),
611                n.y.as_ref(),
612                n.w.as_ref(),
613                n.h.as_ref(),
614                resolved,
615            ),
616            visible: n.visible,
617            locked: n.locked,
618            children: vec![],
619        },
620        Node::Chart(n) => NodeEntry {
621            id: n.id.clone(),
622            kind: "chart".into(),
623            role: n.role.clone(),
624            geometry: bbox_geom(
625                n.x.as_ref(),
626                n.y.as_ref(),
627                n.w.as_ref(),
628                n.h.as_ref(),
629                resolved,
630            ),
631            visible: n.visible,
632            locked: n.locked,
633            children: vec![],
634        },
635        Node::Light(n) => NodeEntry {
636            id: n.id.clone(),
637            kind: "light".into(),
638            role: n.role.clone(),
639            geometry: light_geom(n, resolved),
640            visible: n.visible,
641            locked: n.locked,
642            children: vec![],
643        },
644        Node::Mesh(n) => NodeEntry {
645            id: n.id.clone(),
646            kind: "mesh".into(),
647            role: n.role.clone(),
648            geometry: bbox_geom(
649                n.x.as_ref(),
650                n.y.as_ref(),
651                n.w.as_ref(),
652                n.h.as_ref(),
653                resolved,
654            ),
655            visible: n.visible,
656            locked: n.locked,
657            children: vec![],
658        },
659        Node::Unknown(n) => NodeEntry {
660            id: n.id.clone().unwrap_or_default(),
661            kind: n.kind.clone(),
662            // An unknown (library) node kind carries no typed `role` field.
663            role: None,
664            geometry: None,
665            visible: None,
666            locked: None,
667            children: n
668                .children
669                .iter()
670                .map(|c| build_node_entry(c, resolved))
671                .collect(),
672        },
673    }
674}
675
676// ── Node finder ───────────────────────────────────────────────────────────────
677
678/// Search all pages (depth-first, in source order) for a node with the given
679/// id.  Returns a fully-built [`NodeEntry`] subtree when found.
680pub fn find_node_tree(pages: &[Page], id: &str, resolved: &Resolved) -> Option<NodeEntry> {
681    for page in pages {
682        if let Some(entry) = search_nodes(&page.children, id, resolved) {
683            return Some(entry);
684        }
685    }
686    None
687}
688
689fn search_nodes(nodes: &[Node], id: &str, resolved: &Resolved) -> Option<NodeEntry> {
690    for node in nodes {
691        // Check if this node matches.
692        let node_id = node_id_str(node);
693        if node_id == id {
694            return Some(build_node_entry(node, resolved));
695        }
696        // Recurse into Frame/Group/Unknown children via node_children.
697        if let Some(children) = node_children(node)
698            && let Some(found) = search_nodes(children, id, resolved)
699        {
700            return Some(found);
701        }
702        // Recurse into table cell children (node_children returns None for Table).
703        if let Node::Table(t) = node {
704            for row in &t.rows {
705                for cell in &row.cells {
706                    if let Some(found) = search_nodes(&cell.children, id, resolved) {
707                        return Some(found);
708                    }
709                }
710            }
711        }
712    }
713    None
714}
715
716/// Return the `id` field of a node as a `&str`.
717fn node_id_str(node: &Node) -> &str {
718    match node {
719        Node::Rect(n) => &n.id,
720        Node::Ellipse(n) => &n.id,
721        Node::Line(n) => &n.id,
722        Node::Text(n) => &n.id,
723        Node::Code(n) => &n.id,
724        Node::Frame(n) => &n.id,
725        Node::Group(n) => &n.id,
726        Node::Image(n) => &n.id,
727        Node::Polygon(n) => &n.id,
728        Node::Polyline(n) => &n.id,
729        Node::Instance(n) => &n.id,
730        Node::Field(n) => &n.id,
731        Node::Toc(n) => &n.id,
732        Node::Footnote(n) => &n.id,
733        Node::Table(n) => &n.id,
734        Node::Shape(n) => &n.id,
735        Node::Connector(n) => &n.id,
736        Node::Pattern(n) => &n.id,
737        Node::Chart(n) => &n.id,
738        Node::Light(n) => &n.id,
739        Node::Mesh(n) => &n.id,
740        Node::Unknown(n) => n.id.as_deref().unwrap_or(""),
741    }
742}
743
744/// Return a reference to a container node's children slice, or `None` for leaf
745/// nodes.
746fn node_children(node: &Node) -> Option<&[Node]> {
747    match node {
748        Node::Frame(FrameNode { children, .. }) | Node::Group(GroupNode { children, .. }) => {
749            Some(children)
750        }
751        Node::Unknown(n) => Some(&n.children),
752        Node::Rect(_)
753        | Node::Ellipse(_)
754        | Node::Line(_)
755        | Node::Text(_)
756        | Node::Code(_)
757        | Node::Image(_)
758        | Node::Polygon(_)
759        | Node::Polyline(_)
760        | Node::Instance(_)
761        | Node::Field(_)
762        | Node::Footnote(_)
763        | Node::Toc(_)
764        | Node::Table(_)
765        | Node::Shape(_)
766        | Node::Connector(_)
767        | Node::Pattern(_)
768        | Node::Chart(_)
769        | Node::Light(_)
770        | Node::Mesh(_) => None,
771    }
772}
773
774// ── Geometry helpers ──────────────────────────────────────────────────────────
775
776fn dim_to_f64(d: &Dimension) -> f64 {
777    match d.unit {
778        Unit::Pt => d.value * 96.0 / 72.0,
779        Unit::Px | Unit::Pct | Unit::Deg | Unit::Unknown(_) => d.value,
780    }
781}
782
783fn opt_dim_to_f64(d: Option<&Dimension>) -> Option<f64> {
784    d.map(dim_to_f64)
785}
786
787/// A geometry property is `(px)N` literal OR `(token)"id"` dimension ref.
788/// Inspect reports the resolved px value: a literal yields its own value; a
789/// token ref is resolved against the document's token table. A ref that is
790/// missing, cyclic, or not a dimension token has no px value, so it shows as
791/// `None` (the field is omitted from the JSON).
792fn opt_pv_to_f64(pv: Option<&PropertyValue>, resolved: &Resolved) -> Option<f64> {
793    match pv? {
794        PropertyValue::Dimension(d) => Some(dim_to_f64(d)),
795        PropertyValue::TokenRef(id) => match resolved.get(id).map(|t| &t.value) {
796            Some(ResolvedValue::Dimension(d)) => Some(dim_to_f64(d)),
797            _ => None,
798        },
799        PropertyValue::Literal(_) | PropertyValue::DataRef(_) => None,
800    }
801}
802
803fn bbox_geom(
804    x: Option<&PropertyValue>,
805    y: Option<&PropertyValue>,
806    w: Option<&PropertyValue>,
807    h: Option<&PropertyValue>,
808    resolved: &Resolved,
809) -> Option<NodeGeometry> {
810    Some(NodeGeometry {
811        x: opt_pv_to_f64(x, resolved),
812        y: opt_pv_to_f64(y, resolved),
813        w: opt_pv_to_f64(w, resolved),
814        h: opt_pv_to_f64(h, resolved),
815        x1: None,
816        y1: None,
817        x2: None,
818        y2: None,
819        point_count: None,
820    })
821}
822
823fn light_geom(n: &zenith_core::LightNode, resolved: &Resolved) -> Option<NodeGeometry> {
824    let x = opt_pv_to_f64(n.x.as_ref(), resolved)?;
825    let y = opt_pv_to_f64(n.y.as_ref(), resolved)?;
826    let radius = opt_pv_to_f64(n.radius.as_ref(), resolved)?;
827    Some(NodeGeometry {
828        x: Some(x - radius),
829        y: Some(y - radius),
830        w: Some(radius * 2.0),
831        h: Some(radius * 2.0),
832        x1: None,
833        y1: None,
834        x2: None,
835        y2: None,
836        point_count: None,
837    })
838}
839
840// ── Human rendering ───────────────────────────────────────────────────────────
841
842fn render_pages_human(pages: &[PageEntry]) -> String {
843    let mut out = String::new();
844    for page in pages {
845        let name_part = page
846            .name
847            .as_deref()
848            .map(|n| format!(" \"{}\"", n))
849            .unwrap_or_default();
850        out.push_str(&format!(
851            "page {}{} ({}x{})\n",
852            page.id, name_part, page.width, page.height
853        ));
854        for child in &page.children {
855            out.push_str(&render_node_human(child, 1));
856        }
857    }
858    out.trim_end().to_owned()
859}
860
861/// Render a single node (and its subtree) at the given indent depth.
862/// Called by both the whole-document path and the `--node` subtree path.
863fn render_node_human(node: &NodeEntry, depth: usize) -> String {
864    let indent = "  ".repeat(depth);
865    let geom = render_geom_summary(node);
866    let flags = render_flags(node);
867    let suffix = [geom, flags]
868        .into_iter()
869        .filter(|s| !s.is_empty())
870        .collect::<Vec<_>>()
871        .join(" ");
872    let suffix_part = if suffix.is_empty() {
873        String::new()
874    } else {
875        format!("  {}", suffix)
876    };
877
878    let mut out = format!("{}{} {}{}\n", indent, node.kind, node.id, suffix_part);
879    for child in &node.children {
880        out.push_str(&render_node_human(child, depth + 1));
881    }
882    out
883}
884
885fn render_geom_summary(node: &NodeEntry) -> String {
886    let Some(ref g) = node.geometry else {
887        return String::new();
888    };
889
890    // bbox summary: x,y WxH
891    if g.x.is_some() || g.y.is_some() || g.w.is_some() || g.h.is_some() {
892        let x = g.x.unwrap_or(0.0);
893        let y = g.y.unwrap_or(0.0);
894        let w = g.w.unwrap_or(0.0);
895        let h = g.h.unwrap_or(0.0);
896        return format!(
897            "{},{} {}x{}",
898            fmt_f64(x),
899            fmt_f64(y),
900            fmt_f64(w),
901            fmt_f64(h)
902        );
903    }
904
905    // line endpoint summary
906    if g.x1.is_some() || g.y1.is_some() || g.x2.is_some() || g.y2.is_some() {
907        let x1 = g.x1.unwrap_or(0.0);
908        let y1 = g.y1.unwrap_or(0.0);
909        let x2 = g.x2.unwrap_or(0.0);
910        let y2 = g.y2.unwrap_or(0.0);
911        return format!(
912            "({},{})→({},{})",
913            fmt_f64(x1),
914            fmt_f64(y1),
915            fmt_f64(x2),
916            fmt_f64(y2)
917        );
918    }
919
920    // poly point count
921    if let Some(count) = g.point_count {
922        return format!("{} pts", count);
923    }
924
925    String::new()
926}
927
928fn render_flags(node: &NodeEntry) -> String {
929    let mut flags = Vec::new();
930    if node.visible == Some(false) {
931        flags.push("[hidden]");
932    }
933    if node.locked == Some(true) {
934        flags.push("[locked]");
935    }
936    flags.join(" ")
937}
938
939/// Format an `f64` without a trailing `.0` when the value is whole.
940fn fmt_f64(v: f64) -> String {
941    if v.fract() == 0.0 {
942        (v as i64).to_string()
943    } else {
944        v.to_string()
945    }
946}
947
948// ── Tests ─────────────────────────────────────────────────────────────────────
949
950#[cfg(test)]
951#[path = "document_tests.rs"]
952mod tests;