Skip to main content

omnist/
schema.rs

1//! The Schema model -- Record/Scalar/Ref, per `docs/design/model.md` (issue
2//! #6). Ported from `~/dev/omnist/omnist/schema.py`.
3//!
4//! * **Record** -- a closed set of fields, each `(label, type, cardinality)`.
5//!   Cardinality is the *unordered* number of times a label may appear.
6//! * **Scalar** -- exactly one of seven predefined value types (`string`,
7//!   `integer`, `number`, `boolean`, `date`, `time`, `datetime`), optionally
8//!   nullable. There is no user-declared value-domain composition (no
9//!   union/enum/literal) -- see `docs/design/model.md` §2/§5 for why: a
10//!   composable value-domain would make schema-directed deserialization
11//!   ambiguous (a value could satisfy more than one candidate with no
12//!   principled way to choose).
13//! * **Ref** -- a pointer into the schema's named environment (records
14//!   only); enables reuse and recursion.
15//! * **Any** (`FieldType::Any`) -- accepts every legal document value
16//!   unchecked. Ported from Python's `AnyType`/`ANY` singleton, which has
17//!   been fully implemented and shipped there since v0.5.0 -- not a
18//!   speculative or deferred feature (the *separate*, still-unresolved
19//!   question is whether `any` should be a *permanent* part of the spec
20//!   long-term; that governance question is untouched by this port simply
21//!   catching up to Python's existing behavior, see omnist-rs issue #29).
22//!
23//! A field's type is a `Ref`, a `Scalar`, or `Any`. There are no inline
24//! records and no separate array type -- "array" is a field with
25//! cardinality `max > 1`.
26//! Validation ignores order (per `docs/design/model.md` §7).
27//!
28//! ## Temporal shape-check
29//!
30//! `is_iso_date`, `is_iso_time`, and `is_iso_datetime` are the single
31//! source of truth for "is this string shaped like (and a semantically
32//! valid) date/time/datetime," `pub(crate)` so a future `materialize`/
33//! `infer` module can reuse the exact same check instead of writing a
34//! second, independently-maintained copy (per the porting playbook's
35//! pitfall list). Each check is stricter than a bare shape regex: the regex
36//! only rules out the wrong *spelling* (Python's `datetime.fromisoformat`
37//! is deliberately wider -- it also accepts ISO-8601 basic format
38//! (`20240101`), week dates, and other spellings this crate's docs never
39//! promise) -- the calendar/clock fields are additionally range-checked
40//! (e.g. a syntactically-shaped `"2024-02-30"` is still rejected).
41
42use indexmap::IndexMap;
43use regex::Regex;
44use std::sync::LazyLock;
45
46use crate::document::{self, Scalar as DocScalar};
47use crate::error::SchemaError;
48
49// ---------------------------------------------------------------------------
50// Temporal shape-check (shared by `validate` today; reusable by a future
51// materialize/infer module without duplication).
52// ---------------------------------------------------------------------------
53
54/// Hyphenated ISO date shape: `YYYY-MM-DD`.
55pub(crate) static DATE_RE: LazyLock<Regex> =
56    LazyLock::new(|| Regex::new(r"^(?P<y>\d{4})-(?P<mo>\d{2})-(?P<da>\d{2})$").unwrap());
57
58/// Colon-separated ISO time shape: `HH:MM[:SS[.ffffff]][+-HH:MM]`.
59pub(crate) static TIME_RE: LazyLock<Regex> = LazyLock::new(|| {
60    Regex::new(
61        r"^(?P<h>\d{2}):(?P<m>\d{2})(:(?P<s>\d{2})(\.(?P<f>\d{1,6}))?)?(?P<off>[+\-]\d{2}:\d{2})?$",
62    )
63    .unwrap()
64});
65
66/// `T`-joined ISO datetime shape: date, literal `T`, then the time shape.
67pub(crate) static DATETIME_RE: LazyLock<Regex> = LazyLock::new(|| {
68    Regex::new(
69        r"^(?P<y>\d{4})-(?P<mo>\d{2})-(?P<da>\d{2})T(?P<h>\d{2}):(?P<m>\d{2})(:(?P<s>\d{2})(\.(?P<f>\d{1,6}))?)?(?P<off>[+\-]\d{2}:\d{2})?$",
70    )
71    .unwrap()
72});
73
74pub(crate) fn is_leap_year(y: u32) -> bool {
75    (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
76}
77
78pub(crate) fn days_in_month(y: u32, m: u32) -> u32 {
79    match m {
80        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
81        4 | 6 | 9 | 11 => 30,
82        2 => {
83            if is_leap_year(y) {
84                29
85            } else {
86                28
87            }
88        }
89        _ => 0,
90    }
91}
92
93/// Whether `(y, m, d)` is a real calendar date (`datetime.date`'s domain:
94/// year 1..=9999, per Python's `MINYEAR`/`MAXYEAR`).
95pub(crate) fn valid_ymd(y: u32, m: u32, d: u32) -> bool {
96    (1..=9999).contains(&y) && (1..=12).contains(&m) && d >= 1 && d <= days_in_month(y, m)
97}
98
99/// Whether `(h, m, s)` is a real clock time (`00:00:00..=23:59:59`).
100pub(crate) fn valid_hms(h: u32, m: u32, s: u32) -> bool {
101    h <= 23 && m <= 59 && s <= 59
102}
103
104/// Whether an optional `[+-]HH:MM` offset capture is absent, or present and
105/// in range (`00:00..=23:59` on both sides, mirroring a plain time value).
106fn valid_offset(off: Option<regex::Match<'_>>) -> bool {
107    match off {
108        None => true,
109        Some(m) => {
110            let text = m.as_str();
111            // text is "[+-]HH:MM" per TIME_RE/DATETIME_RE's own `off` group.
112            let oh: u32 = text[1..3].parse().unwrap_or(u32::MAX);
113            let om: u32 = text[4..6].parse().unwrap_or(u32::MAX);
114            oh <= 23 && om <= 59
115        }
116    }
117}
118
119/// Pulls a *mandatory* named group's digits out as a `u32`. Only used for
120/// groups that `DATE_RE`/`TIME_RE`/`DATETIME_RE` require unconditionally
121/// (`y`/`mo`/`da`/`h`/`m` are never inside a `(...)?` group) -- so once
122/// `.captures()` has matched at all, these are guaranteed present and
123/// numeric (`\d{2}`/`\d{4}` can't fail to parse as `u32`), and there is no
124/// reachable failure branch to test here (see the module's coverage note
125/// in the PR description for how this was confirmed empirically rather
126/// than assumed).
127fn mandatory_u32(caps: &regex::Captures<'_>, name: &str) -> u32 {
128    caps.name(name)
129        .expect("group is mandatory in the pattern")
130        .as_str()
131        .parse()
132        .expect("group is all-digits per the pattern")
133}
134
135/// Is `s` shaped like, and a semantically valid, hyphenated ISO date
136/// (`YYYY-MM-DD`)? Narrower than `datetime.fromisoformat` by design -- see
137/// the module doc comment.
138pub(crate) fn is_iso_date(s: &str) -> bool {
139    let Some(caps) = DATE_RE.captures(s) else {
140        return false;
141    };
142    valid_ymd(
143        mandatory_u32(&caps, "y"),
144        mandatory_u32(&caps, "mo"),
145        mandatory_u32(&caps, "da"),
146    )
147}
148
149/// Is `s` shaped like, and a semantically valid, ISO time
150/// (`HH:MM[:SS[.ffffff]][+-HH:MM]`)?
151pub(crate) fn is_iso_time(s: &str) -> bool {
152    let Some(caps) = TIME_RE.captures(s) else {
153        return false;
154    };
155    is_valid_time_captures(&caps)
156}
157
158fn is_valid_time_captures(caps: &regex::Captures<'_>) -> bool {
159    let h = mandatory_u32(caps, "h");
160    let m = mandatory_u32(caps, "m");
161    // Seconds default to 0 when the `:SS` group is absent (shape allows
162    // `HH:MM` alone); when present it's mandatory digits, same guarantee
163    // as `mandatory_u32`'s other callers.
164    let s = caps.name("s").map_or(0, |c| c.as_str().parse().unwrap());
165    valid_hms(h, m, s) && valid_offset(caps.name("off"))
166}
167
168/// Is `s` shaped like, and a semantically valid, `T`-joined ISO datetime
169/// (`YYYY-MM-DDTHH:MM[:SS[.ffffff]][+-HH:MM]`)? Deliberately **excludes** a
170/// bare date string -- `datetime.fromisoformat` is lenient there (a
171/// date-only string parses fine, defaulting the missing time to midnight),
172/// which would silently treat "no time given" as "the time is exactly
173/// midnight," not the same value. Callers that need "datetime, and not
174/// also a bare date" should additionally check `!is_iso_date(s)`, mirroring
175/// the Python reference's `matches_kind("datetime", …)`.
176pub(crate) fn is_iso_datetime(s: &str) -> bool {
177    let Some(caps) = DATETIME_RE.captures(s) else {
178        return false;
179    };
180    let ok_date = valid_ymd(
181        mandatory_u32(&caps, "y"),
182        mandatory_u32(&caps, "mo"),
183        mandatory_u32(&caps, "da"),
184    );
185    ok_date && is_valid_time_captures(&caps)
186}
187
188/// Canonicalizes an already-`is_iso_time`-validated time string to its
189/// normalized form: a missing `:SS` defaults to `:00`, and a present
190/// fractional-seconds part is zero-padded to 6 digits (matching Python's
191/// real `datetime.time`/`datetime.datetime` `.isoformat()` output, which is
192/// what the OML temporal grammar's canonical form is defined against). Any
193/// UTC offset is carried through unchanged -- it's already in the
194/// grammar's canonical `+HH:MM`/`-HH:MM` shape.
195///
196/// # Panics
197/// Panics if `s` doesn't match `TIME_RE` -- callers must validate with
198/// `is_iso_time` first.
199pub(crate) fn canonicalize_iso_time(s: &str) -> String {
200    let caps = TIME_RE
201        .captures(s)
202        .expect("caller must validate with is_iso_time first");
203    canonicalize_time_captures(&caps)
204}
205
206/// Canonicalizes an already-`is_iso_datetime`-validated datetime string the
207/// same way as [`canonicalize_iso_time`], with the date portion (already
208/// fully-specified by the grammar -- `YYYY-MM-DD` has no optional parts)
209/// carried through unchanged.
210///
211/// # Panics
212/// Panics if `s` doesn't match `DATETIME_RE` -- callers must validate with
213/// `is_iso_datetime` first.
214pub(crate) fn canonicalize_iso_datetime(s: &str) -> String {
215    let caps = DATETIME_RE
216        .captures(s)
217        .expect("caller must validate with is_iso_datetime first");
218    let y = caps.name("y").expect("mandatory group").as_str();
219    let mo = caps.name("mo").expect("mandatory group").as_str();
220    let da = caps.name("da").expect("mandatory group").as_str();
221    format!("{y}-{mo}-{da}T{}", canonicalize_time_captures(&caps))
222}
223
224/// Shared canonicalization of the `h`/`m`/`s`/`f`/`off` capture groups that
225/// `TIME_RE` and `DATETIME_RE` both define identically for the time
226/// portion.
227fn canonicalize_time_captures(caps: &regex::Captures<'_>) -> String {
228    let h = caps.name("h").expect("mandatory group").as_str();
229    let m = caps.name("m").expect("mandatory group").as_str();
230    let s = caps.name("s").map_or("00", |c| c.as_str());
231    let mut out = format!("{h}:{m}:{s}");
232    if let Some(f) = caps.name("f") {
233        out.push('.');
234        let digits = f.as_str();
235        out.push_str(digits);
236        for _ in digits.len()..6 {
237            out.push('0');
238        }
239    }
240    if let Some(off) = caps.name("off") {
241        out.push_str(off.as_str());
242    }
243    out
244}
245
246// ---------------------------------------------------------------------------
247// Scalar
248// ---------------------------------------------------------------------------
249
250/// One of the seven predefined value kinds a [`Scalar`] can hold (spec §2.2).
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
252pub enum ScalarKind {
253    /// The `string` scalar kind (UTF-8 character string, spec §2.2).
254    String,
255    /// The `integer` scalar kind (arbitrary-precision integer, spec §2.2).
256    Integer,
257    /// The `number` scalar kind (IEEE 754 double precision float or integer, spec §2.2).
258    Number,
259    /// The `boolean` scalar kind (`true` or `false`, spec §2.2).
260    Boolean,
261    /// The `date` scalar kind (ISO 8601 calendar date `YYYY-MM-DD`, spec §2.2).
262    Date,
263    /// The `time` scalar kind (ISO 8601 clock time `hh:mm:ss`, spec §2.2).
264    Time,
265    /// The `datetime` scalar kind (ISO 8601 combined date-time, spec §2.2).
266    Datetime,
267}
268
269impl ScalarKind {
270    /// All seven kinds, in the order the Python reference declares them.
271    pub const ALL: [ScalarKind; 7] = [
272        ScalarKind::String,
273        ScalarKind::Integer,
274        ScalarKind::Number,
275        ScalarKind::Boolean,
276        ScalarKind::Date,
277        ScalarKind::Time,
278        ScalarKind::Datetime,
279    ];
280
281    /// Return the canonical OSD name of this scalar kind (`"string"`, `"integer"`, etc., spec §2.2).
282    pub fn as_str(&self) -> &'static str {
283        match self {
284            ScalarKind::String => "string",
285            ScalarKind::Integer => "integer",
286            ScalarKind::Number => "number",
287            ScalarKind::Boolean => "boolean",
288            ScalarKind::Date => "date",
289            ScalarKind::Time => "time",
290            ScalarKind::Datetime => "datetime",
291        }
292    }
293
294    /// Parse a scalar kind name, as it would appear in schema text (`"date"`
295    /// etc). Mirrors `Scalar.__init__`'s `SCALAR_NAMES` check.
296    pub fn parse(name: &str) -> Result<ScalarKind, SchemaError> {
297        ScalarKind::ALL
298            .into_iter()
299            .find(|k| k.as_str() == name)
300            .ok_or_else(|| {
301                let names: Vec<&str> = ScalarKind::ALL.iter().map(|k| k.as_str()).collect();
302                SchemaError::new(
303                    name,
304                    "schema.unknown-type",
305                    format!("unknown scalar {name:?}; expected one of {names:?}"),
306                )
307            })
308    }
309}
310
311/// One of the seven predefined value types, optionally nullable.
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
313pub struct Scalar {
314    kind: ScalarKind,
315    nullable: bool,
316}
317
318impl Scalar {
319    /// Construct a new `Scalar` with the given [`ScalarKind`] and nullability flag (spec §2.2, §3).
320    pub const fn new(kind: ScalarKind, nullable: bool) -> Self {
321        Scalar { kind, nullable }
322    }
323
324    /// Construct from a scalar kind's name (as it appears in schema text),
325    /// mirroring the Python constructor's runtime name check.
326    pub fn named(name: &str, nullable: bool) -> Result<Self, SchemaError> {
327        Ok(Scalar::new(ScalarKind::parse(name)?, nullable))
328    }
329
330    /// The value kind of this scalar.
331    pub fn kind(&self) -> ScalarKind {
332        self.kind
333    }
334
335    /// Whether this scalar accepts `null` values (the `?` suffix in OSD syntax).
336    pub fn is_nullable(&self) -> bool {
337        self.nullable
338    }
339}
340
341impl std::fmt::Display for Scalar {
342    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343        write!(
344            f,
345            "{}{}",
346            self.kind.as_str(),
347            if self.nullable { "?" } else { "" }
348        )
349    }
350}
351
352/// Non-nullable `string` scalar constant (spec §2.2, §3).
353pub const STRING: Scalar = Scalar::new(ScalarKind::String, false);
354/// Non-nullable `integer` scalar constant (spec §2.2, §3).
355pub const INTEGER: Scalar = Scalar::new(ScalarKind::Integer, false);
356/// Non-nullable `number` scalar constant (spec §2.2, §3).
357pub const NUMBER: Scalar = Scalar::new(ScalarKind::Number, false);
358/// Non-nullable `boolean` scalar constant (spec §2.2, §3).
359pub const BOOLEAN: Scalar = Scalar::new(ScalarKind::Boolean, false);
360/// Non-nullable `date` scalar constant (spec §2.2, §3).
361pub const DATE: Scalar = Scalar::new(ScalarKind::Date, false);
362/// Non-nullable `time` scalar constant (spec §2.2, §3).
363pub const TIME: Scalar = Scalar::new(ScalarKind::Time, false);
364/// Non-nullable `datetime` scalar constant (spec §2.2, §3).
365pub const DATETIME: Scalar = Scalar::new(ScalarKind::Datetime, false);
366
367/// A copy of `scalar` that also accepts `null` (the `?` form).
368pub fn nullable(scalar: Scalar) -> Scalar {
369    Scalar::new(scalar.kind, true)
370}
371
372// ---------------------------------------------------------------------------
373// Ref
374// ---------------------------------------------------------------------------
375
376/// A reference to a named record in a [`Schema`]'s environment.
377#[derive(Debug, Clone, PartialEq, Eq, Hash)]
378pub struct Ref {
379    /// The name of the target record in the schema environment.
380    pub name: String,
381}
382
383impl Ref {
384    /// Construct a new `Ref` pointing to a named record.
385    pub fn new(name: impl Into<String>) -> Self {
386        Ref { name: name.into() }
387    }
388}
389
390impl std::fmt::Display for Ref {
391    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392        write!(f, "ref({})", self.name)
393    }
394}
395
396/// A field's type: a `Ref` to a named record, a `Scalar`, or `Any` (accepts
397/// every legal document value -- ported from Python's `AnyType`/`ANY`
398/// singleton, shipped there since v0.5.0). `Any` is not a `Scalar` (it has
399/// no kind and no nullable flag -- null is already included) and not a
400/// `Ref` (it names nothing), so it gets its own unit variant rather than
401/// being folded into either.
402#[derive(Debug, Clone, PartialEq, Eq, Hash)]
403pub enum FieldType {
404    /// A scalar type slot.
405    Scalar(Scalar),
406    /// A reference to a named record in the schema environment.
407    Ref(Ref),
408    /// An `any` type slot accepting any legal document value without validation.
409    Any,
410}
411
412impl From<Scalar> for FieldType {
413    fn from(s: Scalar) -> Self {
414        FieldType::Scalar(s)
415    }
416}
417
418impl From<Ref> for FieldType {
419    fn from(r: Ref) -> Self {
420        FieldType::Ref(r)
421    }
422}
423
424// ---------------------------------------------------------------------------
425// Field / Record
426// ---------------------------------------------------------------------------
427
428/// One named, cardinality-bound field slot of a record: `label` of `type`,
429/// occurring `[min, max]` times (`max = None` is unbounded).
430#[derive(Debug, Clone, PartialEq, Eq, Hash)]
431pub struct Field {
432    /// The label of the field.
433    pub label: String,
434    /// The field's type.
435    pub ty: FieldType,
436    /// Minimum occurrence count required.
437    pub min: usize,
438    /// Maximum occurrence count allowed (`None` indicates unbounded cardinality).
439    pub max: Option<usize>,
440}
441
442impl Field {
443    /// Construct a new `Field` with label, type, and cardinality bounds `[min, max]` (spec §3, §3.3).
444    pub fn new(
445        label: impl Into<String>,
446        ty: impl Into<FieldType>,
447        min: usize,
448        max: Option<usize>,
449    ) -> Result<Self, SchemaError> {
450        let label = label.into();
451        if let Some(max) = max
452            && max < min
453        {
454            return Err(SchemaError::new(
455                label.clone(),
456                "schema.invalid-cardinality",
457                format!("field {label:?} has an invalid cardinality [{min},{max}]"),
458            ));
459        }
460        Ok(Field {
461            label,
462            ty: ty.into(),
463            min,
464            max,
465        })
466    }
467
468    /// A required, exactly-once field (`[1,1]`) -- the common case.
469    pub fn required(
470        label: impl Into<String>,
471        ty: impl Into<FieldType>,
472    ) -> Result<Self, SchemaError> {
473        Field::new(label, ty, 1, Some(1))
474    }
475
476    /// Human-readable description of this field's cardinality bounds.
477    pub fn cardinality_str(&self) -> String {
478        match (self.min, self.max) {
479            (1, Some(1)) => "exactly 1".to_string(),
480            (0, Some(1)) => "0 or 1".to_string(),
481            (min, None) => format!("at least {min}"),
482            (min, Some(max)) => format!("between {min} and {max}"),
483        }
484    }
485}
486
487/// A closed set of named fields (constrained by its child labels).
488///
489/// `fields` preserves declaration order (needed for canonical OSD
490/// rendering and `prune`'s declaration-order environment reconstruction --
491/// see `ops/mod.rs`'s module docs), but equality below deliberately treats
492/// it as an unordered set: omnist-spec's `docs/03-schema-model.md` Sec3.1
493/// states fields are an unordered set at the model layer, so two records
494/// declaring the same fields in a different order must compare equal.
495/// Confirmed empirically by this crate's own conformance self-test
496/// (`tools/conformance`, fixture
497/// `_referee-self-test/01-schema-exact-equal-different-field-order`) before
498/// this manual `PartialEq` replaced the field-order-sensitive derive.
499#[derive(Debug, Clone)]
500pub struct Record {
501    fields: Vec<Field>,
502    by_label: IndexMap<String, usize>,
503}
504
505impl PartialEq for Record {
506    fn eq(&self, other: &Self) -> bool {
507        if self.fields.len() != other.fields.len() {
508            return false;
509        }
510        // No duplicate labels within a record (enforced by `Record::new`),
511        // so sorting by label gives each side a canonical, comparable
512        // order regardless of declaration order.
513        let mut a: Vec<&Field> = self.fields.iter().collect();
514        let mut b: Vec<&Field> = other.fields.iter().collect();
515        a.sort_by(|x, y| x.label.cmp(&y.label));
516        b.sort_by(|x, y| x.label.cmp(&y.label));
517        a == b
518    }
519}
520
521impl Eq for Record {}
522
523impl Record {
524    /// Rejects a duplicate field label, matching Python's `Record.__init__`.
525    pub fn new(fields: Vec<Field>) -> Result<Self, SchemaError> {
526        let mut by_label = IndexMap::with_capacity(fields.len());
527        for (i, f) in fields.iter().enumerate() {
528            if by_label.insert(f.label.clone(), i).is_some() {
529                return Err(SchemaError::new(
530                    &f.label,
531                    "schema.duplicate-field",
532                    format!("duplicate field label {:?} in a record", f.label),
533                ));
534            }
535        }
536        Ok(Record { fields, by_label })
537    }
538
539    /// The fields of this record in declaration order.
540    pub fn fields(&self) -> &[Field] {
541        &self.fields
542    }
543
544    /// Look up a field by its label.
545    pub fn field(&self, label: &str) -> Option<&Field> {
546        self.by_label.get(label).map(|&i| &self.fields[i])
547    }
548}
549
550// ---------------------------------------------------------------------------
551// Validation result
552// ---------------------------------------------------------------------------
553
554/// A stable machine-readable validation failure code, mirroring the Python
555/// reference's `Error.code` values.
556#[derive(Debug, Clone, Copy, PartialEq, Eq)]
557pub enum ErrorCode {
558    /// A field was found in the document that is not defined on the record.
559    UnexpectedField,
560    /// The number of field occurrences fell outside `[min, max]`.
561    Cardinality,
562    /// A value did not match the expected type.
563    TypeMismatch,
564    /// A `null` value was encountered for a non-nullable field.
565    NullNotAllowed,
566    /// A record was encountered where a scalar was expected, or vice-versa.
567    ShapeMismatch,
568}
569
570/// Which top-level operation produced an [`ErrorCode`] -- `validate` and
571/// `materialize` share this one `ErrorCode`/`ValidationResult` mechanism
572/// (see `ValidationResult::add`'s doc comment), but omnist-spec §8.3.1
573/// namespaces error codes per call-site family (`validate.*` vs.
574/// `materialize.*`), not per underlying check, so the family has to be
575/// threaded through at the point a code is stringified rather than baked
576/// into `ErrorCode` itself.
577#[derive(Debug, Clone, Copy, PartialEq, Eq)]
578pub enum ErrorFamily {
579    /// Produced by [`Schema::validate`].
580    Validate,
581    /// Produced by [`crate::materialize::materialize`].
582    Materialize,
583}
584
585impl ErrorCode {
586    /// Stable, `family`-namespaced error code string per `omnist-spec`
587    /// §8.3.1 (`<family>.<case>`, e.g. `validate.shape-mismatch`).
588    ///
589    /// `TypeMismatch` under `Materialize` is the one case that isn't a
590    /// literal `materialize.type-mismatch`: materialize's type-mismatch
591    /// check is specifically "this value can't be upgraded to the field's
592    /// kind without loss or ambiguity," which §8.3.1 names
593    /// `materialize.inexact-conversion`.
594    pub fn as_str(&self, family: ErrorFamily) -> &'static str {
595        use ErrorFamily::{Materialize, Validate};
596        match (self, family) {
597            (ErrorCode::UnexpectedField, Validate) => "validate.unexpected-field",
598            (ErrorCode::UnexpectedField, Materialize) => "materialize.unexpected-field",
599            (ErrorCode::Cardinality, Validate) => "validate.cardinality",
600            (ErrorCode::Cardinality, Materialize) => "materialize.cardinality",
601            (ErrorCode::TypeMismatch, Validate) => "validate.type-mismatch",
602            (ErrorCode::TypeMismatch, Materialize) => "materialize.inexact-conversion",
603            (ErrorCode::NullNotAllowed, Validate) => "validate.null-not-allowed",
604            (ErrorCode::NullNotAllowed, Materialize) => "materialize.null-not-allowed",
605            (ErrorCode::ShapeMismatch, Validate) => "validate.shape-mismatch",
606            (ErrorCode::ShapeMismatch, Materialize) => "materialize.shape-mismatch",
607        }
608    }
609}
610
611/// One validation failure: where, what, and a stable code.
612#[derive(Debug, Clone, PartialEq, Eq)]
613pub struct ValidationError {
614    /// The path where the validation error occurred.
615    pub path: String,
616    /// Human-readable error description.
617    pub message: String,
618    /// Stable machine-readable error code.
619    pub code: ErrorCode,
620}
621
622/// The outcome of [`Schema::validate`]: empty on success, one entry per
623/// problem found (validation collects every error, not just the first).
624#[derive(Debug, Clone, Default, PartialEq, Eq)]
625pub struct ValidationResult {
626    errors: Vec<ValidationError>,
627}
628
629impl ValidationResult {
630    /// Create an empty validation result.
631    pub fn new() -> Self {
632        ValidationResult::default()
633    }
634
635    /// Returns `true` if there are no validation errors.
636    pub fn ok(&self) -> bool {
637        self.errors.is_empty()
638    }
639
640    /// Slice of all validation errors collected during validation.
641    pub fn errors(&self) -> &[ValidationError] {
642        &self.errors
643    }
644
645    /// `pub(crate)` (rather than private) so `materialize` can share this
646    /// exact multi-error-collection mechanism instead of duplicating it --
647    /// per issue #14, materialize's shape-check pass reuses the same
648    /// `ValidationResult`/`ValidationError`/`ErrorCode` types `validate`
649    /// already built.
650    pub(crate) fn add(
651        &mut self,
652        path: impl Into<String>,
653        message: impl Into<String>,
654        code: ErrorCode,
655    ) {
656        self.errors.push(ValidationError {
657            path: path.into(),
658            message: message.into(),
659            code,
660        });
661    }
662}
663
664impl std::fmt::Display for ValidationResult {
665    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
666        if self.ok() {
667            return write!(f, "valid");
668        }
669        writeln!(f, "invalid:")?;
670        for (i, e) in self.errors.iter().enumerate() {
671            if i > 0 {
672                writeln!(f)?;
673            }
674            write!(f, "  at {}: {}", e.path, e.message)?;
675        }
676        Ok(())
677    }
678}
679
680// ---------------------------------------------------------------------------
681// Value matching
682// ---------------------------------------------------------------------------
683
684/// Does `value` match scalar kind `kind`? Mirrors Python's `matches_kind` --
685/// validation only *checks*, it never converts (see `docs/design/model.md`
686/// §10).
687///
688/// **Verified against Python's real `schema.py::matches_kind` before
689/// implementing** (issue #105's first attempt got this wrong by reasoning
690/// from first principles instead of checking -- caught by the parity
691/// corpus, `omnist/tests/parity.rs`, before merge): `Date`/`Time`/
692/// `Datetime` match **either** the real `document::Scalar` variant **or**
693/// a plain `Str` whose text independently shape-validates
694/// (`is_iso_date`/`is_iso_time`/`is_iso_datetime`) -- Python's own
695/// `matches_kind` does exactly this hybrid check (a real `datetime.date`
696/// object, or a string `_is_iso`-shaped for one), so a JSON- or
697/// XML-sourced document with a date-shaped string field genuinely
698/// satisfies `kind: date` at `validate` time in Python, with no
699/// `materialize` upgrade required first. This is unlike `Integer`/
700/// `Number`, which stay strict (Python's own `matches_kind` has no
701/// string-shape fallback for those).
702pub fn matches_kind(value: &DocScalar, kind: ScalarKind) -> bool {
703    match kind {
704        ScalarKind::String => matches!(value, DocScalar::Str(_)),
705        ScalarKind::Boolean => matches!(value, DocScalar::Bool(_)),
706        ScalarKind::Integer => matches!(value, DocScalar::Int(_)),
707        ScalarKind::Number => matches!(value, DocScalar::Int(_) | DocScalar::Float(_)),
708        ScalarKind::Date => {
709            matches!(value, DocScalar::Date(_))
710                || matches!(value, DocScalar::Str(s) if is_iso_date(s))
711        }
712        ScalarKind::Time => {
713            matches!(value, DocScalar::Time(_))
714                || matches!(value, DocScalar::Str(s) if is_iso_time(s))
715        }
716        // Deliberately excludes a bare date string -- see is_iso_datetime's
717        // doc comment for why "datetime" and "date" must stay disjoint,
718        // mirroring Python's `_is_iso(value, _dt.datetime) and not
719        // _is_iso(value, _dt.date)`.
720        ScalarKind::Datetime => {
721            matches!(value, DocScalar::Datetime(_))
722                || matches!(value, DocScalar::Str(s) if is_iso_datetime(s) && !is_iso_date(s))
723        }
724    }
725}
726
727/// The most specific scalar kind name a [`document::Scalar`] value matches,
728/// for error messages (`integer` is reported even though it also matches
729/// `number`).
730pub(crate) fn value_kind_name(v: &DocScalar) -> &'static str {
731    match v {
732        DocScalar::Null => "null",
733        DocScalar::Bool(_) => "boolean",
734        DocScalar::Int(_) => "integer",
735        DocScalar::Float(_) => "number",
736        DocScalar::Str(_) => "string",
737        DocScalar::Date(_) => "date",
738        DocScalar::Time(_) => "time",
739        DocScalar::Datetime(_) => "datetime",
740    }
741}
742
743// ---------------------------------------------------------------------------
744// Schema
745// ---------------------------------------------------------------------------
746
747/// A resolved field type: a record (via a `Ref`), a bare `Scalar`, or `Any`.
748pub enum Resolved<'a> {
749    /// A resolved record in the schema environment.
750    Record(&'a Record),
751    /// A resolved scalar type.
752    Scalar(Scalar),
753    /// A resolved `any` type.
754    Any,
755}
756
757/// A schema: a root reference plus an environment of named records.
758#[derive(Debug, Clone, PartialEq, Eq)]
759pub struct Schema {
760    root: Ref,
761    env: IndexMap<String, Record>,
762}
763
764impl Schema {
765    /// Builds a schema and immediately checks every `Ref` (the root's, and
766    /// every field's) resolves within `env` -- mirrors Python's
767    /// `Schema.__init__` calling `check_refs()` unconditionally. Also
768    /// enforces S-3 (omnist-spec docs/03-schema-model.md): no record may be
769    /// named after a scalar keyword or `any`, since a bare name in type
770    /// position always resolves to the builtin first and such a record could
771    /// never be referenced. `osd.rs`'s parser already enforces this; this is
772    /// the same check at the builder-API surface (omnist-rs#76).
773    pub fn new(root: Ref, env: IndexMap<String, Record>) -> Result<Self, SchemaError> {
774        Self::check_reserved_names(&env)?;
775        let schema = Schema { root, env };
776        schema.check_refs()?;
777        Ok(schema)
778    }
779
780    fn check_reserved_names(env: &IndexMap<String, Record>) -> Result<(), SchemaError> {
781        for name in env.keys() {
782            if name == "any" {
783                return Err(SchemaError::new(
784                    "any",
785                    "schema.reserved-name",
786                    format!(
787                        "'any' is a reserved type name and cannot be used as a record name                          (record {name:?})"
788                    ),
789                ));
790            }
791            if ScalarKind::ALL.iter().any(|k| k.as_str() == name) {
792                return Err(SchemaError::new(
793                    name,
794                    "schema.reserved-name",
795                    format!(
796                        "{name:?} is a reserved scalar name; a record cannot be defined with                          this name, or it could never be referenced (a bare name in a type                          position always means the builtin scalar)"
797                    ),
798                ));
799            }
800        }
801        Ok(())
802    }
803
804    /// Reference to the root record of the schema.
805    pub fn root(&self) -> &Ref {
806        &self.root
807    }
808
809    /// Map of named records comprising the schema environment.
810    pub fn env(&self) -> &IndexMap<String, Record> {
811        &self.env
812    }
813
814    fn check_refs(&self) -> Result<(), SchemaError> {
815        if !self.env.contains_key(&self.root.name) {
816            return Err(SchemaError::new(
817                "$",
818                "schema.unknown-type",
819                format!("unknown type {:?}", self.root.name),
820            ));
821        }
822        for (rec_name, rec) in &self.env {
823            for f in rec.fields() {
824                if let FieldType::Ref(r) = &f.ty
825                    && !self.env.contains_key(&r.name)
826                {
827                    return Err(SchemaError::new(
828                        format!("{rec_name}.{}", f.label),
829                        "schema.unknown-type",
830                        format!("unknown type {:?}", r.name),
831                    ));
832                }
833            }
834        }
835        Ok(())
836    }
837
838    /// A bare `Scalar` resolves to itself; a `Ref` is a single environment
839    /// lookup -- `check_refs` already guarantees every `Ref` resolves, so
840    /// this never errors once a `Schema` exists.
841    pub fn resolve(&self, ty: &FieldType) -> Resolved<'_> {
842        match ty {
843            FieldType::Scalar(s) => Resolved::Scalar(*s),
844            FieldType::Any => Resolved::Any,
845            FieldType::Ref(r) => Resolved::Record(
846                self.env
847                    .get(&r.name)
848                    .expect("check_refs guarantees every Ref resolves"),
849            ),
850        }
851    }
852
853    /// Validates `cursor` (and everything beneath it) against this schema's
854    /// root type, collecting every problem found rather than stopping at
855    /// the first.
856    pub fn validate(&self, cursor: &document::Cursor<'_>) -> ValidationResult {
857        let mut res = ValidationResult::new();
858        // `path` is the one path `String` this walk allocates up front (the
859        // root); every deeper edge reuses this same buffer (push a segment,
860        // recurse, truncate back) rather than allocating its own -- see
861        // issue #44. `res.add` still copies out a path when an error is
862        // actually recorded (`impl Into<String>` on a `&str` allocates), but
863        // that only happens for edges that actually produce a problem.
864        let mut path = cursor.path.clone();
865        self.conform(
866            cursor,
867            &FieldType::Ref(self.root.clone()),
868            &mut res,
869            &mut path,
870        );
871        res
872    }
873
874    /// Returns `true` iff `cursor` conforms to this schema with 0 errors (spec §5).
875    pub fn accepts(&self, cursor: &document::Cursor<'_>) -> bool {
876        self.validate(cursor).ok()
877    }
878
879    fn conform(
880        &self,
881        cursor: &document::Cursor<'_>,
882        ty: &FieldType,
883        res: &mut ValidationResult,
884        path: &mut String,
885    ) {
886        match self.resolve(ty) {
887            // `any` accepts every legal Document value unchecked -- there is
888            // nothing to conform against, mirroring Python's
889            // `_conform`: `if isinstance(d, AnyType): return`.
890            Resolved::Any => {}
891            Resolved::Scalar(s) => self.conform_scalar(cursor, s, res, path),
892            Resolved::Record(r) => self.conform_record(cursor, r, res, path),
893        }
894    }
895
896    fn conform_scalar(
897        &self,
898        cursor: &document::Cursor<'_>,
899        s: Scalar,
900        res: &mut ValidationResult,
901        path: &str,
902    ) {
903        if !cursor.is_leaf() {
904            res.add(
905                path,
906                format!("expected a {} value, got an object", s.kind().as_str()),
907                ErrorCode::ShapeMismatch,
908            );
909            return;
910        }
911        let v = cursor
912            .value()
913            .expect("is_leaf() true implies value() succeeds");
914        if matches!(v, DocScalar::Null) {
915            if !s.is_nullable() {
916                res.add(path, "null not allowed here", ErrorCode::NullNotAllowed);
917            }
918            return;
919        }
920        if !matches_kind(v, s.kind()) {
921            res.add(
922                path,
923                format!(
924                    "expected {}, got {} ({})",
925                    s.kind().as_str(),
926                    value_kind_name(v),
927                    v
928                ),
929                ErrorCode::TypeMismatch,
930            );
931        }
932    }
933
934    /// Walks every edge without building a path `String` for it up front
935    /// (issue #44): `path` is a single buffer shared across the whole
936    /// `validate` walk. Each edge pushes its own segment (`.label` or
937    /// `.label[i]`, via [`crate::report::push_child_path`]) onto `path`,
938    /// recurses/reports using that borrowed `&str`, then truncates `path`
939    /// back before moving to the next edge -- so a document with no
940    /// unexpected fields, cardinality problems, or type mismatches never
941    /// allocates a path `String` per edge, only the one `res.add` actually
942    /// needs to keep (via `impl Into<String>`) when a problem is found.
943    fn conform_record(
944        &self,
945        cursor: &document::Cursor<'_>,
946        rec: &Record,
947        res: &mut ValidationResult,
948        path: &mut String,
949    ) {
950        if cursor.is_leaf() {
951            res.add(
952                path.as_str(),
953                "expected an object, got a value",
954                ErrorCode::ShapeMismatch,
955            );
956            return;
957        }
958        let edges = cursor
959            .raw_edges()
960            .expect("is_leaf() false implies raw_edges() succeeds");
961        let mut counts: IndexMap<&str, usize> = IndexMap::new();
962        for (label, i, child_id) in &edges {
963            *counts.entry(*label).or_insert(0) += 1;
964            let base = path.len();
965            crate::report::push_child_path(path, label, *i);
966            match rec.field(label) {
967                None => res.add(
968                    path.as_str(),
969                    "unexpected field",
970                    ErrorCode::UnexpectedField,
971                ),
972                Some(f) => {
973                    let child = cursor.seek(*child_id);
974                    self.conform(&child, &f.ty, res, path);
975                }
976            }
977            path.truncate(base);
978        }
979        for f in rec.fields() {
980            let c = counts.get(f.label.as_str()).copied().unwrap_or(0);
981            if c < f.min || f.max.is_some_and(|max| c > max) {
982                res.add(
983                    path.as_str(),
984                    format!(
985                        "field {:?} occurs {} time(s), expected {}",
986                        f.label,
987                        c,
988                        f.cardinality_str()
989                    ),
990                    ErrorCode::Cardinality,
991                );
992            }
993        }
994    }
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000    use crate::document::{Doc, Value};
1001    use indexmap::IndexMap as Map;
1002
1003    #[test]
1004    fn record_partial_eq_treats_field_order_as_insignificant_but_field_count_as_significant() {
1005        let r1 = Record::new(vec![
1006            Field::required("x", STRING).unwrap(),
1007            Field::required("y", STRING).unwrap(),
1008        ])
1009        .unwrap();
1010        let r2 = Record::new(vec![
1011            Field::required("y", STRING).unwrap(),
1012            Field::required("x", STRING).unwrap(),
1013        ])
1014        .unwrap();
1015        assert_eq!(r1, r2, "declaration order must not affect Record equality");
1016
1017        let r3 = Record::new(vec![Field::required("x", STRING).unwrap()]).unwrap();
1018        assert_ne!(
1019            r1, r3,
1020            "a different field count must never compare equal, regardless of order"
1021        );
1022    }
1023
1024    fn obj(pairs: &[(&str, Value)]) -> Value {
1025        let mut m = Map::new();
1026        for (k, v) in pairs {
1027            m.insert((*k).to_string(), v.clone());
1028        }
1029        Value::Object(m)
1030    }
1031
1032    // -- Scalar construction ---------------------------------------------
1033
1034    #[test]
1035    fn scalar_construction_and_equality() {
1036        let a = Scalar::new(ScalarKind::String, false);
1037        let b = Scalar::new(ScalarKind::String, false);
1038        assert_eq!(a, b);
1039        assert_ne!(a, Scalar::new(ScalarKind::String, true));
1040        assert_eq!(a, STRING);
1041    }
1042
1043    #[test]
1044    fn scalar_nullable_flag() {
1045        assert!(!STRING.is_nullable());
1046        let n = nullable(STRING);
1047        assert!(n.is_nullable());
1048        assert_eq!(n.kind(), ScalarKind::String);
1049    }
1050
1051    #[test]
1052    fn scalar_named_accepts_every_known_name() {
1053        for k in ScalarKind::ALL {
1054            assert_eq!(Scalar::named(k.as_str(), false).unwrap().kind(), k);
1055        }
1056    }
1057
1058    #[test]
1059    fn scalar_named_rejects_unknown_name() {
1060        let err = Scalar::named("bogus", false).unwrap_err();
1061        assert!(err.to_string().contains("unknown scalar"));
1062        assert!(err.to_string().contains("bogus"));
1063    }
1064
1065    #[test]
1066    fn scalar_display_shows_nullable_suffix() {
1067        assert_eq!(STRING.to_string(), "string");
1068        assert_eq!(nullable(STRING).to_string(), "string?");
1069    }
1070
1071    // -- Field cardinality --------------------------------------------------
1072
1073    #[test]
1074    fn field_rejects_max_less_than_min() {
1075        let err = Field::new("a", STRING, 2, Some(1)).unwrap_err();
1076        assert!(err.to_string().contains("invalid cardinality"));
1077        assert!(err.to_string().contains("[2,1]"));
1078    }
1079
1080    #[test]
1081    fn field_accepts_max_equal_to_min_and_unbounded_max() {
1082        assert!(Field::new("a", STRING, 1, Some(1)).is_ok());
1083        assert!(Field::new("a", STRING, 0, None).is_ok());
1084    }
1085
1086    #[test]
1087    fn field_cardinality_str_matches_python_phrasing() {
1088        assert_eq!(
1089            Field::new("a", STRING, 1, Some(1))
1090                .unwrap()
1091                .cardinality_str(),
1092            "exactly 1"
1093        );
1094        assert_eq!(
1095            Field::new("a", STRING, 0, Some(1))
1096                .unwrap()
1097                .cardinality_str(),
1098            "0 or 1"
1099        );
1100        assert_eq!(
1101            Field::new("a", STRING, 1, None).unwrap().cardinality_str(),
1102            "at least 1"
1103        );
1104        assert_eq!(
1105            Field::new("a", STRING, 2, Some(5))
1106                .unwrap()
1107                .cardinality_str(),
1108            "between 2 and 5"
1109        );
1110    }
1111
1112    // -- Record: duplicate field labels -------------------------------------
1113
1114    #[test]
1115    fn record_rejects_duplicate_field_label() {
1116        let a1 = Field::required("a", STRING).unwrap();
1117        let a2 = Field::required("a", INTEGER).unwrap();
1118        let err = Record::new(vec![a1, a2]).unwrap_err();
1119        assert!(err.to_string().contains("duplicate field label"));
1120        assert!(err.to_string().contains("\"a\""));
1121    }
1122
1123    #[test]
1124    fn record_field_lookup_and_ordering() {
1125        let f_b = Field::required("b", STRING).unwrap();
1126        let f_a = Field::required("a", INTEGER).unwrap();
1127        let rec = Record::new(vec![f_b.clone(), f_a.clone()]).unwrap();
1128        assert_eq!(rec.fields(), &[f_b, f_a]);
1129        assert_eq!(rec.field("a").unwrap().label, "a");
1130        assert!(rec.field("missing").is_none());
1131    }
1132
1133    // -- Schema: unknown Ref target -----------------------------------------
1134
1135    #[test]
1136    fn schema_rejects_unknown_root_ref() {
1137        let err = Schema::new(Ref::new("Missing"), Map::new()).unwrap_err();
1138        assert!(err.to_string().contains("unknown type"));
1139        assert!(err.to_string().contains("Missing"));
1140    }
1141
1142    #[test]
1143    fn schema_rejects_unknown_field_ref() {
1144        let mut env = Map::new();
1145        env.insert(
1146            "Root".to_string(),
1147            Record::new(vec![Field::required("x", Ref::new("Missing")).unwrap()]).unwrap(),
1148        );
1149        let err = Schema::new(Ref::new("Root"), env).unwrap_err();
1150        assert!(err.to_string().contains("unknown type"));
1151        assert!(err.to_string().contains("Missing"));
1152    }
1153
1154    #[test]
1155    fn schema_rejects_a_record_named_after_a_scalar_keyword() {
1156        // S-3 (omnist-spec docs/03-schema-model.md): a record named "string"
1157        // could never be referenced, since a bare name in type position
1158        // always resolves to the builtin scalar first. omnist-rs#76.
1159        let mut env = Map::new();
1160        env.insert(
1161            "Root".to_string(),
1162            Record::new(vec![Field::required("x", STRING).unwrap()]).unwrap(),
1163        );
1164        env.insert("string".to_string(), Record::new(vec![]).unwrap());
1165        let err = Schema::new(Ref::new("Root"), env).unwrap_err();
1166        assert!(err.to_string().contains("reserved scalar name"));
1167        assert!(err.to_string().contains("\"string\""));
1168    }
1169
1170    #[test]
1171    fn schema_rejects_a_record_named_any() {
1172        // S-3, the `any` half of the same constraint. omnist-rs#76.
1173        let mut env = Map::new();
1174        env.insert(
1175            "Root".to_string(),
1176            Record::new(vec![Field::required("x", STRING).unwrap()]).unwrap(),
1177        );
1178        env.insert("any".to_string(), Record::new(vec![]).unwrap());
1179        let err = Schema::new(Ref::new("Root"), env).unwrap_err();
1180        assert!(err.to_string().contains("reserved type name"));
1181    }
1182
1183    #[test]
1184    fn schema_accepts_a_valid_self_referential_environment() {
1185        let mut env = Map::new();
1186        env.insert(
1187            "Node".to_string(),
1188            Record::new(vec![
1189                Field::required("value", STRING).unwrap(),
1190                Field::new("child", Ref::new("Node"), 0, Some(1)).unwrap(),
1191            ])
1192            .unwrap(),
1193        );
1194        assert!(Schema::new(Ref::new("Node"), env).is_ok());
1195    }
1196
1197    fn service_schema() -> Schema {
1198        let mut env = Map::new();
1199        env.insert(
1200            "Database".to_string(),
1201            Record::new(vec![
1202                Field::required("type", STRING).unwrap(),
1203                Field::required("server", STRING).unwrap(),
1204                Field::required("port", INTEGER).unwrap(),
1205            ])
1206            .unwrap(),
1207        );
1208        env.insert(
1209            "Service".to_string(),
1210            Record::new(vec![
1211                Field::required("host", STRING).unwrap(),
1212                Field::required("port", INTEGER).unwrap(),
1213                Field::new("databases", Ref::new("Database"), 1, None).unwrap(),
1214                Field::new("tags", STRING, 0, None).unwrap(),
1215            ])
1216            .unwrap(),
1217        );
1218        Schema::new(Ref::new("Service"), env).unwrap()
1219    }
1220
1221    fn valid_service_doc() -> Value {
1222        obj(&[
1223            ("host", Value::Str("api.internal".into())),
1224            ("port", Value::Int((8443).into())),
1225            (
1226                "databases",
1227                Value::Array(vec![obj(&[
1228                    ("type", Value::Str("prod".into())),
1229                    ("server", Value::Str("db1".into())),
1230                    ("port", Value::Int((5432).into())),
1231                ])]),
1232            ),
1233            (
1234                "tags",
1235                Value::Array(vec![
1236                    Value::Str("prod".into()),
1237                    Value::Str("us-east".into()),
1238                ]),
1239            ),
1240        ])
1241    }
1242
1243    // -- cardinality semantics (worked example from model.md's appendix) ---
1244
1245    #[test]
1246    fn cardinality_semantics_worked_example() {
1247        let schema = service_schema();
1248        let doc = Doc::of(&valid_service_doc()).unwrap();
1249        let res = schema.validate(&doc.root());
1250        assert!(res.ok(), "{res}");
1251    }
1252
1253    #[test]
1254    fn cardinality_rejects_too_few_databases() {
1255        let schema = service_schema();
1256        let v = obj(&[
1257            ("host", Value::Str("h".into())),
1258            ("port", Value::Int((1).into())),
1259        ]);
1260        let doc = Doc::of(&v).unwrap();
1261        let res = schema.validate(&doc.root());
1262        assert!(!res.ok());
1263        assert!(
1264            res.errors()
1265                .iter()
1266                .any(|e| e.code == ErrorCode::Cardinality && e.message.contains("\"databases\""))
1267        );
1268    }
1269
1270    #[test]
1271    fn cardinality_ignores_order() {
1272        // Same fields, different declaration/edge order -- must still
1273        // validate (per model.md §7: "order ignored").
1274        let schema = service_schema();
1275        let v = obj(&[
1276            (
1277                "databases",
1278                Value::Array(vec![obj(&[
1279                    ("port", Value::Int((1).into())),
1280                    ("type", Value::Str("t".into())),
1281                    ("server", Value::Str("s".into())),
1282                ])]),
1283            ),
1284            ("port", Value::Int((8443).into())),
1285            ("host", Value::Str("h".into())),
1286        ]);
1287        let doc = Doc::of(&v).unwrap();
1288        assert!(schema.accepts(&doc.root()));
1289    }
1290
1291    // -- unexpected field / closedness --------------------------------------
1292
1293    #[test]
1294    fn unexpected_field_is_rejected() {
1295        let schema = service_schema();
1296        let mut v = valid_service_doc();
1297        if let Value::Object(m) = &mut v {
1298            m.insert("extra".to_string(), Value::Int((1).into()));
1299        }
1300        let doc = Doc::of(&v).unwrap();
1301        let res = schema.validate(&doc.root());
1302        assert!(!res.ok());
1303        assert!(
1304            res.errors()
1305                .iter()
1306                .any(|e| e.code == ErrorCode::UnexpectedField && e.path.contains("extra"))
1307        );
1308    }
1309
1310    // -- shape mismatch / type mismatch / null handling ---------------------
1311
1312    #[test]
1313    fn scalar_expected_but_object_found_is_shape_mismatch() {
1314        let schema = service_schema();
1315        let v = obj(&[
1316            ("host", obj(&[])),
1317            ("port", Value::Int((1).into())),
1318            (
1319                "databases",
1320                Value::Array(vec![obj(&[
1321                    ("type", Value::Str("t".into())),
1322                    ("server", Value::Str("s".into())),
1323                    ("port", Value::Int((1).into())),
1324                ])]),
1325            ),
1326        ]);
1327        let doc = Doc::of(&v).unwrap();
1328        let res = schema.validate(&doc.root());
1329        assert!(
1330            res.errors()
1331                .iter()
1332                .any(|e| e.code == ErrorCode::ShapeMismatch)
1333        );
1334    }
1335
1336    #[test]
1337    fn record_expected_but_scalar_found_is_shape_mismatch() {
1338        let mut env = Map::new();
1339        env.insert("Root".to_string(), Record::new(vec![]).unwrap());
1340        let schema = Schema::new(Ref::new("Root"), env).unwrap();
1341        let doc = Doc::of(&Value::Int((1).into())).unwrap();
1342        let res = schema.validate(&doc.root());
1343        assert!(!res.ok());
1344        assert_eq!(res.errors()[0].code, ErrorCode::ShapeMismatch);
1345    }
1346
1347    #[test]
1348    fn type_mismatch_reports_expected_and_actual() {
1349        let schema = service_schema();
1350        let mut v = valid_service_doc();
1351        if let Value::Object(m) = &mut v {
1352            m.insert("port".to_string(), Value::Str("not a number".into()));
1353        }
1354        let doc = Doc::of(&v).unwrap();
1355        let res = schema.validate(&doc.root());
1356        let e = res
1357            .errors()
1358            .iter()
1359            .find(|e| e.code == ErrorCode::TypeMismatch)
1360            .unwrap();
1361        assert!(e.message.contains("expected integer"));
1362        assert!(e.message.contains("got string"));
1363    }
1364
1365    #[test]
1366    fn null_rejected_for_non_nullable_scalar_but_accepted_when_nullable() {
1367        let mut env = Map::new();
1368        env.insert(
1369            "Root".to_string(),
1370            Record::new(vec![Field::required("v", STRING).unwrap()]).unwrap(),
1371        );
1372        let schema = Schema::new(Ref::new("Root"), env).unwrap();
1373        let doc = Doc::of(&obj(&[("v", Value::Null)])).unwrap();
1374        let res = schema.validate(&doc.root());
1375        assert!(!res.ok());
1376        assert_eq!(res.errors()[0].code, ErrorCode::NullNotAllowed);
1377
1378        let mut env2 = Map::new();
1379        env2.insert(
1380            "Root".to_string(),
1381            Record::new(vec![Field::required("v", nullable(STRING)).unwrap()]).unwrap(),
1382        );
1383        let schema2 = Schema::new(Ref::new("Root"), env2).unwrap();
1384        let doc2 = Doc::of(&obj(&[("v", Value::Null)])).unwrap();
1385        assert!(schema2.accepts(&doc2.root()));
1386    }
1387
1388    #[test]
1389    fn accepts_and_validation_result_display() {
1390        let schema = service_schema();
1391        let doc = Doc::of(&valid_service_doc()).unwrap();
1392        assert!(schema.accepts(&doc.root()));
1393        assert_eq!(schema.validate(&doc.root()).to_string(), "valid");
1394
1395        let bad = Doc::of(&obj(&[])).unwrap();
1396        let res = schema.validate(&bad.root());
1397        assert!(!res.ok());
1398        let s = res.to_string();
1399        assert!(s.starts_with("invalid:\n  at "));
1400    }
1401
1402    // -- matches_kind: bool must not satisfy integer/number -----------------
1403
1404    #[test]
1405    fn bool_never_satisfies_integer_or_number() {
1406        assert!(!matches_kind(&DocScalar::Bool(true), ScalarKind::Integer));
1407        assert!(!matches_kind(&DocScalar::Bool(true), ScalarKind::Number));
1408        assert!(matches_kind(&DocScalar::Bool(true), ScalarKind::Boolean));
1409    }
1410
1411    #[test]
1412    fn integer_satisfies_number_but_not_vice_versa() {
1413        assert!(matches_kind(
1414            &DocScalar::Int((3).into()),
1415            ScalarKind::Number
1416        ));
1417        assert!(!matches_kind(&DocScalar::Float(3.0), ScalarKind::Integer));
1418    }
1419
1420    // -- temporal shape-check: date -----------------------------------------
1421
1422    #[test]
1423    fn is_iso_date_accepts_valid_dates() {
1424        assert!(is_iso_date("2024-01-01"));
1425        assert!(is_iso_date("9999-12-31"));
1426    }
1427
1428    #[test]
1429    fn is_iso_date_rejects_wrong_shape_and_invalid_calendar_dates() {
1430        // Wrong shape: fromisoformat-is-wider cases this crate deliberately
1431        // does NOT accept (basic format, single-digit month/day).
1432        assert!(!is_iso_date("20240101"));
1433        assert!(!is_iso_date("2024-1-1"));
1434        assert!(!is_iso_date("2024-W01-1"));
1435        assert!(!is_iso_date("not-a-date"));
1436        // Right shape, invalid calendar date.
1437        assert!(!is_iso_date("2024-13-01"));
1438        assert!(!is_iso_date("2024-02-30"));
1439        assert!(!is_iso_date("2024-00-01"));
1440        assert!(!is_iso_date("2024-01-00"));
1441        assert!(!is_iso_date("0000-01-01"));
1442    }
1443
1444    #[test]
1445    fn is_iso_date_thirty_day_months() {
1446        assert!(is_iso_date("2024-04-30"));
1447        assert!(!is_iso_date("2024-04-31"));
1448        assert!(is_iso_date("2024-06-30"));
1449        assert!(is_iso_date("2024-09-30"));
1450        assert!(is_iso_date("2024-11-30"));
1451    }
1452
1453    #[test]
1454    fn days_in_month_rejects_an_out_of_range_month_directly() {
1455        // White-box: `days_in_month`'s `_ => 0` arm is unreachable through
1456        // `valid_ymd` (which only calls it after `(1..=12).contains(&m)`
1457        // already passed) -- call the private helper directly to prove the
1458        // arm itself is correct, rather than leaving it untested.
1459        assert_eq!(days_in_month(2024, 13), 0);
1460        assert_eq!(days_in_month(2024, 0), 0);
1461    }
1462
1463    #[test]
1464    fn is_iso_date_leap_year_boundary() {
1465        assert!(is_iso_date("2024-02-29")); // 2024 is a leap year
1466        assert!(!is_iso_date("2023-02-29")); // 2023 is not
1467        assert!(is_iso_date("2000-02-29")); // divisible by 400
1468        assert!(!is_iso_date("1900-02-29")); // divisible by 100, not 400
1469    }
1470
1471    // -- temporal shape-check: time ------------------------------------------
1472
1473    #[test]
1474    fn is_iso_time_accepts_valid_times() {
1475        assert!(is_iso_time("12:00:00"));
1476        assert!(is_iso_time("12:00"));
1477        assert!(is_iso_time("12:00:00.5"));
1478        assert!(is_iso_time("12:00:00.123456"));
1479        assert!(is_iso_time("12:00:00+02:00"));
1480        assert!(is_iso_time("23:59:59"));
1481    }
1482
1483    #[test]
1484    fn is_iso_time_rejects_out_of_range_and_malformed() {
1485        assert!(!is_iso_time("25:00:00"));
1486        assert!(!is_iso_time("12:60:00"));
1487        assert!(!is_iso_time("24:00:00"));
1488        assert!(!is_iso_time("12:00:00+24:00"));
1489        assert!(!is_iso_time("12:00:00+99:99"));
1490        assert!(!is_iso_time("12:00:00.1234567")); // 7 fractional digits
1491        assert!(!is_iso_time("1:00:00")); // not zero-padded
1492        assert!(is_iso_time("12:00:00+23:59"));
1493    }
1494
1495    // -- temporal shape-check: datetime, and the `_is_iso` vs
1496    //    `fromisoformat`-is-wider / date-vs-datetime exclusivity ------------
1497
1498    #[test]
1499    fn is_iso_datetime_accepts_valid_timestamps() {
1500        assert!(is_iso_datetime("2024-01-01T12:00:00"));
1501        assert!(is_iso_datetime("2024-01-01T12:00"));
1502        assert!(is_iso_datetime("2024-01-01T12:00:00+02:00"));
1503        assert!(is_iso_datetime("2024-01-01T12:00:00.123456"));
1504    }
1505
1506    #[test]
1507    fn is_iso_datetime_rejects_bare_date_and_invalid_components() {
1508        assert!(!is_iso_datetime("2024-01-01"));
1509        assert!(!is_iso_datetime("2024-01-01T25:00:00"));
1510        assert!(!is_iso_datetime("2024-13-01T12:00:00"));
1511    }
1512
1513    #[test]
1514    fn matches_kind_datetime_excludes_bare_date_string_and_a_real_date() {
1515        // The exact _is_iso-vs-fromisoformat subtlety, verified against
1516        // Python's real `matches_kind` (issue #105): a bare date-only
1517        // string satisfies `Date` but not `Datetime` (`fromisoformat` on
1518        // `datetime` would succeed, defaulting the missing time to
1519        // midnight -- not the same value as "no time given"). The same
1520        // disjointness holds for the real, non-string variants: a genuine
1521        // `Scalar::Date` never matches `Datetime` and vice versa.
1522        let v = DocScalar::Str("2024-01-01".to_string());
1523        assert!(matches_kind(&v, ScalarKind::Date));
1524        assert!(!matches_kind(&v, ScalarKind::Datetime));
1525
1526        let dt = DocScalar::Str("2024-01-01T00:00:00".to_string());
1527        assert!(matches_kind(&dt, ScalarKind::Datetime));
1528        assert!(!matches_kind(&dt, ScalarKind::Date));
1529
1530        let d_real = DocScalar::Date("2024-01-01".to_string());
1531        assert!(matches_kind(&d_real, ScalarKind::Date));
1532        assert!(!matches_kind(&d_real, ScalarKind::Datetime));
1533
1534        let dt_real = DocScalar::Datetime("2024-01-01T00:00:00".to_string());
1535        assert!(matches_kind(&dt_real, ScalarKind::Datetime));
1536        assert!(!matches_kind(&dt_real, ScalarKind::Date));
1537    }
1538
1539    #[test]
1540    fn matches_kind_date_and_time_directly() {
1541        // Both the real variant and a shape-matching plain string satisfy
1542        // Date/Time (verified against Python's real `matches_kind`, issue
1543        // #105 -- Python's own hybrid check, not just this port's).
1544        assert!(matches_kind(
1545            &DocScalar::Date("2024-01-01".into()),
1546            ScalarKind::Date
1547        ));
1548        assert!(matches_kind(
1549            &DocScalar::Str("2024-01-01".into()),
1550            ScalarKind::Date
1551        ));
1552        assert!(!matches_kind(
1553            &DocScalar::Str("not-a-date".into()),
1554            ScalarKind::Date
1555        ));
1556        assert!(matches_kind(
1557            &DocScalar::Time("12:00:00".into()),
1558            ScalarKind::Time
1559        ));
1560        assert!(matches_kind(
1561            &DocScalar::Str("12:00:00".into()),
1562            ScalarKind::Time
1563        ));
1564        assert!(!matches_kind(
1565            &DocScalar::Str("25:00:00".into()),
1566            ScalarKind::Time
1567        ));
1568        assert!(!matches_kind(&DocScalar::Int((1).into()), ScalarKind::Date));
1569        assert!(!matches_kind(&DocScalar::Int((1).into()), ScalarKind::Time));
1570    }
1571
1572    #[test]
1573    fn schema_root_and_env_accessors() {
1574        let schema = service_schema();
1575        assert_eq!(schema.root().name, "Service");
1576        assert!(schema.env().contains_key("Database"));
1577        assert!(schema.env().contains_key("Service"));
1578    }
1579
1580    // -- FieldType From conversions / Ref, Display --------------------------
1581
1582    // -- `any` field type: accepts every legal value unchecked ------------
1583
1584    #[test]
1585    fn any_field_accepts_scalars_and_objects_unchecked() {
1586        let mut env = Map::new();
1587        env.insert(
1588            "Root".to_string(),
1589            Record::new(vec![Field::required("x", FieldType::Any).unwrap()]).unwrap(),
1590        );
1591        let schema = Schema::new(Ref::new("Root"), env).unwrap();
1592
1593        // Not an array here: cardinality (how many times a label occurs) is
1594        // checked independently of the field's type, `any` included -- an
1595        // array under a `[1,1]` field is still a cardinality violation, not
1596        // an `any`-typed acceptance question. See the array/cardinality
1597        // case in a separate `[0,]`-cardinality field below.
1598        for v in [
1599            Value::Str("hi".into()),
1600            Value::Int((1).into()),
1601            Value::Float(1.5),
1602            Value::Bool(true),
1603            Value::Null,
1604            obj(&[("nested", Value::Int((1).into()))]),
1605        ] {
1606            let doc = Doc::of(&obj(&[("x", v.clone())])).unwrap();
1607            assert!(schema.accepts(&doc.root()), "any field rejected {v:?}");
1608        }
1609    }
1610
1611    #[test]
1612    fn any_field_with_array_cardinality_accepts_repeated_values_unchecked() {
1613        let mut env = Map::new();
1614        env.insert(
1615            "Root".to_string(),
1616            Record::new(vec![Field::new("x", FieldType::Any, 0, None).unwrap()]).unwrap(),
1617        );
1618        let schema = Schema::new(Ref::new("Root"), env).unwrap();
1619        let doc = Doc::of(&obj(&[(
1620            "x",
1621            Value::Array(vec![Value::Int((1).into()), Value::Str("mixed".into())]),
1622        )]))
1623        .unwrap();
1624        assert!(schema.accepts(&doc.root()));
1625    }
1626
1627    #[test]
1628    fn any_resolves_without_touching_env() {
1629        let env: Map<String, Record> = Map::new();
1630        let schema = Schema::new(Ref::new("Root"), {
1631            let mut e = env;
1632            e.insert("Root".to_string(), Record::new(vec![]).unwrap());
1633            e
1634        })
1635        .unwrap();
1636        assert!(matches!(schema.resolve(&FieldType::Any), Resolved::Any));
1637    }
1638
1639    #[test]
1640    fn field_type_from_conversions() {
1641        let ft: FieldType = STRING.into();
1642        assert_eq!(ft, FieldType::Scalar(STRING));
1643        let ft2: FieldType = Ref::new("X").into();
1644        assert_eq!(ft2, FieldType::Ref(Ref::new("X")));
1645    }
1646
1647    #[test]
1648    fn ref_display() {
1649        assert_eq!(Ref::new("Foo").to_string(), "ref(Foo)");
1650    }
1651
1652    #[test]
1653    fn error_code_as_str_covers_every_variant() {
1654        assert_eq!(
1655            ErrorCode::UnexpectedField.as_str(ErrorFamily::Validate),
1656            "validate.unexpected-field"
1657        );
1658        assert_eq!(
1659            ErrorCode::UnexpectedField.as_str(ErrorFamily::Materialize),
1660            "materialize.unexpected-field"
1661        );
1662        assert_eq!(
1663            ErrorCode::Cardinality.as_str(ErrorFamily::Validate),
1664            "validate.cardinality"
1665        );
1666        assert_eq!(
1667            ErrorCode::Cardinality.as_str(ErrorFamily::Materialize),
1668            "materialize.cardinality"
1669        );
1670        assert_eq!(
1671            ErrorCode::TypeMismatch.as_str(ErrorFamily::Validate),
1672            "validate.type-mismatch"
1673        );
1674        assert_eq!(
1675            ErrorCode::TypeMismatch.as_str(ErrorFamily::Materialize),
1676            "materialize.inexact-conversion"
1677        );
1678        assert_eq!(
1679            ErrorCode::NullNotAllowed.as_str(ErrorFamily::Validate),
1680            "validate.null-not-allowed"
1681        );
1682        assert_eq!(
1683            ErrorCode::NullNotAllowed.as_str(ErrorFamily::Materialize),
1684            "materialize.null-not-allowed"
1685        );
1686        assert_eq!(
1687            ErrorCode::ShapeMismatch.as_str(ErrorFamily::Validate),
1688            "validate.shape-mismatch"
1689        );
1690        assert_eq!(
1691            ErrorCode::ShapeMismatch.as_str(ErrorFamily::Materialize),
1692            "materialize.shape-mismatch"
1693        );
1694    }
1695
1696    #[test]
1697    fn value_kind_name_covers_every_variant() {
1698        assert_eq!(value_kind_name(&DocScalar::Null), "null");
1699        assert_eq!(value_kind_name(&DocScalar::Bool(true)), "boolean");
1700        assert_eq!(value_kind_name(&DocScalar::Int((1).into())), "integer");
1701        assert_eq!(value_kind_name(&DocScalar::Float(1.0)), "number");
1702        assert_eq!(value_kind_name(&DocScalar::Str("x".into())), "string");
1703        assert_eq!(
1704            value_kind_name(&DocScalar::Date("2024-01-01".into())),
1705            "date"
1706        );
1707        assert_eq!(value_kind_name(&DocScalar::Time("12:00:00".into())), "time");
1708        assert_eq!(
1709            value_kind_name(&DocScalar::Datetime("2024-01-01T12:00:00".into())),
1710            "datetime"
1711        );
1712    }
1713}