Skip to main content

zenith_cli/commands/variant/
engine.rs

1//! Pure in-memory variant generation engine.
2//!
3//! [`expand_variants`] is the single public entry point.  It consumes a parsed
4//! [`Document`], iterates `doc.variants` in stable id order, and for each
5//! definition clones the source document, builds a transaction op batch, and
6//! runs it through the same [`run_transaction`] path that `zenith merge` uses.
7//!
8//! No file I/O, no CLI parsing, no rendering.  Those live in the CLI entry point.
9
10use 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// ── Result / outcome types ────────────────────────────────────────────────────
16
17/// The complete result of one [`expand_variants`] call.
18///
19/// `results` is sorted by variant id (ascending), matching the deterministic
20/// processing order.
21#[derive(Debug)]
22pub struct VariantExpansion {
23    pub results: Vec<VariantResult>,
24}
25
26impl VariantExpansion {
27    /// Number of successfully-generated variants.
28    pub fn generated(&self) -> usize {
29        self.results
30            .iter()
31            .filter(|r| matches!(r.outcome, VariantOutcome::Generated(_)))
32            .count()
33    }
34
35    /// Number of failed variants.
36    pub fn failed(&self) -> usize {
37        self.results
38            .iter()
39            .filter(|r| matches!(r.outcome, VariantOutcome::Failed(_)))
40            .count()
41    }
42}
43
44/// Result for a single variant entry.
45#[derive(Debug)]
46pub struct VariantResult {
47    /// The variant's stable id.
48    pub id: String,
49    /// The source page id this variant derives from.
50    pub source: String,
51    /// Either the materialized document or a failure reason.
52    pub outcome: VariantOutcome,
53}
54
55/// Outcome of applying one variant's op batch.
56#[derive(Debug)]
57pub enum VariantOutcome {
58    /// The transaction was accepted; contains the materialized document.
59    /// Boxed: a `Document` is much larger than the `Failed` string payload.
60    Generated(Box<Document>),
61    /// The transaction was rejected or the engine returned a hard error.
62    /// Contains a human-readable reason string.
63    Failed(String),
64}
65
66// ── expand_variants ───────────────────────────────────────────────────────────
67
68/// Expand all variant definitions in `doc` into materialized documents.
69///
70/// Processes variants in ascending `id` order (deterministic).  A failure on
71/// one variant does NOT abort the rest — every variant is attempted independently.
72///
73/// Returns an empty [`VariantExpansion`] when `doc.variants` is empty.
74pub fn expand_variants(doc: &Document) -> VariantExpansion {
75    if doc.variants.is_empty() {
76        return VariantExpansion {
77            results: Vec::new(),
78        };
79    }
80
81    // Collect into a BTreeMap keyed by id to enforce deterministic ordering
82    // without mutating the caller's slice.  Duplicate ids are caught by
83    // validation and are not expected here; if they slip through the last
84    // writer wins (both would produce the same key anyway since validation blocks).
85    let sorted: BTreeMap<&str, _> = doc.variants.iter().map(|v| (v.id.as_str(), v)).collect();
86
87    // Generation consumes the variants block: each materialized variant is a
88    // concrete page, not a template. Strip the block from the base document the
89    // transactions run against so (a) the output carries no `variants` block and
90    // (b) one variant's override problems don't fail a sibling variant when the
91    // post-transaction validation re-checks the (shared) variants block.
92    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        // Build the op batch for this variant.
99        let mut ops: Vec<Op> = Vec::new();
100
101        // 1. Resize the source page to the variant's target dimensions.
102        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        // 2. Per-override ops, in stored order, sub-ordered:
109        //    visible → geometry → fill → text.
110        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        // 3. Run the transaction against the variants-stripped base document.
156        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                // Re-parse source_after into the materialized document.
175                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
195// ── Private helpers ───────────────────────────────────────────────────────────
196
197/// Extract a string to pass to [`Op::SetFill`] from a [`PropertyValue`].
198///
199/// [`Op::SetFill`] accepts a token id and stores it as
200/// `PropertyValue::TokenRef`.  For `TokenRef` fills this is straightforward.
201/// For `Literal` and `Dimension` fills the raw string is passed through; the
202/// engine will still wrap it as `TokenRef`, which post-validation will then
203/// reject as `token.unknown_reference` — surfacing a `Failed` outcome for that
204/// variant rather than silently producing a corrupt document.
205fn 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// ── Unit tests ────────────────────────────────────────────────────────────────
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use zenith_core::KdlAdapter;
220
221    // ── Fixtures ──────────────────────────────────────────────────────────────
222
223    /// A minimal document with two variants so tests can exercise independent
224    /// generation in a single parse.
225    ///
226    /// Page `page.a` contains:
227    ///   - `rect.bg`     — a background rect (has `fill`, no text)
228    ///   - `text.label`  — a text node with a single span
229    ///
230    /// Variant `var.small` → resizes page.a to 320×180, hides `rect.bg`.
231    /// Variant `var.large` → resizes page.a to 1920×1080, overrides `text.label` text.
232    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    /// A document whose single variant overrides a node that does NOT exist —
260    /// used to assert the tx engine's behavior on an unknown override target.
261    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    /// A document with a fill-override variant.
283    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    /// A document with no variants block at all.
304    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    // ── Helper ────────────────────────────────────────────────────────────────
319
320    fn parse(src: &str) -> Document {
321        KdlAdapter
322            .parse(src.as_bytes())
323            .expect("fixture must parse")
324    }
325
326    // ── Tests ─────────────────────────────────────────────────────────────────
327
328    #[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        // Both variants should succeed.
343        assert_eq!(expansion.generated(), 2);
344        assert_eq!(expansion.failed(), 0);
345        assert_eq!(expansion.results.len(), 2);
346
347        // Results are sorted by id (ascending).  "var.large" < "var.small".
348        assert_eq!(expansion.results[0].id, "var.large");
349        assert_eq!(expansion.results[1].id, "var.small");
350
351        // Both carry the correct source page.
352        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        // Page should be resized to 1920×1080.
372        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        // text.label should now contain "large variant".
382        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        // Page should be resized to 320×180.
404        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        // rect.bg should be hidden (visible = Some(false)).
414        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        // rect.hero fill should be TokenRef("color.alt").
434        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    /// A document whose single variant repositions a rect node via x/y/w/h
442    /// geometry overrides — all four axes specified.
443    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    /// A document whose single variant overrides only `y` on a rect (partial
463    /// geometry — x/w/h left to the tx engine's partial-apply semantics).
464    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        // All four geometry overrides must be applied.
501        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        // Only y was overridden; x/w/h must keep their original values.
541        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        // var.bad targets a missing node → should fail.
569        // var.good has no overrides → should succeed.
570        assert_eq!(expansion.results.len(), 2);
571
572        // Results sorted by id: "var.bad" < "var.good".
573        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        // var.good must be Generated regardless of var.bad's outcome.
579        assert!(
580            matches!(good.outcome, VariantOutcome::Generated(_)),
581            "var.good must be Generated"
582        );
583
584        // var.bad: the tx engine emits tx.unknown_node for a missing override target,
585        // which causes a Rejected status → Failed outcome.
586        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        // expand_variants takes &Document; the source doc must be identical
602        // after the call (no shared mutation).
603        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        // Source page width must still be 800.
609        assert_eq!(
610            doc.body.pages[0].width.value, original_page_w,
611            "source document must not be mutated"
612        );
613    }
614
615    // ── Node-finding helpers (test-only) ─────────────────────────────────────
616
617    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    /// Extract the px value of a geometry property that is a raw dimension
650    /// (geometry is now `Option<PropertyValue>`; token refs read back as `None`).
651    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}