use crate::utils::error::{Error, Result};
use serde_json;
use std::fs;
use std::path::{Path, PathBuf};
pub struct PrepCtx<'a> {
pub template_path: &'a Path,
pub out_dir: &'a Path,
pub vars_json: &'a serde_json::Value, }
pub trait Stage: Send + Sync {
fn name(&self) -> &'static str;
fn run(&self, input: &str, ctx: &PrepCtx) -> Result<String>;
}
#[derive(Debug, Clone)]
pub enum FreezePolicy {
Always,
Checksum,
Never,
}
pub struct FreezeStage {
pub slots_dir: PathBuf,
pub policy: FreezePolicy,
}
impl Stage for FreezeStage {
fn name(&self) -> &'static str {
"freeze"
}
fn run(&self, input: &str, ctx: &PrepCtx) -> Result<String> {
process_freeze_blocks(input, ctx, self)
}
}
pub struct IncludeStage {
pub template_dirs: Vec<PathBuf>,
}
impl Stage for IncludeStage {
fn name(&self) -> &'static str {
"include"
}
fn run(&self, input: &str, ctx: &PrepCtx) -> Result<String> {
process_includes(input, ctx, self)
}
}
pub struct Preprocessor {
stages: Vec<Box<dyn Stage>>,
}
impl Default for Preprocessor {
fn default() -> Self {
Self::new()
}
}
impl Preprocessor {
pub fn new() -> Self {
Self { stages: Vec::new() }
}
pub fn with<S: Stage + 'static>(mut self, stage: S) -> Self {
self.stages.push(Box::new(stage));
self
}
pub fn run(&self, mut input: String, ctx: &PrepCtx) -> Result<String> {
for stage in &self.stages {
input = stage.run(&input, ctx).map_err(|e| {
Error::with_source(&format!("Stage '{}' failed", stage.name()), Box::new(e))
})?;
}
Ok(input)
}
pub fn with_default_stages() -> Self {
Self::new().with(IncludeStage {
template_dirs: vec![PathBuf::from("templates")],
})
}
pub fn with_freeze(slots_dir: PathBuf, policy: FreezePolicy) -> Self {
Self::new()
.with(IncludeStage {
template_dirs: vec![PathBuf::from("templates")],
})
.with(FreezeStage { slots_dir, policy })
}
}
fn process_freeze_blocks(input: &str, ctx: &PrepCtx, stage: &FreezeStage) -> Result<String> {
let mut result = String::new();
let mut pos = 0;
while let Some(start) = input[pos..].find("{% startfreeze") {
let absolute_start = pos + start;
let end_start = if let Some(end) = input[absolute_start..].find("%}") {
absolute_start + end + 2
} else {
return Err(Error::new(&format!(
"Unclosed startfreeze tag at position {}",
absolute_start
)));
};
let endfreeze_start = if let Some(end) = input[end_start..].find("{% endfreeze %}") {
end_start + end
} else {
return Err(Error::new(&format!(
"Missing endfreeze tag for startfreeze at position {}",
absolute_start
)));
};
let endfreeze_end = endfreeze_start + "{% endfreeze %}".len();
let block_content = &input[end_start..endfreeze_start];
let start_tag = &input[absolute_start..end_start];
let (id, checksum) = parse_freeze_attributes(start_tag)?;
let final_id = id.unwrap_or_else(|| generate_default_id(ctx.template_path, absolute_start));
let final_checksum = checksum.unwrap_or_else(|| generate_checksum(block_content));
let processed_content = match stage.policy {
FreezePolicy::Always => {
load_or_generate_slot(&stage.slots_dir, &final_id, &final_checksum, block_content)?
}
FreezePolicy::Checksum => {
load_or_generate_slot(&stage.slots_dir, &final_id, &final_checksum, block_content)?
}
FreezePolicy::Never => {
generate_and_save_slot(&stage.slots_dir, &final_id, &final_checksum, block_content)?
}
};
result.push_str(&input[pos..absolute_start]);
result.push_str(&processed_content);
pos = endfreeze_end;
}
result.push_str(&input[pos..]);
Ok(result)
}
fn process_includes(input: &str, ctx: &PrepCtx, stage: &IncludeStage) -> Result<String> {
let mut result = String::new();
let mut pos = 0;
while let Some(start) = input[pos..].find("{% include") {
let absolute_start = pos + start;
let end_start = if let Some(end) = input[absolute_start..].find("%}") {
absolute_start + end + 2
} else {
return Err(Error::new(&format!(
"Unclosed include tag at position {}",
absolute_start
)));
};
let include_tag = &input[absolute_start..end_start];
let include_path = parse_include_path(include_tag)?;
let resolved_path = resolve_include_path(&include_path, ctx, &stage.template_dirs)?;
let included_content = fs::read_to_string(&resolved_path).map_err(|e| {
Error::new(&format!(
"Failed to read include file '{}': {}",
resolved_path.display(),
e
))
})?;
result.push_str(&input[pos..absolute_start]);
result.push_str(&included_content);
pos = end_start;
}
result.push_str(&input[pos..]);
Ok(result)
}
fn parse_freeze_attributes(tag: &str) -> Result<(Option<String>, Option<String>)> {
let mut id = None;
let mut checksum = None;
if let Some(id_start) = tag.find("id=\"") {
let id_start = id_start + 4;
if let Some(id_end) = tag[id_start..].find("\"") {
id = Some(tag[id_start..id_start + id_end].to_string());
}
}
if let Some(cs_start) = tag.find("checksum=\"") {
let cs_start = cs_start + 10;
if let Some(cs_end) = tag[cs_start..].find("\"") {
checksum = Some(tag[cs_start..cs_start + cs_end].to_string());
}
}
Ok((id, checksum))
}
fn parse_include_path(tag: &str) -> Result<String> {
let path_start = if let Some(start) = tag.find("\"") {
start + 1
} else if let Some(start) = tag.find("'") {
start + 1
} else {
return Err(Error::new(&format!("Invalid include tag format: {}", tag)));
};
let path_end = if let Some(end) = tag[path_start..].find("\"") {
path_start + end
} else if let Some(end) = tag[path_start..].find("'") {
path_start + end
} else {
return Err(Error::new(&format!(
"Unclosed include path in tag: {}",
tag
)));
};
Ok(tag[path_start..path_end].to_string())
}
fn resolve_include_path(
include_path: &str, ctx: &PrepCtx, template_dirs: &[PathBuf],
) -> Result<PathBuf> {
if let Some(template_dir) = ctx.template_path.parent() {
let relative_path = template_dir.join(include_path);
if relative_path.exists() {
return Ok(relative_path);
}
}
for template_dir in template_dirs {
let full_path = template_dir.join(include_path);
if full_path.exists() {
return Ok(full_path);
}
}
Err(Error::new(&format!(
"Include file not found: {}",
include_path
)))
}
fn generate_default_id(template_path: &Path, position: usize) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
template_path.hash(&mut hasher);
position.hash(&mut hasher);
format!("slot_{:x}", hasher.finish())
}
fn generate_checksum(content: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
fn load_or_generate_slot(
slots_dir: &Path, id: &str, checksum: &str, content: &str,
) -> Result<String> {
let slot_path = slots_dir.join(format!("{}.slot", id));
let checksum_path = slots_dir.join(format!("{}.checksum", id));
if slot_path.exists() && checksum_path.exists() {
if let Ok(cached_checksum) = fs::read_to_string(&checksum_path) {
if cached_checksum.trim() == checksum {
return fs::read_to_string(&slot_path)
.map_err(|e| Error::with_source("Failed to read cached slot", Box::new(e)));
}
}
}
generate_and_save_slot(slots_dir, id, checksum, content)
}
fn generate_and_save_slot(
slots_dir: &Path, id: &str, checksum: &str, content: &str,
) -> Result<String> {
fs::create_dir_all(slots_dir)
.map_err(|e| Error::with_source("Failed to create slots directory", Box::new(e)))?;
let slot_path = slots_dir.join(format!("{}.slot", id));
let checksum_path = slots_dir.join(format!("{}.checksum", id));
fs::write(&slot_path, content)
.map_err(|e| Error::with_source("Failed to write slot file", Box::new(e)))?;
fs::write(&checksum_path, checksum)
.map_err(|e| Error::with_source("Failed to write checksum file", Box::new(e)))?;
Ok(content.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_ctx() -> PrepCtx<'static> {
PrepCtx {
template_path: Path::new("test.tmpl"),
out_dir: Path::new("output"),
vars_json: &serde_json::Value::Null,
}
}
#[test]
fn test_freeze_stage_basic() -> Result<()> {
let temp_dir = TempDir::new()?;
let stage = FreezeStage {
slots_dir: temp_dir.path().to_path_buf(),
policy: FreezePolicy::Always,
};
let input = r#"Hello
{% startfreeze id="test" %}
World
{% endfreeze %}
!"#;
let ctx = create_test_ctx();
let result = stage.run(input, &ctx)?;
assert!(result.contains("World"));
assert!(!result.contains("startfreeze"));
assert!(!result.contains("endfreeze"));
Ok(())
}
#[test]
fn test_include_stage_basic() -> Result<()> {
let temp_dir = TempDir::new()?;
let include_file = temp_dir.path().join("included.tmpl");
fs::write(&include_file, "Included content")?;
let stage = IncludeStage {
template_dirs: vec![temp_dir.path().to_path_buf()],
};
let input = r#"Hello
{% include "included.tmpl" %}
!"#;
let ctx = create_test_ctx();
let result = stage.run(input, &ctx)?;
assert!(result.contains("Included content"));
assert!(!result.contains("include"));
Ok(())
}
#[test]
fn test_preprocessor_pipeline() -> Result<()> {
let temp_dir = TempDir::new()?;
let include_file = temp_dir.path().join("included.tmpl");
fs::write(&include_file, "Included")?;
let preprocessor = Preprocessor::new()
.with(IncludeStage {
template_dirs: vec![temp_dir.path().to_path_buf()],
})
.with(FreezeStage {
slots_dir: temp_dir.path().join("slots"),
policy: FreezePolicy::Always,
});
let input = r#"Hello
{% include "included.tmpl" %}
{% startfreeze id="test" %}
Frozen
{% endfreeze %}
!"#;
let ctx = create_test_ctx();
let result = preprocessor.run(input.to_string(), &ctx)?;
assert!(result.contains("Included"));
assert!(result.contains("Frozen"));
assert!(!result.contains("include"));
assert!(!result.contains("startfreeze"));
Ok(())
}
}