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::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
246pub 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
296pub(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
312pub(crate) fn px(value: f64) -> Dimension {
314 Dimension {
315 value,
316 unit: Unit::Px,
317 }
318}
319
320pub(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 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
338pub(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
351pub(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
376pub(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}