Skip to main content

zenith_cli/library/
add.rs

1//! Shared materialization machinery for `library add`.
2//!
3//! Holds the pieces every `materialize*` flavor needs: the [`AddError`] /
4//! [`AddOutcome`] contract, spec parsing ([`parse_spec`]), loading a resolved
5//! pack's full [`Document`] ([`load_pack_document`]), id collection / uniquing
6//! ([`collect_all_ids`], [`unique_id`]), and the dedup-with-conflict-warning
7//! copiers for tokens / styles / assets.
8
9use std::collections::BTreeSet;
10
11use zenith_core::{
12    AssetDecl, Dimension, Document, KdlAdapter, KdlSource, Node, Style, Token, Unit,
13};
14
15use super::registry::{EMBEDDED_PACKS, LibraryPack, PackSource};
16
17/// An error produced while materializing a library item into a target document.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct AddError {
20    /// Human-readable message describing the failure.
21    pub message: String,
22}
23
24impl AddError {
25    pub(super) fn new(message: impl Into<String>) -> Self {
26        Self {
27            message: message.into(),
28        }
29    }
30}
31
32impl std::fmt::Display for AddError {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.write_str(&self.message)
35    }
36}
37
38impl std::error::Error for AddError {}
39
40/// The outcome of a successful [`super::materialize`] call.
41///
42/// All ids are the FINAL ids written into the target document, so the caller can
43/// build a deterministic human/JSON summary without re-deriving them.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct AddOutcome {
46    /// The package id the item came from (e.g. `@zenith/flowchart`).
47    pub pkg_id: String,
48    /// The item name within the pack (e.g. `decision`).
49    pub item: String,
50    /// The namespaced target component id the item was copied to.
51    pub target_component_id: String,
52    /// The unique id of the inserted instance node.
53    pub instance_id: String,
54    /// The unique id of the recorded provenance entry.
55    pub provenance_id: String,
56    /// Non-fatal dependency-conflict warnings (a pack token/style/asset id that
57    /// already existed in the target with a DIFFERENT definition; the target's
58    /// existing definition was kept). Each entry is a `library.dependency_conflict`
59    /// human-readable line.
60    pub warnings: Vec<String>,
61}
62
63/// Parse a `pkg#item` spec into `(pkg_id, item)`.
64///
65/// # Errors
66///
67/// Returns [`AddError`] when the spec has no `#`, or either side is empty.
68pub fn parse_spec(spec: &str) -> Result<(String, String), AddError> {
69    let (pkg, item) = spec.split_once('#').ok_or_else(|| {
70        AddError::new(format!(
71            "malformed item spec {:?} (expected `<package>#<item>`, e.g. \
72             `@zenith/flowchart#decision`)",
73            spec
74        ))
75    })?;
76    if pkg.is_empty() || item.is_empty() {
77        return Err(AddError::new(format!(
78            "malformed item spec {:?} (both package and item must be non-empty, \
79             e.g. `@zenith/flowchart#decision`)",
80            spec
81        )));
82    }
83    Ok((pkg.to_owned(), item.to_owned()))
84}
85
86/// Load the FULL [`Document`] of a resolved pack.
87///
88/// [`super::resolve_packs`] only yields pack METADATA; materialization needs the
89/// pack's component/token/style/asset subtrees, so this re-reads and parses the
90/// pack's source: embedded presets from [`EMBEDDED_PACKS`], project packs from
91/// disk.
92///
93/// # Errors
94///
95/// Returns [`AddError`] when the embedded source for `pack.id` cannot be located,
96/// or when a project pack file cannot be read or parsed.
97pub fn load_pack_document(pack: &LibraryPack) -> Result<Document, AddError> {
98    let source = match &pack.source {
99        PackSource::Preset => EMBEDDED_PACKS
100            .iter()
101            .find(|(id, _)| *id == pack.id)
102            .map(|(_, src)| (*src).to_owned())
103            .ok_or_else(|| {
104                AddError::new(format!("embedded pack '{}' source not found", pack.id))
105            })?,
106        PackSource::Project(path) => std::fs::read_to_string(path).map_err(|e| {
107            AddError::new(format!("error reading pack '{}': {}", path.display(), e))
108        })?,
109    };
110    KdlAdapter
111        .parse(source.as_bytes())
112        .map_err(|e| AddError::new(format!("error parsing pack '{}': {}", pack.id, e)))
113}
114
115/// Build the "unknown library package" [`AddError`], listing the available pack
116/// ids (sorted, de-duplicated) so the caller sees what they could have meant.
117pub(super) fn unknown_package_error(pkg_id: &str, packs: &[LibraryPack]) -> AddError {
118    let mut available: Vec<&str> = packs.iter().map(|p| p.id.as_str()).collect();
119    available.sort_unstable();
120    available.dedup();
121    AddError::new(format!(
122        "unknown library package '{}' (available: {})",
123        pkg_id,
124        if available.is_empty() {
125            "none".to_owned()
126        } else {
127            available.join(", ")
128        }
129    ))
130}
131
132/// Sanitize a package id into a safe component-id fragment.
133///
134/// Replaces `@` and `/` (and any other non-`[A-Za-z0-9._-]` byte) with `.`,
135/// collapsing the result so `@zenith/flowchart` → `zenith.flowchart`.
136pub(crate) fn sanitize_pkg(pkg_id: &str) -> String {
137    let mut out = String::with_capacity(pkg_id.len());
138    let mut prev_dot = false;
139    for ch in pkg_id.chars() {
140        if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
141            out.push(ch);
142            prev_dot = ch == '.';
143        } else {
144            // Collapse runs of separators (e.g. a leading '@') into a single '.'.
145            if !prev_dot && !out.is_empty() {
146                out.push('.');
147                prev_dot = true;
148            }
149        }
150    }
151    // Trim a trailing separator.
152    while out.ends_with('.') {
153        out.pop();
154    }
155    out
156}
157
158/// The namespaced target component id for a pack item, e.g.
159/// `lib.zenith.flowchart.decision`.
160pub(crate) fn target_component_id(pkg_id: &str, item: &str) -> String {
161    format!("lib.{}.{}", sanitize_pkg(pkg_id), item)
162}
163
164/// Recursively insert every id-bearing node id under `children` into `out`,
165/// descending into every container (group/frame/instance has no children, table
166/// cells do). Mirrors the validator's `collect_local_ids` but ALSO captures the
167/// `Unknown` node id when present (forward-compat: an unknown node may still be
168/// addressable), so dedup never accidentally reuses a taken id.
169fn collect_node_ids(children: &[Node], out: &mut BTreeSet<String>) {
170    for child in children {
171        match child {
172            Node::Rect(n) => {
173                out.insert(n.id.clone());
174            }
175            Node::Ellipse(n) => {
176                out.insert(n.id.clone());
177            }
178            Node::Line(n) => {
179                out.insert(n.id.clone());
180            }
181            Node::Text(n) => {
182                out.insert(n.id.clone());
183            }
184            Node::Code(n) => {
185                out.insert(n.id.clone());
186            }
187            Node::Image(n) => {
188                out.insert(n.id.clone());
189            }
190            Node::Polygon(n) => {
191                out.insert(n.id.clone());
192            }
193            Node::Polyline(n) => {
194                out.insert(n.id.clone());
195            }
196            Node::Frame(n) => {
197                out.insert(n.id.clone());
198                collect_node_ids(&n.children, out);
199            }
200            Node::Group(n) => {
201                out.insert(n.id.clone());
202                collect_node_ids(&n.children, out);
203            }
204            Node::Instance(n) => {
205                out.insert(n.id.clone());
206            }
207            Node::Field(n) => {
208                out.insert(n.id.clone());
209            }
210            Node::Toc(n) => {
211                out.insert(n.id.clone());
212            }
213            Node::Footnote(n) => {
214                out.insert(n.id.clone());
215            }
216            Node::Table(n) => {
217                out.insert(n.id.clone());
218                for row in &n.rows {
219                    for cell in &row.cells {
220                        collect_node_ids(&cell.children, out);
221                    }
222                }
223            }
224            Node::Shape(n) => {
225                out.insert(n.id.clone());
226            }
227            Node::Connector(n) => {
228                out.insert(n.id.clone());
229            }
230            Node::Pattern(n) => {
231                out.insert(n.id.clone());
232            }
233            Node::Chart(n) => {
234                out.insert(n.id.clone());
235            }
236            Node::Light(n) => {
237                out.insert(n.id.clone());
238            }
239            Node::Mesh(n) => {
240                out.insert(n.id.clone());
241            }
242            Node::Unknown(n) => {
243                if let Some(id) = &n.id {
244                    out.insert(id.clone());
245                }
246                collect_node_ids(&n.children, out);
247            }
248        }
249    }
250}
251
252/// Collect EVERY id declared anywhere in `doc` into one set, used to generate
253/// unique instance/provenance ids that cannot collide with anything in the
254/// target: every node id (recursively, across pages, masters, and components),
255/// plus all block-level ids (tokens, styles, assets, libraries, components,
256/// masters, sections, provenance, pages, the document id, and the project id).
257///
258/// Deterministic and side-effect-free.
259pub fn collect_all_ids(doc: &Document) -> BTreeSet<String> {
260    let mut ids = BTreeSet::new();
261
262    if let Some(project) = &doc.project {
263        ids.insert(project.id.clone());
264    }
265    ids.insert(doc.body.id.clone());
266
267    for t in &doc.tokens.tokens {
268        ids.insert(t.id.clone());
269    }
270    for s in &doc.styles.styles {
271        ids.insert(s.id.clone());
272    }
273    for a in &doc.assets.assets {
274        ids.insert(a.id.clone());
275    }
276    for l in &doc.libraries {
277        ids.insert(l.id.clone());
278    }
279    for p in &doc.provenance {
280        ids.insert(p.id.clone());
281    }
282    for s in &doc.sections {
283        ids.insert(s.id.clone());
284    }
285
286    for comp in &doc.components {
287        ids.insert(comp.id.clone());
288        collect_node_ids(&comp.children, &mut ids);
289    }
290    for master in &doc.masters {
291        ids.insert(master.id.clone());
292        collect_node_ids(&master.children, &mut ids);
293    }
294    for page in &doc.body.pages {
295        ids.insert(page.id.clone());
296        collect_node_ids(&page.children, &mut ids);
297    }
298
299    ids
300}
301
302/// Deterministically pick `base`, or `base.1`, `base.2`, … — the first variant
303/// not present in `taken`.
304pub(super) fn unique_id(base: &str, taken: &BTreeSet<String>) -> String {
305    if !taken.contains(base) {
306        return base.to_owned();
307    }
308    let mut n = 1u64;
309    loop {
310        let candidate = format!("{}.{}", base, n);
311        if !taken.contains(&candidate) {
312            return candidate;
313        }
314        n += 1;
315    }
316}
317
318/// A pixel [`Dimension`].
319pub(crate) fn px(value: f64) -> Dimension {
320    Dimension {
321        value,
322        unit: Unit::Px,
323    }
324}
325
326/// Copy pack tokens into `target` tokens, deduping by id; a same-id-different-
327/// value collision keeps the existing token and records a conflict warning.
328pub(super) fn copy_tokens(pack: &[Token], target: &mut Vec<Token>, warnings: &mut Vec<String>) {
329    for tok in pack {
330        match target.iter().find(|t| t.id == tok.id) {
331            // Compare by semantic fields only (type + value); `source_span`
332            // differs between parses and is not a real conflict.
333            Some(existing)
334                if existing.token_type != tok.token_type || existing.value != tok.value =>
335            {
336                warnings.push(dependency_conflict("token", &tok.id));
337            }
338            Some(_) => {}
339            None => target.push(tok.clone()),
340        }
341    }
342}
343
344/// Copy pack styles into `target` styles, deduping by id (see [`copy_tokens`]).
345pub(super) fn copy_styles(pack: &[Style], target: &mut Vec<Style>, warnings: &mut Vec<String>) {
346    for st in pack {
347        match target.iter().find(|t| t.id == st.id) {
348            Some(existing) if existing.properties != st.properties => {
349                warnings.push(dependency_conflict("style", &st.id));
350            }
351            Some(_) => {}
352            None => target.push(st.clone()),
353        }
354    }
355}
356
357/// Copy pack assets into `target` assets, deduping by id (see [`copy_tokens`]).
358///
359/// Conflict detection compares `kind`, `src`, AND `sha256`: a same-id asset
360/// with a different SHA-256 digest is a real semantic conflict (same path,
361/// different content integrity assertion) and warrants a dependency warning.
362pub(super) fn copy_assets(
363    pack: &[AssetDecl],
364    target: &mut Vec<AssetDecl>,
365    warnings: &mut Vec<String>,
366) {
367    for asset in pack {
368        match target.iter().find(|a| a.id == asset.id) {
369            Some(existing)
370                if existing.kind != asset.kind
371                    || existing.src != asset.src
372                    || existing.sha256 != asset.sha256 =>
373            {
374                warnings.push(dependency_conflict("asset", &asset.id));
375            }
376            Some(_) => {}
377            None => target.push(asset.clone()),
378        }
379    }
380}
381
382/// A `library.dependency_conflict` warning line for a kept-existing dependency.
383pub(super) fn dependency_conflict(kind: &str, id: &str) -> String {
384    format!(
385        "library.dependency_conflict: {} '{}' already exists in the target with a \
386         different definition; kept the existing one",
387        kind, id
388    )
389}