mockforge_core/
templating.rs

1//! Extended templating system for MockForge with request chaining support
2//!
3//! This module provides template expansion with support for:
4//! - Standard tokens (UUID, timestamps, random data, faker)
5//! - Request chaining context variables
6//! - End-to-end encryption functions
7
8use 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
20// Pre-compiled regex patterns for templating
21static 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/// Template engine for processing template strings with various token types
65#[derive(Debug, Clone)]
66pub struct TemplateEngine {
67    /// Configuration for the template engine
68    _config: Config,
69}
70
71impl Default for TemplateEngine {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl TemplateEngine {
78    /// Create a new template engine
79    pub fn new() -> Self {
80        Self {
81            _config: Config::default(),
82        }
83    }
84
85    /// Create a new template engine with configuration
86    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    /// Expand templating tokens in a string
93    pub fn expand_str(&self, input: &str) -> String {
94        expand_str(input)
95    }
96
97    /// Expand templating tokens in a string with context
98    pub fn expand_str_with_context(&self, input: &str, context: &TemplatingContext) -> String {
99        expand_str_with_context(input, context)
100    }
101
102    /// Expand templating tokens in a JSON value
103    pub fn expand_tokens(&self, value: &Value) -> Value {
104        expand_tokens(value)
105    }
106
107    /// Expand templating tokens in a JSON value with context
108    pub fn expand_tokens_with_context(&self, value: &Value, context: &TemplatingContext) -> Value {
109        expand_tokens_with_context(value, context)
110    }
111}
112
113/// Context for environment variables during template expansion
114#[derive(Debug, Clone)]
115pub struct EnvironmentTemplatingContext {
116    /// Environment variables available for substitution
117    pub variables: HashMap<String, String>,
118}
119
120impl EnvironmentTemplatingContext {
121    /// Create a new environment context
122    pub fn new(variables: HashMap<String, String>) -> Self {
123        Self { variables }
124    }
125
126    /// Get a variable value by name
127    pub fn get_variable(&self, name: &str) -> Option<&String> {
128        self.variables.get(name)
129    }
130}
131
132/// Combined templating context with both chain and environment variables
133#[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    /// Create empty context
142    pub fn empty() -> Self {
143        Self {
144            chain_context: None,
145            env_context: None,
146            virtual_clock: None,
147        }
148    }
149
150    /// Create context with environment variables only
151    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    /// Create context with chain context only
160    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    /// Create context with both chain and environment contexts
169    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    /// Create context with virtual clock
181    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    /// Add virtual clock to existing context
190    pub fn with_clock(mut self, clock: Arc<VirtualClock>) -> Self {
191        self.virtual_clock = Some(clock);
192        self
193    }
194}
195
196/// Expand templating tokens in a JSON value recursively.
197pub fn expand_tokens(v: &Value) -> Value {
198    expand_tokens_with_context(v, &TemplatingContext::empty())
199}
200
201/// Expand templating tokens in a JSON value recursively with context.
202pub 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
219/// Expand templating tokens in a string.
220pub fn expand_str(input: &str) -> String {
221    expand_str_with_context(input, &TemplatingContext::empty())
222}
223
224/// Expand templating tokens in a string with templating context
225pub fn expand_str_with_context(input: &str, context: &TemplatingContext) -> String {
226    // Basic replacements first (fast paths)
227    let mut out = input.replace("{{uuid}}", &uuid::Uuid::new_v4().to_string());
228
229    // Use virtual clock if available, otherwise use real time
230    let current_time = if let Some(clock) = &context.virtual_clock {
231        clock.now()
232    } else {
233        Utc::now()
234    };
235    out = out.replace("{{now}}", &current_time.to_rfc3339());
236
237    // now±Nd (days), now±Nh (hours), now±Nm (minutes), now±Ns (seconds)
238    out = replace_now_offset_with_time(&out, current_time);
239
240    // Randoms
241    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    // Response function tokens (new response() syntax)
252    if out.contains("response(") {
253        out = replace_response_function(&out, context.chain_context.as_ref());
254    }
255
256    // Environment variables (check before chain context to allow env vars in chain expressions)
257    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    // Chain context variables
264    if out.contains("{{chain.") {
265        out = replace_chain_tokens(&out, context.chain_context.as_ref());
266    }
267
268    // Faker tokens (can be disabled for determinism)
269    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    // File system tokens
277    if out.contains("{{fs.readFile") {
278        out = replace_fs_tokens(&out);
279    }
280
281    // Encryption tokens
282    if out.contains("{{encrypt") || out.contains("{{decrypt") || out.contains("{{secure") {
283        out = replace_encryption_tokens(&out);
284    }
285
286    out
287}
288
289// Provider wiring (optional)
290static 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    // Supports {{randInt a b}} and {{rand.int a b}}
337    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+1d }}, {{now-2h}}, {{now+30m}}, {{now-10s}}
360    NOW_OFFSET_RE
361        .replace_all(input, |caps: &regex::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
381/// Replace environment variable tokens in a template string
382fn replace_env_tokens(input: &str, env_context: &EnvironmentTemplatingContext) -> String {
383    ENV_TOKEN_RE
384        .replace_all(input, |caps: &regex::Captures| {
385            let var_name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
386
387            // Skip built-in tokens (uuid, now, rand.*, faker.*, chain.*, encrypt.*, decrypt.*, secure.*)
388            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            // Look up the variable in environment context
400            match env_context.get_variable(var_name) {
401                Some(value) => value.clone(),
402                None => format!("{{{{{}}}}}", var_name), // Keep original if not found
403            }
404        })
405        .to_string()
406}
407
408/// Replace chain context tokens in a template string
409fn 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: &regex::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(), // Return null for missing values instead of empty string
421                }
422            })
423            .to_string()
424    } else {
425        // No chain context available, return input unchanged
426        input.to_string()
427    }
428}
429
430/// Replace response function tokens (new response() syntax)
431fn replace_response_function(
432    input: &str,
433    chain_context: Option<&ChainTemplatingContext>,
434) -> String {
435    // Match response('request_id', 'jsonpath') - handle both single and double quotes
436    if let Some(context) = chain_context {
437        let result = RESPONSE_FN_RE
438            .replace_all(input, |caps: &regex::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                // Build the full path like "request_id.json_path"
443                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(), // Return null for missing values
455                }
456            })
457            .to_string();
458
459        result
460    } else {
461        // No chain context available, return input unchanged
462        input.to_string()
463    }
464}
465
466/// Replace encryption tokens in a template string
467fn replace_encryption_tokens(input: &str) -> String {
468    // Key store is initialized at startup
469    let key_store = init_key_store();
470
471    // Default key ID for templating
472    let default_key_id = "mockforge_default";
473
474    let mut out = input.to_string();
475
476    // Process encrypt tokens
477    out = ENCRYPT_RE
478        .replace_all(&out, |caps: &regex::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                    // Create a default key if none exists
489                    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    // Process secure tokens (ChaCha20-Poly1305)
508    out = SECURE_RE
509        .replace_all(&out, |caps: &regex::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                    // Use ChaCha20-Poly1305 for secure() function
516                    match key.encrypt_chacha20(plaintext, None) {
517                        Ok(ciphertext) => ciphertext,
518                        Err(_) => "<encryption_error>".to_string(),
519                    }
520                }
521                None => {
522                    // Create a default key if none exists
523                    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    // Process decrypt tokens
542    out = DECRYPT_RE
543        .replace_all(&out, |caps: &regex::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                    // Create a default key if none exists
554                    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
575/// Replace file system tokens in a template string
576fn replace_fs_tokens(input: &str) -> String {
577    // Handle {{fs.readFile "path/to/file"}} or {{fs.readFile('path/to/file')}}
578    FS_READFILE_RE
579        .replace_all(input, |caps: &regex::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 a provider is registered (e.g., from mockforge-data), use it; else fallback
596    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        // Note: This test would need a proper response stored in the chain context
675        let result = replace_chain_tokens("{{chain.test.body}}", context);
676        assert_eq!(result, "null");
677    }
678
679    #[test]
680    fn test_response_function() {
681        // Test with no chain context
682        let result = replace_response_function(r#"response('login', 'body.user_id')"#, None);
683        assert_eq!(result, r#"response('login', 'body.user_id')"#);
684
685        // Test with chain context but no matching response
686        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        // Test with stored response
693        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        // Create a temporary file for testing
713        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        // Test successful file reading
720        let template = format!(r#"{{{{fs.readFile "{}"}}}}"#, temp_file);
721        let result = expand_str(&template);
722        assert_eq!(result, test_content);
723
724        // Test with parentheses
725        let template = format!(r#"{{{{fs.readFile('{}')}}}}"#, temp_file);
726        let result = expand_str(&template);
727        assert_eq!(result, test_content);
728
729        // Test file not found
730        let template = r#"{{fs.readFile "/nonexistent/file.txt"}}"#;
731        let result = expand_str(template);
732        assert!(result.contains("fs.readFile error:"));
733
734        // Test empty path
735        let template = r#"{{fs.readFile ""}}"#;
736        let result = expand_str(template);
737        assert_eq!(result, "<fs.readFile: empty path>");
738
739        // Clean up
740        let _ = fs::remove_file(temp_file);
741    }
742}