greentic_runner_host/runner/
templating.rs1use anyhow::{Result, anyhow};
2use handlebars::Handlebars;
3use once_cell::sync::Lazy;
4use serde_json::{Map as JsonMap, Value};
5
6#[derive(Clone, Copy, Debug, Default)]
7pub struct TemplateOptions {
8 pub allow_pointer: bool,
9}
10
11static HANDLEBARS: Lazy<Handlebars<'static>> = Lazy::new(|| {
12 let mut registry = Handlebars::new();
13 registry.set_strict_mode(false);
14 registry.register_escape_fn(handlebars::no_escape);
15 registry
16});
17
18pub fn render_template_value(
19 template: &Value,
20 ctx: &Value,
21 options: TemplateOptions,
22) -> Result<Value> {
23 match template {
24 Value::String(raw) => render_template_string(raw, ctx, options),
25 Value::Array(items) => {
26 let mut rendered = Vec::with_capacity(items.len());
27 for item in items {
28 rendered.push(render_template_value(item, ctx, options)?);
29 }
30 Ok(Value::Array(rendered))
31 }
32 Value::Object(map) => {
33 let mut rendered = JsonMap::new();
34 for (key, value) in map {
35 rendered.insert(key.clone(), render_template_value(value, ctx, options)?);
36 }
37 Ok(Value::Object(rendered))
38 }
39 other => Ok(other.clone()),
40 }
41}
42
43fn render_template_string(raw: &str, ctx: &Value, options: TemplateOptions) -> Result<Value> {
44 if options.allow_pointer && raw.starts_with('/') && !raw.contains("{{") {
45 return ctx
46 .pointer(raw)
47 .cloned()
48 .ok_or_else(|| anyhow!("mapping path `{raw}` not found"));
49 }
50
51 if let Some(expr) = extract_exact_expression(raw)
52 && let Some(path) = parse_path_expression(expr)
53 {
54 return Ok(match resolve_path(ctx, &path) {
55 Some(value) => value.clone(),
56 None => {
57 tracing::warn!(
58 template = expr,
59 "template expression resolved to empty (path not found)"
60 );
61 Value::String(String::new())
62 }
63 });
64 }
65
66 if raw.contains("{{") {
67 let rendered = HANDLEBARS
68 .render_template(raw, ctx)
69 .map_err(|err| anyhow!("template render failed: {err}"))?;
70 return Ok(Value::String(rendered));
71 }
72
73 Ok(Value::String(raw.to_string()))
74}
75
76fn extract_exact_expression(raw: &str) -> Option<&str> {
77 let trimmed = raw.trim();
78 if trimmed.starts_with("{{") && trimmed.ends_with("}}") {
79 let inner = trimmed.trim_start_matches('{').trim_end_matches('}').trim();
80 if !inner.is_empty() {
81 return Some(inner);
82 }
83 }
84 None
85}
86
87#[derive(Debug)]
88enum PathSegment {
89 Key(String),
90 Index(usize),
91}
92
93fn parse_path_expression(expr: &str) -> Option<Vec<PathSegment>> {
94 let mut chars = expr.trim().chars().peekable();
95 let mut segments = Vec::new();
96 while let Some(&ch) = chars.peek() {
97 match ch {
98 '.' => {
99 chars.next();
100 }
101 '[' => {
102 chars.next();
103 let segment = parse_bracket_segment(&mut chars)?;
104 segments.push(segment);
105 }
106 _ => {
107 let ident = parse_identifier(&mut chars)?;
108 segments.push(PathSegment::Key(ident));
109 }
110 }
111 }
112 if segments.is_empty() {
113 return None;
114 }
115 if matches!(segments.first(), Some(PathSegment::Key(key)) if key == "this") {
116 segments.remove(0);
117 }
118 Some(segments)
119}
120
121fn parse_bracket_segment<I>(chars: &mut std::iter::Peekable<I>) -> Option<PathSegment>
122where
123 I: Iterator<Item = char>,
124{
125 match chars.peek().copied() {
126 Some('"') | Some('\'') => {
127 let quote = chars.next()?;
128 let mut buf = String::new();
129 for ch in chars.by_ref() {
130 if ch == quote {
131 break;
132 }
133 buf.push(ch);
134 }
135 consume_bracket_end(chars)?;
136 Some(PathSegment::Key(buf))
137 }
138 Some(ch) if ch.is_ascii_digit() => {
139 let mut buf = String::new();
140 while let Some(ch) = chars.peek().copied() {
141 if ch.is_ascii_digit() {
142 chars.next();
143 buf.push(ch);
144 } else {
145 break;
146 }
147 }
148 consume_bracket_end(chars)?;
149 let idx = buf.parse::<usize>().ok()?;
150 Some(PathSegment::Index(idx))
151 }
152 Some(_) => {
153 let ident = parse_identifier(chars)?;
154 consume_bracket_end(chars)?;
155 Some(PathSegment::Key(ident))
156 }
157 None => None,
158 }
159}
160
161fn consume_bracket_end<I>(chars: &mut std::iter::Peekable<I>) -> Option<()>
162where
163 I: Iterator<Item = char>,
164{
165 for ch in chars.by_ref() {
166 if ch == ']' {
167 return Some(());
168 }
169 if !ch.is_whitespace() {
170 return None;
171 }
172 }
173 None
174}
175
176fn parse_identifier<I>(chars: &mut std::iter::Peekable<I>) -> Option<String>
177where
178 I: Iterator<Item = char>,
179{
180 let mut buf = String::new();
181 while let Some(&ch) = chars.peek() {
182 if ch == '.' || ch == '[' || ch == ']' {
183 break;
184 }
185 buf.push(ch);
186 chars.next();
187 }
188 let ident = buf.trim();
189 if ident.is_empty() {
190 return None;
191 }
192 Some(ident.to_string())
193}
194
195fn resolve_path<'a>(root: &'a Value, path: &[PathSegment]) -> Option<&'a Value> {
196 let mut current = root;
197 for segment in path {
198 match (segment, current) {
199 (PathSegment::Key(key), Value::Object(map)) => {
200 current = map.get(key)?;
201 }
202 (PathSegment::Index(index), Value::Array(items)) => {
203 current = items.get(*index)?;
204 }
205 _ => return None,
206 }
207 }
208 Some(current)
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use serde_json::json;
215
216 #[test]
217 fn renders_prev_and_node_outputs() {
218 let ctx = json!({
219 "entry": {},
220 "prev": { "text": "hello" },
221 "node": {
222 "start": { "user": { "id": 7 } }
223 },
224 "state": {},
225 });
226 let template = json!({
227 "prev_text": "{{prev.text}}",
228 "user_id": "{{node.start.user.id}}"
229 });
230 let rendered = render_template_value(&template, &ctx, TemplateOptions::default()).unwrap();
231 assert_eq!(
232 rendered,
233 json!({
234 "prev_text": "hello",
235 "user_id": 7
236 })
237 );
238 }
239
240 #[test]
241 fn typed_insertion_keeps_json_types() {
242 let ctx = json!({
243 "entry": { "enabled": true, "count": 3 },
244 "prev": {},
245 "node": {},
246 "state": {},
247 });
248 let rendered = render_template_value(
249 &Value::String("{{entry.enabled}}".to_string()),
250 &ctx,
251 TemplateOptions::default(),
252 )
253 .unwrap();
254 assert_eq!(rendered, json!(true));
255
256 let rendered = render_template_value(
257 &Value::String("{{entry.count}}".to_string()),
258 &ctx,
259 TemplateOptions::default(),
260 )
261 .unwrap();
262 assert_eq!(rendered, json!(3));
263 }
264
265 #[test]
266 fn missing_exact_path_renders_empty_string() {
267 let ctx = json!({
268 "entry": { "input": { "metadata": { "user_question": "what" } } },
269 "prev": {},
270 "node": {},
271 "state": {},
272 });
273 let rendered = render_template_value(
274 &Value::String("{{entry.input.metadata.provider}}".to_string()),
275 &ctx,
276 TemplateOptions::default(),
277 )
278 .unwrap();
279 assert_eq!(rendered, Value::String(String::new()));
280 }
281
282 #[test]
283 fn mixed_template_renders_as_string() {
284 let ctx = json!({
285 "entry": { "user_id": 42 },
286 "prev": {},
287 "node": {},
288 "state": {},
289 });
290 let rendered = render_template_value(
291 &Value::String("https://x/{{entry.user_id}}".to_string()),
292 &ctx,
293 TemplateOptions::default(),
294 )
295 .unwrap();
296 assert_eq!(rendered, Value::String("https://x/42".to_string()));
297 }
298}