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