mod dummy;
mod jinja2;
mod rjango_backend;
use minijinja::{Environment, ErrorKind, Value};
use super::defaultfilters::register_default_filters;
pub use dummy::DummyBackend;
pub use jinja2::Jinja2;
pub use rjango_backend::RjangoTemplates;
pub trait TemplateBackend: Send + Sync {
fn from_string(&self, template_string: &str) -> Result<Template, TemplateError>;
fn get_template(&self, template_name: &str) -> Result<Template, TemplateError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Template {
pub name: String,
pub source: String,
}
impl Template {
#[must_use]
pub fn new(name: impl Into<String>, source: impl Into<String>) -> Self {
Self {
name: name.into(),
source: source.into(),
}
}
pub fn render(&self, context: &Value) -> Result<String, TemplateError> {
let mut env = Environment::new();
register_default_filters(&mut env);
env.add_template_owned(self.name.clone(), self.source.clone())
.map_err(map_minijinja_error)?;
env.get_template(&self.name)
.map_err(map_minijinja_error)?
.render(context.clone())
.map_err(map_minijinja_error)
}
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum TemplateError {
#[error("template not found: {0}")]
NotFound(String),
#[error("template syntax error: {0}")]
SyntaxError(String),
#[error("render error: {0}")]
RenderError(String),
}
pub(crate) fn validate_template_source(name: &str, source: &str) -> Result<(), TemplateError> {
let mut env = Environment::new();
register_default_filters(&mut env);
env.add_template_owned(name.to_string(), source.to_string())
.map(|_| ())
.map_err(map_minijinja_error)
}
pub(crate) fn map_minijinja_error(error: minijinja::Error) -> TemplateError {
match error.kind() {
ErrorKind::TemplateNotFound => TemplateError::NotFound(error.to_string()),
ErrorKind::SyntaxError => TemplateError::SyntaxError(error.to_string()),
_ => TemplateError::RenderError(error.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::{DummyBackend, Jinja2, RjangoTemplates, Template, TemplateBackend, TemplateError};
use minijinja::{Value, context};
use std::{
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new(prefix: &str) -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!(
"rjango-template-backends-{prefix}-{}-{nanos}",
std::process::id()
));
fs::create_dir_all(&path).expect("test directory should be created");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
fn write_file(&self, relative_path: &str, contents: &str) {
let path = self.path.join(relative_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("parent directory should be created");
}
fs::write(path, contents).expect("test file should be written");
}
}
impl Drop for TestDir {
fn drop(&mut self) {
match fs::remove_dir_all(&self.path) {
Ok(()) | Err(_) => {}
}
}
}
fn render_with_backend(
backend: &dyn TemplateBackend,
source: &str,
context: &Value,
) -> Result<String, TemplateError> {
backend.from_string(source)?.render(context)
}
#[test]
fn template_construction_preserves_fields() {
let template = Template::new("hello.html", "Hello {{ name }}!");
assert_eq!(template.name, "hello.html");
assert_eq!(template.source, "Hello {{ name }}!");
}
#[test]
fn template_render_renders_context_values() {
let template = Template::new("hello.html", "Hello {{ name }}!");
let rendered = template
.render(&context!(name => "World"))
.expect("template should render");
assert_eq!(rendered, "Hello World!");
}
#[test]
fn template_render_registers_default_filters() {
let template = Template::new("filters.html", "{{ name|upper }} {{ title|slugify }}");
let rendered = template
.render(&context!(name => "rjango", title => "Hello Rust World"))
.expect("template should render with default filters");
assert_eq!(rendered, "RJANGO hello-rust-world");
}
#[test]
fn template_render_reports_syntax_errors() {
let template = Template::new("broken.html", "{% if user %}");
let error = template
.render(&Value::from(()))
.expect_err("invalid template syntax should error");
assert!(matches!(error, TemplateError::SyntaxError(message) if !message.is_empty()));
}
#[test]
fn template_render_reports_missing_includes_as_not_found() {
let template = Template::new("runtime.html", "{% include \"missing.html\" %}");
let error = template
.render(&Value::from(()))
.expect_err("missing include should error");
assert!(
matches!(error, TemplateError::NotFound(message) if message.contains("missing.html"))
);
}
#[test]
fn template_render_division_by_zero_matches_minijinja_behavior() {
let template = Template::new("runtime.html", "{{ 1 / 0 }}");
let rendered = template
.render(&Value::from(()))
.expect("MiniJinja currently renders floating infinity for division by zero");
assert_eq!(rendered, "inf");
}
#[test]
fn template_error_display_messages_are_human_readable() {
assert_eq!(
TemplateError::NotFound("missing.html".to_string()).to_string(),
"template not found: missing.html"
);
assert_eq!(
TemplateError::SyntaxError("bad syntax".to_string()).to_string(),
"template syntax error: bad syntax"
);
assert_eq!(
TemplateError::RenderError("boom".to_string()).to_string(),
"render error: boom"
);
}
#[test]
fn rjango_backend_from_string_returns_renderable_template() {
let backend = RjangoTemplates::default();
let rendered = backend
.from_string("Hello {{ name }}!")
.expect("template should compile")
.render(&context!(name => "World"))
.expect("template should render");
assert_eq!(rendered, "Hello World!");
}
#[test]
fn template_backend_trait_supports_rjango_backend() {
let backend = RjangoTemplates::default();
let rendered =
render_with_backend(&backend, "{{ name|upper }}", &context!(name => "rjango"))
.expect("trait object should render through backend");
assert_eq!(rendered, "RJANGO");
}
#[test]
fn rjango_backend_get_template_loads_from_template_dirs() {
let dir = TestDir::new("rjango-dir");
dir.write_file("hello.html", "Hello {{ name }}!");
let backend = RjangoTemplates::new(vec![dir.path().to_path_buf()], false);
let rendered = backend
.get_template("hello.html")
.expect("template should load")
.render(&context!(name => "Filesystem"))
.expect("loaded template should render");
assert_eq!(rendered, "Hello Filesystem!");
}
#[test]
fn rjango_backend_app_dirs_searches_templates_subdirectory() {
let dir = TestDir::new("rjango-app-dirs");
dir.write_file("templates/page.html", "Page for {{ name }}");
let backend = RjangoTemplates::new(vec![dir.path().to_path_buf()], true);
let rendered = backend
.get_template("page.html")
.expect("template should load from templates subdirectory")
.render(&context!(name => "apps"))
.expect("loaded template should render");
assert_eq!(rendered, "Page for apps");
}
#[test]
fn rjango_backend_reports_missing_templates() {
let backend = RjangoTemplates::default();
let error = backend
.get_template("missing.html")
.expect_err("missing template should error");
assert!(matches!(error, TemplateError::NotFound(name) if name == "missing.html"));
}
#[test]
fn jinja2_backend_app_dirs_searches_jinja2_subdirectory() {
let dir = TestDir::new("jinja2-app-dirs");
dir.write_file("jinja2/page.html", "Jinja2 says hi to {{ name }}");
let backend = Jinja2::new(vec![dir.path().to_path_buf()], true);
let rendered = backend
.get_template("page.html")
.expect("template should load from jinja2 subdirectory")
.render(&context!(name => "World"))
.expect("loaded template should render");
assert_eq!(rendered, "Jinja2 says hi to World");
}
#[test]
fn dummy_backend_returns_empty_templates() {
let backend = DummyBackend::default();
let template = backend
.get_template("ignored.html")
.expect("dummy backend should return empty template");
assert_eq!(template.name, "ignored.html");
assert!(template.source.is_empty());
assert_eq!(
template
.render(&Value::from(()))
.expect("empty template should render"),
""
);
}
#[test]
fn dummy_backend_returns_configured_errors() {
let backend = DummyBackend::with_error(TemplateError::NotFound("dummy.html".to_string()));
let error = backend
.from_string("Hello {{ name }}")
.expect_err("dummy backend should return configured error");
assert!(matches!(error, TemplateError::NotFound(name) if name == "dummy.html"));
}
}