Skip to main content

zenith_cli/commands/library/
show.rs

1use std::path::Path;
2
3use zenith_core::{TokenLiteral, TokenType, TokenValue};
4use zenith_tx::Transaction;
5
6use crate::commands::serialize_pretty;
7use crate::library::{ItemKind, load_pack_document, parse_spec, resolve_packs};
8
9/// JSON shape for `library show --json`.
10#[derive(Debug, serde::Serialize)]
11struct LibraryShowOutput {
12    schema: &'static str,
13    package: String,
14    item: String,
15    kind: &'static str,
16    detail: ShowDetail,
17    to_use: String,
18}
19
20/// Kind-specific content for `library show`.
21#[derive(Debug, serde::Serialize)]
22#[serde(tag = "kind", rename_all = "snake_case")]
23enum ShowDetail {
24    Token {
25        token_type: String,
26        /// Human-readable ops / value summary derived from the token literal.
27        summary: String,
28    },
29    Component {
30        /// The root node kind (kind of the first child), or "empty" when none.
31        root_node_kind: String,
32        /// Total direct child count of the component.
33        child_count: usize,
34        /// Short breakdown of the child node kinds present (sorted, e.g.
35        /// `"shape(1)"`).
36        node_kinds: String,
37    },
38    Action {
39        /// The op names extracted from the tx JSON (snake_case), in source order.
40        ops: Vec<String>,
41        /// Human-readable label from the action def, if present.
42        label: Option<String>,
43    },
44}
45
46/// Error produced by the `library show` command.
47#[derive(Debug)]
48pub struct ShowCmdErr {
49    /// Human-readable message.
50    pub message: String,
51    /// Recommended exit code.
52    pub exit_code: u8,
53}
54
55impl ShowCmdErr {
56    fn new(message: impl Into<String>, exit_code: u8) -> Self {
57        Self {
58            message: message.into(),
59            exit_code,
60        }
61    }
62}
63
64/// Inspect the library item named by `spec` (`<package>#<item>`) and return
65/// `(stdout_text, exit_code)`.
66///
67/// Resolves packs from `project_dir` (embedded presets + project packs), finds
68/// the named item, loads the pack's full document to derive content detail, and
69/// formats human or JSON output.
70///
71/// # Errors
72///
73/// Returns [`ShowCmdErr`] on a malformed spec, unknown package, or unknown item.
74pub fn show(spec: &str, project_dir: Option<&Path>, json: bool) -> Result<String, ShowCmdErr> {
75    let (pkg_id, item_id) = parse_spec(spec).map_err(|e| ShowCmdErr::new(e.message, 2))?;
76
77    let packs = resolve_packs(project_dir);
78
79    // Find the pack.
80    let pack = packs.iter().find(|p| p.id == pkg_id).ok_or_else(|| {
81        let mut available: Vec<&str> = packs.iter().map(|p| p.id.as_str()).collect();
82        available.sort_unstable();
83        available.dedup();
84        ShowCmdErr::new(
85            format!(
86                "unknown library package '{}' (available: {})",
87                pkg_id,
88                if available.is_empty() {
89                    "none".to_owned()
90                } else {
91                    available.join(", ")
92                }
93            ),
94            2,
95        )
96    })?;
97
98    // Find the item in the pack's metadata.
99    let pack_item = pack
100        .items
101        .iter()
102        .find(|it| it.id == item_id)
103        .ok_or_else(|| {
104            let available: Vec<&str> = pack.items.iter().map(|it| it.id.as_str()).collect();
105            ShowCmdErr::new(
106                format!(
107                    "unknown item '{}' in package '{}' (available: {})",
108                    item_id,
109                    pkg_id,
110                    if available.is_empty() {
111                        "none".to_owned()
112                    } else {
113                        available.join(", ")
114                    }
115                ),
116                2,
117            )
118        })?;
119
120    let kind = pack_item.kind;
121
122    // Load the pack's full document to derive content detail.
123    let pack_doc = load_pack_document(pack).map_err(|e| ShowCmdErr::new(e.message, 2))?;
124
125    let detail = match kind {
126        ItemKind::Token => {
127            // Find the token in the pack document.
128            let token = pack_doc
129                .tokens
130                .tokens
131                .iter()
132                .find(|t| t.id == item_id)
133                .ok_or_else(|| {
134                    ShowCmdErr::new(
135                        format!(
136                            "internal error: item '{}' not found in pack document",
137                            item_id
138                        ),
139                        2,
140                    )
141                })?;
142
143            let token_type = match &token.token_type {
144                TokenType::Filter => "filter".to_owned(),
145                TokenType::Mask => "mask".to_owned(),
146                TokenType::Color => "color".to_owned(),
147                TokenType::Dimension => "dimension".to_owned(),
148                TokenType::Number => "number".to_owned(),
149                TokenType::FontFamily => "fontFamily".to_owned(),
150                TokenType::FontWeight => "fontWeight".to_owned(),
151                TokenType::Gradient => "gradient".to_owned(),
152                TokenType::Shadow => "shadow".to_owned(),
153                TokenType::Unknown(s) => s.clone(),
154            };
155
156            let summary = match &token.value {
157                TokenValue::Reference { token_id } => {
158                    format!("alias to {}", token_id)
159                }
160                TokenValue::Literal(lit) => match lit {
161                    TokenLiteral::Filter(lit) => {
162                        let ops: Vec<String> = lit
163                            .ops
164                            .iter()
165                            .map(|op| op.kind.as_op_name().to_owned())
166                            .collect();
167                        format!("ops: {}", ops.join(", "))
168                    }
169                    TokenLiteral::Mask(lit) => {
170                        let parts: Vec<String> = {
171                            let mut v = vec![lit.shape.as_shape_name().to_owned()];
172                            if lit.feather > 0.0 {
173                                v.push(format!("feather={}", lit.feather));
174                            }
175                            if lit.invert {
176                                v.push("invert=true".to_owned());
177                            }
178                            v
179                        };
180                        format!("shape: {}", parts.join(", "))
181                    }
182                    TokenLiteral::String(s) => s.clone(),
183                    TokenLiteral::Dimension(d) => {
184                        format!("({}){}", d.unit.as_annotation(), d.value)
185                    }
186                    TokenLiteral::Number(n) => n.to_string(),
187                    TokenLiteral::Gradient(g) => {
188                        format!("gradient with {} stop(s)", g.stops.len())
189                    }
190                    TokenLiteral::Shadow(s) => {
191                        format!("shadow with {} layer(s)", s.layers.len())
192                    }
193                },
194            };
195
196            ShowDetail::Token {
197                token_type,
198                summary,
199            }
200        }
201
202        ItemKind::Component => {
203            let comp = pack_doc
204                .components
205                .iter()
206                .find(|c| c.id == item_id)
207                .ok_or_else(|| {
208                    ShowCmdErr::new(
209                        format!(
210                            "internal error: component '{}' not found in pack document",
211                            item_id
212                        ),
213                        2,
214                    )
215                })?;
216
217            let child_count = comp.children.len();
218            let root_node_kind = comp
219                .children
220                .first()
221                .map(|n| node_kind_name(n).to_owned())
222                .unwrap_or_else(|| "empty".to_owned());
223
224            // Count each node kind among direct children.
225            let mut kind_counts: std::collections::BTreeMap<&'static str, usize> =
226                std::collections::BTreeMap::new();
227            for child in &comp.children {
228                *kind_counts.entry(node_kind_name(child)).or_insert(0) += 1;
229            }
230            let node_kinds: Vec<String> = kind_counts
231                .iter()
232                .map(|(k, n)| format!("{}({})", k, n))
233                .collect();
234            let node_kinds = if node_kinds.is_empty() {
235                "none".to_owned()
236            } else {
237                node_kinds.join(", ")
238            };
239
240            ShowDetail::Component {
241                root_node_kind,
242                child_count,
243                node_kinds,
244            }
245        }
246
247        ItemKind::Action => {
248            let action_def = pack_doc
249                .actions
250                .iter()
251                .find(|a| a.id == item_id)
252                .ok_or_else(|| {
253                    ShowCmdErr::new(
254                        format!(
255                            "internal error: action '{}' not found in pack document",
256                            item_id
257                        ),
258                        2,
259                    )
260                })?;
261
262            let label = action_def.label.clone();
263
264            // Parse the tx JSON with Transaction::from_json to extract op names.
265            let tx = Transaction::from_json(&action_def.tx_json).map_err(|e| {
266                ShowCmdErr::new(
267                    format!("malformed tx-script in action '{}': {}", item_id, e.message),
268                    2,
269                )
270            })?;
271
272            let ops: Vec<String> = tx.ops.iter().map(op_name).collect();
273
274            ShowDetail::Action { ops, label }
275        }
276    };
277
278    let to_use = match kind {
279        ItemKind::Component => format!(
280            "zenith library add {}#{} --into <doc.zen> --page <page-id>",
281            pkg_id, item_id
282        ),
283        ItemKind::Token | ItemKind::Action => {
284            format!("zenith library add {}#{} --into <doc.zen>", pkg_id, item_id)
285        }
286    };
287
288    if json {
289        let out = LibraryShowOutput {
290            schema: "zenith-library-show-v1",
291            package: pkg_id,
292            item: item_id,
293            kind: kind.label(),
294            detail,
295            to_use,
296        };
297        Ok(serialize_pretty(&out))
298    } else {
299        Ok(format_show_human(&pkg_id, &item_id, kind, &detail, &to_use))
300    }
301}
302
303/// Format the human-readable output for `library show`.
304fn format_show_human(
305    pkg_id: &str,
306    item_id: &str,
307    kind: ItemKind,
308    detail: &ShowDetail,
309    to_use: &str,
310) -> String {
311    let mut lines = Vec::new();
312    lines.push(format!("package : {}", pkg_id));
313    lines.push(format!("item    : {}", item_id));
314    lines.push(format!("kind    : {}", kind.label()));
315    lines.push(String::new());
316
317    match detail {
318        ShowDetail::Token {
319            token_type,
320            summary,
321        } => {
322            lines.push(format!("type    : {}", token_type));
323            lines.push(format!("content : {}", summary));
324        }
325        ShowDetail::Component {
326            root_node_kind,
327            child_count,
328            node_kinds,
329        } => {
330            lines.push(format!("children: {} node(s)", child_count));
331            lines.push(format!("root    : {}", root_node_kind));
332            lines.push(format!("nodes   : {}", node_kinds));
333        }
334        ShowDetail::Action { ops, label } => {
335            if let Some(lbl) = label {
336                lines.push(format!("label   : {}", lbl));
337            }
338            lines.push(format!(
339                "ops     : {}",
340                if ops.is_empty() {
341                    "(none)".to_owned()
342                } else {
343                    ops.join(", ")
344                }
345            ));
346        }
347    }
348
349    lines.push(String::new());
350    lines.push(format!("To use  : {}", to_use));
351    lines.join("\n")
352}
353
354/// Return the short, stable node-kind name for a [`zenith_core::Node`] variant.
355fn node_kind_name(node: &zenith_core::Node) -> &'static str {
356    match node {
357        zenith_core::Node::Rect(_) => "rect",
358        zenith_core::Node::Ellipse(_) => "ellipse",
359        zenith_core::Node::Line(_) => "line",
360        zenith_core::Node::Text(_) => "text",
361        zenith_core::Node::Code(_) => "code",
362        zenith_core::Node::Image(_) => "image",
363        zenith_core::Node::Polygon(_) => "polygon",
364        zenith_core::Node::Polyline(_) => "polyline",
365        zenith_core::Node::Frame(_) => "frame",
366        zenith_core::Node::Group(_) => "group",
367        zenith_core::Node::Instance(_) => "instance",
368        zenith_core::Node::Field(_) => "field",
369        zenith_core::Node::Toc(_) => "toc",
370        zenith_core::Node::Footnote(_) => "footnote",
371        zenith_core::Node::Table(_) => "table",
372        zenith_core::Node::Shape(_) => "shape",
373        zenith_core::Node::Connector(_) => "connector",
374        zenith_core::Node::Pattern(_) => "pattern",
375        zenith_core::Node::Chart(_) => "chart",
376        zenith_core::Node::Light(_) => "light",
377        zenith_core::Node::Mesh(_) => "mesh",
378        zenith_core::Node::Unknown(_) => "unknown",
379    }
380}
381
382/// Return the snake_case op name for a [`zenith_tx::Op`] variant.
383fn op_name(op: &zenith_tx::Op) -> String {
384    match op {
385        zenith_tx::Op::SetTextAlign { .. } => "set_text_align",
386        zenith_tx::Op::MoveForward { .. } => "move_forward",
387        zenith_tx::Op::MoveBackward { .. } => "move_backward",
388        zenith_tx::Op::MoveToFront { .. } => "move_to_front",
389        zenith_tx::Op::MoveToBack { .. } => "move_to_back",
390        zenith_tx::Op::SetVisible { .. } => "set_visible",
391        zenith_tx::Op::SetLocked { .. } => "set_locked",
392        zenith_tx::Op::SetFill { .. } => "set_fill",
393        zenith_tx::Op::SetStroke { .. } => "set_stroke",
394        zenith_tx::Op::SetStrokeWidth { .. } => "set_stroke_width",
395        zenith_tx::Op::SetOpacity { .. } => "set_opacity",
396        zenith_tx::Op::SetGeometry { .. } => "set_geometry",
397        zenith_tx::Op::SetPoints { .. } => "set_points",
398        zenith_tx::Op::ReplaceText { .. } => "replace_text",
399        zenith_tx::Op::DuplicateNode { .. } => "duplicate_node",
400        zenith_tx::Op::DuplicatePage { .. } => "duplicate_page",
401        zenith_tx::Op::AddNode { .. } => "add_node",
402        zenith_tx::Op::RemoveNode { .. } => "remove_node",
403        zenith_tx::Op::Group { .. } => "group",
404        zenith_tx::Op::Ungroup { .. } => "ungroup",
405        zenith_tx::Op::Reparent { .. } => "reparent",
406        zenith_tx::Op::AlignNodes { .. } => "align_nodes",
407        zenith_tx::Op::SetTextOverflow { .. } => "set_text_overflow",
408        zenith_tx::Op::AddPage { .. } => "add_page",
409        zenith_tx::Op::DeletePage { .. } => "delete_page",
410        zenith_tx::Op::ReorderPages { .. } => "reorder_pages",
411        zenith_tx::Op::AddAsset { .. } => "add_asset",
412        zenith_tx::Op::SetAsset { .. } => "set_asset",
413        zenith_tx::Op::DistributeNodes { .. } => "distribute_nodes",
414        zenith_tx::Op::UpdateTokenValue { .. } => "update_token_value",
415        zenith_tx::Op::SetStyleProperty { .. } => "set_style_property",
416        zenith_tx::Op::SetTextDirection { .. } => "set_text_direction",
417        zenith_tx::Op::FindReplaceText { .. } => "find_replace_text",
418        zenith_tx::Op::SetPageSize { .. } => "set_page_size",
419        zenith_tx::Op::AlignToEdge { .. } => "align_to_edge",
420        zenith_tx::Op::CreateToken { .. } => "create_token",
421        zenith_tx::Op::CreateRecipe { .. } => "create_recipe",
422        zenith_tx::Op::UpdateRecipe { .. } => "update_recipe",
423        zenith_tx::Op::DeleteRecipe { .. } => "delete_recipe",
424        zenith_tx::Op::DetachPattern { .. } => "detach_pattern",
425    }
426    .to_owned()
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    // ── `library show` tests ───────────────────────────────────────────────────
434
435    #[test]
436    fn show_filter_token_human() {
437        let out = show("@zenith/filters#sepia", None, false).expect("show ok");
438        assert!(out.contains("package : @zenith/filters"), "pkg: {}", out);
439        assert!(out.contains("item    : sepia"), "item: {}", out);
440        assert!(out.contains("kind    : token"), "kind: {}", out);
441        assert!(out.contains("type    : filter"), "type: {}", out);
442        assert!(out.contains("ops: sepia"), "ops: {}", out);
443        assert!(out.contains("To use"), "to_use: {}", out);
444        assert!(
445            out.contains("--into <doc.zen>"),
446            "to_use invocation: {}",
447            out
448        );
449    }
450
451    #[test]
452    fn show_mask_token_human() {
453        let out = show("@zenith/masks#vignette", None, false).expect("show ok");
454        assert!(out.contains("kind    : token"), "kind: {}", out);
455        assert!(out.contains("type    : mask"), "type: {}", out);
456        assert!(out.contains("shape: rounded"), "shape: {}", out);
457        assert!(out.contains("invert=true"), "invert: {}", out);
458    }
459
460    #[test]
461    fn show_component_human() {
462        let out = show("@zenith/flowchart#decision", None, false).expect("show ok");
463        assert!(out.contains("package : @zenith/flowchart"), "pkg: {}", out);
464        assert!(out.contains("item    : decision"), "item: {}", out);
465        assert!(out.contains("kind    : component"), "kind: {}", out);
466        assert!(out.contains("children:"), "children: {}", out);
467        assert!(out.contains("root    : shape"), "root: {}", out);
468        // to_use for a component must include --page
469        assert!(out.contains("--page <page-id>"), "to_use: {}", out);
470    }
471
472    #[test]
473    fn show_action_human() {
474        let out = show("@zenith/brand-kit#apply-2026", None, false).expect("show ok");
475        assert!(out.contains("package : @zenith/brand-kit"), "pkg: {}", out);
476        assert!(out.contains("item    : apply-2026"), "item: {}", out);
477        assert!(out.contains("kind    : action"), "kind: {}", out);
478        assert!(out.contains("update_token_value"), "ops: {}", out);
479        assert!(out.contains("To use"), "to_use: {}", out);
480        assert!(
481            out.contains("--into <doc.zen>"),
482            "to_use invocation: {}",
483            out
484        );
485    }
486
487    #[test]
488    fn show_filter_token_json() {
489        let out = show("@zenith/filters#sepia", None, true).expect("show ok");
490        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
491        assert_eq!(v["schema"], "zenith-library-show-v1");
492        assert_eq!(v["package"], "@zenith/filters");
493        assert_eq!(v["item"], "sepia");
494        assert_eq!(v["kind"], "token");
495        assert_eq!(v["detail"]["token_type"], "filter");
496        assert!(
497            v["detail"]["summary"]
498                .as_str()
499                .unwrap_or("")
500                .contains("sepia"),
501            "filter summary: {}",
502            v["detail"]["summary"]
503        );
504        assert!(
505            v["to_use"].as_str().unwrap_or("").contains("--into"),
506            "to_use: {}",
507            v["to_use"]
508        );
509    }
510
511    #[test]
512    fn show_component_json() {
513        let out = show("@zenith/flowchart#decision", None, true).expect("show ok");
514        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
515        assert_eq!(v["schema"], "zenith-library-show-v1");
516        assert_eq!(v["kind"], "component");
517        assert_eq!(v["detail"]["root_node_kind"], "shape");
518        assert!(
519            v["detail"]["child_count"].as_u64().unwrap_or(0) >= 1,
520            "child_count"
521        );
522        assert!(
523            v["to_use"].as_str().unwrap_or("").contains("--page"),
524            "component to_use needs --page: {}",
525            v["to_use"]
526        );
527    }
528
529    #[test]
530    fn show_action_json() {
531        let out = show("@zenith/brand-kit#apply-2026", None, true).expect("show ok");
532        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
533        assert_eq!(v["schema"], "zenith-library-show-v1");
534        assert_eq!(v["kind"], "action");
535        let ops = v["detail"]["ops"].as_array().expect("ops array");
536        assert!(!ops.is_empty(), "ops must not be empty");
537        assert!(
538            ops.iter().any(|o| o == "update_token_value"),
539            "must contain update_token_value; ops: {:?}",
540            ops
541        );
542    }
543
544    #[test]
545    fn show_unknown_package_errors() {
546        let err = show("@no/such#item", None, false).expect_err("unknown pkg errors");
547        assert_eq!(err.exit_code, 2);
548        assert!(
549            err.message.contains("unknown library package"),
550            "{}",
551            err.message
552        );
553        assert!(err.message.contains("@zenith/"), "{}", err.message);
554    }
555
556    #[test]
557    fn show_unknown_item_errors() {
558        let err = show("@zenith/filters#nope", None, false).expect_err("unknown item errors");
559        assert_eq!(err.exit_code, 2);
560        assert!(err.message.contains("unknown item"), "{}", err.message);
561        assert!(err.message.contains("sepia"), "{}", err.message);
562    }
563
564    #[test]
565    fn show_malformed_spec_errors() {
566        let err = show("no-hash", None, false).expect_err("malformed spec errors");
567        assert_eq!(err.exit_code, 2);
568    }
569}