rustapi_view/
templates.rs

1//! Template engine wrapper
2
3use crate::ViewError;
4use std::sync::Arc;
5use tera::Tera;
6use tokio::sync::RwLock;
7
8/// Configuration for the template engine
9#[derive(Debug, Clone)]
10pub struct TemplatesConfig {
11    /// Glob pattern for template files
12    pub glob: String,
13    /// Whether to auto-reload templates on change (development mode)
14    pub auto_reload: bool,
15    /// Whether to fail on undefined variables
16    pub strict_mode: bool,
17}
18
19impl Default for TemplatesConfig {
20    fn default() -> Self {
21        Self {
22            glob: "templates/**/*.html".to_string(),
23            auto_reload: cfg!(debug_assertions),
24            strict_mode: false,
25        }
26    }
27}
28
29impl TemplatesConfig {
30    /// Create a new config with the given glob pattern
31    pub fn new(glob: impl Into<String>) -> Self {
32        Self {
33            glob: glob.into(),
34            ..Default::default()
35        }
36    }
37
38    /// Set auto-reload behavior
39    pub fn auto_reload(mut self, enabled: bool) -> Self {
40        self.auto_reload = enabled;
41        self
42    }
43
44    /// Set strict mode (fail on undefined variables)
45    pub fn strict_mode(mut self, enabled: bool) -> Self {
46        self.strict_mode = enabled;
47        self
48    }
49}
50
51/// Template engine wrapper providing thread-safe template rendering
52///
53/// This type wraps the Tera template engine and can be shared across
54/// handlers via `State<Templates>`.
55///
56/// # Example
57///
58/// ```rust,ignore
59/// use rustapi_view::Templates;
60///
61/// let templates = Templates::new("templates/**/*.html")?;
62/// ```
63#[derive(Clone)]
64pub struct Templates {
65    inner: Arc<RwLock<Tera>>,
66    config: TemplatesConfig,
67}
68
69impl Templates {
70    /// Create a new template engine from a glob pattern
71    ///
72    /// The glob pattern specifies which files to load as templates.
73    /// Common patterns:
74    /// - `templates/**/*.html` - All HTML files in templates directory
75    /// - `views/*.tera` - All .tera files in views directory
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the glob pattern is invalid or templates fail to parse.
80    pub fn new(glob: impl Into<String>) -> Result<Self, ViewError> {
81        let config = TemplatesConfig::new(glob);
82        Self::with_config(config)
83    }
84
85    /// Create a new template engine with configuration
86    pub fn with_config(config: TemplatesConfig) -> Result<Self, ViewError> {
87        let mut tera = Tera::new(&config.glob)?;
88
89        // Register custom filters/functions
90        register_builtin_filters(&mut tera);
91
92        Ok(Self {
93            inner: Arc::new(RwLock::new(tera)),
94            config,
95        })
96    }
97
98    /// Create an empty template engine (for adding templates programmatically)
99    pub fn empty() -> Self {
100        Self {
101            inner: Arc::new(RwLock::new(Tera::default())),
102            config: TemplatesConfig::default(),
103        }
104    }
105
106    /// Add a template from a string
107    pub async fn add_template(
108        &self,
109        name: impl Into<String>,
110        content: impl Into<String>,
111    ) -> Result<(), ViewError> {
112        let mut tera = self.inner.write().await;
113        tera.add_raw_template(&name.into(), &content.into())?;
114        Ok(())
115    }
116
117    /// Render a template with the given context
118    pub async fn render(
119        &self,
120        template: &str,
121        context: &tera::Context,
122    ) -> Result<String, ViewError> {
123        // If auto-reload is enabled and in debug mode, try to reload
124        #[cfg(debug_assertions)]
125        if self.config.auto_reload {
126            let mut tera = self.inner.write().await;
127            if let Err(e) = tera.full_reload() {
128                tracing::warn!("Template reload failed: {}", e);
129            }
130        }
131
132        let tera = self.inner.read().await;
133        tera.render(template, context).map_err(ViewError::from)
134    }
135
136    /// Render a template with a serializable context
137    pub async fn render_with<T: serde::Serialize>(
138        &self,
139        template: &str,
140        data: &T,
141    ) -> Result<String, ViewError> {
142        let context = tera::Context::from_serialize(data)
143            .map_err(|e| ViewError::serialization_error(e.to_string()))?;
144        self.render(template, &context).await
145    }
146
147    /// Check if a template exists
148    pub async fn has_template(&self, name: &str) -> bool {
149        let tera = self.inner.read().await;
150        let result = tera.get_template_names().any(|n| n == name);
151        result
152    }
153
154    /// Get all template names
155    pub async fn template_names(&self) -> Vec<String> {
156        let tera = self.inner.read().await;
157        tera.get_template_names().map(String::from).collect()
158    }
159
160    /// Reload all templates from disk
161    pub async fn reload(&self) -> Result<(), ViewError> {
162        let mut tera = self.inner.write().await;
163        tera.full_reload()?;
164        Ok(())
165    }
166
167    /// Get the configuration
168    pub fn config(&self) -> &TemplatesConfig {
169        &self.config
170    }
171}
172
173/// Register built-in template filters
174fn register_builtin_filters(tera: &mut Tera) {
175    // JSON filter for debugging
176    tera.register_filter(
177        "json_pretty",
178        |value: &tera::Value, _: &std::collections::HashMap<String, tera::Value>| {
179            serde_json::to_string_pretty(value)
180                .map(tera::Value::String)
181                .map_err(|e| tera::Error::msg(e.to_string()))
182        },
183    );
184
185    // Truncate string
186    tera.register_filter(
187        "truncate_words",
188        |value: &tera::Value, args: &std::collections::HashMap<String, tera::Value>| {
189            let s = tera::try_get_value!("truncate_words", "value", String, value);
190            let length = match args.get("length") {
191                Some(val) => tera::try_get_value!("truncate_words", "length", usize, val),
192                None => 50,
193            };
194            let end = match args.get("end") {
195                Some(val) => tera::try_get_value!("truncate_words", "end", String, val),
196                None => "...".to_string(),
197            };
198
199            let words: Vec<&str> = s.split_whitespace().collect();
200            if words.len() <= length {
201                Ok(tera::Value::String(s))
202            } else {
203                let truncated: String = words[..length].join(" ");
204                Ok(tera::Value::String(format!("{}{}", truncated, end)))
205            }
206        },
207    );
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[tokio::test]
215    async fn test_empty_templates() {
216        let templates = Templates::empty();
217        templates
218            .add_template("test", "Hello, {{ name }}!")
219            .await
220            .unwrap();
221
222        let mut ctx = tera::Context::new();
223        ctx.insert("name", "World");
224
225        let result = templates.render("test", &ctx).await.unwrap();
226        assert_eq!(result, "Hello, World!");
227    }
228
229    #[tokio::test]
230    async fn test_render_with_struct() {
231        #[derive(serde::Serialize)]
232        struct Data {
233            name: String,
234        }
235
236        let templates = Templates::empty();
237        templates
238            .add_template("test", "Hello, {{ name }}!")
239            .await
240            .unwrap();
241
242        let data = Data {
243            name: "Alice".to_string(),
244        };
245        let result = templates.render_with("test", &data).await.unwrap();
246        assert_eq!(result, "Hello, Alice!");
247    }
248}