Skip to main content

rustev_core/
value.rs

1//! Exact values and their types (spec 002, 3.4 and 3.7.2), strict decoding of
2//! snapshot values, and the worst-case canonical size used by budgets.
3
4use std::collections::BTreeMap;
5
6use rustev_contract::decimal::Decimal;
7use rustev_contract::definition::{Literal, TypeDecl};
8use rustev_contract::grade::{AbsentReason, PresentWhen};
9use rustev_contract::judgment::{Filtered, OutValue, Ranking, Shortlist};
10use rustev_contract::time::{DurationMs, Timestamp};
11use serde_json::Value as Json;
12
13/// The static type of an exact value.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum Ty {
16    Bool,
17    Integer,
18    Decimal,
19    Text {
20        max_bytes: u64,
21    },
22    Enum(Vec<String>),
23    Timestamp,
24    Duration,
25    /// An operator result that may be empty, such as `extremum@1` over no
26    /// items or a `timestamp_diff@1` of one; distinct from [`Ty::Nullable`],
27    /// a declared version 2 record field.
28    Maybe(Box<Ty>),
29    List {
30        item: Box<Ty>,
31        max: u64,
32    },
33    Record(Vec<(String, Ty)>),
34    /// A filter result: at most `max` eligible ids of at most `id_bytes`.
35    Filtered {
36        max: u64,
37        id_bytes: u64,
38    },
39    Shortlist {
40        max: u64,
41        id_bytes: u64,
42    },
43    Ranking {
44        max: u64,
45    },
46    /// Version 2, record fields only: the field's type, or `null` (spec 032,
47    /// 3.4.1). Reading the field yields the inner type; a `null` read is
48    /// `missing_evidence`.
49    Nullable(Box<Ty>),
50    /// Version 2: text keys of at most `key_max` bytes, at most `max`
51    /// entries.
52    Map {
53        key_max: u64,
54        value: Box<Ty>,
55        max: u64,
56    },
57    /// A `join_keyed@1` result: one entry per left item, in left order
58    /// (spec 032, 3.5.2). Per-item operators see a matched entry as the
59    /// record `{id, match}`.
60    Joined {
61        value: Box<Ty>,
62        max: u64,
63        id_bytes: u64,
64    },
65}
66
67impl Ty {
68    pub fn from_decl(d: &TypeDecl) -> Ty {
69        match d {
70            TypeDecl::Bool => Ty::Bool,
71            TypeDecl::Integer => Ty::Integer,
72            TypeDecl::Decimal => Ty::Decimal,
73            TypeDecl::Text { max_bytes } => Ty::Text {
74                max_bytes: *max_bytes,
75            },
76            TypeDecl::Timestamp => Ty::Timestamp,
77            TypeDecl::Enum { options } => Ty::Enum(options.clone()),
78            TypeDecl::List { item, max_items } => Ty::List {
79                item: Box::new(Ty::from_decl(item)),
80                max: *max_items,
81            },
82            TypeDecl::Record { fields, .. } => Ty::Record(
83                fields
84                    .iter()
85                    .map(|f| (f.name.clone(), Ty::from_decl(&f.ty)))
86                    .collect(),
87            ),
88            // Version 2 types are refused in a version 1 definition before
89            // typing (spec 032, 3.1.3: `first_v2_construct` in compile.rs,
90            // tested in tests/track_versions.rs), so `Ty::Maybe` stays the
91            // type of an operator's optional result and `Ty::Nullable` that
92            // of a declared field; plan 3 typing handles them itself.
93            TypeDecl::Nullable { ty } => Ty::Nullable(Box::new(Ty::from_decl(ty))),
94            TypeDecl::Map {
95                key_max_bytes,
96                value,
97                max_entries,
98            } => Ty::Map {
99                key_max: *key_max_bytes,
100                value: Box::new(Ty::from_decl(value)),
101                max: *max_entries,
102            },
103        }
104    }
105
106    /// Same kind of value, ignoring size bounds and enum option sets beyond
107    /// both being enums. Used for comparison operands.
108    pub fn same_shape(&self, other: &Ty) -> bool {
109        match (self, other) {
110            (Ty::Text { .. }, Ty::Text { .. }) => true,
111            (Ty::Enum(_), Ty::Enum(_)) => true,
112            (Ty::Maybe(a), Ty::Maybe(b)) => a.same_shape(b),
113            (Ty::List { item: a, .. }, Ty::List { item: b, .. }) => a.same_shape(b),
114            (Ty::Record(a), Ty::Record(b)) => {
115                a.len() == b.len()
116                    && a.iter()
117                        .zip(b)
118                        .all(|((n1, t1), (n2, t2))| n1 == n2 && t1.same_shape(t2))
119            }
120            (Ty::Filtered { .. }, Ty::Filtered { .. }) => true,
121            (Ty::Shortlist { .. }, Ty::Shortlist { .. }) => true,
122            (Ty::Ranking { .. }, Ty::Ranking { .. }) => true,
123            (Ty::Nullable(a), Ty::Nullable(b)) => a.same_shape(b),
124            (Ty::Map { value: a, .. }, Ty::Map { value: b, .. }) => a.same_shape(b),
125            (Ty::Joined { value: a, .. }, Ty::Joined { value: b, .. }) => a.same_shape(b),
126            (a, b) => a == b,
127        }
128    }
129
130    /// A short name for refusal messages.
131    pub fn name(&self) -> String {
132        match self {
133            Ty::Bool => "bool".into(),
134            Ty::Integer => "integer".into(),
135            Ty::Decimal => "decimal".into(),
136            Ty::Text { .. } => "text".into(),
137            Ty::Enum(_) => "enum".into(),
138            Ty::Timestamp => "timestamp".into(),
139            Ty::Duration => "duration".into(),
140            Ty::Maybe(t) => format!("maybe<{}>", t.name()),
141            Ty::List { item, .. } => format!("list<{}>", item.name()),
142            Ty::Record(_) => "record".into(),
143            Ty::Filtered { .. } => "filtered".into(),
144            Ty::Shortlist { .. } => "shortlist".into(),
145            Ty::Ranking { .. } => "ranking".into(),
146            Ty::Nullable(t) => format!("nullable<{}>", t.name()),
147            Ty::Map { value, .. } => format!("map<{}>", value.name()),
148            Ty::Joined { value, .. } => format!("joined<{}>", value.name()),
149        }
150    }
151
152    /// The record a per-item operator sees for one joined entry.
153    pub fn join_item(value: &Ty, id_bytes: u64) -> Ty {
154        Ty::Record(vec![
155            (
156                "id".into(),
157                Ty::Text {
158                    max_bytes: id_bytes,
159                },
160            ),
161            ("match".into(), value.clone()),
162        ])
163    }
164
165    /// An upper bound on the canonical JSON bytes of a value of this type:
166    /// strings count six bytes per content byte (worst-case escaping).
167    pub fn max_json_bytes(&self) -> u64 {
168        let text = |n: u64| 2u64.saturating_add(n.saturating_mul(6));
169        match self {
170            Ty::Bool => 5,
171            Ty::Integer => 20,
172            Ty::Decimal => 44,
173            Ty::Text { max_bytes } => text(*max_bytes),
174            Ty::Enum(opts) => text(opts.iter().map(|o| o.len() as u64).max().unwrap_or(0)),
175            Ty::Timestamp => 15,
176            Ty::Duration => 20,
177            Ty::Maybe(t) => t.max_json_bytes().max(4),
178            Ty::List { item, max } => {
179                2u64.saturating_add(max.saturating_mul(item.max_json_bytes().saturating_add(1)))
180            }
181            Ty::Record(fields) => fields.iter().fold(2u64, |acc, (n, t)| {
182                acc.saturating_add(text(n.len() as u64))
183                    .saturating_add(2)
184                    .saturating_add(t.max_json_bytes())
185            }),
186            // Summaries are not projected in increment 1; bound them loosely.
187            Ty::Filtered { max, id_bytes } | Ty::Shortlist { max, id_bytes } => {
188                64u64.saturating_add(max.saturating_mul(text(*id_bytes).saturating_add(64)))
189            }
190            Ty::Ranking { max } => 64u64.saturating_add(max.saturating_mul(1024)),
191            Ty::Nullable(t) => t.max_json_bytes().max(4),
192            Ty::Map {
193                key_max,
194                value,
195                max,
196            } => 2u64.saturating_add(
197                max.saturating_mul(
198                    text(*key_max)
199                        .saturating_add(2)
200                        .saturating_add(value.max_json_bytes()),
201                ),
202            ),
203            Ty::Joined {
204                value,
205                max,
206                id_bytes,
207            } => 2u64.saturating_add(
208                max.saturating_mul(
209                    Ty::join_item(value, *id_bytes)
210                        .max_json_bytes()
211                        .saturating_add(1),
212                ),
213            ),
214        }
215    }
216
217    pub fn is_orderable(&self) -> bool {
218        matches!(
219            self,
220            Ty::Integer | Ty::Decimal | Ty::Timestamp | Ty::Duration
221        )
222    }
223}
224
225/// An exact value.
226#[derive(Debug, Clone, PartialEq)]
227pub enum Value {
228    Bool(bool),
229    Integer(i64),
230    Decimal(Decimal),
231    Text(String),
232    Enum(String),
233    Timestamp(Timestamp),
234    Duration(DurationMs),
235    Maybe(Option<Box<Value>>),
236    List(Vec<Value>),
237    Record(BTreeMap<String, Value>),
238    Filtered(Filtered),
239    Shortlist(Shortlist),
240    Ranking(Ranking),
241    /// A `null` nullable field (spec 032, 3.4.1).
242    Null,
243    /// A value that failed its presence predicate, or a merged field that
244    /// could not be filled (spec 032, 3.4.3 and 3.6.2). Reading it is
245    /// `missing_evidence`; it is never a present value with empty fields.
246    Absent(Absence),
247    Map(BTreeMap<String, Value>),
248    Joined(Vec<JoinEntry>),
249}
250
251/// Why a value is absent.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct Absence {
254    pub reason: AbsentReason,
255    /// The canonical predicate, for `predicate_failed`.
256    pub predicate: Option<PresentWhen>,
257}
258
259/// One `join_keyed@1` result entry; its id is the left item's id.
260#[derive(Debug, Clone, PartialEq)]
261pub struct JoinEntry {
262    pub id: String,
263    pub matched: JoinMatch,
264}
265
266#[derive(Debug, Clone, PartialEq)]
267pub enum JoinMatch {
268    Matched(Box<Value>),
269    /// No entry; `key_path` names a left key that was `null` or absent.
270    Unmatched {
271        key_path: Option<String>,
272    },
273    MatchedAbsent(Absence),
274}
275
276impl JoinEntry {
277    /// The record `{id, match}` a per-item operator reads, for a matched
278    /// entry.
279    pub fn item(&self) -> Option<Value> {
280        match &self.matched {
281            JoinMatch::Matched(v) => Some(Value::Record(BTreeMap::from([
282                ("id".to_string(), Value::Text(self.id.clone())),
283                ("match".to_string(), (**v).clone()),
284            ]))),
285            _ => None,
286        }
287    }
288}
289
290impl Value {
291    pub fn from_literal(l: &Literal) -> Value {
292        match l {
293            Literal::Bool(b) => Value::Bool(*b),
294            Literal::Integer(i) => Value::Integer(*i),
295            Literal::Decimal(d) => Value::Decimal(*d),
296            Literal::Text(s) => Value::Text(s.clone()),
297            Literal::Timestamp(t) => Value::Timestamp(*t),
298            Literal::DurationMs(d) => Value::Duration(DurationMs(*d)),
299            Literal::Enum(s) => Value::Enum(s.clone()),
300        }
301    }
302
303    pub fn literal_ty(l: &Literal) -> Ty {
304        match l {
305            Literal::Bool(_) => Ty::Bool,
306            Literal::Integer(_) => Ty::Integer,
307            Literal::Decimal(_) => Ty::Decimal,
308            Literal::Text(s) => Ty::Text {
309                max_bytes: s.len() as u64,
310            },
311            Literal::Timestamp(_) => Ty::Timestamp,
312            Literal::DurationMs(_) => Ty::Duration,
313            Literal::Enum(s) => Ty::Enum(vec![s.clone()]),
314        }
315    }
316
317    /// The wire form placed in a proposal's parameters.
318    pub fn to_out(&self) -> OutValue {
319        match self {
320            Value::Bool(b) => OutValue::Bool(*b),
321            Value::Integer(i) => OutValue::Integer(*i),
322            Value::Decimal(d) => OutValue::Decimal(*d),
323            Value::Text(s) => OutValue::Text(s.clone()),
324            Value::Enum(s) => OutValue::Enum(s.clone()),
325            Value::Timestamp(t) => OutValue::Timestamp(*t),
326            Value::Duration(d) => OutValue::Duration(*d),
327            Value::Maybe(None) => OutValue::None,
328            Value::Maybe(Some(v)) => v.to_out(),
329            Value::List(items) => OutValue::List(items.iter().map(Value::to_out).collect()),
330            Value::Record(m) => {
331                OutValue::Record(m.iter().map(|(k, v)| (k.clone(), v.to_out())).collect())
332            }
333            Value::Filtered(f) => OutValue::Filtered(f.clone()),
334            Value::Shortlist(s) => OutValue::Shortlist(s.clone()),
335            Value::Ranking(r) => OutValue::Ranking(r.clone()),
336            // A proposal never reads an absent or null value: the read is
337            // `missing_evidence` first (spec 032, 3.4.4).
338            Value::Null | Value::Absent(_) => OutValue::None,
339            Value::Map(m) => {
340                OutValue::Record(m.iter().map(|(k, v)| (k.clone(), v.to_out())).collect())
341            }
342            Value::Joined(entries) => OutValue::List(
343                entries
344                    .iter()
345                    .filter_map(|e| e.item().map(|v| v.to_out()))
346                    .collect(),
347            ),
348        }
349    }
350
351    /// The JSON used in projections and conflict comparison. Decimals are
352    /// strings and there are no fractional numbers, so it has canonical bytes.
353    pub fn to_json(&self) -> Json {
354        match self {
355            Value::Bool(b) => Json::Bool(*b),
356            Value::Integer(i) => Json::from(*i),
357            Value::Decimal(d) => Json::String(d.to_canonical_string()),
358            Value::Text(s) | Value::Enum(s) => Json::String(s.clone()),
359            Value::Timestamp(t) => Json::from(t.as_ms()),
360            Value::Duration(d) => Json::from(d.0),
361            Value::Maybe(None) => Json::Null,
362            Value::Maybe(Some(v)) => v.to_json(),
363            Value::List(items) => Json::Array(items.iter().map(Value::to_json).collect()),
364            Value::Record(m) => {
365                Json::Object(m.iter().map(|(k, v)| (k.clone(), v.to_json())).collect())
366            }
367            Value::Filtered(f) => serde_json::to_value(f).unwrap_or(Json::Null),
368            Value::Shortlist(s) => serde_json::to_value(s).unwrap_or(Json::Null),
369            Value::Ranking(_) => Json::Null,
370            // Projections carry values only, never metadata or presence
371            // (spec 032, 3.11.1).
372            Value::Null | Value::Absent(_) => Json::Null,
373            Value::Map(m) => {
374                Json::Object(m.iter().map(|(k, v)| (k.clone(), v.to_json())).collect())
375            }
376            Value::Joined(entries) => Json::Array(
377                entries
378                    .iter()
379                    .map(|e| match &e.matched {
380                        JoinMatch::Matched(v) => {
381                            serde_json::json!({"id": e.id, "match": v.to_json()})
382                        }
383                        // A projection carries values, never presence
384                        // (spec 032, 3.11.1): `unmatched` and
385                        // `matched_absent` both project no match. Evidence 2
386                        // records each entry's match status (3.10.1).
387                        _ => serde_json::json!({"id": e.id, "match": null}),
388                    })
389                    .collect(),
390            ),
391        }
392    }
393
394    /// Maximum count and byte length of the ids an id-bearing type carries.
395    pub fn id_bounds(ty: &Ty) -> Option<(u64, u64)> {
396        match ty {
397            Ty::Filtered { max, id_bytes } | Ty::Shortlist { max, id_bytes } => {
398                Some((*max, *id_bytes))
399            }
400            Ty::List { item, max } => match item.as_ref() {
401                Ty::Text { max_bytes } => Some((*max, *max_bytes)),
402                Ty::Enum(o) => Some((*max, o.iter().map(|s| s.len() as u64).max().unwrap_or(0))),
403                _ => None,
404            },
405            _ => None,
406        }
407    }
408
409    /// The ids of an id-bearing value: a text list, a filter's eligible ids,
410    /// a shortlist's ids.
411    pub fn ids(&self) -> Option<Vec<String>> {
412        match self {
413            Value::Filtered(f) => Some(f.eligible.clone()),
414            Value::Shortlist(s) => Some(s.ids.clone()),
415            Value::List(items) => items
416                .iter()
417                .map(|v| match v {
418                    Value::Text(s) | Value::Enum(s) => Some(s.clone()),
419                    _ => None,
420                })
421                .collect(),
422            _ => None,
423        }
424    }
425}
426
427/// Decode a plan 3 snapshot value strictly against its declared version 2
428/// type (spec 032, 3.4): a `nullable` field accepts `null` or a missing key,
429/// stored as `null`; a map is a JSON object within its bounds; a record that
430/// fails its presence predicate is absent. Key existence is never presence.
431pub fn decode_v2(decl: &TypeDecl, json: &Json) -> Result<Value, String> {
432    match (decl, json) {
433        (TypeDecl::Nullable { .. }, Json::Null) => Ok(Value::Null),
434        (TypeDecl::Nullable { ty }, v) => decode_v2(ty, v),
435        (TypeDecl::List { item, max_items }, Json::Array(a)) => {
436            if a.len() as u64 > *max_items {
437                return Err(format!("list of {} items exceeds {max_items}", a.len()));
438            }
439            a.iter()
440                .enumerate()
441                .map(|(i, v)| decode_v2(item, v).map_err(|e| format!("[{i}]: {e}")))
442                .collect::<Result<Vec<_>, _>>()
443                .map(Value::List)
444        }
445        (
446            TypeDecl::Map {
447                key_max_bytes,
448                value,
449                max_entries,
450            },
451            Json::Object(o),
452        ) => {
453            if o.len() as u64 > *max_entries {
454                return Err(format!("map of {} entries exceeds {max_entries}", o.len()));
455            }
456            let mut out = BTreeMap::new();
457            for (k, v) in o {
458                if k.len() as u64 > *key_max_bytes {
459                    return Err(format!("key of {} bytes exceeds {key_max_bytes}", k.len()));
460                }
461                out.insert(
462                    k.clone(),
463                    decode_v2(value, v).map_err(|e| format!("[{k:?}]: {e}"))?,
464                );
465            }
466            Ok(Value::Map(out))
467        }
468        (
469            TypeDecl::Record {
470                fields,
471                present_when,
472            },
473            Json::Object(o),
474        ) => {
475            if let Some(extra) = o.keys().find(|k| !fields.iter().any(|f| &f.name == *k)) {
476                return Err(format!("undeclared field {extra:?}"));
477            }
478            let mut out = BTreeMap::new();
479            for f in fields {
480                let v = match (o.get(&f.name), &f.ty) {
481                    (None, TypeDecl::Nullable { .. }) => Value::Null,
482                    (None, _) => return Err(format!("missing field {:?}", f.name)),
483                    (Some(v), t) => decode_v2(t, v).map_err(|e| format!(".{}: {e}", f.name))?,
484                };
485                out.insert(f.name.clone(), v);
486            }
487            if let Some(p) = present_when {
488                // The compiler admits only declared nullable fields (3.4.2);
489                // a predicate naming any other field is never evaluated.
490                let mut named = Vec::new();
491                p.fields(&mut named);
492                if let Some(n) = named.iter().find(|n| !out.contains_key(*n)) {
493                    return Err(format!("present_when names undeclared field {n:?}"));
494                }
495                // An absent nested record is never present (3.4.3), so it
496                // counts as null here.
497                let is_null = |n: &str| matches!(out.get(n), Some(Value::Null | Value::Absent(_)));
498                if !p.holds(&is_null) {
499                    return Ok(Value::Absent(Absence {
500                        reason: AbsentReason::PredicateFailed,
501                        predicate: Some(p.canonical()),
502                    }));
503                }
504            }
505            Ok(Value::Record(out))
506        }
507        (d, j) => decode(&Ty::from_decl(d), j),
508    }
509}
510
511/// Decode a snapshot JSON value strictly against its declared type: no
512/// coercion, no default, every record field required, no extra field.
513pub fn decode(ty: &Ty, json: &Json) -> Result<Value, String> {
514    match (ty, json) {
515        // A nullable field's value, as `decode_v2` reads it (3.4.1).
516        (Ty::Nullable(_), Json::Null) => Ok(Value::Null),
517        (Ty::Nullable(t), v) => decode(t, v),
518        (Ty::Bool, Json::Bool(b)) => Ok(Value::Bool(*b)),
519        (Ty::Integer, Json::Number(n)) => n
520            .as_i64()
521            .map(Value::Integer)
522            .ok_or_else(|| "not an i64 integer".into()),
523        (Ty::Decimal, Json::String(s)) => Decimal::parse(s)
524            .map(Value::Decimal)
525            .map_err(|e| e.to_string()),
526        (Ty::Text { max_bytes }, Json::String(s)) => {
527            if s.len() as u64 > *max_bytes {
528                Err(format!("text of {} bytes exceeds {max_bytes}", s.len()))
529            } else {
530                Ok(Value::Text(s.clone()))
531            }
532        }
533        (Ty::Enum(opts), Json::String(s)) => {
534            if opts.contains(s) {
535                Ok(Value::Enum(s.clone()))
536            } else {
537                Err(format!("{s:?} is not a declared option"))
538            }
539        }
540        (Ty::Timestamp, Json::Number(n)) => n
541            .as_i64()
542            .and_then(|ms| Timestamp::from_ms(ms).ok())
543            .map(Value::Timestamp)
544            .ok_or_else(|| "timestamp out of range".into()),
545        (Ty::List { item, max }, Json::Array(a)) => {
546            if a.len() as u64 > *max {
547                return Err(format!("list of {} items exceeds {max}", a.len()));
548            }
549            a.iter()
550                .enumerate()
551                .map(|(i, v)| decode(item, v).map_err(|e| format!("[{i}]: {e}")))
552                .collect::<Result<Vec<_>, _>>()
553                .map(Value::List)
554        }
555        (
556            Ty::Map {
557                key_max,
558                value,
559                max,
560            },
561            Json::Object(o),
562        ) => {
563            if o.len() as u64 > *max {
564                return Err(format!("map of {} entries exceeds {max}", o.len()));
565            }
566            let mut out = BTreeMap::new();
567            for (k, v) in o {
568                if k.len() as u64 > *key_max {
569                    return Err(format!("key of {} bytes exceeds {key_max}", k.len()));
570                }
571                out.insert(
572                    k.clone(),
573                    decode(value, v).map_err(|e| format!("[{k:?}]: {e}"))?,
574                );
575            }
576            Ok(Value::Map(out))
577        }
578        (Ty::Record(fields), Json::Object(o)) => {
579            if let Some(extra) = o.keys().find(|k| !fields.iter().any(|(n, _)| n == *k)) {
580                return Err(format!("undeclared field {extra:?}"));
581            }
582            let mut out = BTreeMap::new();
583            for (name, fty) in fields {
584                let v = o
585                    .get(name)
586                    .ok_or_else(|| format!("missing field {name:?}"))?;
587                out.insert(
588                    name.clone(),
589                    decode(fty, v).map_err(|e| format!(".{name}: {e}"))?,
590                );
591            }
592            Ok(Value::Record(out))
593        }
594        (t, _) => Err(format!("expected {}", t.name())),
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use serde_json::json;
602
603    #[test]
604    fn version_2_decoding_separates_null_absent_and_present() {
605        use rustev_contract::definition::FieldDecl;
606        use rustev_contract::grade::PresentWhen;
607        let nullable = |ty| TypeDecl::Nullable { ty: Box::new(ty) };
608        let rec = TypeDecl::Record {
609            fields: vec![
610                FieldDecl {
611                    name: "total".into(),
612                    ty: nullable(TypeDecl::Integer),
613                },
614                FieldDecl {
615                    name: "unit".into(),
616                    ty: nullable(TypeDecl::Integer),
617                },
618            ],
619            present_when: Some(PresentWhen::AnyNonNull(vec!["total".into(), "unit".into()])),
620        };
621        let present = decode_v2(&rec, &json!({"total": 3})).unwrap();
622        assert_eq!(
623            present,
624            Value::Record(BTreeMap::from([
625                ("total".into(), Value::Integer(3)),
626                ("unit".into(), Value::Null),
627            ])),
628            "a missing nullable key is null"
629        );
630        // Key existence is never presence: both keys exist, both are null.
631        assert!(matches!(
632            decode_v2(&rec, &json!({"total": null, "unit": null})),
633            Ok(Value::Absent(Absence {
634                reason: AbsentReason::PredicateFailed,
635                predicate: Some(_),
636            }))
637        ));
638        assert!(
639            decode_v2(&rec, &json!({"total": 1, "x": 1})).is_err(),
640            "extra field"
641        );
642        // A predicate naming an undeclared field is an error, never null.
643        let mut stray = rec.clone();
644        if let TypeDecl::Record { present_when, .. } = &mut stray {
645            *present_when = Some(PresentWhen::AnyNonNull(vec!["total".into(), "x".into()]));
646        }
647        assert!(decode_v2(&stray, &json!({"total": 1})).is_err());
648        // `decode` reads a nullable type as `decode_v2` does.
649        let n = Ty::Nullable(Box::new(Ty::Integer));
650        assert_eq!(decode(&n, &json!(null)), Ok(Value::Null));
651        assert_eq!(decode(&n, &json!(2)), Ok(Value::Integer(2)));
652        // A parent whose predicate names an absent nested record is absent.
653        let parent = TypeDecl::Record {
654            fields: vec![FieldDecl {
655                name: "quote".into(),
656                ty: nullable(rec.clone()),
657            }],
658            present_when: Some(PresentWhen::AnyNonNull(vec!["quote".into()])),
659        };
660        assert!(matches!(
661            decode_v2(&parent, &json!({"quote": {"total": null, "unit": null}})),
662            Ok(Value::Absent(_))
663        ));
664        assert!(matches!(
665            decode_v2(&parent, &json!({"quote": {"total": 1}})),
666            Ok(Value::Record(_))
667        ));
668        let strict = TypeDecl::Record {
669            fields: vec![FieldDecl {
670                name: "n".into(),
671                ty: TypeDecl::Integer,
672            }],
673            present_when: None,
674        };
675        assert!(
676            decode_v2(&strict, &json!({})).is_err(),
677            "missing non-nullable field"
678        );
679        let map = TypeDecl::Map {
680            key_max_bytes: 2,
681            value: Box::new(nullable(TypeDecl::Integer)),
682            max_entries: 1,
683        };
684        assert_eq!(
685            decode_v2(&map, &json!({"a": null})),
686            Ok(Value::Map(BTreeMap::from([("a".into(), Value::Null)])))
687        );
688        assert!(
689            decode_v2(&map, &json!({"a": 1, "b": 2})).is_err(),
690            "entries"
691        );
692        assert!(decode_v2(&map, &json!({"abc": 1})).is_err(), "key bytes");
693    }
694
695    #[test]
696    fn a_map_decodes_by_key_within_its_bounds() {
697        let map = Ty::from_decl(&TypeDecl::Map {
698            key_max_bytes: 4,
699            value: Box::new(TypeDecl::Integer),
700            max_entries: 2,
701        });
702        assert_eq!(
703            decode(&map, &json!({"a": 1, "b": 2})),
704            Ok(Value::Map(BTreeMap::from([
705                ("a".into(), Value::Integer(1)),
706                ("b".into(), Value::Integer(2)),
707            ])))
708        );
709        assert!(
710            decode(&map, &json!({"a": 1, "b": 2, "c": 3})).is_err(),
711            "entries"
712        );
713        assert!(decode(&map, &json!({"long_key": 1})).is_err(), "key bytes");
714        assert!(decode(&map, &json!({"a": "1"})).is_err(), "value type");
715        assert!(decode(&map, &json!([1])).is_err(), "not an object");
716    }
717
718    #[test]
719    fn decoding_is_strict() {
720        let rec = Ty::Record(vec![("a".into(), Ty::Integer), ("b".into(), Ty::Decimal)]);
721        assert!(decode(&rec, &json!({"a": 1, "b": "2.5"})).is_ok());
722        assert!(decode(&rec, &json!({"a": 1})).is_err(), "missing field");
723        assert!(
724            decode(&rec, &json!({"a": 1, "b": "2", "c": 0})).is_err(),
725            "extra field"
726        );
727        assert!(
728            decode(&rec, &json!({"a": "1", "b": "2"})).is_err(),
729            "no coercion"
730        );
731        assert!(
732            decode(&Ty::Decimal, &json!(2)).is_err(),
733            "decimal must be a string"
734        );
735        assert!(
736            decode(&Ty::Decimal, &json!("0.0000000001")).is_err(),
737            "excess precision"
738        );
739        assert!(decode(&Ty::Text { max_bytes: 3 }, &json!("abcd")).is_err());
740        assert!(decode(&Ty::Text { max_bytes: 4 }, &json!("abcd")).is_ok());
741        assert!(decode(&Ty::Enum(vec!["x".into()]), &json!("y")).is_err());
742        let list = Ty::List {
743            item: Box::new(Ty::Bool),
744            max: 2,
745        };
746        assert!(decode(&list, &json!([true, false])).is_ok());
747        assert!(decode(&list, &json!([true, false, true])).is_err());
748        assert!(decode(&Ty::Timestamp, &json!(-1)).is_err());
749        assert!(decode(&Ty::Integer, &json!(9223372036854775808u64)).is_err());
750    }
751
752    #[test]
753    fn size_bounds_cover_worst_case_escaping() {
754        let t = Ty::Text { max_bytes: 3 };
755        let v = Value::Text("\u{1}\u{1}\u{1}".into());
756        let bytes = rustev_contract::canonical::canonical_value_bytes(&v.to_json()).unwrap();
757        assert!(bytes.len() as u64 <= t.max_json_bytes());
758        let d = Value::Decimal(Decimal::from_units(i128::MIN));
759        let bytes = rustev_contract::canonical::canonical_value_bytes(&d.to_json()).unwrap();
760        assert!(bytes.len() as u64 <= Ty::Decimal.max_json_bytes());
761    }
762}