Skip to main content

rustmotion_core/
expand.rs

1//! Data-driven repetition and reusable component templates.
2//!
3//! This is the answer to the dominant failure mode the original audit named:
4//! an LLM asked for "ten identical cards, different data" hand-writes ten
5//! JSON subtrees, and every copy is a chance to diverge (a forgotten color,
6//! a stray `font-size`, a `position` that doesn't match its siblings). Two
7//! directives close that gap, both usable inside any `children` array —
8//! exactly where a component would go:
9//!
10//! - **`for-each`** repeats a `template` subtree once per element of an
11//!   array, binding the current element's fields (plus `$index`) into it.
12//! - **`use`** instantiates a named, reusable subtree declared once in a
13//!   top-level `components` block, with `props` overrides — a factored-out
14//!   component definition, the same relationship `include` has to a whole
15//!   scenario file, but *within* one file and *without* the I/O.
16//!
17//! ## Why this lives in `rustmotion-core`, not `rustmotion`
18//!
19//! `include.rs` needs file/network I/O (`std::fs`, `ureq`), so it lives in
20//! the `rustmotion` crate. This module is pure `serde_json::Value` rewriting
21//! — no I/O, same as `variables.rs` — so it lives next to it here.
22//!
23//! ## Syntax, and why it looks like `include`/`config` rather than a third
24//! dialect
25//!
26//! ```json
27//! {
28//!   "components": {
29//!     "stat_card": {
30//!       "params": {
31//!         "label": { "type": "string" },
32//!         "value": { "type": "number", "default": 0 },
33//!         "color": { "type": "string", "default": "#6366F1" }
34//!       },
35//!       "template": {
36//!         "type": "card",
37//!         "style": { "width": "300px", "background": "$color" },
38//!         "children": [
39//!           { "type": "text", "content": "$label" },
40//!           { "type": "counter", "value": "$value" }
41//!         ]
42//!       }
43//!     }
44//!   },
45//!   "scenes": [{
46//!     "duration": 3.0,
47//!     "children": [
48//!       {
49//!         "for-each": "$rows",
50//!         "template": { "use": "stat_card", "props": { "label": "$label", "value": "$value" } }
51//!       }
52//!     ]
53//!   }]
54//! }
55//! ```
56//!
57//! `components[name].params` is deliberately the exact same shape as the
58//! scenario-level `config` block (`{"type": ..., "default": ..., "description": ...}`,
59//! see [`crate::schema::VariableDefinition`]) — a param is a variable scoped
60//! to one component instead of the whole file. `use` + its overrides field
61//! mirrors `IncludeDirective { include, config }` (a name plus overrides) —
62//! *except* the overrides field is called **`props`**, not `config`. That is
63//! a deliberate, load-bearing difference, not inconsistency: [`substitute`]
64//! (shared with `variables.rs`) skips recursing into any object key literally
65//! named `"config"`, so that the scenario-level `config` *declarations* block
66//! (whose `default` values must stay literal, see
67//! `variables::test_config_key_not_substituted`) is never accidentally
68//! rewritten by whole-document substitution. Reusing that same key name for
69//! `use`'s overrides would make a `for-each` binding (`$label`) placed inside
70//! a *nested* `use`'s overrides silently never substitute — exactly the kind
71//! of silent failure this workstream exists to remove. `props` sidesteps the
72//! collision entirely while keeping the rest of the shape familiar.
73//!
74//! For `for-each`, each array element's own fields are bound directly (flat,
75//! not `$item.label`): the codebase's existing `$name` substitution has no
76//! dotted-path support (see `variables::parse_single_var_ref`), so an element
77//! `{"label": "Revenue", "value": 120}` exposes `$label` and `$value`
78//! straight into the template, exactly like a `config` default would. The
79//! whole element is *also* bound to `$item` (for forwarding it wholesale,
80//! e.g. into a nested `use`'s `props` via `{"$var": "item"}`), and the
81//! 0-based position is bound to `$index`. Explicit data always wins: if an
82//! element's own field is named `index` or `item`, that value is kept and the
83//! built-in is not inserted over it.
84//!
85//! ## Pass ordering (load-bearing, tested in
86//! `rustmotion/tests/templates_iteration.rs`)
87//!
88//! Every call site runs `expand_directives` immediately *after*
89//! `variables::apply_variables` and *before* `Scenario` is deserialized —
90//! same document, same pass boundary `include` sits on the other side of.
91//! Concretely, per document (root scenario file, and independently for each
92//! file pulled in by `include`, since `components` is file-local — see
93//! below):
94//!
95//! 1. Parse JSON.
96//! 2. `variables::apply_variables` — resolves the file's own `config`/`$var`.
97//! 3. **`expand::expand_directives`** (this module) — resolves `for-each`/
98//!    `use` using the now-literal document, then removes `components`.
99//! 4. Deserialize into `Scenario`.
100//! 5. `include::resolve_includes` — splices in child files (each of which
101//!    already went through steps 1-4 independently inside
102//!    `include::fetch_and_resolve`).
103//!
104//! Two consequences fall out of running expansion strictly after variable
105//! substitution and strictly per-document:
106//!
107//! - **You *can* iterate over an array that came from a variable.**
108//!   `"for-each": "$rows"` is, by the time this module sees it, no longer a
109//!   `$`-string — step 2 already replaced it with the literal array (if
110//!   `rows` is a declared `config` variable of array type). `for-each` itself
111//!   never has to know variables exist.
112//! - **You *cannot* instantiate a component defined in an included file** —
113//!   not from the *parent's* `use` sites, anyway. `components` is scoped to
114//!   the document it is declared in, the same way `config` is: each document
115//!   gets its own `apply_variables` + `expand_directives` pass over its own
116//!   text before it is ever spliced into anything else. A `use` inside a
117//!   file that *includes* another file cannot see the includee's
118//!   `components`, and a `use` inside the includee cannot see the includer's.
119//!   This is a deliberate simplicity choice (no cross-file component
120//!   registry, no import syntax to design and version) — see the module test
121//!   `use_cannot_reach_a_component_defined_in_a_sibling_included_file` in
122//!   `rustmotion/tests/templates_iteration.rs` for the resulting diagnostic.
123//!
124//! ## The index-shift trap (already drew blood once — see PR #145 / #160)
125//!
126//! `include` has the exact same shape of bug this module could reintroduce:
127//! a directive that expands to a scene count other than 1 shifts every
128//! later `views[V].scenes[S]` index, and `--fix` patches the raw JSON by
129//! that same indexed path. `for-each` is strictly worse on this axis — ten
130//! elements shift nine siblings, not (at most) a handful. This module does
131//! **not** try to solve that by tracking pre/post-expansion index maps: it
132//! solves it the way `include` already does, by removing the temptation.
133//! `expand_directives` runs *before* `Scenario` is deserialized, so
134//! `LoadedScenario::raw` (what `--fix` would serialize) is *already* the
135//! expanded tree by the time `commands/validate.rs` sees it — same as
136//! `include`'s resolved scenes are already spliced into `raw` by the time
137//! `--fix` runs. `commands/validate.rs::refuse_fix` is extended with a
138//! `UsesTemplateDirectives` case, detected the same (raw-substring,
139//! conservative-by-design) way `UsesInclude` already is, so `--fix` refuses
140//! outright rather than writing the expansion back over the author's
141//! `for-each`/`use`/`components` source.
142
143use std::collections::HashMap;
144
145use serde::Deserialize;
146use serde_json::Value;
147
148use crate::error::{Result, RustmotionError};
149use crate::schema::VariableType;
150use crate::variables::substitute;
151
152/// Defense-in-depth ceiling on nested `use`/`for-each` expansion. True
153/// self-reference cycles are caught immediately by the name stack in
154/// [`resolve_entry`] and never reach this; this only guards against
155/// legitimately deep (non-cyclic) nesting run away, mirroring
156/// `include::MAX_INCLUDE_DEPTH`'s role for the sibling mechanism.
157const MAX_EXPANSION_DEPTH: u32 = 64;
158
159/// One entry of the top-level `components` map: a named, parameterised
160/// subtree. `params` reuses the exact shape of the scenario-level `config`
161/// block, except a param's `default` is optional — omitting it makes the
162/// parameter *required*, which `config` variables cannot express (every
163/// `config` variable must have a default, since it is meant to render
164/// standalone with no overrides at all; a component parameter has no such
165/// obligation — an icon component's `icon` name, for instance, has no
166/// sensible default).
167#[derive(Debug, Clone, Deserialize)]
168#[serde(deny_unknown_fields)]
169struct ComponentDefinition {
170    #[serde(default)]
171    params: HashMap<String, ComponentParam>,
172    /// The subtree to instantiate: a single component object, or an array of
173    /// sibling component objects (a fragment spliced in place).
174    template: Value,
175}
176
177#[derive(Debug, Clone, Deserialize)]
178#[serde(deny_unknown_fields)]
179struct ComponentParam {
180    #[serde(rename = "type")]
181    #[allow(dead_code)]
182    // documentation/schema parity with `config`; not cross-checked against `default`'s actual JSON type (same as `VariableDefinition::var_type` today)
183    param_type: VariableType,
184    #[serde(default)]
185    default: Option<Value>,
186    #[serde(default)]
187    #[allow(dead_code)]
188    description: Option<String>,
189}
190
191/// `{"use": "name", "props": {...}}` — instantiate a `components` entry.
192#[derive(Debug, Deserialize)]
193#[serde(deny_unknown_fields)]
194struct UseDirective {
195    #[serde(rename = "use")]
196    use_name: String,
197    #[serde(default)]
198    props: HashMap<String, Value>,
199}
200
201/// `{"for-each": [...], "template": {...}}` — repeat `template` once per
202/// element of the (already variable-substituted) array.
203#[derive(Debug, Deserialize)]
204#[serde(deny_unknown_fields)]
205struct ForEachDirective {
206    #[serde(rename = "for-each")]
207    for_each: Value,
208    template: Value,
209}
210
211fn is_for_each(v: &Value) -> bool {
212    matches!(v, Value::Object(m) if m.contains_key("for-each"))
213}
214
215fn is_use(v: &Value) -> bool {
216    matches!(v, Value::Object(m) if m.contains_key("use"))
217}
218
219/// Expand every `for-each`/`use` directive found in any `children` array
220/// anywhere in `value`, and consume the top-level `components` block (like
221/// `variables::apply_variables` consumes `config`, it is removed so it never
222/// reaches `Scenario`'s `deny_unknown_fields`). Call this once per document,
223/// immediately after `variables::apply_variables` and before deserializing
224/// into `Scenario` — see the module doc for why that ordering is load-bearing.
225///
226/// `file_label` is the same kind of label `apply_variables` takes (a file
227/// path, `<inline>`, or `<root>`) — used only for error messages, alongside a
228/// structural location built while walking (e.g. `scenes[2].children[1]`),
229/// so a diagnostic names *where in the source* the offending directive is,
230/// not just which file.
231pub fn expand_directives(value: &mut Value, file_label: &str) -> Result<()> {
232    let defs = extract_component_definitions(value, file_label)?;
233
234    let Value::Object(root) = value else {
235        return Ok(());
236    };
237    root.remove("components");
238
239    if let Some(Value::Array(scenes)) = root.remove("scenes") {
240        let mut out = Vec::with_capacity(scenes.len());
241        for (i, mut scene) in scenes.into_iter().enumerate() {
242            let scene_path = format!("scenes[{i}]");
243            let mut stack = Vec::new();
244            walk_children(&mut scene, &defs, file_label, &scene_path, &mut stack, 0)?;
245            out.push(scene);
246        }
247        root.insert("scenes".to_string(), Value::Array(out));
248    }
249
250    if let Some(Value::Array(views)) = root.remove("composition") {
251        let mut out_views = Vec::with_capacity(views.len());
252        for (vi, mut view) in views.into_iter().enumerate() {
253            if let Value::Object(vmap) = &mut view {
254                if let Some(Value::Array(scenes)) = vmap.remove("scenes") {
255                    let mut out = Vec::with_capacity(scenes.len());
256                    for (si, mut scene) in scenes.into_iter().enumerate() {
257                        let scene_path = format!("composition[{vi}].scenes[{si}]");
258                        let mut stack = Vec::new();
259                        walk_children(&mut scene, &defs, file_label, &scene_path, &mut stack, 0)?;
260                        out.push(scene);
261                    }
262                    vmap.insert("scenes".to_string(), Value::Array(out));
263                }
264            }
265            out_views.push(view);
266        }
267        root.insert("composition".to_string(), Value::Array(out_views));
268    }
269
270    warn_unresolved_after_expansion(value, file_label);
271    Ok(())
272}
273
274/// Report `$name`s that survived both variable substitution and directive
275/// expansion.
276///
277/// `variables::apply_variables` runs its own scan, but *before* this pass and
278/// skipping `template`/`props`/`components` — every `$name` in there is a
279/// binding this function is about to resolve, and reporting them would emit a
280/// warning per binding on every correct scenario. Those keys are consumed by
281/// the time we get here, so scanning the expanded document sees only genuine
282/// leftovers: a `$typo` in a template that matched no data field, or a `$` in
283/// ordinary content.
284///
285/// A warning rather than an error, matching what `apply_variables` decided for
286/// the same diagnostic: a literal `$` in a price or a shell path is legitimate
287/// content and must not fail a render.
288fn warn_unresolved_after_expansion(value: &Value, file_label: &str) {
289    for name in crate::variables::find_unresolved(value) {
290        eprintln!(
291            "Warning: {}",
292            crate::error::RustmotionError::UnresolvedVariable {
293                name,
294                path: file_label.to_string(),
295            }
296        );
297    }
298}
299
300fn extract_component_definitions(
301    value: &Value,
302    file_label: &str,
303) -> Result<HashMap<String, ComponentDefinition>> {
304    let Value::Object(root) = value else {
305        return Ok(HashMap::new());
306    };
307    match root.get("components") {
308        None => Ok(HashMap::new()),
309        Some(Value::Object(defs_map)) => {
310            let mut out = HashMap::with_capacity(defs_map.len());
311            for (name, def_val) in defs_map {
312                let def: ComponentDefinition =
313                    serde_json::from_value(def_val.clone()).map_err(|e| {
314                        RustmotionError::ComponentDefinitionInvalid {
315                            name: name.clone(),
316                            path: file_label.to_string(),
317                            reason: e.to_string(),
318                        }
319                    })?;
320                out.insert(name.clone(), def);
321            }
322            Ok(out)
323        }
324        Some(_) => Err(RustmotionError::ComponentsBlockNotObject {
325            path: file_label.to_string(),
326        }),
327    }
328}
329
330/// Find the `children` array on `value` (if any), expand every entry in it
331/// (concrete entries pass through unchanged but are still recursed into, so
332/// nested containers get their own `children` expanded too), then recurse
333/// into every other field generically — a `for-each`/`use` can appear
334/// anywhere a `children` array can, at any nesting depth.
335fn walk_children(
336    value: &mut Value,
337    defs: &HashMap<String, ComponentDefinition>,
338    file_label: &str,
339    location: &str,
340    stack: &mut Vec<String>,
341    depth: u32,
342) -> Result<()> {
343    match value {
344        Value::Object(map) => {
345            if matches!(map.get("children"), Some(Value::Array(_))) {
346                if let Some(Value::Array(arr)) = map.remove("children") {
347                    let mut expanded = Vec::with_capacity(arr.len());
348                    for (i, entry) in arr.into_iter().enumerate() {
349                        let entry_loc = format!("{location}.children[{i}]");
350                        expanded.extend(resolve_entry(
351                            entry, defs, file_label, &entry_loc, stack, depth,
352                        )?);
353                    }
354                    map.insert("children".to_string(), Value::Array(expanded));
355                }
356            }
357            for (k, v) in map.iter_mut() {
358                if k == "children" {
359                    continue; // already fully expanded above
360                }
361                walk_children(v, defs, file_label, location, stack, depth)?;
362            }
363        }
364        Value::Array(arr) => {
365            for v in arr.iter_mut() {
366                walk_children(v, defs, file_label, location, stack, depth)?;
367            }
368        }
369        _ => {}
370    }
371    Ok(())
372}
373
374/// Resolve one `children` array entry into zero or more concrete entries.
375/// A plain component entry resolves to exactly itself (after recursing into
376/// its own `children`, if it has one). A `for-each`/`use` directive resolves
377/// to the nodes it produces — which are, in turn, run back through this same
378/// function, so a `for-each` template that is itself a `use`, or a `use`
379/// whose template is itself a `for-each`, composes without special-casing.
380///
381/// A bare JSON array (a `for-each`/`use` template written as a *fragment* —
382/// several sibling nodes instead of one) is flattened here too, generically,
383/// rather than only where `use` happens to produce one: both directives'
384/// `template` accept either shape, and this is the single place that
385/// splices a fragment's elements into the parent `children` array instead of
386/// nesting a raw `[...]` inside it (which downstream `Component`
387/// deserialization has no concept of).
388fn resolve_entry(
389    entry: Value,
390    defs: &HashMap<String, ComponentDefinition>,
391    file_label: &str,
392    location: &str,
393    stack: &mut Vec<String>,
394    depth: u32,
395) -> Result<Vec<Value>> {
396    if depth > MAX_EXPANSION_DEPTH {
397        return Err(RustmotionError::ExpansionDepthExceeded {
398            limit: MAX_EXPANSION_DEPTH,
399            path: format!("{file_label}: {location}"),
400        });
401    }
402
403    if let Value::Array(fragment) = entry {
404        let mut out = Vec::with_capacity(fragment.len());
405        for (i, n) in fragment.into_iter().enumerate() {
406            let frag_loc = format!("{location}[{i}]");
407            out.extend(resolve_entry(
408                n,
409                defs,
410                file_label,
411                &frag_loc,
412                stack,
413                depth + 1,
414            )?);
415        }
416        return Ok(out);
417    }
418
419    if is_for_each(&entry) {
420        let produced = expand_for_each_directive(entry, file_label, location)?;
421        let mut out = Vec::with_capacity(produced.len());
422        for (i, node) in produced.into_iter().enumerate() {
423            let iter_loc = format!("{location}[{i}]");
424            out.extend(resolve_entry(
425                node,
426                defs,
427                file_label,
428                &iter_loc,
429                stack,
430                depth + 1,
431            )?);
432        }
433        return Ok(out);
434    }
435
436    if is_use(&entry) {
437        let (name, node) = expand_use_directive(entry, defs, file_label, location)?;
438        if stack.contains(&name) {
439            let mut chain = stack.clone();
440            chain.push(name);
441            return Err(RustmotionError::ComponentCycle {
442                chain: chain.join(" -> "),
443                path: format!("{file_label}: {location}"),
444            });
445        }
446        stack.push(name);
447        let result = resolve_entry(node, defs, file_label, location, stack, depth + 1);
448        stack.pop();
449        return result;
450    }
451
452    let mut node = entry;
453    walk_children(&mut node, defs, file_label, location, stack, depth)?;
454    Ok(vec![node])
455}
456
457fn expand_for_each_directive(entry: Value, file_label: &str, location: &str) -> Result<Vec<Value>> {
458    let directive: ForEachDirective =
459        serde_json::from_value(entry).map_err(|e| RustmotionError::ForEachDirectiveInvalid {
460            path: format!("{file_label}: {location}"),
461            reason: e.to_string(),
462        })?;
463
464    let items = match &directive.for_each {
465        Value::Array(items) => items.clone(),
466        other => {
467            return Err(RustmotionError::ForEachNotArray {
468                path: format!("{file_label}: {location}"),
469                found: describe_value(other),
470            })
471        }
472    };
473
474    let mut out = Vec::with_capacity(items.len());
475    for (idx, element) in items.into_iter().enumerate() {
476        let mut bindings: HashMap<String, Value> = HashMap::new();
477        if let Value::Object(obj) = &element {
478            for (k, v) in obj {
479                bindings.insert(k.clone(), v.clone());
480            }
481        }
482        // Explicit data wins: only fill these in if the element didn't
483        // already define a field with that name.
484        bindings
485            .entry("index".to_string())
486            .or_insert_with(|| Value::from(idx));
487        bindings
488            .entry("item".to_string())
489            .or_insert_with(|| element.clone());
490
491        let mut node = directive.template.clone();
492        substitute(&mut node, &bindings, file_label)?;
493        out.push(node);
494    }
495    Ok(out)
496}
497
498fn expand_use_directive(
499    entry: Value,
500    defs: &HashMap<String, ComponentDefinition>,
501    file_label: &str,
502    location: &str,
503) -> Result<(String, Value)> {
504    let directive: UseDirective =
505        serde_json::from_value(entry).map_err(|e| RustmotionError::UseDirectiveInvalid {
506            path: format!("{file_label}: {location}"),
507            reason: e.to_string(),
508        })?;
509
510    let def = defs
511        .get(&directive.use_name)
512        .ok_or_else(|| RustmotionError::UnknownComponent {
513            name: directive.use_name.clone(),
514            path: format!("{file_label}: {location}"),
515        })?;
516
517    for key in directive.props.keys() {
518        if !def.params.contains_key(key) {
519            return Err(RustmotionError::UnknownComponentParam {
520                component: directive.use_name.clone(),
521                param: key.clone(),
522                path: format!("{file_label}: {location}"),
523            });
524        }
525    }
526
527    let mut bindings: HashMap<String, Value> = HashMap::with_capacity(def.params.len());
528    for (pname, pdef) in &def.params {
529        match directive.props.get(pname) {
530            Some(v) => {
531                bindings.insert(pname.clone(), v.clone());
532            }
533            None => match &pdef.default {
534                Some(d) => {
535                    bindings.insert(pname.clone(), d.clone());
536                }
537                None => {
538                    return Err(RustmotionError::ComponentParamMissing {
539                        component: directive.use_name.clone(),
540                        param: pname.clone(),
541                        path: format!("{file_label}: {location}"),
542                    })
543                }
544            },
545        }
546    }
547
548    let mut node = def.template.clone();
549    substitute(&mut node, &bindings, file_label)?;
550    Ok((directive.use_name.clone(), node))
551}
552
553fn describe_value(v: &Value) -> String {
554    match v {
555        Value::Null => "null".to_string(),
556        Value::Bool(b) => format!("boolean ({b})"),
557        Value::Number(n) => format!("number ({n})"),
558        Value::String(s) => {
559            let preview: String = s.chars().take(40).collect();
560            let ellipsis = if s.chars().count() > 40 { "…" } else { "" };
561            format!(
562                "string (\"{preview}{ellipsis}\"){}",
563                if s.starts_with('$') {
564                    " — looks like an unresolved/undeclared variable reference"
565                } else {
566                    ""
567                }
568            )
569        }
570        Value::Object(_) => "object".to_string(),
571        Value::Array(_) => "array".to_string(),
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use serde_json::json;
579
580    fn expand(mut value: Value) -> Result<Value> {
581        expand_directives(&mut value, "test.json")?;
582        Ok(value)
583    }
584
585    // ---- unresolved-reference scanning across the two passes ----
586
587    /// `apply_variables` scans for leftover `$name`s before this pass runs.
588    /// Left unguarded it reported every template binding as a typo — six
589    /// warnings on the canonical example, each accusing the author of a
590    /// mistake they had not made. Warnings that are reliably wrong teach the
591    /// reader to ignore warnings, which costs more than the scan is worth.
592    #[test]
593    fn template_bindings_are_not_reported_as_unresolved_before_expansion() {
594        let doc = json!({
595            "components": {
596                "card": {
597                    "params": { "label": { "type": "string" } },
598                    "template": { "type": "text", "content": "$label" }
599                }
600            },
601            "scenes": [{ "duration": 1.0, "children": [{
602                "for-each": [{ "label": "one" }],
603                "template": { "use": "card", "props": { "label": "$label" } }
604            }]}]
605        });
606        assert!(
607            crate::variables::find_unresolved(&doc).is_empty(),
608            "bindings inside components/template/props belong to expansion, \
609             not to the pre-expansion scan: {:?}",
610            crate::variables::find_unresolved(&doc)
611        );
612    }
613
614    /// The other half: skipping those keys must not turn a false positive
615    /// into a false negative. Once expansion has consumed them, a `$name`
616    /// that matched no data field is a genuine leftover and is visible to the
617    /// very same scan.
618    #[test]
619    fn a_typo_inside_a_template_is_still_found_after_expansion() {
620        let expanded = expand(json!({
621            "video": { "width": 100, "height": 100 },
622            "scenes": [{ "duration": 1.0, "children": [{
623                "for-each": [{ "label": "one" }],
624                "template": { "type": "text", "content": "$labl" }
625            }]}]
626        }))
627        .expect("a typo is a warning, not a hard error");
628        assert_eq!(
629            crate::variables::find_unresolved(&expanded),
630            vec!["labl".to_string()],
631            "the leftover must be visible once template/props are gone"
632        );
633    }
634
635    /// And the correct spelling leaves nothing behind, so the scan above is
636    /// discriminating rather than merely quiet.
637    #[test]
638    fn a_correct_binding_leaves_nothing_unresolved_after_expansion() {
639        let expanded = expand(json!({
640            "video": { "width": 100, "height": 100 },
641            "scenes": [{ "duration": 1.0, "children": [{
642                "for-each": [{ "label": "one" }],
643                "template": { "type": "text", "content": "$label" }
644            }]}]
645        }))
646        .expect("expands");
647        assert!(crate::variables::find_unresolved(&expanded).is_empty());
648    }
649
650    // ---- for-each ----
651
652    #[test]
653    fn for_each_repeats_template_once_per_element_binding_its_fields() {
654        let doc = json!({
655            "video": { "width": 100, "height": 100 },
656            "scenes": [{
657                "duration": 1.0,
658                "children": [{
659                    "for-each": [
660                        { "label": "Revenue", "value": 120 },
661                        { "label": "Users", "value": 340 }
662                    ],
663                    "template": { "type": "text", "content": "$label: $value" }
664                }]
665            }]
666        });
667        let out = expand(doc).unwrap();
668        let children = out["scenes"][0]["children"].as_array().unwrap();
669        assert_eq!(children.len(), 2);
670        assert_eq!(children[0]["content"], json!("Revenue: 120"));
671        assert_eq!(children[1]["content"], json!("Users: 340"));
672    }
673
674    #[test]
675    fn for_each_binds_index_and_whole_item() {
676        let doc = json!({
677            "video": { "width": 100, "height": 100 },
678            "scenes": [{
679                "duration": 1.0,
680                "children": [{
681                    "for-each": ["a", "b", "c"],
682                    "template": { "type": "text", "content": "$index:$item" }
683                }]
684            }]
685        });
686        let out = expand(doc).unwrap();
687        let children = out["scenes"][0]["children"].as_array().unwrap();
688        assert_eq!(children.len(), 3);
689        assert_eq!(children[0]["content"], json!("0:a"));
690        assert_eq!(children[1]["content"], json!("1:b"));
691        assert_eq!(children[2]["content"], json!("2:c"));
692    }
693
694    #[test]
695    fn for_each_lets_explicit_item_fields_win_over_built_in_index() {
696        let doc = json!({
697            "video": { "width": 100, "height": 100 },
698            "scenes": [{
699                "duration": 1.0,
700                "children": [{
701                    "for-each": [{ "index": "custom", "label": "x" }],
702                    "template": { "type": "text", "content": "$index" }
703                }]
704            }]
705        });
706        let out = expand(doc).unwrap();
707        assert_eq!(out["scenes"][0]["children"][0]["content"], json!("custom"));
708    }
709
710    #[test]
711    fn for_each_over_empty_array_produces_nothing_and_is_not_an_error() {
712        let doc = json!({
713            "video": { "width": 100, "height": 100 },
714            "scenes": [{
715                "duration": 1.0,
716                "children": [{
717                    "for-each": [],
718                    "template": { "type": "text", "content": "unused" }
719                }]
720            }]
721        });
722        let out = expand(doc).unwrap();
723        assert_eq!(out["scenes"][0]["children"], json!([]));
724    }
725
726    #[test]
727    fn for_each_source_that_is_not_an_array_is_a_named_error_not_a_silent_empty_result() {
728        // The exact silent failure mode the brief calls out: a for-each
729        // source key typo'd or referencing an undeclared variable leaves a
730        // literal, non-array `$...` string here — this must be a hard error
731        // naming where, not a quietly empty `children`.
732        let doc = json!({
733            "video": { "width": 100, "height": 100 },
734            "scenes": [{
735                "duration": 1.0,
736                "children": [{
737                    "for-each": "$itms",
738                    "template": { "type": "text", "content": "$label" }
739                }]
740            }]
741        });
742        let err = expand(doc).expect_err("non-array for-each source must fail loudly");
743        assert!(
744            matches!(err, RustmotionError::ForEachNotArray { .. }),
745            "{err}"
746        );
747        let msg = err.to_string();
748        assert!(msg.contains("scenes[0].children[0]"), "{msg}");
749        assert!(msg.contains("unresolved"), "{msg}");
750    }
751
752    #[test]
753    fn for_each_missing_template_is_a_named_error() {
754        let doc = json!({
755            "video": { "width": 100, "height": 100 },
756            "scenes": [{
757                "duration": 1.0,
758                "children": [{ "for-each": [1, 2, 3] }]
759            }]
760        });
761        let err = expand(doc).expect_err("missing template must fail");
762        assert!(
763            matches!(err, RustmotionError::ForEachDirectiveInvalid { .. }),
764            "{err}"
765        );
766    }
767
768    #[test]
769    fn for_each_with_a_fragment_template_splices_every_sibling_in_place_not_a_nested_array() {
770        // Each iteration's `template` is an *array* of two sibling nodes
771        // (an icon + a label), not a single object — both must end up as
772        // direct, flat siblings in the surrounding `children` array; a
773        // nested `[...]` there would not deserialize as a component.
774        let doc = json!({
775            "video": { "width": 100, "height": 100 },
776            "scenes": [{
777                "duration": 1.0,
778                "children": [{
779                    "for-each": [ { "label": "A" }, { "label": "B" } ],
780                    "template": [
781                        { "type": "icon", "icon": "lucide:dot" },
782                        { "type": "text", "content": "$label" }
783                    ]
784                }]
785            }]
786        });
787        let out = expand(doc).unwrap();
788        let children = out["scenes"][0]["children"].as_array().unwrap();
789        assert_eq!(
790            children.len(),
791            4,
792            "2 iterations x 2 fragment nodes = 4 flat siblings, got: {children:#?}"
793        );
794        assert!(children.iter().all(|c| c.is_object()), "{children:#?}");
795        assert_eq!(children[0]["type"], json!("icon"));
796        assert_eq!(children[1]["content"], json!("A"));
797        assert_eq!(children[2]["type"], json!("icon"));
798        assert_eq!(children[3]["content"], json!("B"));
799    }
800
801    // ---- use / components ----
802
803    fn doc_with_stat_card(props: Value) -> Value {
804        json!({
805            "video": { "width": 100, "height": 100 },
806            "components": {
807                "stat_card": {
808                    "params": {
809                        "label": { "type": "string" },
810                        "value": { "type": "number", "default": 0 },
811                        "color": { "type": "string", "default": "#6366F1" }
812                    },
813                    "template": {
814                        "type": "card",
815                        "style": { "background": "$color" },
816                        "children": [
817                            { "type": "text", "content": "$label" },
818                            { "type": "counter", "value": "$value" }
819                        ]
820                    }
821                }
822            },
823            "scenes": [{
824                "duration": 1.0,
825                "children": [{ "use": "stat_card", "props": props }]
826            }]
827        })
828    }
829
830    #[test]
831    fn use_instantiates_a_component_with_props_overriding_defaults() {
832        let out = expand(doc_with_stat_card(
833            json!({ "label": "Revenue", "value": 42 }),
834        ))
835        .unwrap();
836        let card = &out["scenes"][0]["children"][0];
837        assert_eq!(card["type"], json!("card"));
838        assert_eq!(card["style"]["background"], json!("#6366F1"));
839        assert_eq!(card["children"][0]["content"], json!("Revenue"));
840        assert_eq!(card["children"][1]["value"], json!(42));
841    }
842
843    #[test]
844    fn use_falls_back_to_param_default_when_not_overridden() {
845        let out = expand(doc_with_stat_card(json!({ "label": "Users" }))).unwrap();
846        assert_eq!(
847            out["scenes"][0]["children"][0]["children"][1]["value"],
848            json!(0)
849        );
850    }
851
852    #[test]
853    fn components_block_does_not_survive_expansion() {
854        let out = expand(doc_with_stat_card(json!({ "label": "x" }))).unwrap();
855        assert!(out.get("components").is_none());
856    }
857
858    #[test]
859    fn use_of_unknown_component_is_a_named_error() {
860        let doc = json!({
861            "video": { "width": 100, "height": 100 },
862            "scenes": [{
863                "duration": 1.0,
864                "children": [{ "use": "does_not_exist", "props": {} }]
865            }]
866        });
867        let err = expand(doc).expect_err("unknown component must fail");
868        match &err {
869            RustmotionError::UnknownComponent { name, path } => {
870                assert_eq!(name, "does_not_exist");
871                assert!(path.contains("scenes[0].children[0]"), "{path}");
872            }
873            other => panic!("expected UnknownComponent, got {other}"),
874        }
875    }
876
877    #[test]
878    fn use_missing_a_required_parameter_is_a_named_error() {
879        // `label` has no default in `doc_with_stat_card` — omitting it must
880        // fail, not silently render an empty/placeholder value.
881        let out = expand(doc_with_stat_card(json!({})));
882        let err = out.expect_err("missing required param must fail");
883        match &err {
884            RustmotionError::ComponentParamMissing {
885                component, param, ..
886            } => {
887                assert_eq!(component, "stat_card");
888                assert_eq!(param, "label");
889            }
890            other => panic!("expected ComponentParamMissing, got {other}"),
891        }
892    }
893
894    #[test]
895    fn use_with_an_undeclared_prop_key_is_a_named_error() {
896        let out = expand(doc_with_stat_card(
897            json!({ "label": "x", "labell": "typo" }),
898        ));
899        let err = out.expect_err("typo'd prop key must fail");
900        match &err {
901            RustmotionError::UnknownComponentParam { param, .. } => assert_eq!(param, "labell"),
902            other => panic!("expected UnknownComponentParam, got {other}"),
903        }
904    }
905
906    #[test]
907    fn use_of_a_component_that_uses_itself_is_a_named_cycle_not_a_stack_overflow() {
908        let doc = json!({
909            "video": { "width": 100, "height": 100 },
910            "components": {
911                "recursive": {
912                    "params": {},
913                    "template": { "type": "card", "children": [ { "use": "recursive", "props": {} } ] }
914                }
915            },
916            "scenes": [{
917                "duration": 1.0,
918                "children": [{ "use": "recursive", "props": {} }]
919            }]
920        });
921        let err = expand(doc).expect_err("self-referencing component must fail");
922        match &err {
923            RustmotionError::ComponentCycle { chain, .. } => {
924                assert!(chain.contains("recursive"), "{chain}");
925            }
926            other => panic!("expected ComponentCycle, got {other}"),
927        }
928    }
929
930    #[test]
931    fn indirect_two_hop_cycle_is_also_a_named_cycle() {
932        let doc = json!({
933            "video": { "width": 100, "height": 100 },
934            "components": {
935                "a": { "params": {}, "template": { "type": "card", "children": [ { "use": "b", "props": {} } ] } },
936                "b": { "params": {}, "template": { "type": "card", "children": [ { "use": "a", "props": {} } ] } }
937            },
938            "scenes": [{
939                "duration": 1.0,
940                "children": [{ "use": "a", "props": {} }]
941            }]
942        });
943        let err = expand(doc).expect_err("indirect cycle must fail");
944        match &err {
945            RustmotionError::ComponentCycle { chain, .. } => {
946                assert!(chain.contains('a') && chain.contains('b'), "{chain}");
947            }
948            other => panic!("expected ComponentCycle, got {other}"),
949        }
950    }
951
952    #[test]
953    fn use_with_a_fragment_template_splices_every_sibling_in_place() {
954        let doc = json!({
955            "video": { "width": 100, "height": 100 },
956            "components": {
957                "icon_label": {
958                    "params": { "label": { "type": "string" } },
959                    "template": [
960                        { "type": "icon", "icon": "lucide:dot" },
961                        { "type": "text", "content": "$label" }
962                    ]
963                }
964            },
965            "scenes": [{
966                "duration": 1.0,
967                "children": [{ "use": "icon_label", "props": { "label": "hi" } }]
968            }]
969        });
970        let out = expand(doc).unwrap();
971        let children = out["scenes"][0]["children"].as_array().unwrap();
972        assert_eq!(children.len(), 2, "{children:#?}");
973        assert_eq!(children[0]["type"], json!("icon"));
974        assert_eq!(children[1]["content"], json!("hi"));
975    }
976
977    // ---- composition of the two directives ----
978
979    #[test]
980    fn for_each_template_can_be_a_use_directive() {
981        let doc = json!({
982            "video": { "width": 100, "height": 100 },
983            "components": {
984                "row": {
985                    "params": { "label": { "type": "string" } },
986                    "template": { "type": "text", "content": "$label" }
987                }
988            },
989            "scenes": [{
990                "duration": 1.0,
991                "children": [{
992                    "for-each": [ { "label": "A" }, { "label": "B" } ],
993                    "template": { "use": "row", "props": { "label": "$label" } }
994                }]
995            }]
996        });
997        let out = expand(doc).unwrap();
998        let children = out["scenes"][0]["children"].as_array().unwrap();
999        assert_eq!(children.len(), 2);
1000        assert_eq!(children[0]["content"], json!("A"));
1001        assert_eq!(children[1]["content"], json!("B"));
1002    }
1003
1004    #[test]
1005    fn use_template_can_contain_a_nested_for_each() {
1006        let doc = json!({
1007            "video": { "width": 100, "height": 100 },
1008            "components": {
1009                "list_card": {
1010                    "params": { "items": { "type": "array" } },
1011                    "template": {
1012                        "type": "card",
1013                        "children": [{
1014                            "for-each": "$items",
1015                            "template": { "type": "text", "content": "$item" }
1016                        }]
1017                    }
1018                }
1019            },
1020            "scenes": [{
1021                "duration": 1.0,
1022                "children": [{ "use": "list_card", "props": { "items": ["x", "y", "z"] } }]
1023            }]
1024        });
1025        let out = expand(doc).unwrap();
1026        let inner = out["scenes"][0]["children"][0]["children"]
1027            .as_array()
1028            .unwrap();
1029        assert_eq!(inner.len(), 3);
1030        assert_eq!(inner[2]["content"], json!("z"));
1031    }
1032
1033    #[test]
1034    fn nested_children_containers_are_expanded_recursively() {
1035        let doc = json!({
1036            "video": { "width": 100, "height": 100 },
1037            "scenes": [{
1038                "duration": 1.0,
1039                "children": [{
1040                    "type": "card",
1041                    "children": [{
1042                        "for-each": [{ "v": 1 }, { "v": 2 }],
1043                        "template": { "type": "text", "content": "$v" }
1044                    }]
1045                }]
1046            }]
1047        });
1048        let out = expand(doc).unwrap();
1049        let inner = out["scenes"][0]["children"][0]["children"]
1050            .as_array()
1051            .unwrap();
1052        assert_eq!(inner.len(), 2);
1053        assert_eq!(inner[0]["content"], json!(1));
1054        assert_eq!(inner[1]["content"], json!(2));
1055    }
1056
1057    // ---- the tree-identity proof, at the JSON-value level ----
1058
1059    #[test]
1060    fn for_each_authored_tree_is_identical_to_the_hand_written_equivalent() {
1061        let generated = json!({
1062            "video": { "width": 100, "height": 100 },
1063            "scenes": [{
1064                "duration": 1.0,
1065                "children": [{
1066                    "for-each": [
1067                        { "label": "Revenue", "value": 120 },
1068                        { "label": "Users", "value": 340 },
1069                        { "label": "Growth", "value": 8 }
1070                    ],
1071                    "template": {
1072                        "type": "card",
1073                        "style": { "width": "200px" },
1074                        "children": [
1075                            { "type": "text", "content": "$label" },
1076                            { "type": "counter", "value": "$value" }
1077                        ]
1078                    }
1079                }]
1080            }]
1081        });
1082
1083        let hand_written = json!({
1084            "video": { "width": 100, "height": 100 },
1085            "scenes": [{
1086                "duration": 1.0,
1087                "children": [
1088                    { "type": "card", "style": { "width": "200px" }, "children": [
1089                        { "type": "text", "content": "Revenue" },
1090                        { "type": "counter", "value": 120 }
1091                    ]},
1092                    { "type": "card", "style": { "width": "200px" }, "children": [
1093                        { "type": "text", "content": "Users" },
1094                        { "type": "counter", "value": 340 }
1095                    ]},
1096                    { "type": "card", "style": { "width": "200px" }, "children": [
1097                        { "type": "text", "content": "Growth" },
1098                        { "type": "counter", "value": 8 }
1099                    ]}
1100                ]
1101            }]
1102        });
1103
1104        let expanded = expand(generated).unwrap();
1105        assert_eq!(
1106            expanded, hand_written,
1107            "the for-each-authored tree must be byte-for-byte identical (as JSON values) to the \
1108             hand-written equivalent — this is the only proof that factoring changes nothing about \
1109             what gets rendered"
1110        );
1111    }
1112}