Skip to main content

zenith_cli/library/
component.rs

1//! Component materialization: `library add` of a COMPONENT item.
2
3use std::collections::BTreeMap;
4
5use zenith_core::{ComponentDef, Document, InstanceNode, LibraryDef, Node, ProvenanceDef};
6
7use super::add::{
8    AddError, AddOutcome, collect_all_ids, copy_assets, copy_styles, copy_tokens,
9    load_pack_document, px, target_component_id, unique_id, unknown_package_error,
10};
11use super::registry::LibraryPack;
12
13/// Materialize the pack item `pkg_id#item` into `target` at `(at_x, at_y)` on the
14/// page `page_id`, returning the [`AddOutcome`] describing what was added.
15///
16/// This is the PURE core of `library add`: it mutates the parsed `target`
17/// [`Document`] in place and performs NO filesystem or process I/O (the caller
18/// resolves the pack set, reads files, formats, and writes). Steps:
19///
20/// 1. Resolve the FIRST pack in `packs` whose id == `pkg_id` (project shadows
21///    preset); load its full [`Document`] and find the `ComponentDef` == `item`.
22/// 2. Copy that component into `target` under a namespaced id
23///    (`lib.<sanitized-pkg>.<item>`), REUSING an existing copy if present (dedup).
24///    Child ids are left untouched (instance expansion prefixes them at compile).
25/// 3. Copy ALL of the pack's tokens/styles/assets into `target`, deduping by id;
26///    a same-id-but-different-definition collision keeps the target's existing
27///    one and records a `library.dependency_conflict` warning.
28/// 4. Generate a unique instance id (base = `id_base`) against ALL target ids,
29///    insert an [`InstanceNode`] referencing the copied component onto the page.
30/// 5. Record a `libraries` entry for `pkg_id` (if absent) and a unique
31///    `provenance` record linking the instance to the item.
32///
33/// # Errors
34///
35/// Returns [`AddError`] when the package or item is unknown (the message lists
36/// the available options), or the page id is not found.
37pub fn materialize(
38    target: &mut Document,
39    packs: &[LibraryPack],
40    pkg_id: &str,
41    item: &str,
42    page_id: &str,
43    id_base: &str,
44    at: (f64, f64),
45) -> Result<AddOutcome, AddError> {
46    let (at_x, at_y) = at;
47    // 1. Resolve the pack + load its document. ────────────────────────────────
48    let pack = packs
49        .iter()
50        .find(|p| p.id == pkg_id)
51        .ok_or_else(|| unknown_package_error(pkg_id, packs))?;
52
53    let pack_doc = load_pack_document(pack)?;
54
55    let comp = pack_doc
56        .components
57        .iter()
58        .find(|c| c.id == item)
59        .ok_or_else(|| {
60            let available: Vec<&str> = pack_doc.components.iter().map(|c| c.id.as_str()).collect();
61            AddError::new(format!(
62                "unknown item '{}' in package '{}' (available: {})",
63                item,
64                pkg_id,
65                if available.is_empty() {
66                    "none".to_owned()
67                } else {
68                    available.join(", ")
69                }
70            ))
71        })?;
72
73    // Verify the target page exists BEFORE mutating anything, so an unknown page
74    // leaves the target document untouched.
75    if !target.body.pages.iter().any(|p| p.id == page_id) {
76        let available: Vec<&str> = target.body.pages.iter().map(|p| p.id.as_str()).collect();
77        return Err(AddError::new(format!(
78            "page '{}' not found in target document (available: {})",
79            page_id,
80            if available.is_empty() {
81                "none".to_owned()
82            } else {
83                available.join(", ")
84            }
85        )));
86    }
87
88    let mut warnings: Vec<String> = Vec::new();
89
90    // 2. Copy the component (dedup by namespaced id). ──────────────────────────
91    let comp_id = target_component_id(pkg_id, item);
92    if !target.components.iter().any(|c| c.id == comp_id) {
93        target.components.push(ComponentDef {
94            id: comp_id.clone(),
95            children: comp.children.clone(),
96            source_span: None,
97        });
98    }
99
100    // 3. Copy dependency tokens/styles/assets (dedup by id). ───────────────────
101    // Ensure the target's tokens block has a format (adopt the pack's when empty).
102    if target.tokens.format.is_empty() {
103        target.tokens.format = pack_doc.tokens.format.clone();
104    }
105    copy_tokens(
106        &pack_doc.tokens.tokens,
107        &mut target.tokens.tokens,
108        &mut warnings,
109    );
110    copy_styles(
111        &pack_doc.styles.styles,
112        &mut target.styles.styles,
113        &mut warnings,
114    );
115    copy_assets(
116        &pack_doc.assets.assets,
117        &mut target.assets.assets,
118        &mut warnings,
119    );
120
121    // 4. Generate a unique instance id + insert the instance on the page. ──────
122    let mut all_ids = collect_all_ids(target);
123    let instance_id = unique_id(id_base, &all_ids);
124    all_ids.insert(instance_id.clone());
125
126    let instance = InstanceNode {
127        id: instance_id.clone(),
128        name: None,
129        role: None,
130        component: comp_id.clone(),
131        x: Some(px(at_x)),
132        y: Some(px(at_y)),
133        opacity: None,
134        visible: None,
135        locked: None,
136        overrides: Vec::new(),
137        source_span: None,
138        unknown_props: BTreeMap::new(),
139    };
140
141    // The page is guaranteed to exist (checked above); push the instance at the
142    // end of its children = top of z-order.
143    if let Some(page) = target.body.pages.iter_mut().find(|p| p.id == page_id) {
144        page.children.push(Node::Instance(instance));
145    }
146
147    // 5. Record libraries + provenance. ────────────────────────────────────────
148    let provenance_id = unique_id(&format!("prov.{}", instance_id), &all_ids);
149
150    if !target.libraries.iter().any(|l| l.id == pkg_id) {
151        target.libraries.push(LibraryDef {
152            id: pkg_id.to_owned(),
153            version: pack.version.clone(),
154            hash: None,
155            source_span: None,
156            unknown_props: BTreeMap::new(),
157        });
158    }
159
160    target.provenance.push(ProvenanceDef {
161        id: provenance_id.clone(),
162        node: instance_id.clone(),
163        library: pkg_id.to_owned(),
164        item: Some(item.to_owned()),
165        linked: Some(true),
166        source_span: None,
167        unknown_props: BTreeMap::new(),
168    });
169
170    Ok(AddOutcome {
171        pkg_id: pkg_id.to_owned(),
172        item: item.to_owned(),
173        target_component_id: comp_id,
174        instance_id,
175        provenance_id,
176        warnings,
177    })
178}