Skip to main content

romance_core/
template.rs

1use anyhow::{Context as _, Result};
2use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase, ToTitleCase};
3use romance_templates::Templates;
4use std::collections::HashMap;
5use tera::{Context, Tera, Value};
6
7pub struct TemplateEngine {
8    tera: Tera,
9}
10
11impl TemplateEngine {
12    pub fn new() -> Result<Self> {
13        let mut tera = Tera::default();
14
15        // Load only .tera templates into Tera (static files are accessed via get_raw())
16        for file in Templates::iter() {
17            let path = file.as_ref();
18            if !path.ends_with(".tera") {
19                continue;
20            }
21            if let Some(content) = Templates::get(path) {
22                let content_str = std::str::from_utf8(content.data.as_ref())?;
23                tera.add_raw_template(path, content_str)?;
24            }
25        }
26
27        // Register custom filters
28        tera.register_filter("snake_case", snake_case_filter);
29        tera.register_filter("pascal_case", pascal_case_filter);
30        tera.register_filter("camel_case", camel_case_filter);
31        tera.register_filter("plural", plural_filter);
32        tera.register_filter("title_case", title_case_filter);
33        tera.register_filter("rust_ident", rust_ident_filter);
34
35        Ok(TemplateEngine { tera })
36    }
37
38    pub fn render(&self, template_name: &str, context: &Context) -> Result<String> {
39        let result = self
40            .tera
41            .render(template_name, context)
42            .with_context(|| format!("Failed to render template '{}'", template_name))?;
43        Ok(result)
44    }
45
46    /// Read an embedded file as raw string without Tera rendering.
47    pub fn get_raw(&self, path: &str) -> Result<String> {
48        let content = Templates::get(path)
49            .with_context(|| format!("Embedded file '{}' not found", path))?;
50        let s = std::str::from_utf8(content.data.as_ref())
51            .with_context(|| format!("Invalid UTF-8 in '{}'", path))?;
52        Ok(s.to_string())
53    }
54}
55
56fn snake_case_filter(
57    value: &Value,
58    _args: &HashMap<String, Value>,
59) -> tera::Result<Value> {
60    match value.as_str() {
61        Some(s) => Ok(Value::String(s.to_snake_case())),
62        None => Err(tera::Error::msg("snake_case filter expects a string")),
63    }
64}
65
66fn pascal_case_filter(
67    value: &Value,
68    _args: &HashMap<String, Value>,
69) -> tera::Result<Value> {
70    match value.as_str() {
71        Some(s) => Ok(Value::String(s.to_pascal_case())),
72        None => Err(tera::Error::msg("pascal_case filter expects a string")),
73    }
74}
75
76fn camel_case_filter(
77    value: &Value,
78    _args: &HashMap<String, Value>,
79) -> tera::Result<Value> {
80    match value.as_str() {
81        Some(s) => Ok(Value::String(s.to_lower_camel_case())),
82        None => Err(tera::Error::msg("camel_case filter expects a string")),
83    }
84}
85
86fn plural_filter(
87    value: &Value,
88    _args: &HashMap<String, Value>,
89) -> tera::Result<Value> {
90    match value.as_str() {
91        Some(s) => Ok(Value::String(crate::utils::pluralize(s))),
92        None => Err(tera::Error::msg("plural filter expects a string")),
93    }
94}
95
96fn title_case_filter(
97    value: &Value,
98    _args: &HashMap<String, Value>,
99) -> tera::Result<Value> {
100    match value.as_str() {
101        Some(s) => Ok(Value::String(s.to_title_case())),
102        None => Err(tera::Error::msg("title_case filter expects a string")),
103    }
104}
105
106fn rust_ident_filter(
107    value: &Value,
108    _args: &HashMap<String, Value>,
109) -> tera::Result<Value> {
110    match value.as_str() {
111        Some(s) => Ok(Value::String(crate::utils::rust_ident(s))),
112        None => Err(tera::Error::msg("rust_ident filter expects a string")),
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    // ── Filter unit tests (direct function calls) ─────────────────────
121
122    fn val(s: &str) -> Value {
123        Value::String(s.to_string())
124    }
125    fn empty_args() -> HashMap<String, Value> {
126        HashMap::new()
127    }
128
129    #[test]
130    fn snake_case_filter_works() {
131        let result = snake_case_filter(&val("ProductCategory"), &empty_args()).unwrap();
132        assert_eq!(result.as_str().unwrap(), "product_category");
133    }
134
135    #[test]
136    fn snake_case_filter_single_word() {
137        let result = snake_case_filter(&val("Post"), &empty_args()).unwrap();
138        assert_eq!(result.as_str().unwrap(), "post");
139    }
140
141    #[test]
142    fn pascal_case_filter_works() {
143        let result = pascal_case_filter(&val("product_category"), &empty_args()).unwrap();
144        assert_eq!(result.as_str().unwrap(), "ProductCategory");
145    }
146
147    #[test]
148    fn pascal_case_filter_from_snake() {
149        let result = pascal_case_filter(&val("blog_post"), &empty_args()).unwrap();
150        assert_eq!(result.as_str().unwrap(), "BlogPost");
151    }
152
153    #[test]
154    fn camel_case_filter_works() {
155        let result = camel_case_filter(&val("ProductCategory"), &empty_args()).unwrap();
156        assert_eq!(result.as_str().unwrap(), "productCategory");
157    }
158
159    #[test]
160    fn camel_case_filter_from_snake() {
161        let result = camel_case_filter(&val("blog_post"), &empty_args()).unwrap();
162        assert_eq!(result.as_str().unwrap(), "blogPost");
163    }
164
165    #[test]
166    fn plural_filter_regular() {
167        let result = plural_filter(&val("post"), &empty_args()).unwrap();
168        assert_eq!(result.as_str().unwrap(), "posts");
169    }
170
171    #[test]
172    fn plural_filter_category() {
173        let result = plural_filter(&val("Category"), &empty_args()).unwrap();
174        assert_eq!(result.as_str().unwrap(), "Categories");
175    }
176
177    #[test]
178    fn plural_filter_box() {
179        let result = plural_filter(&val("box"), &empty_args()).unwrap();
180        assert_eq!(result.as_str().unwrap(), "boxes");
181    }
182
183    #[test]
184    fn title_case_filter_works() {
185        let result = title_case_filter(&val("product_category"), &empty_args()).unwrap();
186        assert_eq!(result.as_str().unwrap(), "Product Category");
187    }
188
189    #[test]
190    fn rust_ident_filter_works() {
191        let result = rust_ident_filter(&val("type"), &empty_args()).unwrap();
192        assert_eq!(result.as_str().unwrap(), "r#type");
193    }
194
195    #[test]
196    fn rust_ident_filter_non_reserved() {
197        let result = rust_ident_filter(&val("title"), &empty_args()).unwrap();
198        assert_eq!(result.as_str().unwrap(), "title");
199    }
200
201    // ── Filter error on non-string ────────────────────────────────────
202
203    #[test]
204    fn snake_case_filter_rejects_non_string() {
205        let num = Value::Number(serde_json::Number::from(42));
206        assert!(snake_case_filter(&num, &empty_args()).is_err());
207    }
208
209    #[test]
210    fn pascal_case_filter_rejects_non_string() {
211        let num = Value::Number(serde_json::Number::from(42));
212        assert!(pascal_case_filter(&num, &empty_args()).is_err());
213    }
214
215    #[test]
216    fn camel_case_filter_rejects_non_string() {
217        let num = Value::Number(serde_json::Number::from(42));
218        assert!(camel_case_filter(&num, &empty_args()).is_err());
219    }
220
221    #[test]
222    fn plural_filter_rejects_non_string() {
223        let num = Value::Number(serde_json::Number::from(42));
224        assert!(plural_filter(&num, &empty_args()).is_err());
225    }
226
227    // ── TemplateEngine creation ───────────────────────────────────────
228
229    #[test]
230    fn template_engine_creates_successfully() {
231        let _engine = TemplateEngine::new().unwrap();
232    }
233
234    // ── Rendering with custom filters via the engine ──────────────────
235
236    #[test]
237    fn render_inline_template_with_snake_case() {
238        let mut tera = Tera::default();
239        tera.register_filter("snake_case", snake_case_filter);
240        tera.add_raw_template("test", "{{ name | snake_case }}").unwrap();
241        let mut ctx = Context::new();
242        ctx.insert("name", "ProductCategory");
243        let result = tera.render("test", &ctx).unwrap();
244        assert_eq!(result, "product_category");
245    }
246
247    #[test]
248    fn render_inline_template_with_plural() {
249        let mut tera = Tera::default();
250        tera.register_filter("plural", plural_filter);
251        tera.add_raw_template("test", "{{ name | plural }}").unwrap();
252        let mut ctx = Context::new();
253        ctx.insert("name", "Category");
254        let result = tera.render("test", &ctx).unwrap();
255        assert_eq!(result, "Categories");
256    }
257
258    #[test]
259    fn render_inline_template_with_multiple_filters() {
260        let mut tera = Tera::default();
261        tera.register_filter("snake_case", snake_case_filter);
262        tera.register_filter("pascal_case", pascal_case_filter);
263        tera.register_filter("camel_case", camel_case_filter);
264        tera.add_raw_template(
265            "test",
266            "snake={{ name | snake_case }} pascal={{ name | pascal_case }} camel={{ name | camel_case }}",
267        )
268        .unwrap();
269        let mut ctx = Context::new();
270        ctx.insert("name", "blog_post");
271        let result = tera.render("test", &ctx).unwrap();
272        assert_eq!(result, "snake=blog_post pascal=BlogPost camel=blogPost");
273    }
274
275    #[test]
276    fn render_inline_template_with_context_variables() {
277        let mut tera = Tera::default();
278        tera.add_raw_template("test", "Hello {{ name }}, port {{ port }}").unwrap();
279        let mut ctx = Context::new();
280        ctx.insert("name", "Romance");
281        ctx.insert("port", &3000);
282        let result = tera.render("test", &ctx).unwrap();
283        assert_eq!(result, "Hello Romance, port 3000");
284    }
285}