Skip to main content

torrust_tracker_deployer_lib/domain/template/
engine.rs

1//! Template Engine Implementation
2//!
3//! Provides the `TemplateEngine` struct that handles template validation and rendering with Tera.
4
5use serde::Serialize;
6use std::error::Error as StdError;
7use tera::Tera;
8use thiserror::Error;
9
10/// Extracts the full error chain from a `tera::Error` as a single string.
11///
12/// Tera errors have nested sources that are important for debugging (e.g., "Variable 'x' not found").
13/// The standard Display trait only shows the outer message. This function traverses
14/// the entire error chain and concatenates all messages.
15fn tera_error_chain(err: &tera::Error) -> String {
16    let mut messages = vec![err.to_string()];
17    let mut current: Option<&(dyn StdError + 'static)> = err.source();
18
19    while let Some(source) = current {
20        messages.push(source.to_string());
21        current = source.source();
22    }
23
24    messages.join(" -> ")
25}
26
27/// Errors that can occur during template engine operations
28#[derive(Debug, Error)]
29pub enum TemplateEngineError {
30    #[error(
31        "Failed to parse template '{template_name}': {}",
32        tera_error_chain(source)
33    )]
34    TemplateParse {
35        template_name: String,
36        #[source]
37        source: tera::Error,
38    },
39
40    #[error("Failed to serialize template context: {}", tera_error_chain(source))]
41    ContextSerialization {
42        #[source]
43        source: tera::Error,
44    },
45
46    #[error(
47        "Failed to render template '{template_name}': {}",
48        tera_error_chain(source)
49    )]
50    TemplateRender {
51        template_name: String,
52        #[source]
53        source: tera::Error,
54    },
55}
56
57/// Template processing engine for validation and rendering
58#[derive(Debug, Default)]
59pub struct TemplateEngine {
60    tera: Tera,
61}
62
63impl TemplateEngine {
64    /// Creates a new `TemplateEngine` instance with an empty Tera engine
65    #[must_use]
66    pub fn new() -> Self {
67        Self {
68            tera: Tera::default(),
69        }
70    }
71
72    /// Creates a new `TemplateEngine` with template content and validates it with the given context
73    ///
74    /// This method combines template creation and validation to ensure templates are always
75    /// instantiated in a valid state. It will fail if:
76    /// - Template has syntax errors
77    /// - Template references undefined variables
78    /// - Template cannot be rendered for any reason
79    ///
80    /// # Errors
81    /// Returns an error if template content cannot be parsed or validation fails
82    pub fn render<T: Serialize>(
83        &mut self,
84        template_name: &str,
85        template_content: &str,
86        context: &T,
87    ) -> Result<String, TemplateEngineError> {
88        // Add the template content to this validator instance
89        self.add_template(template_name, template_content)?;
90
91        // Validate the template by rendering it
92        let validated_content = self.render_template(template_name, context)?;
93
94        Ok(validated_content)
95    }
96
97    /// Adds template content to this validator instance
98    ///
99    /// # Errors
100    /// Returns an error if the template content cannot be parsed
101    fn add_template(
102        &mut self,
103        template_name: &str,
104        template_content: &str,
105    ) -> Result<(), TemplateEngineError> {
106        self.tera
107            .add_raw_template(template_name, template_content)
108            .map_err(|source| TemplateEngineError::TemplateParse {
109                template_name: template_name.to_string(),
110                source,
111            })?;
112
113        Ok(())
114    }
115
116    /// Renders a template by name with the given context and validates the result
117    ///
118    /// # Errors
119    /// Returns an error if template rendering fails or variables cannot be substituted
120    fn render_template<T: Serialize>(
121        &self,
122        template_name: &str,
123        context: &T,
124    ) -> Result<String, TemplateEngineError> {
125        let tera_context = tera::Context::from_serialize(context)
126            .map_err(|source| TemplateEngineError::ContextSerialization { source })?;
127
128        let rendered_content =
129            self.tera
130                .render(template_name, &tera_context)
131                .map_err(|source| TemplateEngineError::TemplateRender {
132                    template_name: template_name.to_string(),
133                    source,
134                })?;
135
136        Ok(rendered_content)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use serde::Serialize;
144
145    #[derive(Serialize)]
146    struct TestContext {
147        name: String,
148        value: u32,
149    }
150
151    #[derive(Serialize)]
152    struct PartialContext {
153        name: String,
154    }
155
156    // Tests for TemplateEngine::new()
157    #[test]
158    fn it_should_create_new_validator_instance() {
159        let validator = TemplateEngine::new();
160
161        // Verify it was created successfully - we can't inspect internal state
162        // but we can verify the Debug trait works (indicating successful creation)
163        assert!(format!("{validator:?}").contains("TemplateEngine"));
164    }
165
166    #[test]
167    fn it_should_create_multiple_independent_validators() {
168        let validator1 = TemplateEngine::new();
169        let validator2 = TemplateEngine::new();
170
171        // Both should be successfully created
172        assert!(format!("{validator1:?}").contains("TemplateEngine"));
173        assert!(format!("{validator2:?}").contains("TemplateEngine"));
174    }
175
176    // Tests for TemplateEngine::render()
177    #[test]
178    fn it_should_render_simple_template_successfully() -> Result<(), TemplateEngineError> {
179        let mut validator = TemplateEngine::new();
180        let template_content = "Hello {{name}}! Value: {{value}}";
181        let context = TestContext {
182            name: "World".to_string(),
183            value: 42,
184        };
185
186        let rendered_content = validator.render("test_template", template_content, &context)?;
187
188        assert_eq!(rendered_content, "Hello World! Value: 42");
189        Ok(())
190    }
191
192    #[test]
193    fn it_should_render_template_with_no_variables() -> Result<(), TemplateEngineError> {
194        let mut validator = TemplateEngine::new();
195        let template_content = "This is a static template with no variables.";
196        let context = TestContext {
197            name: "unused".to_string(),
198            value: 0,
199        };
200
201        let rendered_content = validator.render("static_template", template_content, &context)?;
202
203        assert_eq!(
204            rendered_content,
205            "This is a static template with no variables."
206        );
207        Ok(())
208    }
209
210    #[test]
211    fn it_should_render_empty_template() -> Result<(), TemplateEngineError> {
212        let mut validator = TemplateEngine::new();
213        let template_content = "";
214        let context = TestContext {
215            name: "test".to_string(),
216            value: 42,
217        };
218
219        let rendered_content = validator.render("empty_template", template_content, &context)?;
220
221        assert_eq!(rendered_content, "");
222        Ok(())
223    }
224
225    #[test]
226    fn it_should_handle_empty_template_name() -> Result<(), TemplateEngineError> {
227        let mut validator = TemplateEngine::new();
228        let template_content = "Hello {{name}}!";
229        let context = TestContext {
230            name: "World".to_string(),
231            value: 42,
232        };
233
234        let rendered_content = validator.render("", template_content, &context)?;
235
236        assert_eq!(rendered_content, "Hello World!");
237        Ok(())
238    }
239
240    #[test]
241    fn it_should_fail_when_template_has_malformed_syntax() {
242        let mut validator = TemplateEngine::new();
243        let template_content = "Hello {{name! Invalid syntax";
244        let context = TestContext {
245            name: "World".to_string(),
246            value: 42,
247        };
248
249        let result = validator.render("malformed_template", template_content, &context);
250
251        assert!(result.is_err());
252        match result.unwrap_err() {
253            TemplateEngineError::TemplateParse { template_name, .. } => {
254                assert_eq!(template_name, "malformed_template");
255            }
256            other => panic!("Expected TemplateParse error, got: {other:?}"),
257        }
258    }
259
260    #[test]
261    fn it_should_fail_when_template_references_undefined_variable() {
262        let mut validator = TemplateEngine::new();
263        let template_content = "Hello {{name}}! Value: {{undefined_variable}}";
264        let context = PartialContext {
265            name: "World".to_string(),
266        };
267
268        let result = validator.render("undefined_var_template", template_content, &context);
269
270        assert!(result.is_err());
271        match result.unwrap_err() {
272            TemplateEngineError::TemplateRender { template_name, .. } => {
273                assert_eq!(template_name, "undefined_var_template");
274            }
275            other => panic!("Expected TemplateRender error, got: {other:?}"),
276        }
277    }
278
279    #[test]
280    fn it_should_fail_when_context_serialization_fails() {
281        struct FailingSerialize;
282
283        impl Serialize for FailingSerialize {
284            fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
285            where
286                S: serde::Serializer,
287            {
288                Err(serde::ser::Error::custom(
289                    "Intentional serialization failure",
290                ))
291            }
292        }
293
294        let mut validator = TemplateEngine::new();
295        let template_content = "Hello {{name}}!";
296        let context = FailingSerialize;
297
298        let result = validator.render("serialize_fail_template", template_content, &context);
299
300        assert!(result.is_err());
301        match result.unwrap_err() {
302            TemplateEngineError::ContextSerialization { .. } => {
303                // Expected
304            }
305            other => panic!("Expected ContextSerialization error, got: {other:?}"),
306        }
307    }
308
309    #[test]
310    fn it_should_render_yaml_like_template() -> Result<(), TemplateEngineError> {
311        let mut validator = TemplateEngine::new();
312        let template_content = "name: {{name}}\nvalue: {{value}}\nstatic: true";
313        let context = TestContext {
314            name: "test".to_string(),
315            value: 42,
316        };
317
318        let rendered_content = validator.render("yaml_template", template_content, &context)?;
319
320        assert!(rendered_content.contains("name: test"));
321        assert!(rendered_content.contains("value: 42"));
322        assert!(rendered_content.contains("static: true"));
323        Ok(())
324    }
325
326    #[test]
327    fn it_should_render_different_templates_with_same_validator() -> Result<(), TemplateEngineError>
328    {
329        let mut validator = TemplateEngine::new();
330
331        let context = TestContext {
332            name: "Alice".to_string(),
333            value: 100,
334        };
335
336        // Render first template
337        let template1 = "Hello {{name}}!";
338        let result1 = validator.render("template1", template1, &context)?;
339        assert_eq!(result1, "Hello Alice!");
340
341        // Render second template with same validator
342        let template2 = "Value is: {{value}}";
343        let result2 = validator.render("template2", template2, &context)?;
344        assert_eq!(result2, "Value is: 100");
345
346        Ok(())
347    }
348
349    #[test]
350    fn it_should_handle_complex_template_with_multiple_variables() -> Result<(), TemplateEngineError>
351    {
352        let mut validator = TemplateEngine::new();
353        let template_content = r#"
354# Configuration for {{name}}
355version: "1.0"
356settings:
357  port: {{value}}
358  enabled: true
359  name: "{{name}}"
360"#;
361        let context = TestContext {
362            name: "MyApp".to_string(),
363            value: 8080,
364        };
365
366        let rendered_content = validator.render("config_template", template_content, &context)?;
367
368        assert!(rendered_content.contains("# Configuration for MyApp"));
369        assert!(rendered_content.contains("port: 8080"));
370        assert!(rendered_content.contains(r#"name: "MyApp""#));
371        Ok(())
372    }
373
374    #[test]
375    fn it_should_handle_special_characters_in_template_name() -> Result<(), TemplateEngineError> {
376        let mut validator = TemplateEngine::new();
377        let template_content = "Hello {{name}}!";
378        let context = TestContext {
379            name: "World".to_string(),
380            value: 42,
381        };
382
383        // Test with special characters in template name
384        let rendered_content =
385            validator.render("template-with_special.chars", template_content, &context)?;
386
387        assert_eq!(rendered_content, "Hello World!");
388        Ok(())
389    }
390
391    #[test]
392    fn it_should_allow_extra_variables_in_context() -> Result<(), TemplateEngineError> {
393        let mut validator = TemplateEngine::new();
394        let template_content = "Hello {{name}}!"; // Only uses 'name' variable
395        let context = TestContext {
396            name: "World".to_string(),
397            value: 42, // This variable is not used in the template but should be allowed
398        };
399
400        let rendered_content =
401            validator.render("extra_vars_template", template_content, &context)?;
402
403        // Should render successfully and ignore the extra 'value' variable
404        assert_eq!(rendered_content, "Hello World!");
405        Ok(())
406    }
407
408    #[test]
409    fn it_should_allow_tera_delimiters_in_rendered_output() -> Result<(), TemplateEngineError> {
410        let mut validator = TemplateEngine::new();
411
412        // Simple template that outputs content containing Tera-like delimiters
413        // We use raw blocks to prevent Tera from parsing the output delimiters
414        let template_content =
415            "Hello {{name}}! Use {% raw %}{{ variable }}{% endraw %} in your config.";
416
417        let context = TestContext {
418            name: "World".to_string(),
419            value: 42,
420        };
421
422        let rendered_content =
423            validator.render("delimiter_template", template_content, &context)?;
424
425        // Should render successfully and contain delimiters in the final output
426        assert_eq!(
427            rendered_content,
428            "Hello World! Use {{ variable }} in your config."
429        );
430        Ok(())
431    }
432
433    #[test]
434    fn it_should_allow_all_tera_delimiter_types_in_output() -> Result<(), TemplateEngineError> {
435        #[derive(Serialize)]
436        struct SimpleContext {
437            app_name: String,
438        }
439
440        let mut validator = TemplateEngine::new();
441
442        // Template demonstrating all Tera delimiter types can appear in final output
443        let template_content = r"App: {{app_name}}
444Expression example: {% raw %}{{ expr }}{% endraw %}
445Statement example: {% raw %}{% if condition %}{% endraw %}
446Comment example: {% raw %}{# comment #}{% endraw %}";
447
448        let context = SimpleContext {
449            app_name: "MyApp".to_string(),
450        };
451
452        let rendered_content = validator.render("all_delimiters", template_content, &context)?;
453
454        // Should render successfully and preserve all delimiter types in output
455        assert!(rendered_content.contains("App: MyApp"));
456        assert!(rendered_content.contains("{{ expr }}"));
457        assert!(rendered_content.contains("{% if condition %}"));
458        assert!(rendered_content.contains("{# comment #}"));
459        Ok(())
460    }
461}