1use crate::encryption::init_key_store;
11use crate::request_chaining::ChainTemplatingContext;
12use crate::time_travel::VirtualClock;
13use crate::Config;
14use chrono::{Duration as ChronoDuration, Utc};
15use once_cell::sync::{Lazy, OnceCell};
16use rand::{thread_rng, Rng};
17use regex::Regex;
18use serde_json::Value;
19use std::collections::HashMap;
20use std::sync::Arc;
21
22static RANDINT_RE: Lazy<Regex> = Lazy::new(|| {
24 Regex::new(r"\{\{\s*(?:randInt|rand\.int)\s+(-?\d+)\s+(-?\d+)\s*\}\}")
25 .expect("RANDINT_RE regex pattern is valid")
26});
27
28static NOW_OFFSET_RE: Lazy<Regex> = Lazy::new(|| {
29 Regex::new(r"\{\{\s*now\s*([+-])\s*(\d+)\s*([smhd])\s*\}\}")
30 .expect("NOW_OFFSET_RE regex pattern is valid")
31});
32
33static ENV_TOKEN_RE: Lazy<Regex> = Lazy::new(|| {
34 Regex::new(r"\{\{\s*([^{}\s]+)\s*\}\}").expect("ENV_TOKEN_RE regex pattern is valid")
35});
36
37static CHAIN_TOKEN_RE: Lazy<Regex> = Lazy::new(|| {
38 Regex::new(r"\{\{\s*chain\.([^}]+)\s*\}\}").expect("CHAIN_TOKEN_RE regex pattern is valid")
39});
40
41static RESPONSE_FN_RE: Lazy<Regex> = Lazy::new(|| {
42 Regex::new(r#"response\s*\(\s*['"]([^'"]*)['"]\s*,\s*['"]([^'"]*)['"]\s*\)"#)
43 .expect("RESPONSE_FN_RE regex pattern is valid")
44});
45
46static ENCRYPT_RE: Lazy<Regex> = Lazy::new(|| {
47 Regex::new(r#"\{\{\s*encrypt\s+(?:([^\s}]+)\s+)?\s*"([^"]+)"\s*\}\}"#)
48 .expect("ENCRYPT_RE regex pattern is valid")
49});
50
51static SECURE_RE: Lazy<Regex> = Lazy::new(|| {
52 Regex::new(r#"\{\{\s*secure\s+(?:([^\s}]+)\s+)?\s*"([^"]+)"\s*\}\}"#)
53 .expect("SECURE_RE regex pattern is valid")
54});
55
56static DECRYPT_RE: Lazy<Regex> = Lazy::new(|| {
57 Regex::new(r#"\{\{\s*decrypt\s+(?:([^\s}]+)\s+)?\s*"([^"]+)"\s*\}\}"#)
58 .expect("DECRYPT_RE regex pattern is valid")
59});
60
61static FS_READFILE_RE: Lazy<Regex> = Lazy::new(|| {
62 Regex::new(r#"\{\{\s*fs\.readFile\s*(?:\(?\s*(?:'([^']*)'|"([^"]*)")\s*\)?)?\s*\}\}"#)
63 .expect("FS_READFILE_RE regex pattern is valid")
64});
65
66#[derive(Debug, Clone)]
68pub struct TemplateEngine {
69 _config: Config,
71}
72
73impl Default for TemplateEngine {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl TemplateEngine {
80 pub fn new() -> Self {
82 Self {
83 _config: Config::default(),
84 }
85 }
86
87 pub fn new_with_config(
89 config: Config,
90 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
91 Ok(Self { _config: config })
92 }
93
94 pub fn expand_str(&self, input: &str) -> String {
96 expand_str(input)
97 }
98
99 pub fn expand_str_with_context(&self, input: &str, context: &TemplatingContext) -> String {
101 expand_str_with_context(input, context)
102 }
103
104 pub fn expand_tokens(&self, value: &Value) -> Value {
106 expand_tokens(value)
107 }
108
109 pub fn expand_tokens_with_context(&self, value: &Value, context: &TemplatingContext) -> Value {
111 expand_tokens_with_context(value, context)
112 }
113}
114
115#[derive(Debug, Clone)]
117pub struct EnvironmentTemplatingContext {
118 pub variables: HashMap<String, String>,
120}
121
122impl EnvironmentTemplatingContext {
123 pub fn new(variables: HashMap<String, String>) -> Self {
125 Self { variables }
126 }
127
128 pub fn get_variable(&self, name: &str) -> Option<&String> {
130 self.variables.get(name)
131 }
132}
133
134#[derive(Debug, Clone)]
136pub struct TemplatingContext {
137 pub chain_context: Option<ChainTemplatingContext>,
139 pub env_context: Option<EnvironmentTemplatingContext>,
141 pub virtual_clock: Option<Arc<VirtualClock>>,
143}
144
145impl TemplatingContext {
146 pub fn empty() -> Self {
148 Self {
149 chain_context: None,
150 env_context: None,
151 virtual_clock: None,
152 }
153 }
154
155 pub fn with_env(variables: HashMap<String, String>) -> Self {
157 Self {
158 chain_context: None,
159 env_context: Some(EnvironmentTemplatingContext::new(variables)),
160 virtual_clock: None,
161 }
162 }
163
164 pub fn with_chain(chain_context: ChainTemplatingContext) -> Self {
166 Self {
167 chain_context: Some(chain_context),
168 env_context: None,
169 virtual_clock: None,
170 }
171 }
172
173 pub fn with_both(
175 chain_context: ChainTemplatingContext,
176 variables: HashMap<String, String>,
177 ) -> Self {
178 Self {
179 chain_context: Some(chain_context),
180 env_context: Some(EnvironmentTemplatingContext::new(variables)),
181 virtual_clock: None,
182 }
183 }
184
185 pub fn with_virtual_clock(clock: Arc<VirtualClock>) -> Self {
187 Self {
188 chain_context: None,
189 env_context: None,
190 virtual_clock: Some(clock),
191 }
192 }
193
194 pub fn with_clock(mut self, clock: Arc<VirtualClock>) -> Self {
196 self.virtual_clock = Some(clock);
197 self
198 }
199}
200
201pub fn expand_tokens(v: &Value) -> Value {
212 expand_tokens_with_context(v, &TemplatingContext::empty())
213}
214
215pub fn expand_tokens_with_context(v: &Value, context: &TemplatingContext) -> Value {
227 match v {
228 Value::String(s) => Value::String(expand_str_with_context(s, context)),
229 Value::Array(a) => {
230 Value::Array(a.iter().map(|item| expand_tokens_with_context(item, context)).collect())
231 }
232 Value::Object(o) => {
233 let mut map = serde_json::Map::new();
234 for (k, vv) in o {
235 map.insert(k.clone(), expand_tokens_with_context(vv, context));
236 }
237 Value::Object(map)
238 }
239 _ => v.clone(),
240 }
241}
242
243pub fn expand_str(input: &str) -> String {
254 expand_str_with_context(input, &TemplatingContext::empty())
255}
256
257pub fn expand_str_with_context(input: &str, context: &TemplatingContext) -> String {
269 if !input.contains("{{") {
271 return input.to_string();
272 }
273
274 let mut out = input.to_string();
275
276 if out.contains("{{uuid}}") {
278 out = out.replace("{{uuid}}", &uuid::Uuid::new_v4().to_string());
279 }
280
281 let needs_time = out.contains("{{now}}") || NOW_OFFSET_RE.is_match(&out);
283 let current_time = if needs_time {
284 if let Some(clock) = &context.virtual_clock {
285 Some(clock.now())
286 } else {
287 Some(Utc::now())
288 }
289 } else {
290 None
291 };
292
293 if let Some(time) = current_time {
294 if out.contains("{{now}}") {
295 out = out.replace("{{now}}", &time.to_rfc3339());
296 }
297 if NOW_OFFSET_RE.is_match(&out) {
299 out = replace_now_offset_with_time(&out, time);
300 }
301 }
302
303 if out.contains("{{rand.int}}") {
305 let n: i64 = thread_rng().random_range(0..=1_000_000);
306 out = out.replace("{{rand.int}}", &n.to_string());
307 }
308 if out.contains("{{rand.float}}") {
309 let n: f64 = thread_rng().random();
310 out = out.replace("{{rand.float}}", &format!("{:.6}", n));
311 }
312 if RANDINT_RE.is_match(&out) {
313 out = replace_randint_ranges(&out);
314 }
315
316 if out.contains("response(") {
318 out = replace_response_function(&out, context.chain_context.as_ref());
319 }
320
321 if out.contains("{{") {
323 if let Some(env_ctx) = context.env_context.as_ref() {
324 out = replace_env_tokens(&out, env_ctx);
325 }
326 }
327
328 if out.contains("{{chain.") {
330 out = replace_chain_tokens(&out, context.chain_context.as_ref());
331 }
332
333 static FAKER_ENABLED: Lazy<bool> = Lazy::new(|| {
336 std::env::var("MOCKFORGE_FAKE_TOKENS")
337 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
338 .unwrap_or(true)
339 });
340 if *FAKER_ENABLED && out.contains("{{faker.") {
341 out = replace_faker_tokens(&out);
342 }
343
344 if out.contains("{{fs.readFile") {
346 out = replace_fs_tokens(&out);
347 }
348
349 if out.contains("{{encrypt") || out.contains("{{decrypt") || out.contains("{{secure") {
351 out = replace_encryption_tokens(&out);
352 }
353
354 out
355}
356
357static FAKER_PROVIDER: OnceCell<Arc<dyn FakerProvider + Send + Sync>> = OnceCell::new();
359
360pub trait FakerProvider {
365 fn uuid(&self) -> String {
367 uuid::Uuid::new_v4().to_string()
368 }
369 fn email(&self) -> String {
371 format!("user{}@example.com", thread_rng().random_range(1000..=9999))
372 }
373 fn name(&self) -> String {
375 "Alex Smith".to_string()
376 }
377 fn address(&self) -> String {
379 "1 Main St".to_string()
380 }
381 fn phone(&self) -> String {
383 "+1-555-0100".to_string()
384 }
385 fn company(&self) -> String {
387 "Example Inc".to_string()
388 }
389 fn url(&self) -> String {
391 "https://example.com".to_string()
392 }
393 fn ip(&self) -> String {
395 "192.168.1.1".to_string()
396 }
397 fn color(&self) -> String {
399 "blue".to_string()
400 }
401 fn word(&self) -> String {
403 "word".to_string()
404 }
405 fn sentence(&self) -> String {
407 "A sample sentence.".to_string()
408 }
409 fn paragraph(&self) -> String {
411 "A sample paragraph.".to_string()
412 }
413}
414
415pub fn register_faker_provider(provider: Arc<dyn FakerProvider + Send + Sync>) {
423 let _ = FAKER_PROVIDER.set(provider);
424}
425
426fn replace_randint_ranges(input: &str) -> String {
427 let mut s = input.to_string();
429 loop {
430 let mat = RANDINT_RE.captures(&s);
431 if let Some(caps) = mat {
432 let a: i64 = caps.get(1).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
433 let b: i64 = caps.get(2).map(|m| m.as_str().parse().unwrap_or(100)).unwrap_or(100);
434 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
435 let n: i64 = thread_rng().random_range(lo..=hi);
436 s = RANDINT_RE.replace(&s, n.to_string()).to_string();
437 } else {
438 break;
439 }
440 }
441 s
442}
443
444#[allow(dead_code)]
448fn replace_now_offset(input: &str) -> String {
449 replace_now_offset_with_time(input, Utc::now())
450}
451
452fn replace_now_offset_with_time(input: &str, current_time: chrono::DateTime<Utc>) -> String {
453 NOW_OFFSET_RE
455 .replace_all(input, |caps: ®ex::Captures| {
456 let sign = caps.get(1).map(|m| m.as_str()).unwrap_or("+");
457 let amount: i64 = caps.get(2).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
458 let unit = caps.get(3).map(|m| m.as_str()).unwrap_or("d");
459 let dur = match unit {
460 "s" => ChronoDuration::seconds(amount),
461 "m" => ChronoDuration::minutes(amount),
462 "h" => ChronoDuration::hours(amount),
463 _ => ChronoDuration::days(amount),
464 };
465 let ts = if sign == "+" {
466 current_time + dur
467 } else {
468 current_time - dur
469 };
470 ts.to_rfc3339()
471 })
472 .to_string()
473}
474
475fn replace_env_tokens(input: &str, env_context: &EnvironmentTemplatingContext) -> String {
477 ENV_TOKEN_RE
478 .replace_all(input, |caps: ®ex::Captures| {
479 let var_name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
480
481 if matches!(var_name, "uuid" | "now")
483 || var_name.starts_with("rand.")
484 || var_name.starts_with("faker.")
485 || var_name.starts_with("chain.")
486 || var_name.starts_with("encrypt")
487 || var_name.starts_with("decrypt")
488 || var_name.starts_with("secure")
489 {
490 return caps.get(0).map(|m| m.as_str().to_string()).unwrap_or_default();
491 }
492
493 match env_context.get_variable(var_name) {
495 Some(value) => value.clone(),
496 None => format!("{{{{{}}}}}", var_name), }
498 })
499 .to_string()
500}
501
502fn replace_chain_tokens(input: &str, chain_context: Option<&ChainTemplatingContext>) -> String {
504 if let Some(context) = chain_context {
505 CHAIN_TOKEN_RE
506 .replace_all(input, |caps: ®ex::Captures| {
507 let path = caps.get(1).map(|m| m.as_str()).unwrap_or("");
508
509 match context.extract_value(path) {
510 Some(Value::String(s)) => s,
511 Some(Value::Number(n)) => n.to_string(),
512 Some(Value::Bool(b)) => b.to_string(),
513 Some(val) => serde_json::to_string(&val).unwrap_or_else(|_| "null".to_string()),
514 None => "null".to_string(), }
516 })
517 .to_string()
518 } else {
519 input.to_string()
521 }
522}
523
524fn replace_response_function(
526 input: &str,
527 chain_context: Option<&ChainTemplatingContext>,
528) -> String {
529 if let Some(context) = chain_context {
531 let result = RESPONSE_FN_RE
532 .replace_all(input, |caps: ®ex::Captures| {
533 let request_id = caps.get(1).map(|m| m.as_str()).unwrap_or("");
534 let json_path = caps.get(2).map(|m| m.as_str()).unwrap_or("");
535
536 let full_path = if json_path.is_empty() {
538 request_id.to_string()
539 } else {
540 format!("{}.{}", request_id, json_path)
541 };
542
543 match context.extract_value(&full_path) {
544 Some(Value::String(s)) => s,
545 Some(Value::Number(n)) => n.to_string(),
546 Some(Value::Bool(b)) => b.to_string(),
547 Some(val) => serde_json::to_string(&val).unwrap_or_else(|_| "null".to_string()),
548 None => "null".to_string(), }
550 })
551 .to_string();
552
553 result
554 } else {
555 input.to_string()
557 }
558}
559
560fn replace_encryption_tokens(input: &str) -> String {
562 let key_store = init_key_store();
564
565 let default_key_id = "mockforge_default";
567
568 let mut out = input.to_string();
569
570 out = ENCRYPT_RE
572 .replace_all(&out, |caps: ®ex::Captures| {
573 let key_id = caps.get(1).map(|m| m.as_str()).unwrap_or(default_key_id);
574 let plaintext = caps.get(2).map(|m| m.as_str()).unwrap_or("");
575
576 match key_store.get_key(key_id) {
577 Some(key) => match key.encrypt(plaintext, None) {
578 Ok(ciphertext) => ciphertext,
579 Err(_) => "<encryption_error>".to_string(),
580 },
581 None => {
582 let password = std::env::var("MOCKFORGE_ENCRYPTION_KEY")
584 .unwrap_or_else(|_| "mockforge_default_encryption_key_2024".to_string());
585 match crate::encryption::EncryptionKey::from_password_pbkdf2(
586 &password,
587 None,
588 crate::encryption::EncryptionAlgorithm::Aes256Gcm,
589 ) {
590 Ok(key) => match key.encrypt(plaintext, None) {
591 Ok(ciphertext) => ciphertext,
592 Err(_) => "<encryption_error>".to_string(),
593 },
594 Err(_) => "<key_creation_error>".to_string(),
595 }
596 }
597 }
598 })
599 .to_string();
600
601 out = SECURE_RE
603 .replace_all(&out, |caps: ®ex::Captures| {
604 let key_id = caps.get(1).map(|m| m.as_str()).unwrap_or(default_key_id);
605 let plaintext = caps.get(2).map(|m| m.as_str()).unwrap_or("");
606
607 match key_store.get_key(key_id) {
608 Some(key) => {
609 match key.encrypt_chacha20(plaintext, None) {
611 Ok(ciphertext) => ciphertext,
612 Err(_) => "<encryption_error>".to_string(),
613 }
614 }
615 None => {
616 let password = std::env::var("MOCKFORGE_ENCRYPTION_KEY")
618 .unwrap_or_else(|_| "mockforge_default_encryption_key_2024".to_string());
619 match crate::encryption::EncryptionKey::from_password_pbkdf2(
620 &password,
621 None,
622 crate::encryption::EncryptionAlgorithm::ChaCha20Poly1305,
623 ) {
624 Ok(key) => match key.encrypt_chacha20(plaintext, None) {
625 Ok(ciphertext) => ciphertext,
626 Err(_) => "<encryption_error>".to_string(),
627 },
628 Err(_) => "<key_creation_error>".to_string(),
629 }
630 }
631 }
632 })
633 .to_string();
634
635 out = DECRYPT_RE
637 .replace_all(&out, |caps: ®ex::Captures| {
638 let key_id = caps.get(1).map(|m| m.as_str()).unwrap_or(default_key_id);
639 let ciphertext = caps.get(2).map(|m| m.as_str()).unwrap_or("");
640
641 match key_store.get_key(key_id) {
642 Some(key) => match key.decrypt(ciphertext, None) {
643 Ok(plaintext) => plaintext,
644 Err(_) => "<decryption_error>".to_string(),
645 },
646 None => {
647 let password = std::env::var("MOCKFORGE_ENCRYPTION_KEY")
649 .unwrap_or_else(|_| "mockforge_default_encryption_key_2024".to_string());
650 match crate::encryption::EncryptionKey::from_password_pbkdf2(
651 &password,
652 None,
653 crate::encryption::EncryptionAlgorithm::Aes256Gcm,
654 ) {
655 Ok(key) => match key.decrypt(ciphertext, None) {
656 Ok(plaintext) => plaintext,
657 Err(_) => "<decryption_error>".to_string(),
658 },
659 Err(_) => "<key_creation_error>".to_string(),
660 }
661 }
662 }
663 })
664 .to_string();
665
666 out
667}
668
669fn replace_fs_tokens(input: &str) -> String {
671 FS_READFILE_RE
673 .replace_all(input, |caps: ®ex::Captures| {
674 let file_path = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str()).unwrap_or("");
675
676 if file_path.is_empty() {
677 return "<fs.readFile: empty path>".to_string();
678 }
679
680 match std::fs::read_to_string(file_path) {
681 Ok(content) => content,
682 Err(e) => format!("<fs.readFile error: {}>", e),
683 }
684 })
685 .to_string()
686}
687
688fn replace_faker_tokens(input: &str) -> String {
689 if let Some(provider) = FAKER_PROVIDER.get() {
691 return replace_with_provider(input, provider.as_ref());
692 }
693 replace_with_fallback(input)
694}
695
696fn replace_with_provider(input: &str, p: &dyn FakerProvider) -> String {
697 let mut out = input.to_string();
698 let map = [
699 ("{{faker.uuid}}", p.uuid()),
700 ("{{faker.email}}", p.email()),
701 ("{{faker.name}}", p.name()),
702 ("{{faker.address}}", p.address()),
703 ("{{faker.phone}}", p.phone()),
704 ("{{faker.company}}", p.company()),
705 ("{{faker.url}}", p.url()),
706 ("{{faker.ip}}", p.ip()),
707 ("{{faker.color}}", p.color()),
708 ("{{faker.word}}", p.word()),
709 ("{{faker.sentence}}", p.sentence()),
710 ("{{faker.paragraph}}", p.paragraph()),
711 ];
712 for (pat, val) in map {
713 if out.contains(pat) {
714 out = out.replace(pat, &val);
715 }
716 }
717 out
718}
719
720fn replace_with_fallback(input: &str) -> String {
721 let mut out = input.to_string();
722 if out.contains("{{faker.uuid}}") {
723 out = out.replace("{{faker.uuid}}", &uuid::Uuid::new_v4().to_string());
724 }
725 if out.contains("{{faker.email}}") {
726 let user: String =
727 (0..8).map(|_| (b'a' + (thread_rng().random::<u8>() % 26)) as char).collect();
728 let dom: String =
729 (0..6).map(|_| (b'a' + (thread_rng().random::<u8>() % 26)) as char).collect();
730 out = out.replace("{{faker.email}}", &format!("{}@{}.example", user, dom));
731 }
732 if out.contains("{{faker.name}}") {
733 let firsts = ["Alex", "Sam", "Taylor", "Jordan", "Casey", "Riley"];
734 let lasts = ["Smith", "Lee", "Patel", "Garcia", "Kim", "Brown"];
735 let fi = thread_rng().random::<u8>() as usize % firsts.len();
736 let li = thread_rng().random::<u8>() as usize % lasts.len();
737 out = out.replace("{{faker.name}}", &format!("{} {}", firsts[fi], lasts[li]));
738 }
739 out
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745 use crate::request_chaining::{ChainContext, ChainResponse, ChainTemplatingContext};
746 use serde_json::json;
747
748 #[test]
749 fn test_expand_str_with_context() {
750 let chain_context = ChainTemplatingContext::new(ChainContext::new());
751 let context = TemplatingContext::with_chain(chain_context);
752 let result = expand_str_with_context("{{uuid}}", &context);
753 assert!(!result.is_empty());
754 }
755
756 #[test]
757 fn test_replace_env_tokens() {
758 let mut vars = HashMap::new();
759 vars.insert("api_key".to_string(), "secret123".to_string());
760 let env_context = EnvironmentTemplatingContext::new(vars);
761 let result = replace_env_tokens("{{api_key}}", &env_context);
762 assert_eq!(result, "secret123");
763 }
764
765 #[test]
766 fn test_replace_chain_tokens() {
767 let chain_ctx = ChainContext::new();
768 let template_ctx = ChainTemplatingContext::new(chain_ctx);
769 let context = Some(&template_ctx);
770 let result = replace_chain_tokens("{{chain.test.body}}", context);
772 assert_eq!(result, "null");
773 }
774
775 #[test]
776 fn test_response_function() {
777 let result = replace_response_function(r#"response('login', 'body.user_id')"#, None);
779 assert_eq!(result, r#"response('login', 'body.user_id')"#);
780
781 let chain_ctx = ChainContext::new();
783 let template_ctx = ChainTemplatingContext::new(chain_ctx);
784 let context = Some(&template_ctx);
785 let result = replace_response_function(r#"response('login', 'body.user_id')"#, context);
786 assert_eq!(result, "null");
787
788 let mut chain_ctx = ChainContext::new();
790 let response = ChainResponse {
791 status: 200,
792 headers: HashMap::new(),
793 body: Some(json!({"user_id": 12345})),
794 duration_ms: 150,
795 executed_at: "2023-01-01T00:00:00Z".to_string(),
796 error: None,
797 };
798 chain_ctx.store_response("login".to_string(), response);
799 let template_ctx = ChainTemplatingContext::new(chain_ctx);
800 let context = Some(&template_ctx);
801
802 let result = replace_response_function(r#"response('login', 'user_id')"#, context);
803 assert_eq!(result, "12345");
804 }
805
806 #[test]
807 fn test_fs_readfile() {
808 use std::fs;
810
811 let temp_file = "/tmp/mockforge_test_file.txt";
812 let test_content = "Hello, this is test content!";
813 fs::write(temp_file, test_content).unwrap();
814
815 let template = format!(r#"{{{{fs.readFile "{}"}}}}"#, temp_file);
817 let result = expand_str(&template);
818 assert_eq!(result, test_content);
819
820 let template = format!(r#"{{{{fs.readFile('{}')}}}}"#, temp_file);
822 let result = expand_str(&template);
823 assert_eq!(result, test_content);
824
825 let template = r#"{{fs.readFile "/nonexistent/file.txt"}}"#;
827 let result = expand_str(template);
828 assert!(result.contains("fs.readFile error:"));
829
830 let template = r#"{{fs.readFile ""}}"#;
832 let result = expand_str(template);
833 assert_eq!(result, "<fs.readFile: empty path>");
834
835 let _ = fs::remove_file(temp_file);
837 }
838}