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) -> 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 { 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 { self.render_kind(id, locale, vars, "html") } pub fn render_text( &self, id: &str, locale: Option<&str>, vars: &serde_json::Value, ) -> Result { self.render_kind(id, locale, vars, "text") } fn render_kind( &self, id: &str, locale: Option<&str>, vars: &serde_json::Value, kind: &str, ) -> Result { 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("

Hi {{name}}

"), 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, "

Hi Alice

"); 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"), "

Hi {{name}}

").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, "

Hi A

"); } #[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); } }