d5939db
use std::path::Path;
use handlebars::Handlebars;
use crate::error::EmailError;
pub struct TemplateRegistry {
inner: Handlebars<'static>,
default_locale: String,
}
fn key(id: &str, locale: &str, kind: &str) -> String {
format!("{id}__{locale}__{kind}")
}
impl TemplateRegistry {
pub fn new(default_locale: impl Into<String>) -> Self {
let mut hb = Handlebars::new();
hb.set_strict_mode(true);
Self {
inner: hb,
default_locale: default_locale.into(),
}
}
pub fn default_locale(&self) -> &str {
&self.default_locale
}
pub fn add_template(
&mut self,
id: &str,
locale: &str,
html: Option<&str>,
text: Option<&str>,
) -> Result<(), EmailError> {
if let Some(h) = html {
self.inner
.register_template_string(&key(id, locale, "html"), h)?;
}
if let Some(t) = text {
self.inner
.register_template_string(&key(id, locale, "text"), t)?;
}
Ok(())
}
pub fn load_dir(&mut self, dir: &Path) -> Result<usize, EmailError> {
if !dir.is_dir() {
return Err(EmailError::Config(format!(
"template_dir is not a directory: {}",
dir.display()
)));
}
let mut loaded = 0usize;
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Some((stem, kind)) = split_template_filename(file_name) else {
continue;
};
let Some((id, locale)) = split_id_locale(stem) else {
continue;
};
let body = std::fs::read_to_string(&path)?;
self.add_template(
id,
locale,
(kind == "html").then_some(body.as_str()),
(kind == "text").then_some(body.as_str()),
)?;
loaded += 1;
}
Ok(loaded)
}
pub fn render_html(
&self,
id: &str,
locale: Option<&str>,
vars: &serde_json::Value,
) -> Result<String, EmailError> {
self.render_kind(id, locale, vars, "html")
}
pub fn render_text(
&self,
id: &str,
locale: Option<&str>,
vars: &serde_json::Value,
) -> Result<String, EmailError> {
self.render_kind(id, locale, vars, "text")
}
fn render_kind(
&self,
id: &str,
locale: Option<&str>,
vars: &serde_json::Value,
kind: &str,
) -> Result<String, EmailError> {
let try_locales: Vec<&str> = locale
.map(|l| vec![l, &self.default_locale])
.unwrap_or_else(|| vec![&self.default_locale]);
for loc in &try_locales {
let k = key(id, loc, kind);
if self.inner.has_template(&k) {
return Ok(self.inner.render(&k, vars)?);
}
}
Err(EmailError::TemplateNotFound(format!(
"{id}/{kind} (tried locales: {try_locales:?})"
)))
}
}
fn split_template_filename(name: &str) -> Option<(&str, &str)> {
let dot = name.rfind('.')?;
let kind = &name[dot + 1..];
if kind != "html" && kind != "txt" {
return None;
}
let mapped = if kind == "txt" { "text" } else { kind };
Some((&name[..dot], mapped))
}
fn split_id_locale(stem: &str) -> Option<(&str, &str)> {
let dot = stem.rfind('.')?;
Some((&stem[..dot], &stem[dot + 1..]))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn register_and_render_html_and_text() {
let mut r = TemplateRegistry::new("en");
r.add_template(
"welcome",
"en",
Some("<h1>Hi {{name}}</h1>"),
Some("Hi {{name}}"),
)
.unwrap();
let html = r
.render_html("welcome", Some("en"), &json!({"name":"Alice"}))
.unwrap();
let text = r
.render_text("welcome", Some("en"), &json!({"name":"Alice"}))
.unwrap();
assert_eq!(html, "<h1>Hi Alice</h1>");
assert_eq!(text, "Hi Alice");
}
#[test]
fn locale_fallback() {
let mut r = TemplateRegistry::new("en");
r.add_template("welcome", "en", Some("Hi {{name}}"), None)
.unwrap();
let out = r
.render_html("welcome", Some("de"), &json!({"name":"A"}))
.unwrap();
assert_eq!(out, "Hi A");
}
#[test]
fn template_not_found() {
let r = TemplateRegistry::new("en");
let err = r.render_html("missing", None, &json!({})).unwrap_err();
assert!(matches!(err, EmailError::TemplateNotFound(_)));
}
#[test]
fn load_dir_loads_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("welcome.en.html"), "<p>Hi {{name}}</p>").unwrap();
std::fs::write(dir.path().join("welcome.en.txt"), "Hi {{name}}").unwrap();
std::fs::write(dir.path().join("ignored.txt"), "ignored").unwrap();
let mut r = TemplateRegistry::new("en");
let loaded = r.load_dir(dir.path()).unwrap();
assert_eq!(loaded, 2);
let html = r
.render_html("welcome", Some("en"), &json!({"name":"A"}))
.unwrap();
assert_eq!(html, "<p>Hi A</p>");
}
#[test]
fn split_template_filename_works() {
assert_eq!(split_template_filename("a.en.html"), Some(("a.en", "html")));
assert_eq!(split_template_filename("a.en.txt"), Some(("a.en", "text")));
assert_eq!(split_template_filename("a.b.css"), None);
}
#[test]
fn split_id_locale_works() {
assert_eq!(split_id_locale("welcome.en"), Some(("welcome", "en")));
assert_eq!(split_id_locale("welcome"), None);
}
}