use crate::cache::Cache;
use crate::context::Context;
use fnv::FnvHashMap;
use reqwest;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tempfile::tempdir;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum EngineError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Request error: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("Render error: {0}")]
Render(String),
#[error("Invalid template: {0}")]
InvalidTemplate(String),
}
#[derive(Debug, Default, PartialEq, Eq, Clone)]
pub struct PageOptions {
pub elements: FnvHashMap<String, String>,
}
impl PageOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set(&mut self, key: String, value: String) {
let _ = self.elements.insert(key, value);
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&String> {
self.elements.get(key)
}
}
#[derive(Debug)]
pub struct Engine {
pub template_path: String,
pub render_cache: Cache<String, String>,
pub open_delim: String,
pub close_delim: String,
}
impl Engine {
#[must_use]
pub fn new(template_path: &str, cache_ttl: Duration) -> Self {
Self {
template_path: template_path.to_string(),
render_cache: Cache::new(cache_ttl),
open_delim: "{{".to_string(),
close_delim: "}}".to_string(),
}
}
pub fn render_page(
&mut self,
context: &Context,
layout: &str,
) -> Result<String, EngineError> {
let cache_key = format!("{}:{}", layout, context.hash());
if let Some(cached) = self.render_cache.get(&cache_key) {
return Ok(cached.to_string());
}
let template_path = Path::new(&self.template_path)
.join(format!("{}.html", layout));
let template_content = fs::read_to_string(&template_path)?;
let rendered =
self.render_template(&template_content, context)?;
let _ = self.render_cache.insert(cache_key, rendered.clone());
Ok(rendered)
}
pub fn render_template(
&self,
template: &str,
context: &Context,
) -> Result<String, EngineError> {
if template.trim().is_empty() {
return Err(EngineError::InvalidTemplate(
"Template is empty".to_string(),
));
}
if template.contains(&self.open_delim[..1])
&& !template.contains(&self.open_delim)
{
return Err(EngineError::InvalidTemplate(format!(
"Invalid template syntax: single '{}' are not allowed",
&self.open_delim[..1]
)));
}
let mut output = String::with_capacity(template.len());
let mut last_end = 0;
let mut depth = 0;
for (idx, _) in template.match_indices(&self.open_delim) {
if depth > 0 {
return Err(EngineError::InvalidTemplate(
"Nested delimiters are not allowed".to_string(),
));
}
depth += 1;
output.push_str(&template[last_end..idx]);
if let Some(end) = template[idx..].find(&self.close_delim) {
let key =
&template[idx + self.open_delim.len()..idx + end];
if let Some(value) = context.get(key) {
output.push_str(value);
} else {
return Err(EngineError::Render(format!(
"Unresolved template tag: {}",
key
)));
}
last_end = idx + end + self.close_delim.len();
depth -= 1;
} else {
return Err(EngineError::InvalidTemplate(
"Unclosed template tag".to_string(),
));
}
}
output.push_str(&template[last_end..]);
Ok(output)
}
pub fn set_delimiters(&mut self, open: &str, close: &str) {
self.open_delim = open.to_string();
self.close_delim = close.to_string();
}
pub fn create_template_folder(
&self,
template_path: Option<&str>,
) -> Result<String, EngineError> {
let current_dir = std::env::current_dir()?;
let template_dir_path = match template_path {
Some(path) if is_url(path) => {
Self::download_files_from_url(path)?
}
Some(path) => {
let local_path = current_dir.join(path);
if local_path.exists() && local_path.is_dir() {
local_path
} else {
return Err(EngineError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"Template directory not found: {}",
path
),
)));
}
}
None => {
let default_url = "https://raw.githubusercontent.com/sebastienrousseau/shokunin/main/template/";
Self::download_files_from_url(default_url)?
}
};
Ok(template_dir_path
.to_str()
.ok_or_else(|| {
EngineError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid UTF-8 sequence in template path",
))
})?
.to_string())
}
fn download_files_from_url(
url: &str,
) -> Result<PathBuf, EngineError> {
let template_dir_path = tempdir()?.into_path();
let files = [
"contact.html",
"index.html",
"page.html",
"post.html",
"main.js",
"sw.js",
];
for file in &files {
Self::download_file(url, file, &template_dir_path)?;
}
Ok(template_dir_path)
}
fn download_file(
url: &str,
file: &str,
dir: &Path,
) -> Result<(), EngineError> {
let file_url = format!("{}/{}", url, file);
let file_path = dir.join(file);
let client = reqwest::blocking::Client::new();
let response = client
.get(&file_url)
.timeout(Duration::from_secs(10)) .send()?;
if !response.status().is_success() {
return Err(EngineError::Render(format!(
"Failed to download {}: HTTP {}",
file,
response.status()
)));
}
let mut file = File::create(&file_path)?;
let content = response.text()?;
file.write_all(content.as_bytes())?;
Ok(())
}
pub fn clear_cache(&mut self) {
self.render_cache.clear();
}
pub fn set_max_cache_size(&mut self, max_size: usize) {
if self.render_cache.len() > max_size {
self.clear_cache();
}
}
}
fn is_url(path: &str) -> bool {
path.starts_with("http://") || path.starts_with("https://")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Context;
#[test]
fn test_render_template() {
let mut engine = Engine::new("", Duration::from_secs(60));
engine.set_delimiters("<<", ">>");
let mut context = Context::new();
context.set("name".to_string(), "Alice".to_string());
context.set("greeting".to_string(), "Hello".to_string());
let template = "<<greeting>>, <<name>>!";
let result =
engine.render_template(template, &context).unwrap();
assert_eq!(result, "Hello, Alice!");
}
#[test]
fn test_render_template_empty() {
let engine = Engine::new("", Duration::from_secs(60));
let context = Context::new();
let template = "";
let result = engine.render_template(template, &context);
assert!(matches!(result, Err(EngineError::InvalidTemplate(_))));
}
#[test]
fn test_render_template_invalid_syntax() {
let mut engine = Engine::new("", Duration::from_secs(60));
engine.set_delimiters("{{", "}}"); let context = Context::new();
let template = "Hello, {name}!";
let result = engine.render_template(template, &context);
assert!(
matches!(result, Err(EngineError::InvalidTemplate(msg)) if msg.contains("single '{'"))
);
}
#[test]
fn test_render_template_custom_delimiters() {
let mut engine = Engine::new("", Duration::from_secs(60));
engine.set_delimiters("<<", ">>");
let mut context = Context::new();
context.set("name".to_string(), "Alice".to_string());
context.set("greeting".to_string(), "Hello".to_string());
let template = "<<greeting>>, <<name>>!";
let result =
engine.render_template(template, &context).unwrap();
assert_eq!(result, "Hello, Alice!");
let invalid_template = "Hello, <name>!";
let result = engine.render_template(invalid_template, &context);
assert!(
matches!(result, Err(EngineError::InvalidTemplate(msg)) if msg.contains("single '<'"))
);
}
#[test]
fn test_render_template_unresolved_tag() {
let engine = Engine::new("", Duration::from_secs(60));
let context = Context::new();
let template = "Hello, {{name}}!";
let result = engine.render_template(template, &context);
assert!(matches!(result, Err(EngineError::Render(_))));
}
#[test]
fn test_is_url() {
assert!(is_url("http://example.com"));
assert!(is_url("https://example.com"));
assert!(!is_url("file:///path/to/file"));
assert!(!is_url("/local/path"));
}
#[test]
fn test_page_options() {
let mut options = PageOptions::new();
options.set("title".to_string(), "My Page".to_string());
assert_eq!(options.get("title"), Some(&"My Page".to_string()));
assert_eq!(options.get("non_existent"), None);
}
#[test]
fn test_render_page() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let template_path = temp_dir.path().join("template.html");
fs::write(&template_path, "Hello, {{name}}!").unwrap();
let mut engine = Engine::new(
temp_dir.path().to_str().unwrap(),
Duration::from_secs(60),
);
let mut context = Context::new();
context.set("name".to_string(), "World".to_string());
let result = engine.render_page(&context, "template").unwrap();
assert_eq!(result, "Hello, World!");
}
#[test]
fn test_clear_cache() {
let mut engine =
Engine::new("templates", Duration::from_secs(3600));
let _ = engine
.render_cache
.insert("key1".to_string(), "value1".to_string());
assert!(!engine.render_cache.is_empty());
engine.clear_cache();
assert!(engine.render_cache.is_empty());
}
#[test]
fn test_set_max_cache_size() {
let mut engine =
Engine::new("templates", Duration::from_secs(3600));
let _ = engine
.render_cache
.insert("key1".to_string(), "value1".to_string());
let _ = engine
.render_cache
.insert("key2".to_string(), "value2".to_string());
assert_eq!(engine.render_cache.len(), 2);
engine.set_max_cache_size(1);
assert!(engine.render_cache.is_empty());
}
}