1use anyhow::{anyhow, Result};
19use handlebars::Handlebars;
20use serde_json::Value as JsonValue;
21use std::collections::HashMap;
22
23#[derive(Debug, Clone, serde::Serialize)]
25pub struct TemplateContext {
26 pub item: JsonValue,
28 pub index: usize,
30 pub source_id: String,
32}
33
34pub struct TemplateEngine {
36 handlebars: Handlebars<'static>,
37}
38
39impl Default for TemplateEngine {
40 fn default() -> Self {
41 Self::new()
42 }
43}
44
45impl TemplateEngine {
46 pub fn new() -> Self {
48 let mut handlebars = Handlebars::new();
49 handlebars.set_strict_mode(false);
50 Self { handlebars }
51 }
52
53 pub fn render_string(&self, template: &str, context: &TemplateContext) -> Result<String> {
55 self.handlebars
56 .render_template(template, context)
57 .map_err(|e| anyhow!("Template render error: {e}"))
58 }
59
60 pub fn render_value(&self, template: &str, context: &TemplateContext) -> Result<JsonValue> {
66 if let Some(path) = extract_simple_path(template) {
68 let context_json = context_to_json(context);
69 if let Some(value) = resolve_path(&context_json, &path) {
70 return Ok(value.clone());
71 }
72 }
73
74 let rendered = self.render_string(template, context)?;
76 if rendered.is_empty() {
77 Ok(JsonValue::Null)
78 } else {
79 Ok(JsonValue::String(rendered))
80 }
81 }
82
83 pub fn render_properties(
86 &self,
87 properties: &JsonValue,
88 context: &TemplateContext,
89 ) -> Result<HashMap<String, JsonValue>> {
90 match properties {
91 JsonValue::Object(map) => {
92 let mut result = HashMap::new();
93 for (key, value) in map {
94 let rendered = self.render_property_value(value, context)?;
95 result.insert(key.clone(), rendered);
96 }
97 Ok(result)
98 }
99 _ => Err(anyhow!("Properties must be a JSON object")),
100 }
101 }
102
103 fn render_property_value(
105 &self,
106 value: &JsonValue,
107 context: &TemplateContext,
108 ) -> Result<JsonValue> {
109 match value {
110 JsonValue::String(template) => self.render_value(template, context),
111 JsonValue::Object(map) => {
112 let mut result = serde_json::Map::new();
113 for (key, v) in map {
114 let rendered = self.render_property_value(v, context)?;
115 result.insert(key.clone(), rendered);
116 }
117 Ok(JsonValue::Object(result))
118 }
119 JsonValue::Array(arr) => {
120 let mut result = Vec::new();
121 for v in arr {
122 let rendered = self.render_property_value(v, context)?;
123 result.push(rendered);
124 }
125 Ok(JsonValue::Array(result))
126 }
127 other => Ok(other.clone()),
129 }
130 }
131}
132
133fn extract_simple_path(template: &str) -> Option<String> {
136 let trimmed = template.trim();
137 if trimmed.starts_with("{{") && trimmed.ends_with("}}") {
138 let inner = trimmed[2..trimmed.len() - 2].trim();
139 if !inner.contains(' ')
141 && !inner.contains('#')
142 && !inner.contains('/')
143 && !inner.contains('{')
144 && !inner.contains('}')
145 {
146 return Some(inner.to_string());
147 }
148 }
149 None
150}
151
152fn resolve_path<'a>(value: &'a JsonValue, path: &str) -> Option<&'a JsonValue> {
154 let mut current = value;
155 for part in path.split('.') {
156 current = match current {
157 JsonValue::Object(obj) => obj.get(part)?,
158 JsonValue::Array(arr) => {
159 let index: usize = part.parse().ok()?;
160 arr.get(index)?
161 }
162 _ => return None,
163 };
164 }
165 Some(current)
166}
167
168fn context_to_json(context: &TemplateContext) -> JsonValue {
170 serde_json::to_value(context)
171 .expect("TemplateContext serialization should never fail (all fields are JSON-safe)")
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use serde_json::json;
178
179 #[test]
180 fn test_render_simple_template() {
181 let engine = TemplateEngine::new();
182 let context = TemplateContext {
183 item: json!({"id": "123", "name": "Alice"}),
184 index: 0,
185 source_id: "test-source".to_string(),
186 };
187
188 let result = engine.render_string("{{item.id}}", &context).unwrap();
189 assert_eq!(result, "123");
190
191 let result = engine.render_string("{{item.name}}", &context).unwrap();
192 assert_eq!(result, "Alice");
193 }
194
195 #[test]
196 fn test_render_value_preserves_types() {
197 let engine = TemplateEngine::new();
198 let context = TemplateContext {
199 item: json!({"id": "str-123", "count": 42, "rate": 3.15, "active": true}),
200 index: 0,
201 source_id: "test-source".to_string(),
202 };
203
204 assert_eq!(
206 engine.render_value("{{item.id}}", &context).unwrap(),
207 json!("str-123")
208 );
209 assert_eq!(
210 engine.render_value("{{item.count}}", &context).unwrap(),
211 json!(42)
212 );
213 assert_eq!(
214 engine.render_value("{{item.rate}}", &context).unwrap(),
215 json!(3.15)
216 );
217 assert_eq!(
218 engine.render_value("{{item.active}}", &context).unwrap(),
219 json!(true)
220 );
221 }
222
223 #[test]
224 fn test_render_value_complex_template_returns_string() {
225 let engine = TemplateEngine::new();
226 let context = TemplateContext {
227 item: json!({"id": 42, "prefix": "user"}),
228 index: 0,
229 source_id: "test-source".to_string(),
230 };
231
232 let result = engine
234 .render_value("{{item.prefix}}-{{item.id}}", &context)
235 .unwrap();
236 assert_eq!(result, json!("user-42"));
237 }
238
239 #[test]
240 fn test_render_properties_preserves_types() {
241 let engine = TemplateEngine::new();
242 let context = TemplateContext {
243 item: json!({"id": "123", "name": "Alice", "age": 30}),
244 index: 0,
245 source_id: "test-source".to_string(),
246 };
247
248 let props = json!({
249 "name": "{{item.name}}",
250 "age": "{{item.age}}"
251 });
252
253 let result = engine.render_properties(&props, &context).unwrap();
254 assert_eq!(result["name"], json!("Alice"));
255 assert_eq!(result["age"], json!(30)); }
257
258 #[test]
259 fn test_render_missing_field() {
260 let engine = TemplateEngine::new();
261 let context = TemplateContext {
262 item: json!({"id": "123"}),
263 index: 0,
264 source_id: "test-source".to_string(),
265 };
266
267 let result = engine
269 .render_string("{{item.nonexistent}}", &context)
270 .unwrap();
271 assert_eq!(result, "");
272 }
273
274 #[test]
275 fn test_extract_simple_path() {
276 assert_eq!(
277 extract_simple_path("{{item.id}}"),
278 Some("item.id".to_string())
279 );
280 assert_eq!(
281 extract_simple_path("{{item.nested.field}}"),
282 Some("item.nested.field".to_string())
283 );
284 assert_eq!(extract_simple_path("{{item.a}}-{{item.b}}"), None);
286 assert_eq!(extract_simple_path("prefix-{{item.id}}"), None);
287 assert_eq!(extract_simple_path("static text"), None);
288 }
289
290 #[test]
291 fn test_string_value_not_coerced_to_int() {
292 let engine = TemplateEngine::new();
293 let context = TemplateContext {
294 item: json!({"id": "42"}), index: 0,
296 source_id: "test-source".to_string(),
297 };
298
299 let result = engine.render_value("{{item.id}}", &context).unwrap();
301 assert_eq!(result, json!("42"));
302 assert!(result.is_string());
303 }
304}