use serde::Serialize;
use tera::{Context, Tera};
use crate::error::RenderError;
pub struct Engine {
tera: Tera,
}
impl Engine {
pub fn builder() -> EngineBuilder {
EngineBuilder::new()
}
pub fn register_template(&mut self, name: &str, body: &str) -> Result<(), RenderError> {
if contains_now_call(body) {
return Err(RenderError::NonDeterministicTemplate { name: name.into() });
}
self.tera
.add_raw_template(name, body)
.map_err(|source| RenderError::TemplateParse {
name: name.into(),
source,
})
}
pub fn render_value(
&self,
name: &str,
view: &serde_json::Value,
) -> Result<String, RenderError> {
let context = Context::from_value(view.clone()).map_err(|source| RenderError::Render {
name: name.into(),
source,
})?;
self.render_context(name, &context)
}
pub fn render<T: Serialize>(&self, name: &str, view: &T) -> Result<String, RenderError> {
let value =
serde_json::to_value(view).map_err(|source| RenderError::Serialize { source })?;
self.render_value(name, &value)
}
pub fn render_str<T: Serialize>(
&mut self,
body: &str,
view: &T,
) -> Result<String, RenderError> {
const INLINE_NAME: &str = "<inline>";
if contains_now_call(body) {
return Err(RenderError::NonDeterministicTemplate {
name: INLINE_NAME.into(),
});
}
let context = serialize_to_context(view).map_err(|source| RenderError::Render {
name: INLINE_NAME.into(),
source,
})?;
self.tera
.render_str(body, &context)
.map_err(|source| RenderError::Render {
name: INLINE_NAME.into(),
source,
})
}
pub fn register_helper<F>(&mut self, name: &str, function: F)
where
F: tera::Function + 'static,
{
self.tera.register_function(name, function);
}
fn render_context(&self, name: &str, context: &Context) -> Result<String, RenderError> {
if !self.tera.get_template_names().any(|n| n == name) {
return Err(RenderError::MissingTemplate { name: name.into() });
}
self.tera
.render(name, context)
.map_err(|source| RenderError::Render {
name: name.into(),
source,
})
}
}
pub struct EngineBuilder {
tera: Tera,
}
impl EngineBuilder {
fn new() -> Self {
let mut tera = Tera::default();
tera.autoescape_on(vec![]);
crate::filters::install_defaults(&mut tera);
Self { tera }
}
pub fn build(self) -> Engine {
Engine { tera: self.tera }
}
}
impl Default for EngineBuilder {
fn default() -> Self {
Self::new()
}
}
fn serialize_to_context<T: Serialize>(view: &T) -> Result<Context, tera::Error> {
let value = serde_json::to_value(view).map_err(tera::Error::json)?;
match value {
serde_json::Value::Null => Ok(Context::new()),
serde_json::Value::Object(_) => Context::from_value(value),
other => Err(tera::Error::msg(format!(
"render_str view must serialize to a JSON object or null, got {other:?}"
))),
}
}
fn contains_now_call(body: &str) -> bool {
let bytes = body.as_bytes();
let needle = b"now";
let mut i = 0usize;
while i + needle.len() <= bytes.len() {
if &bytes[i..i + needle.len()] == needle {
let prev_ok = i == 0 || !is_ident_char(bytes[i - 1]);
let mut j = i + needle.len();
while j < bytes.len() && matches!(bytes[j], b' ' | b'\t') {
j += 1;
}
let next_ok = j < bytes.len() && bytes[j] == b'(';
if prev_ok && next_ok {
return true;
}
}
i += 1;
}
false
}
fn is_ident_char(c: u8) -> bool {
c.is_ascii_alphanumeric() || c == b'_'
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Serialize;
#[derive(Serialize)]
struct Greeting {
name: String,
}
fn build() -> Engine {
Engine::builder().build()
}
#[test]
fn build_yields_engine_with_no_templates() {
let engine = build();
assert_eq!(engine.tera.get_template_names().count(), 0);
}
#[test]
fn register_then_render_value_round_trips() {
let mut engine = build();
engine
.register_template("hello", "Hello, {{ name }}!")
.unwrap();
let view = serde_json::json!({"name": "world"});
let out = engine.render_value("hello", &view).unwrap();
assert_eq!(out, "Hello, world!");
}
#[test]
fn render_struct_round_trips() {
let mut engine = build();
engine
.register_template("hello", "Hello, {{ name }}!")
.unwrap();
let view = Greeting {
name: "tera".into(),
};
let out = engine.render("hello", &view).unwrap();
assert_eq!(out, "Hello, tera!");
}
#[test]
fn missing_template_is_reported_by_name() {
let engine = build();
let err = engine
.render_value("absent", &serde_json::json!({}))
.unwrap_err();
match err {
RenderError::MissingTemplate { name } => assert_eq!(name, "absent"),
other => panic!("expected MissingTemplate, got {other:?}"),
}
}
#[test]
fn template_with_now_call_is_rejected() {
let mut engine = build();
let err = engine
.register_template("bad", "stamp: {{ now() }}")
.unwrap_err();
match err {
RenderError::NonDeterministicTemplate { name } => assert_eq!(name, "bad"),
other => panic!("expected NonDeterministicTemplate, got {other:?}"),
}
}
#[test]
fn template_with_now_call_and_whitespace_is_rejected() {
let mut engine = build();
let err = engine
.register_template("bad", "{{ now ( ) }}")
.unwrap_err();
assert!(matches!(err, RenderError::NonDeterministicTemplate { .. }));
}
#[test]
fn identifier_containing_now_substring_is_allowed() {
let mut engine = build();
engine
.register_template("snowflake", "Hello, {{ snowflake }}!")
.unwrap();
let view = serde_json::json!({"snowflake": "ok"});
let out = engine.render_value("snowflake", &view).unwrap();
assert_eq!(out, "Hello, ok!");
}
#[test]
fn template_parse_error_surfaces_name_and_source() {
let mut engine = build();
let err = engine.register_template("broken", "{% if %}").unwrap_err();
match err {
RenderError::TemplateParse { name, source } => {
assert_eq!(name, "broken");
let printed = format!("{source}");
assert!(
!printed.is_empty(),
"tera error message should not be empty"
);
}
other => panic!("expected TemplateParse, got {other:?}"),
}
}
#[test]
fn render_runtime_error_surfaces_name() {
let mut engine = build();
engine
.register_template("strict", "{{ value | upper }}")
.unwrap();
let err = engine
.render_value("strict", &serde_json::json!({"value": 42}))
.unwrap_err();
match err {
RenderError::Render { name, .. } => assert_eq!(name, "strict"),
other => panic!("expected Render, got {other:?}"),
}
}
#[test]
fn engine_does_not_auto_escape_html() {
let mut engine = build();
engine.register_template("md", "value: {{ raw }}").unwrap();
let view = serde_json::json!({"raw": "<b>bold</b>"});
let out = engine.render_value("md", &view).unwrap();
assert_eq!(out, "value: <b>bold</b>");
}
#[test]
fn render_str_uses_registered_helpers_and_view() {
let mut engine = build();
engine.register_helper(
"shout",
|args: &std::collections::HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let v = args
.get("v")
.and_then(|x| x.as_str())
.ok_or_else(|| tera::Error::msg("shout(): required arg `v`"))?;
Ok(tera::Value::String(v.to_uppercase()))
},
);
let out = engine
.render_str(
r#"hi {{ name }} / {{ shout(v="ok") }}"#,
&serde_json::json!({"name": "tera"}),
)
.unwrap();
assert_eq!(out, "hi tera / OK");
}
#[test]
fn render_str_rejects_now_call() {
let mut engine = build();
let err = engine
.render_str("stamp: {{ now() }}", &serde_json::json!({}))
.unwrap_err();
assert!(matches!(err, RenderError::NonDeterministicTemplate { .. }));
}
#[test]
fn register_helper_attaches_consumer_function() {
use std::collections::HashMap;
let mut engine = build();
engine.register_helper(
"shout",
|args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let v = args
.get("v")
.and_then(|x| x.as_str())
.ok_or_else(|| tera::Error::msg("shout(): required arg `v`"))?;
Ok(tera::Value::String(v.to_uppercase()))
},
);
engine
.register_template("greet", r#"hey, {{ shout(v="world") }}!"#)
.unwrap();
let out = engine
.render_value("greet", &serde_json::json!({}))
.unwrap();
assert_eq!(out, "hey, WORLD!");
}
}