Skip to main content

cratefield_core/
template.rs

1//! The template registry (issue #4): mail subjects and bodies with
2//! venture-level overrides and locale fallback.
3//!
4//! `Template` is a trait so ventures can use askama, `format!`, or anything
5//! else. Ids are `<module>/<template>` (`email-signup/confirm`); locale
6//! variants register as `<id>@<locale>` and are tried first.
7//!
8//! **Where defaults come from.** The locked `Module` trait (architecture
9//! section 4) has no `templates()` hook, so a module's default templates are
10//! its own code: modules ship `pub fn default_templates() ->
11//! Vec<(String, Box<dyn Template>)>` as a plain associated function, and the
12//! venture's `harness.rs` registers them before its overrides — or the
13//! module falls back to its built-in template when the registry misses.
14//! This keeps the trait exactly as specified in the architecture doc.
15
16use serde_json::Value;
17use std::collections::HashMap;
18use std::sync::Arc;
19use thiserror::Error;
20
21/// A rendered mail, ready for `Message`.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Rendered {
24    pub subject: String,
25    pub html: String,
26    pub text: String,
27}
28
29#[derive(Debug, Clone, Error)]
30pub enum TemplateError {
31    #[error("template {id:?} is not registered (locale {locale:?})")]
32    UnknownTemplate { id: String, locale: String },
33    #[error("template {id:?} failed to render: {reason}")]
34    RenderFailed { id: String, reason: String },
35}
36
37/// One template. `data` is the caller's JSON payload; `locale` is the
38/// requested locale tag (`en`, `de`, ...) used by the implementation for
39/// its own variants.
40pub trait Template: Send + Sync {
41    /// # Errors
42    ///
43    /// `Err` when the template cannot render `data` (missing fields, ...).
44    fn render(&self, data: &Value, locale: &str) -> Result<Rendered, TemplateError>;
45}
46
47/// Immutable registry: venture overrides are inserted after module defaults
48/// at `Harness::build`, so they win on id collision.
49#[derive(Clone, Default)]
50pub struct TemplateRegistry {
51    templates: HashMap<String, Arc<dyn Template>>,
52}
53
54impl TemplateRegistry {
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Inserts (or replaces) a template under `id` or `id@<locale>`.
60    pub fn register(&mut self, id: impl Into<String>, template: Box<dyn Template>) {
61        self.templates.insert(id.into(), Arc::from(template));
62    }
63
64    /// Registers every pair; later entries win on id collision, so call it
65    /// with overrides last.
66    pub fn register_all(
67        &mut self,
68        templates: impl IntoIterator<Item = (String, Box<dyn Template>)>,
69    ) {
70        for (id, template) in templates {
71            self.register(id, template);
72        }
73    }
74
75    /// Renders `<id>@<locale>` if present, else `<id>`.
76    ///
77    /// # Errors
78    ///
79    /// `UnknownTemplate` when neither id is registered; `RenderFailed`
80    /// when the chosen template cannot render `data`.
81    pub fn render(&self, id: &str, data: &Value, locale: &str) -> Result<Rendered, TemplateError> {
82        let localized = format!("{id}@{locale}");
83        if let Some(template) = self.templates.get(&localized) {
84            return template.render(data, locale);
85        }
86        match self.templates.get(id) {
87            Some(template) => template.render(data, locale),
88            None => Err(TemplateError::UnknownTemplate {
89                id: id.to_string(),
90                locale: locale.to_string(),
91            }),
92        }
93    }
94
95    /// Whether `id` (or `id@<locale>`) is registered.
96    pub fn contains(&self, id: &str, locale: &str) -> bool {
97        self.templates.contains_key(&format!("{id}@{locale}")) || self.templates.contains_key(id)
98    }
99}
100
101impl std::fmt::Debug for TemplateRegistry {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        let mut ids: Vec<&String> = self.templates.keys().collect();
104        ids.sort();
105        f.debug_struct("TemplateRegistry")
106            .field("ids", &ids)
107            .finish()
108    }
109}