1use serde::Serialize;
2use tera::{Context, Tera};
3
4use crate::error::RenderError;
5
6pub struct Engine {
14 tera: Tera,
15}
16
17impl Engine {
18 pub fn builder() -> EngineBuilder {
22 EngineBuilder::new()
23 }
24
25 pub fn register_template(&mut self, name: &str, body: &str) -> Result<(), RenderError> {
30 if contains_now_call(body) {
31 return Err(RenderError::NonDeterministicTemplate { name: name.into() });
32 }
33 self.tera
34 .add_raw_template(name, body)
35 .map_err(|source| RenderError::TemplateParse {
36 name: name.into(),
37 source,
38 })
39 }
40
41 pub fn render_value(
45 &self,
46 name: &str,
47 view: &serde_json::Value,
48 ) -> Result<String, RenderError> {
49 let context = Context::from_value(view.clone()).map_err(|source| RenderError::Render {
50 name: name.into(),
51 source,
52 })?;
53 self.render_context(name, &context)
54 }
55
56 pub fn render<T: Serialize>(&self, name: &str, view: &T) -> Result<String, RenderError> {
62 let value =
63 serde_json::to_value(view).map_err(|source| RenderError::Serialize { source })?;
64 self.render_value(name, &value)
65 }
66
67 pub fn render_str<T: Serialize>(
74 &mut self,
75 body: &str,
76 view: &T,
77 ) -> Result<String, RenderError> {
78 const INLINE_NAME: &str = "<inline>";
79 if contains_now_call(body) {
80 return Err(RenderError::NonDeterministicTemplate {
81 name: INLINE_NAME.into(),
82 });
83 }
84 let context = serialize_to_context(view).map_err(|source| RenderError::Render {
85 name: INLINE_NAME.into(),
86 source,
87 })?;
88 self.tera
89 .render_str(body, &context)
90 .map_err(|source| RenderError::Render {
91 name: INLINE_NAME.into(),
92 source,
93 })
94 }
95
96 pub fn register_helper<F>(&mut self, name: &str, function: F)
101 where
102 F: tera::Function + 'static,
103 {
104 self.tera.register_function(name, function);
105 }
106
107 fn render_context(&self, name: &str, context: &Context) -> Result<String, RenderError> {
108 if !self.tera.get_template_names().any(|n| n == name) {
109 return Err(RenderError::MissingTemplate { name: name.into() });
110 }
111 self.tera
112 .render(name, context)
113 .map_err(|source| RenderError::Render {
114 name: name.into(),
115 source,
116 })
117 }
118}
119
120pub struct EngineBuilder {
124 tera: Tera,
125}
126
127impl EngineBuilder {
128 fn new() -> Self {
129 let mut tera = Tera::default();
130 tera.autoescape_on(vec![]);
131 crate::filters::install_defaults(&mut tera);
132 Self { tera }
133 }
134
135 pub fn build(self) -> Engine {
136 Engine { tera: self.tera }
137 }
138}
139
140impl Default for EngineBuilder {
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146fn serialize_to_context<T: Serialize>(view: &T) -> Result<Context, tera::Error> {
151 let value = serde_json::to_value(view).map_err(tera::Error::json)?;
152 match value {
153 serde_json::Value::Null => Ok(Context::new()),
154 serde_json::Value::Object(_) => Context::from_value(value),
155 other => Err(tera::Error::msg(format!(
156 "render_str view must serialize to a JSON object or null, got {other:?}"
157 ))),
158 }
159}
160
161fn contains_now_call(body: &str) -> bool {
162 let bytes = body.as_bytes();
163 let needle = b"now";
164 let mut i = 0usize;
165 while i + needle.len() <= bytes.len() {
166 if &bytes[i..i + needle.len()] == needle {
167 let prev_ok = i == 0 || !is_ident_char(bytes[i - 1]);
168 let mut j = i + needle.len();
169 while j < bytes.len() && matches!(bytes[j], b' ' | b'\t') {
170 j += 1;
171 }
172 let next_ok = j < bytes.len() && bytes[j] == b'(';
173 if prev_ok && next_ok {
174 return true;
175 }
176 }
177 i += 1;
178 }
179 false
180}
181
182fn is_ident_char(c: u8) -> bool {
183 c.is_ascii_alphanumeric() || c == b'_'
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use serde::Serialize;
190
191 #[derive(Serialize)]
192 struct Greeting {
193 name: String,
194 }
195
196 fn build() -> Engine {
197 Engine::builder().build()
198 }
199
200 #[test]
201 fn build_yields_engine_with_no_templates() {
202 let engine = build();
203 assert_eq!(engine.tera.get_template_names().count(), 0);
204 }
205
206 #[test]
207 fn register_then_render_value_round_trips() {
208 let mut engine = build();
209 engine
210 .register_template("hello", "Hello, {{ name }}!")
211 .unwrap();
212 let view = serde_json::json!({"name": "world"});
213 let out = engine.render_value("hello", &view).unwrap();
214 assert_eq!(out, "Hello, world!");
215 }
216
217 #[test]
218 fn render_struct_round_trips() {
219 let mut engine = build();
220 engine
221 .register_template("hello", "Hello, {{ name }}!")
222 .unwrap();
223 let view = Greeting {
224 name: "tera".into(),
225 };
226 let out = engine.render("hello", &view).unwrap();
227 assert_eq!(out, "Hello, tera!");
228 }
229
230 #[test]
231 fn missing_template_is_reported_by_name() {
232 let engine = build();
233 let err = engine
234 .render_value("absent", &serde_json::json!({}))
235 .unwrap_err();
236 match err {
237 RenderError::MissingTemplate { name } => assert_eq!(name, "absent"),
238 other => panic!("expected MissingTemplate, got {other:?}"),
239 }
240 }
241
242 #[test]
243 fn template_with_now_call_is_rejected() {
244 let mut engine = build();
245 let err = engine
246 .register_template("bad", "stamp: {{ now() }}")
247 .unwrap_err();
248 match err {
249 RenderError::NonDeterministicTemplate { name } => assert_eq!(name, "bad"),
250 other => panic!("expected NonDeterministicTemplate, got {other:?}"),
251 }
252 }
253
254 #[test]
255 fn template_with_now_call_and_whitespace_is_rejected() {
256 let mut engine = build();
257 let err = engine
258 .register_template("bad", "{{ now ( ) }}")
259 .unwrap_err();
260 assert!(matches!(err, RenderError::NonDeterministicTemplate { .. }));
261 }
262
263 #[test]
264 fn identifier_containing_now_substring_is_allowed() {
265 let mut engine = build();
266 engine
267 .register_template("snowflake", "Hello, {{ snowflake }}!")
268 .unwrap();
269 let view = serde_json::json!({"snowflake": "ok"});
270 let out = engine.render_value("snowflake", &view).unwrap();
271 assert_eq!(out, "Hello, ok!");
272 }
273
274 #[test]
275 fn template_parse_error_surfaces_name_and_source() {
276 let mut engine = build();
277 let err = engine.register_template("broken", "{% if %}").unwrap_err();
278 match err {
279 RenderError::TemplateParse { name, source } => {
280 assert_eq!(name, "broken");
281 let printed = format!("{source}");
282 assert!(
283 !printed.is_empty(),
284 "tera error message should not be empty"
285 );
286 }
287 other => panic!("expected TemplateParse, got {other:?}"),
288 }
289 }
290
291 #[test]
292 fn render_runtime_error_surfaces_name() {
293 let mut engine = build();
294 engine
295 .register_template("strict", "{{ value | upper }}")
296 .unwrap();
297 let err = engine
298 .render_value("strict", &serde_json::json!({"value": 42}))
299 .unwrap_err();
300 match err {
301 RenderError::Render { name, .. } => assert_eq!(name, "strict"),
302 other => panic!("expected Render, got {other:?}"),
303 }
304 }
305
306 #[test]
307 fn engine_does_not_auto_escape_html() {
308 let mut engine = build();
309 engine.register_template("md", "value: {{ raw }}").unwrap();
310 let view = serde_json::json!({"raw": "<b>bold</b>"});
311 let out = engine.render_value("md", &view).unwrap();
312 assert_eq!(out, "value: <b>bold</b>");
313 }
314
315 #[test]
316 fn render_str_uses_registered_helpers_and_view() {
317 let mut engine = build();
318 engine.register_helper(
319 "shout",
320 |args: &std::collections::HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
321 let v = args
322 .get("v")
323 .and_then(|x| x.as_str())
324 .ok_or_else(|| tera::Error::msg("shout(): required arg `v`"))?;
325 Ok(tera::Value::String(v.to_uppercase()))
326 },
327 );
328 let out = engine
329 .render_str(
330 r#"hi {{ name }} / {{ shout(v="ok") }}"#,
331 &serde_json::json!({"name": "tera"}),
332 )
333 .unwrap();
334 assert_eq!(out, "hi tera / OK");
335 }
336
337 #[test]
338 fn render_str_rejects_now_call() {
339 let mut engine = build();
340 let err = engine
341 .render_str("stamp: {{ now() }}", &serde_json::json!({}))
342 .unwrap_err();
343 assert!(matches!(err, RenderError::NonDeterministicTemplate { .. }));
344 }
345
346 #[test]
347 fn register_helper_attaches_consumer_function() {
348 use std::collections::HashMap;
349 let mut engine = build();
350 engine.register_helper(
351 "shout",
352 |args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
353 let v = args
354 .get("v")
355 .and_then(|x| x.as_str())
356 .ok_or_else(|| tera::Error::msg("shout(): required arg `v`"))?;
357 Ok(tera::Value::String(v.to_uppercase()))
358 },
359 );
360 engine
361 .register_template("greet", r#"hey, {{ shout(v="world") }}!"#)
362 .unwrap();
363 let out = engine
364 .render_value("greet", &serde_json::json!({}))
365 .unwrap();
366 assert_eq!(out, "hey, WORLD!");
367 }
368}