zenith_cli/commands/variant/
engine.rs1use std::collections::BTreeMap;
11
12use zenith_core::{Document, KdlAdapter, KdlSource, PropertyValue, dim_to_px};
13use zenith_tx::{Op, OpSpan, Permissions, Transaction, TxStatus, run_transaction};
14
15#[derive(Debug)]
22pub struct VariantExpansion {
23 pub results: Vec<VariantResult>,
24}
25
26impl VariantExpansion {
27 pub fn generated(&self) -> usize {
29 self.results
30 .iter()
31 .filter(|r| matches!(r.outcome, VariantOutcome::Generated(_)))
32 .count()
33 }
34
35 pub fn failed(&self) -> usize {
37 self.results
38 .iter()
39 .filter(|r| matches!(r.outcome, VariantOutcome::Failed(_)))
40 .count()
41 }
42}
43
44#[derive(Debug)]
46pub struct VariantResult {
47 pub id: String,
49 pub source: String,
51 pub outcome: VariantOutcome,
53}
54
55#[derive(Debug)]
57pub enum VariantOutcome {
58 Generated(Box<Document>),
61 Failed(String),
64}
65
66pub fn expand_variants(doc: &Document) -> VariantExpansion {
75 if doc.variants.is_empty() {
76 return VariantExpansion {
77 results: Vec::new(),
78 };
79 }
80
81 let sorted: BTreeMap<&str, _> = doc.variants.iter().map(|v| (v.id.as_str(), v)).collect();
86
87 let mut base = doc.clone();
93 base.variants.clear();
94
95 let mut results: Vec<VariantResult> = Vec::with_capacity(sorted.len());
96
97 for variant in sorted.values() {
98 let mut ops: Vec<Op> = Vec::new();
100
101 ops.push(Op::SetPageSize {
103 page: variant.source.clone(),
104 w: variant.w.to_kdl_string(),
105 h: variant.h.to_kdl_string(),
106 });
107
108 for ov in &variant.overrides {
111 if let Some(visible) = ov.visible {
112 ops.push(Op::SetVisible {
113 node: ov.node.clone(),
114 visible,
115 });
116 }
117 if ov.x.is_some() || ov.y.is_some() || ov.w.is_some() || ov.h.is_some() {
118 ops.push(Op::SetGeometry {
119 node: ov.node.clone(),
120 x: ov.x.as_ref().and_then(|d| dim_to_px(d.value, &d.unit)),
121 y: ov.y.as_ref().and_then(|d| dim_to_px(d.value, &d.unit)),
122 w: ov.w.as_ref().and_then(|d| dim_to_px(d.value, &d.unit)),
123 h: ov.h.as_ref().and_then(|d| dim_to_px(d.value, &d.unit)),
124 rotate: None,
125 });
126 }
127 if let Some(fill) = &ov.fill {
128 ops.push(Op::SetFill {
129 node: ov.node.clone(),
130 fill: property_value_to_fill_str(fill),
131 });
132 }
133 if let Some(text) = &ov.text {
134 ops.push(Op::ReplaceText {
135 node: ov.node.clone(),
136 spans: vec![OpSpan {
137 text: text.clone(),
138 fill: None,
139 font_weight: None,
140 italic: None,
141 underline: None,
142 strikethrough: None,
143 vertical_align: None,
144 footnote_ref: None,
145 }],
146 });
147 }
148 }
149
150 let tx = Transaction {
151 ops,
152 permissions: Permissions::default(),
153 };
154
155 let outcome = match run_transaction(&base, &tx) {
157 Err(e) => VariantOutcome::Failed(format!("transaction engine error: {}", e.message)),
158 Ok(tx_result) if tx_result.status == TxStatus::Rejected => {
159 let msgs: Vec<String> = tx_result
160 .diagnostics
161 .iter()
162 .map(|d| {
163 format!(
164 "{}[{}]: {}",
165 crate::json_types::severity_str(&d.severity),
166 d.code,
167 d.message
168 )
169 })
170 .collect();
171 VariantOutcome::Failed(format!("transaction rejected: {}", msgs.join("; ")))
172 }
173 Ok(tx_result) => {
174 match KdlAdapter.parse(tx_result.source_after.as_bytes()) {
176 Err(e) => VariantOutcome::Failed(format!(
177 "post-transaction parse error: {}",
178 e.message
179 )),
180 Ok(materialized) => VariantOutcome::Generated(Box::new(materialized)),
181 }
182 }
183 };
184
185 results.push(VariantResult {
186 id: variant.id.clone(),
187 source: variant.source.clone(),
188 outcome,
189 });
190 }
191
192 VariantExpansion { results }
193}
194
195fn property_value_to_fill_str(pv: &PropertyValue) -> String {
206 match pv {
207 PropertyValue::TokenRef(id) => id.clone(),
208 PropertyValue::Literal(s) => s.clone(),
209 PropertyValue::Dimension(d) => d.to_kdl_string(),
210 PropertyValue::DataRef(path) => path.clone(),
211 }
212}
213
214#[cfg(test)]
217mod tests {
218 use super::*;
219 use zenith_core::KdlAdapter;
220
221 const DOC_TWO_VARIANTS: &str = r##"zenith version=1 {
233 project id="proj.v" name="Variant Test"
234 tokens format="zenith-token-v1" {
235 token id="color.bg" type="color" value="#ffffff"
236 token id="color.ink" type="color" value="#111111"
237 token id="color.accent" type="color" value="#e11d48"
238 }
239 styles {}
240 document id="doc.v" title="Variant Test" {
241 page id="page.a" w=(px)800 h=(px)600 {
242 rect id="rect.bg" x=(px)0 y=(px)0 w=(px)800 h=(px)600 fill=(token)"color.bg"
243 text id="text.label" x=(px)10 y=(px)10 w=(px)780 h=(px)80 fill=(token)"color.ink" {
244 span "original text"
245 }
246 }
247 }
248 variants {
249 variant id="var.large" source="page.a" w=(px)1920 h=(px)1080 {
250 override node="text.label" text="large variant"
251 }
252 variant id="var.small" source="page.a" w=(px)320 h=(px)180 {
253 override node="rect.bg" visible=#false
254 }
255 }
256}
257"##;
258
259 const DOC_MISSING_NODE_VARIANT: &str = r##"zenith version=1 {
262 project id="proj.mv" name="Missing Node Test"
263 tokens format="zenith-token-v1" {
264 token id="color.bg" type="color" value="#ffffff"
265 }
266 styles {}
267 document id="doc.mv" title="Missing Node Test" {
268 page id="page.m" w=(px)400 h=(px)300 {
269 rect id="rect.only" x=(px)0 y=(px)0 w=(px)400 h=(px)300 fill=(token)"color.bg"
270 }
271 }
272 variants {
273 variant id="var.bad" source="page.m" w=(px)800 h=(px)600 {
274 override node="node.does.not.exist" visible=#false
275 }
276 variant id="var.good" source="page.m" w=(px)200 h=(px)150 {
277 }
278 }
279}
280"##;
281
282 const DOC_FILL_VARIANT: &str = r##"zenith version=1 {
284 project id="proj.fv" name="Fill Variant Test"
285 tokens format="zenith-token-v1" {
286 token id="color.bg" type="color" value="#ffffff"
287 token id="color.alt" type="color" value="#3b82f6"
288 }
289 styles {}
290 document id="doc.fv" title="Fill Variant Test" {
291 page id="page.f" w=(px)400 h=(px)300 {
292 rect id="rect.hero" x=(px)0 y=(px)0 w=(px)400 h=(px)300 fill=(token)"color.bg"
293 }
294 }
295 variants {
296 variant id="var.filled" source="page.f" w=(px)400 h=(px)300 {
297 override node="rect.hero" fill=(token)"color.alt"
298 }
299 }
300}
301"##;
302
303 const DOC_NO_VARIANTS: &str = r##"zenith version=1 {
305 project id="proj.nv" name="No Variants"
306 tokens format="zenith-token-v1" {
307 token id="color.bg" type="color" value="#ffffff"
308 }
309 styles {}
310 document id="doc.nv" title="No Variants" {
311 page id="page.nv" w=(px)400 h=(px)300 {
312 rect id="rect.bg" x=(px)0 y=(px)0 w=(px)400 h=(px)300 fill=(token)"color.bg"
313 }
314 }
315}
316"##;
317
318 fn parse(src: &str) -> Document {
321 KdlAdapter
322 .parse(src.as_bytes())
323 .expect("fixture must parse")
324 }
325
326 #[test]
329 fn empty_variants_returns_empty_expansion() {
330 let doc = parse(DOC_NO_VARIANTS);
331 let expansion = expand_variants(&doc);
332 assert_eq!(expansion.results.len(), 0);
333 assert_eq!(expansion.generated(), 0);
334 assert_eq!(expansion.failed(), 0);
335 }
336
337 #[test]
338 fn two_variants_both_generated_in_id_order() {
339 let doc = parse(DOC_TWO_VARIANTS);
340 let expansion = expand_variants(&doc);
341
342 assert_eq!(expansion.generated(), 2);
344 assert_eq!(expansion.failed(), 0);
345 assert_eq!(expansion.results.len(), 2);
346
347 assert_eq!(expansion.results[0].id, "var.large");
349 assert_eq!(expansion.results[1].id, "var.small");
350
351 assert_eq!(expansion.results[0].source, "page.a");
353 assert_eq!(expansion.results[1].source, "page.a");
354 }
355
356 #[test]
357 fn var_large_page_resized_and_text_replaced() {
358 let doc = parse(DOC_TWO_VARIANTS);
359 let expansion = expand_variants(&doc);
360
361 let result = expansion
362 .results
363 .iter()
364 .find(|r| r.id == "var.large")
365 .expect("var.large must be present");
366
367 let VariantOutcome::Generated(ref materialized) = result.outcome else {
368 panic!("var.large must be Generated, got failure");
369 };
370
371 let page = materialized
373 .body
374 .pages
375 .iter()
376 .find(|p| p.id == "page.a")
377 .expect("page.a must exist");
378 assert_eq!(page.width.value, 1920.0);
379 assert_eq!(page.height.value, 1080.0);
380
381 let text_node =
383 find_text_node_by_id(materialized, "text.label").expect("text.label must exist");
384 let first_span_text: String = text_node.spans.iter().map(|s| s.text.as_str()).collect();
385 assert_eq!(first_span_text, "large variant");
386 }
387
388 #[test]
389 fn var_small_page_resized_and_node_hidden() {
390 let doc = parse(DOC_TWO_VARIANTS);
391 let expansion = expand_variants(&doc);
392
393 let result = expansion
394 .results
395 .iter()
396 .find(|r| r.id == "var.small")
397 .expect("var.small must be present");
398
399 let VariantOutcome::Generated(ref materialized) = result.outcome else {
400 panic!("var.small must be Generated, got failure");
401 };
402
403 let page = materialized
405 .body
406 .pages
407 .iter()
408 .find(|p| p.id == "page.a")
409 .expect("page.a must exist");
410 assert_eq!(page.width.value, 320.0);
411 assert_eq!(page.height.value, 180.0);
412
413 let rect = find_rect_node_by_id(materialized, "rect.bg").expect("rect.bg must exist");
415 assert_eq!(rect.visible, Some(false));
416 }
417
418 #[test]
419 fn fill_override_applied() {
420 let doc = parse(DOC_FILL_VARIANT);
421 let expansion = expand_variants(&doc);
422
423 assert_eq!(expansion.generated(), 1);
424 assert_eq!(expansion.failed(), 0);
425
426 let result = &expansion.results[0];
427 assert_eq!(result.id, "var.filled");
428
429 let VariantOutcome::Generated(ref materialized) = result.outcome else {
430 panic!("var.filled must be Generated");
431 };
432
433 let rect = find_rect_node_by_id(materialized, "rect.hero").expect("rect.hero must exist");
435 assert_eq!(
436 rect.fill,
437 Some(PropertyValue::TokenRef("color.alt".to_owned()))
438 );
439 }
440
441 const DOC_GEOMETRY_VARIANT: &str = r##"zenith version=1 {
444 project id="proj.gv" name="Geometry Variant Test"
445 tokens format="zenith-token-v1" {
446 token id="color.bg" type="color" value="#ffffff"
447 }
448 styles {}
449 document id="doc.gv" title="Geometry Variant Test" {
450 page id="page.g" w=(px)1920 h=(px)1080 {
451 rect id="rect.hero" x=(px)0 y=(px)0 w=(px)400 h=(px)200 fill=(token)"color.bg"
452 }
453 }
454 variants {
455 variant id="var.geo" source="page.g" w=(px)1920 h=(px)1080 {
456 override node="rect.hero" x=(px)100 y=(px)266 w=(px)880 h=(px)340
457 }
458 }
459}
460"##;
461
462 const DOC_PARTIAL_GEOMETRY_VARIANT: &str = r##"zenith version=1 {
465 project id="proj.pgv" name="Partial Geometry Test"
466 tokens format="zenith-token-v1" {
467 token id="color.bg" type="color" value="#ffffff"
468 }
469 styles {}
470 document id="doc.pgv" title="Partial Geometry Test" {
471 page id="page.pg" w=(px)800 h=(px)600 {
472 rect id="rect.box" x=(px)10 y=(px)20 w=(px)300 h=(px)150 fill=(token)"color.bg"
473 }
474 }
475 variants {
476 variant id="var.pgeo" source="page.pg" w=(px)800 h=(px)600 {
477 override node="rect.box" y=(px)50
478 }
479 }
480}
481"##;
482
483 #[test]
484 fn geometry_override_repositions_node() {
485 let doc = parse(DOC_GEOMETRY_VARIANT);
486 let expansion = expand_variants(&doc);
487
488 assert_eq!(expansion.generated(), 1, "var.geo must be generated");
489 assert_eq!(expansion.failed(), 0);
490
491 let result = &expansion.results[0];
492 assert_eq!(result.id, "var.geo");
493
494 let VariantOutcome::Generated(ref materialized) = result.outcome else {
495 panic!("var.geo must be Generated");
496 };
497
498 let rect = find_rect_node_by_id(materialized, "rect.hero").expect("rect.hero must exist");
499
500 assert_eq!(
502 rect.x.as_ref().and_then(pv_value),
503 Some(100.0),
504 "x must be overridden to 100"
505 );
506 assert_eq!(
507 rect.y.as_ref().and_then(pv_value),
508 Some(266.0),
509 "y must be overridden to 266"
510 );
511 assert_eq!(
512 rect.w.as_ref().and_then(pv_value),
513 Some(880.0),
514 "w must be overridden to 880"
515 );
516 assert_eq!(
517 rect.h.as_ref().and_then(pv_value),
518 Some(340.0),
519 "h must be overridden to 340"
520 );
521 }
522
523 #[test]
524 fn partial_geometry_override_only_changes_specified_axes() {
525 let doc = parse(DOC_PARTIAL_GEOMETRY_VARIANT);
526 let expansion = expand_variants(&doc);
527
528 assert_eq!(expansion.generated(), 1, "var.pgeo must be generated");
529 assert_eq!(expansion.failed(), 0);
530
531 let result = &expansion.results[0];
532 assert_eq!(result.id, "var.pgeo");
533
534 let VariantOutcome::Generated(ref materialized) = result.outcome else {
535 panic!("var.pgeo must be Generated");
536 };
537
538 let rect = find_rect_node_by_id(materialized, "rect.box").expect("rect.box must exist");
539
540 assert_eq!(
542 rect.x.as_ref().and_then(pv_value),
543 Some(10.0),
544 "x must remain 10 (unset in override)"
545 );
546 assert_eq!(
547 rect.y.as_ref().and_then(pv_value),
548 Some(50.0),
549 "y must be overridden to 50"
550 );
551 assert_eq!(
552 rect.w.as_ref().and_then(pv_value),
553 Some(300.0),
554 "w must remain 300 (unset in override)"
555 );
556 assert_eq!(
557 rect.h.as_ref().and_then(pv_value),
558 Some(150.0),
559 "h must remain 150 (unset in override)"
560 );
561 }
562
563 #[test]
564 fn missing_node_override_fails_sibling_still_generated() {
565 let doc = parse(DOC_MISSING_NODE_VARIANT);
566 let expansion = expand_variants(&doc);
567
568 assert_eq!(expansion.results.len(), 2);
571
572 let bad = &expansion.results[0];
574 let good = &expansion.results[1];
575 assert_eq!(bad.id, "var.bad");
576 assert_eq!(good.id, "var.good");
577
578 assert!(
580 matches!(good.outcome, VariantOutcome::Generated(_)),
581 "var.good must be Generated"
582 );
583
584 assert!(
587 matches!(bad.outcome, VariantOutcome::Failed(_)),
588 "var.bad must be Failed because its override target does not exist"
589 );
590
591 if let VariantOutcome::Failed(ref reason) = bad.outcome {
592 assert!(
593 reason.contains("node.does.not.exist"),
594 "failure reason should mention the missing node id; got: {reason}"
595 );
596 }
597 }
598
599 #[test]
600 fn source_document_not_mutated() {
601 let doc = parse(DOC_TWO_VARIANTS);
604 let original_page_w = doc.body.pages[0].width.value;
605
606 let _ = expand_variants(&doc);
607
608 assert_eq!(
610 doc.body.pages[0].width.value, original_page_w,
611 "source document must not be mutated"
612 );
613 }
614
615 fn find_text_node_by_id<'a>(doc: &'a Document, id: &str) -> Option<&'a zenith_core::TextNode> {
618 for page in &doc.body.pages {
619 if let Some(n) = find_text_in_nodes(&page.children, id) {
620 return Some(n);
621 }
622 }
623 None
624 }
625
626 fn find_text_in_nodes<'a>(
627 nodes: &'a [zenith_core::Node],
628 id: &str,
629 ) -> Option<&'a zenith_core::TextNode> {
630 for node in nodes {
631 match node {
632 zenith_core::Node::Text(n) if n.id == id => return Some(n),
633 zenith_core::Node::Frame(n) => {
634 if let Some(found) = find_text_in_nodes(&n.children, id) {
635 return Some(found);
636 }
637 }
638 zenith_core::Node::Group(n) => {
639 if let Some(found) = find_text_in_nodes(&n.children, id) {
640 return Some(found);
641 }
642 }
643 _ => {}
644 }
645 }
646 None
647 }
648
649 fn pv_value(pv: &zenith_core::PropertyValue) -> Option<f64> {
652 match pv {
653 zenith_core::PropertyValue::Dimension(d) => Some(d.value),
654 _ => None,
655 }
656 }
657
658 fn find_rect_node_by_id<'a>(doc: &'a Document, id: &str) -> Option<&'a zenith_core::RectNode> {
659 for page in &doc.body.pages {
660 if let Some(n) = find_rect_in_nodes(&page.children, id) {
661 return Some(n);
662 }
663 }
664 None
665 }
666
667 fn find_rect_in_nodes<'a>(
668 nodes: &'a [zenith_core::Node],
669 id: &str,
670 ) -> Option<&'a zenith_core::RectNode> {
671 for node in nodes {
672 match node {
673 zenith_core::Node::Rect(n) if n.id == id => return Some(n),
674 zenith_core::Node::Frame(n) => {
675 if let Some(found) = find_rect_in_nodes(&n.children, id) {
676 return Some(found);
677 }
678 }
679 zenith_core::Node::Group(n) => {
680 if let Some(found) = find_rect_in_nodes(&n.children, id) {
681 return Some(found);
682 }
683 }
684 _ => {}
685 }
686 }
687 None
688 }
689}