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, VariableType};
8
9fn value_matches_declared_type(value: &Value, var_type: &VariableType) -> bool {
10    match var_type {
11        VariableType::String => value.is_string(),
12        VariableType::Number => value.is_number(),
13        VariableType::Boolean => value.is_boolean(),
14        VariableType::Object => value.is_object(),
15        VariableType::Array => value.is_array(),
16    }
17}
18
19fn json_type_name(value: &Value) -> &'static str {
20    match value {
21        Value::Null => "null",
22        Value::Bool(_) => "boolean",
23        Value::Number(_) => "number",
24        Value::String(_) => "string",
25        Value::Array(_) => "array",
26        Value::Object(_) => "object",
27    }
28}
29
30fn declared_type_name(var_type: &VariableType) -> &'static str {
31    match var_type {
32        VariableType::String => "string",
33        VariableType::Number => "number",
34        VariableType::Boolean => "boolean",
35        VariableType::Object => "object",
36        VariableType::Array => "array",
37    }
38}
39
40fn merge_variables(
41    definitions: &HashMap<String, VariableDefinition>,
42    overrides: Option<&HashMap<String, Value>>,
43    path: &str,
44) -> Result<HashMap<String, Value>> {
45    let mut merged = HashMap::with_capacity(definitions.len());
46
47    for (name, def) in definitions {
48        if !value_matches_declared_type(&def.default, &def.var_type) {
49            return Err(RustmotionError::Generic(format!(
50                "Variable '${name}' in '{path}' is declared as type \"{declared}\" but its \
51                 default value is a {actual}",
52                declared = declared_type_name(&def.var_type),
53                actual = json_type_name(&def.default),
54            )));
55        }
56        merged.insert(name.clone(), def.default.clone());
57    }
58
59    if let Some(ovr) = overrides {
60        for (name, value) in ovr {
61            let def = definitions
62                .get(name)
63                .ok_or_else(|| RustmotionError::UndefinedVariable {
64                    name: name.clone(),
65                    path: path.to_string(),
66                })?;
67            if !value_matches_declared_type(value, &def.var_type) {
68                return Err(RustmotionError::Generic(format!(
69                    "Variable '${name}' in '{path}' is declared as type \"{declared}\" but the \
70                     override value is a {actual}",
71                    declared = declared_type_name(&def.var_type),
72                    actual = json_type_name(value),
73                )));
74            }
75            merged.insert(name.clone(), value.clone());
76        }
77    }
78
79    Ok(merged)
80}
81
82pub(crate) fn substitute(
83    value: &mut Value,
84    vars: &HashMap<String, Value>,
85    path: &str,
86) -> Result<()> {
87    match value {
88        Value::String(s) => {
89            if let Some(var_name) = parse_single_var_ref(s) {
90                if let Some(replacement) = vars.get(var_name) {
91                    *value = replacement.clone();
92                    return Ok(());
93                }
94                return Ok(());
95            }
96
97            if s.contains('$') {
98                let result = interpolate_string(s, vars, path)?;
99                *s = result;
100            }
101        }
102        Value::Object(map) => {
103            if map.len() == 1 {
104                if let Some(var_name_val) = map.get("$var") {
105                    if let Some(var_name) = var_name_val.as_str() {
106                        if let Some(replacement) = vars.get(var_name) {
107                            *value = replacement.clone();
108                            return Ok(());
109                        }
110                        return Ok(());
111                    }
112                }
113            }
114
115            let keys: Vec<String> = map.keys().cloned().collect();
116            for key in keys {
117                if key == "config" {
118                    continue;
119                }
120                if let Some(v) = map.get_mut(&key) {
121                    substitute(v, vars, path)?;
122                }
123            }
124        }
125        Value::Array(arr) => {
126            for item in arr.iter_mut() {
127                substitute(item, vars, path)?;
128            }
129        }
130        _ => {}
131    }
132    Ok(())
133}
134
135fn parse_single_var_ref(s: &str) -> Option<&str> {
136    let s = s.trim();
137    if !s.starts_with('$') || s.starts_with("$$") {
138        return None;
139    }
140    let name = &s[1..];
141    if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
142        return None;
143    }
144    if s.len() != 1 + name.len() {
145        return None;
146    }
147    Some(name)
148}
149
150fn interpolate_string(s: &str, vars: &HashMap<String, Value>, path: &str) -> Result<String> {
151    let mut result = String::with_capacity(s.len());
152    let mut chars = s.chars().peekable();
153
154    while let Some(ch) = chars.next() {
155        if ch == '$' {
156            if chars.peek() == Some(&'$') {
157                chars.next();
158                result.push('$');
159            } else {
160                let mut name = String::new();
161                while let Some(&c) = chars.peek() {
162                    if c.is_alphanumeric() || c == '_' {
163                        name.push(c);
164                        chars.next();
165                    } else {
166                        break;
167                    }
168                }
169                if name.is_empty() {
170                    result.push('$');
171                } else if let Some(val) = vars.get(&name) {
172                    match val {
173                        Value::String(s) => result.push_str(s),
174                        Value::Number(n) => result.push_str(&n.to_string()),
175                        Value::Bool(b) => result.push_str(&b.to_string()),
176                        _ => {
177                            return Err(RustmotionError::VariableInterpolationTypeError {
178                                name,
179                                path: path.to_string(),
180                            });
181                        }
182                    }
183                } else {
184                    result.push('$');
185                    result.push_str(&name);
186                }
187            }
188        } else {
189            result.push(ch);
190        }
191    }
192
193    Ok(result)
194}
195
196pub fn find_unresolved(value: &Value) -> Vec<String> {
197    let mut unresolved = Vec::new();
198    find_unresolved_recursive(value, &mut unresolved);
199    unresolved
200}
201
202fn find_unresolved_recursive(value: &Value, out: &mut Vec<String>) {
203    match value {
204        Value::String(s) => {
205            if s.trim_start().starts_with('=') {
206                return;
207            }
208            let mut chars = s.chars().peekable();
209            while let Some(ch) = chars.next() {
210                if ch == '$' {
211                    if chars.peek() == Some(&'$') {
212                        chars.next();
213                    } else {
214                        let mut name = String::new();
215                        while let Some(&c) = chars.peek() {
216                            if c.is_alphanumeric() || c == '_' {
217                                name.push(c);
218                                chars.next();
219                            } else {
220                                break;
221                            }
222                        }
223                        if !name.is_empty() {
224                            out.push(name);
225                        }
226                    }
227                }
228            }
229        }
230        Value::Object(map) => {
231            if map.len() == 1 {
232                if let Some(val) = map.get("$var") {
233                    if let Some(name) = val.as_str() {
234                        out.push(name.to_string());
235                        return;
236                    }
237                }
238            }
239            for (key, v) in map {
240                if !matches!(key.as_str(), "config" | "template" | "props" | "components") {
241                    find_unresolved_recursive(v, out);
242                }
243            }
244        }
245        Value::Array(arr) => {
246            for item in arr {
247                find_unresolved_recursive(item, out);
248            }
249        }
250        _ => {}
251    }
252}
253
254pub fn apply_variables(
255    value: &mut Value,
256    overrides: Option<&HashMap<String, Value>>,
257    path: &str,
258) -> Result<()> {
259    let definitions = extract_variable_definitions(value)?;
260
261    match definitions {
262        Some(defs) => {
263            for (name, def) in &defs {
264                if def.default.is_null() {
265                    return Err(RustmotionError::VariableMissingDefault {
266                        name: name.clone(),
267                        path: path.to_string(),
268                    });
269                }
270            }
271
272            let merged = merge_variables(&defs, overrides, path)?;
273            if let Value::Object(map) = value {
274                map.remove("config");
275            }
276            substitute(value, &merged, path)?;
277        }
278        None => {
279            if let Some(ovr) = overrides {
280                if !ovr.is_empty() {
281                    substitute(value, ovr, path)?;
282                }
283            }
284        }
285    }
286
287    for name in find_unresolved(value) {
288        let diagnostic = RustmotionError::UnresolvedVariable {
289            name,
290            path: path.to_string(),
291        };
292        eprintln!(
293            "Warning: {diagnostic} — either a typo'd variable name or literal '$' content (a \
294             price, a shell $PATH, ...); the literal text is kept as-is instead of failing the \
295             render."
296        );
297    }
298
299    Ok(())
300}
301
302pub fn apply_defaults(value: &mut Value) -> Result<()> {
303    apply_variables(value, None, "<root>")
304}
305
306fn extract_variable_definitions(
307    value: &Value,
308) -> Result<Option<HashMap<String, VariableDefinition>>> {
309    if let Value::Object(map) = value {
310        if let Some(vars_val) = map.get("config") {
311            let defs: HashMap<String, VariableDefinition> =
312                serde_json::from_value(vars_val.clone())?;
313            return Ok(Some(defs));
314        }
315    }
316    Ok(None)
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use serde_json::json;
323
324    #[test]
325    fn test_simple_string_substitution() {
326        let mut val = json!({
327            "text": "$greeting"
328        });
329        let mut vars = HashMap::new();
330        vars.insert("greeting".to_string(), json!("Hello World"));
331        substitute(&mut val, &vars, "test").unwrap();
332        assert_eq!(val["text"], json!("Hello World"));
333    }
334
335    #[test]
336    fn test_number_substitution_preserves_type() {
337        let mut val = json!({
338            "count": "$num"
339        });
340        let mut vars = HashMap::new();
341        vars.insert("num".to_string(), json!(42));
342        substitute(&mut val, &vars, "test").unwrap();
343        assert_eq!(val["count"], json!(42));
344    }
345
346    #[test]
347    fn test_var_object_syntax() {
348        let mut val = json!({
349            "count": { "$var": "num" }
350        });
351        let mut vars = HashMap::new();
352        vars.insert("num".to_string(), json!(100));
353        substitute(&mut val, &vars, "test").unwrap();
354        assert_eq!(val["count"], json!(100));
355    }
356
357    #[test]
358    fn test_string_interpolation() {
359        let mut val = json!({
360            "text": "Hello $name, welcome!"
361        });
362        let mut vars = HashMap::new();
363        vars.insert("name".to_string(), json!("Alice"));
364        substitute(&mut val, &vars, "test").unwrap();
365        assert_eq!(val["text"], json!("Hello Alice, welcome!"));
366    }
367
368    #[test]
369    fn test_escape_dollar() {
370        let mut val = json!({
371            "text": "Price: $$100"
372        });
373        let vars = HashMap::new();
374        substitute(&mut val, &vars, "test").unwrap();
375        assert_eq!(val["text"], json!("Price: $100"));
376    }
377
378    #[test]
379    fn test_merge_variables_rejects_undefined() {
380        let mut defs = HashMap::new();
381        defs.insert(
382            "color".to_string(),
383            VariableDefinition {
384                var_type: crate::schema::VariableType::String,
385                default: json!("#000"),
386                description: None,
387            },
388        );
389        let mut overrides = HashMap::new();
390        overrides.insert("unknown".to_string(), json!("value"));
391
392        let result = merge_variables(&defs, Some(&overrides), "test.json");
393        assert!(result.is_err());
394    }
395
396    #[test]
397    fn test_merge_variables_rejects_type_mismatched_override() {
398        let mut defs = HashMap::new();
399        defs.insert(
400            "count".to_string(),
401            VariableDefinition {
402                var_type: crate::schema::VariableType::Number,
403                default: json!(0),
404                description: None,
405            },
406        );
407        let mut overrides = HashMap::new();
408        overrides.insert("count".to_string(), json!("not a number"));
409
410        let result = merge_variables(&defs, Some(&overrides), "test.json");
411        assert!(
412            result.is_err(),
413            "a string override for a declared `number` variable must be rejected"
414        );
415    }
416
417    #[test]
418    fn test_merge_variables_accepts_type_matched_override() {
419        let mut defs = HashMap::new();
420        defs.insert(
421            "count".to_string(),
422            VariableDefinition {
423                var_type: crate::schema::VariableType::Number,
424                default: json!(0),
425                description: None,
426            },
427        );
428        let mut overrides = HashMap::new();
429        overrides.insert("count".to_string(), json!(42));
430
431        let result = merge_variables(&defs, Some(&overrides), "test.json").unwrap();
432        assert_eq!(result["count"], json!(42));
433    }
434
435    #[test]
436    fn test_merge_variables_rejects_type_mismatched_default() {
437        let mut defs = HashMap::new();
438        defs.insert(
439            "flag".to_string(),
440            VariableDefinition {
441                var_type: crate::schema::VariableType::Boolean,
442                default: json!("yes"),
443                description: None,
444            },
445        );
446
447        let result = merge_variables(&defs, None, "test.json");
448        assert!(
449            result.is_err(),
450            "a string default for a declared `boolean` variable must be rejected"
451        );
452    }
453
454    #[test]
455    fn test_interpolation_type_error() {
456        let mut val = json!({
457            "text": "value is $obj"
458        });
459        let mut vars = HashMap::new();
460        vars.insert("obj".to_string(), json!({"key": "value"}));
461        let result = substitute(&mut val, &vars, "test");
462        assert!(result.is_err());
463    }
464
465    #[test]
466    fn test_find_unresolved() {
467        let val = json!({
468            "text": "$missing",
469            "nested": {
470                "val": { "$var": "also_missing" }
471            }
472        });
473        let unresolved = find_unresolved(&val);
474        assert!(unresolved.contains(&"missing".to_string()));
475        assert!(unresolved.contains(&"also_missing".to_string()));
476    }
477
478    #[test]
479    fn find_unresolved_does_not_scan_inside_an_expression_string() {
480        let val = json!({
481            "x": "= $W/2 + cos($i / $count * TAU) * 700",
482            "y": "  = $H/2",
483            "plain": "$still_flagged"
484        });
485        let unresolved = find_unresolved(&val);
486        assert!(
487            !unresolved
488                .iter()
489                .any(|n| n == "W" || n == "H" || n == "i" || n == "count"),
490            "expression identifiers must not be reported as unresolved variables: {unresolved:?}"
491        );
492        assert!(
493            unresolved.contains(&"still_flagged".to_string()),
494            "a plain (non-expression) string must still be scanned: {unresolved:?}"
495        );
496    }
497
498    #[test]
499    fn test_apply_defaults() {
500        let mut val = json!({
501            "config": {
502                "color": { "type": "string", "default": "#FF0000" }
503            },
504            "video": { "width": 1080, "height": 1920 },
505            "scenes": [
506                { "duration": 5.0, "children": [
507                    { "type": "text", "content": "Color is $color" }
508                ]}
509            ]
510        });
511        apply_defaults(&mut val).unwrap();
512        assert_eq!(
513            val["scenes"][0]["children"][0]["content"],
514            json!("Color is #FF0000")
515        );
516        assert!(val.get("config").is_none());
517    }
518
519    #[test]
520    fn test_config_key_not_substituted() {
521        let mut val = json!({
522            "config": {
523                "name": { "type": "string", "default": "$not_a_ref" }
524            },
525            "text": "$name"
526        });
527        let mut vars = HashMap::new();
528        vars.insert("name".to_string(), json!("resolved"));
529        substitute(&mut val, &vars, "test").unwrap();
530        assert_eq!(val["config"]["name"]["default"], json!("$not_a_ref"));
531        assert_eq!(val["text"], json!("resolved"));
532    }
533
534    #[test]
535    fn test_recursive_array_substitution() {
536        let mut val = json!(["$a", ["$b", "$c"]]);
537        let mut vars = HashMap::new();
538        vars.insert("a".to_string(), json!(1));
539        vars.insert("b".to_string(), json!(2));
540        vars.insert("c".to_string(), json!(3));
541        substitute(&mut val, &vars, "test").unwrap();
542        assert_eq!(val, json!([1, [2, 3]]));
543    }
544
545    #[test]
546    fn test_number_interpolation_in_string() {
547        let mut val = json!({
548            "text": "Count: $num items"
549        });
550        let mut vars = HashMap::new();
551        vars.insert("num".to_string(), json!(42));
552        substitute(&mut val, &vars, "test").unwrap();
553        assert_eq!(val["text"], json!("Count: 42 items"));
554    }
555
556    fn doc_with_literal_dollar_no_config() -> serde_json::Value {
557        json!({
558            "video": { "width": 1080, "height": 1920 },
559            "scenes": [{
560                "duration": 3.0,
561                "children": [
562                    { "type": "list", "items": ["echo $PATH", "cd $HOME/project"] },
563                    { "type": "text", "content": "Price: $100 today only" }
564                ]
565            }]
566        })
567    }
568
569    fn doc_with_literal_dollar_and_unrelated_config() -> serde_json::Value {
570        json!({
571            "config": {
572                "title": { "type": "string", "default": "Demo" }
573            },
574            "video": { "width": 1080, "height": 1920 },
575            "scenes": [{
576                "duration": 3.0,
577                "children": [
578                    { "type": "text", "content": "$title" },
579                    { "type": "list", "items": ["echo $PATH", "cd $HOME/project"] },
580                    { "type": "text", "content": "Price: $100 today only" }
581                ]
582            }]
583        })
584    }
585
586    #[test]
587    fn literal_dollar_without_config_block_already_succeeds() {
588        let mut doc = doc_with_literal_dollar_no_config();
589        apply_defaults(&mut doc)
590            .expect("a literal '$' in list/text content with no config block must not be fatal");
591        assert_eq!(
592            doc["scenes"][0]["children"][0]["items"][0],
593            json!("echo $PATH")
594        );
595    }
596
597    #[test]
598    fn literal_dollar_with_unrelated_config_block_must_not_be_fatal() {
599        let mut doc = doc_with_literal_dollar_and_unrelated_config();
600        apply_defaults(&mut doc).expect(
601            "a literal '$' in unrelated content must not become fatal just because the \
602             document also happens to declare an unrelated `config` block",
603        );
604        assert_eq!(doc["scenes"][0]["children"][0]["content"], json!("Demo"));
605        assert_eq!(
606            doc["scenes"][0]["children"][1]["items"][0],
607            json!("echo $PATH")
608        );
609        assert_eq!(
610            doc["scenes"][0]["children"][2]["content"],
611            json!("Price: $100 today only")
612        );
613    }
614
615    #[test]
616    fn undeclared_override_is_still_a_hard_error_unaffected_by_the_fix() {
617        let mut doc = json!({
618            "config": { "title": { "type": "string", "default": "Demo" } },
619            "video": { "width": 1, "height": 1 },
620            "scenes": []
621        });
622        let mut overrides = HashMap::new();
623        overrides.insert("nope".to_string(), json!("x"));
624        let err = apply_variables(&mut doc, Some(&overrides), "test.json")
625            .expect_err("an override referencing an undeclared variable must still be rejected");
626        assert!(matches!(
627            err,
628            crate::error::RustmotionError::UndefinedVariable { .. }
629        ));
630    }
631
632    #[test]
633    fn declared_variable_reference_still_resolves_with_no_override() {
634        let mut doc = json!({
635            "config": { "greeting": { "type": "string", "default": "Hello" } },
636            "video": { "width": 1, "height": 1 },
637            "scenes": [{ "duration": 1.0, "children": [
638                { "type": "text", "content": "$greeting" }
639            ]}]
640        });
641        apply_defaults(&mut doc).unwrap();
642        assert_eq!(doc["scenes"][0]["children"][0]["content"], json!("Hello"));
643    }
644}