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(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}