zenith_cli/library/
add.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct AddError {
20 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#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct AddOutcome {
46 pub pkg_id: String,
48 pub item: String,
50 pub target_component_id: String,
52 pub instance_id: String,
54 pub provenance_id: String,
56 pub warnings: Vec<String>,
61}
62
63pub 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
86pub 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
115pub(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
132pub(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 if !prev_dot && !out.is_empty() {
146 out.push('.');
147 prev_dot = true;
148 }
149 }
150 }
151 while out.ends_with('.') {
153 out.pop();
154 }
155 out
156}
157
158pub(crate) fn target_component_id(pkg_id: &str, item: &str) -> String {
161 format!("lib.{}.{}", sanitize_pkg(pkg_id), item)
162}
163
164fn 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
252pub 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
302pub(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
318pub(crate) fn px(value: f64) -> Dimension {
320 Dimension {
321 value,
322 unit: Unit::Px,
323 }
324}
325
326pub(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 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
344pub(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
357pub(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
382pub(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}