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.
447fn replace_now_offset(input: &str) -> String {
448    replace_now_offset_with_time(input, Utc::now())
449}
450
451fn replace_now_offset_with_time(input: &str, current_time: chrono::DateTime<Utc>) -> String {
452    // {{ now+1d }}, {{now-2h}}, {{now+30m}}, {{now-10s}}
453    NOW_OFFSET_RE
454        .replace_all(input, |caps: &regex::Captures| {
455            let sign = caps.get(1).map(|m| m.as_str()).unwrap_or("+");
456            let amount: i64 = caps.get(2).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
457            let unit = caps.get(3).map(|m| m.as_str()).unwrap_or("d");
458            let dur = match unit {
459                "s" => ChronoDuration::seconds(amount),
460                "m" => ChronoDuration::minutes(amount),
461                "h" => ChronoDuration::hours(amount),
462                _ => ChronoDuration::days(amount),
463            };
464            let ts = if sign == "+" {
465                current_time + dur
466            } else {
467                current_time - dur
468            };
469            ts.to_rfc3339()
470        })
471        .to_string()
472}
473
474/// Replace environment variable tokens in a template string
475fn replace_env_tokens(input: &str, env_context: &EnvironmentTemplatingContext) -> String {
476    ENV_TOKEN_RE
477        .replace_all(input, |caps: &regex::Captures| {
478            let var_name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
479
480            // Skip built-in tokens (uuid, now, rand.*, faker.*, chain.*, encrypt.*, decrypt.*, secure.*)
481            if matches!(var_name, "uuid" | "now")
482                || var_name.starts_with("rand.")
483                || var_name.starts_with("faker.")
484                || var_name.starts_with("chain.")
485                || var_name.starts_with("encrypt")
486                || var_name.starts_with("decrypt")
487                || var_name.starts_with("secure")
488            {
489                return caps.get(0).map(|m| m.as_str().to_string()).unwrap_or_default();
490            }
491
492            // Look up the variable in environment context
493            match env_context.get_variable(var_name) {
494                Some(value) => value.clone(),
495                None => format!("{{{{{}}}}}", var_name), // Keep original if not found
496            }
497        })
498        .to_string()
499}
500
501/// Replace chain context tokens in a template string
502fn replace_chain_tokens(input: &str, chain_context: Option<&ChainTemplatingContext>) -> String {
503    if let Some(context) = chain_context {
504        CHAIN_TOKEN_RE
505            .replace_all(input, |caps: &regex::Captures| {
506                let path = caps.get(1).map(|m| m.as_str()).unwrap_or("");
507
508                match context.extract_value(path) {
509                    Some(Value::String(s)) => s,
510                    Some(Value::Number(n)) => n.to_string(),
511                    Some(Value::Bool(b)) => b.to_string(),
512                    Some(val) => serde_json::to_string(&val).unwrap_or_else(|_| "null".to_string()),
513                    None => "null".to_string(), // Return null for missing values instead of empty string
514                }
515            })
516            .to_string()
517    } else {
518        // No chain context available, return input unchanged
519        input.to_string()
520    }
521}
522
523/// Replace response function tokens (new response() syntax)
524fn replace_response_function(
525    input: &str,
526    chain_context: Option<&ChainTemplatingContext>,
527) -> String {
528    // Match response('request_id', 'jsonpath') - handle both single and double quotes
529    if let Some(context) = chain_context {
530        let result = RESPONSE_FN_RE
531            .replace_all(input, |caps: &regex::Captures| {
532                let request_id = caps.get(1).map(|m| m.as_str()).unwrap_or("");
533                let json_path = caps.get(2).map(|m| m.as_str()).unwrap_or("");
534
535                // Build the full path like "request_id.json_path"
536                let full_path = if json_path.is_empty() {
537                    request_id.to_string()
538                } else {
539                    format!("{}.{}", request_id, json_path)
540                };
541
542                match context.extract_value(&full_path) {
543                    Some(Value::String(s)) => s,
544                    Some(Value::Number(n)) => n.to_string(),
545                    Some(Value::Bool(b)) => b.to_string(),
546                    Some(val) => serde_json::to_string(&val).unwrap_or_else(|_| "null".to_string()),
547                    None => "null".to_string(), // Return null for missing values
548                }
549            })
550            .to_string();
551
552        result
553    } else {
554        // No chain context available, return input unchanged
555        input.to_string()
556    }
557}
558
559/// Replace encryption tokens in a template string
560fn replace_encryption_tokens(input: &str) -> String {
561    // Key store is initialized at startup
562    let key_store = init_key_store();
563
564    // Default key ID for templating
565    let default_key_id = "mockforge_default";
566
567    let mut out = input.to_string();
568
569    // Process encrypt tokens
570    out = ENCRYPT_RE
571        .replace_all(&out, |caps: &regex::Captures| {
572            let key_id = caps.get(1).map(|m| m.as_str()).unwrap_or(default_key_id);
573            let plaintext = caps.get(2).map(|m| m.as_str()).unwrap_or("");
574
575            match key_store.get_key(key_id) {
576                Some(key) => match key.encrypt(plaintext, None) {
577                    Ok(ciphertext) => ciphertext,
578                    Err(_) => "<encryption_error>".to_string(),
579                },
580                None => {
581                    // Create a default key if none exists
582                    let password = std::env::var("MOCKFORGE_ENCRYPTION_KEY")
583                        .unwrap_or_else(|_| "mockforge_default_encryption_key_2024".to_string());
584                    match crate::encryption::EncryptionKey::from_password_pbkdf2(
585                        &password,
586                        None,
587                        crate::encryption::EncryptionAlgorithm::Aes256Gcm,
588                    ) {
589                        Ok(key) => match key.encrypt(plaintext, None) {
590                            Ok(ciphertext) => ciphertext,
591                            Err(_) => "<encryption_error>".to_string(),
592                        },
593                        Err(_) => "<key_creation_error>".to_string(),
594                    }
595                }
596            }
597        })
598        .to_string();
599
600    // Process secure tokens (ChaCha20-Poly1305)
601    out = SECURE_RE
602        .replace_all(&out, |caps: &regex::Captures| {
603            let key_id = caps.get(1).map(|m| m.as_str()).unwrap_or(default_key_id);
604            let plaintext = caps.get(2).map(|m| m.as_str()).unwrap_or("");
605
606            match key_store.get_key(key_id) {
607                Some(key) => {
608                    // Use ChaCha20-Poly1305 for secure() function
609                    match key.encrypt_chacha20(plaintext, None) {
610                        Ok(ciphertext) => ciphertext,
611                        Err(_) => "<encryption_error>".to_string(),
612                    }
613                }
614                None => {
615                    // Create a default key if none exists
616                    let password = std::env::var("MOCKFORGE_ENCRYPTION_KEY")
617                        .unwrap_or_else(|_| "mockforge_default_encryption_key_2024".to_string());
618                    match crate::encryption::EncryptionKey::from_password_pbkdf2(
619                        &password,
620                        None,
621                        crate::encryption::EncryptionAlgorithm::ChaCha20Poly1305,
622                    ) {
623                        Ok(key) => match key.encrypt_chacha20(plaintext, None) {
624                            Ok(ciphertext) => ciphertext,
625                            Err(_) => "<encryption_error>".to_string(),
626                        },
627                        Err(_) => "<key_creation_error>".to_string(),
628                    }
629                }
630            }
631        })
632        .to_string();
633
634    // Process decrypt tokens
635    out = DECRYPT_RE
636        .replace_all(&out, |caps: &regex::Captures| {
637            let key_id = caps.get(1).map(|m| m.as_str()).unwrap_or(default_key_id);
638            let ciphertext = caps.get(2).map(|m| m.as_str()).unwrap_or("");
639
640            match key_store.get_key(key_id) {
641                Some(key) => match key.decrypt(ciphertext, None) {
642                    Ok(plaintext) => plaintext,
643                    Err(_) => "<decryption_error>".to_string(),
644                },
645                None => {
646                    // Create a default key if none exists
647                    let password = std::env::var("MOCKFORGE_ENCRYPTION_KEY")
648                        .unwrap_or_else(|_| "mockforge_default_encryption_key_2024".to_string());
649                    match crate::encryption::EncryptionKey::from_password_pbkdf2(
650                        &password,
651                        None,
652                        crate::encryption::EncryptionAlgorithm::Aes256Gcm,
653                    ) {
654                        Ok(key) => match key.decrypt(ciphertext, None) {
655                            Ok(plaintext) => plaintext,
656                            Err(_) => "<decryption_error>".to_string(),
657                        },
658                        Err(_) => "<key_creation_error>".to_string(),
659                    }
660                }
661            }
662        })
663        .to_string();
664
665    out
666}
667
668/// Replace file system tokens in a template string
669fn replace_fs_tokens(input: &str) -> String {
670    // Handle {{fs.readFile "path/to/file"}} or {{fs.readFile('path/to/file')}}
671    FS_READFILE_RE
672        .replace_all(input, |caps: &regex::Captures| {
673            let file_path = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str()).unwrap_or("");
674
675            if file_path.is_empty() {
676                return "<fs.readFile: empty path>".to_string();
677            }
678
679            match std::fs::read_to_string(file_path) {
680                Ok(content) => content,
681                Err(e) => format!("<fs.readFile error: {}>", e),
682            }
683        })
684        .to_string()
685}
686
687fn replace_faker_tokens(input: &str) -> String {
688    // If a provider is registered (e.g., from mockforge-data), use it; else fallback
689    if let Some(provider) = FAKER_PROVIDER.get() {
690        return replace_with_provider(input, provider.as_ref());
691    }
692    replace_with_fallback(input)
693}
694
695fn replace_with_provider(input: &str, p: &dyn FakerProvider) -> String {
696    let mut out = input.to_string();
697    let map = [
698        ("{{faker.uuid}}", p.uuid()),
699        ("{{faker.email}}", p.email()),
700        ("{{faker.name}}", p.name()),
701        ("{{faker.address}}", p.address()),
702        ("{{faker.phone}}", p.phone()),
703        ("{{faker.company}}", p.company()),
704        ("{{faker.url}}", p.url()),
705        ("{{faker.ip}}", p.ip()),
706        ("{{faker.color}}", p.color()),
707        ("{{faker.word}}", p.word()),
708        ("{{faker.sentence}}", p.sentence()),
709        ("{{faker.paragraph}}", p.paragraph()),
710    ];
711    for (pat, val) in map {
712        if out.contains(pat) {
713            out = out.replace(pat, &val);
714        }
715    }
716    out
717}
718
719fn replace_with_fallback(input: &str) -> String {
720    let mut out = input.to_string();
721    if out.contains("{{faker.uuid}}") {
722        out = out.replace("{{faker.uuid}}", &uuid::Uuid::new_v4().to_string());
723    }
724    if out.contains("{{faker.email}}") {
725        let user: String =
726            (0..8).map(|_| (b'a' + (thread_rng().random::<u8>() % 26)) as char).collect();
727        let dom: String =
728            (0..6).map(|_| (b'a' + (thread_rng().random::<u8>() % 26)) as char).collect();
729        out = out.replace("{{faker.email}}", &format!("{}@{}.example", user, dom));
730    }
731    if out.contains("{{faker.name}}") {
732        let firsts = ["Alex", "Sam", "Taylor", "Jordan", "Casey", "Riley"];
733        let lasts = ["Smith", "Lee", "Patel", "Garcia", "Kim", "Brown"];
734        let fi = thread_rng().random::<u8>() as usize % firsts.len();
735        let li = thread_rng().random::<u8>() as usize % lasts.len();
736        out = out.replace("{{faker.name}}", &format!("{} {}", firsts[fi], lasts[li]));
737    }
738    out
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744    use crate::request_chaining::{ChainContext, ChainResponse, ChainTemplatingContext};
745    use serde_json::json;
746
747    #[test]
748    fn test_expand_str_with_context() {
749        let chain_context = ChainTemplatingContext::new(ChainContext::new());
750        let context = TemplatingContext::with_chain(chain_context);
751        let result = expand_str_with_context("{{uuid}}", &context);
752        assert!(!result.is_empty());
753    }
754
755    #[test]
756    fn test_replace_env_tokens() {
757        let mut vars = HashMap::new();
758        vars.insert("api_key".to_string(), "secret123".to_string());
759        let env_context = EnvironmentTemplatingContext::new(vars);
760        let result = replace_env_tokens("{{api_key}}", &env_context);
761        assert_eq!(result, "secret123");
762    }
763
764    #[test]
765    fn test_replace_chain_tokens() {
766        let chain_ctx = ChainContext::new();
767        let template_ctx = ChainTemplatingContext::new(chain_ctx);
768        let context = Some(&template_ctx);
769        // Note: This test would need a proper response stored in the chain context
770        let result = replace_chain_tokens("{{chain.test.body}}", context);
771        assert_eq!(result, "null");
772    }
773
774    #[test]
775    fn test_response_function() {
776        // Test with no chain context
777        let result = replace_response_function(r#"response('login', 'body.user_id')"#, None);
778        assert_eq!(result, r#"response('login', 'body.user_id')"#);
779
780        // Test with chain context but no matching response
781        let chain_ctx = ChainContext::new();
782        let template_ctx = ChainTemplatingContext::new(chain_ctx);
783        let context = Some(&template_ctx);
784        let result = replace_response_function(r#"response('login', 'body.user_id')"#, context);
785        assert_eq!(result, "null");
786
787        // Test with stored response
788        let mut chain_ctx = ChainContext::new();
789        let response = ChainResponse {
790            status: 200,
791            headers: HashMap::new(),
792            body: Some(json!({"user_id": 12345})),
793            duration_ms: 150,
794            executed_at: "2023-01-01T00:00:00Z".to_string(),
795            error: None,
796        };
797        chain_ctx.store_response("login".to_string(), response);
798        let template_ctx = ChainTemplatingContext::new(chain_ctx);
799        let context = Some(&template_ctx);
800
801        let result = replace_response_function(r#"response('login', 'user_id')"#, context);
802        assert_eq!(result, "12345");
803    }
804
805    #[test]
806    fn test_fs_readfile() {
807        // Create a temporary file for testing
808        use std::fs;
809
810        let temp_file = "/tmp/mockforge_test_file.txt";
811        let test_content = "Hello, this is test content!";
812        fs::write(temp_file, test_content).unwrap();
813
814        // Test successful file reading
815        let template = format!(r#"{{{{fs.readFile "{}"}}}}"#, temp_file);
816        let result = expand_str(&template);
817        assert_eq!(result, test_content);
818
819        // Test with parentheses
820        let template = format!(r#"{{{{fs.readFile('{}')}}}}"#, temp_file);
821        let result = expand_str(&template);
822        assert_eq!(result, test_content);
823
824        // Test file not found
825        let template = r#"{{fs.readFile "/nonexistent/file.txt"}}"#;
826        let result = expand_str(template);
827        assert!(result.contains("fs.readFile error:"));
828
829        // Test empty path
830        let template = r#"{{fs.readFile ""}}"#;
831        let result = expand_str(template);
832        assert_eq!(result, "<fs.readFile: empty path>");
833
834        // Clean up
835        let _ = fs::remove_file(temp_file);
836    }
837}