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::Unknown(n) => {
237                if let Some(id) = &n.id {
238                    out.insert(id.clone());
239                }
240                collect_node_ids(&n.children, out);
241            }
242        }
243    }
244}
245
246/// Collect EVERY id declared anywhere in `doc` into one set, used to generate
247/// unique instance/provenance ids that cannot collide with anything in the
248/// target: every node id (recursively, across pages, masters, and components),
249/// plus all block-level ids (tokens, styles, assets, libraries, components,
250/// masters, sections, provenance, pages, the document id, and the project id).
251///
252/// Deterministic and side-effect-free.
253pub fn collect_all_ids(doc: &Document) -> BTreeSet<String> {
254    let mut ids = BTreeSet::new();
255
256    if let Some(project) = &doc.project {
257        ids.insert(project.id.clone());
258    }
259    ids.insert(doc.body.id.clone());
260
261    for t in &doc.tokens.tokens {
262        ids.insert(t.id.clone());
263    }
264    for s in &doc.styles.styles {
265        ids.insert(s.id.clone());
266    }
267    for a in &doc.assets.assets {
268        ids.insert(a.id.clone());
269    }
270    for l in &doc.libraries {
271        ids.insert(l.id.clone());
272    }
273    for p in &doc.provenance {
274        ids.insert(p.id.clone());
275    }
276    for s in &doc.sections {
277        ids.insert(s.id.clone());
278    }
279
280    for comp in &doc.components {
281        ids.insert(comp.id.clone());
282        collect_node_ids(&comp.children, &mut ids);
283    }
284    for master in &doc.masters {
285        ids.insert(master.id.clone());
286        collect_node_ids(&master.children, &mut ids);
287    }
288    for page in &doc.body.pages {
289        ids.insert(page.id.clone());
290        collect_node_ids(&page.children, &mut ids);
291    }
292
293    ids
294}
295
296/// Deterministically pick `base`, or `base.1`, `base.2`, … — the first variant
297/// not present in `taken`.
298pub(super) fn unique_id(base: &str, taken: &BTreeSet<String>) -> String {
299    if !taken.contains(base) {
300        return base.to_owned();
301    }
302    let mut n = 1u64;
303    loop {
304        let candidate = format!("{}.{}", base, n);
305        if !taken.contains(&candidate) {
306            return candidate;
307        }
308        n += 1;
309    }
310}
311
312/// A pixel [`Dimension`].
313pub(crate) fn px(value: f64) -> Dimension {
314    Dimension {
315        value,
316        unit: Unit::Px,
317    }
318}
319
320/// Copy pack tokens into `target` tokens, deduping by id; a same-id-different-
321/// value collision keeps the existing token and records a conflict warning.
322pub(super) fn copy_tokens(pack: &[Token], target: &mut Vec<Token>, warnings: &mut Vec<String>) {
323    for tok in pack {
324        match target.iter().find(|t| t.id == tok.id) {
325            // Compare by semantic fields only (type + value); `source_span`
326            // differs between parses and is not a real conflict.
327            Some(existing)
328                if existing.token_type != tok.token_type || existing.value != tok.value =>
329            {
330                warnings.push(dependency_conflict("token", &tok.id));
331            }
332            Some(_) => {}
333            None => target.push(tok.clone()),
334        }
335    }
336}
337
338/// Copy pack styles into `target` styles, deduping by id (see [`copy_tokens`]).
339pub(super) fn copy_styles(pack: &[Style], target: &mut Vec<Style>, warnings: &mut Vec<String>) {
340    for st in pack {
341        match target.iter().find(|t| t.id == st.id) {
342            Some(existing) if existing.properties != st.properties => {
343                warnings.push(dependency_conflict("style", &st.id));
344            }
345            Some(_) => {}
346            None => target.push(st.clone()),
347        }
348    }
349}
350
351/// Copy pack assets into `target` assets, deduping by id (see [`copy_tokens`]).
352///
353/// Conflict detection compares `kind`, `src`, AND `sha256`: a same-id asset
354/// with a different SHA-256 digest is a real semantic conflict (same path,
355/// different content integrity assertion) and warrants a dependency warning.
356pub(super) fn copy_assets(
357    pack: &[AssetDecl],
358    target: &mut Vec<AssetDecl>,
359    warnings: &mut Vec<String>,
360) {
361    for asset in pack {
362        match target.iter().find(|a| a.id == asset.id) {
363            Some(existing)
364                if existing.kind != asset.kind
365                    || existing.src != asset.src
366                    || existing.sha256 != asset.sha256 =>
367            {
368                warnings.push(dependency_conflict("asset", &asset.id));
369            }
370            Some(_) => {}
371            None => target.push(asset.clone()),
372        }
373    }
374}
375
376/// A `library.dependency_conflict` warning line for a kept-existing dependency.
377pub(super) fn dependency_conflict(kind: &str, id: &str) -> String {
378    format!(
379        "library.dependency_conflict: {} '{}' already exists in the target with a \
380         different definition; kept the existing one",
381        kind, id
382    )
383}