Skip to main content

greentic_runner_host/runner/
templating.rs

1use 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}