1use 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
14pub enum Ref {
15 Input(String, Vec<String>),
18 Step(String),
19 Item(Vec<String>),
21 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#[derive(Debug, Clone, PartialEq)]
63pub enum StepTy {
64 ModelRanking(u64),
65 Selection(Ty),
66 Exact(Ty),
67 Semantic(crate::sem::SemKind, Vec<String>),
70}
71
72pub struct TyEnv<'a> {
74 pub inputs: &'a BTreeMap<String, Ty>,
75 pub steps: &'a BTreeMap<String, StepTy>,
76 pub item: Option<&'a Ty>,
77 pub v2: bool,
81}
82
83pub 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
90pub fn split_item(path: Vec<String>) -> Vec<String> {
92 path.iter()
93 .flat_map(|p| p.split('/').map(String::from))
94 .collect()
95}
96
97pub 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
106pub 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
115pub 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
163pub 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
182pub 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
194pub 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 Ty::Nullable(inner) => inner.as_ref(),
269 t => t,
270 };
271 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
293fn 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
315pub 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
358pub 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, <) && operand_fits(right, <, &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 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#[derive(Debug, Clone, PartialEq, Eq)]
522pub enum Fault {
523 Invalid(String),
525 Missing(String),
527 Conflict(String),
529 Absent(String),
532}
533
534impl From<ArithError> for Fault {
535 fn from(e: ArithError) -> Self {
536 Fault::Invalid(e.to_string())
537 }
538}
539
540pub 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 pub v2: bool,
550}
551
552fn 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 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
659pub 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 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 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 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 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 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 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}