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::List(items) => i64::try_from(items.len())
766 .map(Value::Integer)
767 .map_err(|_| overflow()),
768 _ => Err(Fault::Invalid("ill-typed len".into())),
769 },
770 Expr::ToDecimal(v) => match eval_expr(v, s)? {
771 Value::Integer(i) => Ok(Value::Decimal(Decimal::from_i64(i))),
772 _ => Err(Fault::Invalid("ill-typed to_decimal".into())),
773 },
774 Expr::Graded { reference, .. } => eval_ref(reference, s),
778 }
779}
780
781fn cmp_values(a: &Value, op: CmpOp, b: &Value) -> Result<bool, Fault> {
782 use std::cmp::Ordering;
783 let ord: Option<Ordering> = match (a, b) {
784 (Value::Integer(x), Value::Integer(y)) => Some(x.cmp(y)),
785 (Value::Decimal(x), Value::Decimal(y)) => Some(x.cmp(y)),
786 (Value::Timestamp(x), Value::Timestamp(y)) => Some(x.cmp(y)),
787 (Value::Duration(x), Value::Duration(y)) => Some(x.cmp(y)),
788 _ => None,
789 };
790 Ok(match (op, ord) {
791 (CmpOp::Eq, _) => {
792 a == b
793 || matches!((a, b), (Value::Text(x), Value::Text(y)) | (Value::Enum(x), Value::Enum(y)) if x == y)
794 }
795 (CmpOp::Ne, _) => a != b,
796 (CmpOp::Lt, Some(o)) => o == Ordering::Less,
797 (CmpOp::Le, Some(o)) => o != Ordering::Greater,
798 (CmpOp::Gt, Some(o)) => o == Ordering::Greater,
799 (CmpOp::Ge, Some(o)) => o != Ordering::Less,
800 _ => return Err(Fault::Invalid("ill-typed comparison".into())),
801 })
802}
803
804pub fn eval_predicate(p: &Predicate, s: &Scope<'_>) -> Result<bool, Fault> {
805 match p {
806 Predicate::Cmp { left, op, right } => {
807 cmp_values(&eval_expr(left, s)?, *op, &eval_expr(right, s)?)
808 }
809 Predicate::All(ps) => {
810 for p in ps {
811 if !eval_predicate(p, s)? {
812 return Ok(false);
813 }
814 }
815 Ok(true)
816 }
817 Predicate::Any(ps) => {
818 for p in ps {
819 if eval_predicate(p, s)? {
820 return Ok(true);
821 }
822 }
823 Ok(false)
824 }
825 Predicate::Not(p) => Ok(!eval_predicate(p, s)?),
826 Predicate::Member { value, set } => {
827 let v = eval_expr(value, s)?;
828 Ok(set.iter().any(|l| Value::from_literal(l) == v))
829 }
830 Predicate::IsConstructed { reference } => Ok(s
831 .constructed
832 .is_some_and(|set| set.contains(&base_ref_v2(reference)))
833 && eval_ref(reference, s).is_ok()),
834 Predicate::Present(r) if s.v2 => Ok(eval_ref(r, s).is_ok()),
835 Predicate::Present(r) => Ok(r
836 .strip_prefix("input:")
837 .is_some_and(|n| s.present.contains(n))),
838 Predicate::MemberRef { value, set } => {
839 let v = match eval_expr(value, s)? {
840 Value::Text(x) | Value::Enum(x) => x,
841 _ => return Err(Fault::Invalid("member_ref of a non-text value".into())),
842 };
843 match eval_ref(set, s)? {
844 Value::List(items) => Ok(items
845 .iter()
846 .any(|i| matches!(i, Value::Text(x) | Value::Enum(x) if *x == v))),
847 Value::Map(m) => Ok(m.contains_key(&v)),
848 _ => Err(Fault::Invalid("member_ref of a non-set".into())),
849 }
850 }
851 }
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857 use rustev_contract::definition::Literal as L;
858
859 fn dec(s: &str) -> Decimal {
860 Decimal::parse(s).unwrap()
861 }
862
863 fn rates(v: &[(&str, &str)]) -> Vec<Value> {
864 v.iter()
865 .map(|(c, r)| {
866 Value::Record(
867 [
868 ("currency".to_string(), Value::Text(c.to_string())),
869 ("rate".to_string(), Value::Decimal(dec(r))),
870 ]
871 .into(),
872 )
873 })
874 .collect()
875 }
876
877 #[test]
878 fn plan_three_text_projection_checks_bounds_types_and_absence() {
879 let text = Ty::Text { max_bytes: 32 };
880 let records = Ty::List {
881 item: Box::new(Ty::Record(vec![
882 ("key".into(), text.clone()),
883 ("count".into(), Ty::Integer),
884 ])),
885 max: 4,
886 };
887 let ty = Ty::Record(vec![("items".into(), records)]);
888 let path: Vec<String> = ["items", "*", "key"].map(String::from).into();
889 assert_eq!(
890 step_field_ty(&ty, &path).unwrap(),
891 Ty::List {
892 item: Box::new(text),
893 max: 4
894 }
895 );
896 for p in [
897 vec!["items", "*", "count"],
898 vec!["items", "*", "unknown"],
899 vec!["items", "*"],
900 vec!["*", "key"],
901 vec!["items", "*", "key", "*", "x"],
902 ] {
903 assert!(
904 step_field_ty(&ty, &p.into_iter().map(String::from).collect::<Vec<_>>()).is_err()
905 );
906 }
907 let inputs = BTreeMap::new();
908 let steps = BTreeMap::from([("lookup".into(), StepTy::Exact(ty))]);
909 let mut env = TyEnv {
910 inputs: &inputs,
911 steps: &steps,
912 item: None,
913 v2: true,
914 };
915 assert!(ref_ty("step:lookup/items/*/key", &env).is_ok());
916 env.v2 = false;
917 assert!(ref_ty("step:lookup/items/*/key", &env).is_err());
918 let value = |keys: Vec<Value>| {
919 Value::Record(BTreeMap::from([(
920 "items".into(),
921 Value::List(
922 keys.into_iter()
923 .map(|key| Value::Record(BTreeMap::from([("key".into(), key)])))
924 .collect(),
925 ),
926 )]))
927 };
928 let keys = vec![
929 Value::Text("b".into()),
930 Value::Text("a".into()),
931 Value::Text("b".into()),
932 ];
933 assert_eq!(
934 step_field(&value(keys.clone()), &path, &|| "lookup".into()).unwrap(),
935 Value::List(keys)
936 );
937 assert_eq!(
938 step_field(&value(vec![]), &path, &|| "lookup".into()).unwrap(),
939 Value::List(vec![])
940 );
941 assert!(matches!(
942 step_field(&value(vec![Value::Null]), &path, &|| "lookup/key".into()),
943 Err(Fault::Absent(_))
944 ));
945 }
946
947 #[test]
948 fn fx_converts_through_the_base_and_rounds_half_even() {
949 let t = rates(&[("USD", "1"), ("EUR", "0.8"), ("JPY", "150")]);
950 assert_eq!(fx(dec("100"), "EUR", "USD", &t, "fx", 2), Ok(dec("125")));
951 assert_eq!(fx(dec("1"), "USD", "JPY", &t, "fx", 0), Ok(dec("150")));
952 let t2 = rates(&[("A", "1"), ("B", "0.125"), ("C", "0.135")]);
954 assert_eq!(fx(dec("1"), "A", "B", &t2, "fx", 2), Ok(dec("0.12")));
955 assert_eq!(fx(dec("1"), "A", "C", &t2, "fx", 2), Ok(dec("0.14")));
956 }
957
958 #[test]
959 fn fx_missing_conflicting_and_zero_rates_are_faults() {
960 let t = rates(&[("USD", "1"), ("EUR", "0.8"), ("EUR", "0.9"), ("ZZZ", "0")]);
961 assert_eq!(
962 fx(dec("1"), "GBP", "USD", &t, "fx", 2),
963 Err(Fault::Missing("fx.GBP".into()))
964 );
965 assert_eq!(
966 fx(dec("1"), "EUR", "USD", &t, "fx", 2),
967 Err(Fault::Conflict("fx.EUR".into()))
968 );
969 assert!(matches!(
970 fx(dec("1"), "ZZZ", "USD", &t, "fx", 2),
971 Err(Fault::Invalid(_))
972 ));
973 let same = rates(&[("USD", "1"), ("EUR", "0.8"), ("EUR", "0.8")]);
975 assert!(fx(dec("1"), "EUR", "USD", &same, "fx", 2).is_ok());
976 }
977
978 #[test]
979 fn arithmetic_overflow_is_a_fault_not_a_wrap() {
980 let inputs = BTreeMap::new();
981 let steps = BTreeMap::new();
982 let present = BTreeSet::new();
983 let s = Scope {
984 constructed: None,
985 now: Timestamp::from_ms(0).unwrap(),
986 inputs: &inputs,
987 steps: &steps,
988 present: &present,
989 item: None,
990 v2: false,
991 };
992 let big = Expr::Lit(L::Integer(i64::MAX));
993 let add = Expr::Add {
994 left: Box::new(big.clone()),
995 right: Box::new(Expr::Lit(L::Integer(1))),
996 };
997 assert!(matches!(eval_expr(&add, &s), Err(Fault::Invalid(_))));
998 let div0 = Expr::Div {
999 left: Box::new(Expr::Lit(L::Decimal(dec("1")))),
1000 right: Box::new(Expr::Lit(L::Decimal(dec("0")))),
1001 };
1002 assert!(matches!(eval_expr(&div0, &s), Err(Fault::Invalid(_))));
1003 let back = Expr::Sub {
1004 left: Box::new(Expr::Ref("now".into())),
1005 right: Box::new(Expr::Lit(L::DurationMs(1))),
1006 };
1007 assert!(matches!(eval_expr(&back, &s), Err(Fault::Invalid(_))));
1008 let ok = Expr::Add {
1010 left: Box::new(Expr::Lit(L::Integer(1))),
1011 right: Box::new(Expr::Lit(L::Integer(1))),
1012 };
1013 assert_eq!(eval_expr(&ok, &s), Ok(Value::Integer(2)));
1014 }
1015
1016 #[test]
1017 fn an_empty_field_name_in_a_path_is_refused() {
1018 let rec = Ty::Record(vec![("a".into(), Ty::Integer)]);
1019 assert_eq!(field_ty(&rec, &["a".into()]), Ok(&Ty::Integer));
1020 let e = field_ty(&rec, &["".into(), "a".into()]).unwrap_err();
1021 assert!(e.contains("empty field name"), "{e}");
1022 }
1023
1024 #[test]
1025 fn version_2_step_paths_split_at_the_step_id() {
1026 assert_eq!(
1027 split_step("join/match/total"),
1028 (
1029 "join".to_string(),
1030 vec!["match".to_string(), "total".to_string()]
1031 )
1032 );
1033 assert_eq!(split_step("plain"), ("plain".to_string(), vec![]));
1034 assert!(Ref::parse("step:/a").is_ok());
1037 let inputs = BTreeMap::new();
1038 let steps = BTreeMap::from([("".to_string(), StepTy::Exact(Ty::Bool))]);
1039 let env = TyEnv {
1040 inputs: &inputs,
1041 steps: &steps,
1042 item: None,
1043 v2: true,
1044 };
1045 let e = ref_ty("step:/a", &env).unwrap_err();
1046 assert!(e.contains("names no step"), "{e}");
1047 let values = BTreeMap::from([("".to_string(), Value::Bool(true))]);
1048 let present = BTreeSet::new();
1049 let s = Scope {
1050 constructed: None,
1051 now: Timestamp::from_ms(0).unwrap(),
1052 inputs: &BTreeMap::new(),
1053 steps: &values,
1054 present: &present,
1055 item: None,
1056 v2: true,
1057 };
1058 assert!(
1059 matches!(eval_ref("step:/a", &s), Err(Fault::Invalid(e)) if e.contains("names no step"))
1060 );
1061 assert_eq!(base_ref_v2("step:join/match"), "step:join");
1062 assert_eq!(base_ref_v2("input:a/b"), "input:a");
1063 assert_eq!(base_ref("step:join/match"), "step:join/match");
1065 assert_eq!(
1066 split_item(vec!["match/total".into(), "unit".into()]),
1067 vec!["match", "total", "unit"]
1068 );
1069 }
1070
1071 #[test]
1072 fn member_ref_of_a_map_tests_its_keys() {
1073 let map_ty = Ty::Map {
1074 key_max: 8,
1075 value: Box::new(Ty::Text { max_bytes: 8 }),
1076 max: 4,
1077 };
1078 let tys = BTreeMap::from([
1079 ("codes".to_string(), map_ty),
1080 (
1081 "tier".to_string(),
1082 Ty::Enum(vec!["gold".into(), "eu".into()]),
1083 ),
1084 ("n".to_string(), Ty::Integer),
1085 ]);
1086 let step_tys = BTreeMap::new();
1087 let env = TyEnv {
1088 inputs: &tys,
1089 steps: &step_tys,
1090 item: None,
1091 v2: true,
1092 };
1093 let member = |value: &str| Predicate::MemberRef {
1094 value: Expr::Ref(value.into()),
1095 set: "input:codes".into(),
1096 };
1097 assert!(type_predicate(&member("input:tier"), &env).is_ok());
1098 assert!(
1099 type_predicate(&member("input:n"), &env).is_err(),
1100 "a non-text value"
1101 );
1102
1103 let inputs = BTreeMap::from([
1104 (
1105 "codes".to_string(),
1106 Value::Map(BTreeMap::from([(
1107 "eu".to_string(),
1108 Value::Text("gold".into()),
1109 )])),
1110 ),
1111 ("tier".to_string(), Value::Enum("eu".into())),
1112 ("other".to_string(), Value::Enum("gold".into())),
1113 ]);
1114 let steps = BTreeMap::new();
1115 let present = BTreeSet::new();
1116 let s = Scope {
1117 constructed: None,
1118 now: Timestamp::from_ms(0).unwrap(),
1119 inputs: &inputs,
1120 steps: &steps,
1121 present: &present,
1122 item: None,
1123 v2: true,
1124 };
1125 assert_eq!(eval_predicate(&member("input:tier"), &s), Ok(true));
1126 assert_eq!(
1127 eval_predicate(&member("input:other"), &s),
1128 Ok(false),
1129 "a map's values are not its members"
1130 );
1131 }
1132
1133 #[test]
1134 fn a_reference_that_names_nothing_is_refused() {
1135 for r in ["item:", "input:", "step:", "bind:"] {
1136 assert!(Ref::parse(r).is_err(), "{r}");
1137 }
1138 let item = Value::Null;
1139 let inputs = BTreeMap::new();
1140 let steps = BTreeMap::new();
1141 let present = BTreeSet::new();
1142 let s = Scope {
1143 constructed: None,
1144 now: Timestamp::from_ms(0).unwrap(),
1145 inputs: &inputs,
1146 steps: &steps,
1147 present: &present,
1148 item: Some(&item),
1149 v2: true,
1150 };
1151 assert_eq!(eval_ref("item:a", &s), Err(Fault::Absent("a".into())));
1152 }
1153
1154 #[test]
1155 fn integers_and_decimals_do_not_mix_silently() {
1156 let inputs = BTreeMap::new();
1157 let steps = BTreeMap::new();
1158 let env = TyEnv {
1159 inputs: &inputs,
1160 steps: &steps,
1161 item: None,
1162 v2: false,
1163 };
1164 let mixed = Expr::Add {
1165 left: Box::new(Expr::Lit(L::Integer(1))),
1166 right: Box::new(Expr::Lit(L::Decimal(dec("1")))),
1167 };
1168 assert!(type_expr(&mixed, &env).is_err());
1169 let explicit = Expr::Add {
1170 left: Box::new(Expr::ToDecimal(Box::new(Expr::Lit(L::Integer(1))))),
1171 right: Box::new(Expr::Lit(L::Decimal(dec("1")))),
1172 };
1173 assert_eq!(type_expr(&explicit, &env), Ok(Ty::Decimal));
1174 let int_div = Expr::Div {
1175 left: Box::new(Expr::Lit(L::Integer(1))),
1176 right: Box::new(Expr::Lit(L::Integer(1))),
1177 };
1178 assert!(type_expr(&int_div, &env).is_err());
1179 }
1180
1181 #[test]
1182 fn enum_literals_must_be_declared_options() {
1183 let inputs: BTreeMap<String, Ty> = [(
1184 "tier".to_string(),
1185 Ty::Enum(vec!["free".into(), "enterprise".into()]),
1186 )]
1187 .into();
1188 let steps = BTreeMap::new();
1189 let env = TyEnv {
1190 inputs: &inputs,
1191 steps: &steps,
1192 item: None,
1193 v2: false,
1194 };
1195 let ok = Predicate::Cmp {
1196 left: Expr::Ref("input:tier".into()),
1197 op: CmpOp::Eq,
1198 right: Expr::Lit(L::Enum("enterprise".into())),
1199 };
1200 assert!(type_predicate(&ok, &env).is_ok());
1201 let bad = Predicate::Cmp {
1202 left: Expr::Ref("input:tier".into()),
1203 op: CmpOp::Eq,
1204 right: Expr::Lit(L::Enum("gold".into())),
1205 };
1206 assert!(type_predicate(&bad, &env).is_err());
1207 let text = Predicate::Cmp {
1208 left: Expr::Ref("input:tier".into()),
1209 op: CmpOp::Eq,
1210 right: Expr::Lit(L::Text("enterprise".into())),
1211 };
1212 assert!(type_predicate(&text, &env).is_err(), "text is not an enum");
1213 let ordered = Predicate::Cmp {
1214 left: Expr::Ref("input:tier".into()),
1215 op: CmpOp::Lt,
1216 right: Expr::Lit(L::Enum("free".into())),
1217 };
1218 assert!(
1219 type_predicate(&ordered, &env).is_err(),
1220 "enums have no order"
1221 );
1222 }
1223
1224 #[test]
1225 fn references_parse_strictly() {
1226 assert_eq!(
1227 Ref::parse("input:a.b"),
1228 Ok(Ref::Input("a.b".into(), vec![]))
1229 );
1230 assert_eq!(
1231 Ref::parse("input:a.b/c/d"),
1232 Ok(Ref::Input("a.b".into(), vec!["c".into(), "d".into()]))
1233 );
1234 assert_eq!(
1235 Ref::parse("item:price.amount"),
1236 Ok(Ref::Item(vec!["price".into(), "amount".into()]))
1237 );
1238 assert_eq!(Ref::parse("now"), Ok(Ref::Now));
1239 for bad in [
1240 "",
1241 "input:",
1242 "x:y",
1243 "item:a..b",
1244 "plain",
1245 "input:a//b",
1246 "input:/b",
1247 ] {
1248 assert!(Ref::parse(bad).is_err(), "{bad:?}");
1249 }
1250 }
1251}