Skip to main content

mockforge_core/
templating.rs

1//! Pillars: [DevX]
2//!
3//! Extended templating system for MockForge with request chaining support
4//!
5//! This module provides template expansion with support for:
6//! - Standard tokens (UUID, timestamps, random data, faker)
7//! - Request chaining context variables
8//! - End-to-end encryption functions
9
10use 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
22// Pre-compiled regex patterns for templating
23static 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/// Template engine for processing template strings with various token types
67#[derive(Debug, Clone)]
68pub struct TemplateEngine {
69    /// Configuration for the template engine
70    _config: Config,
71}
72
73impl Default for TemplateEngine {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl TemplateEngine {
80    /// Create a new template engine
81    pub fn new() -> Self {
82        Self {
83            _config: Config::default(),
84        }
85    }
86
87    /// Create a new template engine with configuration
88    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    /// Expand templating tokens in a string
95    pub fn expand_str(&self, input: &str) -> String {
96        expand_str(input)
97    }
98
99    /// Expand templating tokens in a string with context
100    pub fn expand_str_with_context(&self, input: &str, context: &TemplatingContext) -> String {
101        expand_str_with_context(input, context)
102    }
103
104    /// Expand templating tokens in a JSON value
105    pub fn expand_tokens(&self, value: &Value) -> Value {
106        expand_tokens(value)
107    }
108
109    /// Expand templating tokens in a JSON value with context
110    pub fn expand_tokens_with_context(&self, value: &Value, context: &TemplatingContext) -> Value {
111        expand_tokens_with_context(value, context)
112    }
113}
114
115/// Context for environment variables during template expansion
116#[derive(Debug, Clone)]
117pub struct EnvironmentTemplatingContext {
118    /// Map of environment variable names to values
119    pub variables: HashMap<String, String>,
120}
121
122impl EnvironmentTemplatingContext {
123    /// Create a new environment context
124    pub fn new(variables: HashMap<String, String>) -> Self {
125        Self { variables }
126    }
127
128    /// Get a variable value by name
129    pub fn get_variable(&self, name: &str) -> Option<&String> {
130        self.variables.get(name)
131    }
132}
133
134/// Combined templating context with chain, environment, and time variables
135#[derive(Debug, Clone)]
136pub struct TemplatingContext {
137    /// Request chaining context for accessing previous request responses
138    pub chain_context: Option<ChainTemplatingContext>,
139    /// Environment variable context for variable substitution
140    pub env_context: Option<EnvironmentTemplatingContext>,
141    /// Virtual clock for time-based template tokens
142    pub virtual_clock: Option<Arc<VirtualClock>>,
143}
144
145impl TemplatingContext {
146    /// Create empty context
147    pub fn empty() -> Self {
148        Self {
149            chain_context: None,
150            env_context: None,
151            virtual_clock: None,
152        }
153    }
154
155    /// Create context with environment variables only
156    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    /// Create context with chain context only
165    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    /// Create context with both chain and environment contexts
174    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    /// Create context with virtual clock
186    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    /// Add virtual clock to existing context
195    pub fn with_clock(mut self, clock: Arc<VirtualClock>) -> Self {
196        self.virtual_clock = Some(clock);
197        self
198    }
199}
200
201/// Expand templating tokens in a JSON value recursively
202///
203/// Processes all string values in the JSON structure and expands template tokens
204/// like `{{uuid}}`, `{{now}}`, `{{faker.email}}`, etc.
205///
206/// # Arguments
207/// * `v` - JSON value to process
208///
209/// # Returns
210/// New JSON value with all template tokens expanded
211pub fn expand_tokens(v: &Value) -> Value {
212    expand_tokens_with_context(v, &TemplatingContext::empty())
213}
214
215/// Expand templating tokens in a JSON value recursively with context
216///
217/// Similar to `expand_tokens` but uses the provided context for chain variables,
218/// environment variables, and virtual clock.
219///
220/// # Arguments
221/// * `v` - JSON value to process
222/// * `context` - Templating context with chain, environment, and time information
223///
224/// # Returns
225/// New JSON value with all template tokens expanded
226pub 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
243/// Expand templating tokens in a string
244///
245/// Processes template tokens in the input string and replaces them with generated values.
246/// Supports UUID, timestamps, random numbers, faker data, environment variables, and more.
247///
248/// # Arguments
249/// * `input` - String containing template tokens (e.g., "{{uuid}}", "{{now}}")
250///
251/// # Returns
252/// String with all template tokens replaced
253pub fn expand_str(input: &str) -> String {
254    expand_str_with_context(input, &TemplatingContext::empty())
255}
256
257/// Expand templating tokens in a string with templating context
258///
259/// Similar to `expand_str` but uses the provided context for chain variables,
260/// environment variables, and virtual clock operations.
261///
262/// # Arguments
263/// * `input` - String containing template tokens
264/// * `context` - Templating context with chain, environment, and time information
265///
266/// # Returns
267/// String with all template tokens replaced
268pub fn expand_str_with_context(input: &str, context: &TemplatingContext) -> String {
269    // Early return if no template tokens present (common case optimization)
270    if !input.contains("{{") {
271        return input.to_string();
272    }
273
274    let mut out = input.to_string();
275
276    // Basic replacements first (fast paths) - only if tokens are present
277    if out.contains("{{uuid}}") {
278        out = out.replace("{{uuid}}", &uuid::Uuid::new_v4().to_string());
279    }
280
281    // Only get current time if we need it (for {{now}} or time offsets)
282    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        // now±Nd (days), now±Nh (hours), now±Nm (minutes), now±Ns (seconds)
298        if NOW_OFFSET_RE.is_match(&out) {
299            out = replace_now_offset_with_time(&out, time);
300        }
301    }
302
303    // Randoms - only process if tokens are present
304    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    // Response function tokens (new response() syntax)
317    if out.contains("response(") {
318        out = replace_response_function(&out, context.chain_context.as_ref());
319    }
320
321    // Environment variables (check before chain context to allow env vars in chain expressions)
322    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    // Chain context variables
329    if out.contains("{{chain.") {
330        out = replace_chain_tokens(&out, context.chain_context.as_ref());
331    }
332
333    // Faker tokens (can be disabled for determinism)
334    // Cache the environment variable check using OnceCell for better performance
335    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    // File system tokens
345    if out.contains("{{fs.readFile") {
346        out = replace_fs_tokens(&out);
347    }
348
349    // Encryption tokens
350    if out.contains("{{encrypt") || out.contains("{{decrypt") || out.contains("{{secure") {
351        out = replace_encryption_tokens(&out);
352    }
353
354    out
355}
356
357// Provider wiring (optional)
358static FAKER_PROVIDER: OnceCell<Arc<dyn FakerProvider + Send + Sync>> = OnceCell::new();
359
360/// Provider trait for generating fake data in templates
361///
362/// Implement this trait to customize how fake data is generated for template tokens
363/// like `{{faker.email}}`, `{{faker.name}}`, etc.
364pub trait FakerProvider {
365    /// Generate a random UUID
366    fn uuid(&self) -> String {
367        uuid::Uuid::new_v4().to_string()
368    }
369    /// Generate a fake email address
370    fn email(&self) -> String {
371        format!("user{}@example.com", thread_rng().random_range(1000..=9999))
372    }
373    /// Generate a fake person name
374    fn name(&self) -> String {
375        "Alex Smith".to_string()
376    }
377    /// Generate a fake street address
378    fn address(&self) -> String {
379        "1 Main St".to_string()
380    }
381    /// Generate a fake phone number
382    fn phone(&self) -> String {
383        "+1-555-0100".to_string()
384    }
385    /// Generate a fake company name
386    fn company(&self) -> String {
387        "Example Inc".to_string()
388    }
389    /// Generate a fake URL
390    fn url(&self) -> String {
391        "https://example.com".to_string()
392    }
393    /// Generate a fake IP address
394    fn ip(&self) -> String {
395        "192.168.1.1".to_string()
396    }
397    /// Generate a fake color name
398    fn color(&self) -> String {
399        "blue".to_string()
400    }
401    /// Generate a fake word
402    fn word(&self) -> String {
403        "word".to_string()
404    }
405    /// Generate a fake sentence
406    fn sentence(&self) -> String {
407        "A sample sentence.".to_string()
408    }
409    /// Generate a fake paragraph
410    fn paragraph(&self) -> String {
411        "A sample paragraph.".to_string()
412    }
413}
414
415/// Register a custom faker provider for template token generation
416///
417/// This allows you to replace the default faker implementation with a custom one
418/// that can generate more realistic or customized fake data.
419///
420/// # Arguments
421/// * `provider` - Custom faker provider implementation
422pub 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    // Supports {{randInt a b}} and {{rand.int a b}}
428    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/// Replace `{{ now+1d }}` style templates in input string
445///
446/// This is a convenience wrapper around `replace_now_offset_with_time` that uses the current time.
447#[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+1d }}, {{now-2h}}, {{now+30m}}, {{now-10s}}
454    NOW_OFFSET_RE
455        .replace_all(input, |caps: &regex::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
475/// Replace environment variable tokens in a template string
476fn replace_env_tokens(input: &str, env_context: &EnvironmentTemplatingContext) -> String {
477    ENV_TOKEN_RE
478        .replace_all(input, |caps: &regex::Captures| {
479            let var_name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
480
481            // Skip built-in tokens (uuid, now, rand.*, faker.*, chain.*, encrypt.*, decrypt.*, secure.*)
482            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            // Look up the variable in environment context
494            match env_context.get_variable(var_name) {
495                Some(value) => value.clone(),
496                None => format!("{{{{{}}}}}", var_name), // Keep original if not found
497            }
498        })
499        .to_string()
500}
501
502/// Replace chain context tokens in a template string
503fn 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: &regex::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(), // Return null for missing values instead of empty string
515                }
516            })
517            .to_string()
518    } else {
519        // No chain context available, return input unchanged
520        input.to_string()
521    }
522}
523
524/// Replace response function tokens (new response() syntax)
525fn replace_response_function(
526    input: &str,
527    chain_context: Option<&ChainTemplatingContext>,
528) -> String {
529    // Match response('request_id', 'jsonpath') - handle both single and double quotes
530    if let Some(context) = chain_context {
531        let result = RESPONSE_FN_RE
532            .replace_all(input, |caps: &regex::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                // Build the full path like "request_id.json_path"
537                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(), // Return null for missing values
549                }
550            })
551            .to_string();
552
553        result
554    } else {
555        // No chain context available, return input unchanged
556        input.to_string()
557    }
558}
559
560/// Replace encryption tokens in a template string
561fn replace_encryption_tokens(input: &str) -> String {
562    // Key store is initialized at startup
563    let key_store = init_key_store();
564
565    // Default key ID for templating
566    let default_key_id = "mockforge_default";
567
568    let mut out = input.to_string();
569
570    // Process encrypt tokens
571    out = ENCRYPT_RE
572        .replace_all(&out, |caps: &regex::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                    // Create a default key if none exists
583                    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    // Process secure tokens (ChaCha20-Poly1305)
602    out = SECURE_RE
603        .replace_all(&out, |caps: &regex::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                    // Use ChaCha20-Poly1305 for secure() function
610                    match key.encrypt_chacha20(plaintext, None) {
611                        Ok(ciphertext) => ciphertext,
612                        Err(_) => "<encryption_error>".to_string(),
613                    }
614                }
615                None => {
616                    // Create a default key if none exists
617                    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    // Process decrypt tokens
636    out = DECRYPT_RE
637        .replace_all(&out, |caps: &regex::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                    // Create a default key if none exists
648                    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
669/// Replace file system tokens in a template string
670fn replace_fs_tokens(input: &str) -> String {
671    // Handle {{fs.readFile "path/to/file"}} or {{fs.readFile('path/to/file')}}
672    FS_READFILE_RE
673        .replace_all(input, |caps: &regex::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 a provider is registered (e.g., from mockforge-data), use it; else fallback
690    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        // Note: This test would need a proper response stored in the chain context
771        let result = replace_chain_tokens("{{chain.test.body}}", context);
772        assert_eq!(result, "null");
773    }
774
775    #[test]
776    fn test_response_function() {
777        // Test with no chain context
778        let result = replace_response_function(r#"response('login', 'body.user_id')"#, None);
779        assert_eq!(result, r#"response('login', 'body.user_id')"#);
780
781        // Test with chain context but no matching response
782        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        // Test with stored response
789        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        // Create a temporary file for testing
809        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        // Test successful file reading
816        let template = format!(r#"{{{{fs.readFile "{}"}}}}"#, temp_file);
817        let result = expand_str(&template);
818        assert_eq!(result, test_content);
819
820        // Test with parentheses
821        let template = format!(r#"{{{{fs.readFile('{}')}}}}"#, temp_file);
822        let result = expand_str(&template);
823        assert_eq!(result, test_content);
824
825        // Test file not found
826        let template = r#"{{fs.readFile "/nonexistent/file.txt"}}"#;
827        let result = expand_str(template);
828        assert!(result.contains("fs.readFile error:"));
829
830        // Test empty path
831        let template = r#"{{fs.readFile ""}}"#;
832        let result = expand_str(template);
833        assert_eq!(result, "<fs.readFile: empty path>");
834
835        // Clean up
836        let _ = fs::remove_file(temp_file);
837    }
838}