Skip to main content

chorus_core/
template.rs

1use crate::error::ChorusError;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// An email template with `{{variable}}` placeholders.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Template {
8    pub slug: String,
9    pub name: String,
10    pub subject: String,
11    pub html_body: String,
12    pub text_body: String,
13    pub variables: Vec<String>,
14}
15
16impl Template {
17    /// Renders the template by replacing `{{variable}}` placeholders with provided values.
18    /// Variables not found in the map are left as-is.
19    pub fn render(
20        &self,
21        variables: &HashMap<String, String>,
22    ) -> Result<RenderedTemplate, ChorusError> {
23        let subject = Self::replace_vars(&self.subject, variables);
24        let html_body = Self::replace_vars(&self.html_body, variables);
25        let text_body = Self::replace_vars(&self.text_body, variables);
26
27        Ok(RenderedTemplate {
28            subject,
29            html_body,
30            text_body,
31        })
32    }
33
34    fn replace_vars(text: &str, variables: &HashMap<String, String>) -> String {
35        let mut result = text.to_string();
36        for (key, value) in variables {
37            result = result.replace(&format!("{{{{{}}}}}", key), value);
38        }
39        result
40    }
41}
42
43/// The result of rendering a [`Template`] with variable values.
44#[derive(Debug, Clone)]
45pub struct RenderedTemplate {
46    pub subject: String,
47    pub html_body: String,
48    pub text_body: String,
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    fn test_template() -> Template {
56        Template {
57            slug: "otp".to_string(),
58            name: "OTP Email".to_string(),
59            subject: "Your {{app_name}} code".to_string(),
60            html_body: "<p>Code: <strong>{{code}}</strong>. Expires in {{expire}} min.</p>"
61                .to_string(),
62            text_body: "Code: {{code}}. Expires in {{expire}} min.".to_string(),
63            variables: vec![
64                "code".to_string(),
65                "app_name".to_string(),
66                "expire".to_string(),
67            ],
68        }
69    }
70
71    #[test]
72    fn render_replaces_all_variables() {
73        let tmpl = test_template();
74        let mut vars = HashMap::new();
75        vars.insert("code".to_string(), "123456".to_string());
76        vars.insert("app_name".to_string(), "Orbit".to_string());
77        vars.insert("expire".to_string(), "5".to_string());
78
79        let rendered = tmpl.render(&vars).unwrap();
80        assert_eq!(rendered.subject, "Your Orbit code");
81        assert!(rendered.html_body.contains("<strong>123456</strong>"));
82        assert!(rendered.text_body.contains("123456"));
83        assert!(rendered.text_body.contains("5 min"));
84    }
85
86    #[test]
87    fn render_leaves_unknown_vars_as_is() {
88        let tmpl = test_template();
89        let vars = HashMap::new();
90        let rendered = tmpl.render(&vars).unwrap();
91        assert!(rendered.subject.contains("{{app_name}}"));
92    }
93
94    #[test]
95    fn render_handles_repeated_variable() {
96        let tmpl = Template {
97            slug: "test".into(),
98            name: "Test".into(),
99            subject: "{{code}} is your code {{code}}".into(),
100            html_body: "".into(),
101            text_body: "".into(),
102            variables: vec!["code".into()],
103        };
104        let mut vars = HashMap::new();
105        vars.insert("code".into(), "999".into());
106        let rendered = tmpl.render(&vars).unwrap();
107        assert_eq!(rendered.subject, "999 is your code 999");
108    }
109
110    #[test]
111    fn render_empty_template() {
112        let tmpl = Template {
113            slug: "empty".into(),
114            name: "Empty".into(),
115            subject: "".into(),
116            html_body: "".into(),
117            text_body: "".into(),
118            variables: vec![],
119        };
120        let rendered = tmpl.render(&HashMap::new()).unwrap();
121        assert_eq!(rendered.subject, "");
122        assert_eq!(rendered.html_body, "");
123        assert_eq!(rendered.text_body, "");
124    }
125
126    #[test]
127    fn render_no_placeholders() {
128        let tmpl = Template {
129            slug: "plain".into(),
130            name: "Plain".into(),
131            subject: "Welcome!".into(),
132            html_body: "<p>Hello world</p>".into(),
133            text_body: "Hello world".into(),
134            variables: vec![],
135        };
136        let rendered = tmpl.render(&HashMap::new()).unwrap();
137        assert_eq!(rendered.subject, "Welcome!");
138        assert_eq!(rendered.text_body, "Hello world");
139    }
140
141    #[test]
142    fn render_with_special_characters_in_value() {
143        let tmpl = Template {
144            slug: "test".into(),
145            name: "Test".into(),
146            subject: "Hello {{name}}".into(),
147            html_body: "<p>{{name}}</p>".into(),
148            text_body: "{{name}}".into(),
149            variables: vec!["name".into()],
150        };
151        let mut vars = HashMap::new();
152        vars.insert("name".into(), "O'Brien <script>".into());
153        let rendered = tmpl.render(&vars).unwrap();
154        assert_eq!(rendered.subject, "Hello O'Brien <script>");
155        assert!(rendered.html_body.contains("O'Brien <script>"));
156    }
157}