acton_htmx/template/framework/
loader.rs1use 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#[derive(Debug, Error)]
14pub enum FrameworkTemplateError {
15 #[error("failed to read template '{0}': {1}")]
17 ReadFailed(String, std::io::Error),
18
19 #[error("template not found: {0}")]
21 NotFound(String),
22
23 #[error("template render error: {0}")]
25 RenderError(#[from] minijinja::Error),
26
27 #[error("failed to resolve XDG directory: {0}")]
29 XdgError(String),
30
31 #[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_dir: String,
45 cache_dir: String,
47 },
48}
49
50#[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 pub fn new() -> Result<Self, FrameworkTemplateError> {
72 let config_dir = Self::get_config_dir();
73 let cache_dir = Self::get_cache_dir();
74
75 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 fn verify_templates_exist(
89 config_dir: Option<&PathBuf>,
90 cache_dir: Option<&PathBuf>,
91 ) -> Result<(), FrameworkTemplateError> {
92 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 #[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 #[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 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 env.set_trim_blocks(true);
152 env.set_lstrip_blocks(true);
153
154 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 fn load_template_content(
168 name: &str,
169 config_dir: Option<&PathBuf>,
170 cache_dir: Option<&PathBuf>,
171 ) -> Result<String, FrameworkTemplateError> {
172 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 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 Err(FrameworkTemplateError::NotFound(name.to_string()))
192 }
193
194 #[must_use]
198 pub fn get_embedded_template(name: &str) -> Option<&'static str> {
199 EMBEDDED_TEMPLATES.get(name).copied()
200 }
201
202 pub fn embedded_template_names() -> impl Iterator<Item = &'static str> {
204 EMBEDDED_TEMPLATES.keys().copied()
205 }
206
207 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 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 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 *self.env.write() = new_env;
253
254 tracing::debug!("Framework templates reloaded");
255 Ok(())
256 }
257
258 #[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 #[must_use]
270 pub fn get_template_path(&self, name: &str) -> Option<PathBuf> {
271 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 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 None
289 }
290
291 #[must_use]
293 pub const fn config_dir(&self) -> Option<&PathBuf> {
294 self.config_dir.as_ref()
295 }
296
297 #[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
320static EMBEDDED_TEMPLATES: phf::Map<&'static str, &'static str> = phf::phf_map! {
323 "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/field-errors.html" => include_str!("defaults/validation/field-errors.html"),
337 "validation/validation-summary.html" => include_str!("defaults/validation/validation-summary.html"),
338 "flash/container.html" => include_str!("defaults/flash/container.html"),
340 "flash/message.html" => include_str!("defaults/flash/message.html"),
341 "htmx/oob-wrapper.html" => include_str!("defaults/htmx/oob-wrapper.html"),
343 "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 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 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 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}