use anyhow::{anyhow, Result};
use handlebars::Handlebars;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
#[derive(Debug, Clone, serde::Serialize)]
pub struct TemplateContext {
pub item: JsonValue,
pub index: usize,
pub source_id: String,
}
pub struct TemplateEngine {
handlebars: Handlebars<'static>,
}
impl Default for TemplateEngine {
fn default() -> Self {
Self::new()
}
}
impl TemplateEngine {
pub fn new() -> Self {
let mut handlebars = Handlebars::new();
handlebars.set_strict_mode(false);
Self { handlebars }
}
pub fn render_string(&self, template: &str, context: &TemplateContext) -> Result<String> {
self.handlebars
.render_template(template, context)
.map_err(|e| anyhow!("Template render error: {e}"))
}
pub fn render_value(&self, template: &str, context: &TemplateContext) -> Result<JsonValue> {
if let Some(path) = extract_simple_path(template) {
let context_json = context_to_json(context);
if let Some(value) = resolve_path(&context_json, &path) {
return Ok(value.clone());
}
}
let rendered = self.render_string(template, context)?;
if rendered.is_empty() {
Ok(JsonValue::Null)
} else {
Ok(JsonValue::String(rendered))
}
}
pub fn render_properties(
&self,
properties: &JsonValue,
context: &TemplateContext,
) -> Result<HashMap<String, JsonValue>> {
match properties {
JsonValue::Object(map) => {
let mut result = HashMap::new();
for (key, value) in map {
let rendered = self.render_property_value(value, context)?;
result.insert(key.clone(), rendered);
}
Ok(result)
}
_ => Err(anyhow!("Properties must be a JSON object")),
}
}
fn render_property_value(
&self,
value: &JsonValue,
context: &TemplateContext,
) -> Result<JsonValue> {
match value {
JsonValue::String(template) => self.render_value(template, context),
JsonValue::Object(map) => {
let mut result = serde_json::Map::new();
for (key, v) in map {
let rendered = self.render_property_value(v, context)?;
result.insert(key.clone(), rendered);
}
Ok(JsonValue::Object(result))
}
JsonValue::Array(arr) => {
let mut result = Vec::new();
for v in arr {
let rendered = self.render_property_value(v, context)?;
result.push(rendered);
}
Ok(JsonValue::Array(result))
}
other => Ok(other.clone()),
}
}
}
fn extract_simple_path(template: &str) -> Option<String> {
let trimmed = template.trim();
if trimmed.starts_with("{{") && trimmed.ends_with("}}") {
let inner = trimmed[2..trimmed.len() - 2].trim();
if !inner.contains(' ')
&& !inner.contains('#')
&& !inner.contains('/')
&& !inner.contains('{')
&& !inner.contains('}')
{
return Some(inner.to_string());
}
}
None
}
fn resolve_path<'a>(value: &'a JsonValue, path: &str) -> Option<&'a JsonValue> {
let mut current = value;
for part in path.split('.') {
current = match current {
JsonValue::Object(obj) => obj.get(part)?,
JsonValue::Array(arr) => {
let index: usize = part.parse().ok()?;
arr.get(index)?
}
_ => return None,
};
}
Some(current)
}
fn context_to_json(context: &TemplateContext) -> JsonValue {
serde_json::to_value(context)
.expect("TemplateContext serialization should never fail (all fields are JSON-safe)")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_render_simple_template() {
let engine = TemplateEngine::new();
let context = TemplateContext {
item: json!({"id": "123", "name": "Alice"}),
index: 0,
source_id: "test-source".to_string(),
};
let result = engine.render_string("{{item.id}}", &context).unwrap();
assert_eq!(result, "123");
let result = engine.render_string("{{item.name}}", &context).unwrap();
assert_eq!(result, "Alice");
}
#[test]
fn test_render_value_preserves_types() {
let engine = TemplateEngine::new();
let context = TemplateContext {
item: json!({"id": "str-123", "count": 42, "rate": 3.15, "active": true}),
index: 0,
source_id: "test-source".to_string(),
};
assert_eq!(
engine.render_value("{{item.id}}", &context).unwrap(),
json!("str-123")
);
assert_eq!(
engine.render_value("{{item.count}}", &context).unwrap(),
json!(42)
);
assert_eq!(
engine.render_value("{{item.rate}}", &context).unwrap(),
json!(3.15)
);
assert_eq!(
engine.render_value("{{item.active}}", &context).unwrap(),
json!(true)
);
}
#[test]
fn test_render_value_complex_template_returns_string() {
let engine = TemplateEngine::new();
let context = TemplateContext {
item: json!({"id": 42, "prefix": "user"}),
index: 0,
source_id: "test-source".to_string(),
};
let result = engine
.render_value("{{item.prefix}}-{{item.id}}", &context)
.unwrap();
assert_eq!(result, json!("user-42"));
}
#[test]
fn test_render_properties_preserves_types() {
let engine = TemplateEngine::new();
let context = TemplateContext {
item: json!({"id": "123", "name": "Alice", "age": 30}),
index: 0,
source_id: "test-source".to_string(),
};
let props = json!({
"name": "{{item.name}}",
"age": "{{item.age}}"
});
let result = engine.render_properties(&props, &context).unwrap();
assert_eq!(result["name"], json!("Alice"));
assert_eq!(result["age"], json!(30)); }
#[test]
fn test_render_missing_field() {
let engine = TemplateEngine::new();
let context = TemplateContext {
item: json!({"id": "123"}),
index: 0,
source_id: "test-source".to_string(),
};
let result = engine
.render_string("{{item.nonexistent}}", &context)
.unwrap();
assert_eq!(result, "");
}
#[test]
fn test_extract_simple_path() {
assert_eq!(
extract_simple_path("{{item.id}}"),
Some("item.id".to_string())
);
assert_eq!(
extract_simple_path("{{item.nested.field}}"),
Some("item.nested.field".to_string())
);
assert_eq!(extract_simple_path("{{item.a}}-{{item.b}}"), None);
assert_eq!(extract_simple_path("prefix-{{item.id}}"), None);
assert_eq!(extract_simple_path("static text"), None);
}
#[test]
fn test_string_value_not_coerced_to_int() {
let engine = TemplateEngine::new();
let context = TemplateContext {
item: json!({"id": "42"}), index: 0,
source_id: "test-source".to_string(),
};
let result = engine.render_value("{{item.id}}", &context).unwrap();
assert_eq!(result, json!("42"));
assert!(result.is_string());
}
}