Skip to main content

rustev_core/
expr.rs

1//! References, expressions and predicates of registry `rustev.exact/1`:
2//! typing (compile phase e) and evaluation (spec 002, 3.4 and 3.8).
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use rustev_contract::decimal::{ArithError, Decimal, Rounding};
7use rustev_contract::definition::{CmpOp, Expr, Literal, Predicate};
8use rustev_contract::time::{DurationMs, Timestamp};
9
10use crate::value::{Ty, Value};
11
12/// A parsed reference.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
14pub enum Ref {
15    /// An input, optionally followed by a record field path
16    /// (`input:trip.dates/check_in`).
17    Input(String, Vec<String>),
18    Step(String),
19    /// A field path inside the current item of a per-item operator.
20    Item(Vec<String>),
21    /// The item bound to a fan-out instance key (projections only).
22    Bind(String),
23    Now,
24}
25
26impl Ref {
27    pub fn parse(s: &str) -> Result<Ref, String> {
28        if s == "now" {
29            return Ok(Ref::Now);
30        }
31        let (kind, rest) = s
32            .split_once(':')
33            .ok_or_else(|| format!("reference {s:?} has no kind"))?;
34        if rest.is_empty() {
35            return Err(format!("reference {s:?} names nothing"));
36        }
37        match kind {
38            "input" => {
39                let mut parts = rest.split('/');
40                let name = parts.next().unwrap_or_default().to_string();
41                let path: Vec<String> = parts.map(String::from).collect();
42                if name.is_empty() || path.iter().any(String::is_empty) {
43                    return Err(format!("reference {s:?} has an empty part"));
44                }
45                Ok(Ref::Input(name, path))
46            }
47            "step" => Ok(Ref::Step(rest.into())),
48            "bind" => Ok(Ref::Bind(rest.into())),
49            "item" => {
50                let path: Vec<String> = rest.split('.').map(String::from).collect();
51                if path.iter().any(String::is_empty) {
52                    return Err(format!("reference {s:?} has an empty field"));
53                }
54                Ok(Ref::Item(path))
55            }
56            _ => Err(format!("reference {s:?} has unknown kind {kind:?}")),
57        }
58    }
59}
60
61/// The type of a step's result as expressions see it.
62#[derive(Debug, Clone, PartialEq)]
63pub enum StepTy {
64    ModelRanking(u64),
65    Selection(Ty),
66    Exact(Ty),
67    /// A semantic value and its fan-out binds; exact expressions cannot read
68    /// it, only `weighted_rank@1` components and the policy can.
69    Semantic(crate::sem::SemKind, Vec<String>),
70}
71
72/// Types visible while typing an expression.
73pub struct TyEnv<'a> {
74    pub inputs: &'a BTreeMap<String, Ty>,
75    pub steps: &'a BTreeMap<String, StepTy>,
76    pub item: Option<&'a Ty>,
77    /// A version 2 definition: step references may carry a field path
78    /// (`step:<id>/<field>`) and item paths may use `/` (spec 032, 3.4.4
79    /// and 3.5.4). Version 2 step ids and field names hold no `/` or `.`.
80    pub v2: bool,
81}
82
83/// A version 2 step reference's id and field path.
84pub fn split_step(s: &str) -> (String, Vec<String>) {
85    let mut parts = s.split('/');
86    let id = parts.next().unwrap_or_default().to_string();
87    (id, parts.map(String::from).collect())
88}
89
90/// A version 2 item path, with `/` as well as `.` separating fields.
91pub fn split_item(path: Vec<String>) -> Vec<String> {
92    path.iter()
93        .flat_map(|p| p.split('/').map(String::from))
94        .collect()
95}
96
97/// The dependency a reference names: `input:<name>` or `step:<id>` without
98/// any field path.
99pub fn base_ref(r: &str) -> String {
100    match r.strip_prefix("input:") {
101        Some(rest) => format!("input:{}", rest.split('/').next().unwrap_or_default()),
102        None => r.to_string(),
103    }
104}
105
106/// [`base_ref`] for a version 2 definition, whose step references may carry
107/// a field path.
108pub fn base_ref_v2(r: &str) -> String {
109    match r.strip_prefix("step:") {
110        Some(s) => format!("step:{}", split_step(s).0),
111        None => base_ref(r),
112    }
113}
114
115/// Every reference an expression or predicate reads, in reading order.
116pub fn expr_refs(e: &Expr, out: &mut Vec<String>) {
117    match e {
118        Expr::Ref(r) => out.push(r.clone()),
119        Expr::Lit(_) => {}
120        Expr::Add { left, right }
121        | Expr::Sub { left, right }
122        | Expr::Mul { left, right }
123        | Expr::Div { left, right } => {
124            expr_refs(left, out);
125            expr_refs(right, out);
126        }
127        Expr::Round { value, .. } | Expr::Len(value) | Expr::ToDecimal(value) => {
128            expr_refs(value, out)
129        }
130        Expr::Fx {
131            amount,
132            from,
133            to,
134            rates,
135            ..
136        } => {
137            expr_refs(amount, out);
138            expr_refs(from, out);
139            expr_refs(to, out);
140            out.push(rates.clone());
141        }
142        Expr::Graded { reference, .. } => out.push(reference.clone()),
143    }
144}
145
146pub fn predicate_refs(p: &Predicate, out: &mut Vec<String>) {
147    match p {
148        Predicate::Cmp { left, right, .. } => {
149            expr_refs(left, out);
150            expr_refs(right, out);
151        }
152        Predicate::All(ps) | Predicate::Any(ps) => ps.iter().for_each(|p| predicate_refs(p, out)),
153        Predicate::Not(p) => predicate_refs(p, out),
154        Predicate::Member { value, .. } => expr_refs(value, out),
155        Predicate::Present(r) | Predicate::IsConstructed { reference: r } => out.push(r.clone()),
156        Predicate::MemberRef { value, set } => {
157            expr_refs(value, out);
158            out.push(set.clone());
159        }
160    }
161}
162
163/// Whether an expression uses a construct of `rustev.exact/2` that a
164/// version 1 definition cannot carry (spec 032, 3.1.3).
165pub fn expr_uses_v2(e: &Expr) -> bool {
166    match e {
167        Expr::Graded { .. } => true,
168        Expr::Ref(_) | Expr::Lit(_) => false,
169        Expr::Add { left, right }
170        | Expr::Sub { left, right }
171        | Expr::Mul { left, right }
172        | Expr::Div { left, right } => expr_uses_v2(left) || expr_uses_v2(right),
173        Expr::Round { value, .. } | Expr::Len(value) | Expr::ToDecimal(value) => {
174            expr_uses_v2(value)
175        }
176        Expr::Fx {
177            amount, from, to, ..
178        } => expr_uses_v2(amount) || expr_uses_v2(from) || expr_uses_v2(to),
179    }
180}
181
182/// Whether a predicate uses a construct of `rustev.exact/2`.
183pub fn predicate_uses_v2(p: &Predicate) -> bool {
184    match p {
185        Predicate::MemberRef { .. } | Predicate::IsConstructed { .. } => true,
186        Predicate::Cmp { left, right, .. } => expr_uses_v2(left) || expr_uses_v2(right),
187        Predicate::All(ps) | Predicate::Any(ps) => ps.iter().any(predicate_uses_v2),
188        Predicate::Not(p) => predicate_uses_v2(p),
189        Predicate::Member { value, .. } => expr_uses_v2(value),
190        Predicate::Present(_) => false,
191    }
192}
193
194/// Structural checks of compile phase b: scales in range.
195pub fn expr_structure(e: &Expr) -> Result<(), String> {
196    match e {
197        Expr::Ref(_) | Expr::Lit(_) => Ok(()),
198        Expr::Add { left, right }
199        | Expr::Sub { left, right }
200        | Expr::Mul { left, right }
201        | Expr::Div { left, right } => {
202            expr_structure(left)?;
203            expr_structure(right)
204        }
205        Expr::Round { value, scale } => {
206            if *scale > 9 {
207                return Err(format!("scale {scale} exceeds 9"));
208            }
209            expr_structure(value)
210        }
211        Expr::Fx {
212            amount,
213            from,
214            to,
215            scale,
216            ..
217        } => {
218            if *scale > 9 {
219                return Err(format!("scale {scale} exceeds 9"));
220            }
221            expr_structure(amount)?;
222            expr_structure(from)?;
223            expr_structure(to)
224        }
225        Expr::Len(v) | Expr::ToDecimal(v) => expr_structure(v),
226        Expr::Graded { reference, .. } => match Ref::parse(reference)? {
227            Ref::Input(..) | Ref::Step(_) | Ref::Item(_) => Ok(()),
228            _ => Err(format!("a grade requirement reads {reference:?}")),
229        },
230    }
231}
232
233pub fn predicate_structure(p: &Predicate) -> Result<(), String> {
234    match p {
235        Predicate::Cmp { left, right, .. } => {
236            expr_structure(left)?;
237            expr_structure(right)
238        }
239        Predicate::All(ps) | Predicate::Any(ps) => ps.iter().try_for_each(predicate_structure),
240        Predicate::Not(p) => predicate_structure(p),
241        Predicate::Member { value, set } => {
242            if set.is_empty() {
243                return Err("member set is empty".into());
244            }
245            expr_structure(value)
246        }
247        Predicate::IsConstructed { reference } => Ref::parse(reference).map(|_| ()),
248        Predicate::Present(r) => match Ref::parse(r)? {
249            Ref::Step(s) if !s.contains('/') => Ok(()),
250            Ref::Input(_, p) if p.is_empty() => Ok(()),
251            _ => Err(format!(
252                "present() takes an input or a bare step reference, not {r:?}"
253            )),
254        },
255        Predicate::MemberRef { value, set } => {
256            expr_structure(value)?;
257            match Ref::parse(set)? {
258                Ref::Input(..) | Ref::Step(_) => Ok(()),
259                _ => Err(format!("member_ref set {set:?} must be an input or step")),
260            }
261        }
262    }
263}
264
265fn field_ty<'t>(ty: &'t Ty, path: &[String]) -> Result<&'t Ty, String> {
266    let unwrap = |t: &'t Ty| match t {
267        // Reading a nullable field yields its type; `null` is missing.
268        Ty::Nullable(inner) => inner.as_ref(),
269        t => t,
270    };
271    // A root is never nullable (`nullable` is a record field type only,
272    // spec 032 3.4.1), so only a field read is unwrapped.
273    let mut cur = ty;
274    for f in path {
275        if f.is_empty() {
276            return Err("a path has an empty field name".into());
277        }
278        match cur {
279            Ty::Record(fields) => {
280                cur = fields
281                    .iter()
282                    .find(|(n, _)| n == f)
283                    .map(|(_, t)| t)
284                    .ok_or_else(|| format!("no field {f:?}"))?;
285                cur = unwrap(cur);
286            }
287            other => return Err(format!("{} has no field {f:?}", other.name())),
288        }
289    }
290    Ok(cur)
291}
292
293/// Plan 3 acquired-key projection (spec 036 acceptance): one list of
294/// records becomes a bounded text list. Metadata uses the same `*` path.
295fn step_field_ty(ty: &Ty, path: &[String]) -> Result<Ty, String> {
296    let Some(index) = path.iter().position(|p| p == "*") else {
297        return field_ty(ty, path).cloned();
298    };
299    let Ty::List { item, max } = field_ty(ty, &path[..index])? else {
300        return Err("text projection requires a list of records".into());
301    };
302    if !matches!(item.as_ref(), Ty::Record(_)) || index + 1 == path.len() {
303        return Err("text projection requires a record field".into());
304    }
305    let projected = field_ty(item, &path[index + 1..])?;
306    if !matches!(projected, Ty::Text { .. }) {
307        return Err("text projection requires a text field".into());
308    }
309    Ok(Ty::List {
310        item: Box::new(projected.clone()),
311        max: *max,
312    })
313}
314
315/// The type of a reference, or a kind mismatch.
316pub fn ref_ty(r: &str, env: &TyEnv<'_>) -> Result<Ty, String> {
317    match Ref::parse(r)? {
318        Ref::Input(n, path) => {
319            let t = env
320                .inputs
321                .get(&n)
322                .ok_or_else(|| format!("unknown input {n:?}"))?;
323            field_ty(t, &path).cloned()
324        }
325        Ref::Step(s) => {
326            let (s, path) = if env.v2 { split_step(&s) } else { (s, vec![]) };
327            if s.is_empty() {
328                return Err(format!("{r:?} names no step"));
329            }
330            match env.steps.get(&s) {
331                Some(StepTy::Exact(t)) => step_field_ty(t, &path),
332                Some(StepTy::Selection(ty)) if path.is_empty() => Ok(ty.clone()),
333                Some(StepTy::Selection(_)) | Some(StepTy::ModelRanking(_)) => {
334                    Err("ranking value kind cannot be read here".into())
335                }
336                Some(StepTy::Semantic(..)) => Err(format!(
337                    "step {s:?} is semantic; exact operators cannot read it"
338                )),
339                None => Err(format!("unknown step {s:?}")),
340            }
341        }
342        Ref::Item(path) => {
343            let item = env
344                .item
345                .ok_or_else(|| format!("{r:?} outside a per-item operator"))?;
346            let path = if env.v2 { split_item(path) } else { path };
347            field_ty(item, &path).cloned()
348        }
349        Ref::Now => Ok(Ty::Timestamp),
350        Ref::Bind(_) => Err(format!("{r:?} is only valid in a projection")),
351    }
352}
353
354fn is_currency(t: &Ty) -> bool {
355    matches!(t, Ty::Text { .. } | Ty::Enum(_))
356}
357
358/// The static type of an expression (compile phase e). Values never convert
359/// silently: integer and decimal mix only through `to_decimal`.
360pub fn type_expr(e: &Expr, env: &TyEnv<'_>) -> Result<Ty, String> {
361    let bin = |l: &Expr, r: &Expr| -> Result<(Ty, Ty), String> {
362        Ok((type_expr(l, env)?, type_expr(r, env)?))
363    };
364    match e {
365        Expr::Ref(r) => ref_ty(r, env),
366        Expr::Lit(l) => Ok(Value::literal_ty(l)),
367        Expr::Add { left, right } => match bin(left, right)? {
368            (Ty::Integer, Ty::Integer) => Ok(Ty::Integer),
369            (Ty::Decimal, Ty::Decimal) => Ok(Ty::Decimal),
370            (Ty::Timestamp, Ty::Duration) => Ok(Ty::Timestamp),
371            (Ty::Duration, Ty::Duration) => Ok(Ty::Duration),
372            (a, b) => Err(format!("cannot add {} and {}", a.name(), b.name())),
373        },
374        Expr::Sub { left, right } => match bin(left, right)? {
375            (Ty::Integer, Ty::Integer) => Ok(Ty::Integer),
376            (Ty::Decimal, Ty::Decimal) => Ok(Ty::Decimal),
377            (Ty::Timestamp, Ty::Duration) => Ok(Ty::Timestamp),
378            (Ty::Timestamp, Ty::Timestamp) => Ok(Ty::Duration),
379            (Ty::Duration, Ty::Duration) => Ok(Ty::Duration),
380            (a, b) => Err(format!("cannot subtract {} from {}", b.name(), a.name())),
381        },
382        Expr::Mul { left, right } => match bin(left, right)? {
383            (Ty::Integer, Ty::Integer) => Ok(Ty::Integer),
384            (Ty::Decimal, Ty::Decimal) => Ok(Ty::Decimal),
385            (a, b) => Err(format!("cannot multiply {} by {}", a.name(), b.name())),
386        },
387        Expr::Div { left, right } => match bin(left, right)? {
388            (Ty::Decimal, Ty::Decimal) => Ok(Ty::Decimal),
389            (a, b) => Err(format!(
390                "cannot divide {} by {} (only decimals divide)",
391                a.name(),
392                b.name()
393            )),
394        },
395        Expr::Round { value, .. } => match type_expr(value, env)? {
396            Ty::Decimal => Ok(Ty::Decimal),
397            t => Err(format!("cannot round {}", t.name())),
398        },
399        Expr::Fx {
400            amount,
401            from,
402            to,
403            rates,
404            ..
405        } => {
406            if type_expr(amount, env)? != Ty::Decimal {
407                return Err("fx amount must be a decimal".into());
408            }
409            if !is_currency(&type_expr(from, env)?) || !is_currency(&type_expr(to, env)?) {
410                return Err("fx currencies must be text or enum".into());
411            }
412            match ref_ty(rates, env)? {
413                Ty::List { item, .. } => match item.as_ref() {
414                    Ty::Record(f)
415                        if f.iter().any(|(n, t)| n == "currency" && is_currency(t))
416                            && f.iter().any(|(n, t)| n == "rate" && *t == Ty::Decimal) =>
417                    {
418                        Ok(Ty::Decimal)
419                    }
420                    _ => Err(
421                        "fx rates must be a list of records with `currency` and decimal `rate`"
422                            .into(),
423                    ),
424                },
425                t => Err(format!("fx rates must be a list, not {}", t.name())),
426            }
427        }
428        Expr::Len(v) => match type_expr(v, env)? {
429            Ty::List { .. } => Ok(Ty::Integer),
430            t => Err(format!("len() of {}", t.name())),
431        },
432        Expr::ToDecimal(v) => match type_expr(v, env)? {
433            Ty::Integer => Ok(Ty::Decimal),
434            t => Err(format!("to_decimal() of {}", t.name())),
435        },
436        Expr::Graded { reference, .. } => ref_ty(reference, env),
437    }
438}
439
440fn literal_fits(l: &Literal, t: &Ty) -> bool {
441    match (l, t) {
442        (Literal::Enum(s), Ty::Enum(opts)) => opts.contains(s),
443        _ => Value::literal_ty(l).same_shape(t),
444    }
445}
446
447fn operand_fits(e: &Expr, t_other: &Ty, t_self: &Ty) -> bool {
448    match e {
449        Expr::Lit(l) => literal_fits(l, t_other),
450        _ => t_self.same_shape(t_other),
451    }
452}
453
454pub fn type_predicate(p: &Predicate, env: &TyEnv<'_>) -> Result<(), String> {
455    match p {
456        Predicate::Cmp { left, op, right } => {
457            let (lt, rt) = (type_expr(left, env)?, type_expr(right, env)?);
458            if matches!(lt, Ty::Maybe(_)) || matches!(rt, Ty::Maybe(_)) {
459                return Err("a maybe value cannot be compared".into());
460            }
461            if !(operand_fits(left, &rt, &lt) && operand_fits(right, &lt, &rt)) {
462                return Err(format!("cannot compare {} with {}", lt.name(), rt.name()));
463            }
464            let ordered = !matches!(op, CmpOp::Eq | CmpOp::Ne);
465            if ordered && !lt.is_orderable() {
466                return Err(format!("{} has no order", lt.name()));
467            }
468            if !ordered
469                && !matches!(
470                    lt,
471                    Ty::Bool
472                        | Ty::Integer
473                        | Ty::Decimal
474                        | Ty::Text { .. }
475                        | Ty::Enum(_)
476                        | Ty::Timestamp
477                        | Ty::Duration
478                )
479            {
480                return Err(format!("{} cannot be tested for equality", lt.name()));
481            }
482            Ok(())
483        }
484        Predicate::All(ps) | Predicate::Any(ps) => {
485            ps.iter().try_for_each(|p| type_predicate(p, env))
486        }
487        Predicate::Not(p) => type_predicate(p, env),
488        Predicate::Member { value, set } => {
489            let t = type_expr(value, env)?;
490            if set.iter().all(|l| literal_fits(l, &t)) {
491                Ok(())
492            } else {
493                Err(format!("member set does not match {}", t.name()))
494            }
495        }
496        Predicate::IsConstructed { reference } => ref_ty(reference, env).map(|_| ()),
497        Predicate::Present(r) => match Ref::parse(r)? {
498            Ref::Step(s) if env.v2 && !s.contains('/') => ref_ty(r, env).map(|_| ()),
499            Ref::Input(n, p) if p.is_empty() && env.inputs.contains_key(&n) => Ok(()),
500            _ => Err(format!("present() of unknown input {r:?}")),
501        },
502        Predicate::MemberRef { value, set } => {
503            let t = type_expr(value, env)?;
504            let textual = matches!(t, Ty::Text { .. } | Ty::Enum(_));
505            match ref_ty(set, env)? {
506                Ty::List { item, .. } if textual && item.same_shape(&t) => Ok(()),
507                // A map's keys are its members, never its values (spec 032,
508                // 3.7.2).
509                Ty::Map { .. } if textual => Ok(()),
510                s => Err(format!(
511                    "member_ref of {} in {}: the set is a list of text or enum values, or a map",
512                    t.name(),
513                    s.name()
514                )),
515            }
516        }
517    }
518}
519
520/// Why evaluation produced no value; becomes the step's unresolved reason.
521#[derive(Debug, Clone, PartialEq, Eq)]
522pub enum Fault {
523    /// Overflow, division by zero, an out-of-range timestamp.
524    Invalid(String),
525    /// A missing fact, e.g. `fx.rates.EUR`.
526    Missing(String),
527    /// Two different values for one fact.
528    Conflict(String),
529    /// A read of an absent value or a `null` field, naming its path
530    /// (spec 032, 3.4.4); only plan 3 values can be absent.
531    Absent(String),
532}
533
534impl From<ArithError> for Fault {
535    fn from(e: ArithError) -> Self {
536        Fault::Invalid(e.to_string())
537    }
538}
539
540/// Values visible while evaluating.
541pub struct Scope<'a> {
542    pub constructed: Option<&'a BTreeSet<String>>,
543    pub now: Timestamp,
544    pub inputs: &'a BTreeMap<String, Value>,
545    pub steps: &'a BTreeMap<String, Value>,
546    pub present: &'a BTreeSet<String>,
547    pub item: Option<&'a Value>,
548    /// See [`TyEnv::v2`].
549    pub v2: bool,
550}
551
552/// Reads `path` from `v`. A `null` or absent value on the way is missing
553/// evidence named by `name`. That includes the root: `field_ty` does not
554/// unwrap a root because no root is typed nullable (spec 032 3.4.1), so a
555/// null root can only be a fault, never a value.
556fn field<'v>(v: &'v Value, path: &[String], name: &dyn Fn() -> String) -> Result<&'v Value, Fault> {
557    let present = |v: &'v Value| match v {
558        Value::Null | Value::Absent(_) => Err(Fault::Absent(name())),
559        v => Ok(v),
560    };
561    let mut cur = present(v)?;
562    for f in path {
563        match cur {
564            Value::Record(m) => {
565                cur = present(
566                    m.get(f)
567                        .ok_or_else(|| Fault::Invalid(format!("no field {f:?}")))?,
568                )?
569            }
570            _ => return Err(Fault::Invalid(format!("no field {f:?}"))),
571        }
572    }
573    Ok(cur)
574}
575
576fn step_field(v: &Value, path: &[String], name: &dyn Fn() -> String) -> Result<Value, Fault> {
577    let Some(index) = path.iter().position(|p| p == "*") else {
578        return field(v, path, name).cloned();
579    };
580    let Value::List(items) = field(v, &path[..index], name)? else {
581        return Err(Fault::Invalid("text projection requires a list".into()));
582    };
583    items
584        .iter()
585        .map(|item| match field(item, &path[index + 1..], name)? {
586            Value::Text(text) => Ok(Value::Text(text.clone())),
587            _ => Err(Fault::Invalid(
588                "text projection requires a text field".into(),
589            )),
590        })
591        .collect::<Result<Vec<_>, _>>()
592        .map(Value::List)
593}
594
595pub fn eval_ref(r: &str, s: &Scope<'_>) -> Result<Value, Fault> {
596    match Ref::parse(r).map_err(Fault::Invalid)? {
597        Ref::Input(n, path) => {
598            let v = s.inputs.get(&n).ok_or_else(|| Fault::Missing(n.clone()))?;
599            field(v, &path, &|| {
600                [vec![n.clone()], path.clone()].concat().join("/")
601            })
602            .cloned()
603        }
604        Ref::Step(n) if s.v2 => {
605            let (id, path) = split_step(&n);
606            if id.is_empty() {
607                return Err(Fault::Invalid(format!("{r:?} names no step")));
608            }
609            let v = s.steps.get(&id).ok_or_else(|| Fault::Missing(id.clone()))?;
610            step_field(v, &path, &|| format!("step:{n}"))
611        }
612        Ref::Step(n) => s.steps.get(&n).cloned().ok_or(Fault::Missing(n)),
613        Ref::Item(path) => {
614            let item = s
615                .item
616                .ok_or_else(|| Fault::Invalid("no current item".into()))?;
617            let path = if s.v2 { split_item(path) } else { path };
618            // `Ref::parse` refuses a bare `item:`; the fallback keeps a
619            // missing-evidence name non-empty regardless.
620            let name = || {
621                if path.is_empty() {
622                    "item".to_string()
623                } else {
624                    path.join("/")
625                }
626            };
627            field(item, &path, &name).cloned()
628        }
629        Ref::Now => Ok(Value::Timestamp(s.now)),
630        Ref::Bind(b) => Err(Fault::Invalid(format!("bind:{b} outside a projection"))),
631    }
632}
633
634fn time_err(_: rustev_contract::time::TimeRangeError) -> Fault {
635    Fault::Invalid("timestamp or duration out of range".into())
636}
637
638fn rate(rates: &[Value], currency: &str, table: &str) -> Result<Decimal, Fault> {
639    let mut found: Option<Decimal> = None;
640    for r in rates {
641        let (Ok(Value::Text(c) | Value::Enum(c)), Ok(Value::Decimal(v))) = (
642            field(r, &["currency".into()], &String::new),
643            field(r, &["rate".into()], &String::new),
644        ) else {
645            continue;
646        };
647        if c == currency {
648            match found {
649                Some(prev) if prev != *v => {
650                    return Err(Fault::Conflict(format!("{table}.{currency}")));
651                }
652                _ => found = Some(*v),
653            }
654        }
655    }
656    found.ok_or_else(|| Fault::Missing(format!("{table}.{currency}")))
657}
658
659/// `amount / rate(from) * rate(to)`, each step half-even at 10^-9, then
660/// rescaled half-even to `scale` (`fx_convert@1`).
661pub fn fx(
662    amount: Decimal,
663    from: &str,
664    to: &str,
665    rates: &[Value],
666    table: &str,
667    scale: u32,
668) -> Result<Decimal, Fault> {
669    let rf = rate(rates, from, table)?;
670    let rt = rate(rates, to, table)?;
671    let base = amount.checked_div(rf, Rounding::HalfEven)?;
672    Ok(base
673        .checked_mul(rt, Rounding::HalfEven)?
674        .round_to_scale(scale, Rounding::HalfEven)?)
675}
676
677fn text_of(v: Value) -> Result<String, Fault> {
678    match v {
679        Value::Text(s) | Value::Enum(s) => Ok(s),
680        _ => Err(Fault::Invalid("expected text".into())),
681    }
682}
683
684pub fn eval_expr(e: &Expr, s: &Scope<'_>) -> Result<Value, Fault> {
685    let bin = |l: &Expr, r: &Expr| -> Result<(Value, Value), Fault> {
686        Ok((eval_expr(l, s)?, eval_expr(r, s)?))
687    };
688    let overflow = || Fault::Invalid("integer overflow".into());
689    match e {
690        Expr::Ref(r) => eval_ref(r, s),
691        Expr::Lit(l) => Ok(Value::from_literal(l)),
692        Expr::Add { left, right } => match bin(left, right)? {
693            (Value::Integer(a), Value::Integer(b)) => {
694                a.checked_add(b).map(Value::Integer).ok_or_else(overflow)
695            }
696            (Value::Decimal(a), Value::Decimal(b)) => Ok(Value::Decimal(a.checked_add(b)?)),
697            (Value::Timestamp(a), Value::Duration(b)) => {
698                a.checked_add(b).map(Value::Timestamp).map_err(time_err)
699            }
700            (Value::Duration(a), Value::Duration(b)) => {
701                a.0.checked_add(b.0)
702                    .map(|d| Value::Duration(DurationMs(d)))
703                    .ok_or_else(overflow)
704            }
705            _ => Err(Fault::Invalid("ill-typed add".into())),
706        },
707        Expr::Sub { left, right } => match bin(left, right)? {
708            (Value::Integer(a), Value::Integer(b)) => {
709                a.checked_sub(b).map(Value::Integer).ok_or_else(overflow)
710            }
711            (Value::Decimal(a), Value::Decimal(b)) => Ok(Value::Decimal(a.checked_sub(b)?)),
712            (Value::Timestamp(a), Value::Duration(b)) => {
713                a.checked_sub(b).map(Value::Timestamp).map_err(time_err)
714            }
715            (Value::Timestamp(a), Value::Timestamp(b)) => {
716                a.since(b).map(Value::Duration).map_err(time_err)
717            }
718            (Value::Duration(a), Value::Duration(b)) => {
719                a.0.checked_sub(b.0)
720                    .map(|d| Value::Duration(DurationMs(d)))
721                    .ok_or_else(|| Fault::Invalid("negative duration".into()))
722            }
723            _ => Err(Fault::Invalid("ill-typed sub".into())),
724        },
725        Expr::Mul { left, right } => match bin(left, right)? {
726            (Value::Integer(a), Value::Integer(b)) => {
727                a.checked_mul(b).map(Value::Integer).ok_or_else(overflow)
728            }
729            (Value::Decimal(a), Value::Decimal(b)) => {
730                Ok(Value::Decimal(a.checked_mul(b, Rounding::HalfEven)?))
731            }
732            _ => Err(Fault::Invalid("ill-typed mul".into())),
733        },
734        Expr::Div { left, right } => match bin(left, right)? {
735            (Value::Decimal(a), Value::Decimal(b)) => {
736                Ok(Value::Decimal(a.checked_div(b, Rounding::HalfEven)?))
737            }
738            _ => Err(Fault::Invalid("ill-typed div".into())),
739        },
740        Expr::Round { value, scale } => match eval_expr(value, s)? {
741            Value::Decimal(d) => Ok(Value::Decimal(
742                d.round_to_scale(*scale, Rounding::HalfEven)?,
743            )),
744            _ => Err(Fault::Invalid("ill-typed round".into())),
745        },
746        Expr::Fx {
747            amount,
748            from,
749            to,
750            rates,
751            scale,
752        } => {
753            let Value::Decimal(a) = eval_expr(amount, s)? else {
754                return Err(Fault::Invalid("ill-typed fx".into()));
755            };
756            let from = text_of(eval_expr(from, s)?)?;
757            let to = text_of(eval_expr(to, s)?)?;
758            let Value::List(table) = eval_ref(rates, s)? else {
759                return Err(Fault::Invalid("ill-typed fx rates".into()));
760            };
761            let name = rates.strip_prefix("input:").unwrap_or(rates);
762            Ok(Value::Decimal(fx(a, &from, &to, &table, name, *scale)?))
763        }
764        Expr::Len(v) => match eval_expr(v, s)? {
765            Value::Selection(s) => i64::try_from(s.entries.len())
766                .map(Value::Integer)
767                .map_err(|_| overflow()),
768            Value::List(items) => i64::try_from(items.len())
769                .map(Value::Integer)
770                .map_err(|_| overflow()),
771            _ => Err(Fault::Invalid("ill-typed len".into())),
772        },
773        Expr::ToDecimal(v) => match eval_expr(v, s)? {
774            Value::Integer(i) => Ok(Value::Decimal(Decimal::from_i64(i))),
775            _ => Err(Fault::Invalid("ill-typed to_decimal".into())),
776        },
777        // The grade requirement is checked by the evaluator before the
778        // expression is evaluated (spec 032, 3.9.1); the value is the
779        // reference's own.
780        Expr::Graded { reference, .. } => eval_ref(reference, s),
781    }
782}
783
784fn cmp_values(a: &Value, op: CmpOp, b: &Value) -> Result<bool, Fault> {
785    use std::cmp::Ordering;
786    let ord: Option<Ordering> = match (a, b) {
787        (Value::Integer(x), Value::Integer(y)) => Some(x.cmp(y)),
788        (Value::Decimal(x), Value::Decimal(y)) => Some(x.cmp(y)),
789        (Value::Timestamp(x), Value::Timestamp(y)) => Some(x.cmp(y)),
790        (Value::Duration(x), Value::Duration(y)) => Some(x.cmp(y)),
791        _ => None,
792    };
793    Ok(match (op, ord) {
794        (CmpOp::Eq, _) => {
795            a == b
796                || matches!((a, b), (Value::Text(x), Value::Text(y)) | (Value::Enum(x), Value::Enum(y)) if x == y)
797        }
798        (CmpOp::Ne, _) => a != b,
799        (CmpOp::Lt, Some(o)) => o == Ordering::Less,
800        (CmpOp::Le, Some(o)) => o != Ordering::Greater,
801        (CmpOp::Gt, Some(o)) => o == Ordering::Greater,
802        (CmpOp::Ge, Some(o)) => o != Ordering::Less,
803        _ => return Err(Fault::Invalid("ill-typed comparison".into())),
804    })
805}
806
807pub fn eval_predicate(p: &Predicate, s: &Scope<'_>) -> Result<bool, Fault> {
808    match p {
809        Predicate::Cmp { left, op, right } => {
810            cmp_values(&eval_expr(left, s)?, *op, &eval_expr(right, s)?)
811        }
812        Predicate::All(ps) => {
813            for p in ps {
814                if !eval_predicate(p, s)? {
815                    return Ok(false);
816                }
817            }
818            Ok(true)
819        }
820        Predicate::Any(ps) => {
821            for p in ps {
822                if eval_predicate(p, s)? {
823                    return Ok(true);
824                }
825            }
826            Ok(false)
827        }
828        Predicate::Not(p) => Ok(!eval_predicate(p, s)?),
829        Predicate::Member { value, set } => {
830            let v = eval_expr(value, s)?;
831            Ok(set.iter().any(|l| Value::from_literal(l) == v))
832        }
833        Predicate::IsConstructed { reference } => Ok(s
834            .constructed
835            .is_some_and(|set| set.contains(&base_ref_v2(reference)))
836            && eval_ref(reference, s).is_ok()),
837        Predicate::Present(r) if s.v2 => Ok(eval_ref(r, s).is_ok()),
838        Predicate::Present(r) => Ok(r
839            .strip_prefix("input:")
840            .is_some_and(|n| s.present.contains(n))),
841        Predicate::MemberRef { value, set } => {
842            let v = match eval_expr(value, s)? {
843                Value::Text(x) | Value::Enum(x) => x,
844                _ => return Err(Fault::Invalid("member_ref of a non-text value".into())),
845            };
846            match eval_ref(set, s)? {
847                Value::List(items) => Ok(items
848                    .iter()
849                    .any(|i| matches!(i, Value::Text(x) | Value::Enum(x) if *x == v))),
850                Value::Map(m) => Ok(m.contains_key(&v)),
851                _ => Err(Fault::Invalid("member_ref of a non-set".into())),
852            }
853        }
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use rustev_contract::definition::Literal as L;
861
862    fn dec(s: &str) -> Decimal {
863        Decimal::parse(s).unwrap()
864    }
865
866    fn rates(v: &[(&str, &str)]) -> Vec<Value> {
867        v.iter()
868            .map(|(c, r)| {
869                Value::Record(
870                    [
871                        ("currency".to_string(), Value::Text(c.to_string())),
872                        ("rate".to_string(), Value::Decimal(dec(r))),
873                    ]
874                    .into(),
875                )
876            })
877            .collect()
878    }
879
880    #[test]
881    fn plan_three_text_projection_checks_bounds_types_and_absence() {
882        let text = Ty::Text { max_bytes: 32 };
883        let records = Ty::List {
884            item: Box::new(Ty::Record(vec![
885                ("key".into(), text.clone()),
886                ("count".into(), Ty::Integer),
887            ])),
888            max: 4,
889        };
890        let ty = Ty::Record(vec![("items".into(), records)]);
891        let path: Vec<String> = ["items", "*", "key"].map(String::from).into();
892        assert_eq!(
893            step_field_ty(&ty, &path).unwrap(),
894            Ty::List {
895                item: Box::new(text),
896                max: 4
897            }
898        );
899        for p in [
900            vec!["items", "*", "count"],
901            vec!["items", "*", "unknown"],
902            vec!["items", "*"],
903            vec!["*", "key"],
904            vec!["items", "*", "key", "*", "x"],
905        ] {
906            assert!(
907                step_field_ty(&ty, &p.into_iter().map(String::from).collect::<Vec<_>>()).is_err()
908            );
909        }
910        let inputs = BTreeMap::new();
911        let steps = BTreeMap::from([("lookup".into(), StepTy::Exact(ty))]);
912        let mut env = TyEnv {
913            inputs: &inputs,
914            steps: &steps,
915            item: None,
916            v2: true,
917        };
918        assert!(ref_ty("step:lookup/items/*/key", &env).is_ok());
919        env.v2 = false;
920        assert!(ref_ty("step:lookup/items/*/key", &env).is_err());
921        let value = |keys: Vec<Value>| {
922            Value::Record(BTreeMap::from([(
923                "items".into(),
924                Value::List(
925                    keys.into_iter()
926                        .map(|key| Value::Record(BTreeMap::from([("key".into(), key)])))
927                        .collect(),
928                ),
929            )]))
930        };
931        let keys = vec![
932            Value::Text("b".into()),
933            Value::Text("a".into()),
934            Value::Text("b".into()),
935        ];
936        assert_eq!(
937            step_field(&value(keys.clone()), &path, &|| "lookup".into()).unwrap(),
938            Value::List(keys)
939        );
940        assert_eq!(
941            step_field(&value(vec![]), &path, &|| "lookup".into()).unwrap(),
942            Value::List(vec![])
943        );
944        assert!(matches!(
945            step_field(&value(vec![Value::Null]), &path, &|| "lookup/key".into()),
946            Err(Fault::Absent(_))
947        ));
948    }
949
950    #[test]
951    fn fx_converts_through_the_base_and_rounds_half_even() {
952        let t = rates(&[("USD", "1"), ("EUR", "0.8"), ("JPY", "150")]);
953        assert_eq!(fx(dec("100"), "EUR", "USD", &t, "fx", 2), Ok(dec("125")));
954        assert_eq!(fx(dec("1"), "USD", "JPY", &t, "fx", 0), Ok(dec("150")));
955        // 0.125 at scale 2 is a tie: half-even gives 0.12; 0.135 gives 0.14.
956        let t2 = rates(&[("A", "1"), ("B", "0.125"), ("C", "0.135")]);
957        assert_eq!(fx(dec("1"), "A", "B", &t2, "fx", 2), Ok(dec("0.12")));
958        assert_eq!(fx(dec("1"), "A", "C", &t2, "fx", 2), Ok(dec("0.14")));
959    }
960
961    #[test]
962    fn fx_missing_conflicting_and_zero_rates_are_faults() {
963        let t = rates(&[("USD", "1"), ("EUR", "0.8"), ("EUR", "0.9"), ("ZZZ", "0")]);
964        assert_eq!(
965            fx(dec("1"), "GBP", "USD", &t, "fx", 2),
966            Err(Fault::Missing("fx.GBP".into()))
967        );
968        assert_eq!(
969            fx(dec("1"), "EUR", "USD", &t, "fx", 2),
970            Err(Fault::Conflict("fx.EUR".into()))
971        );
972        assert!(matches!(
973            fx(dec("1"), "ZZZ", "USD", &t, "fx", 2),
974            Err(Fault::Invalid(_))
975        ));
976        // Duplicate but equal rates are not a conflict.
977        let same = rates(&[("USD", "1"), ("EUR", "0.8"), ("EUR", "0.8")]);
978        assert!(fx(dec("1"), "EUR", "USD", &same, "fx", 2).is_ok());
979    }
980
981    #[test]
982    fn arithmetic_overflow_is_a_fault_not_a_wrap() {
983        let inputs = BTreeMap::new();
984        let steps = BTreeMap::new();
985        let present = BTreeSet::new();
986        let s = Scope {
987            constructed: None,
988            now: Timestamp::from_ms(0).unwrap(),
989            inputs: &inputs,
990            steps: &steps,
991            present: &present,
992            item: None,
993            v2: false,
994        };
995        let big = Expr::Lit(L::Integer(i64::MAX));
996        let add = Expr::Add {
997            left: Box::new(big.clone()),
998            right: Box::new(Expr::Lit(L::Integer(1))),
999        };
1000        assert!(matches!(eval_expr(&add, &s), Err(Fault::Invalid(_))));
1001        let div0 = Expr::Div {
1002            left: Box::new(Expr::Lit(L::Decimal(dec("1")))),
1003            right: Box::new(Expr::Lit(L::Decimal(dec("0")))),
1004        };
1005        assert!(matches!(eval_expr(&div0, &s), Err(Fault::Invalid(_))));
1006        let back = Expr::Sub {
1007            left: Box::new(Expr::Ref("now".into())),
1008            right: Box::new(Expr::Lit(L::DurationMs(1))),
1009        };
1010        assert!(matches!(eval_expr(&back, &s), Err(Fault::Invalid(_))));
1011        // Negative control: in-range arithmetic succeeds.
1012        let ok = Expr::Add {
1013            left: Box::new(Expr::Lit(L::Integer(1))),
1014            right: Box::new(Expr::Lit(L::Integer(1))),
1015        };
1016        assert_eq!(eval_expr(&ok, &s), Ok(Value::Integer(2)));
1017    }
1018
1019    #[test]
1020    fn an_empty_field_name_in_a_path_is_refused() {
1021        let rec = Ty::Record(vec![("a".into(), Ty::Integer)]);
1022        assert_eq!(field_ty(&rec, &["a".into()]), Ok(&Ty::Integer));
1023        let e = field_ty(&rec, &["".into(), "a".into()]).unwrap_err();
1024        assert!(e.contains("empty field name"), "{e}");
1025    }
1026
1027    #[test]
1028    fn version_2_step_paths_split_at_the_step_id() {
1029        assert_eq!(
1030            split_step("join/match/total"),
1031            (
1032                "join".to_string(),
1033                vec!["match".to_string(), "total".to_string()]
1034            )
1035        );
1036        assert_eq!(split_step("plain"), ("plain".to_string(), vec![]));
1037        // `Ref::parse` takes a step reference whole, so a version 2 path
1038        // with an empty id is refused where it is split.
1039        assert!(Ref::parse("step:/a").is_ok());
1040        let inputs = BTreeMap::new();
1041        let steps = BTreeMap::from([("".to_string(), StepTy::Exact(Ty::Bool))]);
1042        let env = TyEnv {
1043            inputs: &inputs,
1044            steps: &steps,
1045            item: None,
1046            v2: true,
1047        };
1048        let e = ref_ty("step:/a", &env).unwrap_err();
1049        assert!(e.contains("names no step"), "{e}");
1050        let values = BTreeMap::from([("".to_string(), Value::Bool(true))]);
1051        let present = BTreeSet::new();
1052        let s = Scope {
1053            constructed: None,
1054            now: Timestamp::from_ms(0).unwrap(),
1055            inputs: &BTreeMap::new(),
1056            steps: &values,
1057            present: &present,
1058            item: None,
1059            v2: true,
1060        };
1061        assert!(
1062            matches!(eval_ref("step:/a", &s), Err(Fault::Invalid(e)) if e.contains("names no step"))
1063        );
1064        assert_eq!(base_ref_v2("step:join/match"), "step:join");
1065        assert_eq!(base_ref_v2("input:a/b"), "input:a");
1066        // Version 1 keeps a step reference whole.
1067        assert_eq!(base_ref("step:join/match"), "step:join/match");
1068        assert_eq!(
1069            split_item(vec!["match/total".into(), "unit".into()]),
1070            vec!["match", "total", "unit"]
1071        );
1072    }
1073
1074    #[test]
1075    fn member_ref_of_a_map_tests_its_keys() {
1076        let map_ty = Ty::Map {
1077            key_max: 8,
1078            value: Box::new(Ty::Text { max_bytes: 8 }),
1079            max: 4,
1080        };
1081        let tys = BTreeMap::from([
1082            ("codes".to_string(), map_ty),
1083            (
1084                "tier".to_string(),
1085                Ty::Enum(vec!["gold".into(), "eu".into()]),
1086            ),
1087            ("n".to_string(), Ty::Integer),
1088        ]);
1089        let step_tys = BTreeMap::new();
1090        let env = TyEnv {
1091            inputs: &tys,
1092            steps: &step_tys,
1093            item: None,
1094            v2: true,
1095        };
1096        let member = |value: &str| Predicate::MemberRef {
1097            value: Expr::Ref(value.into()),
1098            set: "input:codes".into(),
1099        };
1100        assert!(type_predicate(&member("input:tier"), &env).is_ok());
1101        assert!(
1102            type_predicate(&member("input:n"), &env).is_err(),
1103            "a non-text value"
1104        );
1105
1106        let inputs = BTreeMap::from([
1107            (
1108                "codes".to_string(),
1109                Value::Map(BTreeMap::from([(
1110                    "eu".to_string(),
1111                    Value::Text("gold".into()),
1112                )])),
1113            ),
1114            ("tier".to_string(), Value::Enum("eu".into())),
1115            ("other".to_string(), Value::Enum("gold".into())),
1116        ]);
1117        let steps = BTreeMap::new();
1118        let present = BTreeSet::new();
1119        let s = Scope {
1120            constructed: None,
1121            now: Timestamp::from_ms(0).unwrap(),
1122            inputs: &inputs,
1123            steps: &steps,
1124            present: &present,
1125            item: None,
1126            v2: true,
1127        };
1128        assert_eq!(eval_predicate(&member("input:tier"), &s), Ok(true));
1129        assert_eq!(
1130            eval_predicate(&member("input:other"), &s),
1131            Ok(false),
1132            "a map's values are not its members"
1133        );
1134    }
1135
1136    #[test]
1137    fn a_reference_that_names_nothing_is_refused() {
1138        for r in ["item:", "input:", "step:", "bind:"] {
1139            assert!(Ref::parse(r).is_err(), "{r}");
1140        }
1141        let item = Value::Null;
1142        let inputs = BTreeMap::new();
1143        let steps = BTreeMap::new();
1144        let present = BTreeSet::new();
1145        let s = Scope {
1146            constructed: None,
1147            now: Timestamp::from_ms(0).unwrap(),
1148            inputs: &inputs,
1149            steps: &steps,
1150            present: &present,
1151            item: Some(&item),
1152            v2: true,
1153        };
1154        assert_eq!(eval_ref("item:a", &s), Err(Fault::Absent("a".into())));
1155    }
1156
1157    #[test]
1158    fn integers_and_decimals_do_not_mix_silently() {
1159        let inputs = BTreeMap::new();
1160        let steps = BTreeMap::new();
1161        let env = TyEnv {
1162            inputs: &inputs,
1163            steps: &steps,
1164            item: None,
1165            v2: false,
1166        };
1167        let mixed = Expr::Add {
1168            left: Box::new(Expr::Lit(L::Integer(1))),
1169            right: Box::new(Expr::Lit(L::Decimal(dec("1")))),
1170        };
1171        assert!(type_expr(&mixed, &env).is_err());
1172        let explicit = Expr::Add {
1173            left: Box::new(Expr::ToDecimal(Box::new(Expr::Lit(L::Integer(1))))),
1174            right: Box::new(Expr::Lit(L::Decimal(dec("1")))),
1175        };
1176        assert_eq!(type_expr(&explicit, &env), Ok(Ty::Decimal));
1177        let int_div = Expr::Div {
1178            left: Box::new(Expr::Lit(L::Integer(1))),
1179            right: Box::new(Expr::Lit(L::Integer(1))),
1180        };
1181        assert!(type_expr(&int_div, &env).is_err());
1182    }
1183
1184    #[test]
1185    fn enum_literals_must_be_declared_options() {
1186        let inputs: BTreeMap<String, Ty> = [(
1187            "tier".to_string(),
1188            Ty::Enum(vec!["free".into(), "enterprise".into()]),
1189        )]
1190        .into();
1191        let steps = BTreeMap::new();
1192        let env = TyEnv {
1193            inputs: &inputs,
1194            steps: &steps,
1195            item: None,
1196            v2: false,
1197        };
1198        let ok = Predicate::Cmp {
1199            left: Expr::Ref("input:tier".into()),
1200            op: CmpOp::Eq,
1201            right: Expr::Lit(L::Enum("enterprise".into())),
1202        };
1203        assert!(type_predicate(&ok, &env).is_ok());
1204        let bad = Predicate::Cmp {
1205            left: Expr::Ref("input:tier".into()),
1206            op: CmpOp::Eq,
1207            right: Expr::Lit(L::Enum("gold".into())),
1208        };
1209        assert!(type_predicate(&bad, &env).is_err());
1210        let text = Predicate::Cmp {
1211            left: Expr::Ref("input:tier".into()),
1212            op: CmpOp::Eq,
1213            right: Expr::Lit(L::Text("enterprise".into())),
1214        };
1215        assert!(type_predicate(&text, &env).is_err(), "text is not an enum");
1216        let ordered = Predicate::Cmp {
1217            left: Expr::Ref("input:tier".into()),
1218            op: CmpOp::Lt,
1219            right: Expr::Lit(L::Enum("free".into())),
1220        };
1221        assert!(
1222            type_predicate(&ordered, &env).is_err(),
1223            "enums have no order"
1224        );
1225    }
1226
1227    #[test]
1228    fn references_parse_strictly() {
1229        assert_eq!(
1230            Ref::parse("input:a.b"),
1231            Ok(Ref::Input("a.b".into(), vec![]))
1232        );
1233        assert_eq!(
1234            Ref::parse("input:a.b/c/d"),
1235            Ok(Ref::Input("a.b".into(), vec!["c".into(), "d".into()]))
1236        );
1237        assert_eq!(
1238            Ref::parse("item:price.amount"),
1239            Ok(Ref::Item(vec!["price".into(), "amount".into()]))
1240        );
1241        assert_eq!(Ref::parse("now"), Ok(Ref::Now));
1242        for bad in [
1243            "",
1244            "input:",
1245            "x:y",
1246            "item:a..b",
1247            "plain",
1248            "input:a//b",
1249            "input:/b",
1250        ] {
1251            assert!(Ref::parse(bad).is_err(), "{bad:?}");
1252        }
1253    }
1254}