use minijinja::{Environment, Value};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use thiserror::Error;
use super::TEMPLATE_NAMES;
#[derive(Debug, Error)]
pub enum FrameworkTemplateError {
#[error("failed to read template '{0}': {1}")]
ReadFailed(String, std::io::Error),
#[error("template not found: {0}")]
NotFound(String),
#[error("template render error: {0}")]
RenderError(#[from] minijinja::Error),
#[error("failed to resolve XDG directory: {0}")]
XdgError(String),
#[error(
"Framework templates not found.\n\n\
Templates must exist in one of these locations:\n\
- {config_dir}\n\
- {cache_dir}\n\n\
To initialize templates, run:\n\
\x1b[1m acton-htmx templates init\x1b[0m\n\n\
Or download manually from:\n\
\x1b[4mhttps://github.com/Govcraft/acton-htmx/tree/main/templates/framework\x1b[0m"
)]
TemplatesNotInitialized {
config_dir: String,
cache_dir: String,
},
}
#[derive(Debug)]
pub struct FrameworkTemplates {
env: Arc<RwLock<Environment<'static>>>,
config_dir: Option<PathBuf>,
cache_dir: Option<PathBuf>,
}
impl FrameworkTemplates {
pub fn new() -> Result<Self, FrameworkTemplateError> {
let config_dir = Self::get_config_dir();
let cache_dir = Self::get_cache_dir();
Self::verify_templates_exist(config_dir.as_ref(), cache_dir.as_ref())?;
let env = Self::create_environment(config_dir.as_ref(), cache_dir.as_ref())?;
Ok(Self {
env: Arc::new(RwLock::new(env)),
config_dir,
cache_dir,
})
}
fn verify_templates_exist(
config_dir: Option<&PathBuf>,
cache_dir: Option<&PathBuf>,
) -> Result<(), FrameworkTemplateError> {
let test_template = "forms/form.html";
let config_exists = config_dir.is_some_and(|d| d.join(test_template).exists());
let cache_exists = cache_dir.is_some_and(|d| d.join(test_template).exists());
if !config_exists && !cache_exists {
return Err(FrameworkTemplateError::TemplatesNotInitialized {
config_dir: config_dir.map_or_else(
|| "~/.config/acton-htmx/templates/framework".to_string(),
|p| p.display().to_string(),
),
cache_dir: cache_dir.map_or_else(
|| "~/.cache/acton-htmx/templates/framework".to_string(),
|p| p.display().to_string(),
),
});
}
Ok(())
}
#[must_use]
pub fn get_config_dir() -> Option<PathBuf> {
let base = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
PathBuf::from(xdg)
} else {
dirs::home_dir()?.join(".config")
};
Some(base.join("acton-htmx").join("templates").join("framework"))
}
#[must_use]
pub fn get_cache_dir() -> Option<PathBuf> {
let base = if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
PathBuf::from(xdg)
} else {
dirs::home_dir()?.join(".cache")
};
Some(base.join("acton-htmx").join("templates").join("framework"))
}
fn create_environment(
config_dir: Option<&PathBuf>,
cache_dir: Option<&PathBuf>,
) -> Result<Environment<'static>, FrameworkTemplateError> {
let mut env = Environment::new();
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
for name in TEMPLATE_NAMES {
let content = Self::load_template_content(name, config_dir, cache_dir)?;
env.add_template_owned((*name).to_string(), content)?;
}
Ok(env)
}
fn load_template_content(
name: &str,
config_dir: Option<&PathBuf>,
cache_dir: Option<&PathBuf>,
) -> Result<String, FrameworkTemplateError> {
if let Some(dir) = config_dir {
let path = dir.join(name);
if path.exists() {
return std::fs::read_to_string(&path)
.map_err(|e| FrameworkTemplateError::ReadFailed(name.to_string(), e));
}
}
if let Some(dir) = cache_dir {
let path = dir.join(name);
if path.exists() {
return std::fs::read_to_string(&path)
.map_err(|e| FrameworkTemplateError::ReadFailed(name.to_string(), e));
}
}
Err(FrameworkTemplateError::NotFound(name.to_string()))
}
#[must_use]
pub fn get_embedded_template(name: &str) -> Option<&'static str> {
EMBEDDED_TEMPLATES.get(name).copied()
}
pub fn embedded_template_names() -> impl Iterator<Item = &'static str> {
EMBEDDED_TEMPLATES.keys().copied()
}
pub fn render(&self, name: &str, ctx: Value) -> Result<String, FrameworkTemplateError> {
self.env
.read()
.get_template(name)
.and_then(|tmpl| tmpl.render(ctx))
.map_err(Into::into)
}
pub fn render_with_map(
&self,
name: &str,
ctx: HashMap<&str, Value>,
) -> Result<String, FrameworkTemplateError> {
self.env
.read()
.get_template(name)
.and_then(|tmpl| tmpl.render(ctx))
.map_err(Into::into)
}
pub fn reload(&self) -> Result<(), FrameworkTemplateError> {
let new_env =
Self::create_environment(self.config_dir.as_ref(), self.cache_dir.as_ref())?;
*self.env.write() = new_env;
tracing::debug!("Framework templates reloaded");
Ok(())
}
#[must_use]
pub fn is_customized(&self, name: &str) -> bool {
self.config_dir
.as_ref()
.is_some_and(|dir| dir.join(name).exists())
}
#[must_use]
pub fn get_template_path(&self, name: &str) -> Option<PathBuf> {
if let Some(dir) = &self.config_dir {
let path = dir.join(name);
if path.exists() {
return Some(path);
}
}
if let Some(dir) = &self.cache_dir {
let path = dir.join(name);
if path.exists() {
return Some(path);
}
}
None
}
#[must_use]
pub const fn config_dir(&self) -> Option<&PathBuf> {
self.config_dir.as_ref()
}
#[must_use]
pub const fn cache_dir(&self) -> Option<&PathBuf> {
self.cache_dir.as_ref()
}
}
impl Default for FrameworkTemplates {
fn default() -> Self {
Self::new().expect("Failed to create framework templates")
}
}
impl Clone for FrameworkTemplates {
fn clone(&self) -> Self {
Self {
env: Arc::clone(&self.env),
config_dir: self.config_dir.clone(),
cache_dir: self.cache_dir.clone(),
}
}
}
static EMBEDDED_TEMPLATES: phf::Map<&'static str, &'static str> = phf::phf_map! {
"forms/form.html" => include_str!("defaults/forms/form.html"),
"forms/field-wrapper.html" => include_str!("defaults/forms/field-wrapper.html"),
"forms/input.html" => include_str!("defaults/forms/input.html"),
"forms/textarea.html" => include_str!("defaults/forms/textarea.html"),
"forms/select.html" => include_str!("defaults/forms/select.html"),
"forms/checkbox.html" => include_str!("defaults/forms/checkbox.html"),
"forms/radio-group.html" => include_str!("defaults/forms/radio-group.html"),
"forms/submit-button.html" => include_str!("defaults/forms/submit-button.html"),
"forms/help-text.html" => include_str!("defaults/forms/help-text.html"),
"forms/label.html" => include_str!("defaults/forms/label.html"),
"forms/csrf-input.html" => include_str!("defaults/forms/csrf-input.html"),
"validation/field-errors.html" => include_str!("defaults/validation/field-errors.html"),
"validation/validation-summary.html" => include_str!("defaults/validation/validation-summary.html"),
"flash/container.html" => include_str!("defaults/flash/container.html"),
"flash/message.html" => include_str!("defaults/flash/message.html"),
"htmx/oob-wrapper.html" => include_str!("defaults/htmx/oob-wrapper.html"),
"errors/400.html" => include_str!("defaults/errors/400.html"),
"errors/401.html" => include_str!("defaults/errors/401.html"),
"errors/403.html" => include_str!("defaults/errors/403.html"),
"errors/404.html" => include_str!("defaults/errors/404.html"),
"errors/422.html" => include_str!("defaults/errors/422.html"),
"errors/500.html" => include_str!("defaults/errors/500.html"),
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_dir_resolution() {
let dir = FrameworkTemplates::get_config_dir();
assert!(dir.is_some());
let path = dir.unwrap();
assert!(path.to_string_lossy().contains("acton-htmx"));
assert!(path.to_string_lossy().contains("templates"));
assert!(path.to_string_lossy().contains("framework"));
}
#[test]
fn test_cache_dir_resolution() {
let dir = FrameworkTemplates::get_cache_dir();
assert!(dir.is_some());
let path = dir.unwrap();
assert!(path.to_string_lossy().contains("acton-htmx"));
}
#[test]
fn test_embedded_templates_exist() {
for name in TEMPLATE_NAMES {
assert!(
EMBEDDED_TEMPLATES.contains_key(name),
"Missing embedded template: {name}"
);
}
}
#[test]
fn test_framework_templates_creation() {
let templates = FrameworkTemplates::new();
assert!(templates.is_ok(), "Failed to create FrameworkTemplates");
}
#[test]
fn test_render_csrf_input() {
let templates = FrameworkTemplates::new().unwrap();
let result = templates.render(
"forms/csrf-input.html",
minijinja::context! {
token => "test-token-123",
},
);
assert!(result.is_ok());
let html = result.unwrap();
assert!(html.contains("test-token-123"));
assert!(html.contains("_csrf_token"));
}
#[test]
fn test_is_customized_returns_false_for_embedded() {
let templates = FrameworkTemplates::new().unwrap();
assert!(!templates.is_customized("forms/input.html"));
}
#[test]
fn test_clone() {
let templates = FrameworkTemplates::new().unwrap();
let cloned = templates.clone();
assert!(templates.render("forms/csrf-input.html", minijinja::context! { token => "a" }).is_ok());
assert!(cloned.render("forms/csrf-input.html", minijinja::context! { token => "b" }).is_ok());
}
}