Skip to main content

chorus_core/
template.rs

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