Skip to main content

agentd/engine/
data.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The **data steps** (RFC 0027 §5 "Data"): array and text operations without
3//! a model — `map`, `filter`, `reduce`, `sort`, `dedupe`, `chunk`, `parse` —
4//! as pure functions over JSON values. Element expressions are CEL (`item`,
5//! `index`, `acc`, plus the run data) or `{{template}}` strings; `by` keys are
6//! dotted paths.
7
8use super::template::{self, Data};
9use serde_json::{Map, Value, json};
10
11/// Evaluate an element expression: `CEL: …` (or a bare CEL when it does not
12/// look like a template), or a `{{…}}` template.
13fn eval_expr(expr: &str, data: &Data) -> Result<Value, String> {
14    let t = expr.trim();
15    if t.contains("{{") {
16        return template::render_str(t, data);
17    }
18    let cel = t.strip_prefix("CEL:").unwrap_or(t).trim();
19    let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
20    crate::cel::eval_value(cel, &vars).map_err(|e| format!("CEL: {e}"))
21}
22
23fn with_item(data: &Data, alias: &str, item: &Value, index: usize) -> Data {
24    let mut d = data.clone();
25    d.insert(alias.to_string(), item.clone());
26    d.insert("item".to_string(), item.clone());
27    d.insert("index".to_string(), json!(index));
28    d
29}
30
31fn as_array(over: &Value, what: &str) -> Result<Vec<Value>, String> {
32    match over {
33        Value::Array(a) => Ok(a.clone()),
34        Value::Object(o) => Ok(o
35            .iter()
36            .map(|(k, v)| json!({"key": k, "value": v}))
37            .collect()),
38        Value::Null => Ok(Vec::new()),
39        other => Err(format!(
40            "{what}: `over` must be an array (got {})",
41            type_name(other)
42        )),
43    }
44}
45
46fn type_name(v: &Value) -> &'static str {
47    match v {
48        Value::Null => "null",
49        Value::Bool(_) => "boolean",
50        Value::Number(_) => "number",
51        Value::String(_) => "string",
52        Value::Array(_) => "array",
53        Value::Object(_) => "object",
54    }
55}
56
57/// `map`: `expr` over every element.
58pub fn map(over: &Value, expr: &str, alias: &str, data: &Data) -> Result<Value, String> {
59    let items = as_array(over, "map")?;
60    let mut out = Vec::with_capacity(items.len());
61    for (i, it) in items.iter().enumerate() {
62        out.push(eval_expr(expr, &with_item(data, alias, it, i))?);
63    }
64    Ok(Value::Array(out))
65}
66
67/// `filter`: keep the elements whose `expr` is true.
68pub fn filter(over: &Value, expr: &str, alias: &str, data: &Data) -> Result<Value, String> {
69    let items = as_array(over, "filter")?;
70    let mut out = Vec::new();
71    for (i, it) in items.iter().enumerate() {
72        let v = eval_expr(expr, &with_item(data, alias, it, i))?;
73        match v {
74            Value::Bool(true) => out.push(it.clone()),
75            Value::Bool(false) => {}
76            other => {
77                return Err(format!(
78                    "filter: expr must yield a boolean (got {})",
79                    type_name(&other)
80                ));
81            }
82        }
83    }
84    Ok(Value::Array(out))
85}
86
87/// `reduce`: fold `expr` over the elements with `acc` (starting at `initial`).
88pub fn reduce(
89    over: &Value,
90    expr: &str,
91    initial: Value,
92    alias: &str,
93    acc_alias: &str,
94    data: &Data,
95) -> Result<Value, String> {
96    let items = as_array(over, "reduce")?;
97    let mut acc = initial;
98    for (i, it) in items.iter().enumerate() {
99        let mut d = with_item(data, alias, it, i);
100        d.insert(acc_alias.to_string(), acc.clone());
101        d.insert("acc".to_string(), acc.clone());
102        acc = eval_expr(expr, &d)?;
103    }
104    Ok(acc)
105}
106
107/// `sort`: by a dotted path (or the element itself), `asc|desc`; stable.
108pub fn sort(over: &Value, by: Option<&str>, order: Option<&str>) -> Result<Value, String> {
109    let mut items = as_array(over, "sort")?;
110    let desc = matches!(order, Some("desc") | Some("descending"));
111    let key = |v: &Value| -> Value {
112        match by {
113            None | Some("") => v.clone(),
114            Some(p) => path_of(v, p).unwrap_or(Value::Null),
115        }
116    };
117    items.sort_by(|a, b| {
118        let o = cmp_values(&key(a), &key(b));
119        if desc { o.reverse() } else { o }
120    });
121    Ok(Value::Array(items))
122}
123
124/// `dedupe`: keep the first occurrence per key (`by` path) / value.
125pub fn dedupe(over: &Value, by: Option<&str>) -> Result<Value, String> {
126    let items = as_array(over, "dedupe")?;
127    let mut seen: Vec<Value> = Vec::new();
128    let mut out = Vec::new();
129    for it in items {
130        let k = match by {
131            None | Some("") => it.clone(),
132            Some(p) => path_of(&it, p).unwrap_or(Value::Null),
133        };
134        if !seen.contains(&k) {
135            seen.push(k);
136            out.push(it);
137        }
138    }
139    Ok(Value::Array(out))
140}
141
142/// `chunk`: split text by `chars|lines|tokens` (approximate) or an array into
143/// slices of `size`, with `overlap` elements/chars carried over.
144pub fn chunk(
145    value: &Value,
146    by: Option<&str>,
147    size: usize,
148    overlap: usize,
149) -> Result<Value, String> {
150    if size == 0 {
151        return Err("chunk: size must be > 0".into());
152    }
153    let overlap = overlap.min(size.saturating_sub(1));
154    match value {
155        Value::Array(a) => {
156            let mut out = Vec::new();
157            let mut start = 0;
158            while start < a.len() {
159                let end = (start + size).min(a.len());
160                out.push(Value::Array(a[start..end].to_vec()));
161                if end == a.len() {
162                    break;
163                }
164                start = end - overlap;
165            }
166            Ok(Value::Array(out))
167        }
168        Value::String(s) => {
169            let mode = by.unwrap_or("chars");
170            let out: Vec<Value> = match mode {
171                "lines" => {
172                    let lines: Vec<&str> = s.lines().collect();
173                    windows(&lines, size, overlap)
174                        .into_iter()
175                        .map(|w| Value::String(w.join("\n")))
176                        .collect()
177                }
178                "words" | "tokens" => {
179                    // tokens ≈ words × 1.3; chunk by words with size/1.3 words per chunk.
180                    let words: Vec<&str> = s.split_whitespace().collect();
181                    let per = if mode == "tokens" {
182                        ((size as f64) / 1.3).max(1.0) as usize
183                    } else {
184                        size
185                    };
186                    let ov = if mode == "tokens" {
187                        ((overlap as f64) / 1.3) as usize
188                    } else {
189                        overlap
190                    };
191                    windows(&words, per, ov.min(per.saturating_sub(1)))
192                        .into_iter()
193                        .map(|w| Value::String(w.join(" ")))
194                        .collect()
195                }
196                "chars" => {
197                    let chars: Vec<char> = s.chars().collect();
198                    windows(&chars, size, overlap)
199                        .into_iter()
200                        .map(|w| Value::String(w.into_iter().collect()))
201                        .collect()
202                }
203                other => {
204                    return Err(format!(
205                        "chunk: by must be chars|lines|words|tokens (got {other:?})"
206                    ));
207                }
208            };
209            Ok(Value::Array(out))
210        }
211        other => Err(format!(
212            "chunk: value must be a string or an array (got {})",
213            type_name(other)
214        )),
215    }
216}
217
218fn windows<T: Clone>(items: &[T], size: usize, overlap: usize) -> Vec<Vec<T>> {
219    let mut out = Vec::new();
220    let mut start = 0;
221    while start < items.len() {
222        let end = (start + size).min(items.len());
223        out.push(items[start..end].to_vec());
224        if end == items.len() {
225            break;
226        }
227        start = end - overlap;
228    }
229    out
230}
231
232/// `parse`: text → JSON (`json` | `yaml` | `csv` | `lines` | `auto`).
233pub fn parse(text: &str, format: Option<&str>) -> Result<Value, String> {
234    let f = format.unwrap_or("auto");
235    match f {
236        "json" => serde_json::from_str::<Value>(text).map_err(|e| format!("parse json: {e}")),
237        "yaml" => crate::config::yaml::parse(text).map_err(|e| format!("parse yaml: {e}")),
238        "lines" => Ok(Value::Array(
239            text.lines().map(|l| Value::String(l.to_string())).collect(),
240        )),
241        "csv" => Ok(parse_csv(text)),
242        "auto" => {
243            let t = text.trim();
244            if let Ok(v) = serde_json::from_str::<Value>(t) {
245                return Ok(v);
246            }
247            if let Ok(v) = crate::config::yaml::parse(t)
248                && !matches!(v, Value::String(_))
249            {
250                return Ok(v);
251            }
252            Ok(Value::Array(
253                text.lines().map(|l| Value::String(l.to_string())).collect(),
254            ))
255        }
256        other => Err(format!(
257            "parse: format must be json|yaml|csv|lines|auto (got {other:?})"
258        )),
259    }
260}
261
262/// A minimal CSV reader (RFC 4180 quoting; header row → objects).
263fn parse_csv(text: &str) -> Value {
264    let rows: Vec<Vec<String>> = text
265        .lines()
266        .filter(|l| !l.trim().is_empty())
267        .map(csv_row)
268        .collect();
269    let Some((header, body)) = rows.split_first() else {
270        return json!([]);
271    };
272    Value::Array(
273        body.iter()
274            .map(|r| {
275                let mut o = Map::new();
276                for (i, h) in header.iter().enumerate() {
277                    o.insert(
278                        h.clone(),
279                        r.get(i).map(|c| coerce_scalar(c)).unwrap_or(Value::Null),
280                    );
281                }
282                Value::Object(o)
283            })
284            .collect(),
285    )
286}
287
288fn csv_row(line: &str) -> Vec<String> {
289    let mut out = Vec::new();
290    let mut cur = String::new();
291    let mut quoted = false;
292    let mut chars = line.chars().peekable();
293    while let Some(c) = chars.next() {
294        match c {
295            '"' if quoted && chars.peek() == Some(&'"') => {
296                cur.push('"');
297                chars.next();
298            }
299            '"' => quoted = !quoted,
300            ',' if !quoted => {
301                out.push(std::mem::take(&mut cur));
302            }
303            other => cur.push(other),
304        }
305    }
306    out.push(cur);
307    out
308}
309
310fn coerce_scalar(s: &str) -> Value {
311    if let Ok(i) = s.parse::<i64>() {
312        return json!(i);
313    }
314    if let Ok(f) = s.parse::<f64>() {
315        return json!(f);
316    }
317    match s {
318        "true" => json!(true),
319        "false" => json!(false),
320        _ => Value::String(s.to_string()),
321    }
322}
323
324/// A dotted path inside a value.
325pub fn path_of(v: &Value, path: &str) -> Option<Value> {
326    let mut cur = v;
327    for seg in path.split('.') {
328        cur = match cur {
329            Value::Object(m) => m.get(seg)?,
330            Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
331            _ => return None,
332        };
333    }
334    Some(cur.clone())
335}
336
337/// A total order over JSON values: null < bool < number < string < array < object.
338pub fn cmp_values(a: &Value, b: &Value) -> std::cmp::Ordering {
339    use std::cmp::Ordering::*;
340    let rank = |v: &Value| match v {
341        Value::Null => 0,
342        Value::Bool(_) => 1,
343        Value::Number(_) => 2,
344        Value::String(_) => 3,
345        Value::Array(_) => 4,
346        Value::Object(_) => 5,
347    };
348    match (a, b) {
349        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
350        (Value::Number(x), Value::Number(y)) => {
351            x.as_f64().partial_cmp(&y.as_f64()).unwrap_or(Equal)
352        }
353        (Value::String(x), Value::String(y)) => x.cmp(y),
354        (Value::Array(x), Value::Array(y)) => {
355            for (p, q) in x.iter().zip(y.iter()) {
356                let o = cmp_values(p, q);
357                if o != Equal {
358                    return o;
359                }
360            }
361            x.len().cmp(&y.len())
362        }
363        _ => rank(a).cmp(&rank(b)),
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[cfg(feature = "cel")]
372    fn data() -> Data {
373        let mut d = Data::new();
374        d.insert("vars".into(), json!({"min": 2}));
375        d
376    }
377
378    #[cfg(feature = "cel")]
379    #[test]
380    fn map_filter_reduce_with_cel_and_templates() {
381        let d = data();
382        assert_eq!(
383            map(&json!([1, 2, 3]), "item * 2", "item", &d).unwrap(),
384            json!([2, 4, 6])
385        );
386        assert_eq!(
387            map(
388                &json!([{"n": 1}, {"n": 5}]),
389                "{{item.n}}-{{index}}",
390                "item",
391                &d
392            )
393            .unwrap(),
394            json!(["1-0", "5-1"])
395        );
396        assert_eq!(
397            filter(&json!([1, 2, 3, 4]), "CEL: x > vars.min", "x", &d).unwrap(),
398            json!([3, 4])
399        );
400        assert!(
401            filter(&json!([1]), "item", "item", &d).is_err(),
402            "non-boolean"
403        );
404        assert_eq!(
405            reduce(&json!([1, 2, 3]), "acc + item", json!(0), "item", "acc", &d).unwrap(),
406            json!(6)
407        );
408        assert_eq!(
409            reduce(
410                &json!(["a", "b"]),
411                "total + \"|\" + s",
412                json!(""),
413                "s",
414                "total",
415                &d
416            )
417            .unwrap(),
418            json!("|a|b")
419        );
420        // Objects iterate as {key, value}.
421        assert_eq!(
422            map(
423                &json!({"a": 1, "b": 2}),
424                "item.key + \"=\" + string(item.value)",
425                "item",
426                &d
427            )
428            .unwrap(),
429            json!(["a=1", "b=2"])
430        );
431    }
432
433    #[test]
434    fn sort_dedupe_chunk_parse() {
435        assert_eq!(
436            sort(&json!([3, 1, 2]), None, None).unwrap(),
437            json!([1, 2, 3])
438        );
439        assert_eq!(
440            sort(
441                &json!([{"n": 3, "s": "c"}, {"n": 1, "s": "a"}]),
442                Some("n"),
443                Some("desc")
444            )
445            .unwrap(),
446            json!([{"n": 3, "s": "c"}, {"n": 1, "s": "a"}])
447        );
448        assert_eq!(
449            sort(&json!(["b", null, 2, "a", true]), None, None).unwrap(),
450            json!([null, true, 2, "a", "b"])
451        );
452        assert_eq!(
453            dedupe(&json!([1, 2, 1, 3, 2]), None).unwrap(),
454            json!([1, 2, 3])
455        );
456        assert_eq!(
457            dedupe(
458                &json!([{"id": 1, "x": "a"}, {"id": 1, "x": "b"}, {"id": 2}]),
459                Some("id")
460            )
461            .unwrap(),
462            json!([{"id": 1, "x": "a"}, {"id": 2}])
463        );
464        assert_eq!(
465            chunk(&json!([1, 2, 3, 4, 5]), None, 2, 0).unwrap(),
466            json!([[1, 2], [3, 4], [5]])
467        );
468        assert_eq!(
469            chunk(&json!([1, 2, 3, 4, 5]), None, 3, 1).unwrap(),
470            json!([[1, 2, 3], [3, 4, 5]])
471        );
472        assert_eq!(
473            chunk(&json!("abcdefg"), Some("chars"), 3, 0).unwrap(),
474            json!(["abc", "def", "g"])
475        );
476        assert_eq!(
477            chunk(&json!("l1\nl2\nl3"), Some("lines"), 2, 0).unwrap(),
478            json!(["l1\nl2", "l3"])
479        );
480        assert_eq!(
481            chunk(&json!("a b c d"), Some("words"), 2, 0).unwrap(),
482            json!(["a b", "c d"])
483        );
484        assert!(chunk(&json!("x"), None, 0, 0).is_err());
485        assert_eq!(parse("{\"a\": 1}", None).unwrap(), json!({"a": 1}));
486        assert_eq!(
487            parse("a: 1\nb: [x, y]", Some("yaml")).unwrap(),
488            json!({"a": 1, "b": ["x", "y"]})
489        );
490        assert_eq!(parse("x\ny", Some("lines")).unwrap(), json!(["x", "y"]));
491        assert_eq!(
492            parse("name,age\n\"Doe, J\",42\nAnn,7", Some("csv")).unwrap(),
493            json!([{"name": "Doe, J", "age": 42}, {"name": "Ann", "age": 7}])
494        );
495        assert_eq!(parse("plain text", None).unwrap(), json!(["plain text"]));
496        assert!(parse("x", Some("xml")).is_err());
497        assert_eq!(
498            path_of(&json!({"a": {"b": [10, 20]}}), "a.b.1"),
499            Some(json!(20))
500        );
501    }
502}