Skip to main content

standout_render/template/
simple.rs

1//! Simple template engine using format-string style substitution.
2//!
3//! This module provides [`SimpleEngine`], a lightweight template engine that uses
4//! `{variable}` syntax for variable substitution. It's much lighter than MiniJinja
5//! and suitable for simple templates that don't need loops, conditionals, or filters.
6//!
7//! # Syntax
8//!
9//! - `{name}` - Simple variable substitution
10//! - `{user.name}` - Nested property access via dot notation
11//! - `{items.0}` - Array index access
12//! - `{{` and `}}` - Escaped braces (renders as `{` and `}`)
13//!
14//! # Example
15//!
16//! ```rust
17//! use standout_render::template::{SimpleEngine, TemplateEngine};
18//! use serde_json::json;
19//!
20//! let engine = SimpleEngine::new();
21//! let data = json!({"name": "World", "user": {"email": "test@example.com"}});
22//!
23//! let output = engine.render_template(
24//!     "Hello, {name}! Contact: {user.email}",
25//!     &data,
26//! ).unwrap();
27//!
28//! assert_eq!(output, "Hello, World! Contact: test@example.com");
29//! ```
30//!
31//! # Limitations
32//!
33//! SimpleEngine intentionally does NOT support:
34//! - Loops (`{% for %}`)
35//! - Conditionals (`{% if %}`)
36//! - Filters (`| upper`)
37//! - Template includes
38//! - Macros or blocks
39//!
40//! For these features, use [`MiniJinjaEngine`](super::MiniJinjaEngine).
41
42use std::collections::HashMap;
43
44use crate::error::RenderError;
45
46use super::TemplateEngine;
47
48/// A lightweight template engine using format-string style substitution.
49///
50/// This engine provides simple `{variable}` substitution without the overhead
51/// of a full template engine. It's ideal for:
52///
53/// - Simple output templates
54/// - Configuration messages
55/// - Status displays
56/// - Any template that just needs variable substitution
57///
58/// # Thread Safety
59///
60/// `SimpleEngine` is `Send + Sync` and can be shared across threads.
61///
62/// # Example
63///
64/// ```rust
65/// use standout_render::template::{SimpleEngine, TemplateEngine};
66/// use serde_json::json;
67///
68/// let engine = SimpleEngine::new();
69/// let data = json!({"status": "ok", "count": 42});
70///
71/// let output = engine.render_template(
72///     "Status: {status}, Count: {count}",
73///     &data,
74/// ).unwrap();
75///
76/// assert_eq!(output, "Status: ok, Count: 42");
77/// ```
78pub struct SimpleEngine {
79    templates: HashMap<String, String>,
80}
81
82impl SimpleEngine {
83    /// Creates a new SimpleEngine.
84    pub fn new() -> Self {
85        Self {
86            templates: HashMap::new(),
87        }
88    }
89
90    /// Resolves a dotted path in a JSON value.
91    ///
92    /// Supports:
93    /// - Simple keys: `name`
94    /// - Nested objects: `user.profile.name`
95    /// - Array indices: `items.0` or `items.0.name`
96    fn resolve_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
97        let mut current = value;
98
99        for part in path.split('.') {
100            current = match current {
101                serde_json::Value::Object(map) => map.get(part)?,
102                serde_json::Value::Array(arr) => {
103                    let index: usize = part.parse().ok()?;
104                    arr.get(index)?
105                }
106                _ => return None,
107            };
108        }
109
110        Some(current)
111    }
112
113    /// Formats a JSON value as a string for output.
114    fn format_value(value: &serde_json::Value) -> String {
115        match value {
116            serde_json::Value::String(s) => s.clone(),
117            serde_json::Value::Number(n) => n.to_string(),
118            serde_json::Value::Bool(b) => b.to_string(),
119            serde_json::Value::Null => String::new(),
120            // For arrays and objects, use JSON representation
121            serde_json::Value::Array(_) | serde_json::Value::Object(_) => value.to_string(),
122        }
123    }
124
125    /// Renders a template string with the given data.
126    fn render_impl(
127        &self,
128        template: &str,
129        data: &serde_json::Value,
130        context: Option<&HashMap<String, serde_json::Value>>,
131    ) -> Result<String, RenderError> {
132        let mut result = String::with_capacity(template.len());
133        let mut chars = template.chars().peekable();
134
135        while let Some(ch) = chars.next() {
136            if ch == '{' {
137                if chars.peek() == Some(&'{') {
138                    // Escaped brace: {{ -> {
139                    chars.next();
140                    result.push('{');
141                } else {
142                    // Variable substitution
143                    let mut var_name = String::new();
144                    let mut found_close = false;
145
146                    for inner_ch in chars.by_ref() {
147                        if inner_ch == '}' {
148                            found_close = true;
149                            break;
150                        }
151                        var_name.push(inner_ch);
152                    }
153
154                    if !found_close {
155                        return Err(RenderError::TemplateError(format!(
156                            "Unclosed variable substitution: {{{}",
157                            var_name
158                        )));
159                    }
160
161                    let var_name = var_name.trim();
162
163                    if var_name.is_empty() {
164                        return Err(RenderError::TemplateError(
165                            "Empty variable name in template".to_string(),
166                        ));
167                    }
168
169                    // Try to resolve from context first (if provided), then from data
170                    let value = if let Some(ctx) = context {
171                        // For simple (non-dotted) names, check context first
172                        if !var_name.contains('.') {
173                            if let Some(ctx_val) = ctx.get(var_name) {
174                                Some(ctx_val)
175                            } else {
176                                Self::resolve_path(data, var_name)
177                            }
178                        } else {
179                            // For dotted paths, check if first segment is in context
180                            let first_segment = var_name.split('.').next().unwrap_or(var_name);
181                            if let Some(ctx_val) = ctx.get(first_segment) {
182                                // Resolve rest of path in context value
183                                let rest = &var_name[first_segment.len()..];
184                                if rest.is_empty() {
185                                    Some(ctx_val)
186                                } else {
187                                    Self::resolve_path(ctx_val, &rest[1..]) // Skip leading dot
188                                }
189                            } else {
190                                Self::resolve_path(data, var_name)
191                            }
192                        }
193                    } else {
194                        Self::resolve_path(data, var_name)
195                    };
196
197                    match value {
198                        Some(v) => result.push_str(&Self::format_value(v)),
199                        None => {
200                            // Variable not found - leave placeholder for debugging
201                            result.push_str(&format!("{{{}}}", var_name));
202                        }
203                    }
204                }
205            } else if ch == '}' {
206                if chars.peek() == Some(&'}') {
207                    // Escaped brace: }} -> }
208                    chars.next();
209                    result.push('}');
210                } else {
211                    // Stray closing brace - just include it
212                    result.push(ch);
213                }
214            } else {
215                result.push(ch);
216            }
217        }
218
219        Ok(result)
220    }
221}
222
223impl Default for SimpleEngine {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229impl TemplateEngine for SimpleEngine {
230    fn render_template(
231        &self,
232        template: &str,
233        data: &serde_json::Value,
234    ) -> Result<String, RenderError> {
235        self.render_impl(template, data, None)
236    }
237
238    fn add_template(&mut self, name: &str, source: &str) -> Result<(), RenderError> {
239        self.templates.insert(name.to_string(), source.to_string());
240        Ok(())
241    }
242
243    fn render_named(&self, name: &str, data: &serde_json::Value) -> Result<String, RenderError> {
244        let template = self
245            .templates
246            .get(name)
247            .ok_or_else(|| RenderError::TemplateNotFound(name.to_string()))?;
248        self.render_impl(template, data, None)
249    }
250
251    fn has_template(&self, name: &str) -> bool {
252        self.templates.contains_key(name)
253    }
254
255    fn render_with_context(
256        &self,
257        template: &str,
258        data: &serde_json::Value,
259        context: HashMap<String, serde_json::Value>,
260    ) -> Result<String, RenderError> {
261        self.render_impl(template, data, Some(&context))
262    }
263
264    fn supports_includes(&self) -> bool {
265        false
266    }
267
268    fn supports_filters(&self) -> bool {
269        false
270    }
271
272    fn supports_control_flow(&self) -> bool {
273        false
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use serde_json::json;
281
282    #[test]
283    fn test_simple_substitution() {
284        let engine = SimpleEngine::new();
285        let data = json!({"name": "World"});
286
287        let output = engine.render_template("Hello, {name}!", &data).unwrap();
288        assert_eq!(output, "Hello, World!");
289    }
290
291    #[test]
292    fn test_multiple_variables() {
293        let engine = SimpleEngine::new();
294        let data = json!({"first": "John", "last": "Doe"});
295
296        let output = engine.render_template("{first} {last}", &data).unwrap();
297        assert_eq!(output, "John Doe");
298    }
299
300    #[test]
301    fn test_nested_access() {
302        let engine = SimpleEngine::new();
303        let data = json!({
304            "user": {
305                "name": "Alice",
306                "profile": {
307                    "email": "alice@example.com"
308                }
309            }
310        });
311
312        let output = engine
313            .render_template("Name: {user.name}, Email: {user.profile.email}", &data)
314            .unwrap();
315        assert_eq!(output, "Name: Alice, Email: alice@example.com");
316    }
317
318    #[test]
319    fn test_array_index() {
320        let engine = SimpleEngine::new();
321        let data = json!({
322            "items": ["first", "second", "third"]
323        });
324
325        let output = engine
326            .render_template("First: {items.0}, Third: {items.2}", &data)
327            .unwrap();
328        assert_eq!(output, "First: first, Third: third");
329    }
330
331    #[test]
332    fn test_array_object_access() {
333        let engine = SimpleEngine::new();
334        let data = json!({
335            "users": [
336                {"name": "Alice"},
337                {"name": "Bob"}
338            ]
339        });
340
341        let output = engine
342            .render_template("{users.0.name} and {users.1.name}", &data)
343            .unwrap();
344        assert_eq!(output, "Alice and Bob");
345    }
346
347    #[test]
348    fn test_number_values() {
349        let engine = SimpleEngine::new();
350        let data = json!({"count": 42, "price": 19.99});
351
352        let output = engine
353            .render_template("Count: {count}, Price: {price}", &data)
354            .unwrap();
355        assert_eq!(output, "Count: 42, Price: 19.99");
356    }
357
358    #[test]
359    fn test_boolean_values() {
360        let engine = SimpleEngine::new();
361        let data = json!({"active": true, "deleted": false});
362
363        let output = engine
364            .render_template("Active: {active}, Deleted: {deleted}", &data)
365            .unwrap();
366        assert_eq!(output, "Active: true, Deleted: false");
367    }
368
369    #[test]
370    fn test_null_value() {
371        let engine = SimpleEngine::new();
372        let data = json!({"value": null});
373
374        let output = engine.render_template("Value: {value}", &data).unwrap();
375        assert_eq!(output, "Value: ");
376    }
377
378    #[test]
379    fn test_escaped_braces() {
380        let engine = SimpleEngine::new();
381        let data = json!({"name": "test"});
382
383        let output = engine
384            .render_template("Use {{name}} for {name}", &data)
385            .unwrap();
386        assert_eq!(output, "Use {name} for test");
387    }
388
389    #[test]
390    fn test_escaped_closing_brace() {
391        let engine = SimpleEngine::new();
392        let data = json!({});
393
394        let output = engine
395            .render_template("JSON: {{\"key\": \"value\"}}", &data)
396            .unwrap();
397        assert_eq!(output, "JSON: {\"key\": \"value\"}");
398    }
399
400    #[test]
401    fn test_missing_variable() {
402        let engine = SimpleEngine::new();
403        let data = json!({"name": "test"});
404
405        let output = engine.render_template("Hello {missing}!", &data).unwrap();
406        // Missing variables are left as-is for debugging
407        assert_eq!(output, "Hello {missing}!");
408    }
409
410    #[test]
411    fn test_unclosed_variable() {
412        let engine = SimpleEngine::new();
413        let data = json!({});
414
415        let result = engine.render_template("Hello {name", &data);
416        assert!(result.is_err());
417        assert!(result.unwrap_err().to_string().contains("Unclosed"));
418    }
419
420    #[test]
421    fn test_empty_variable_name() {
422        let engine = SimpleEngine::new();
423        let data = json!({});
424
425        let result = engine.render_template("Hello {}!", &data);
426        assert!(result.is_err());
427        assert!(result.unwrap_err().to_string().contains("Empty variable"));
428    }
429
430    #[test]
431    fn test_whitespace_in_variable() {
432        let engine = SimpleEngine::new();
433        let data = json!({"name": "World"});
434
435        // Whitespace around variable name should be trimmed
436        let output = engine.render_template("Hello { name }!", &data).unwrap();
437        assert_eq!(output, "Hello World!");
438    }
439
440    #[test]
441    fn test_named_template() {
442        let mut engine = SimpleEngine::new();
443        engine.add_template("greeting", "Hello, {name}!").unwrap();
444
445        let data = json!({"name": "World"});
446        let output = engine.render_named("greeting", &data).unwrap();
447        assert_eq!(output, "Hello, World!");
448    }
449
450    #[test]
451    fn test_named_template_not_found() {
452        let engine = SimpleEngine::new();
453        let data = json!({});
454
455        let result = engine.render_named("missing", &data);
456        assert!(result.is_err());
457        assert!(matches!(
458            result.unwrap_err(),
459            RenderError::TemplateNotFound(_)
460        ));
461    }
462
463    #[test]
464    fn test_has_template() {
465        let mut engine = SimpleEngine::new();
466        assert!(!engine.has_template("test"));
467
468        engine.add_template("test", "content").unwrap();
469        assert!(engine.has_template("test"));
470    }
471
472    #[test]
473    fn test_with_context() {
474        let engine = SimpleEngine::new();
475        let data = json!({"name": "Alice"});
476        let mut context = HashMap::new();
477        context.insert("version".to_string(), json!("1.0.0"));
478
479        let output = engine
480            .render_with_context("{name} v{version}", &data, context)
481            .unwrap();
482        assert_eq!(output, "Alice v1.0.0");
483    }
484
485    #[test]
486    fn test_context_data_precedence() {
487        let engine = SimpleEngine::new();
488        let data = json!({"value": "from_data"});
489        let mut context = HashMap::new();
490        context.insert("value".to_string(), json!("from_context"));
491
492        // Context is checked first for simple names
493        let output = engine
494            .render_with_context("{value}", &data, context)
495            .unwrap();
496        assert_eq!(output, "from_context");
497    }
498
499    #[test]
500    fn test_supports_flags() {
501        let engine = SimpleEngine::new();
502        assert!(!engine.supports_includes());
503        assert!(!engine.supports_filters());
504        assert!(!engine.supports_control_flow());
505    }
506
507    #[test]
508    fn test_no_template_logic() {
509        let engine = SimpleEngine::new();
510        let data = json!({"items": [1, 2, 3]});
511
512        // Jinja-style syntax is NOT interpreted - it's passed through as-is
513        // Note: {{i}} becomes {i} due to brace escaping ({{ -> {, }} -> })
514        let output = engine
515            .render_template("{% for i in items %}{{i}}{% endfor %}", &data)
516            .unwrap();
517        // The Jinja control flow is preserved, but {{ }} are unescaped to { }
518        assert_eq!(output, "{% for i in items %}{i}{% endfor %}");
519    }
520
521    #[test]
522    fn test_plain_text() {
523        let engine = SimpleEngine::new();
524        let data = json!({});
525
526        let output = engine
527            .render_template("Just plain text, no variables", &data)
528            .unwrap();
529        assert_eq!(output, "Just plain text, no variables");
530    }
531
532    #[test]
533    fn test_complex_json_value() {
534        let engine = SimpleEngine::new();
535        let data = json!({
536            "obj": {"a": 1, "b": 2},
537            "arr": [1, 2, 3]
538        });
539
540        // Objects and arrays are rendered as JSON
541        let output = engine.render_template("Obj: {obj}", &data).unwrap();
542        assert!(output.contains("\"a\":1") || output.contains("\"a\": 1"));
543    }
544}