1use std::collections::HashMap;
2
3use crate::error::Result;
4use serde_json::Value;
5
6use crate::error::RustmotionError;
7use crate::schema::VariableDefinition;
8
9fn 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 for (name, def) in definitions {
20 merged.insert(name.clone(), def.default.clone());
21 }
22
23 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
39pub(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 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 return Ok(());
64 }
65
66 if s.contains('$') {
68 let result = interpolate_string(s, vars, path)?;
69 *s = result;
70 }
71 }
72 Value::Object(map) => {
73 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 return Ok(());
83 }
84 }
85 }
86
87 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
108fn 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 if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
118 return None;
119 }
120 if s.len() != 1 + name.len() {
122 return None;
123 }
124 Some(name)
125}
126
127fn 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 chars.next();
138 result.push('$');
139 } else {
140 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 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 result.push('$');
168 result.push_str(&name);
169 }
170 }
171 } else {
172 result.push(ch);
173 }
174 }
175
176 Ok(result)
177}
178
179pub 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(); } 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 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 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
253pub 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 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 if let Value::Object(map) = value {
287 map.remove("config");
288 }
289 substitute(value, &merged, path)?;
290 }
291 None => {
292 if let Some(ovr) = overrides {
295 if !ovr.is_empty() {
296 substitute(value, ovr, path)?;
297 }
298 }
299 }
300 }
301
302 for name in find_unresolved(value) {
322 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
339pub fn apply_defaults(value: &mut Value) -> Result<()> {
341 apply_variables(value, None, "<root>")
342}
343
344fn 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 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 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 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 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 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 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 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}