Skip to main content

backbone_core/workflow/
expr.rs

1//! The closed expression set a workflow runs.
2//!
3//! The schema generator parses every condition, `set:` value and `{{ }}` template of a workflow
4//! when it generates, refuses what is not in this set, and writes the result as the data types of
5//! this module (an [`Expr`] tree, a [`Template`], a [`Tpl`] value). The engine evaluates them over
6//! the instance's JSON context; nothing is parsed at run time.
7//!
8//! What an expression can say:
9//!
10//! - paths into `context`, `event`, `result`, `error` and `config`, with `.key` and `[n]`, and
11//!   `steps.<name>.completed`; a missing key reads as `null`;
12//! - literals (`null`, booleans, numbers, strings, arrays) and duration literals (`15m`, `30d`);
13//! - `== != < <= > >=`, `&& || !`, `+ - * / %`, `a ?? b`, `a ? b : c`, `a in [..]`;
14//! - `.length`, `.contains(x)`, `.includes(x)`, `.starts_with(x)`, `.ends_with(x)`;
15//! - `now()`, `new_uuid()`, `coalesce(a, b, ..)`, `len(x)`;
16//! - a function the workflow declares under `functions:`, called through [`Funcs`].
17//!
18//! `time + duration` and `time - duration` add to an RFC 3339 text and answer one; `<` and friends
19//! compare two RFC 3339 texts as instants.
20
21use std::collections::HashSet;
22use std::future::Future;
23use std::pin::Pin;
24
25use async_trait::async_trait;
26use chrono::{DateTime, Duration, SecondsFormat, Utc};
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29
30/// Where a path starts.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum Root {
34    /// The instance's context.
35    Context,
36    /// The payload of the event being handled (the start event, or the one a wait received).
37    Event,
38    /// The result of the step that just ran (else the last stored one, `context.result`).
39    Result,
40    /// The message of the failure a step was routed on.
41    Error,
42    /// The workflow's configuration the generator exposes.
43    Config,
44    /// The completed steps: `steps.<name>.completed`.
45    Steps,
46}
47
48/// One step of a path.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Seg {
52    /// `.name`
53    Key(String),
54    /// `[n]`
55    Index(usize),
56}
57
58/// A binary operator.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum BinOp {
62    /// `==`
63    Eq,
64    /// `!=`
65    Ne,
66    /// `<`
67    Lt,
68    /// `<=`
69    Le,
70    /// `>`
71    Gt,
72    /// `>=`
73    Ge,
74    /// `&&`, short-circuiting.
75    And,
76    /// `||`, short-circuiting.
77    Or,
78    /// `+`
79    Add,
80    /// `-`
81    Sub,
82    /// `*`
83    Mul,
84    /// `/`
85    Div,
86    /// `%`
87    Mod,
88}
89
90/// A compiled expression.
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum Expr {
94    /// A literal JSON value.
95    Lit(Value),
96    /// A duration literal, in seconds. It adds to or subtracts from a time.
97    Dur(i64),
98    /// `now()`: the time the step runs, as RFC 3339 text.
99    Now,
100    /// A path.
101    Path {
102        /// Where it starts.
103        root: Root,
104        /// What it walks.
105        path: Vec<Seg>,
106    },
107    /// `!x`
108    Not(Box<Expr>),
109    /// `-x`
110    Neg(Box<Expr>),
111    /// `l op r`
112    Bin {
113        /// The operator.
114        op: BinOp,
115        /// Left side.
116        l: Box<Expr>,
117        /// Right side.
118        r: Box<Expr>,
119    },
120    /// `l ?? r`: `l`, unless it is `null`.
121    Coalesce(Box<Expr>, Box<Expr>),
122    /// `c ? t : e`
123    If {
124        /// The condition.
125        c: Box<Expr>,
126        /// When it holds.
127        t: Box<Expr>,
128        /// When it does not.
129        e: Box<Expr>,
130    },
131    /// `[a, b]`
132    Array(Vec<Expr>),
133    /// `l in r` (or `not in` when `negate`): membership of an array, a substring of a string.
134    In {
135        /// What is looked for.
136        l: Box<Expr>,
137        /// What is searched.
138        r: Box<Expr>,
139        /// `not in`.
140        negate: bool,
141    },
142    /// `obj.name(args)`: `length`, `contains`, `includes`, `starts_with`, `ends_with`.
143    Method {
144        /// The receiver.
145        obj: Box<Expr>,
146        /// The method.
147        name: String,
148        /// Its arguments.
149        args: Vec<Expr>,
150    },
151    /// `name(args)`: `new_uuid`, `coalesce`, `len`, or a function the workflow declares.
152    Call {
153        /// The function.
154        name: String,
155        /// Its arguments.
156        args: Vec<Expr>,
157    },
158}
159
160/// A piece of a template.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum Part {
164    /// Text as written.
165    Text(String),
166    /// A `{{ }}` expression.
167    Expr(Expr),
168}
169
170/// A string with `{{ }}` holes. A template that is one hole renders as that expression's own value
171/// (a number stays a number, an object stays an object); anything else renders as text.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct Template(pub Vec<Part>);
174
175/// A value of a step's parameters: a literal, a template, or a map or list of them.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum Tpl {
179    /// A literal.
180    Lit(Value),
181    /// A template.
182    Template(Template),
183    /// A map, in the order written.
184    Map(Vec<(String, Tpl)>),
185    /// A list.
186    List(Vec<Tpl>),
187}
188
189/// A duration: fixed, or a template evaluated when it is needed (`{{ context.minutes }}m`).
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum Dur {
193    /// Seconds.
194    Secs(i64),
195    /// Rendered, then read with [`parse_duration`].
196    Tpl(Template),
197}
198
199/// What an expression reads.
200pub struct Scope<'a> {
201    /// The instance's context.
202    pub context: &'a Value,
203    /// The event being handled.
204    pub event: &'a Value,
205    /// The result of the step that just ran.
206    pub result: &'a Value,
207    /// The failure message a step was routed on.
208    pub error: &'a Value,
209    /// The workflow's configuration.
210    pub config: &'a Value,
211    /// The names of the steps that completed.
212    pub completed: &'a HashSet<String>,
213    /// When the step runs.
214    pub now: DateTime<Utc>,
215}
216
217/// The functions a workflow declares under `functions:`, implemented by hand.
218#[async_trait]
219pub trait Funcs: Send + Sync {
220    /// Call the declared function `name`. An error fails the step that evaluated it.
221    async fn call(&self, name: &str, args: &[Value]) -> Result<Value, String>;
222}
223
224/// A [`Funcs`] that declares nothing: every call is an error.
225pub struct NoFuncs;
226
227#[async_trait]
228impl Funcs for NoFuncs {
229    async fn call(&self, name: &str, _args: &[Value]) -> Result<Value, String> {
230        Err(format!("function `{name}` is not declared"))
231    }
232}
233
234type Eval<'a> = Pin<Box<dyn Future<Output = Result<Value, String>> + Send + 'a>>;
235
236/// Whether `v` counts as true: not `null`, `false`, `0` or `""`.
237pub fn truthy(v: &Value) -> bool {
238    match v {
239        Value::Null => false,
240        Value::Bool(b) => *b,
241        Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),
242        Value::String(s) => !s.is_empty(),
243        Value::Array(_) | Value::Object(_) => true,
244    }
245}
246
247/// Evaluate `expr` over `scope`.
248pub fn eval<'a>(expr: &'a Expr, scope: &'a Scope<'a>, funcs: &'a dyn Funcs) -> Eval<'a> {
249    Box::pin(async move {
250        match expr {
251            Expr::Lit(v) => Ok(v.clone()),
252            Expr::Dur(s) => Ok(Value::from(*s)),
253            Expr::Now => Ok(Value::String(time_text(scope.now))),
254            Expr::Path { root, path } => Ok(read_path(*root, path, scope)),
255            Expr::Not(e) => Ok(Value::Bool(!truthy(&eval(e, scope, funcs).await?))),
256            Expr::Neg(e) => {
257                let v = eval(e, scope, funcs).await?;
258                number(&v)
259                    .map(|n| num_value(-n))
260                    .ok_or_else(|| "`-` needs a number".to_string())
261            }
262            Expr::Bin {
263                op: BinOp::And,
264                l,
265                r,
266            } => {
267                let left = eval(l, scope, funcs).await?;
268                if !truthy(&left) {
269                    return Ok(Value::Bool(false));
270                }
271                Ok(Value::Bool(truthy(&eval(r, scope, funcs).await?)))
272            }
273            Expr::Bin {
274                op: BinOp::Or,
275                l,
276                r,
277            } => {
278                let left = eval(l, scope, funcs).await?;
279                if truthy(&left) {
280                    return Ok(Value::Bool(true));
281                }
282                Ok(Value::Bool(truthy(&eval(r, scope, funcs).await?)))
283            }
284            Expr::Bin { op, l, r } => {
285                let left = eval(l, scope, funcs).await?;
286                let right = eval(r, scope, funcs).await?;
287                binary(*op, &left, &right, r)
288            }
289            Expr::Coalesce(l, r) => {
290                let left = eval(l, scope, funcs).await?;
291                if left.is_null() {
292                    eval(r, scope, funcs).await
293                } else {
294                    Ok(left)
295                }
296            }
297            Expr::If { c, t, e } => {
298                if truthy(&eval(c, scope, funcs).await?) {
299                    eval(t, scope, funcs).await
300                } else {
301                    eval(e, scope, funcs).await
302                }
303            }
304            Expr::Array(items) => {
305                let mut out = Vec::with_capacity(items.len());
306                for item in items {
307                    out.push(eval(item, scope, funcs).await?);
308                }
309                Ok(Value::Array(out))
310            }
311            Expr::In { l, r, negate } => {
312                let needle = eval(l, scope, funcs).await?;
313                let hay = eval(r, scope, funcs).await?;
314                Ok(Value::Bool(contains(&hay, &needle) != *negate))
315            }
316            Expr::Method { obj, name, args } => {
317                let recv = eval(obj, scope, funcs).await?;
318                let mut vals = Vec::with_capacity(args.len());
319                for a in args {
320                    vals.push(eval(a, scope, funcs).await?);
321                }
322                method(&recv, name, &vals)
323            }
324            Expr::Call { name, args } => {
325                let mut vals = Vec::with_capacity(args.len());
326                for a in args {
327                    vals.push(eval(a, scope, funcs).await?);
328                }
329                match name.as_str() {
330                    "new_uuid" => Ok(Value::String(uuid::Uuid::new_v4().to_string())),
331                    "coalesce" => Ok(vals
332                        .into_iter()
333                        .find(|v| !v.is_null())
334                        .unwrap_or(Value::Null)),
335                    "len" => method(vals.first().unwrap_or(&Value::Null), "length", &[]),
336                    other => funcs.call(other, &vals).await,
337                }
338            }
339        }
340    })
341}
342
343/// Evaluate `expr` and read the answer as a condition.
344pub async fn holds(expr: &Expr, scope: &Scope<'_>, funcs: &dyn Funcs) -> Result<bool, String> {
345    Ok(truthy(&eval(expr, scope, funcs).await?))
346}
347
348/// Render a template: one hole is that expression's value, anything else is text.
349pub async fn render_template(
350    t: &Template,
351    scope: &Scope<'_>,
352    funcs: &dyn Funcs,
353) -> Result<Value, String> {
354    if let [Part::Expr(e)] = t.0.as_slice() {
355        return eval(e, scope, funcs).await;
356    }
357    let mut out = String::new();
358    for part in &t.0 {
359        match part {
360            Part::Text(s) => out.push_str(s),
361            Part::Expr(e) => out.push_str(&text_of(&eval(e, scope, funcs).await?)),
362        }
363    }
364    Ok(Value::String(out))
365}
366
367/// Render a parameter value.
368pub fn render<'a>(tpl: &'a Tpl, scope: &'a Scope<'a>, funcs: &'a dyn Funcs) -> Eval<'a> {
369    Box::pin(async move {
370        match tpl {
371            Tpl::Lit(v) => Ok(v.clone()),
372            Tpl::Template(t) => render_template(t, scope, funcs).await,
373            Tpl::Map(entries) => {
374                let mut map = serde_json::Map::new();
375                for (k, v) in entries {
376                    map.insert(k.clone(), render(v, scope, funcs).await?);
377                }
378                Ok(Value::Object(map))
379            }
380            Tpl::List(items) => {
381                let mut out = Vec::with_capacity(items.len());
382                for item in items {
383                    out.push(render(item, scope, funcs).await?);
384                }
385                Ok(Value::Array(out))
386            }
387        }
388    })
389}
390
391/// Render a duration: seconds, or a template read with [`parse_duration`].
392pub async fn render_duration(d: &Dur, scope: &Scope<'_>, funcs: &dyn Funcs) -> Result<i64, String> {
393    match d {
394        Dur::Secs(s) => Ok(*s),
395        Dur::Tpl(t) => {
396            let v = render_template(t, scope, funcs).await?;
397            match &v {
398                Value::Number(n) => n
399                    .as_i64()
400                    .ok_or_else(|| format!("`{n}` is not a whole number of seconds")),
401                Value::String(s) => parse_duration(s)
402                    .ok_or_else(|| format!("`{s}` is not a duration (use 30s, 15m, 24h, 7d)")),
403                other => Err(format!("`{other}` is not a duration")),
404            }
405        }
406    }
407}
408
409/// Read `30s`, `15m`, `24h`, `7d`, `2w`, or a bare number of seconds, as seconds.
410pub fn parse_duration(text: &str) -> Option<i64> {
411    let t = text.trim();
412    if !t.is_empty() && t.chars().all(|c| c.is_ascii_digit()) {
413        return t.parse().ok();
414    }
415    let split = t.find(|c: char| !c.is_ascii_digit())?;
416    let (digits, unit) = t.split_at(split);
417    let n: i64 = digits.parse().ok()?;
418    let secs = match unit {
419        "s" => 1,
420        "m" => 60,
421        "h" => 3600,
422        "d" => 86_400,
423        "w" => 604_800,
424        _ => return None,
425    };
426    n.checked_mul(secs)
427}
428
429/// The key a wait is matched on: the canonical text of each value, in the order given, as a JSON
430/// array. Both the instance (from its context) and the event (from its payload) build it the same
431/// way, so equal values make equal keys.
432pub fn wait_key(values: &[Value]) -> String {
433    let parts: Vec<String> = values.iter().map(canonical).collect();
434    serde_json::to_string(&parts).unwrap_or_default()
435}
436
437/// The canonical text of a value as a wait key: strings as they are, `null` as `null`, a whole
438/// number without a fraction.
439pub fn canonical(v: &Value) -> String {
440    match v {
441        Value::String(s) => s.clone(),
442        Value::Number(n) => match n.as_f64() {
443            Some(f) if f.fract() == 0.0 && f.abs() < 9e15 => format!("{}", f as i64),
444            _ => n.to_string(),
445        },
446        other => other.to_string(),
447    }
448}
449
450fn read_path(root: Root, path: &[Seg], scope: &Scope<'_>) -> Value {
451    if root == Root::Steps {
452        return match path {
453            [Seg::Key(name), Seg::Key(field)] if field == "completed" => {
454                Value::Bool(scope.completed.contains(name))
455            }
456            _ => Value::Null,
457        };
458    }
459    let mut cur = match root {
460        Root::Context => scope.context,
461        Root::Event => scope.event,
462        Root::Result => scope.result,
463        Root::Error => scope.error,
464        Root::Config => scope.config,
465        Root::Steps => unreachable!(),
466    };
467    for seg in path {
468        cur = match (seg, cur) {
469            (Seg::Key(k), Value::Object(m)) => match m.get(k) {
470                Some(v) => v,
471                None => return Value::Null,
472            },
473            (Seg::Index(i), Value::Array(a)) => match a.get(*i) {
474                Some(v) => v,
475                None => return Value::Null,
476            },
477            _ => return Value::Null,
478        };
479    }
480    cur.clone()
481}
482
483fn time_text(t: DateTime<Utc>) -> String {
484    t.to_rfc3339_opts(SecondsFormat::Micros, true)
485}
486
487fn parse_time(v: &Value) -> Option<DateTime<Utc>> {
488    v.as_str()
489        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
490        .map(|d| d.with_timezone(&Utc))
491}
492
493fn number(v: &Value) -> Option<f64> {
494    match v {
495        Value::Number(n) => n.as_f64(),
496        _ => None,
497    }
498}
499
500fn num_value(f: f64) -> Value {
501    if f.fract() == 0.0 && f.abs() < 9e15 {
502        Value::from(f as i64)
503    } else {
504        serde_json::Number::from_f64(f)
505            .map(Value::Number)
506            .unwrap_or(Value::Null)
507    }
508}
509
510fn json_eq(a: &Value, b: &Value) -> bool {
511    match (a, b) {
512        (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(),
513        _ => a == b,
514    }
515}
516
517fn contains(hay: &Value, needle: &Value) -> bool {
518    match hay {
519        Value::Array(items) => items.iter().any(|i| json_eq(i, needle)),
520        Value::String(s) => needle.as_str().is_some_and(|n| s.contains(n)),
521        _ => false,
522    }
523}
524
525fn method(recv: &Value, name: &str, args: &[Value]) -> Result<Value, String> {
526    match name {
527        "length" | "len" => Ok(match recv {
528            Value::String(s) => Value::from(s.chars().count() as i64),
529            Value::Array(a) => Value::from(a.len() as i64),
530            Value::Object(m) => Value::from(m.len() as i64),
531            _ => Value::from(0),
532        }),
533        "contains" | "includes" => Ok(Value::Bool(contains(
534            recv,
535            args.first().unwrap_or(&Value::Null),
536        ))),
537        "starts_with" => Ok(Value::Bool(
538            match (recv.as_str(), args.first().and_then(Value::as_str)) {
539                (Some(s), Some(p)) => s.starts_with(p),
540                _ => false,
541            },
542        )),
543        "ends_with" => Ok(Value::Bool(
544            match (recv.as_str(), args.first().and_then(Value::as_str)) {
545                (Some(s), Some(p)) => s.ends_with(p),
546                _ => false,
547            },
548        )),
549        other => Err(format!(
550            "method `.{other}` is not part of the workflow expression set"
551        )),
552    }
553}
554
555fn binary(op: BinOp, l: &Value, r: &Value, right_expr: &Expr) -> Result<Value, String> {
556    match op {
557        BinOp::Eq => Ok(Value::Bool(json_eq(l, r))),
558        BinOp::Ne => Ok(Value::Bool(!json_eq(l, r))),
559        BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => {
560            let ord = if let (Some(x), Some(y)) = (number(l), number(r)) {
561                x.partial_cmp(&y)
562            } else if let (Some(x), Some(y)) = (parse_time(l), parse_time(r)) {
563                Some(x.cmp(&y))
564            } else if let (Some(x), Some(y)) = (l.as_str(), r.as_str()) {
565                Some(x.cmp(y))
566            } else {
567                None
568            };
569            Ok(Value::Bool(match (ord, op) {
570                (None, _) => false,
571                (Some(o), BinOp::Lt) => o.is_lt(),
572                (Some(o), BinOp::Le) => o.is_le(),
573                (Some(o), BinOp::Gt) => o.is_gt(),
574                (Some(o), _) => o.is_ge(),
575            }))
576        }
577        BinOp::Add | BinOp::Sub => {
578            // A time moved by a duration literal.
579            if let (Some(t), Expr::Dur(secs)) = (parse_time(l), right_expr) {
580                let delta = Duration::seconds(if op == BinOp::Add { *secs } else { -*secs });
581                return Ok(Value::String(time_text(t + delta)));
582            }
583            if op == BinOp::Add {
584                if let (Value::String(a), Value::String(b)) = (l, r) {
585                    return Ok(Value::String(format!("{a}{b}")));
586                }
587            }
588            match (number(l), number(r)) {
589                (Some(x), Some(y)) => Ok(num_value(if op == BinOp::Add { x + y } else { x - y })),
590                _ => Err(format!(
591                    "`{}` needs two numbers",
592                    if op == BinOp::Add { "+" } else { "-" }
593                )),
594            }
595        }
596        BinOp::Mul | BinOp::Div | BinOp::Mod => match (number(l), number(r)) {
597            (Some(x), Some(y)) => {
598                if op != BinOp::Mul && y == 0.0 {
599                    return Err("division by zero".into());
600                }
601                Ok(num_value(match op {
602                    BinOp::Mul => x * y,
603                    BinOp::Div => x / y,
604                    _ => x % y,
605                }))
606            }
607            _ => Err("arithmetic needs two numbers".into()),
608        },
609        BinOp::And | BinOp::Or => unreachable!("short-circuited by eval"),
610    }
611}
612
613/// The text of a value inside a larger template: strings as they are, `null` as nothing.
614pub fn text_of(v: &Value) -> String {
615    match v {
616        Value::Null => String::new(),
617        Value::String(s) => s.clone(),
618        other => other.to_string(),
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use serde_json::json;
626
627    fn path(root: Root, keys: &[&str]) -> Expr {
628        Expr::Path {
629            root,
630            path: keys.iter().map(|k| Seg::Key(k.to_string())).collect(),
631        }
632    }
633
634    fn lit(v: Value) -> Box<Expr> {
635        Box::new(Expr::Lit(v))
636    }
637
638    async fn run(e: &Expr, context: Value) -> Value {
639        let done: HashSet<String> = ["a".to_string()].into();
640        let scope = Scope {
641            context: &context,
642            event: &json!({ "user_id": "u1" }),
643            result: &json!({ "id": 7 }),
644            error: &Value::Null,
645            config: &json!({}),
646            completed: &done,
647            now: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
648                .unwrap()
649                .with_timezone(&Utc),
650        };
651        eval(e, &scope, &NoFuncs).await.unwrap()
652    }
653
654    #[tokio::test]
655    async fn paths_read_the_scope_and_a_missing_key_is_null() {
656        let ctx = json!({ "user": { "id": "u1", "roles": ["a", "b"] } });
657        assert_eq!(
658            run(&path(Root::Context, &["user", "id"]), ctx.clone()).await,
659            json!("u1")
660        );
661        assert_eq!(
662            run(
663                &path(Root::Context, &["user", "nope", "deeper"]),
664                ctx.clone()
665            )
666            .await,
667            Value::Null
668        );
669        assert_eq!(
670            run(&path(Root::Event, &["user_id"]), ctx.clone()).await,
671            json!("u1")
672        );
673        assert_eq!(
674            run(&path(Root::Result, &["id"]), ctx.clone()).await,
675            json!(7)
676        );
677        let idx = Expr::Path {
678            root: Root::Context,
679            path: vec![
680                Seg::Key("user".into()),
681                Seg::Key("roles".into()),
682                Seg::Index(1),
683            ],
684        };
685        assert_eq!(run(&idx, ctx.clone()).await, json!("b"));
686        assert_eq!(
687            run(&path(Root::Steps, &["a", "completed"]), ctx.clone()).await,
688            json!(true)
689        );
690        assert_eq!(
691            run(&path(Root::Steps, &["b", "completed"]), ctx).await,
692            json!(false)
693        );
694    }
695
696    #[tokio::test]
697    async fn comparison_logic_and_coalesce_follow_json_truthiness() {
698        let eq = Expr::Bin {
699            op: BinOp::Eq,
700            l: lit(json!(1)),
701            r: lit(json!(1.0)),
702        };
703        assert_eq!(run(&eq, json!({})).await, json!(true), "1 equals 1.0");
704        let and = Expr::Bin {
705            op: BinOp::And,
706            l: Box::new(path(Root::Context, &["missing"])),
707            r: Box::new(Expr::Call {
708                name: "boom".into(),
709                args: vec![],
710            }),
711        };
712        assert_eq!(
713            run(&and, json!({})).await,
714            json!(false),
715            "`&&` short-circuits past a function that would fail"
716        );
717        let co = Expr::Coalesce(Box::new(path(Root::Context, &["x"])), lit(json!("d")));
718        assert_eq!(run(&co, json!({})).await, json!("d"));
719        assert_eq!(run(&co, json!({ "x": "set" })).await, json!("set"));
720        let inn = Expr::In {
721            l: lit(json!("low")),
722            r: lit(json!(["low", "medium"])),
723            negate: false,
724        };
725        assert_eq!(run(&inn, json!({})).await, json!(true));
726    }
727
728    #[tokio::test]
729    async fn time_arithmetic_and_comparison_use_instants() {
730        let plus = Expr::Bin {
731            op: BinOp::Add,
732            l: Box::new(Expr::Now),
733            r: Box::new(Expr::Dur(86_400)),
734        };
735        assert_eq!(
736            run(&plus, json!({})).await,
737            json!("2026-01-02T00:00:00.000000Z")
738        );
739        let later = Expr::Bin {
740            op: BinOp::Ge,
741            l: Box::new(Expr::Now),
742            r: lit(json!("2025-12-31T23:00:00+00:00")),
743        };
744        assert_eq!(run(&later, json!({})).await, json!(true));
745    }
746
747    #[tokio::test]
748    async fn methods_and_builtin_functions() {
749        let ctx = json!({ "mime": "image/png", "list": [1, 2, 3] });
750        let starts = Expr::Method {
751            obj: Box::new(path(Root::Context, &["mime"])),
752            name: "starts_with".into(),
753            args: vec![Expr::Lit(json!("image/"))],
754        };
755        assert_eq!(run(&starts, ctx.clone()).await, json!(true));
756        let len = Expr::Method {
757            obj: Box::new(path(Root::Context, &["list"])),
758            name: "length".into(),
759            args: vec![],
760        };
761        assert_eq!(run(&len, ctx.clone()).await, json!(3));
762        let id = run(
763            &Expr::Call {
764                name: "new_uuid".into(),
765                args: vec![],
766            },
767            ctx.clone(),
768        )
769        .await;
770        assert!(uuid::Uuid::parse_str(id.as_str().unwrap()).is_ok());
771        let co = Expr::Call {
772            name: "coalesce".into(),
773            args: vec![Expr::Lit(Value::Null), Expr::Lit(json!(5))],
774        };
775        assert_eq!(run(&co, ctx).await, json!(5));
776    }
777
778    #[tokio::test]
779    async fn a_template_of_one_hole_keeps_the_type_and_mixed_text_is_text() {
780        let context = json!({ "n": 5, "who": "ann" });
781        let scope = Scope {
782            context: &context,
783            event: &Value::Null,
784            result: &Value::Null,
785            error: &Value::Null,
786            config: &Value::Null,
787            completed: &HashSet::new(),
788            now: Utc::now(),
789        };
790        let hole = Template(vec![Part::Expr(path(Root::Context, &["n"]))]);
791        assert_eq!(
792            render_template(&hole, &scope, &NoFuncs).await.unwrap(),
793            json!(5)
794        );
795        let mixed = Template(vec![
796            Part::Text("hi ".into()),
797            Part::Expr(path(Root::Context, &["who"])),
798            Part::Text(" #".into()),
799            Part::Expr(path(Root::Context, &["n"])),
800        ]);
801        assert_eq!(
802            render_template(&mixed, &scope, &NoFuncs).await.unwrap(),
803            json!("hi ann #5")
804        );
805        let tpl = Tpl::Map(vec![
806            ("a".into(), Tpl::Template(hole)),
807            ("b".into(), Tpl::List(vec![Tpl::Lit(json!(1))])),
808        ]);
809        assert_eq!(
810            render(&tpl, &scope, &NoFuncs).await.unwrap(),
811            json!({ "a": 5, "b": [1] })
812        );
813    }
814
815    #[test]
816    fn durations_read_with_a_unit_and_wait_keys_are_canonical() {
817        assert_eq!(parse_duration("15m"), Some(900));
818        assert_eq!(parse_duration("30d"), Some(2_592_000));
819        assert_eq!(parse_duration("120"), Some(120));
820        assert_eq!(parse_duration("soon"), None);
821        assert_eq!(parse_duration("5x"), None);
822        assert_eq!(
823            wait_key(&[json!("u1"), json!(3)]),
824            wait_key(&[json!("u1"), json!(3.0)])
825        );
826        assert_ne!(wait_key(&[json!("u1")]), wait_key(&[json!("u2")]));
827    }
828
829    #[test]
830    fn compiled_expressions_survive_their_json_form() {
831        let e = Expr::If {
832            c: Box::new(path(Root::Context, &["a"])),
833            t: lit(json!("x")),
834            e: Box::new(Expr::Bin {
835                op: BinOp::Add,
836                l: Box::new(Expr::Now),
837                r: Box::new(Expr::Dur(60)),
838            }),
839        };
840        let text = serde_json::to_string(&e).unwrap();
841        assert_eq!(serde_json::from_str::<Expr>(&text).unwrap(), e);
842    }
843}