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(true);
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 resolve_path(ctx, &path)
55            .cloned()
56            .ok_or_else(|| anyhow!("template expression `{expr}` not found"));
57    }
58
59    if raw.contains("{{") {
60        let rendered = HANDLEBARS
61            .render_template(raw, ctx)
62            .map_err(|err| anyhow!("template render failed: {err}"))?;
63        return Ok(Value::String(rendered));
64    }
65
66    Ok(Value::String(raw.to_string()))
67}
68
69fn extract_exact_expression(raw: &str) -> Option<&str> {
70    let trimmed = raw.trim();
71    if trimmed.starts_with("{{") && trimmed.ends_with("}}") {
72        let inner = trimmed.trim_start_matches('{').trim_end_matches('}').trim();
73        if !inner.is_empty() {
74            return Some(inner);
75        }
76    }
77    None
78}
79
80#[derive(Debug)]
81enum PathSegment {
82    Key(String),
83    Index(usize),
84}
85
86fn parse_path_expression(expr: &str) -> Option<Vec<PathSegment>> {
87    let mut chars = expr.trim().chars().peekable();
88    let mut segments = Vec::new();
89    while let Some(&ch) = chars.peek() {
90        match ch {
91            '.' => {
92                chars.next();
93            }
94            '[' => {
95                chars.next();
96                let segment = parse_bracket_segment(&mut chars)?;
97                segments.push(segment);
98            }
99            _ => {
100                let ident = parse_identifier(&mut chars)?;
101                segments.push(PathSegment::Key(ident));
102            }
103        }
104    }
105    if segments.is_empty() {
106        return None;
107    }
108    if matches!(segments.first(), Some(PathSegment::Key(key)) if key == "this") {
109        segments.remove(0);
110    }
111    Some(segments)
112}
113
114fn parse_bracket_segment<I>(chars: &mut std::iter::Peekable<I>) -> Option<PathSegment>
115where
116    I: Iterator<Item = char>,
117{
118    match chars.peek().copied() {
119        Some('"') | Some('\'') => {
120            let quote = chars.next()?;
121            let mut buf = String::new();
122            for ch in chars.by_ref() {
123                if ch == quote {
124                    break;
125                }
126                buf.push(ch);
127            }
128            consume_bracket_end(chars)?;
129            Some(PathSegment::Key(buf))
130        }
131        Some(ch) if ch.is_ascii_digit() => {
132            let mut buf = String::new();
133            while let Some(ch) = chars.peek().copied() {
134                if ch.is_ascii_digit() {
135                    chars.next();
136                    buf.push(ch);
137                } else {
138                    break;
139                }
140            }
141            consume_bracket_end(chars)?;
142            let idx = buf.parse::<usize>().ok()?;
143            Some(PathSegment::Index(idx))
144        }
145        Some(_) => {
146            let ident = parse_identifier(chars)?;
147            consume_bracket_end(chars)?;
148            Some(PathSegment::Key(ident))
149        }
150        None => None,
151    }
152}
153
154fn consume_bracket_end<I>(chars: &mut std::iter::Peekable<I>) -> Option<()>
155where
156    I: Iterator<Item = char>,
157{
158    for ch in chars.by_ref() {
159        if ch == ']' {
160            return Some(());
161        }
162        if !ch.is_whitespace() {
163            return None;
164        }
165    }
166    None
167}
168
169fn parse_identifier<I>(chars: &mut std::iter::Peekable<I>) -> Option<String>
170where
171    I: Iterator<Item = char>,
172{
173    let mut buf = String::new();
174    while let Some(&ch) = chars.peek() {
175        if ch == '.' || ch == '[' || ch == ']' {
176            break;
177        }
178        buf.push(ch);
179        chars.next();
180    }
181    let ident = buf.trim();
182    if ident.is_empty() {
183        return None;
184    }
185    Some(ident.to_string())
186}
187
188fn resolve_path<'a>(root: &'a Value, path: &[PathSegment]) -> Option<&'a Value> {
189    let mut current = root;
190    for segment in path {
191        match (segment, current) {
192            (PathSegment::Key(key), Value::Object(map)) => {
193                current = map.get(key)?;
194            }
195            (PathSegment::Index(index), Value::Array(items)) => {
196                current = items.get(*index)?;
197            }
198            _ => return None,
199        }
200    }
201    Some(current)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use serde_json::json;
208
209    #[test]
210    fn renders_prev_and_node_outputs() {
211        let ctx = json!({
212            "entry": {},
213            "prev": { "text": "hello" },
214            "node": {
215                "start": { "user": { "id": 7 } }
216            },
217            "state": {},
218        });
219        let template = json!({
220            "prev_text": "{{prev.text}}",
221            "user_id": "{{node.start.user.id}}"
222        });
223        let rendered = render_template_value(&template, &ctx, TemplateOptions::default()).unwrap();
224        assert_eq!(
225            rendered,
226            json!({
227                "prev_text": "hello",
228                "user_id": 7
229            })
230        );
231    }
232
233    #[test]
234    fn typed_insertion_keeps_json_types() {
235        let ctx = json!({
236            "entry": { "enabled": true, "count": 3 },
237            "prev": {},
238            "node": {},
239            "state": {},
240        });
241        let rendered = render_template_value(
242            &Value::String("{{entry.enabled}}".to_string()),
243            &ctx,
244            TemplateOptions::default(),
245        )
246        .unwrap();
247        assert_eq!(rendered, json!(true));
248
249        let rendered = render_template_value(
250            &Value::String("{{entry.count}}".to_string()),
251            &ctx,
252            TemplateOptions::default(),
253        )
254        .unwrap();
255        assert_eq!(rendered, json!(3));
256    }
257
258    #[test]
259    fn mixed_template_renders_as_string() {
260        let ctx = json!({
261            "entry": { "user_id": 42 },
262            "prev": {},
263            "node": {},
264            "state": {},
265        });
266        let rendered = render_template_value(
267            &Value::String("https://x/{{entry.user_id}}".to_string()),
268            &ctx,
269            TemplateOptions::default(),
270        )
271        .unwrap();
272        assert_eq!(rendered, Value::String("https://x/42".to_string()));
273    }
274}