Skip to main content

rustmotion_core/
variables.rs

1use std::collections::HashMap;
2
3use crate::error::Result;
4use serde_json::Value;
5
6use crate::error::RustmotionError;
7use crate::schema::VariableDefinition;
8
9/// Build the final variable map: start from defaults, then apply overrides.
10/// Returns an error if an override references a variable not in the definitions.
11fn merge_variables(
12    definitions: &HashMap<String, VariableDefinition>,
13    overrides: Option<&HashMap<String, Value>>,
14    path: &str,
15) -> Result<HashMap<String, Value>> {
16    let mut merged = HashMap::with_capacity(definitions.len());
17
18    // Start with defaults
19    for (name, def) in definitions {
20        merged.insert(name.clone(), def.default.clone());
21    }
22
23    // Apply overrides
24    if let Some(ovr) = overrides {
25        for (name, value) in ovr {
26            if !definitions.contains_key(name) {
27                return Err(RustmotionError::UndefinedVariable {
28                    name: name.clone(),
29                    path: path.to_string(),
30                });
31            }
32            merged.insert(name.clone(), value.clone());
33        }
34    }
35
36    Ok(merged)
37}
38
39/// Recursively substitute variable references in a JSON value tree.
40///
41/// `pub(crate)` (not private) so `crate::expand` can reuse the exact same
42/// `$name` / `{"$var": "name"}` / interpolation semantics for component-param
43/// and `for-each` item/index bindings, rather than re-implementing a second,
44/// subtly-different substitution pass. Same reason `"config"` is skipped here
45/// (see the loop below): a component-template clone can itself contain a
46/// nested `use`'s `props` block — deliberately *not* named `config`, so this
47/// skip does not swallow it (see `expand.rs` module doc for why `props` was
48/// chosen over `config` for that field).
49pub(crate) fn substitute(
50    value: &mut Value,
51    vars: &HashMap<String, Value>,
52    path: &str,
53) -> Result<()> {
54    match value {
55        Value::String(s) => {
56            // Check for exact match "$name" (whole-string replacement, preserves type)
57            if let Some(var_name) = parse_single_var_ref(s) {
58                if let Some(replacement) = vars.get(var_name) {
59                    *value = replacement.clone();
60                    return Ok(());
61                }
62                // Not in vars — leave as-is for find_unresolved to catch
63                return Ok(());
64            }
65
66            // Check for escaped $$ or interpolation
67            if s.contains('$') {
68                let result = interpolate_string(s, vars, path)?;
69                *s = result;
70            }
71        }
72        Value::Object(map) => {
73            // Check for { "$var": "name" } pattern
74            if map.len() == 1 {
75                if let Some(var_name_val) = map.get("$var") {
76                    if let Some(var_name) = var_name_val.as_str() {
77                        if let Some(replacement) = vars.get(var_name) {
78                            *value = replacement.clone();
79                            return Ok(());
80                        }
81                        // Not found — leave as-is
82                        return Ok(());
83                    }
84                }
85            }
86
87            // Recurse into object values, but skip "variables" key (don't substitute in definitions)
88            let keys: Vec<String> = map.keys().cloned().collect();
89            for key in keys {
90                if key == "config" {
91                    continue;
92                }
93                if let Some(v) = map.get_mut(&key) {
94                    substitute(v, vars, path)?;
95                }
96            }
97        }
98        Value::Array(arr) => {
99            for item in arr.iter_mut() {
100                substitute(item, vars, path)?;
101            }
102        }
103        _ => {}
104    }
105    Ok(())
106}
107
108/// Parse a string that is exactly "$name" (single variable reference, no interpolation).
109/// Returns the variable name without the leading $.
110fn parse_single_var_ref(s: &str) -> Option<&str> {
111    let s = s.trim();
112    if !s.starts_with('$') || s.starts_with("$$") {
113        return None;
114    }
115    let name = &s[1..];
116    // Must be a simple identifier (alphanumeric + underscore)
117    if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
118        return None;
119    }
120    // Only match if the entire string is just "$name" — no surrounding text
121    if s.len() != 1 + name.len() {
122        return None;
123    }
124    Some(name)
125}
126
127/// Perform string interpolation: replace $name occurrences within a larger string.
128/// Handles $$ escape sequences.
129fn interpolate_string(s: &str, vars: &HashMap<String, Value>, path: &str) -> Result<String> {
130    let mut result = String::with_capacity(s.len());
131    let mut chars = s.chars().peekable();
132
133    while let Some(ch) = chars.next() {
134        if ch == '$' {
135            if chars.peek() == Some(&'$') {
136                // Escaped $$  → literal $
137                chars.next();
138                result.push('$');
139            } else {
140                // Try to read a variable name
141                let mut name = String::new();
142                while let Some(&c) = chars.peek() {
143                    if c.is_alphanumeric() || c == '_' {
144                        name.push(c);
145                        chars.next();
146                    } else {
147                        break;
148                    }
149                }
150                if name.is_empty() {
151                    // Lone $ not followed by identifier — keep as-is
152                    result.push('$');
153                } else if let Some(val) = vars.get(&name) {
154                    match val {
155                        Value::String(s) => result.push_str(s),
156                        Value::Number(n) => result.push_str(&n.to_string()),
157                        Value::Bool(b) => result.push_str(&b.to_string()),
158                        _ => {
159                            return Err(RustmotionError::VariableInterpolationTypeError {
160                                name,
161                                path: path.to_string(),
162                            });
163                        }
164                    }
165                } else {
166                    // Unknown variable — keep original text for find_unresolved
167                    result.push('$');
168                    result.push_str(&name);
169                }
170            }
171        } else {
172            result.push(ch);
173        }
174    }
175
176    Ok(result)
177}
178
179/// Scan a Value tree for unresolved $variable references after substitution.
180pub fn find_unresolved(value: &Value) -> Vec<String> {
181    let mut unresolved = Vec::new();
182    find_unresolved_recursive(value, &mut unresolved);
183    unresolved
184}
185
186fn find_unresolved_recursive(value: &Value, out: &mut Vec<String>) {
187    match value {
188        Value::String(s) => {
189            let mut chars = s.chars().peekable();
190            while let Some(ch) = chars.next() {
191                if ch == '$' {
192                    if chars.peek() == Some(&'$') {
193                        chars.next(); // skip escaped
194                    } else {
195                        let mut name = String::new();
196                        while let Some(&c) = chars.peek() {
197                            if c.is_alphanumeric() || c == '_' {
198                                name.push(c);
199                                chars.next();
200                            } else {
201                                break;
202                            }
203                        }
204                        if !name.is_empty() {
205                            out.push(name);
206                        }
207                    }
208                }
209            }
210        }
211        Value::Object(map) => {
212            // Check for { "$var": "name" }
213            if map.len() == 1 {
214                if let Some(val) = map.get("$var") {
215                    if let Some(name) = val.as_str() {
216                        out.push(name.to_string());
217                        return;
218                    }
219                }
220            }
221            for (key, v) in map {
222                // `config` holds the declarations themselves, never references.
223                //
224                // `template` / `props` / `components` hold the bodies of the
225                // template directives, whose `$name`s are bound by
226                // `expand::expand_directives` — which runs *after* this pass.
227                // Scanning them here reports every correct binding as an
228                // unresolved typo: the canonical `for-each` example emits six
229                // such warnings, each accusing the author of a mistake they
230                // did not make. Warnings that are reliably wrong teach the
231                // reader to ignore warnings, which would cost more than the
232                // scan is worth.
233                //
234                // Nothing is lost: `expand_directives` re-runs this same scan
235                // once expansion is done and these keys no longer exist, so a
236                // genuine typo inside a template is still reported — with the
237                // benefit of naming it after substitution, where the leftover
238                // is unambiguous.
239                if !matches!(key.as_str(), "config" | "template" | "props" | "components") {
240                    find_unresolved_recursive(v, out);
241                }
242            }
243        }
244        Value::Array(arr) => {
245            for item in arr {
246                find_unresolved_recursive(item, out);
247            }
248        }
249        _ => {}
250    }
251}
252
253/// Apply variable substitution to a JSON Value.
254/// Extracts the "variables" definitions, merges with optional overrides, then substitutes.
255///
256/// When a `config` block is present, overrides must reference declared variables (unknown
257/// names produce `UndefinedVariable`).
258///
259/// When there is **no** `config` block but `overrides` are provided (e.g. from the CLI for
260/// an HTML scenario that cannot carry a `config` key), the overrides are applied as raw
261/// value substitutions without type declarations — any `$name` found in the document is
262/// replaced by the override value as-is. Unresolved references after this pass are ignored
263/// (no `UnresolvedVariable` error), because the document may legitimately contain no
264/// variable references at all.
265pub fn apply_variables(
266    value: &mut Value,
267    overrides: Option<&HashMap<String, Value>>,
268    path: &str,
269) -> Result<()> {
270    let definitions = extract_variable_definitions(value)?;
271
272    match definitions {
273        Some(defs) => {
274            // Validate that every definition has a default
275            for (name, def) in &defs {
276                if def.default.is_null() {
277                    return Err(RustmotionError::VariableMissingDefault {
278                        name: name.clone(),
279                        path: path.to_string(),
280                    });
281                }
282            }
283
284            let merged = merge_variables(&defs, overrides, path)?;
285            // Remove "config" key from the value so it doesn't interfere with deserialization
286            if let Value::Object(map) = value {
287                map.remove("config");
288            }
289            substitute(value, &merged, path)?;
290        }
291        None => {
292            // No config block. If overrides were supplied (e.g. from the CLI for an HTML
293            // scenario), apply them as raw substitutions — no declaration required.
294            if let Some(ovr) = overrides {
295                if !ovr.is_empty() {
296                    substitute(value, ovr, path)?;
297                }
298            }
299        }
300    }
301
302    // Constat #7: `find_unresolved` used to run — and hard-fail the whole
303    // render/validate on its first hit — *only* inside the `Some(defs)`
304    // branch above, so the exact same leftover `$word` (a price tag, a
305    // terminal `$PATH`, a shell `$HOME`) was harmless in a document with no
306    // `config` block and fatal the moment an unrelated `config` block
307    // existed anywhere else in the same file. `find_unresolved` cannot
308    // structurally tell a genuine unresolved-reference typo apart from
309    // incidental literal-`$` content — by construction, every name in
310    // `defs` above is always present in `merged` (defaults ∪ overrides), so
311    // `substitute` can never leave a *declared* variable name unresolved;
312    // everything `find_unresolved` can still find here is, definitionally,
313    // *not* one of the variables this document declared. So: run the same
314    // scan unconditionally (fixing the "depends on an unrelated key"
315    // inconsistency), but report it as a loud warning rather than aborting
316    // the whole document — same fail-loud-not-silent contract already used
317    // elsewhere in this workstream (see `css::units::px_or_warn`), applied
318    // here because a hard rejection would break any existing scenario that
319    // legitimately has a `$` in its content and would newly break every one
320    // of those the moment it also gained a `config` block.
321    for name in find_unresolved(value) {
322        // Reuse `UnresolvedVariable`'s existing `Display` message (see
323        // `error.rs`) for the warning text instead of hand-rolling a new
324        // one — this is the same diagnostic, just no longer fatal.
325        let diagnostic = RustmotionError::UnresolvedVariable {
326            name,
327            path: path.to_string(),
328        };
329        eprintln!(
330            "Warning: {diagnostic} — either a typo'd variable name or literal '$' content (a \
331             price, a shell $PATH, ...); the literal text is kept as-is instead of failing the \
332             render."
333        );
334    }
335
336    Ok(())
337}
338
339/// For standalone rendering: apply defaults only (no overrides).
340pub fn apply_defaults(value: &mut Value) -> Result<()> {
341    apply_variables(value, None, "<root>")
342}
343
344/// Extract variable definitions from a JSON value (if present).
345fn extract_variable_definitions(
346    value: &Value,
347) -> Result<Option<HashMap<String, VariableDefinition>>> {
348    if let Value::Object(map) = value {
349        if let Some(vars_val) = map.get("config") {
350            let defs: HashMap<String, VariableDefinition> =
351                serde_json::from_value(vars_val.clone())?;
352            return Ok(Some(defs));
353        }
354    }
355    Ok(None)
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use serde_json::json;
362
363    #[test]
364    fn test_simple_string_substitution() {
365        let mut val = json!({
366            "text": "$greeting"
367        });
368        let mut vars = HashMap::new();
369        vars.insert("greeting".to_string(), json!("Hello World"));
370        substitute(&mut val, &vars, "test").unwrap();
371        assert_eq!(val["text"], json!("Hello World"));
372    }
373
374    #[test]
375    fn test_number_substitution_preserves_type() {
376        let mut val = json!({
377            "count": "$num"
378        });
379        let mut vars = HashMap::new();
380        vars.insert("num".to_string(), json!(42));
381        substitute(&mut val, &vars, "test").unwrap();
382        assert_eq!(val["count"], json!(42));
383    }
384
385    #[test]
386    fn test_var_object_syntax() {
387        let mut val = json!({
388            "count": { "$var": "num" }
389        });
390        let mut vars = HashMap::new();
391        vars.insert("num".to_string(), json!(100));
392        substitute(&mut val, &vars, "test").unwrap();
393        assert_eq!(val["count"], json!(100));
394    }
395
396    #[test]
397    fn test_string_interpolation() {
398        let mut val = json!({
399            "text": "Hello $name, welcome!"
400        });
401        let mut vars = HashMap::new();
402        vars.insert("name".to_string(), json!("Alice"));
403        substitute(&mut val, &vars, "test").unwrap();
404        assert_eq!(val["text"], json!("Hello Alice, welcome!"));
405    }
406
407    #[test]
408    fn test_escape_dollar() {
409        let mut val = json!({
410            "text": "Price: $$100"
411        });
412        let vars = HashMap::new();
413        substitute(&mut val, &vars, "test").unwrap();
414        assert_eq!(val["text"], json!("Price: $100"));
415    }
416
417    #[test]
418    fn test_merge_variables_rejects_undefined() {
419        let mut defs = HashMap::new();
420        defs.insert(
421            "color".to_string(),
422            VariableDefinition {
423                var_type: crate::schema::VariableType::String,
424                default: json!("#000"),
425                description: None,
426            },
427        );
428        let mut overrides = HashMap::new();
429        overrides.insert("unknown".to_string(), json!("value"));
430
431        let result = merge_variables(&defs, Some(&overrides), "test.json");
432        assert!(result.is_err());
433    }
434
435    #[test]
436    fn test_interpolation_type_error() {
437        let mut val = json!({
438            "text": "value is $obj"
439        });
440        let mut vars = HashMap::new();
441        vars.insert("obj".to_string(), json!({"key": "value"}));
442        let result = substitute(&mut val, &vars, "test");
443        assert!(result.is_err());
444    }
445
446    #[test]
447    fn test_find_unresolved() {
448        let val = json!({
449            "text": "$missing",
450            "nested": {
451                "val": { "$var": "also_missing" }
452            }
453        });
454        let unresolved = find_unresolved(&val);
455        assert!(unresolved.contains(&"missing".to_string()));
456        assert!(unresolved.contains(&"also_missing".to_string()));
457    }
458
459    #[test]
460    fn test_apply_defaults() {
461        let mut val = json!({
462            "config": {
463                "color": { "type": "string", "default": "#FF0000" }
464            },
465            "video": { "width": 1080, "height": 1920 },
466            "scenes": [
467                { "duration": 5.0, "children": [
468                    { "type": "text", "content": "Color is $color" }
469                ]}
470            ]
471        });
472        apply_defaults(&mut val).unwrap();
473        assert_eq!(
474            val["scenes"][0]["children"][0]["content"],
475            json!("Color is #FF0000")
476        );
477        // "config" key should be removed
478        assert!(val.get("config").is_none());
479    }
480
481    #[test]
482    fn test_config_key_not_substituted() {
483        let mut val = json!({
484            "config": {
485                "name": { "type": "string", "default": "$not_a_ref" }
486            },
487            "text": "$name"
488        });
489        let mut vars = HashMap::new();
490        vars.insert("name".to_string(), json!("resolved"));
491        substitute(&mut val, &vars, "test").unwrap();
492        // "config" block should be untouched
493        assert_eq!(val["config"]["name"]["default"], json!("$not_a_ref"));
494        assert_eq!(val["text"], json!("resolved"));
495    }
496
497    #[test]
498    fn test_recursive_array_substitution() {
499        let mut val = json!(["$a", ["$b", "$c"]]);
500        let mut vars = HashMap::new();
501        vars.insert("a".to_string(), json!(1));
502        vars.insert("b".to_string(), json!(2));
503        vars.insert("c".to_string(), json!(3));
504        substitute(&mut val, &vars, "test").unwrap();
505        assert_eq!(val, json!([1, [2, 3]]));
506    }
507
508    #[test]
509    fn test_number_interpolation_in_string() {
510        let mut val = json!({
511            "text": "Count: $num items"
512        });
513        let mut vars = HashMap::new();
514        vars.insert("num".to_string(), json!(42));
515        substitute(&mut val, &vars, "test").unwrap();
516        assert_eq!(val["text"], json!("Count: 42 items"));
517    }
518
519    // ---- constat #7: literal `$` fatality must not depend on an unrelated
520    // `config` key (RED first) ----
521
522    /// A document with **no** `config` block and a literal `$` in unrelated
523    /// content (a `terminal` line's `$PATH`) — this already succeeds today
524    /// (the bug is the *other* direction; this locks in it keeps working).
525    fn doc_with_literal_dollar_no_config() -> serde_json::Value {
526        json!({
527            "video": { "width": 1080, "height": 1920 },
528            "scenes": [{
529                "duration": 3.0,
530                "children": [
531                    { "type": "terminal", "lines": ["echo $PATH", "cd $HOME/project"] },
532                    { "type": "text", "content": "Price: $100 today only" }
533                ]
534            }]
535        })
536    }
537
538    /// The exact same literal-`$` content, but the document also happens to
539    /// declare an unrelated `config` block (e.g. because it's a reusable
540    /// template with one templated field). Before the fix, this made
541    /// `apply_variables` return `Err(UnresolvedVariable)` and abort the
542    /// entire render/validate — for content the config block has nothing to
543    /// do with.
544    fn doc_with_literal_dollar_and_unrelated_config() -> serde_json::Value {
545        json!({
546            "config": {
547                "title": { "type": "string", "default": "Demo" }
548            },
549            "video": { "width": 1080, "height": 1920 },
550            "scenes": [{
551                "duration": 3.0,
552                "children": [
553                    { "type": "text", "content": "$title" },
554                    { "type": "terminal", "lines": ["echo $PATH", "cd $HOME/project"] },
555                    { "type": "text", "content": "Price: $100 today only" }
556                ]
557            }]
558        })
559    }
560
561    #[test]
562    fn literal_dollar_without_config_block_already_succeeds() {
563        let mut doc = doc_with_literal_dollar_no_config();
564        apply_defaults(&mut doc).expect(
565            "a literal '$' in terminal/text content with no config block must not be fatal",
566        );
567        // Content is left as-is: nothing declared these as variables.
568        assert_eq!(
569            doc["scenes"][0]["children"][0]["lines"][0],
570            json!("echo $PATH")
571        );
572    }
573
574    #[test]
575    fn literal_dollar_with_unrelated_config_block_must_not_be_fatal() {
576        // RED before the fix: this currently returns
577        // `Err(UnresolvedVariable { name: "PATH", .. })` (or "HOME", or
578        // "100", whichever `find_unresolved` reaches first) purely because
579        // *some* config block exists elsewhere in the same document — the
580        // exact inconsistency named in constat #7. The declared `$title`
581        // variable must still resolve correctly either way.
582        let mut doc = doc_with_literal_dollar_and_unrelated_config();
583        apply_defaults(&mut doc).expect(
584            "a literal '$' in unrelated content must not become fatal just because the \
585             document also happens to declare an unrelated `config` block",
586        );
587        assert_eq!(doc["scenes"][0]["children"][0]["content"], json!("Demo"));
588        assert_eq!(
589            doc["scenes"][0]["children"][1]["lines"][0],
590            json!("echo $PATH")
591        );
592        assert_eq!(
593            doc["scenes"][0]["children"][2]["content"],
594            json!("Price: $100 today only")
595        );
596    }
597
598    #[test]
599    fn undeclared_override_is_still_a_hard_error_unaffected_by_the_fix() {
600        // The other half of `apply_variables`'s error surface (an override
601        // key that doesn't match any declared variable) is a genuine,
602        // unambiguous user error — unrelated to the literal-`$`-in-content
603        // ambiguity — and must remain a hard error.
604        let mut doc = json!({
605            "config": { "title": { "type": "string", "default": "Demo" } },
606            "video": { "width": 1, "height": 1 },
607            "scenes": []
608        });
609        let mut overrides = HashMap::new();
610        overrides.insert("nope".to_string(), json!("x"));
611        let err = apply_variables(&mut doc, Some(&overrides), "test.json")
612            .expect_err("an override referencing an undeclared variable must still be rejected");
613        assert!(matches!(
614            err,
615            crate::error::RustmotionError::UndefinedVariable { .. }
616        ));
617    }
618
619    #[test]
620    fn declared_variable_reference_still_resolves_with_no_override() {
621        let mut doc = json!({
622            "config": { "greeting": { "type": "string", "default": "Hello" } },
623            "video": { "width": 1, "height": 1 },
624            "scenes": [{ "duration": 1.0, "children": [
625                { "type": "text", "content": "$greeting" }
626            ]}]
627        });
628        apply_defaults(&mut doc).unwrap();
629        assert_eq!(doc["scenes"][0]["children"][0]["content"], json!("Hello"));
630    }
631}