Skip to main content

saorsa_agent/templates/
engine.rs

1//! Template rendering engine with variable substitution and conditionals.
2
3use crate::error::{Result, SaorsaAgentError};
4use std::collections::HashMap;
5
6/// Template context for variable substitution.
7pub type TemplateContext = HashMap<String, String>;
8
9/// Template rendering engine.
10#[derive(Debug, Default)]
11pub struct TemplateEngine;
12
13impl TemplateEngine {
14    /// Create a new template engine.
15    pub fn new() -> Self {
16        Self
17    }
18
19    /// Render a template with the given context.
20    ///
21    /// Supports:
22    /// - Variable substitution: `{{variable}}`
23    /// - Conditionals: `{{#if var}}...{{/if}}`
24    /// - Negated conditionals: `{{#unless var}}...{{/unless}}`
25    pub fn render(&self, template: &str, context: &TemplateContext) -> Result<String> {
26        let mut result = String::new();
27        let mut chars = template.chars().peekable();
28
29        while let Some(ch) = chars.next() {
30            if ch == '{' && chars.peek() == Some(&'{') {
31                chars.next(); // consume second '{'
32                let tag = self.collect_until(&mut chars, '}', '}')?;
33                let rendered = self.render_tag(&tag, context, template)?;
34                result.push_str(&rendered);
35            } else {
36                result.push(ch);
37            }
38        }
39
40        Ok(result)
41    }
42
43    /// Collect characters until we see the delimiter pair.
44    fn collect_until(
45        &self,
46        chars: &mut std::iter::Peekable<std::str::Chars>,
47        delim1: char,
48        delim2: char,
49    ) -> Result<String> {
50        let mut content = String::new();
51        while let Some(ch) = chars.next() {
52            if ch == delim1 && chars.peek() == Some(&delim2) {
53                chars.next(); // consume second delimiter
54                return Ok(content);
55            }
56            content.push(ch);
57        }
58        Err(SaorsaAgentError::Context(
59            "Unclosed template tag".to_string(),
60        ))
61    }
62
63    /// Render a template tag (variable or conditional).
64    fn render_tag(
65        &self,
66        tag: &str,
67        context: &TemplateContext,
68        full_template: &str,
69    ) -> Result<String> {
70        let tag = tag.trim();
71
72        if let Some(var_name) = tag.strip_prefix("#if ") {
73            self.render_if(var_name.trim(), context, full_template)
74        } else if let Some(var_name) = tag.strip_prefix("#unless ") {
75            self.render_unless(var_name.trim(), context, full_template)
76        } else if tag == "/if" || tag == "/unless" {
77            // Closing tags are handled by conditional logic, should not appear here
78            Ok(String::new())
79        } else {
80            // Variable substitution
81            self.render_variable(tag, context)
82        }
83    }
84
85    /// Render a variable substitution.
86    fn render_variable(&self, var_name: &str, context: &TemplateContext) -> Result<String> {
87        context.get(var_name).cloned().ok_or_else(|| {
88            SaorsaAgentError::Context(format!("Missing template variable: {}", var_name))
89        })
90    }
91
92    /// Render an `{{#if var}}...{{/if}}` block.
93    ///
94    /// Note: This is a placeholder. Actual conditional logic is handled by render_simple.
95    fn render_if(
96        &self,
97        _var_name: &str,
98        _context: &TemplateContext,
99        _full_template: &str,
100    ) -> Result<String> {
101        Ok(String::new())
102    }
103
104    /// Render an `{{#unless var}}...{{/unless}}` block.
105    ///
106    /// Note: This is a placeholder. Actual conditional logic is handled by render_simple.
107    fn render_unless(
108        &self,
109        _var_name: &str,
110        _context: &TemplateContext,
111        _full_template: &str,
112    ) -> Result<String> {
113        Ok(String::new())
114    }
115}
116
117/// Simple template renderer (does not support nested conditionals properly).
118///
119/// This is a basic implementation. For proper conditional support, a full parser
120/// would be needed. This version handles simple cases only.
121pub fn render_simple(template: &str, context: &TemplateContext) -> Result<String> {
122    let mut result = template.to_string();
123
124    // Replace variables
125    for (key, value) in context {
126        let placeholder = format!("{{{{{}}}}}", key);
127        result = result.replace(&placeholder, value);
128    }
129
130    // Handle simple if blocks (single line only)
131    // This is a simplified implementation
132    let if_pattern_start = "{{#if ";
133    let if_pattern_end = "{{/if}}";
134
135    while let Some(start_pos) = result.find(if_pattern_start) {
136        if let Some(content_start) = result[start_pos..].find("}}") {
137            let var_end = start_pos + if_pattern_start.len();
138            let var_name = &result[var_end..start_pos + content_start];
139
140            if let Some(end_pos) = result[start_pos..].find(if_pattern_end) {
141                let full_start = start_pos;
142                let full_end = start_pos + end_pos + if_pattern_end.len();
143                let content = &result[start_pos + content_start + 2..start_pos + end_pos];
144
145                let replacement = if context.contains_key(var_name.trim())
146                    && !context.get(var_name.trim()).is_some_and(|v| v.is_empty())
147                {
148                    content.to_string()
149                } else {
150                    String::new()
151                };
152
153                result.replace_range(full_start..full_end, &replacement);
154            } else {
155                break;
156            }
157        } else {
158            break;
159        }
160    }
161
162    // Handle simple unless blocks
163    let unless_pattern_start = "{{#unless ";
164    let unless_pattern_end = "{{/unless}}";
165
166    while let Some(start_pos) = result.find(unless_pattern_start) {
167        if let Some(content_start) = result[start_pos..].find("}}") {
168            let var_end = start_pos + unless_pattern_start.len();
169            let var_name = &result[var_end..start_pos + content_start];
170
171            if let Some(end_pos) = result[start_pos..].find(unless_pattern_end) {
172                let full_start = start_pos;
173                let full_end = start_pos + end_pos + unless_pattern_end.len();
174                let content = &result[start_pos + content_start + 2..start_pos + end_pos];
175
176                let replacement = if !context.contains_key(var_name.trim())
177                    || context.get(var_name.trim()).is_some_and(|v| v.is_empty())
178                {
179                    content.to_string()
180                } else {
181                    String::new()
182                };
183
184                result.replace_range(full_start..full_end, &replacement);
185            } else {
186                break;
187            }
188        } else {
189            break;
190        }
191    }
192
193    Ok(result)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_variable_substitution() {
202        let template = "Hello {{name}}!";
203        let mut context = TemplateContext::new();
204        context.insert("name".to_string(), "World".to_string());
205
206        let result = render_simple(template, &context);
207        assert!(result.is_ok());
208
209        match result {
210            Ok(r) => assert_eq!(r, "Hello World!"),
211            Err(_) => unreachable!("Should render successfully"),
212        }
213    }
214
215    #[test]
216    fn test_multiple_variables() {
217        let template = "{{greeting}} {{name}}!";
218        let mut context = TemplateContext::new();
219        context.insert("greeting".to_string(), "Hi".to_string());
220        context.insert("name".to_string(), "Alice".to_string());
221
222        let result = render_simple(template, &context);
223        assert!(result.is_ok());
224
225        match result {
226            Ok(r) => assert_eq!(r, "Hi Alice!"),
227            Err(_) => unreachable!("Should render successfully"),
228        }
229    }
230
231    #[test]
232    fn test_if_conditional_true() {
233        let template = "Start {{#if show}}visible{{/if}} end";
234        let mut context = TemplateContext::new();
235        context.insert("show".to_string(), "yes".to_string());
236
237        let result = render_simple(template, &context);
238        assert!(result.is_ok());
239
240        match result {
241            Ok(r) => assert_eq!(r, "Start visible end"),
242            Err(_) => unreachable!("Should render successfully"),
243        }
244    }
245
246    #[test]
247    fn test_if_conditional_false() {
248        let template = "Start {{#if show}}hidden{{/if}} end";
249        let context = TemplateContext::new();
250
251        let result = render_simple(template, &context);
252        assert!(result.is_ok());
253
254        match result {
255            Ok(r) => assert_eq!(r, "Start  end"),
256            Err(_) => unreachable!("Should render successfully"),
257        }
258    }
259
260    #[test]
261    fn test_unless_conditional_true() {
262        let template = "Start {{#unless hide}}visible{{/unless}} end";
263        let context = TemplateContext::new();
264
265        let result = render_simple(template, &context);
266        assert!(result.is_ok());
267
268        match result {
269            Ok(r) => assert_eq!(r, "Start visible end"),
270            Err(_) => unreachable!("Should render successfully"),
271        }
272    }
273
274    #[test]
275    fn test_unless_conditional_false() {
276        let template = "Start {{#unless hide}}hidden{{/unless}} end";
277        let mut context = TemplateContext::new();
278        context.insert("hide".to_string(), "yes".to_string());
279
280        let result = render_simple(template, &context);
281        assert!(result.is_ok());
282
283        match result {
284            Ok(r) => assert_eq!(r, "Start  end"),
285            Err(_) => unreachable!("Should render successfully"),
286        }
287    }
288
289    #[test]
290    fn test_empty_context() {
291        let template = "No variables here";
292        let context = TemplateContext::new();
293
294        let result = render_simple(template, &context);
295        assert!(result.is_ok());
296
297        match result {
298            Ok(r) => assert_eq!(r, "No variables here"),
299            Err(_) => unreachable!("Should render successfully"),
300        }
301    }
302
303    #[test]
304    fn test_template_engine_new() {
305        let engine = TemplateEngine::new();
306        let template = "Hello {{name}}";
307        let mut context = TemplateContext::new();
308        context.insert("name".to_string(), "Test".to_string());
309
310        let result = engine.render(template, &context);
311        assert!(result.is_ok());
312    }
313}