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#[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#[derive(Debug, serde::Serialize)]
22#[serde(tag = "kind", rename_all = "snake_case")]
23enum ShowDetail {
24 Token {
25 token_type: String,
26 summary: String,
28 },
29 Component {
30 root_node_kind: String,
32 child_count: usize,
34 node_kinds: String,
37 },
38 Action {
39 ops: Vec<String>,
41 label: Option<String>,
43 },
44}
45
46#[derive(Debug)]
48pub struct ShowCmdErr {
49 pub message: String,
51 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
64pub 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 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 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 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 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 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 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
303fn 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
354fn 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
380fn 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 #[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 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}