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}