acton_htmx/template/framework/
loader.rs

1//! Framework template loader with XDG resolution and hot reload support
2
3use minijinja::{Environment, Value};
4use parking_lot::RwLock;
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::Arc;
8use thiserror::Error;
9
10use super::TEMPLATE_NAMES;
11
12/// Errors that can occur when loading or rendering framework templates
13#[derive(Debug, Error)]
14pub enum FrameworkTemplateError {
15    /// Template file could not be read
16    #[error("failed to read template '{0}': {1}")]
17    ReadFailed(String, std::io::Error),
18
19    /// Template was not found in any location
20    #[error("template not found: {0}")]
21    NotFound(String),
22
23    /// Template rendering failed
24    #[error("template render error: {0}")]
25    RenderError(#[from] minijinja::Error),
26
27    /// XDG directory resolution failed
28    #[error("failed to resolve XDG directory: {0}")]
29    XdgError(String),
30
31    /// Templates not initialized - user needs to run CLI
32    #[error(
33        "Framework templates not found.\n\n\
34        Templates must exist in one of these locations:\n\
35        - {config_dir}\n\
36        - {cache_dir}\n\n\
37        To initialize templates, run:\n\
38        \x1b[1m  acton-htmx templates init\x1b[0m\n\n\
39        Or download manually from:\n\
40        \x1b[4mhttps://github.com/Govcraft/acton-htmx/tree/main/templates/framework\x1b[0m"
41    )]
42    TemplatesNotInitialized {
43        /// Config directory path
44        config_dir: String,
45        /// Cache directory path
46        cache_dir: String,
47    },
48}
49
50/// Thread-safe framework template environment with hot reload support
51///
52/// Templates are loaded from XDG directories with embedded fallbacks.
53/// The environment supports atomic reload for development hot-reload.
54#[derive(Debug)]
55pub struct FrameworkTemplates {
56    env: Arc<RwLock<Environment<'static>>>,
57    config_dir: Option<PathBuf>,
58    cache_dir: Option<PathBuf>,
59}
60
61impl FrameworkTemplates {
62    /// Create a new framework templates instance
63    ///
64    /// Loads templates from XDG directories. Templates MUST exist on disk
65    /// (either in config or cache directory). Run `acton-htmx templates init`
66    /// to download them.
67    ///
68    /// # Errors
69    ///
70    /// Returns error if templates are not found or cannot be loaded.
71    pub fn new() -> Result<Self, FrameworkTemplateError> {
72        let config_dir = Self::get_config_dir();
73        let cache_dir = Self::get_cache_dir();
74
75        // Verify templates exist before loading
76        Self::verify_templates_exist(config_dir.as_ref(), cache_dir.as_ref())?;
77
78        let env = Self::create_environment(config_dir.as_ref(), cache_dir.as_ref())?;
79
80        Ok(Self {
81            env: Arc::new(RwLock::new(env)),
82            config_dir,
83            cache_dir,
84        })
85    }
86
87    /// Verify that templates exist in at least one XDG location
88    fn verify_templates_exist(
89        config_dir: Option<&PathBuf>,
90        cache_dir: Option<&PathBuf>,
91    ) -> Result<(), FrameworkTemplateError> {
92        // Check if at least one required template exists
93        let test_template = "forms/form.html";
94
95        let config_exists = config_dir.is_some_and(|d| d.join(test_template).exists());
96
97        let cache_exists = cache_dir.is_some_and(|d| d.join(test_template).exists());
98
99        if !config_exists && !cache_exists {
100            return Err(FrameworkTemplateError::TemplatesNotInitialized {
101                config_dir: config_dir.map_or_else(
102                    || "~/.config/acton-htmx/templates/framework".to_string(),
103                    |p| p.display().to_string(),
104                ),
105                cache_dir: cache_dir.map_or_else(
106                    || "~/.cache/acton-htmx/templates/framework".to_string(),
107                    |p| p.display().to_string(),
108                ),
109            });
110        }
111
112        Ok(())
113    }
114
115    /// Get the XDG config directory for framework templates
116    ///
117    /// Returns `$XDG_CONFIG_HOME/acton-htmx/templates/framework/` or
118    /// `~/.config/acton-htmx/templates/framework/` if not set.
119    #[must_use]
120    pub fn get_config_dir() -> Option<PathBuf> {
121        let base = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
122            PathBuf::from(xdg)
123        } else {
124            dirs::home_dir()?.join(".config")
125        };
126        Some(base.join("acton-htmx").join("templates").join("framework"))
127    }
128
129    /// Get the XDG cache directory for framework templates
130    ///
131    /// Returns `$XDG_CACHE_HOME/acton-htmx/templates/framework/` or
132    /// `~/.cache/acton-htmx/templates/framework/` if not set.
133    #[must_use]
134    pub fn get_cache_dir() -> Option<PathBuf> {
135        let base = if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
136            PathBuf::from(xdg)
137        } else {
138            dirs::home_dir()?.join(".cache")
139        };
140        Some(base.join("acton-htmx").join("templates").join("framework"))
141    }
142
143    /// Create a new minijinja environment with all templates loaded
144    fn create_environment(
145        config_dir: Option<&PathBuf>,
146        cache_dir: Option<&PathBuf>,
147    ) -> Result<Environment<'static>, FrameworkTemplateError> {
148        let mut env = Environment::new();
149
150        // Configure environment
151        env.set_trim_blocks(true);
152        env.set_lstrip_blocks(true);
153
154        // Load all templates
155        for name in TEMPLATE_NAMES {
156            let content = Self::load_template_content(name, config_dir, cache_dir)?;
157            env.add_template_owned((*name).to_string(), content)?;
158        }
159
160        Ok(env)
161    }
162
163    /// Load template content with XDG resolution order
164    ///
165    /// Templates are loaded from disk only - no embedded fallback.
166    /// Order: config (customizations) > cache (defaults)
167    fn load_template_content(
168        name: &str,
169        config_dir: Option<&PathBuf>,
170        cache_dir: Option<&PathBuf>,
171    ) -> Result<String, FrameworkTemplateError> {
172        // 1. Check user config (customized templates)
173        if let Some(dir) = config_dir {
174            let path = dir.join(name);
175            if path.exists() {
176                return std::fs::read_to_string(&path)
177                    .map_err(|e| FrameworkTemplateError::ReadFailed(name.to_string(), e));
178            }
179        }
180
181        // 2. Check cache (downloaded defaults)
182        if let Some(dir) = cache_dir {
183            let path = dir.join(name);
184            if path.exists() {
185                return std::fs::read_to_string(&path)
186                    .map_err(|e| FrameworkTemplateError::ReadFailed(name.to_string(), e));
187            }
188        }
189
190        // No embedded fallback - templates must be on disk
191        Err(FrameworkTemplateError::NotFound(name.to_string()))
192    }
193
194    /// Get embedded template content (for CLI to write to cache)
195    ///
196    /// This is used by the CLI `templates init` command to populate the cache.
197    #[must_use]
198    pub fn get_embedded_template(name: &str) -> Option<&'static str> {
199        EMBEDDED_TEMPLATES.get(name).copied()
200    }
201
202    /// Get all embedded template names
203    pub fn embedded_template_names() -> impl Iterator<Item = &'static str> {
204        EMBEDDED_TEMPLATES.keys().copied()
205    }
206
207    /// Render a template with the given context
208    ///
209    /// # Errors
210    ///
211    /// Returns error if the template is not found or rendering fails.
212    pub fn render(&self, name: &str, ctx: Value) -> Result<String, FrameworkTemplateError> {
213        self.env
214            .read()
215            .get_template(name)
216            .and_then(|tmpl| tmpl.render(ctx))
217            .map_err(Into::into)
218    }
219
220    /// Render a template with a context map
221    ///
222    /// Convenience method that accepts a HashMap instead of minijinja::Value.
223    ///
224    /// # Errors
225    ///
226    /// Returns error if the template is not found or rendering fails.
227    pub fn render_with_map(
228        &self,
229        name: &str,
230        ctx: HashMap<&str, Value>,
231    ) -> Result<String, FrameworkTemplateError> {
232        self.env
233            .read()
234            .get_template(name)
235            .and_then(|tmpl| tmpl.render(ctx))
236            .map_err(Into::into)
237    }
238
239    /// Reload all templates from disk
240    ///
241    /// Useful for hot-reload during development. Creates a new environment
242    /// and atomically swaps it with the current one.
243    ///
244    /// # Errors
245    ///
246    /// Returns error if templates cannot be reloaded.
247    pub fn reload(&self) -> Result<(), FrameworkTemplateError> {
248        let new_env =
249            Self::create_environment(self.config_dir.as_ref(), self.cache_dir.as_ref())?;
250
251        // Atomic swap
252        *self.env.write() = new_env;
253
254        tracing::debug!("Framework templates reloaded");
255        Ok(())
256    }
257
258    /// Check if a template exists in user config (customized)
259    #[must_use]
260    pub fn is_customized(&self, name: &str) -> bool {
261        self.config_dir
262            .as_ref()
263            .is_some_and(|dir| dir.join(name).exists())
264    }
265
266    /// Get the path where a template would be loaded from
267    ///
268    /// Returns the actual file path if found on disk, or None if using embedded.
269    #[must_use]
270    pub fn get_template_path(&self, name: &str) -> Option<PathBuf> {
271        // Check config dir first
272        if let Some(dir) = &self.config_dir {
273            let path = dir.join(name);
274            if path.exists() {
275                return Some(path);
276            }
277        }
278
279        // Check cache dir
280        if let Some(dir) = &self.cache_dir {
281            let path = dir.join(name);
282            if path.exists() {
283                return Some(path);
284            }
285        }
286
287        // Using embedded
288        None
289    }
290
291    /// Get a reference to the config directory
292    #[must_use]
293    pub const fn config_dir(&self) -> Option<&PathBuf> {
294        self.config_dir.as_ref()
295    }
296
297    /// Get a reference to the cache directory
298    #[must_use]
299    pub const fn cache_dir(&self) -> Option<&PathBuf> {
300        self.cache_dir.as_ref()
301    }
302}
303
304impl Default for FrameworkTemplates {
305    fn default() -> Self {
306        Self::new().expect("Failed to create framework templates")
307    }
308}
309
310impl Clone for FrameworkTemplates {
311    fn clone(&self) -> Self {
312        Self {
313            env: Arc::clone(&self.env),
314            config_dir: self.config_dir.clone(),
315            cache_dir: self.cache_dir.clone(),
316        }
317    }
318}
319
320// Embedded fallback templates (always available)
321// These are the default templates that ship with the framework
322static EMBEDDED_TEMPLATES: phf::Map<&'static str, &'static str> = phf::phf_map! {
323    // Forms
324    "forms/form.html" => include_str!("defaults/forms/form.html"),
325    "forms/field-wrapper.html" => include_str!("defaults/forms/field-wrapper.html"),
326    "forms/input.html" => include_str!("defaults/forms/input.html"),
327    "forms/textarea.html" => include_str!("defaults/forms/textarea.html"),
328    "forms/select.html" => include_str!("defaults/forms/select.html"),
329    "forms/checkbox.html" => include_str!("defaults/forms/checkbox.html"),
330    "forms/radio-group.html" => include_str!("defaults/forms/radio-group.html"),
331    "forms/submit-button.html" => include_str!("defaults/forms/submit-button.html"),
332    "forms/help-text.html" => include_str!("defaults/forms/help-text.html"),
333    "forms/label.html" => include_str!("defaults/forms/label.html"),
334    "forms/csrf-input.html" => include_str!("defaults/forms/csrf-input.html"),
335    // Validation
336    "validation/field-errors.html" => include_str!("defaults/validation/field-errors.html"),
337    "validation/validation-summary.html" => include_str!("defaults/validation/validation-summary.html"),
338    // Flash messages
339    "flash/container.html" => include_str!("defaults/flash/container.html"),
340    "flash/message.html" => include_str!("defaults/flash/message.html"),
341    // HTMX
342    "htmx/oob-wrapper.html" => include_str!("defaults/htmx/oob-wrapper.html"),
343    // Error pages
344    "errors/400.html" => include_str!("defaults/errors/400.html"),
345    "errors/401.html" => include_str!("defaults/errors/401.html"),
346    "errors/403.html" => include_str!("defaults/errors/403.html"),
347    "errors/404.html" => include_str!("defaults/errors/404.html"),
348    "errors/422.html" => include_str!("defaults/errors/422.html"),
349    "errors/500.html" => include_str!("defaults/errors/500.html"),
350};
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_config_dir_resolution() {
358        let dir = FrameworkTemplates::get_config_dir();
359        assert!(dir.is_some());
360        let path = dir.unwrap();
361        assert!(path.to_string_lossy().contains("acton-htmx"));
362        assert!(path.to_string_lossy().contains("templates"));
363        assert!(path.to_string_lossy().contains("framework"));
364    }
365
366    #[test]
367    fn test_cache_dir_resolution() {
368        let dir = FrameworkTemplates::get_cache_dir();
369        assert!(dir.is_some());
370        let path = dir.unwrap();
371        assert!(path.to_string_lossy().contains("acton-htmx"));
372    }
373
374    #[test]
375    fn test_embedded_templates_exist() {
376        // All templates should have embedded fallbacks
377        for name in TEMPLATE_NAMES {
378            assert!(
379                EMBEDDED_TEMPLATES.contains_key(name),
380                "Missing embedded template: {name}"
381            );
382        }
383    }
384
385    #[test]
386    fn test_framework_templates_creation() {
387        let templates = FrameworkTemplates::new();
388        assert!(templates.is_ok(), "Failed to create FrameworkTemplates");
389    }
390
391    #[test]
392    fn test_render_csrf_input() {
393        let templates = FrameworkTemplates::new().unwrap();
394        let result = templates.render(
395            "forms/csrf-input.html",
396            minijinja::context! {
397                token => "test-token-123",
398            },
399        );
400        assert!(result.is_ok());
401        let html = result.unwrap();
402        assert!(html.contains("test-token-123"));
403        assert!(html.contains("_csrf_token"));
404    }
405
406    #[test]
407    fn test_is_customized_returns_false_for_embedded() {
408        let templates = FrameworkTemplates::new().unwrap();
409        // Should return false since we're using embedded templates
410        assert!(!templates.is_customized("forms/input.html"));
411    }
412
413    #[test]
414    fn test_clone() {
415        let templates = FrameworkTemplates::new().unwrap();
416        let cloned = templates.clone();
417        // Both should work
418        assert!(templates.render("forms/csrf-input.html", minijinja::context! { token => "a" }).is_ok());
419        assert!(cloned.render("forms/csrf-input.html", minijinja::context! { token => "b" }).is_ok());
420    }
421}