Skip to main content

chorus_core/
template.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::ChorusError;
6
7/// A reusable message template with variable placeholders.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Template {
10    /// Unique identifier for looking up this template.
11    pub slug: String,
12    /// Human-readable template name.
13    pub name: String,
14    /// Subject line template (rendered with variables).
15    pub subject: String,
16    /// HTML body template.
17    pub html_body: String,
18    /// Plain text body template.
19    pub text_body: String,
20    /// List of expected variable names (for documentation/validation).
21    pub variables: Vec<String>,
22}
23
24/// The result of rendering a template with variables.
25#[derive(Debug, Clone)]
26pub struct RenderedTemplate {
27    pub subject: String,
28    pub html_body: String,
29    pub text_body: String,
30}
31
32impl Template {
33    /// Renders the template by replacing placeholders with provided values.
34    ///
35    /// Supports Jinja2 syntax: `{{ variable }}`, `{% if %}`, `{% for %}`, filters.
36    /// Simple `{{variable}}` from prior versions remains compatible.
37    pub fn render(
38        &self,
39        variables: &HashMap<String, String>,
40    ) -> Result<RenderedTemplate, ChorusError> {
41        let subject = render_string(&self.subject, variables)?;
42        let html_body = render_string(&self.html_body, variables)?;
43        let text_body = render_string(&self.text_body, variables)?;
44
45        Ok(RenderedTemplate {
46            subject,
47            html_body,
48            text_body,
49        })
50    }
51}
52
53/// Render a single template string with the given variables.
54fn render_string(
55    template: &str,
56    variables: &HashMap<String, String>,
57) -> Result<String, ChorusError> {
58    let env = minijinja::Environment::new();
59    let ctx = minijinja::value::Value::from_serialize(variables);
60    env.render_str(template, ctx)
61        .map_err(|e| ChorusError::Validation(format!("template render error: {}", e)))
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    fn make_template(subject: &str, html: &str, text: &str) -> Template {
69        Template {
70            slug: "test".into(),
71            name: "Test".into(),
72            subject: subject.into(),
73            html_body: html.into(),
74            text_body: text.into(),
75            variables: vec![],
76        }
77    }
78
79    #[test]
80    fn renders_simple_variables() {
81        let t = make_template(
82            "Hello {{ name }}",
83            "<p>Hi {{ name }}, code: {{ code }}</p>",
84            "Hi {{ name }}, code: {{ code }}",
85        );
86        let vars = HashMap::from([
87            ("name".into(), "Alice".into()),
88            ("code".into(), "123456".into()),
89        ]);
90        let r = t.render(&vars).unwrap();
91        assert_eq!(r.subject, "Hello Alice");
92        assert_eq!(r.html_body, "<p>Hi Alice, code: 123456</p>");
93        assert_eq!(r.text_body, "Hi Alice, code: 123456");
94    }
95
96    #[test]
97    fn undefined_variables_render_empty() {
98        let t = make_template("{{ missing }}", "", "");
99        let r = t.render(&HashMap::new()).unwrap();
100        assert_eq!(r.subject, "");
101    }
102
103    #[test]
104    fn repeated_variables() {
105        let t = make_template("{{ x }} and {{ x }}", "", "");
106        let vars = HashMap::from([("x".into(), "hi".into())]);
107        let r = t.render(&vars).unwrap();
108        assert_eq!(r.subject, "hi and hi");
109    }
110
111    #[test]
112    fn empty_template() {
113        let t = make_template("", "", "");
114        let r = t.render(&HashMap::new()).unwrap();
115        assert_eq!(r.subject, "");
116    }
117
118    #[test]
119    fn no_placeholders() {
120        let t = make_template("Hello world", "<p>Hi</p>", "Hi");
121        let r = t.render(&HashMap::new()).unwrap();
122        assert_eq!(r.subject, "Hello world");
123    }
124
125    #[test]
126    fn if_else_conditional() {
127        let t = make_template(
128            "{% if name %}Hi {{ name }}{% else %}Hi there{% endif %}",
129            "",
130            "",
131        );
132        let with_name = HashMap::from([("name".into(), "Bob".into())]);
133        assert_eq!(t.render(&with_name).unwrap().subject, "Hi Bob");
134
135        let without_name = HashMap::new();
136        assert_eq!(t.render(&without_name).unwrap().subject, "Hi there");
137    }
138
139    #[test]
140    fn for_loop() {
141        let env = minijinja::Environment::new();
142        let ctx = minijinja::context! { items => vec!["a", "b", "c"] };
143        let result = env
144            .render_str("{% for item in items %}{{ item }} {% endfor %}", ctx)
145            .unwrap();
146        assert_eq!(result, "a b c ");
147    }
148
149    #[test]
150    fn default_filter() {
151        let t = make_template("{{ name | default('Guest') }}", "", "");
152        let r = t.render(&HashMap::new()).unwrap();
153        assert_eq!(r.subject, "Guest");
154    }
155
156    #[test]
157    fn special_characters_in_values() {
158        let t = make_template("{{ val }}", "", "");
159        let vars = HashMap::from([("val".into(), "<script>alert('xss')</script>".into())]);
160        let r = t.render(&vars).unwrap();
161        // minijinja auto-escapes HTML in templates
162        assert!(r.subject.contains("&lt;script&gt;") || r.subject.contains("<script>"));
163    }
164}