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::Unknown(_) => "unknown",
377    }
378}
379
380/// Return the snake_case op name for a [`zenith_tx::Op`] variant.
381fn op_name(op: &zenith_tx::Op) -> String {
382    match op {
383        zenith_tx::Op::SetTextAlign { .. } => "set_text_align",
384        zenith_tx::Op::MoveForward { .. } => "move_forward",
385        zenith_tx::Op::MoveBackward { .. } => "move_backward",
386        zenith_tx::Op::MoveToFront { .. } => "move_to_front",
387        zenith_tx::Op::MoveToBack { .. } => "move_to_back",
388        zenith_tx::Op::SetVisible { .. } => "set_visible",
389        zenith_tx::Op::SetLocked { .. } => "set_locked",
390        zenith_tx::Op::SetFill { .. } => "set_fill",
391        zenith_tx::Op::SetStroke { .. } => "set_stroke",
392        zenith_tx::Op::SetStrokeWidth { .. } => "set_stroke_width",
393        zenith_tx::Op::SetOpacity { .. } => "set_opacity",
394        zenith_tx::Op::SetGeometry { .. } => "set_geometry",
395        zenith_tx::Op::SetPoints { .. } => "set_points",
396        zenith_tx::Op::ReplaceText { .. } => "replace_text",
397        zenith_tx::Op::DuplicateNode { .. } => "duplicate_node",
398        zenith_tx::Op::DuplicatePage { .. } => "duplicate_page",
399        zenith_tx::Op::AddNode { .. } => "add_node",
400        zenith_tx::Op::RemoveNode { .. } => "remove_node",
401        zenith_tx::Op::Group { .. } => "group",
402        zenith_tx::Op::Ungroup { .. } => "ungroup",
403        zenith_tx::Op::Reparent { .. } => "reparent",
404        zenith_tx::Op::AlignNodes { .. } => "align_nodes",
405        zenith_tx::Op::SetTextOverflow { .. } => "set_text_overflow",
406        zenith_tx::Op::AddPage { .. } => "add_page",
407        zenith_tx::Op::DeletePage { .. } => "delete_page",
408        zenith_tx::Op::ReorderPages { .. } => "reorder_pages",
409        zenith_tx::Op::AddAsset { .. } => "add_asset",
410        zenith_tx::Op::SetAsset { .. } => "set_asset",
411        zenith_tx::Op::DistributeNodes { .. } => "distribute_nodes",
412        zenith_tx::Op::UpdateTokenValue { .. } => "update_token_value",
413        zenith_tx::Op::SetStyleProperty { .. } => "set_style_property",
414        zenith_tx::Op::SetTextDirection { .. } => "set_text_direction",
415        zenith_tx::Op::FindReplaceText { .. } => "find_replace_text",
416        zenith_tx::Op::SetPageSize { .. } => "set_page_size",
417        zenith_tx::Op::AlignToEdge { .. } => "align_to_edge",
418        zenith_tx::Op::CreateToken { .. } => "create_token",
419        zenith_tx::Op::CreateRecipe { .. } => "create_recipe",
420        zenith_tx::Op::UpdateRecipe { .. } => "update_recipe",
421        zenith_tx::Op::DeleteRecipe { .. } => "delete_recipe",
422        zenith_tx::Op::DetachPattern { .. } => "detach_pattern",
423    }
424    .to_owned()
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    // ── `library show` tests ───────────────────────────────────────────────────
432
433    #[test]
434    fn show_filter_token_human() {
435        let out = show("@zenith/filters#sepia", None, false).expect("show ok");
436        assert!(out.contains("package : @zenith/filters"), "pkg: {}", out);
437        assert!(out.contains("item    : sepia"), "item: {}", out);
438        assert!(out.contains("kind    : token"), "kind: {}", out);
439        assert!(out.contains("type    : filter"), "type: {}", out);
440        assert!(out.contains("ops: sepia"), "ops: {}", out);
441        assert!(out.contains("To use"), "to_use: {}", out);
442        assert!(
443            out.contains("--into <doc.zen>"),
444            "to_use invocation: {}",
445            out
446        );
447    }
448
449    #[test]
450    fn show_mask_token_human() {
451        let out = show("@zenith/masks#vignette", None, false).expect("show ok");
452        assert!(out.contains("kind    : token"), "kind: {}", out);
453        assert!(out.contains("type    : mask"), "type: {}", out);
454        assert!(out.contains("shape: rounded"), "shape: {}", out);
455        assert!(out.contains("invert=true"), "invert: {}", out);
456    }
457
458    #[test]
459    fn show_component_human() {
460        let out = show("@zenith/flowchart#decision", None, false).expect("show ok");
461        assert!(out.contains("package : @zenith/flowchart"), "pkg: {}", out);
462        assert!(out.contains("item    : decision"), "item: {}", out);
463        assert!(out.contains("kind    : component"), "kind: {}", out);
464        assert!(out.contains("children:"), "children: {}", out);
465        assert!(out.contains("root    : shape"), "root: {}", out);
466        // to_use for a component must include --page
467        assert!(out.contains("--page <page-id>"), "to_use: {}", out);
468    }
469
470    #[test]
471    fn show_action_human() {
472        let out = show("@zenith/brand-kit#apply-2026", None, false).expect("show ok");
473        assert!(out.contains("package : @zenith/brand-kit"), "pkg: {}", out);
474        assert!(out.contains("item    : apply-2026"), "item: {}", out);
475        assert!(out.contains("kind    : action"), "kind: {}", out);
476        assert!(out.contains("update_token_value"), "ops: {}", out);
477        assert!(out.contains("To use"), "to_use: {}", out);
478        assert!(
479            out.contains("--into <doc.zen>"),
480            "to_use invocation: {}",
481            out
482        );
483    }
484
485    #[test]
486    fn show_filter_token_json() {
487        let out = show("@zenith/filters#sepia", None, true).expect("show ok");
488        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
489        assert_eq!(v["schema"], "zenith-library-show-v1");
490        assert_eq!(v["package"], "@zenith/filters");
491        assert_eq!(v["item"], "sepia");
492        assert_eq!(v["kind"], "token");
493        assert_eq!(v["detail"]["token_type"], "filter");
494        assert!(
495            v["detail"]["summary"]
496                .as_str()
497                .unwrap_or("")
498                .contains("sepia"),
499            "filter summary: {}",
500            v["detail"]["summary"]
501        );
502        assert!(
503            v["to_use"].as_str().unwrap_or("").contains("--into"),
504            "to_use: {}",
505            v["to_use"]
506        );
507    }
508
509    #[test]
510    fn show_component_json() {
511        let out = show("@zenith/flowchart#decision", None, true).expect("show ok");
512        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
513        assert_eq!(v["schema"], "zenith-library-show-v1");
514        assert_eq!(v["kind"], "component");
515        assert_eq!(v["detail"]["root_node_kind"], "shape");
516        assert!(
517            v["detail"]["child_count"].as_u64().unwrap_or(0) >= 1,
518            "child_count"
519        );
520        assert!(
521            v["to_use"].as_str().unwrap_or("").contains("--page"),
522            "component to_use needs --page: {}",
523            v["to_use"]
524        );
525    }
526
527    #[test]
528    fn show_action_json() {
529        let out = show("@zenith/brand-kit#apply-2026", None, true).expect("show ok");
530        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
531        assert_eq!(v["schema"], "zenith-library-show-v1");
532        assert_eq!(v["kind"], "action");
533        let ops = v["detail"]["ops"].as_array().expect("ops array");
534        assert!(!ops.is_empty(), "ops must not be empty");
535        assert!(
536            ops.iter().any(|o| o == "update_token_value"),
537            "must contain update_token_value; ops: {:?}",
538            ops
539        );
540    }
541
542    #[test]
543    fn show_unknown_package_errors() {
544        let err = show("@no/such#item", None, false).expect_err("unknown pkg errors");
545        assert_eq!(err.exit_code, 2);
546        assert!(
547            err.message.contains("unknown library package"),
548            "{}",
549            err.message
550        );
551        assert!(err.message.contains("@zenith/"), "{}", err.message);
552    }
553
554    #[test]
555    fn show_unknown_item_errors() {
556        let err = show("@zenith/filters#nope", None, false).expect_err("unknown item errors");
557        assert_eq!(err.exit_code, 2);
558        assert!(err.message.contains("unknown item"), "{}", err.message);
559        assert!(err.message.contains("sepia"), "{}", err.message);
560    }
561
562    #[test]
563    fn show_malformed_spec_errors() {
564        let err = show("no-hash", None, false).expect_err("malformed spec errors");
565        assert_eq!(err.exit_code, 2);
566    }
567}