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>,
136 pub env_context: Option<EnvironmentTemplatingContext>,
137 pub virtual_clock: Option<Arc<VirtualClock>>,
138}
139
140impl TemplatingContext {
141 pub fn empty() -> Self {
143 Self {
144 chain_context: None,
145 env_context: None,
146 virtual_clock: None,
147 }
148 }
149
150 pub fn with_env(variables: HashMap<String, String>) -> Self {
152 Self {
153 chain_context: None,
154 env_context: Some(EnvironmentTemplatingContext::new(variables)),
155 virtual_clock: None,
156 }
157 }
158
159 pub fn with_chain(chain_context: ChainTemplatingContext) -> Self {
161 Self {
162 chain_context: Some(chain_context),
163 env_context: None,
164 virtual_clock: None,
165 }
166 }
167
168 pub fn with_both(
170 chain_context: ChainTemplatingContext,
171 variables: HashMap<String, String>,
172 ) -> Self {
173 Self {
174 chain_context: Some(chain_context),
175 env_context: Some(EnvironmentTemplatingContext::new(variables)),
176 virtual_clock: None,
177 }
178 }
179
180 pub fn with_virtual_clock(clock: Arc<VirtualClock>) -> Self {
182 Self {
183 chain_context: None,
184 env_context: None,
185 virtual_clock: Some(clock),
186 }
187 }
188
189 pub fn with_clock(mut self, clock: Arc<VirtualClock>) -> Self {
191 self.virtual_clock = Some(clock);
192 self
193 }
194}
195
196pub fn expand_tokens(v: &Value) -> Value {
198 expand_tokens_with_context(v, &TemplatingContext::empty())
199}
200
201pub fn expand_tokens_with_context(v: &Value, context: &TemplatingContext) -> Value {
203 match v {
204 Value::String(s) => Value::String(expand_str_with_context(s, context)),
205 Value::Array(a) => {
206 Value::Array(a.iter().map(|item| expand_tokens_with_context(item, context)).collect())
207 }
208 Value::Object(o) => {
209 let mut map = serde_json::Map::new();
210 for (k, vv) in o {
211 map.insert(k.clone(), expand_tokens_with_context(vv, context));
212 }
213 Value::Object(map)
214 }
215 _ => v.clone(),
216 }
217}
218
219pub fn expand_str(input: &str) -> String {
221 expand_str_with_context(input, &TemplatingContext::empty())
222}
223
224pub fn expand_str_with_context(input: &str, context: &TemplatingContext) -> String {
226 let mut out = input.replace("{{uuid}}", &uuid::Uuid::new_v4().to_string());
228
229 let current_time = if let Some(clock) = &context.virtual_clock {
231 clock.now()
232 } else {
233 Utc::now()
234 };
235 out = out.replace("{{now}}", ¤t_time.to_rfc3339());
236
237 out = replace_now_offset_with_time(&out, current_time);
239
240 if out.contains("{{rand.int}}") {
242 let n: i64 = rng().random_range(0..=1_000_000);
243 out = out.replace("{{rand.int}}", &n.to_string());
244 }
245 if out.contains("{{rand.float}}") {
246 let n: f64 = rng().random();
247 out = out.replace("{{rand.float}}", &format!("{:.6}", n));
248 }
249 out = replace_randint_ranges(&out);
250
251 if out.contains("response(") {
253 out = replace_response_function(&out, context.chain_context.as_ref());
254 }
255
256 if out.contains("{{") {
258 if let Some(env_ctx) = context.env_context.as_ref() {
259 out = replace_env_tokens(&out, env_ctx);
260 }
261 }
262
263 if out.contains("{{chain.") {
265 out = replace_chain_tokens(&out, context.chain_context.as_ref());
266 }
267
268 let faker_enabled = std::env::var("MOCKFORGE_FAKE_TOKENS")
270 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
271 .unwrap_or(true);
272 if faker_enabled {
273 out = replace_faker_tokens(&out);
274 }
275
276 if out.contains("{{fs.readFile") {
278 out = replace_fs_tokens(&out);
279 }
280
281 if out.contains("{{encrypt") || out.contains("{{decrypt") || out.contains("{{secure") {
283 out = replace_encryption_tokens(&out);
284 }
285
286 out
287}
288
289static FAKER_PROVIDER: OnceCell<Arc<dyn FakerProvider + Send + Sync>> = OnceCell::new();
291
292pub trait FakerProvider {
293 fn uuid(&self) -> String {
294 uuid::Uuid::new_v4().to_string()
295 }
296 fn email(&self) -> String {
297 format!("user{}@example.com", rng().random_range(1000..=9999))
298 }
299 fn name(&self) -> String {
300 "Alex Smith".to_string()
301 }
302 fn address(&self) -> String {
303 "1 Main St".to_string()
304 }
305 fn phone(&self) -> String {
306 "+1-555-0100".to_string()
307 }
308 fn company(&self) -> String {
309 "Example Inc".to_string()
310 }
311 fn url(&self) -> String {
312 "https://example.com".to_string()
313 }
314 fn ip(&self) -> String {
315 "192.168.1.1".to_string()
316 }
317 fn color(&self) -> String {
318 "blue".to_string()
319 }
320 fn word(&self) -> String {
321 "word".to_string()
322 }
323 fn sentence(&self) -> String {
324 "A sample sentence.".to_string()
325 }
326 fn paragraph(&self) -> String {
327 "A sample paragraph.".to_string()
328 }
329}
330
331pub fn register_faker_provider(provider: Arc<dyn FakerProvider + Send + Sync>) {
332 let _ = FAKER_PROVIDER.set(provider);
333}
334
335fn replace_randint_ranges(input: &str) -> String {
336 let mut s = input.to_string();
338 loop {
339 let mat = RANDINT_RE.captures(&s);
340 if let Some(caps) = mat {
341 let a: i64 = caps.get(1).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
342 let b: i64 = caps.get(2).map(|m| m.as_str().parse().unwrap_or(100)).unwrap_or(100);
343 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
344 let n: i64 = rng().random_range(lo..=hi);
345 s = RANDINT_RE.replace(&s, n.to_string()).to_string();
346 } else {
347 break;
348 }
349 }
350 s
351}
352
353#[allow(dead_code)]
354fn replace_now_offset(input: &str) -> String {
355 replace_now_offset_with_time(input, Utc::now())
356}
357
358fn replace_now_offset_with_time(input: &str, current_time: chrono::DateTime<Utc>) -> String {
359 NOW_OFFSET_RE
361 .replace_all(input, |caps: ®ex::Captures| {
362 let sign = caps.get(1).map(|m| m.as_str()).unwrap_or("+");
363 let amount: i64 = caps.get(2).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
364 let unit = caps.get(3).map(|m| m.as_str()).unwrap_or("d");
365 let dur = match unit {
366 "s" => ChronoDuration::seconds(amount),
367 "m" => ChronoDuration::minutes(amount),
368 "h" => ChronoDuration::hours(amount),
369 _ => ChronoDuration::days(amount),
370 };
371 let ts = if sign == "+" {
372 current_time + dur
373 } else {
374 current_time - dur
375 };
376 ts.to_rfc3339()
377 })
378 .to_string()
379}
380
381fn replace_env_tokens(input: &str, env_context: &EnvironmentTemplatingContext) -> String {
383 ENV_TOKEN_RE
384 .replace_all(input, |caps: ®ex::Captures| {
385 let var_name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
386
387 if matches!(var_name, "uuid" | "now")
389 || var_name.starts_with("rand.")
390 || var_name.starts_with("faker.")
391 || var_name.starts_with("chain.")
392 || var_name.starts_with("encrypt")
393 || var_name.starts_with("decrypt")
394 || var_name.starts_with("secure")
395 {
396 return caps.get(0).map(|m| m.as_str().to_string()).unwrap_or_default();
397 }
398
399 match env_context.get_variable(var_name) {
401 Some(value) => value.clone(),
402 None => format!("{{{{{}}}}}", var_name), }
404 })
405 .to_string()
406}
407
408fn replace_chain_tokens(input: &str, chain_context: Option<&ChainTemplatingContext>) -> String {
410 if let Some(context) = chain_context {
411 CHAIN_TOKEN_RE
412 .replace_all(input, |caps: ®ex::Captures| {
413 let path = caps.get(1).map(|m| m.as_str()).unwrap_or("");
414
415 match context.extract_value(path) {
416 Some(Value::String(s)) => s,
417 Some(Value::Number(n)) => n.to_string(),
418 Some(Value::Bool(b)) => b.to_string(),
419 Some(val) => serde_json::to_string(&val).unwrap_or_else(|_| "null".to_string()),
420 None => "null".to_string(), }
422 })
423 .to_string()
424 } else {
425 input.to_string()
427 }
428}
429
430fn replace_response_function(
432 input: &str,
433 chain_context: Option<&ChainTemplatingContext>,
434) -> String {
435 if let Some(context) = chain_context {
437 let result = RESPONSE_FN_RE
438 .replace_all(input, |caps: ®ex::Captures| {
439 let request_id = caps.get(1).map(|m| m.as_str()).unwrap_or("");
440 let json_path = caps.get(2).map(|m| m.as_str()).unwrap_or("");
441
442 let full_path = if json_path.is_empty() {
444 request_id.to_string()
445 } else {
446 format!("{}.{}", request_id, json_path)
447 };
448
449 match context.extract_value(&full_path) {
450 Some(Value::String(s)) => s,
451 Some(Value::Number(n)) => n.to_string(),
452 Some(Value::Bool(b)) => b.to_string(),
453 Some(val) => serde_json::to_string(&val).unwrap_or_else(|_| "null".to_string()),
454 None => "null".to_string(), }
456 })
457 .to_string();
458
459 result
460 } else {
461 input.to_string()
463 }
464}
465
466fn replace_encryption_tokens(input: &str) -> String {
468 let key_store = init_key_store();
470
471 let default_key_id = "mockforge_default";
473
474 let mut out = input.to_string();
475
476 out = ENCRYPT_RE
478 .replace_all(&out, |caps: ®ex::Captures| {
479 let key_id = caps.get(1).map(|m| m.as_str()).unwrap_or(default_key_id);
480 let plaintext = caps.get(2).map(|m| m.as_str()).unwrap_or("");
481
482 match key_store.get_key(key_id) {
483 Some(key) => match key.encrypt(plaintext, None) {
484 Ok(ciphertext) => ciphertext,
485 Err(_) => "<encryption_error>".to_string(),
486 },
487 None => {
488 let password = std::env::var("MOCKFORGE_ENCRYPTION_KEY")
490 .unwrap_or_else(|_| "mockforge_default_encryption_key_2024".to_string());
491 match crate::encryption::EncryptionKey::from_password_pbkdf2(
492 &password,
493 None,
494 crate::encryption::EncryptionAlgorithm::Aes256Gcm,
495 ) {
496 Ok(key) => match key.encrypt(plaintext, None) {
497 Ok(ciphertext) => ciphertext,
498 Err(_) => "<encryption_error>".to_string(),
499 },
500 Err(_) => "<key_creation_error>".to_string(),
501 }
502 }
503 }
504 })
505 .to_string();
506
507 out = SECURE_RE
509 .replace_all(&out, |caps: ®ex::Captures| {
510 let key_id = caps.get(1).map(|m| m.as_str()).unwrap_or(default_key_id);
511 let plaintext = caps.get(2).map(|m| m.as_str()).unwrap_or("");
512
513 match key_store.get_key(key_id) {
514 Some(key) => {
515 match key.encrypt_chacha20(plaintext, None) {
517 Ok(ciphertext) => ciphertext,
518 Err(_) => "<encryption_error>".to_string(),
519 }
520 }
521 None => {
522 let password = std::env::var("MOCKFORGE_ENCRYPTION_KEY")
524 .unwrap_or_else(|_| "mockforge_default_encryption_key_2024".to_string());
525 match crate::encryption::EncryptionKey::from_password_pbkdf2(
526 &password,
527 None,
528 crate::encryption::EncryptionAlgorithm::ChaCha20Poly1305,
529 ) {
530 Ok(key) => match key.encrypt_chacha20(plaintext, None) {
531 Ok(ciphertext) => ciphertext,
532 Err(_) => "<encryption_error>".to_string(),
533 },
534 Err(_) => "<key_creation_error>".to_string(),
535 }
536 }
537 }
538 })
539 .to_string();
540
541 out = DECRYPT_RE
543 .replace_all(&out, |caps: ®ex::Captures| {
544 let key_id = caps.get(1).map(|m| m.as_str()).unwrap_or(default_key_id);
545 let ciphertext = caps.get(2).map(|m| m.as_str()).unwrap_or("");
546
547 match key_store.get_key(key_id) {
548 Some(key) => match key.decrypt(ciphertext, None) {
549 Ok(plaintext) => plaintext,
550 Err(_) => "<decryption_error>".to_string(),
551 },
552 None => {
553 let password = std::env::var("MOCKFORGE_ENCRYPTION_KEY")
555 .unwrap_or_else(|_| "mockforge_default_encryption_key_2024".to_string());
556 match crate::encryption::EncryptionKey::from_password_pbkdf2(
557 &password,
558 None,
559 crate::encryption::EncryptionAlgorithm::Aes256Gcm,
560 ) {
561 Ok(key) => match key.decrypt(ciphertext, None) {
562 Ok(plaintext) => plaintext,
563 Err(_) => "<decryption_error>".to_string(),
564 },
565 Err(_) => "<key_creation_error>".to_string(),
566 }
567 }
568 }
569 })
570 .to_string();
571
572 out
573}
574
575fn replace_fs_tokens(input: &str) -> String {
577 FS_READFILE_RE
579 .replace_all(input, |caps: ®ex::Captures| {
580 let file_path = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str()).unwrap_or("");
581
582 if file_path.is_empty() {
583 return "<fs.readFile: empty path>".to_string();
584 }
585
586 match std::fs::read_to_string(file_path) {
587 Ok(content) => content,
588 Err(e) => format!("<fs.readFile error: {}>", e),
589 }
590 })
591 .to_string()
592}
593
594fn replace_faker_tokens(input: &str) -> String {
595 if let Some(provider) = FAKER_PROVIDER.get() {
597 return replace_with_provider(input, provider.as_ref());
598 }
599 replace_with_fallback(input)
600}
601
602fn replace_with_provider(input: &str, p: &dyn FakerProvider) -> String {
603 let mut out = input.to_string();
604 let map = [
605 ("{{faker.uuid}}", p.uuid()),
606 ("{{faker.email}}", p.email()),
607 ("{{faker.name}}", p.name()),
608 ("{{faker.address}}", p.address()),
609 ("{{faker.phone}}", p.phone()),
610 ("{{faker.company}}", p.company()),
611 ("{{faker.url}}", p.url()),
612 ("{{faker.ip}}", p.ip()),
613 ("{{faker.color}}", p.color()),
614 ("{{faker.word}}", p.word()),
615 ("{{faker.sentence}}", p.sentence()),
616 ("{{faker.paragraph}}", p.paragraph()),
617 ];
618 for (pat, val) in map {
619 if out.contains(pat) {
620 out = out.replace(pat, &val);
621 }
622 }
623 out
624}
625
626fn replace_with_fallback(input: &str) -> String {
627 let mut out = input.to_string();
628 if out.contains("{{faker.uuid}}") {
629 out = out.replace("{{faker.uuid}}", &uuid::Uuid::new_v4().to_string());
630 }
631 if out.contains("{{faker.email}}") {
632 let user: String = (0..8).map(|_| (b'a' + (rng().random::<u8>() % 26)) as char).collect();
633 let dom: String = (0..6).map(|_| (b'a' + (rng().random::<u8>() % 26)) as char).collect();
634 out = out.replace("{{faker.email}}", &format!("{}@{}.example", user, dom));
635 }
636 if out.contains("{{faker.name}}") {
637 let firsts = ["Alex", "Sam", "Taylor", "Jordan", "Casey", "Riley"];
638 let lasts = ["Smith", "Lee", "Patel", "Garcia", "Kim", "Brown"];
639 let fi = rng().random::<u8>() as usize % firsts.len();
640 let li = rng().random::<u8>() as usize % lasts.len();
641 out = out.replace("{{faker.name}}", &format!("{} {}", firsts[fi], lasts[li]));
642 }
643 out
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 use crate::request_chaining::{ChainContext, ChainResponse, ChainTemplatingContext};
650 use serde_json::json;
651
652 #[test]
653 fn test_expand_str_with_context() {
654 let chain_context = ChainTemplatingContext::new(ChainContext::new());
655 let context = TemplatingContext::with_chain(chain_context);
656 let result = expand_str_with_context("{{uuid}}", &context);
657 assert!(!result.is_empty());
658 }
659
660 #[test]
661 fn test_replace_env_tokens() {
662 let mut vars = HashMap::new();
663 vars.insert("api_key".to_string(), "secret123".to_string());
664 let env_context = EnvironmentTemplatingContext::new(vars);
665 let result = replace_env_tokens("{{api_key}}", &env_context);
666 assert_eq!(result, "secret123");
667 }
668
669 #[test]
670 fn test_replace_chain_tokens() {
671 let chain_ctx = ChainContext::new();
672 let template_ctx = ChainTemplatingContext::new(chain_ctx);
673 let context = Some(&template_ctx);
674 let result = replace_chain_tokens("{{chain.test.body}}", context);
676 assert_eq!(result, "null");
677 }
678
679 #[test]
680 fn test_response_function() {
681 let result = replace_response_function(r#"response('login', 'body.user_id')"#, None);
683 assert_eq!(result, r#"response('login', 'body.user_id')"#);
684
685 let chain_ctx = ChainContext::new();
687 let template_ctx = ChainTemplatingContext::new(chain_ctx);
688 let context = Some(&template_ctx);
689 let result = replace_response_function(r#"response('login', 'body.user_id')"#, context);
690 assert_eq!(result, "null");
691
692 let mut chain_ctx = ChainContext::new();
694 let response = ChainResponse {
695 status: 200,
696 headers: HashMap::new(),
697 body: Some(json!({"user_id": 12345})),
698 duration_ms: 150,
699 executed_at: "2023-01-01T00:00:00Z".to_string(),
700 error: None,
701 };
702 chain_ctx.store_response("login".to_string(), response);
703 let template_ctx = ChainTemplatingContext::new(chain_ctx);
704 let context = Some(&template_ctx);
705
706 let result = replace_response_function(r#"response('login', 'user_id')"#, context);
707 assert_eq!(result, "12345");
708 }
709
710 #[test]
711 fn test_fs_readfile() {
712 use std::fs;
714
715 let temp_file = "/tmp/mockforge_test_file.txt";
716 let test_content = "Hello, this is test content!";
717 fs::write(temp_file, test_content).unwrap();
718
719 let template = format!(r#"{{{{fs.readFile "{}"}}}}"#, temp_file);
721 let result = expand_str(&template);
722 assert_eq!(result, test_content);
723
724 let template = format!(r#"{{{{fs.readFile('{}')}}}}"#, temp_file);
726 let result = expand_str(&template);
727 assert_eq!(result, test_content);
728
729 let template = r#"{{fs.readFile "/nonexistent/file.txt"}}"#;
731 let result = expand_str(template);
732 assert!(result.contains("fs.readFile error:"));
733
734 let template = r#"{{fs.readFile ""}}"#;
736 let result = expand_str(template);
737 assert_eq!(result, "<fs.readFile: empty path>");
738
739 let _ = fs::remove_file(temp_file);
741 }
742}