Skip to main content

zenith_cli/commands/library/
add.rs

1use std::path::Path;
2
3use zenith_core::{KdlAdapter, KdlSource, Severity, validate};
4use zenith_tx::TxStatus;
5
6use crate::library::{ItemKind, parse_spec, resolve_packs};
7
8/// Error produced by the `library add` command.
9#[derive(Debug)]
10pub struct AddCmdErr {
11    /// Human-readable message.
12    pub message: String,
13    /// Recommended exit code.
14    pub exit_code: u8,
15}
16
17impl AddCmdErr {
18    fn new(message: impl Into<String>, exit_code: u8) -> Self {
19        Self {
20            message: message.into(),
21            exit_code,
22        }
23    }
24}
25
26/// The successful outcome of `library add`: the canonical formatted source to
27/// write back (or print on `--dry-run`) plus a human-readable summary.
28#[derive(Debug)]
29pub struct AddResult {
30    /// The canonical formatted bytes of the mutated document.
31    pub formatted: Vec<u8>,
32    /// A multi-line human-readable summary of what was added.
33    pub summary: String,
34}
35
36/// Materialize the library item named by `spec` into the document `target_src`,
37/// returning the formatted result + a summary.
38///
39/// `project_dir` is the directory whose `libraries/*.zen` packs are resolved
40/// alongside the embedded presets (the `--into` file's parent). `at` is the
41/// instance origin in pixels; `id_base` overrides the generated instance id base.
42///
43/// This is pure: it parses, mutates an in-memory [`zenith_core::Document`],
44/// VALIDATES the result (hard errors abort with no write), and formats — it never
45/// touches the filesystem itself (the dispatcher reads/writes files). Steps mirror
46/// [`crate::library::materialize`]: resolve pack → copy component (dedup) → copy
47/// dep tokens/styles/assets (dedup) → unique instance id → insert instance →
48/// record libraries + provenance → validate → format.
49///
50/// `page` is required only for COMPONENT items (which materialize as an instance
51/// on a page); TOKEN items (filter tokens) ignore it.
52///
53/// # Errors
54///
55/// Returns [`AddCmdErr`] on a malformed spec, parse/format failure, unknown
56/// package/item, a missing page (for a component item), or a post-mutation
57/// validation that has hard errors.
58pub fn add(
59    target_src: &str,
60    spec: &str,
61    project_dir: Option<&Path>,
62    page: Option<&str>,
63    at: (f64, f64),
64    id_override: Option<&str>,
65) -> Result<AddResult, AddCmdErr> {
66    let (pkg_id, item) = parse_spec(spec).map_err(|e| AddCmdErr::new(e.message, 2))?;
67
68    let mut target = KdlAdapter
69        .parse(target_src.as_bytes())
70        .map_err(|e| AddCmdErr::new(format!("parse error: {}", e.message), 2))?;
71
72    let packs = resolve_packs(project_dir);
73    let id_base = id_override.unwrap_or(item.as_str());
74
75    // Determine the item kind from the resolved pack's exported items. An unknown
76    // pkg/item falls through to a `materialize*` call, which yields a precise
77    // "unknown package/item" diagnostic.
78    let item_kind = packs
79        .iter()
80        .find(|p| p.id == pkg_id)
81        .and_then(|p| p.items.iter().find(|it| it.id == item))
82        .map(|it| it.kind);
83
84    let summary = match item_kind {
85        Some(ItemKind::Action) => {
86            let outcome = crate::library::materialize_action(target_src, &packs, &pkg_id, &item)
87                .map_err(|e| AddCmdErr::new(e.message, 2))?;
88
89            // Rejected → early-return with the rejection diagnostics; the two
90            // accepted variants yield the status label used in the summary.
91            let status_label = match outcome.tx_result.status {
92                TxStatus::Rejected => {
93                    let diag_lines: Vec<String> = outcome
94                        .tx_result
95                        .diagnostics
96                        .iter()
97                        .map(crate::commands::format_diagnostic_line)
98                        .collect();
99                    return Err(AddCmdErr::new(
100                        format!(
101                            "action '{}#{}' was rejected:\n{}",
102                            pkg_id,
103                            item,
104                            diag_lines.join("\n")
105                        ),
106                        1,
107                    ));
108                }
109                TxStatus::Accepted => "accepted",
110                TxStatus::AcceptedWithWarnings => "accepted-with-warnings",
111            };
112
113            let final_source = outcome.final_source.ok_or_else(|| {
114                AddCmdErr::new("internal error: accepted action produced no source", 2)
115            })?;
116
117            let result_doc = KdlAdapter.parse(final_source.as_bytes()).map_err(|e| {
118                AddCmdErr::new(
119                    format!(
120                        "internal error: could not re-parse action result: {}",
121                        e.message
122                    ),
123                    2,
124                )
125            })?;
126
127            let formatted = validate_and_format(&result_doc)?;
128
129            let affected = if outcome.tx_result.affected_node_ids.is_empty() {
130                "none".to_owned()
131            } else {
132                outcome.tx_result.affected_node_ids.join(", ")
133            };
134            let provenance_id = outcome.provenance_id.unwrap_or_default();
135            let mut summary = String::new();
136            summary.push_str(&format!(
137                "applied {}#{} ({})\n",
138                outcome.pkg_id, outcome.item, status_label
139            ));
140            summary.push_str(&format!("  affected: {}\n", affected));
141            summary.push_str(&format!("  provenance: {}", provenance_id));
142            for w in &outcome.warnings {
143                summary.push_str(&format!("\n  warning: {}", w));
144            }
145            return Ok(AddResult { formatted, summary });
146        }
147        Some(ItemKind::Token) => {
148            // TOKEN item: copy the filter token + color deps; no instance, no page.
149            let outcome =
150                crate::library::materialize_token(&mut target, &packs, &pkg_id, &item, id_base)
151                    .map_err(|e| AddCmdErr::new(e.message, 2))?;
152            let deps = if outcome.dep_token_ids.is_empty() {
153                "none".to_owned()
154            } else {
155                outcome.dep_token_ids.join(", ")
156            };
157            let mut summary = String::new();
158            summary.push_str(&format!(
159                "added {}#{} as {} token '{}'\n",
160                outcome.pkg_id, outcome.item, outcome.apply_property, outcome.token_id
161            ));
162            summary.push_str(&format!(
163                "  apply with: {}=(token)\"{}\"\n",
164                outcome.apply_property, outcome.token_id
165            ));
166            summary.push_str(&format!("  dependencies: {}\n", deps));
167            summary.push_str(&format!("  provenance: {}", outcome.provenance_id));
168            for w in &outcome.warnings {
169                summary.push_str(&format!("\n  warning: {}", w));
170            }
171            summary
172        }
173        // COMPONENT item (or unknown). A real component requires `--page`. For an
174        // unknown pkg/item (`None`), skip the page requirement and let
175        // `materialize` emit the precise "unknown package/item" diagnostic — it
176        // checks pkg/item BEFORE page, so an empty page never masks that error.
177        Some(ItemKind::Component) | None => {
178            let page = match item_kind {
179                Some(ItemKind::Component) => page.ok_or_else(|| {
180                    AddCmdErr::new(
181                        "page is required to add a component item (use --page <id>)",
182                        2,
183                    )
184                })?,
185                Some(ItemKind::Token) | Some(ItemKind::Action) | None => page.unwrap_or(""),
186            };
187            let outcome =
188                crate::library::materialize(&mut target, &packs, &pkg_id, &item, page, id_base, at)
189                    .map_err(|e| AddCmdErr::new(e.message, 2))?;
190            let mut summary = String::new();
191            summary.push_str(&format!(
192                "added {}#{} as instance '{}' on page '{}'\n",
193                outcome.pkg_id, outcome.item, outcome.instance_id, page
194            ));
195            summary.push_str(&format!("  component: {}\n", outcome.target_component_id));
196            summary.push_str(&format!("  provenance: {}", outcome.provenance_id));
197            for w in &outcome.warnings {
198                summary.push_str(&format!("\n  warning: {}", w));
199            }
200            summary
201        }
202    };
203
204    let formatted = validate_and_format(&target)?;
205    Ok(AddResult { formatted, summary })
206}
207
208/// Validate the mutated `target` (hard errors abort with no write) then format it
209/// to canonical bytes. Shared by the component and token `add` branches.
210fn validate_and_format(target: &zenith_core::Document) -> Result<Vec<u8>, AddCmdErr> {
211    let report = validate(target);
212    let errors: Vec<String> = report
213        .diagnostics
214        .iter()
215        .filter(|d| d.severity == Severity::Error)
216        .map(crate::commands::format_diagnostic_line)
217        .collect();
218    if !errors.is_empty() {
219        return Err(AddCmdErr::new(
220            format!(
221                "materialized document has {} validation error(s):\n{}",
222                errors.len(),
223                errors.join("\n")
224            ),
225            1,
226        ));
227    }
228    KdlAdapter
229        .format(target)
230        .map_err(|e| AddCmdErr::new(format!("format error: {}", e.message), 2))
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    // ── `add` command tests ────────────────────────────────────────────────────
238
239    const TARGET_SRC: &str = r#"zenith version=1 {
240  project id="proj.x" name="Target"
241  tokens format="zenith-token-v1" {}
242  styles {}
243  document id="d" title="x" {
244    page id="pg" w=(px)800 h=(px)600 {}
245  }
246}
247"#;
248
249    #[test]
250    fn add_produces_formatted_doc_that_round_trips_and_compiles() {
251        let result = add(
252            TARGET_SRC,
253            "@zenith/flowchart#decision",
254            None,
255            Some("pg"),
256            (120.0, 80.0),
257            None,
258        )
259        .expect("add ok");
260
261        // Result is valid UTF-8 KDL that reparses + validates clean.
262        let src = String::from_utf8(result.formatted).expect("utf8");
263        let doc = KdlAdapter.parse(src.as_bytes()).expect("reparse");
264        let errors: Vec<_> = validate(&doc)
265            .diagnostics
266            .into_iter()
267            .filter(|d| d.severity == Severity::Error)
268            .collect();
269        assert!(errors.is_empty(), "errors: {:?}", errors);
270
271        // Summary mentions the instance + component + provenance.
272        assert!(
273            result.summary.contains("decision"),
274            "summary: {}",
275            result.summary
276        );
277        assert!(
278            result.summary.contains("lib.zenith.flowchart.decision"),
279            "summary: {}",
280            result.summary
281        );
282
283        // Smoke: the document compiles to a non-empty scene (instance expands to
284        // the shape) when rendered to a scene JSON.
285        let artifact = crate::commands::render::to_scene_json(
286            &src,
287            None,
288            1,
289            &crate::config::CliPolicyFlags::default(),
290            None,
291        )
292        .expect("compile ok");
293        let scene: serde_json::Value =
294            serde_json::from_str(&artifact.json).expect("scene json parses");
295        let commands = scene["commands"].as_array().expect("commands array");
296        assert!(
297            !commands.is_empty(),
298            "instance must expand to at least one scene command"
299        );
300    }
301
302    #[test]
303    fn add_malformed_spec_errors() {
304        let err = add(TARGET_SRC, "no-hash", None, Some("pg"), (0.0, 0.0), None)
305            .expect_err("malformed spec errors");
306        assert_eq!(err.exit_code, 2);
307    }
308
309    #[test]
310    fn add_unknown_page_errors() {
311        let err = add(
312            TARGET_SRC,
313            "@zenith/flowchart#decision",
314            None,
315            Some("nope"),
316            (0.0, 0.0),
317            None,
318        )
319        .expect_err("unknown page errors");
320        assert!(
321            err.message.contains("page 'nope' not found"),
322            "msg: {}",
323            err.message
324        );
325    }
326
327    #[test]
328    fn add_unknown_pkg_and_item_error() {
329        let e1 = add(
330            TARGET_SRC,
331            "@no/such#decision",
332            None,
333            Some("pg"),
334            (0.0, 0.0),
335            None,
336        )
337        .expect_err("unknown pkg");
338        assert!(e1.message.contains("@zenith/flowchart"), "{}", e1.message);
339        let e2 = add(
340            TARGET_SRC,
341            "@zenith/flowchart#nope",
342            None,
343            Some("pg"),
344            (0.0, 0.0),
345            None,
346        )
347        .expect_err("unknown item");
348        assert!(e2.message.contains("process"), "{}", e2.message);
349    }
350
351    #[test]
352    fn add_is_pure_on_input_string() {
353        // `add` never mutates its input; writing happens only in the dispatcher.
354        // Two calls on the same input yield byte-identical output (deterministic).
355        let a = add(
356            TARGET_SRC,
357            "@zenith/flowchart#process",
358            None,
359            Some("pg"),
360            (0.0, 0.0),
361            None,
362        )
363        .expect("a");
364        let b = add(
365            TARGET_SRC,
366            "@zenith/flowchart#process",
367            None,
368            Some("pg"),
369            (0.0, 0.0),
370            None,
371        )
372        .expect("b");
373        assert_eq!(a.formatted, b.formatted, "add is deterministic + pure");
374    }
375
376    #[test]
377    fn add_filter_token_then_apply_compiles() {
378        let result = add(
379            TARGET_SRC,
380            "@zenith/filters#noir",
381            None,
382            None,
383            (0.0, 0.0),
384            None,
385        )
386        .expect("add filter token ok");
387
388        // Result reparses + validates clean.
389        let src = String::from_utf8(result.formatted).expect("utf8");
390        let doc = KdlAdapter.parse(src.as_bytes()).expect("reparse");
391        let errors: Vec<_> = validate(&doc)
392            .diagnostics
393            .into_iter()
394            .filter(|d| d.severity == Severity::Error)
395            .collect();
396        assert!(errors.is_empty(), "errors: {:?}", errors);
397
398        // Summary mentions how to apply the token.
399        assert!(
400            result.summary.contains("filter=(token)\"noir\""),
401            "summary: {}",
402            result.summary
403        );
404
405        // The added token can be applied to a rect: add it into a target that
406        // already carries a rect referencing `filter=(token)"noir"`, then assert
407        // the result validates clean and compiles to scene commands.
408        const TARGET_WITH_RECT: &str = r#"zenith version=1 {
409  project id="proj.x" name="Target"
410  tokens format="zenith-token-v1" {}
411  styles {}
412  document id="d" title="x" {
413    page id="pg" w=(px)800 h=(px)600 {
414      rect id="r" x=(px)10 y=(px)10 w=(px)100 h=(px)100 filter=(token)"noir"
415    }
416  }
417}
418"#;
419        let applied = add(
420            TARGET_WITH_RECT,
421            "@zenith/filters#noir",
422            None,
423            None,
424            (0.0, 0.0),
425            None,
426        )
427        .expect("add into rect target ok");
428        let applied_src = String::from_utf8(applied.formatted).expect("utf8");
429        let applied_doc = KdlAdapter
430            .parse(applied_src.as_bytes())
431            .expect("reparse applied");
432        let applied_errors: Vec<_> = validate(&applied_doc)
433            .diagnostics
434            .into_iter()
435            .filter(|d| d.severity == Severity::Error)
436            .collect();
437        assert!(
438            applied_errors.is_empty(),
439            "applied errors: {:?}",
440            applied_errors
441        );
442        let artifact = crate::commands::render::to_scene_json(
443            &applied_src,
444            None,
445            1,
446            &crate::config::CliPolicyFlags::default(),
447            None,
448        )
449        .expect("compile ok");
450        let scene: serde_json::Value =
451            serde_json::from_str(&artifact.json).expect("scene json parses");
452        let commands = scene["commands"].as_array().expect("commands array");
453        assert!(!commands.is_empty(), "applied filter compiles to commands");
454    }
455
456    #[test]
457    fn add_action_accepted_applies_tx_and_writes_provenance() {
458        // Pack source with an action that updates token color.brand to #e11d48.
459        // Raw string uses r##"..."## to avoid early termination on "#e11d48".
460        const ACTION_PACK_SRC: &str = r##"zenith version=1 {
461  project id="@test/actions" name="Test Actions"
462  libraries { library id="@test/actions" version="1.0.0" }
463  actions {
464    action id="apply-brand-kit" {
465      tx "{\"ops\":[{\"op\":\"update_token_value\",\"id\":\"color.brand\",\"value\":\"#e11d48\"}]}"
466    }
467  }
468  document id="d" title="x" {
469    page id="pg" w=(px)100 h=(px)100 {}
470  }
471}
472"##;
473        // Target document that declares the token the action will update.
474        const TARGET_WITH_TOKEN: &str = r##"zenith version=1 {
475  project id="proj.x" name="Target"
476  tokens format="zenith-token-v1" {
477    token id="color.brand" type="color" value="#111111"
478  }
479  styles {}
480  document id="d" title="x" {
481    page id="pg" w=(px)800 h=(px)600 {}
482  }
483}
484"##;
485
486        let dir = tempfile::tempdir().expect("tempdir");
487        let lib_dir = dir.path().join("libraries");
488        std::fs::create_dir_all(&lib_dir).expect("create libraries dir");
489        std::fs::write(lib_dir.join("actions.zen"), ACTION_PACK_SRC).expect("write pack");
490
491        let result = add(
492            TARGET_WITH_TOKEN,
493            "@test/actions#apply-brand-kit",
494            Some(dir.path()),
495            None,
496            (0.0, 0.0),
497            None,
498        )
499        .expect("action add ok");
500
501        let src = String::from_utf8(result.formatted).expect("utf8");
502        assert!(src.contains("#e11d48"), "updated value in output: {}", src);
503        assert!(
504            result.summary.contains("apply-brand-kit"),
505            "summary mentions action id: {}",
506            result.summary
507        );
508        assert!(
509            result.summary.contains("provenance"),
510            "summary mentions provenance: {}",
511            result.summary
512        );
513    }
514
515    #[test]
516    fn add_action_rejected_returns_error_exit_1() {
517        // Action targets a non-existent token — tx will be rejected.
518        const ACTION_PACK_SRC: &str = r##"zenith version=1 {
519  project id="@test/actions" name="Test Actions"
520  libraries { library id="@test/actions" version="1.0.0" }
521  actions {
522    action id="bad-action" {
523      tx "{\"ops\":[{\"op\":\"update_token_value\",\"id\":\"no.such.token\",\"value\":\"#fff\"}]}"
524    }
525  }
526  document id="d" title="x" {
527    page id="pg" w=(px)100 h=(px)100 {}
528  }
529}
530"##;
531
532        let dir = tempfile::tempdir().expect("tempdir");
533        let lib_dir = dir.path().join("libraries");
534        std::fs::create_dir_all(&lib_dir).expect("create libraries dir");
535        std::fs::write(lib_dir.join("actions.zen"), ACTION_PACK_SRC).expect("write pack");
536
537        let err = add(
538            TARGET_SRC,
539            "@test/actions#bad-action",
540            Some(dir.path()),
541            None,
542            (0.0, 0.0),
543            None,
544        )
545        .expect_err("rejected action must return an error");
546
547        assert_eq!(err.exit_code, 1, "exit_code must be 1 for rejected tx");
548        assert!(
549            err.message.contains("rejected"),
550            "msg must mention rejected: {}",
551            err.message
552        );
553    }
554
555    #[test]
556    fn add_component_without_page_errors() {
557        let err = add(
558            TARGET_SRC,
559            "@zenith/flowchart#decision",
560            None,
561            None,
562            (0.0, 0.0),
563            None,
564        )
565        .expect_err("component without page errors");
566        assert_eq!(err.exit_code, 2);
567        assert!(
568            err.message.contains("--page"),
569            "msg should ask for --page: {}",
570            err.message
571        );
572    }
573}