Rustelo/server/src/template/service.rs
2025-07-07 23:05:19 +01:00

523 lines
16 KiB
Rust

//! Template service for handling localized template rendering
#![allow(dead_code)]
use crate::template::{
RenderedTemplate, Result, TemplateConfig, TemplateEngine, TemplateError, TemplateLoader,
TemplatePageConfig,
};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tracing::{debug, info, warn};
/// Template service that manages template loading and rendering
#[derive(Debug)]
pub struct TemplateService {
/// Template engine
engine: Arc<RwLock<TemplateEngine>>,
/// Template loader
loader: Arc<RwLock<TemplateLoader>>,
/// Template configuration
config: TemplateConfig,
/// Cache for rendered templates
render_cache: Arc<RwLock<HashMap<String, RenderedTemplate>>>,
/// Cache enabled flag
cache_enabled: bool,
}
impl TemplateService {
/// Create a new template service
pub fn new(template_dir: impl Into<String>, content_dir: impl Into<String>) -> Result<Self> {
let template_dir = template_dir.into();
let content_dir = content_dir.into();
let config = TemplateConfig::new(&template_dir, &content_dir);
let mut engine = TemplateEngine::new(&template_dir)?;
// Add default filters
engine.add_default_filters();
let loader = TemplateLoader::new(config.clone());
Ok(Self {
engine: Arc::new(RwLock::new(engine)),
loader: Arc::new(RwLock::new(loader)),
config,
render_cache: Arc::new(RwLock::new(HashMap::new())),
cache_enabled: true,
})
}
/// Create template service with custom configuration
pub fn with_config(config: TemplateConfig) -> Result<Self> {
let mut engine = TemplateEngine::new(&config.template_dir)?;
engine.add_default_filters();
let loader = TemplateLoader::new(config.clone());
Ok(Self {
engine: Arc::new(RwLock::new(engine)),
loader: Arc::new(RwLock::new(loader)),
config,
render_cache: Arc::new(RwLock::new(HashMap::new())),
cache_enabled: true,
})
}
/// Set available languages
pub fn with_languages(self, languages: Vec<String>) -> Self {
if let Ok(mut loader) = self.loader.write() {
*loader = std::mem::take(&mut *loader).with_languages(languages);
}
self
}
/// Set default language
pub fn with_default_language(self, lang: impl Into<String>) -> Self {
if let Ok(mut loader) = self.loader.write() {
*loader = std::mem::take(&mut *loader).with_default_lang(lang);
}
self
}
/// Enable or disable caching
pub fn with_cache(mut self, enabled: bool) -> Self {
self.cache_enabled = enabled;
if let Ok(mut loader) = self.loader.write() {
*loader = std::mem::take(&mut *loader).with_cache(enabled);
}
self
}
/// Render a template page
///
/// For URL `/page:content-name` with language `lang`, this will:
/// 1. Load the template configuration from `{lang}_content-name.tpl.toml`
/// 2. Render the template using the Tera engine
/// 3. Return the rendered content
pub async fn render_page(&self, content_name: &str, lang: &str) -> Result<RenderedTemplate> {
let cache_key = format!("{}_{}", lang, content_name);
// Check render cache first
if self.cache_enabled {
if let Ok(cache) = self.render_cache.read() {
if let Some(cached) = cache.get(&cache_key) {
debug!("Serving cached template for {}", cache_key);
return Ok(cached.clone());
}
}
}
// Load template configuration
let config = {
let mut loader = self.loader.write().map_err(|_| {
TemplateError::RenderError("Failed to acquire loader lock".to_string())
})?;
loader.load_page_config_with_fallback(content_name, lang)?
};
// Render template
let source_path = {
let loader = self.loader.read().map_err(|_| {
TemplateError::RenderError("Failed to acquire loader lock".to_string())
})?;
loader.get_template_file_path(content_name, lang)
};
let rendered = {
let engine = self.engine.read().map_err(|_| {
TemplateError::RenderError("Failed to acquire engine lock".to_string())
})?;
engine.render_template(&config, &source_path)?
};
// Cache the result
if self.cache_enabled {
if let Ok(mut cache) = self.render_cache.write() {
cache.insert(cache_key, rendered.clone());
}
}
info!(
"Rendered template '{}' for content '{}' in language '{}'",
config.template_name, content_name, lang
);
Ok(rendered)
}
/// Check if a template page exists
pub fn page_exists(&self, content_name: &str, lang: &str) -> bool {
let loader = match self.loader.read() {
Ok(loader) => loader,
Err(_) => return false,
};
loader.exists(content_name, lang)
}
/// Get all available content for a specific language
pub async fn get_available_content(&self, lang: &str) -> Result<Vec<String>> {
let loader = self
.loader
.read()
.map_err(|_| TemplateError::RenderError("Failed to acquire loader lock".to_string()))?;
loader.get_available_content(lang)
}
/// Get available languages
pub fn get_available_languages(&self) -> Vec<String> {
let loader = match self.loader.read() {
Ok(loader) => loader,
Err(_) => return vec!["en".to_string()],
};
loader.get_available_languages().to_vec()
}
/// Get default language
pub fn get_default_language(&self) -> String {
let loader = match self.loader.read() {
Ok(loader) => loader,
Err(_) => return "en".to_string(),
};
loader.get_default_language().to_string()
}
/// Parse page URL to extract content name
///
/// Converts `/page:content-name` to `content-name`
pub fn parse_page_url(&self, url_path: &str) -> Option<String> {
let loader = self.loader.read().ok()?;
loader.parse_page_url(url_path)
}
/// Generate page URL from content name
///
/// Converts `content-name` to `/page:content-name`
pub fn generate_page_url(&self, content_name: &str) -> String {
let loader = match self.loader.read() {
Ok(loader) => loader,
Err(_) => return format!("/page:{}", content_name),
};
loader.generate_page_url(content_name)
}
/// Clear all caches
pub async fn clear_cache(&self) -> Result<()> {
// Clear render cache
if let Ok(mut cache) = self.render_cache.write() {
cache.clear();
}
// Clear loader cache
if let Ok(mut loader) = self.loader.write() {
loader.clear_cache();
}
info!("Template caches cleared");
Ok(())
}
/// Reload templates from disk
pub async fn reload_templates(&self) -> Result<()> {
// Reload engine templates
if let Ok(mut engine) = self.engine.write() {
engine.reload_templates()?;
}
// Clear all caches
self.clear_cache().await?;
info!("Templates reloaded from disk");
Ok(())
}
/// Get template engine statistics
pub fn get_engine_stats(&self) -> HashMap<String, serde_json::Value> {
let mut stats = HashMap::new();
if let Ok(engine) = self.engine.read() {
let template_names = engine.get_template_names();
stats.insert("template_count".to_string(), template_names.len().into());
stats.insert("template_names".to_string(), template_names.into());
}
if let Ok(cache) = self.render_cache.read() {
stats.insert("render_cache_size".to_string(), cache.len().into());
}
if let Ok(loader) = self.loader.read() {
let (cache_size, cache_enabled) = loader.get_cache_stats();
stats.insert("loader_cache_size".to_string(), cache_size.into());
stats.insert("loader_cache_enabled".to_string(), cache_enabled.into());
}
stats.insert("cache_enabled".to_string(), self.cache_enabled.into());
stats
}
/// Render a template with custom context (for testing or special cases)
pub async fn render_with_context(
&self,
template_name: &str,
context: HashMap<String, serde_json::Value>,
) -> Result<String> {
let engine = self
.engine
.read()
.map_err(|_| TemplateError::RenderError("Failed to acquire engine lock".to_string()))?;
let tera_context = TemplateEngine::create_context(&context);
engine.render_with_context(template_name, &tera_context)
}
/// Add a custom filter to the template engine
pub fn add_filter<F>(&self, name: &str, filter: F) -> Result<()>
where
F: tera::Filter + 'static,
{
let mut engine = self
.engine
.write()
.map_err(|_| TemplateError::RenderError("Failed to acquire engine lock".to_string()))?;
engine.add_filter(name, filter);
Ok(())
}
/// Add a custom function to the template engine
pub fn add_function<F>(&self, name: &str, func: F) -> Result<()>
where
F: tera::Function + 'static,
{
let mut engine = self
.engine
.write()
.map_err(|_| TemplateError::RenderError("Failed to acquire engine lock".to_string()))?;
engine.add_function(name, func);
Ok(())
}
/// Preload templates for a specific language
pub async fn preload_language(&self, lang: &str) -> Result<usize> {
let content_names = self.get_available_content(lang).await?;
let mut loaded_count = 0;
for content_name in content_names {
match self.render_page(&content_name, lang).await {
Ok(_) => {
loaded_count += 1;
debug!("Preloaded template: {}_{}", lang, content_name);
}
Err(e) => {
warn!(
"Failed to preload template {}_{}: {}",
lang, content_name, e
);
}
}
}
info!(
"Preloaded {} templates for language '{}'",
loaded_count, lang
);
Ok(loaded_count)
}
/// Get template configuration for a specific page
pub async fn get_page_config(
&self,
content_name: &str,
lang: &str,
) -> Result<TemplatePageConfig> {
let mut loader = self
.loader
.write()
.map_err(|_| TemplateError::RenderError("Failed to acquire loader lock".to_string()))?;
loader.load_page_config_with_fallback(content_name, lang)
}
/// Check if template engine has a specific template
pub fn has_template(&self, template_name: &str) -> bool {
let engine = match self.engine.read() {
Ok(engine) => engine,
Err(_) => return false,
};
engine.template_exists(template_name)
}
}
impl Clone for TemplateService {
fn clone(&self) -> Self {
Self {
engine: Arc::clone(&self.engine),
loader: Arc::clone(&self.loader),
config: self.config.clone(),
render_cache: Arc::clone(&self.render_cache),
cache_enabled: self.cache_enabled,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_template_file(
dir: &std::path::Path,
filename: &str,
content: &str,
) -> std::io::Result<()> {
let file_path = dir.join(filename);
fs::write(file_path, content)
}
#[tokio::test]
async fn test_template_service_creation() {
let temp_dir = TempDir::new().unwrap();
let template_dir = temp_dir.path().join("templates");
let content_dir = temp_dir.path().join("content");
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&content_dir).unwrap();
// Create a test template
create_test_template_file(&template_dir, "test.html", "<h1>{{title}}</h1>").unwrap();
let service = TemplateService::new(
template_dir.to_str().unwrap(),
content_dir.to_str().unwrap(),
);
assert!(service.is_ok());
}
#[tokio::test]
async fn test_render_page() {
let temp_dir = TempDir::new().unwrap();
let template_dir = temp_dir.path().join("templates");
let content_dir = temp_dir.path().join("content");
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&content_dir).unwrap();
// Create template
create_test_template_file(
&template_dir,
"blog-post.html",
"<h1>{{title}}</h1><p>{{content}}</p>",
)
.unwrap();
// Create template config
let config_content = r#"
template_name = "blog-post"
[values]
title = "Test Blog Post"
content = "This is test content"
"#;
create_test_template_file(&content_dir, "en_my-blog.tpl.toml", config_content).unwrap();
let service = TemplateService::new(
template_dir.to_str().unwrap(),
content_dir.to_str().unwrap(),
)
.unwrap();
let result = service.render_page("my-blog", "en").await.unwrap();
assert!(result.content.contains("Test Blog Post"));
assert!(result.content.contains("This is test content"));
}
#[tokio::test]
async fn test_language_fallback() {
let temp_dir = TempDir::new().unwrap();
let template_dir = temp_dir.path().join("templates");
let content_dir = temp_dir.path().join("content");
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&content_dir).unwrap();
// Create template
create_test_template_file(&template_dir, "blog-post.html", "<h1>{{title}}</h1>").unwrap();
// Create only English config
let config_content = r#"
template_name = "blog-post"
[values]
title = "English Title"
"#;
create_test_template_file(&content_dir, "en_my-blog.tpl.toml", config_content).unwrap();
let service = TemplateService::new(
template_dir.to_str().unwrap(),
content_dir.to_str().unwrap(),
)
.unwrap();
// Try to render in French, should fallback to English
let result = service.render_page("my-blog", "fr").await.unwrap();
assert!(result.content.contains("English Title"));
}
#[tokio::test]
async fn test_parse_page_url() {
let temp_dir = TempDir::new().unwrap();
let template_dir = temp_dir.path().join("templates");
let content_dir = temp_dir.path().join("content");
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&content_dir).unwrap();
let service = TemplateService::new(
template_dir.to_str().unwrap(),
content_dir.to_str().unwrap(),
)
.unwrap();
assert_eq!(
service.parse_page_url("/page:my-blog"),
Some("my-blog".to_string())
);
assert_eq!(
service.parse_page_url("/page:about-us"),
Some("about-us".to_string())
);
assert_eq!(service.parse_page_url("/other-path"), None);
}
#[tokio::test]
async fn test_generate_page_url() {
let temp_dir = TempDir::new().unwrap();
let template_dir = temp_dir.path().join("templates");
let content_dir = temp_dir.path().join("content");
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&content_dir).unwrap();
let service = TemplateService::new(
template_dir.to_str().unwrap(),
content_dir.to_str().unwrap(),
)
.unwrap();
assert_eq!(service.generate_page_url("my-blog"), "/page:my-blog");
assert_eq!(service.generate_page_url("about-us"), "/page:about-us");
}
}