1use std::path::Path;
2
3use zenith_core::{KdlAdapter, KdlSource, Severity, validate};
4use zenith_tx::TxStatus;
5
6use crate::library::{ItemKind, parse_spec, resolve_packs};
7
8#[derive(Debug)]
10pub struct AddCmdErr {
11 pub message: String,
13 pub exit_code: u8,
15}
16
17impl AddCmdErr {
18 fn new(message: impl Into<String>, exit_code: u8) -> Self {
19 Self {
20 message: message.into(),
21 exit_code,
22 }
23 }
24}
25
26#[derive(Debug)]
29pub struct AddResult {
30 pub formatted: Vec<u8>,
32 pub summary: String,
34}
35
36pub fn add(
59 target_src: &str,
60 spec: &str,
61 project_dir: Option<&Path>,
62 page: Option<&str>,
63 at: (f64, f64),
64 id_override: Option<&str>,
65) -> Result<AddResult, AddCmdErr> {
66 let (pkg_id, item) = parse_spec(spec).map_err(|e| AddCmdErr::new(e.message, 2))?;
67
68 let mut target = KdlAdapter
69 .parse(target_src.as_bytes())
70 .map_err(|e| AddCmdErr::new(format!("parse error: {}", e.message), 2))?;
71
72 let packs = resolve_packs(project_dir);
73 let id_base = id_override.unwrap_or(item.as_str());
74
75 let item_kind = packs
79 .iter()
80 .find(|p| p.id == pkg_id)
81 .and_then(|p| p.items.iter().find(|it| it.id == item))
82 .map(|it| it.kind);
83
84 let summary = match item_kind {
85 Some(ItemKind::Action) => {
86 let outcome = crate::library::materialize_action(target_src, &packs, &pkg_id, &item)
87 .map_err(|e| AddCmdErr::new(e.message, 2))?;
88
89 let status_label = match outcome.tx_result.status {
92 TxStatus::Rejected => {
93 let diag_lines: Vec<String> = outcome
94 .tx_result
95 .diagnostics
96 .iter()
97 .map(crate::commands::format_diagnostic_line)
98 .collect();
99 return Err(AddCmdErr::new(
100 format!(
101 "action '{}#{}' was rejected:\n{}",
102 pkg_id,
103 item,
104 diag_lines.join("\n")
105 ),
106 1,
107 ));
108 }
109 TxStatus::Accepted => "accepted",
110 TxStatus::AcceptedWithWarnings => "accepted-with-warnings",
111 };
112
113 let final_source = outcome.final_source.ok_or_else(|| {
114 AddCmdErr::new("internal error: accepted action produced no source", 2)
115 })?;
116
117 let result_doc = KdlAdapter.parse(final_source.as_bytes()).map_err(|e| {
118 AddCmdErr::new(
119 format!(
120 "internal error: could not re-parse action result: {}",
121 e.message
122 ),
123 2,
124 )
125 })?;
126
127 let formatted = validate_and_format(&result_doc)?;
128
129 let affected = if outcome.tx_result.affected_node_ids.is_empty() {
130 "none".to_owned()
131 } else {
132 outcome.tx_result.affected_node_ids.join(", ")
133 };
134 let provenance_id = outcome.provenance_id.unwrap_or_default();
135 let mut summary = String::new();
136 summary.push_str(&format!(
137 "applied {}#{} ({})\n",
138 outcome.pkg_id, outcome.item, status_label
139 ));
140 summary.push_str(&format!(" affected: {}\n", affected));
141 summary.push_str(&format!(" provenance: {}", provenance_id));
142 for w in &outcome.warnings {
143 summary.push_str(&format!("\n warning: {}", w));
144 }
145 return Ok(AddResult { formatted, summary });
146 }
147 Some(ItemKind::Token) => {
148 let outcome =
150 crate::library::materialize_token(&mut target, &packs, &pkg_id, &item, id_base)
151 .map_err(|e| AddCmdErr::new(e.message, 2))?;
152 let deps = if outcome.dep_token_ids.is_empty() {
153 "none".to_owned()
154 } else {
155 outcome.dep_token_ids.join(", ")
156 };
157 let mut summary = String::new();
158 summary.push_str(&format!(
159 "added {}#{} as {} token '{}'\n",
160 outcome.pkg_id, outcome.item, outcome.apply_property, outcome.token_id
161 ));
162 summary.push_str(&format!(
163 " apply with: {}=(token)\"{}\"\n",
164 outcome.apply_property, outcome.token_id
165 ));
166 summary.push_str(&format!(" dependencies: {}\n", deps));
167 summary.push_str(&format!(" provenance: {}", outcome.provenance_id));
168 for w in &outcome.warnings {
169 summary.push_str(&format!("\n warning: {}", w));
170 }
171 summary
172 }
173 Some(ItemKind::Component) | None => {
178 let page = match item_kind {
179 Some(ItemKind::Component) => page.ok_or_else(|| {
180 AddCmdErr::new(
181 "page is required to add a component item (use --page <id>)",
182 2,
183 )
184 })?,
185 Some(ItemKind::Token) | Some(ItemKind::Action) | None => page.unwrap_or(""),
186 };
187 let outcome =
188 crate::library::materialize(&mut target, &packs, &pkg_id, &item, page, id_base, at)
189 .map_err(|e| AddCmdErr::new(e.message, 2))?;
190 let mut summary = String::new();
191 summary.push_str(&format!(
192 "added {}#{} as instance '{}' on page '{}'\n",
193 outcome.pkg_id, outcome.item, outcome.instance_id, page
194 ));
195 summary.push_str(&format!(" component: {}\n", outcome.target_component_id));
196 summary.push_str(&format!(" provenance: {}", outcome.provenance_id));
197 for w in &outcome.warnings {
198 summary.push_str(&format!("\n warning: {}", w));
199 }
200 summary
201 }
202 };
203
204 let formatted = validate_and_format(&target)?;
205 Ok(AddResult { formatted, summary })
206}
207
208fn validate_and_format(target: &zenith_core::Document) -> Result<Vec<u8>, AddCmdErr> {
211 let report = validate(target);
212 let errors: Vec<String> = report
213 .diagnostics
214 .iter()
215 .filter(|d| d.severity == Severity::Error)
216 .map(crate::commands::format_diagnostic_line)
217 .collect();
218 if !errors.is_empty() {
219 return Err(AddCmdErr::new(
220 format!(
221 "materialized document has {} validation error(s):\n{}",
222 errors.len(),
223 errors.join("\n")
224 ),
225 1,
226 ));
227 }
228 KdlAdapter
229 .format(target)
230 .map_err(|e| AddCmdErr::new(format!("format error: {}", e.message), 2))
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 const TARGET_SRC: &str = r#"zenith version=1 {
240 project id="proj.x" name="Target"
241 tokens format="zenith-token-v1" {}
242 styles {}
243 document id="d" title="x" {
244 page id="pg" w=(px)800 h=(px)600 {}
245 }
246}
247"#;
248
249 #[test]
250 fn add_produces_formatted_doc_that_round_trips_and_compiles() {
251 let result = add(
252 TARGET_SRC,
253 "@zenith/flowchart#decision",
254 None,
255 Some("pg"),
256 (120.0, 80.0),
257 None,
258 )
259 .expect("add ok");
260
261 let src = String::from_utf8(result.formatted).expect("utf8");
263 let doc = KdlAdapter.parse(src.as_bytes()).expect("reparse");
264 let errors: Vec<_> = validate(&doc)
265 .diagnostics
266 .into_iter()
267 .filter(|d| d.severity == Severity::Error)
268 .collect();
269 assert!(errors.is_empty(), "errors: {:?}", errors);
270
271 assert!(
273 result.summary.contains("decision"),
274 "summary: {}",
275 result.summary
276 );
277 assert!(
278 result.summary.contains("lib.zenith.flowchart.decision"),
279 "summary: {}",
280 result.summary
281 );
282
283 let artifact = crate::commands::render::to_scene_json(
286 &src,
287 None,
288 1,
289 &crate::config::CliPolicyFlags::default(),
290 None,
291 )
292 .expect("compile ok");
293 let scene: serde_json::Value =
294 serde_json::from_str(&artifact.json).expect("scene json parses");
295 let commands = scene["commands"].as_array().expect("commands array");
296 assert!(
297 !commands.is_empty(),
298 "instance must expand to at least one scene command"
299 );
300 }
301
302 #[test]
303 fn add_malformed_spec_errors() {
304 let err = add(TARGET_SRC, "no-hash", None, Some("pg"), (0.0, 0.0), None)
305 .expect_err("malformed spec errors");
306 assert_eq!(err.exit_code, 2);
307 }
308
309 #[test]
310 fn add_unknown_page_errors() {
311 let err = add(
312 TARGET_SRC,
313 "@zenith/flowchart#decision",
314 None,
315 Some("nope"),
316 (0.0, 0.0),
317 None,
318 )
319 .expect_err("unknown page errors");
320 assert!(
321 err.message.contains("page 'nope' not found"),
322 "msg: {}",
323 err.message
324 );
325 }
326
327 #[test]
328 fn add_unknown_pkg_and_item_error() {
329 let e1 = add(
330 TARGET_SRC,
331 "@no/such#decision",
332 None,
333 Some("pg"),
334 (0.0, 0.0),
335 None,
336 )
337 .expect_err("unknown pkg");
338 assert!(e1.message.contains("@zenith/flowchart"), "{}", e1.message);
339 let e2 = add(
340 TARGET_SRC,
341 "@zenith/flowchart#nope",
342 None,
343 Some("pg"),
344 (0.0, 0.0),
345 None,
346 )
347 .expect_err("unknown item");
348 assert!(e2.message.contains("process"), "{}", e2.message);
349 }
350
351 #[test]
352 fn add_is_pure_on_input_string() {
353 let a = add(
356 TARGET_SRC,
357 "@zenith/flowchart#process",
358 None,
359 Some("pg"),
360 (0.0, 0.0),
361 None,
362 )
363 .expect("a");
364 let b = add(
365 TARGET_SRC,
366 "@zenith/flowchart#process",
367 None,
368 Some("pg"),
369 (0.0, 0.0),
370 None,
371 )
372 .expect("b");
373 assert_eq!(a.formatted, b.formatted, "add is deterministic + pure");
374 }
375
376 #[test]
377 fn add_filter_token_then_apply_compiles() {
378 let result = add(
379 TARGET_SRC,
380 "@zenith/filters#noir",
381 None,
382 None,
383 (0.0, 0.0),
384 None,
385 )
386 .expect("add filter token ok");
387
388 let src = String::from_utf8(result.formatted).expect("utf8");
390 let doc = KdlAdapter.parse(src.as_bytes()).expect("reparse");
391 let errors: Vec<_> = validate(&doc)
392 .diagnostics
393 .into_iter()
394 .filter(|d| d.severity == Severity::Error)
395 .collect();
396 assert!(errors.is_empty(), "errors: {:?}", errors);
397
398 assert!(
400 result.summary.contains("filter=(token)\"noir\""),
401 "summary: {}",
402 result.summary
403 );
404
405 const TARGET_WITH_RECT: &str = r#"zenith version=1 {
409 project id="proj.x" name="Target"
410 tokens format="zenith-token-v1" {}
411 styles {}
412 document id="d" title="x" {
413 page id="pg" w=(px)800 h=(px)600 {
414 rect id="r" x=(px)10 y=(px)10 w=(px)100 h=(px)100 filter=(token)"noir"
415 }
416 }
417}
418"#;
419 let applied = add(
420 TARGET_WITH_RECT,
421 "@zenith/filters#noir",
422 None,
423 None,
424 (0.0, 0.0),
425 None,
426 )
427 .expect("add into rect target ok");
428 let applied_src = String::from_utf8(applied.formatted).expect("utf8");
429 let applied_doc = KdlAdapter
430 .parse(applied_src.as_bytes())
431 .expect("reparse applied");
432 let applied_errors: Vec<_> = validate(&applied_doc)
433 .diagnostics
434 .into_iter()
435 .filter(|d| d.severity == Severity::Error)
436 .collect();
437 assert!(
438 applied_errors.is_empty(),
439 "applied errors: {:?}",
440 applied_errors
441 );
442 let artifact = crate::commands::render::to_scene_json(
443 &applied_src,
444 None,
445 1,
446 &crate::config::CliPolicyFlags::default(),
447 None,
448 )
449 .expect("compile ok");
450 let scene: serde_json::Value =
451 serde_json::from_str(&artifact.json).expect("scene json parses");
452 let commands = scene["commands"].as_array().expect("commands array");
453 assert!(!commands.is_empty(), "applied filter compiles to commands");
454 }
455
456 #[test]
457 fn add_action_accepted_applies_tx_and_writes_provenance() {
458 const ACTION_PACK_SRC: &str = r##"zenith version=1 {
461 project id="@test/actions" name="Test Actions"
462 libraries { library id="@test/actions" version="1.0.0" }
463 actions {
464 action id="apply-brand-kit" {
465 tx "{\"ops\":[{\"op\":\"update_token_value\",\"id\":\"color.brand\",\"value\":\"#e11d48\"}]}"
466 }
467 }
468 document id="d" title="x" {
469 page id="pg" w=(px)100 h=(px)100 {}
470 }
471}
472"##;
473 const TARGET_WITH_TOKEN: &str = r##"zenith version=1 {
475 project id="proj.x" name="Target"
476 tokens format="zenith-token-v1" {
477 token id="color.brand" type="color" value="#111111"
478 }
479 styles {}
480 document id="d" title="x" {
481 page id="pg" w=(px)800 h=(px)600 {}
482 }
483}
484"##;
485
486 let dir = tempfile::tempdir().expect("tempdir");
487 let lib_dir = dir.path().join("libraries");
488 std::fs::create_dir_all(&lib_dir).expect("create libraries dir");
489 std::fs::write(lib_dir.join("actions.zen"), ACTION_PACK_SRC).expect("write pack");
490
491 let result = add(
492 TARGET_WITH_TOKEN,
493 "@test/actions#apply-brand-kit",
494 Some(dir.path()),
495 None,
496 (0.0, 0.0),
497 None,
498 )
499 .expect("action add ok");
500
501 let src = String::from_utf8(result.formatted).expect("utf8");
502 assert!(src.contains("#e11d48"), "updated value in output: {}", src);
503 assert!(
504 result.summary.contains("apply-brand-kit"),
505 "summary mentions action id: {}",
506 result.summary
507 );
508 assert!(
509 result.summary.contains("provenance"),
510 "summary mentions provenance: {}",
511 result.summary
512 );
513 }
514
515 #[test]
516 fn add_action_rejected_returns_error_exit_1() {
517 const ACTION_PACK_SRC: &str = r##"zenith version=1 {
519 project id="@test/actions" name="Test Actions"
520 libraries { library id="@test/actions" version="1.0.0" }
521 actions {
522 action id="bad-action" {
523 tx "{\"ops\":[{\"op\":\"update_token_value\",\"id\":\"no.such.token\",\"value\":\"#fff\"}]}"
524 }
525 }
526 document id="d" title="x" {
527 page id="pg" w=(px)100 h=(px)100 {}
528 }
529}
530"##;
531
532 let dir = tempfile::tempdir().expect("tempdir");
533 let lib_dir = dir.path().join("libraries");
534 std::fs::create_dir_all(&lib_dir).expect("create libraries dir");
535 std::fs::write(lib_dir.join("actions.zen"), ACTION_PACK_SRC).expect("write pack");
536
537 let err = add(
538 TARGET_SRC,
539 "@test/actions#bad-action",
540 Some(dir.path()),
541 None,
542 (0.0, 0.0),
543 None,
544 )
545 .expect_err("rejected action must return an error");
546
547 assert_eq!(err.exit_code, 1, "exit_code must be 1 for rejected tx");
548 assert!(
549 err.message.contains("rejected"),
550 "msg must mention rejected: {}",
551 err.message
552 );
553 }
554
555 #[test]
556 fn add_component_without_page_errors() {
557 let err = add(
558 TARGET_SRC,
559 "@zenith/flowchart#decision",
560 None,
561 None,
562 (0.0, 0.0),
563 None,
564 )
565 .expect_err("component without page errors");
566 assert_eq!(err.exit_code, 2);
567 assert!(
568 err.message.contains("--page"),
569 "msg should ask for --page: {}",
570 err.message
571 );
572 }
573}