Skip to main content

agentd/store/
mapping.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **mapping language** the store adapters and the tool overrides share:
3//! render an argument object / URL / body from a template over named inputs,
4//! and extract a value from a result.
5//!
6//! - **JSON templates** — a JSON text with `{name}` placeholders: a placeholder
7//!   inside quotes (`"{key}"`) is substituted as JSON-escaped string content; a
8//!   bare placeholder (`"seq": {seq}`, `"state": {envelope}`) is substituted as
9//!   the JSON serialization of the value; the result must parse as JSON. A
10//!   template that is exactly one bare placeholder yields the value itself.
11//! - **Text templates** — `{name}` substituted textually (URLs, header values).
12//! - **`CEL:` expressions** — evaluated over the same inputs (feature `cel`;
13//!   an error without the feature).
14//! - **Extraction** — a JSON pointer (`/result/structuredContent/state`), a
15//!   dotted path (`result.structuredContent.state`, numeric segments index
16//!   arrays), or `CEL:` over the context.
17//!
18//! No dependency: placeholders are `{ident}` or `{{ident}}` where ident is
19//! `[A-Za-z_][A-Za-z0-9_.]*` (dotted paths reach into object inputs) — JSON
20//! braces are followed by `"` / `}` / whitespace, never an identifier, so the
21//! two never collide.
22
23use serde_json::{Map, Value};
24use std::collections::BTreeMap;
25
26/// A rendering/extraction failure (a template that does not parse, an unknown
27/// placeholder, a CEL error).
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct MappingError(pub String);
30
31impl std::fmt::Display for MappingError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.write_str(&self.0)
34    }
35}
36
37impl std::error::Error for MappingError {}
38
39/// The named inputs a template renders over.
40pub type Vars = BTreeMap<String, Value>;
41
42/// Render a JSON template (or a `CEL:` expression) into a value.
43pub fn render_json(template: &str, vars: &Vars) -> Result<Value, MappingError> {
44    let t = template.trim();
45    if let Some(expr) = t.strip_prefix("CEL:") {
46        return crate::cel::eval_value(expr.trim(), &crate::cel::vars_of(vars))
47            .map_err(|e| MappingError(format!("CEL: {e}")));
48    }
49    // A lone placeholder yields the value itself (any type).
50    if let Some(name) = lone_placeholder(t) {
51        return vars
52            .get(name)
53            .cloned()
54            .ok_or_else(|| MappingError(format!("unknown placeholder {{{name}}}")));
55    }
56    let text = substitute(t, vars, Mode::Json)?;
57    serde_json::from_str(&text).map_err(|e| {
58        MappingError(format!(
59            "template does not render to valid JSON: {e} (rendered: {})",
60            text.chars().take(200).collect::<String>()
61        ))
62    })
63}
64
65/// Render a text template (`{name}` substituted textually) or a `CEL:`
66/// expression (its result stringified).
67pub fn render_text(template: &str, vars: &Vars) -> Result<String, MappingError> {
68    let t = template.trim();
69    if let Some(expr) = t.strip_prefix("CEL:") {
70        let v = crate::cel::eval_value(expr.trim(), &crate::cel::vars_of(vars))
71            .map_err(|e| MappingError(format!("CEL: {e}")))?;
72        return Ok(match v {
73            Value::String(s) => s,
74            other => other.to_string(),
75        });
76    }
77    substitute(t, vars, Mode::Text)
78}
79
80/// Extract a value from `ctx` by JSON pointer, dotted path, or `CEL:`.
81/// `None` when the path does not resolve (a CEL error is `Err`).
82pub fn extract(expr: &str, ctx: &Value) -> Result<Option<Value>, MappingError> {
83    let e = expr.trim();
84    if let Some(cel) = e.strip_prefix("CEL:") {
85        let vars: Vars = match ctx {
86            Value::Object(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
87            other => {
88                let mut m = Vars::new();
89                m.insert("value".into(), other.clone());
90                m
91            }
92        };
93        return crate::cel::eval_value(cel.trim(), &crate::cel::vars_of(&vars))
94            .map(Some)
95            .map_err(|e| MappingError(format!("CEL: {e}")));
96    }
97    if e.is_empty() {
98        return Ok(Some(ctx.clone()));
99    }
100    if e.starts_with('/') {
101        return Ok(ctx.pointer(e).cloned());
102    }
103    // Dotted path with numeric array indexes.
104    let mut cur = ctx;
105    for seg in e.split('.') {
106        cur = match cur {
107            Value::Object(m) => match m.get(seg) {
108                Some(v) => v,
109                None => return Ok(None),
110            },
111            Value::Array(a) => match seg.parse::<usize>().ok().and_then(|i| a.get(i)) {
112                Some(v) => v,
113                None => return Ok(None),
114            },
115            _ => return Ok(None),
116        };
117    }
118    Ok(Some(cur.clone()))
119}
120
121/// Truthiness for `ok`/`conflict` predicates: `true`, a non-zero number, a
122/// non-empty string/array/object.
123pub fn truthy(v: &Value) -> bool {
124    match v {
125        Value::Null => false,
126        Value::Bool(b) => *b,
127        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
128        Value::String(s) => !s.is_empty(),
129        Value::Array(a) => !a.is_empty(),
130        Value::Object(o) => !o.is_empty(),
131    }
132}
133
134/// Build the standard `Vars` for a store operation.
135pub fn store_vars(
136    key: &str,
137    seq: Option<u64>,
138    prefix: &str,
139    instance: &str,
140    envelope: Option<&Value>,
141    kind: &str,
142    id: &str,
143) -> Vars {
144    let mut v = Vars::new();
145    v.insert("key".into(), Value::String(key.to_string()));
146    v.insert("seq".into(), seq.map(Value::from).unwrap_or(Value::Null));
147    v.insert("prefix".into(), Value::String(prefix.to_string()));
148    v.insert("instance".into(), Value::String(instance.to_string()));
149    v.insert("envelope".into(), envelope.cloned().unwrap_or(Value::Null));
150    v.insert("kind".into(), Value::String(kind.to_string()));
151    v.insert("id".into(), Value::String(id.to_string()));
152    v
153}
154
155#[derive(Clone, Copy, PartialEq, Eq)]
156enum Mode {
157    Json,
158    Text,
159}
160
161fn lone_placeholder(t: &str) -> Option<&str> {
162    let inner = t.strip_prefix('{')?.strip_suffix('}')?;
163    let inner = match (inner.strip_prefix('{'), inner.strip_suffix('}')) {
164        (Some(a), Some(_)) => &a[..a.len() - 1],
165        _ => inner,
166    };
167    is_ident(inner).then_some(inner)
168}
169
170fn is_ident(s: &str) -> bool {
171    let mut chars = s.chars();
172    match chars.next() {
173        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
174        _ => return false,
175    }
176    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
177}
178
179/// Look up a (possibly dotted) placeholder in the vars.
180fn lookup<'a>(vars: &'a Vars, name: &str) -> Option<&'a Value> {
181    if let Some(v) = vars.get(name) {
182        return Some(v);
183    }
184    let (head, rest) = name.split_once('.')?;
185    let mut cur = vars.get(head)?;
186    for seg in rest.split('.') {
187        cur = match cur {
188            Value::Object(m) => m.get(seg)?,
189            Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
190            _ => return None,
191        };
192    }
193    Some(cur)
194}
195
196fn substitute(t: &str, vars: &Vars, mode: Mode) -> Result<String, MappingError> {
197    let bytes = t.as_bytes();
198    let mut out = String::with_capacity(t.len() + 32);
199    let mut i = 0;
200    while i < bytes.len() {
201        if bytes[i] == b'{' {
202            // `{name}` or `{{name}}` — both are accepted, and the double form
203            // is the spelling tool overrides use. Find the identifier and its
204            // closing braces.
205            let open = if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
206                2
207            } else {
208                1
209            };
210            let start = i + open;
211            let mut j = start;
212            while j < bytes.len()
213                && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_' || bytes[j] == b'.')
214            {
215                j += 1;
216            }
217            let closes = j + open <= bytes.len() && bytes[j..j + open].iter().all(|b| *b == b'}');
218            if j > start && closes && is_ident(&t[start..j]) {
219                let name = &t[start..j];
220                let end = j + open; // index just past the closing braces
221                let value = lookup(vars, name)
222                    .ok_or_else(|| MappingError(format!("unknown placeholder {{{name}}}")))?;
223                match mode {
224                    Mode::Text => out.push_str(&match value {
225                        Value::String(s) => s.clone(),
226                        Value::Null => String::new(),
227                        other => other.to_string(),
228                    }),
229                    Mode::Json => {
230                        let quoted = i > 0
231                            && bytes[i - 1] == b'"'
232                            && end < bytes.len()
233                            && bytes[end] == b'"';
234                        match value {
235                            Value::String(s) if quoted => {
236                                // Inside quotes: escaped string CONTENT (the
237                                // template supplies the quotes).
238                                let js = serde_json::to_string(s).unwrap_or_default();
239                                out.push_str(&js[1..js.len() - 1]);
240                            }
241                            other => out.push_str(&other.to_string()),
242                        }
243                    }
244                }
245                i = end;
246                continue;
247            }
248        }
249        // Copy one UTF-8 char verbatim.
250        let ch_len = utf8_len(bytes[i]);
251        out.push_str(&t[i..(i + ch_len).min(bytes.len())]);
252        i += ch_len;
253    }
254    Ok(out)
255}
256
257fn utf8_len(lead: u8) -> usize {
258    if lead >= 0xF0 {
259        4
260    } else if lead >= 0xE0 {
261        3
262    } else if lead >= 0xC0 {
263        2
264    } else {
265        1
266    }
267}
268
269/// Convenience: an object → `Vars`.
270pub fn vars_from(map: &Map<String, Value>) -> Vars {
271    map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use serde_json::json;
278
279    fn vars() -> Vars {
280        let mut v = Vars::new();
281        v.insert("key".into(), json!("agentd/x/run/1"));
282        v.insert("seq".into(), json!(7));
283        v.insert(
284            "envelope".into(),
285            json!({"v": 2, "state": {"a": "q\"uote"}}),
286        );
287        v.insert("prefix".into(), json!("agentd"));
288        v.insert(
289            "nested".into(),
290            json!({"deep": {"n": 3}, "arr": ["x", "y"]}),
291        );
292        v
293    }
294
295    #[test]
296    fn json_templates_substitute_typed_values() {
297        let v = render_json(
298            r#"{"key": "{key}", "seq": {seq}, "state": {envelope}}"#,
299            &vars(),
300        )
301        .unwrap();
302        assert_eq!(
303            v,
304            json!({"key": "agentd/x/run/1", "seq": 7, "state": {"v": 2, "state": {"a": "q\"uote"}}})
305        );
306        // A lone placeholder yields the value itself.
307        assert_eq!(
308            render_json("{envelope}", &vars()).unwrap(),
309            vars()["envelope"]
310        );
311        // Dotted placeholders reach into objects/arrays.
312        assert_eq!(
313            render_json(r#"{"n": {nested.deep.n}, "y": "{nested.arr.1}"}"#, &vars()).unwrap(),
314            json!({"n": 3, "y": "y"})
315        );
316        // A string placeholder used bare becomes a JSON string.
317        assert_eq!(
318            render_json(r#"{"k": {key}}"#, &vars()).unwrap(),
319            json!({"k": "agentd/x/run/1"})
320        );
321        // The double-brace spelling names the same placeholder.
322        assert_eq!(
323            render_json(r#"{"k": "{{key}}", "n": {{nested.deep.n}}}"#, &vars()).unwrap(),
324            json!({"k": "agentd/x/run/1", "n": 3})
325        );
326        assert_eq!(
327            render_json("{{envelope}}", &vars()).unwrap(),
328            vars()["envelope"]
329        );
330        assert_eq!(
331            render_text("x={{key}}", &vars()).unwrap(),
332            "x=agentd/x/run/1"
333        );
334        // Unknown placeholder / non-JSON result are errors.
335        assert!(render_json(r#"{"k": {nope}}"#, &vars()).is_err());
336        assert!(render_json(r#"{"k": "{key}"#, &vars()).is_err());
337        // Escaping: a value with quotes inside a quoted placeholder stays valid.
338        let mut v2 = vars();
339        v2.insert("key".into(), json!("a\"b"));
340        assert_eq!(
341            render_json(r#"{"k": "{key}"}"#, &v2).unwrap(),
342            json!({"k": "a\"b"})
343        );
344    }
345
346    #[test]
347    fn text_templates_and_extraction() {
348        assert_eq!(
349            render_text("{prefix}/kv/{key}?seq={seq}", &vars()).unwrap(),
350            "agentd/kv/agentd/x/run/1?seq=7"
351        );
352        assert!(render_text("{missing}", &vars()).is_err());
353        let ctx = json!({"result": {"structuredContent": {"state": {"x": 1}, "keys": ["a"]}, "isError": false}, "status": 200});
354        assert_eq!(
355            extract("result.structuredContent.state", &ctx).unwrap(),
356            Some(json!({"x": 1}))
357        );
358        assert_eq!(
359            extract("/result/structuredContent/keys/0", &ctx).unwrap(),
360            Some(json!("a"))
361        );
362        assert_eq!(
363            extract("result.structuredContent.keys.0", &ctx).unwrap(),
364            Some(json!("a"))
365        );
366        assert_eq!(extract("result.nope.deeper", &ctx).unwrap(), None);
367        assert_eq!(extract("", &ctx).unwrap(), Some(ctx.clone()));
368        assert!(
369            truthy(&json!(true))
370                && truthy(&json!(1))
371                && truthy(&json!("x"))
372                && !truthy(&json!(null))
373                && !truthy(&json!(0))
374        );
375    }
376
377    #[cfg(feature = "cel")]
378    #[test]
379    fn cel_templates_render_and_extract() {
380        let v = render_json(r#"CEL: {"k": key, "next": seq + 1}"#, &vars()).unwrap();
381        assert_eq!(v, json!({"k": "agentd/x/run/1", "next": 8}));
382        assert_eq!(
383            render_text("CEL: prefix + '/' + key", &vars()).unwrap(),
384            "agentd/agentd/x/run/1"
385        );
386        let ctx = json!({"result": {"structuredContent": {"ok": true, "latest": 9}}});
387        assert_eq!(
388            extract("CEL: result.structuredContent.latest * 2", &ctx).unwrap(),
389            Some(json!(18))
390        );
391    }
392}