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    Selection(rustev_contract::ranking_result::Selection),
242    /// A `null` nullable field (spec 032, 3.4.1).
243    Null,
244    /// A value that failed its presence predicate, or a merged field that
245    /// could not be filled (spec 032, 3.4.3 and 3.6.2). Reading it is
246    /// `missing_evidence`; it is never a present value with empty fields.
247    Absent(Absence),
248    Map(BTreeMap<String, Value>),
249    Joined(Vec<JoinEntry>),
250}
251
252/// Why a value is absent.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct Absence {
255    pub reason: AbsentReason,
256    /// The canonical predicate, for `predicate_failed`.
257    pub predicate: Option<PresentWhen>,
258}
259
260/// One `join_keyed@1` result entry; its id is the left item's id.
261#[derive(Debug, Clone, PartialEq)]
262pub struct JoinEntry {
263    pub id: String,
264    pub matched: JoinMatch,
265}
266
267#[derive(Debug, Clone, PartialEq)]
268pub enum JoinMatch {
269    Matched(Box<Value>),
270    /// No entry; `key_path` names a left key that was `null` or absent.
271    Unmatched {
272        key_path: Option<String>,
273    },
274    MatchedAbsent(Absence),
275}
276
277impl JoinEntry {
278    /// The record `{id, match}` a per-item operator reads, for a matched
279    /// entry.
280    pub fn item(&self) -> Option<Value> {
281        match &self.matched {
282            JoinMatch::Matched(v) => Some(Value::Record(BTreeMap::from([
283                ("id".to_string(), Value::Text(self.id.clone())),
284                ("match".to_string(), (**v).clone()),
285            ]))),
286            _ => None,
287        }
288    }
289}
290
291impl Value {
292    pub fn from_literal(l: &Literal) -> Value {
293        match l {
294            Literal::Bool(b) => Value::Bool(*b),
295            Literal::Integer(i) => Value::Integer(*i),
296            Literal::Decimal(d) => Value::Decimal(*d),
297            Literal::Text(s) => Value::Text(s.clone()),
298            Literal::Timestamp(t) => Value::Timestamp(*t),
299            Literal::DurationMs(d) => Value::Duration(DurationMs(*d)),
300            Literal::Enum(s) => Value::Enum(s.clone()),
301        }
302    }
303
304    pub fn literal_ty(l: &Literal) -> Ty {
305        match l {
306            Literal::Bool(_) => Ty::Bool,
307            Literal::Integer(_) => Ty::Integer,
308            Literal::Decimal(_) => Ty::Decimal,
309            Literal::Text(s) => Ty::Text {
310                max_bytes: s.len() as u64,
311            },
312            Literal::Timestamp(_) => Ty::Timestamp,
313            Literal::DurationMs(_) => Ty::Duration,
314            Literal::Enum(s) => Ty::Enum(vec![s.clone()]),
315        }
316    }
317
318    /// The wire form placed in a proposal's parameters.
319    pub fn to_out(&self) -> OutValue {
320        match self {
321            Value::Bool(b) => OutValue::Bool(*b),
322            Value::Integer(i) => OutValue::Integer(*i),
323            Value::Decimal(d) => OutValue::Decimal(*d),
324            Value::Text(s) => OutValue::Text(s.clone()),
325            Value::Enum(s) => OutValue::Enum(s.clone()),
326            Value::Timestamp(t) => OutValue::Timestamp(*t),
327            Value::Duration(d) => OutValue::Duration(*d),
328            Value::Maybe(None) => OutValue::None,
329            Value::Maybe(Some(v)) => v.to_out(),
330            Value::List(items) => OutValue::List(items.iter().map(Value::to_out).collect()),
331            Value::Record(m) => {
332                OutValue::Record(m.iter().map(|(k, v)| (k.clone(), v.to_out())).collect())
333            }
334            Value::Filtered(f) => OutValue::Filtered(f.clone()),
335            Value::Shortlist(s) => OutValue::Shortlist(s.clone()),
336            Value::Ranking(r) => OutValue::Ranking(r.clone()),
337            Value::Selection(s) => OutValue::Selection(s.clone()),
338            // A proposal never reads an absent or null value: the read is
339            // `missing_evidence` first (spec 032, 3.4.4).
340            Value::Null | Value::Absent(_) => OutValue::None,
341            Value::Map(m) => {
342                OutValue::Record(m.iter().map(|(k, v)| (k.clone(), v.to_out())).collect())
343            }
344            Value::Joined(entries) => OutValue::List(
345                entries
346                    .iter()
347                    .filter_map(|e| e.item().map(|v| v.to_out()))
348                    .collect(),
349            ),
350        }
351    }
352
353    /// The JSON used in projections and conflict comparison. Decimals are
354    /// strings and there are no fractional numbers, so it has canonical bytes.
355    pub fn to_json(&self) -> Json {
356        match self {
357            Value::Bool(b) => Json::Bool(*b),
358            Value::Integer(i) => Json::from(*i),
359            Value::Decimal(d) => Json::String(d.to_canonical_string()),
360            Value::Text(s) | Value::Enum(s) => Json::String(s.clone()),
361            Value::Timestamp(t) => Json::from(t.as_ms()),
362            Value::Duration(d) => Json::from(d.0),
363            Value::Maybe(None) => Json::Null,
364            Value::Maybe(Some(v)) => v.to_json(),
365            Value::List(items) => Json::Array(items.iter().map(Value::to_json).collect()),
366            Value::Record(m) => {
367                Json::Object(m.iter().map(|(k, v)| (k.clone(), v.to_json())).collect())
368            }
369            Value::Filtered(f) => serde_json::to_value(f).unwrap_or(Json::Null),
370            Value::Shortlist(s) => serde_json::to_value(s).unwrap_or(Json::Null),
371            Value::Ranking(_) => Json::Null,
372            Value::Selection(_) => Json::Null,
373            // Projections carry values only, never metadata or presence
374            // (spec 032, 3.11.1).
375            Value::Null | Value::Absent(_) => Json::Null,
376            Value::Map(m) => {
377                Json::Object(m.iter().map(|(k, v)| (k.clone(), v.to_json())).collect())
378            }
379            Value::Joined(entries) => Json::Array(
380                entries
381                    .iter()
382                    .map(|e| match &e.matched {
383                        JoinMatch::Matched(v) => {
384                            serde_json::json!({"id": e.id, "match": v.to_json()})
385                        }
386                        // A projection carries values, never presence
387                        // (spec 032, 3.11.1): `unmatched` and
388                        // `matched_absent` both project no match. Evidence 2
389                        // records each entry's match status (3.10.1).
390                        _ => serde_json::json!({"id": e.id, "match": null}),
391                    })
392                    .collect(),
393            ),
394        }
395    }
396
397    /// Maximum count and byte length of the ids an id-bearing type carries.
398    pub fn id_bounds(ty: &Ty) -> Option<(u64, u64)> {
399        match ty {
400            Ty::Filtered { max, id_bytes } | Ty::Shortlist { max, id_bytes } => {
401                Some((*max, *id_bytes))
402            }
403            Ty::List { item, max } => match item.as_ref() {
404                Ty::Text { max_bytes } => Some((*max, *max_bytes)),
405                Ty::Enum(o) => Some((*max, o.iter().map(|s| s.len() as u64).max().unwrap_or(0))),
406                _ => None,
407            },
408            _ => None,
409        }
410    }
411
412    /// The ids of an id-bearing value: a text list, a filter's eligible ids,
413    /// a shortlist's ids.
414    pub fn ids(&self) -> Option<Vec<String>> {
415        match self {
416            Value::Filtered(f) => Some(f.eligible.clone()),
417            Value::Shortlist(s) => Some(s.ids.clone()),
418            Value::List(items) => items
419                .iter()
420                .map(|v| match v {
421                    Value::Text(s) | Value::Enum(s) => Some(s.clone()),
422                    _ => None,
423                })
424                .collect(),
425            _ => None,
426        }
427    }
428}
429
430/// Decode a plan 3 snapshot value strictly against its declared version 2
431/// type (spec 032, 3.4): a `nullable` field accepts `null` or a missing key,
432/// stored as `null`; a map is a JSON object within its bounds; a record that
433/// fails its presence predicate is absent. Key existence is never presence.
434pub fn decode_v2(decl: &TypeDecl, json: &Json) -> Result<Value, String> {
435    match (decl, json) {
436        (TypeDecl::Nullable { .. }, Json::Null) => Ok(Value::Null),
437        (TypeDecl::Nullable { ty }, v) => decode_v2(ty, v),
438        (TypeDecl::List { item, max_items }, Json::Array(a)) => {
439            if a.len() as u64 > *max_items {
440                return Err(format!("list of {} items exceeds {max_items}", a.len()));
441            }
442            a.iter()
443                .enumerate()
444                .map(|(i, v)| decode_v2(item, v).map_err(|e| format!("[{i}]: {e}")))
445                .collect::<Result<Vec<_>, _>>()
446                .map(Value::List)
447        }
448        (
449            TypeDecl::Map {
450                key_max_bytes,
451                value,
452                max_entries,
453            },
454            Json::Object(o),
455        ) => {
456            if o.len() as u64 > *max_entries {
457                return Err(format!("map of {} entries exceeds {max_entries}", o.len()));
458            }
459            let mut out = BTreeMap::new();
460            for (k, v) in o {
461                if k.len() as u64 > *key_max_bytes {
462                    return Err(format!("key of {} bytes exceeds {key_max_bytes}", k.len()));
463                }
464                out.insert(
465                    k.clone(),
466                    decode_v2(value, v).map_err(|e| format!("[{k:?}]: {e}"))?,
467                );
468            }
469            Ok(Value::Map(out))
470        }
471        (
472            TypeDecl::Record {
473                fields,
474                present_when,
475            },
476            Json::Object(o),
477        ) => {
478            if let Some(extra) = o.keys().find(|k| !fields.iter().any(|f| &f.name == *k)) {
479                return Err(format!("undeclared field {extra:?}"));
480            }
481            let mut out = BTreeMap::new();
482            for f in fields {
483                let v = match (o.get(&f.name), &f.ty) {
484                    (None, TypeDecl::Nullable { .. }) => Value::Null,
485                    (None, _) => return Err(format!("missing field {:?}", f.name)),
486                    (Some(v), t) => decode_v2(t, v).map_err(|e| format!(".{}: {e}", f.name))?,
487                };
488                out.insert(f.name.clone(), v);
489            }
490            if let Some(p) = present_when {
491                // The compiler admits only declared nullable fields (3.4.2);
492                // a predicate naming any other field is never evaluated.
493                let mut named = Vec::new();
494                p.fields(&mut named);
495                if let Some(n) = named.iter().find(|n| !out.contains_key(*n)) {
496                    return Err(format!("present_when names undeclared field {n:?}"));
497                }
498                // An absent nested record is never present (3.4.3), so it
499                // counts as null here.
500                let is_null = |n: &str| matches!(out.get(n), Some(Value::Null | Value::Absent(_)));
501                if !p.holds(&is_null) {
502                    return Ok(Value::Absent(Absence {
503                        reason: AbsentReason::PredicateFailed,
504                        predicate: Some(p.canonical()),
505                    }));
506                }
507            }
508            Ok(Value::Record(out))
509        }
510        (d, j) => decode(&Ty::from_decl(d), j),
511    }
512}
513
514/// Decode a snapshot JSON value strictly against its declared type: no
515/// coercion, no default, every record field required, no extra field.
516pub fn decode(ty: &Ty, json: &Json) -> Result<Value, String> {
517    match (ty, json) {
518        // A nullable field's value, as `decode_v2` reads it (3.4.1).
519        (Ty::Nullable(_), Json::Null) => Ok(Value::Null),
520        (Ty::Nullable(t), v) => decode(t, v),
521        (Ty::Bool, Json::Bool(b)) => Ok(Value::Bool(*b)),
522        (Ty::Integer, Json::Number(n)) => n
523            .as_i64()
524            .map(Value::Integer)
525            .ok_or_else(|| "not an i64 integer".into()),
526        (Ty::Decimal, Json::String(s)) => Decimal::parse(s)
527            .map(Value::Decimal)
528            .map_err(|e| e.to_string()),
529        (Ty::Text { max_bytes }, Json::String(s)) => {
530            if s.len() as u64 > *max_bytes {
531                Err(format!("text of {} bytes exceeds {max_bytes}", s.len()))
532            } else {
533                Ok(Value::Text(s.clone()))
534            }
535        }
536        (Ty::Enum(opts), Json::String(s)) => {
537            if opts.contains(s) {
538                Ok(Value::Enum(s.clone()))
539            } else {
540                Err(format!("{s:?} is not a declared option"))
541            }
542        }
543        (Ty::Timestamp, Json::Number(n)) => n
544            .as_i64()
545            .and_then(|ms| Timestamp::from_ms(ms).ok())
546            .map(Value::Timestamp)
547            .ok_or_else(|| "timestamp out of range".into()),
548        (Ty::List { item, max }, Json::Array(a)) => {
549            if a.len() as u64 > *max {
550                return Err(format!("list of {} items exceeds {max}", a.len()));
551            }
552            a.iter()
553                .enumerate()
554                .map(|(i, v)| decode(item, v).map_err(|e| format!("[{i}]: {e}")))
555                .collect::<Result<Vec<_>, _>>()
556                .map(Value::List)
557        }
558        (
559            Ty::Map {
560                key_max,
561                value,
562                max,
563            },
564            Json::Object(o),
565        ) => {
566            if o.len() as u64 > *max {
567                return Err(format!("map of {} entries exceeds {max}", o.len()));
568            }
569            let mut out = BTreeMap::new();
570            for (k, v) in o {
571                if k.len() as u64 > *key_max {
572                    return Err(format!("key of {} bytes exceeds {key_max}", k.len()));
573                }
574                out.insert(
575                    k.clone(),
576                    decode(value, v).map_err(|e| format!("[{k:?}]: {e}"))?,
577                );
578            }
579            Ok(Value::Map(out))
580        }
581        (Ty::Record(fields), Json::Object(o)) => {
582            if let Some(extra) = o.keys().find(|k| !fields.iter().any(|(n, _)| n == *k)) {
583                return Err(format!("undeclared field {extra:?}"));
584            }
585            let mut out = BTreeMap::new();
586            for (name, fty) in fields {
587                let v = o
588                    .get(name)
589                    .ok_or_else(|| format!("missing field {name:?}"))?;
590                out.insert(
591                    name.clone(),
592                    decode(fty, v).map_err(|e| format!(".{name}: {e}"))?,
593                );
594            }
595            Ok(Value::Record(out))
596        }
597        (t, _) => Err(format!("expected {}", t.name())),
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use serde_json::json;
605
606    #[test]
607    fn version_2_decoding_separates_null_absent_and_present() {
608        use rustev_contract::definition::FieldDecl;
609        use rustev_contract::grade::PresentWhen;
610        let nullable = |ty| TypeDecl::Nullable { ty: Box::new(ty) };
611        let rec = TypeDecl::Record {
612            fields: vec![
613                FieldDecl {
614                    name: "total".into(),
615                    ty: nullable(TypeDecl::Integer),
616                },
617                FieldDecl {
618                    name: "unit".into(),
619                    ty: nullable(TypeDecl::Integer),
620                },
621            ],
622            present_when: Some(PresentWhen::AnyNonNull(vec!["total".into(), "unit".into()])),
623        };
624        let present = decode_v2(&rec, &json!({"total": 3})).unwrap();
625        assert_eq!(
626            present,
627            Value::Record(BTreeMap::from([
628                ("total".into(), Value::Integer(3)),
629                ("unit".into(), Value::Null),
630            ])),
631            "a missing nullable key is null"
632        );
633        // Key existence is never presence: both keys exist, both are null.
634        assert!(matches!(
635            decode_v2(&rec, &json!({"total": null, "unit": null})),
636            Ok(Value::Absent(Absence {
637                reason: AbsentReason::PredicateFailed,
638                predicate: Some(_),
639            }))
640        ));
641        assert!(
642            decode_v2(&rec, &json!({"total": 1, "x": 1})).is_err(),
643            "extra field"
644        );
645        // A predicate naming an undeclared field is an error, never null.
646        let mut stray = rec.clone();
647        if let TypeDecl::Record { present_when, .. } = &mut stray {
648            *present_when = Some(PresentWhen::AnyNonNull(vec!["total".into(), "x".into()]));
649        }
650        assert!(decode_v2(&stray, &json!({"total": 1})).is_err());
651        // `decode` reads a nullable type as `decode_v2` does.
652        let n = Ty::Nullable(Box::new(Ty::Integer));
653        assert_eq!(decode(&n, &json!(null)), Ok(Value::Null));
654        assert_eq!(decode(&n, &json!(2)), Ok(Value::Integer(2)));
655        // A parent whose predicate names an absent nested record is absent.
656        let parent = TypeDecl::Record {
657            fields: vec![FieldDecl {
658                name: "quote".into(),
659                ty: nullable(rec.clone()),
660            }],
661            present_when: Some(PresentWhen::AnyNonNull(vec!["quote".into()])),
662        };
663        assert!(matches!(
664            decode_v2(&parent, &json!({"quote": {"total": null, "unit": null}})),
665            Ok(Value::Absent(_))
666        ));
667        assert!(matches!(
668            decode_v2(&parent, &json!({"quote": {"total": 1}})),
669            Ok(Value::Record(_))
670        ));
671        let strict = TypeDecl::Record {
672            fields: vec![FieldDecl {
673                name: "n".into(),
674                ty: TypeDecl::Integer,
675            }],
676            present_when: None,
677        };
678        assert!(
679            decode_v2(&strict, &json!({})).is_err(),
680            "missing non-nullable field"
681        );
682        let map = TypeDecl::Map {
683            key_max_bytes: 2,
684            value: Box::new(nullable(TypeDecl::Integer)),
685            max_entries: 1,
686        };
687        assert_eq!(
688            decode_v2(&map, &json!({"a": null})),
689            Ok(Value::Map(BTreeMap::from([("a".into(), Value::Null)])))
690        );
691        assert!(
692            decode_v2(&map, &json!({"a": 1, "b": 2})).is_err(),
693            "entries"
694        );
695        assert!(decode_v2(&map, &json!({"abc": 1})).is_err(), "key bytes");
696    }
697
698    #[test]
699    fn a_map_decodes_by_key_within_its_bounds() {
700        let map = Ty::from_decl(&TypeDecl::Map {
701            key_max_bytes: 4,
702            value: Box::new(TypeDecl::Integer),
703            max_entries: 2,
704        });
705        assert_eq!(
706            decode(&map, &json!({"a": 1, "b": 2})),
707            Ok(Value::Map(BTreeMap::from([
708                ("a".into(), Value::Integer(1)),
709                ("b".into(), Value::Integer(2)),
710            ])))
711        );
712        assert!(
713            decode(&map, &json!({"a": 1, "b": 2, "c": 3})).is_err(),
714            "entries"
715        );
716        assert!(decode(&map, &json!({"long_key": 1})).is_err(), "key bytes");
717        assert!(decode(&map, &json!({"a": "1"})).is_err(), "value type");
718        assert!(decode(&map, &json!([1])).is_err(), "not an object");
719    }
720
721    #[test]
722    fn decoding_is_strict() {
723        let rec = Ty::Record(vec![("a".into(), Ty::Integer), ("b".into(), Ty::Decimal)]);
724        assert!(decode(&rec, &json!({"a": 1, "b": "2.5"})).is_ok());
725        assert!(decode(&rec, &json!({"a": 1})).is_err(), "missing field");
726        assert!(
727            decode(&rec, &json!({"a": 1, "b": "2", "c": 0})).is_err(),
728            "extra field"
729        );
730        assert!(
731            decode(&rec, &json!({"a": "1", "b": "2"})).is_err(),
732            "no coercion"
733        );
734        assert!(
735            decode(&Ty::Decimal, &json!(2)).is_err(),
736            "decimal must be a string"
737        );
738        assert!(
739            decode(&Ty::Decimal, &json!("0.0000000001")).is_err(),
740            "excess precision"
741        );
742        assert!(decode(&Ty::Text { max_bytes: 3 }, &json!("abcd")).is_err());
743        assert!(decode(&Ty::Text { max_bytes: 4 }, &json!("abcd")).is_ok());
744        assert!(decode(&Ty::Enum(vec!["x".into()]), &json!("y")).is_err());
745        let list = Ty::List {
746            item: Box::new(Ty::Bool),
747            max: 2,
748        };
749        assert!(decode(&list, &json!([true, false])).is_ok());
750        assert!(decode(&list, &json!([true, false, true])).is_err());
751        assert!(decode(&Ty::Timestamp, &json!(-1)).is_err());
752        assert!(decode(&Ty::Integer, &json!(9223372036854775808u64)).is_err());
753    }
754
755    #[test]
756    fn size_bounds_cover_worst_case_escaping() {
757        let t = Ty::Text { max_bytes: 3 };
758        let v = Value::Text("\u{1}\u{1}\u{1}".into());
759        let bytes = rustev_contract::canonical::canonical_value_bytes(&v.to_json()).unwrap();
760        assert!(bytes.len() as u64 <= t.max_json_bytes());
761        let d = Value::Decimal(Decimal::from_units(i128::MIN));
762        let bytes = rustev_contract::canonical::canonical_value_bytes(&d.to_json()).unwrap();
763        assert!(bytes.len() as u64 <= Ty::Decimal.max_json_bytes());
764    }
765}