Skip to main content

agentd/engine/
template.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Workflow **templates** (RFC 0027 §3): `{{path}}` interpolation with dotted /
3//! JSON-pointer paths and `{{path | default}}` over the run data (`inputs`,
4//! `run`, `steps.<id>.output`, `vars`, `memory.<key>`, `item`, `index`,
5//! `batch`, `env`), plus `CEL:` expressions over the same names (feature
6//! `cel`; a non-CEL build refuses them at validation). Dependency-free.
7//!
8//! Rules: a string that is exactly one `{{path}}` yields the value itself
9//! (typed); any other string interpolates (strings raw, other values as
10//! JSON); objects and arrays render recursively; a missing path with no
11//! default is an error (the step takes its error edge rather than running
12//! with a silently-wrong shape).
13
14use serde_json::{Map, Value};
15use std::collections::BTreeMap;
16
17/// The named inputs of a render (RFC 0027 §3 data model).
18pub type Data = BTreeMap<String, Value>;
19
20/// Render `template` over `data`.
21pub fn render(template: &Value, data: &Data) -> Result<Value, String> {
22    match template {
23        Value::String(s) => render_str(s, data),
24        Value::Array(a) => a
25            .iter()
26            .map(|v| render(v, data))
27            .collect::<Result<Vec<_>, _>>()
28            .map(Value::Array),
29        Value::Object(o) => {
30            let mut out = Map::new();
31            for (k, v) in o {
32                out.insert(k.clone(), render(v, data)?);
33            }
34            Ok(Value::Object(out))
35        }
36        other => Ok(other.clone()),
37    }
38}
39
40/// Render a string template: `CEL:` expression, a lone `{{path}}` (typed), or
41/// interpolation.
42pub fn render_str(s: &str, data: &Data) -> Result<Value, String> {
43    let t = s.trim_start();
44    if let Some(expr) = t.strip_prefix("CEL:") {
45        let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
46        return crate::cel::eval_value(expr.trim(), &vars).map_err(|e| format!("CEL: {e}"));
47    }
48    if !s.contains("{{") {
49        return Ok(Value::String(s.to_string()));
50    }
51    // A lone placeholder yields the typed value.
52    let trimmed = s.trim();
53    if trimmed.starts_with("{{") && trimmed.ends_with("}}") && trimmed.matches("{{").count() == 1 {
54        let inner = &trimmed[2..trimmed.len() - 2];
55        return resolve_placeholder(inner, data);
56    }
57    let mut out = String::with_capacity(s.len());
58    let mut rest = s;
59    while let Some(start) = rest.find("{{") {
60        out.push_str(&rest[..start]);
61        let after = &rest[start + 2..];
62        let Some(end) = after.find("}}") else {
63            return Err(format!("unterminated placeholder in {s:?}"));
64        };
65        let inner = &after[..end];
66        let v = resolve_placeholder(inner, data)?;
67        out.push_str(&match v {
68            Value::String(x) => x,
69            Value::Null => String::new(),
70            other => other.to_string(),
71        });
72        rest = &after[end + 2..];
73    }
74    out.push_str(rest);
75    Ok(Value::String(out))
76}
77
78/// `path` or `path | default` (default parsed as JSON, else a literal string).
79fn resolve_placeholder(inner: &str, data: &Data) -> Result<Value, String> {
80    // `{{secret:NAME}}` / `{{secret-file:PATH}}` are config-secret references,
81    // not workflow data. Leave them verbatim so the consuming node resolves
82    // them through the redacting secret resolver — a credential must never be
83    // expanded into rendered step data (and thence into logs/outputs).
84    let t = inner.trim();
85    if t.starts_with("secret:") || t.starts_with("secret-file:") {
86        return Ok(Value::String(format!("{{{{{t}}}}}")));
87    }
88    let (path, default) = match inner.split_once('|') {
89        Some((p, d)) => (p.trim(), Some(d.trim())),
90        None => (inner.trim(), None),
91    };
92    match lookup(path, data) {
93        Some(v) => Ok(v),
94        None => match default {
95            Some(d) => Ok(serde_json::from_str::<Value>(d)
96                .unwrap_or_else(|_| Value::String(d.trim_matches(['"', '\'']).to_string()))),
97            None => Err(format!(
98                "template path {path:?} is not set (no default given)"
99            )),
100        },
101    }
102}
103
104/// Look up a dotted or JSON-pointer path in the data.
105pub fn lookup(path: &str, data: &Data) -> Option<Value> {
106    if path.is_empty() {
107        return None;
108    }
109    if let Some(p) = path.strip_prefix('/') {
110        let (head, rest) = match p.split_once('/') {
111            Some((h, r)) => (h, Some(r)),
112            None => (p, None),
113        };
114        let root = data.get(head)?;
115        return match rest {
116            None => Some(root.clone()),
117            Some(r) => root.pointer(&format!("/{r}")).cloned(),
118        };
119    }
120    let mut segs = path.split('.');
121    let head = segs.next()?;
122    let mut cur = data.get(head)?;
123    for seg in segs {
124        cur = match cur {
125            Value::Object(m) => m.get(seg)?,
126            Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
127            _ => return None,
128        };
129    }
130    Some(cur.clone())
131}
132
133/// Every `{{path}}` root referenced by a template (`steps`, `vars`, …) — for
134/// validation / dependency hints.
135pub fn referenced_roots(template: &Value) -> Vec<String> {
136    let mut out = Vec::new();
137    fn walk(v: &Value, out: &mut Vec<String>) {
138        match v {
139            Value::String(s) => {
140                let mut rest = s.as_str();
141                while let Some(start) = rest.find("{{") {
142                    let after = &rest[start + 2..];
143                    let Some(end) = after.find("}}") else { break };
144                    let inner = after[..end].split('|').next().unwrap_or("").trim();
145                    let root = inner
146                        .trim_start_matches('/')
147                        .split(['.', '/'])
148                        .next()
149                        .unwrap_or("")
150                        .to_string();
151                    if !root.is_empty() && !out.contains(&root) {
152                        out.push(root);
153                    }
154                    rest = &after[end + 2..];
155                }
156            }
157            Value::Array(a) => a.iter().for_each(|x| walk(x, out)),
158            Value::Object(o) => o.values().for_each(|x| walk(x, out)),
159            _ => {}
160        }
161    }
162    walk(template, &mut out);
163    out
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use serde_json::json;
170
171    fn data() -> Data {
172        let mut d = Data::new();
173        d.insert("inputs".into(), json!({"instruction": "do it", "n": 3}));
174        d.insert(
175            "steps".into(),
176            json!({"fetch": {"status": "done", "output": {"items": [1, 2, 3], "name": "x"}}}),
177        );
178        d.insert("vars".into(), json!({"count": 2}));
179        d.insert(
180            "env".into(),
181            json!({"instance": "i", "instruction": "brief"}),
182        );
183        d
184    }
185
186    #[test]
187    fn typed_lone_placeholders_interpolation_defaults_and_pointers() {
188        let d = data();
189        assert_eq!(
190            render(&json!("{{steps.fetch.output.items}}"), &d).unwrap(),
191            json!([1, 2, 3])
192        );
193        assert_eq!(render(&json!("  {{inputs.n}} "), &d).unwrap(), json!(3));
194        assert_eq!(render(&json!("count={{vars.count}}, first={{steps.fetch.output.items.0}}, name={{steps.fetch.output.name}}"), &d).unwrap(), json!("count=2, first=1, name=x"));
195        assert_eq!(
196            render(&json!("{{/steps/fetch/output/items/1}}"), &d).unwrap(),
197            json!(2)
198        );
199        assert_eq!(
200            render(&json!("{{vars.missing | 7}}"), &d).unwrap(),
201            json!(7)
202        );
203        assert_eq!(
204            render(&json!("{{vars.missing | \"dflt\"}}"), &d).unwrap(),
205            json!("dflt")
206        );
207        assert_eq!(
208            render(&json!("x{{vars.missing | y}}z"), &d).unwrap(),
209            json!("xyz")
210        );
211        assert!(
212            render(&json!("{{vars.missing}}"), &d)
213                .unwrap_err()
214                .contains("not set")
215        );
216        assert!(
217            render(&json!("{{oops"), &d)
218                .unwrap_err()
219                .contains("unterminated")
220        );
221        // Recursion into objects/arrays; non-strings pass through.
222        let v = render(
223            &json!({"a": ["{{inputs.n}}", {"b": "{{env.instruction}}"}], "c": 5, "d": null}),
224            &d,
225        )
226        .unwrap();
227        assert_eq!(v, json!({"a": [3, {"b": "brief"}], "c": 5, "d": null}));
228        assert_eq!(
229            referenced_roots(
230                &json!({"a": "{{steps.x.output}} {{vars.y | 1}}", "b": ["{{/inputs/z}}"]})
231            ),
232            vec!["steps", "vars", "inputs"]
233        );
234        assert_eq!(render(&json!("plain"), &d).unwrap(), json!("plain"));
235    }
236
237    #[cfg(feature = "cel")]
238    #[test]
239    fn cel_values_evaluate_over_the_data() {
240        let d = data();
241        assert_eq!(render(&json!("CEL: inputs.n * 2"), &d).unwrap(), json!(6));
242        assert_eq!(
243            render(&json!("CEL: steps.fetch.output.items.size()"), &d).unwrap(),
244            json!(3)
245        );
246        assert!(render(&json!("CEL: nope.x"), &d).is_err());
247    }
248}