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/// S-8: `[A-Za-z_][A-Za-z0-9_]*`.
377fn is_valid_name(name: &str) -> bool {
378    let mut chars = name.chars();
379    chars
380        .next()
381        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
382        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
383}
384
385/// A reference to a named record in a [`Schema`]'s environment.
386#[derive(Debug, Clone, PartialEq, Eq, Hash)]
387pub struct Ref {
388    /// The name of the target record in the schema environment.
389    pub name: String,
390}
391
392impl Ref {
393    /// Construct a new `Ref` pointing to a named record.
394    pub fn new(name: impl Into<String>) -> Self {
395        Ref { name: name.into() }
396    }
397}
398
399impl std::fmt::Display for Ref {
400    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401        write!(f, "ref({})", self.name)
402    }
403}
404
405/// A field's type: a `Ref` to a named record, a `Scalar`, or `Any` (accepts
406/// every legal document value -- ported from Python's `AnyType`/`ANY`
407/// singleton, shipped there since v0.5.0). `Any` is not a `Scalar` (it has
408/// no kind and no nullable flag -- null is already included) and not a
409/// `Ref` (it names nothing), so it gets its own unit variant rather than
410/// being folded into either.
411#[derive(Debug, Clone, PartialEq, Eq, Hash)]
412pub enum FieldType {
413    /// A scalar type slot.
414    Scalar(Scalar),
415    /// A reference to a named record in the schema environment.
416    Ref(Ref),
417    /// An `any` type slot accepting any legal document value without validation.
418    Any,
419}
420
421impl From<Scalar> for FieldType {
422    fn from(s: Scalar) -> Self {
423        FieldType::Scalar(s)
424    }
425}
426
427impl From<Ref> for FieldType {
428    fn from(r: Ref) -> Self {
429        FieldType::Ref(r)
430    }
431}
432
433// ---------------------------------------------------------------------------
434// Field / Record
435// ---------------------------------------------------------------------------
436
437/// One named, cardinality-bound field slot of a record: `label` of `type`,
438/// occurring `[min, max]` times (`max = None` is unbounded).
439#[derive(Debug, Clone, PartialEq, Eq, Hash)]
440pub struct Field {
441    /// The label of the field.
442    pub label: String,
443    /// The field's type.
444    pub ty: FieldType,
445    /// Minimum occurrence count required.
446    pub min: usize,
447    /// Maximum occurrence count allowed (`None` indicates unbounded cardinality).
448    pub max: Option<usize>,
449}
450
451impl Field {
452    /// Construct a new `Field` with label, type, and cardinality bounds `[min, max]` (spec §3, §3.3).
453    pub fn new(
454        label: impl Into<String>,
455        ty: impl Into<FieldType>,
456        min: usize,
457        max: Option<usize>,
458    ) -> Result<Self, SchemaError> {
459        let label = label.into();
460        if let Some(max) = max
461            && max < min
462        {
463            return Err(SchemaError::new(
464                label.clone(),
465                "schema.invalid-cardinality",
466                format!("field {label:?} has an invalid cardinality [{min},{max}]"),
467            ));
468        }
469        Ok(Field {
470            label,
471            ty: ty.into(),
472            min,
473            max,
474        })
475    }
476
477    /// A required, exactly-once field (`[1,1]`) -- the common case.
478    pub fn required(
479        label: impl Into<String>,
480        ty: impl Into<FieldType>,
481    ) -> Result<Self, SchemaError> {
482        Field::new(label, ty, 1, Some(1))
483    }
484
485    /// Human-readable description of this field's cardinality bounds.
486    pub fn cardinality_str(&self) -> String {
487        match (self.min, self.max) {
488            (1, Some(1)) => "exactly 1".to_string(),
489            (0, Some(1)) => "0 or 1".to_string(),
490            (min, None) => format!("at least {min}"),
491            (min, Some(max)) => format!("between {min} and {max}"),
492        }
493    }
494}
495
496/// A closed set of named fields (constrained by its child labels).
497///
498/// `fields` preserves declaration order (needed for canonical OSD
499/// rendering and `prune`'s declaration-order environment reconstruction --
500/// see `ops/mod.rs`'s module docs), but equality below deliberately treats
501/// it as an unordered set: omnist-spec's `docs/03-schema-model.md` Sec3.1
502/// states fields are an unordered set at the model layer, so two records
503/// declaring the same fields in a different order must compare equal.
504/// Confirmed empirically by this crate's own conformance self-test
505/// (`tools/conformance`, fixture
506/// `_referee-self-test/01-schema-exact-equal-different-field-order`) before
507/// this manual `PartialEq` replaced the field-order-sensitive derive.
508#[derive(Debug, Clone)]
509pub struct Record {
510    fields: Vec<Field>,
511    by_label: IndexMap<String, usize>,
512}
513
514impl PartialEq for Record {
515    fn eq(&self, other: &Self) -> bool {
516        if self.fields.len() != other.fields.len() {
517            return false;
518        }
519        // No duplicate labels within a record (enforced by `Record::new`),
520        // so sorting by label gives each side a canonical, comparable
521        // order regardless of declaration order.
522        let mut a: Vec<&Field> = self.fields.iter().collect();
523        let mut b: Vec<&Field> = other.fields.iter().collect();
524        a.sort_by(|x, y| x.label.cmp(&y.label));
525        b.sort_by(|x, y| x.label.cmp(&y.label));
526        a == b
527    }
528}
529
530impl Eq for Record {}
531
532impl Record {
533    /// Rejects a duplicate field label, matching Python's `Record.__init__`.
534    pub fn new(fields: Vec<Field>) -> Result<Self, SchemaError> {
535        let mut by_label = IndexMap::with_capacity(fields.len());
536        for (i, f) in fields.iter().enumerate() {
537            if by_label.insert(f.label.clone(), i).is_some() {
538                return Err(SchemaError::new(
539                    &f.label,
540                    "schema.duplicate-field",
541                    format!("duplicate field label {:?} in a record", f.label),
542                ));
543            }
544        }
545        Ok(Record { fields, by_label })
546    }
547
548    /// The fields of this record in declaration order.
549    pub fn fields(&self) -> &[Field] {
550        &self.fields
551    }
552
553    /// Look up a field by its label.
554    pub fn field(&self, label: &str) -> Option<&Field> {
555        self.by_label.get(label).map(|&i| &self.fields[i])
556    }
557}
558
559// ---------------------------------------------------------------------------
560// Validation result
561// ---------------------------------------------------------------------------
562
563/// A stable machine-readable validation failure code, mirroring the Python
564/// reference's `Error.code` values.
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub enum ErrorCode {
567    /// A field was found in the document that is not defined on the record.
568    UnexpectedField,
569    /// The number of field occurrences fell outside `[min, max]`.
570    Cardinality,
571    /// A value did not match the expected type.
572    TypeMismatch,
573    /// A `null` value was encountered for a non-nullable field.
574    NullNotAllowed,
575    /// A record was encountered where a scalar was expected, or vice-versa.
576    ShapeMismatch,
577}
578
579/// Which top-level operation produced an [`ErrorCode`] -- `validate` and
580/// `materialize` share this one `ErrorCode`/`ValidationResult` mechanism
581/// (see `ValidationResult::add`'s doc comment), but omnist-spec §8.3.1
582/// namespaces error codes per call-site family (`validate.*` vs.
583/// `materialize.*`), not per underlying check, so the family has to be
584/// threaded through at the point a code is stringified rather than baked
585/// into `ErrorCode` itself.
586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587pub enum ErrorFamily {
588    /// Produced by [`Schema::validate`].
589    Validate,
590    /// Produced by [`crate::materialize::materialize`].
591    Materialize,
592}
593
594impl ErrorCode {
595    /// Stable, `family`-namespaced error code string per `omnist-spec`
596    /// §8.3.1 (`<family>.<case>`, e.g. `validate.shape-mismatch`).
597    ///
598    /// Materialization performs the same shape/cardinality checks as
599    /// validation, and §8.3.5 gives them no separate names: under
600    /// `Materialize` they are the same `validate.*` codes. The one
601    /// exception is `TypeMismatch`: materialize's version is specifically
602    /// "this value can't be upgraded to the field's kind without loss or
603    /// ambiguity," which §8.3.5 names `materialize.inexact-conversion`.
604    pub fn as_str(&self, family: ErrorFamily) -> &'static str {
605        use ErrorFamily::{Materialize, Validate};
606        match (self, family) {
607            // Materialization performs the same shape/cardinality checks as
608            // validation and reports them under the SAME `validate.*` codes
609            // (spec section 8.3.5): there is no separate `materialize.*` name
610            // for them. Only the exactness of a leaf's conversion has its own.
611            (ErrorCode::UnexpectedField, _) => "validate.unexpected-field",
612            (ErrorCode::Cardinality, _) => "validate.cardinality",
613            (ErrorCode::TypeMismatch, Validate) => "validate.type-mismatch",
614            (ErrorCode::TypeMismatch, Materialize) => "materialize.inexact-conversion",
615            (ErrorCode::NullNotAllowed, _) => "validate.null-not-allowed",
616            (ErrorCode::ShapeMismatch, _) => "validate.shape-mismatch",
617        }
618    }
619}
620
621/// One validation failure: where, what, and a stable code.
622#[derive(Debug, Clone, PartialEq, Eq)]
623pub struct ValidationError {
624    /// The path where the validation error occurred.
625    pub path: String,
626    /// Human-readable error description.
627    pub message: String,
628    /// Stable machine-readable error code.
629    pub code: ErrorCode,
630}
631
632/// The outcome of [`Schema::validate`]: empty on success, one entry per
633/// problem found (validation collects every error, not just the first).
634#[derive(Debug, Clone, Default, PartialEq, Eq)]
635pub struct ValidationResult {
636    errors: Vec<ValidationError>,
637}
638
639impl ValidationResult {
640    /// Create an empty validation result.
641    pub fn new() -> Self {
642        ValidationResult::default()
643    }
644
645    /// Returns `true` if there are no validation errors.
646    pub fn ok(&self) -> bool {
647        self.errors.is_empty()
648    }
649
650    /// Slice of all validation errors collected during validation.
651    pub fn errors(&self) -> &[ValidationError] {
652        &self.errors
653    }
654
655    /// `pub(crate)` (rather than private) so `materialize` can share this
656    /// exact multi-error-collection mechanism instead of duplicating it --
657    /// per issue #14, materialize's shape-check pass reuses the same
658    /// `ValidationResult`/`ValidationError`/`ErrorCode` types `validate`
659    /// already built.
660    pub(crate) fn add(
661        &mut self,
662        path: impl Into<String>,
663        message: impl Into<String>,
664        code: ErrorCode,
665    ) {
666        self.errors.push(ValidationError {
667            path: path.into(),
668            message: message.into(),
669            code,
670        });
671    }
672}
673
674impl std::fmt::Display for ValidationResult {
675    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676        if self.ok() {
677            return write!(f, "valid");
678        }
679        writeln!(f, "invalid:")?;
680        for (i, e) in self.errors.iter().enumerate() {
681            if i > 0 {
682                writeln!(f)?;
683            }
684            write!(f, "  at {}: {}", e.path, e.message)?;
685        }
686        Ok(())
687    }
688}
689
690// ---------------------------------------------------------------------------
691// Value matching
692// ---------------------------------------------------------------------------
693
694/// Does `value` match scalar kind `kind`? Mirrors Python's `matches_kind` --
695/// validation only *checks*, it never converts (see `docs/design/model.md`
696/// §10).
697///
698/// **Verified against Python's real `schema.py::matches_kind` before
699/// implementing** (issue #105's first attempt got this wrong by reasoning
700/// from first principles instead of checking -- caught by the parity
701/// corpus, `omnist/tests/parity.rs`, before merge): `Date`/`Time`/
702/// `Datetime` match **either** the real `document::Scalar` variant **or**
703/// a plain `Str` whose text independently shape-validates
704/// (`is_iso_date`/`is_iso_time`/`is_iso_datetime`) -- Python's own
705/// `matches_kind` does exactly this hybrid check (a real `datetime.date`
706/// object, or a string `_is_iso`-shaped for one), so a JSON- or
707/// XML-sourced document with a date-shaped string field genuinely
708/// satisfies `kind: date` at `validate` time in Python, with no
709/// `materialize` upgrade required first. This is unlike `Integer`/
710/// `Number`, which stay strict (Python's own `matches_kind` has no
711/// string-shape fallback for those).
712pub fn matches_kind(value: &DocScalar, kind: ScalarKind) -> bool {
713    match kind {
714        ScalarKind::String => matches!(value, DocScalar::Str(_)),
715        ScalarKind::Boolean => matches!(value, DocScalar::Bool(_)),
716        ScalarKind::Integer => matches!(value, DocScalar::Int(_)),
717        ScalarKind::Number => matches!(value, DocScalar::Int(_) | DocScalar::Float(_)),
718        ScalarKind::Date => {
719            matches!(value, DocScalar::Date(_))
720                || matches!(value, DocScalar::Str(s) if is_iso_date(s))
721        }
722        ScalarKind::Time => {
723            matches!(value, DocScalar::Time(_))
724                || matches!(value, DocScalar::Str(s) if is_iso_time(s))
725        }
726        // Deliberately excludes a bare date string -- see is_iso_datetime's
727        // doc comment for why "datetime" and "date" must stay disjoint,
728        // mirroring Python's `_is_iso(value, _dt.datetime) and not
729        // _is_iso(value, _dt.date)`.
730        ScalarKind::Datetime => {
731            matches!(value, DocScalar::Datetime(_))
732                || matches!(value, DocScalar::Str(s) if is_iso_datetime(s) && !is_iso_date(s))
733        }
734    }
735}
736
737/// The most specific scalar kind name a [`document::Scalar`] value matches,
738/// for error messages (`integer` is reported even though it also matches
739/// `number`).
740pub(crate) fn value_kind_name(v: &DocScalar) -> &'static str {
741    match v {
742        DocScalar::Null => "null",
743        DocScalar::Bool(_) => "boolean",
744        DocScalar::Int(_) => "integer",
745        DocScalar::Float(_) => "number",
746        DocScalar::Str(_) => "string",
747        DocScalar::Date(_) => "date",
748        DocScalar::Time(_) => "time",
749        DocScalar::Datetime(_) => "datetime",
750    }
751}
752
753// ---------------------------------------------------------------------------
754// Schema
755// ---------------------------------------------------------------------------
756
757/// A resolved field type: a record (via a `Ref`), a bare `Scalar`, or `Any`.
758pub enum Resolved<'a> {
759    /// A resolved record in the schema environment.
760    Record(&'a Record),
761    /// A resolved scalar type.
762    Scalar(Scalar),
763    /// A resolved `any` type.
764    Any,
765}
766
767/// A schema: a root reference plus an environment of named records.
768#[derive(Debug, Clone, PartialEq, Eq)]
769pub struct Schema {
770    root: Ref,
771    env: IndexMap<String, Record>,
772}
773
774impl Schema {
775    /// Builds a schema and immediately checks every `Ref` (the root's, and
776    /// every field's) resolves within `env` -- mirrors Python's
777    /// `Schema.__init__` calling `check_refs()` unconditionally. Also
778    /// enforces S-3 (omnist-spec docs/03-schema-model.md): no record may be
779    /// named after a scalar keyword or `any`, since a bare name in type
780    /// position always resolves to the builtin first and such a record could
781    /// never be referenced. `osd.rs`'s parser already enforces this; this is
782    /// the same check at the builder-API surface (omnist-rs#76).
783    ///
784    /// # Errors
785    ///
786    /// S-8 (spec v0.28.0-beta): a record name, or a `Ref` target name (the
787    /// root's or a field's), that is not `[A-Za-z_][A-Za-z0-9_]*` is
788    /// `schema.invalid-name` at the path `$`, the name in the message only.
789    /// S-22 (`schema.invalid-label`) cannot occur here: labels are `String`s
790    /// and cannot hold invalid UTF-8. A `max = 0` field is accepted (S-15); the
791    /// OSD writer refuses it ([`crate::osd::to_osd`]).
792    pub fn new(root: Ref, env: IndexMap<String, Record>) -> Result<Self, SchemaError> {
793        Self::check_names(&root, &env)?;
794        Self::check_reserved_names(&env)?;
795        let schema = Schema { root, env };
796        schema.check_refs()?;
797        Ok(schema)
798    }
799
800    /// S-8 (omnist-spec v0.28.0-beta): every record name and every `Ref`
801    /// target name (the root's and each field's) must match
802    /// `[A-Za-z_][A-Za-z0-9_]*`. A violation on this direct-construction route
803    /// is `schema.invalid-name` at `$`, the offending name in the message only
804    /// (it may be malformed, and a field path would carry a label).
805    fn check_names(root: &Ref, env: &IndexMap<String, Record>) -> Result<(), SchemaError> {
806        let refs = env.values().flat_map(|rec| {
807            rec.fields().iter().filter_map(|f| match &f.ty {
808                FieldType::Ref(r) => Some(&r.name),
809                _ => None,
810            })
811        });
812        let bad = std::iter::once(&root.name)
813            .chain(env.keys())
814            .chain(refs)
815            .find(|n| !is_valid_name(n));
816        match bad {
817            Some(name) => Err(SchemaError::new(
818                "$",
819                "schema.invalid-name",
820                format!("{name:?} is not a valid name; expected [A-Za-z_][A-Za-z0-9_]* (S-8)"),
821            )),
822            None => Ok(()),
823        }
824    }
825
826    fn check_reserved_names(env: &IndexMap<String, Record>) -> Result<(), SchemaError> {
827        for name in env.keys() {
828            if name == "any" {
829                return Err(SchemaError::new(
830                    "any",
831                    "schema.reserved-name",
832                    format!(
833                        "'any' is a reserved type name and cannot be used as a record name                          (record {name:?})"
834                    ),
835                ));
836            }
837            if ScalarKind::ALL.iter().any(|k| k.as_str() == name) {
838                return Err(SchemaError::new(
839                    name,
840                    "schema.reserved-name",
841                    format!(
842                        "{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)"
843                    ),
844                ));
845            }
846        }
847        Ok(())
848    }
849
850    /// Reference to the root record of the schema.
851    pub fn root(&self) -> &Ref {
852        &self.root
853    }
854
855    /// Map of named records comprising the schema environment.
856    pub fn env(&self) -> &IndexMap<String, Record> {
857        &self.env
858    }
859
860    fn check_refs(&self) -> Result<(), SchemaError> {
861        if !self.env.contains_key(&self.root.name) {
862            return Err(SchemaError::new(
863                "$",
864                "schema.unknown-type",
865                format!("unknown type {:?}", self.root.name),
866            ));
867        }
868        for (rec_name, rec) in &self.env {
869            for f in rec.fields() {
870                if let FieldType::Ref(r) = &f.ty
871                    && !self.env.contains_key(&r.name)
872                {
873                    return Err(SchemaError::new(
874                        format!("{rec_name}.{}", f.label),
875                        "schema.unknown-type",
876                        format!("unknown type {:?}", r.name),
877                    ));
878                }
879            }
880        }
881        Ok(())
882    }
883
884    /// A bare `Scalar` resolves to itself; a `Ref` is a single environment
885    /// lookup -- `check_refs` already guarantees every `Ref` resolves, so
886    /// this never errors once a `Schema` exists.
887    pub fn resolve(&self, ty: &FieldType) -> Resolved<'_> {
888        match ty {
889            FieldType::Scalar(s) => Resolved::Scalar(*s),
890            FieldType::Any => Resolved::Any,
891            FieldType::Ref(r) => Resolved::Record(
892                self.env
893                    .get(&r.name)
894                    .expect("check_refs guarantees every Ref resolves"),
895            ),
896        }
897    }
898
899    /// Validates `cursor` (and everything beneath it) against this schema's
900    /// root type, collecting every problem found rather than stopping at
901    /// the first.
902    pub fn validate(&self, cursor: &document::Cursor<'_>) -> ValidationResult {
903        let mut res = ValidationResult::new();
904        // `path` is the one path `String` this walk allocates up front (the
905        // root); every deeper edge reuses this same buffer (push a segment,
906        // recurse, truncate back) rather than allocating its own -- see
907        // issue #44. `res.add` still copies out a path when an error is
908        // actually recorded (`impl Into<String>` on a `&str` allocates), but
909        // that only happens for edges that actually produce a problem.
910        let mut path = cursor.path.clone();
911        self.conform(
912            cursor,
913            &FieldType::Ref(self.root.clone()),
914            &mut res,
915            &mut path,
916        );
917        res
918    }
919
920    /// Returns `true` iff `cursor` conforms to this schema with 0 errors (spec §5).
921    pub fn accepts(&self, cursor: &document::Cursor<'_>) -> bool {
922        self.validate(cursor).ok()
923    }
924
925    fn conform(
926        &self,
927        cursor: &document::Cursor<'_>,
928        ty: &FieldType,
929        res: &mut ValidationResult,
930        path: &mut String,
931    ) {
932        match self.resolve(ty) {
933            // `any` accepts every legal Document value unchecked -- there is
934            // nothing to conform against, mirroring Python's
935            // `_conform`: `if isinstance(d, AnyType): return`.
936            Resolved::Any => {}
937            Resolved::Scalar(s) => self.conform_scalar(cursor, s, res, path),
938            Resolved::Record(r) => self.conform_record(cursor, r, res, path),
939        }
940    }
941
942    fn conform_scalar(
943        &self,
944        cursor: &document::Cursor<'_>,
945        s: Scalar,
946        res: &mut ValidationResult,
947        path: &str,
948    ) {
949        if !cursor.is_leaf() {
950            res.add(
951                path,
952                format!("expected a {} value, got an object", s.kind().as_str()),
953                ErrorCode::ShapeMismatch,
954            );
955            return;
956        }
957        let v = cursor
958            .value()
959            .expect("is_leaf() true implies value() succeeds");
960        if matches!(v, DocScalar::Null) {
961            if !s.is_nullable() {
962                res.add(path, "null not allowed here", ErrorCode::NullNotAllowed);
963            }
964            return;
965        }
966        if !matches_kind(v, s.kind()) {
967            res.add(
968                path,
969                format!(
970                    "expected {}, got {} ({})",
971                    s.kind().as_str(),
972                    value_kind_name(v),
973                    v
974                ),
975                ErrorCode::TypeMismatch,
976            );
977        }
978    }
979
980    /// Walks every edge without building a path `String` for it up front
981    /// (issue #44): `path` is a single buffer shared across the whole
982    /// `validate` walk. Each edge pushes its own segment (`.label` or
983    /// `.label[i]`, via [`crate::report::push_child_path`]) onto `path`,
984    /// recurses/reports using that borrowed `&str`, then truncates `path`
985    /// back before moving to the next edge -- so a document with no
986    /// unexpected fields, cardinality problems, or type mismatches never
987    /// allocates a path `String` per edge, only the one `res.add` actually
988    /// needs to keep (via `impl Into<String>`) when a problem is found.
989    fn conform_record(
990        &self,
991        cursor: &document::Cursor<'_>,
992        rec: &Record,
993        res: &mut ValidationResult,
994        path: &mut String,
995    ) {
996        if cursor.is_leaf() {
997            res.add(
998                path.as_str(),
999                "expected an object, got a value",
1000                ErrorCode::ShapeMismatch,
1001            );
1002            return;
1003        }
1004        let edges = cursor
1005            .raw_edges()
1006            .expect("is_leaf() false implies raw_edges() succeeds");
1007        let mut counts: IndexMap<&str, usize> = IndexMap::new();
1008        for (label, index, child_id) in &edges {
1009            *counts.entry(*label).or_insert(0) += 1;
1010            let base = path.len();
1011            crate::report::push_child_path(path, label, *index);
1012            match rec.field(label) {
1013                None => res.add(
1014                    path.as_str(),
1015                    "unexpected field",
1016                    ErrorCode::UnexpectedField,
1017                ),
1018                Some(f) => {
1019                    let child = cursor.seek(*child_id);
1020                    self.conform(&child, &f.ty, res, path);
1021                }
1022            }
1023            path.truncate(base);
1024        }
1025        for f in rec.fields() {
1026            let c = counts.get(f.label.as_str()).copied().unwrap_or(0);
1027            if c < f.min || f.max.is_some_and(|max| c > max) {
1028                res.add(
1029                    path.as_str(),
1030                    format!(
1031                        "field {:?} occurs {} time(s), expected {}",
1032                        f.label,
1033                        c,
1034                        f.cardinality_str()
1035                    ),
1036                    ErrorCode::Cardinality,
1037                );
1038            }
1039        }
1040    }
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046    use crate::document::{Doc, Value};
1047    use indexmap::IndexMap as Map;
1048
1049    #[test]
1050    fn record_partial_eq_treats_field_order_as_insignificant_but_field_count_as_significant() {
1051        let r1 = Record::new(vec![
1052            Field::required("x", STRING).unwrap(),
1053            Field::required("y", STRING).unwrap(),
1054        ])
1055        .unwrap();
1056        let r2 = Record::new(vec![
1057            Field::required("y", STRING).unwrap(),
1058            Field::required("x", STRING).unwrap(),
1059        ])
1060        .unwrap();
1061        assert_eq!(r1, r2, "declaration order must not affect Record equality");
1062
1063        let r3 = Record::new(vec![Field::required("x", STRING).unwrap()]).unwrap();
1064        assert_ne!(
1065            r1, r3,
1066            "a different field count must never compare equal, regardless of order"
1067        );
1068    }
1069
1070    fn obj(pairs: &[(&str, Value)]) -> Value {
1071        let mut m = Map::new();
1072        for (k, v) in pairs {
1073            m.insert((*k).to_string(), v.clone());
1074        }
1075        Value::Object(m)
1076    }
1077
1078    // -- Scalar construction ---------------------------------------------
1079
1080    #[test]
1081    fn scalar_construction_and_equality() {
1082        let a = Scalar::new(ScalarKind::String, false);
1083        let b = Scalar::new(ScalarKind::String, false);
1084        assert_eq!(a, b);
1085        assert_ne!(a, Scalar::new(ScalarKind::String, true));
1086        assert_eq!(a, STRING);
1087    }
1088
1089    #[test]
1090    fn scalar_nullable_flag() {
1091        assert!(!STRING.is_nullable());
1092        let n = nullable(STRING);
1093        assert!(n.is_nullable());
1094        assert_eq!(n.kind(), ScalarKind::String);
1095    }
1096
1097    #[test]
1098    fn scalar_named_accepts_every_known_name() {
1099        for k in ScalarKind::ALL {
1100            assert_eq!(Scalar::named(k.as_str(), false).unwrap().kind(), k);
1101        }
1102    }
1103
1104    #[test]
1105    fn scalar_named_rejects_unknown_name() {
1106        let err = Scalar::named("bogus", false).unwrap_err();
1107        assert!(err.to_string().contains("unknown scalar"));
1108        assert!(err.to_string().contains("bogus"));
1109    }
1110
1111    #[test]
1112    fn scalar_display_shows_nullable_suffix() {
1113        assert_eq!(STRING.to_string(), "string");
1114        assert_eq!(nullable(STRING).to_string(), "string?");
1115    }
1116
1117    // -- Field cardinality --------------------------------------------------
1118
1119    #[test]
1120    fn field_rejects_max_less_than_min() {
1121        let err = Field::new("a", STRING, 2, Some(1)).unwrap_err();
1122        assert!(err.to_string().contains("invalid cardinality"));
1123        assert!(err.to_string().contains("[2,1]"));
1124    }
1125
1126    #[test]
1127    fn field_accepts_max_equal_to_min_and_unbounded_max() {
1128        assert!(Field::new("a", STRING, 1, Some(1)).is_ok());
1129        assert!(Field::new("a", STRING, 0, None).is_ok());
1130    }
1131
1132    #[test]
1133    fn field_cardinality_str_matches_python_phrasing() {
1134        assert_eq!(
1135            Field::new("a", STRING, 1, Some(1))
1136                .unwrap()
1137                .cardinality_str(),
1138            "exactly 1"
1139        );
1140        assert_eq!(
1141            Field::new("a", STRING, 0, Some(1))
1142                .unwrap()
1143                .cardinality_str(),
1144            "0 or 1"
1145        );
1146        assert_eq!(
1147            Field::new("a", STRING, 1, None).unwrap().cardinality_str(),
1148            "at least 1"
1149        );
1150        assert_eq!(
1151            Field::new("a", STRING, 2, Some(5))
1152                .unwrap()
1153                .cardinality_str(),
1154            "between 2 and 5"
1155        );
1156    }
1157
1158    // -- Record: duplicate field labels -------------------------------------
1159
1160    #[test]
1161    fn record_rejects_duplicate_field_label() {
1162        let a1 = Field::required("a", STRING).unwrap();
1163        let a2 = Field::required("a", INTEGER).unwrap();
1164        let err = Record::new(vec![a1, a2]).unwrap_err();
1165        assert!(err.to_string().contains("duplicate field label"));
1166        assert!(err.to_string().contains("\"a\""));
1167    }
1168
1169    #[test]
1170    fn record_field_lookup_and_ordering() {
1171        let f_b = Field::required("b", STRING).unwrap();
1172        let f_a = Field::required("a", INTEGER).unwrap();
1173        let rec = Record::new(vec![f_b.clone(), f_a.clone()]).unwrap();
1174        assert_eq!(rec.fields(), &[f_b, f_a]);
1175        assert_eq!(rec.field("a").unwrap().label, "a");
1176        assert!(rec.field("missing").is_none());
1177    }
1178
1179    // -- Schema: unknown Ref target -----------------------------------------
1180
1181    #[test]
1182    fn schema_rejects_unknown_root_ref() {
1183        let err = Schema::new(Ref::new("Missing"), Map::new()).unwrap_err();
1184        assert!(err.to_string().contains("unknown type"));
1185        assert!(err.to_string().contains("Missing"));
1186    }
1187
1188    #[test]
1189    fn schema_rejects_unknown_field_ref() {
1190        let mut env = Map::new();
1191        env.insert(
1192            "Root".to_string(),
1193            Record::new(vec![Field::required("x", Ref::new("Missing")).unwrap()]).unwrap(),
1194        );
1195        let err = Schema::new(Ref::new("Root"), env).unwrap_err();
1196        assert!(err.to_string().contains("unknown type"));
1197        assert!(err.to_string().contains("Missing"));
1198    }
1199
1200    #[test]
1201    fn schema_rejects_a_record_named_after_a_scalar_keyword() {
1202        // S-3 (omnist-spec docs/03-schema-model.md): a record named "string"
1203        // could never be referenced, since a bare name in type position
1204        // always resolves to the builtin scalar first. omnist-rs#76.
1205        let mut env = Map::new();
1206        env.insert(
1207            "Root".to_string(),
1208            Record::new(vec![Field::required("x", STRING).unwrap()]).unwrap(),
1209        );
1210        env.insert("string".to_string(), Record::new(vec![]).unwrap());
1211        let err = Schema::new(Ref::new("Root"), env).unwrap_err();
1212        assert!(err.to_string().contains("reserved scalar name"));
1213        assert!(err.to_string().contains("\"string\""));
1214    }
1215
1216    #[test]
1217    fn schema_rejects_a_record_named_any() {
1218        // S-3, the `any` half of the same constraint. omnist-rs#76.
1219        let mut env = Map::new();
1220        env.insert(
1221            "Root".to_string(),
1222            Record::new(vec![Field::required("x", STRING).unwrap()]).unwrap(),
1223        );
1224        env.insert("any".to_string(), Record::new(vec![]).unwrap());
1225        let err = Schema::new(Ref::new("Root"), env).unwrap_err();
1226        assert!(err.to_string().contains("reserved type name"));
1227    }
1228
1229    #[test]
1230    fn schema_accepts_a_valid_self_referential_environment() {
1231        let mut env = Map::new();
1232        env.insert(
1233            "Node".to_string(),
1234            Record::new(vec![
1235                Field::required("value", STRING).unwrap(),
1236                Field::new("child", Ref::new("Node"), 0, Some(1)).unwrap(),
1237            ])
1238            .unwrap(),
1239        );
1240        assert!(Schema::new(Ref::new("Node"), env).is_ok());
1241    }
1242
1243    fn service_schema() -> Schema {
1244        let mut env = Map::new();
1245        env.insert(
1246            "Database".to_string(),
1247            Record::new(vec![
1248                Field::required("type", STRING).unwrap(),
1249                Field::required("server", STRING).unwrap(),
1250                Field::required("port", INTEGER).unwrap(),
1251            ])
1252            .unwrap(),
1253        );
1254        env.insert(
1255            "Service".to_string(),
1256            Record::new(vec![
1257                Field::required("host", STRING).unwrap(),
1258                Field::required("port", INTEGER).unwrap(),
1259                Field::new("databases", Ref::new("Database"), 1, None).unwrap(),
1260                Field::new("tags", STRING, 0, None).unwrap(),
1261            ])
1262            .unwrap(),
1263        );
1264        Schema::new(Ref::new("Service"), env).unwrap()
1265    }
1266
1267    fn valid_service_doc() -> Value {
1268        obj(&[
1269            ("host", Value::Str("api.internal".into())),
1270            ("port", Value::Int((8443).into())),
1271            (
1272                "databases",
1273                Value::Array(vec![obj(&[
1274                    ("type", Value::Str("prod".into())),
1275                    ("server", Value::Str("db1".into())),
1276                    ("port", Value::Int((5432).into())),
1277                ])]),
1278            ),
1279            (
1280                "tags",
1281                Value::Array(vec![
1282                    Value::Str("prod".into()),
1283                    Value::Str("us-east".into()),
1284                ]),
1285            ),
1286        ])
1287    }
1288
1289    // -- cardinality semantics (worked example from model.md's appendix) ---
1290
1291    #[test]
1292    fn cardinality_semantics_worked_example() {
1293        let schema = service_schema();
1294        let doc = Doc::of(&valid_service_doc()).unwrap();
1295        let res = schema.validate(&doc.root());
1296        assert!(res.ok(), "{res}");
1297    }
1298
1299    #[test]
1300    fn cardinality_rejects_too_few_databases() {
1301        let schema = service_schema();
1302        let v = obj(&[
1303            ("host", Value::Str("h".into())),
1304            ("port", Value::Int((1).into())),
1305        ]);
1306        let doc = Doc::of(&v).unwrap();
1307        let res = schema.validate(&doc.root());
1308        assert!(!res.ok());
1309        assert!(
1310            res.errors()
1311                .iter()
1312                .any(|e| e.code == ErrorCode::Cardinality && e.message.contains("\"databases\""))
1313        );
1314    }
1315
1316    #[test]
1317    fn cardinality_ignores_order() {
1318        // Same fields, different declaration/edge order -- must still
1319        // validate (per model.md §7: "order ignored").
1320        let schema = service_schema();
1321        let v = obj(&[
1322            (
1323                "databases",
1324                Value::Array(vec![obj(&[
1325                    ("port", Value::Int((1).into())),
1326                    ("type", Value::Str("t".into())),
1327                    ("server", Value::Str("s".into())),
1328                ])]),
1329            ),
1330            ("port", Value::Int((8443).into())),
1331            ("host", Value::Str("h".into())),
1332        ]);
1333        let doc = Doc::of(&v).unwrap();
1334        assert!(schema.accepts(&doc.root()));
1335    }
1336
1337    // -- unexpected field / closedness --------------------------------------
1338
1339    #[test]
1340    fn unexpected_field_is_rejected() {
1341        let schema = service_schema();
1342        let mut v = valid_service_doc();
1343        if let Value::Object(m) = &mut v {
1344            m.insert("extra".to_string(), Value::Int((1).into()));
1345        }
1346        let doc = Doc::of(&v).unwrap();
1347        let res = schema.validate(&doc.root());
1348        assert!(!res.ok());
1349        assert!(
1350            res.errors()
1351                .iter()
1352                .any(|e| e.code == ErrorCode::UnexpectedField && e.path.contains("extra"))
1353        );
1354    }
1355
1356    // -- shape mismatch / type mismatch / null handling ---------------------
1357
1358    #[test]
1359    fn scalar_expected_but_object_found_is_shape_mismatch() {
1360        let schema = service_schema();
1361        let v = obj(&[
1362            ("host", obj(&[])),
1363            ("port", Value::Int((1).into())),
1364            (
1365                "databases",
1366                Value::Array(vec![obj(&[
1367                    ("type", Value::Str("t".into())),
1368                    ("server", Value::Str("s".into())),
1369                    ("port", Value::Int((1).into())),
1370                ])]),
1371            ),
1372        ]);
1373        let doc = Doc::of(&v).unwrap();
1374        let res = schema.validate(&doc.root());
1375        assert!(
1376            res.errors()
1377                .iter()
1378                .any(|e| e.code == ErrorCode::ShapeMismatch)
1379        );
1380    }
1381
1382    #[test]
1383    fn record_expected_but_scalar_found_is_shape_mismatch() {
1384        let mut env = Map::new();
1385        env.insert("Root".to_string(), Record::new(vec![]).unwrap());
1386        let schema = Schema::new(Ref::new("Root"), env).unwrap();
1387        let doc = Doc::of(&Value::Int((1).into())).unwrap();
1388        let res = schema.validate(&doc.root());
1389        assert!(!res.ok());
1390        assert_eq!(res.errors()[0].code, ErrorCode::ShapeMismatch);
1391    }
1392
1393    #[test]
1394    fn type_mismatch_reports_expected_and_actual() {
1395        let schema = service_schema();
1396        let mut v = valid_service_doc();
1397        if let Value::Object(m) = &mut v {
1398            m.insert("port".to_string(), Value::Str("not a number".into()));
1399        }
1400        let doc = Doc::of(&v).unwrap();
1401        let res = schema.validate(&doc.root());
1402        let e = res
1403            .errors()
1404            .iter()
1405            .find(|e| e.code == ErrorCode::TypeMismatch)
1406            .unwrap();
1407        assert!(e.message.contains("expected integer"));
1408        assert!(e.message.contains("got string"));
1409    }
1410
1411    #[test]
1412    fn null_rejected_for_non_nullable_scalar_but_accepted_when_nullable() {
1413        let mut env = Map::new();
1414        env.insert(
1415            "Root".to_string(),
1416            Record::new(vec![Field::required("v", STRING).unwrap()]).unwrap(),
1417        );
1418        let schema = Schema::new(Ref::new("Root"), env).unwrap();
1419        let doc = Doc::of(&obj(&[("v", Value::Null)])).unwrap();
1420        let res = schema.validate(&doc.root());
1421        assert!(!res.ok());
1422        assert_eq!(res.errors()[0].code, ErrorCode::NullNotAllowed);
1423
1424        let mut env2 = Map::new();
1425        env2.insert(
1426            "Root".to_string(),
1427            Record::new(vec![Field::required("v", nullable(STRING)).unwrap()]).unwrap(),
1428        );
1429        let schema2 = Schema::new(Ref::new("Root"), env2).unwrap();
1430        let doc2 = Doc::of(&obj(&[("v", Value::Null)])).unwrap();
1431        assert!(schema2.accepts(&doc2.root()));
1432    }
1433
1434    #[test]
1435    fn accepts_and_validation_result_display() {
1436        let schema = service_schema();
1437        let doc = Doc::of(&valid_service_doc()).unwrap();
1438        assert!(schema.accepts(&doc.root()));
1439        assert_eq!(schema.validate(&doc.root()).to_string(), "valid");
1440
1441        let bad = Doc::of(&obj(&[])).unwrap();
1442        let res = schema.validate(&bad.root());
1443        assert!(!res.ok());
1444        let s = res.to_string();
1445        assert!(s.starts_with("invalid:\n  at "));
1446    }
1447
1448    // -- matches_kind: bool must not satisfy integer/number -----------------
1449
1450    #[test]
1451    fn bool_never_satisfies_integer_or_number() {
1452        assert!(!matches_kind(&DocScalar::Bool(true), ScalarKind::Integer));
1453        assert!(!matches_kind(&DocScalar::Bool(true), ScalarKind::Number));
1454        assert!(matches_kind(&DocScalar::Bool(true), ScalarKind::Boolean));
1455    }
1456
1457    #[test]
1458    fn integer_satisfies_number_but_not_vice_versa() {
1459        assert!(matches_kind(
1460            &DocScalar::Int((3).into()),
1461            ScalarKind::Number
1462        ));
1463        assert!(!matches_kind(&DocScalar::Float(3.0), ScalarKind::Integer));
1464    }
1465
1466    // -- temporal shape-check: date -----------------------------------------
1467
1468    #[test]
1469    fn is_iso_date_accepts_valid_dates() {
1470        assert!(is_iso_date("2024-01-01"));
1471        assert!(is_iso_date("9999-12-31"));
1472    }
1473
1474    #[test]
1475    fn is_iso_date_rejects_wrong_shape_and_invalid_calendar_dates() {
1476        // Wrong shape: fromisoformat-is-wider cases this crate deliberately
1477        // does NOT accept (basic format, single-digit month/day).
1478        assert!(!is_iso_date("20240101"));
1479        assert!(!is_iso_date("2024-1-1"));
1480        assert!(!is_iso_date("2024-W01-1"));
1481        assert!(!is_iso_date("not-a-date"));
1482        // Right shape, invalid calendar date.
1483        assert!(!is_iso_date("2024-13-01"));
1484        assert!(!is_iso_date("2024-02-30"));
1485        assert!(!is_iso_date("2024-00-01"));
1486        assert!(!is_iso_date("2024-01-00"));
1487        assert!(!is_iso_date("0000-01-01"));
1488    }
1489
1490    #[test]
1491    fn is_iso_date_thirty_day_months() {
1492        assert!(is_iso_date("2024-04-30"));
1493        assert!(!is_iso_date("2024-04-31"));
1494        assert!(is_iso_date("2024-06-30"));
1495        assert!(is_iso_date("2024-09-30"));
1496        assert!(is_iso_date("2024-11-30"));
1497    }
1498
1499    #[test]
1500    fn days_in_month_rejects_an_out_of_range_month_directly() {
1501        // White-box: `days_in_month`'s `_ => 0` arm is unreachable through
1502        // `valid_ymd` (which only calls it after `(1..=12).contains(&m)`
1503        // already passed) -- call the private helper directly to prove the
1504        // arm itself is correct, rather than leaving it untested.
1505        assert_eq!(days_in_month(2024, 13), 0);
1506        assert_eq!(days_in_month(2024, 0), 0);
1507    }
1508
1509    #[test]
1510    fn is_iso_date_leap_year_boundary() {
1511        assert!(is_iso_date("2024-02-29")); // 2024 is a leap year
1512        assert!(!is_iso_date("2023-02-29")); // 2023 is not
1513        assert!(is_iso_date("2000-02-29")); // divisible by 400
1514        assert!(!is_iso_date("1900-02-29")); // divisible by 100, not 400
1515    }
1516
1517    // -- temporal shape-check: time ------------------------------------------
1518
1519    #[test]
1520    fn is_iso_time_accepts_valid_times() {
1521        assert!(is_iso_time("12:00:00"));
1522        assert!(is_iso_time("12:00"));
1523        assert!(is_iso_time("12:00:00.5"));
1524        assert!(is_iso_time("12:00:00.123456"));
1525        assert!(is_iso_time("12:00:00+02:00"));
1526        assert!(is_iso_time("23:59:59"));
1527    }
1528
1529    #[test]
1530    fn is_iso_time_rejects_out_of_range_and_malformed() {
1531        assert!(!is_iso_time("25:00:00"));
1532        assert!(!is_iso_time("12:60:00"));
1533        assert!(!is_iso_time("24:00:00"));
1534        assert!(!is_iso_time("12:00:00+24:00"));
1535        assert!(!is_iso_time("12:00:00+99:99"));
1536        assert!(!is_iso_time("12:00:00.1234567")); // 7 fractional digits
1537        assert!(!is_iso_time("1:00:00")); // not zero-padded
1538        assert!(is_iso_time("12:00:00+23:59"));
1539    }
1540
1541    // -- temporal shape-check: datetime, and the `_is_iso` vs
1542    //    `fromisoformat`-is-wider / date-vs-datetime exclusivity ------------
1543
1544    #[test]
1545    fn is_iso_datetime_accepts_valid_timestamps() {
1546        assert!(is_iso_datetime("2024-01-01T12:00:00"));
1547        assert!(is_iso_datetime("2024-01-01T12:00"));
1548        assert!(is_iso_datetime("2024-01-01T12:00:00+02:00"));
1549        assert!(is_iso_datetime("2024-01-01T12:00:00.123456"));
1550    }
1551
1552    #[test]
1553    fn is_iso_datetime_rejects_bare_date_and_invalid_components() {
1554        assert!(!is_iso_datetime("2024-01-01"));
1555        assert!(!is_iso_datetime("2024-01-01T25:00:00"));
1556        assert!(!is_iso_datetime("2024-13-01T12:00:00"));
1557    }
1558
1559    #[test]
1560    fn matches_kind_datetime_excludes_bare_date_string_and_a_real_date() {
1561        // The exact _is_iso-vs-fromisoformat subtlety, verified against
1562        // Python's real `matches_kind` (issue #105): a bare date-only
1563        // string satisfies `Date` but not `Datetime` (`fromisoformat` on
1564        // `datetime` would succeed, defaulting the missing time to
1565        // midnight -- not the same value as "no time given"). The same
1566        // disjointness holds for the real, non-string variants: a genuine
1567        // `Scalar::Date` never matches `Datetime` and vice versa.
1568        let v = DocScalar::Str("2024-01-01".to_string());
1569        assert!(matches_kind(&v, ScalarKind::Date));
1570        assert!(!matches_kind(&v, ScalarKind::Datetime));
1571
1572        let dt = DocScalar::Str("2024-01-01T00:00:00".to_string());
1573        assert!(matches_kind(&dt, ScalarKind::Datetime));
1574        assert!(!matches_kind(&dt, ScalarKind::Date));
1575
1576        let d_real = DocScalar::Date("2024-01-01".to_string());
1577        assert!(matches_kind(&d_real, ScalarKind::Date));
1578        assert!(!matches_kind(&d_real, ScalarKind::Datetime));
1579
1580        let dt_real = DocScalar::Datetime("2024-01-01T00:00:00".to_string());
1581        assert!(matches_kind(&dt_real, ScalarKind::Datetime));
1582        assert!(!matches_kind(&dt_real, ScalarKind::Date));
1583    }
1584
1585    #[test]
1586    fn matches_kind_date_and_time_directly() {
1587        // Both the real variant and a shape-matching plain string satisfy
1588        // Date/Time (verified against Python's real `matches_kind`, issue
1589        // #105 -- Python's own hybrid check, not just this port's).
1590        assert!(matches_kind(
1591            &DocScalar::Date("2024-01-01".into()),
1592            ScalarKind::Date
1593        ));
1594        assert!(matches_kind(
1595            &DocScalar::Str("2024-01-01".into()),
1596            ScalarKind::Date
1597        ));
1598        assert!(!matches_kind(
1599            &DocScalar::Str("not-a-date".into()),
1600            ScalarKind::Date
1601        ));
1602        assert!(matches_kind(
1603            &DocScalar::Time("12:00:00".into()),
1604            ScalarKind::Time
1605        ));
1606        assert!(matches_kind(
1607            &DocScalar::Str("12:00:00".into()),
1608            ScalarKind::Time
1609        ));
1610        assert!(!matches_kind(
1611            &DocScalar::Str("25:00:00".into()),
1612            ScalarKind::Time
1613        ));
1614        assert!(!matches_kind(&DocScalar::Int((1).into()), ScalarKind::Date));
1615        assert!(!matches_kind(&DocScalar::Int((1).into()), ScalarKind::Time));
1616    }
1617
1618    #[test]
1619    fn schema_root_and_env_accessors() {
1620        let schema = service_schema();
1621        assert_eq!(schema.root().name, "Service");
1622        assert!(schema.env().contains_key("Database"));
1623        assert!(schema.env().contains_key("Service"));
1624    }
1625
1626    // -- FieldType From conversions / Ref, Display --------------------------
1627
1628    // -- `any` field type: accepts every legal value unchecked ------------
1629
1630    #[test]
1631    fn any_field_accepts_scalars_and_objects_unchecked() {
1632        let mut env = Map::new();
1633        env.insert(
1634            "Root".to_string(),
1635            Record::new(vec![Field::required("x", FieldType::Any).unwrap()]).unwrap(),
1636        );
1637        let schema = Schema::new(Ref::new("Root"), env).unwrap();
1638
1639        // Not an array here: cardinality (how many times a label occurs) is
1640        // checked independently of the field's type, `any` included -- an
1641        // array under a `[1,1]` field is still a cardinality violation, not
1642        // an `any`-typed acceptance question. See the array/cardinality
1643        // case in a separate `[0,]`-cardinality field below.
1644        for v in [
1645            Value::Str("hi".into()),
1646            Value::Int((1).into()),
1647            Value::Float(1.5),
1648            Value::Bool(true),
1649            Value::Null,
1650            obj(&[("nested", Value::Int((1).into()))]),
1651        ] {
1652            let doc = Doc::of(&obj(&[("x", v.clone())])).unwrap();
1653            assert!(schema.accepts(&doc.root()), "any field rejected {v:?}");
1654        }
1655    }
1656
1657    #[test]
1658    fn any_field_with_array_cardinality_accepts_repeated_values_unchecked() {
1659        let mut env = Map::new();
1660        env.insert(
1661            "Root".to_string(),
1662            Record::new(vec![Field::new("x", FieldType::Any, 0, None).unwrap()]).unwrap(),
1663        );
1664        let schema = Schema::new(Ref::new("Root"), env).unwrap();
1665        let doc = Doc::of(&obj(&[(
1666            "x",
1667            Value::Array(vec![Value::Int((1).into()), Value::Str("mixed".into())]),
1668        )]))
1669        .unwrap();
1670        assert!(schema.accepts(&doc.root()));
1671    }
1672
1673    #[test]
1674    fn any_resolves_without_touching_env() {
1675        let env: Map<String, Record> = Map::new();
1676        let schema = Schema::new(Ref::new("Root"), {
1677            let mut e = env;
1678            e.insert("Root".to_string(), Record::new(vec![]).unwrap());
1679            e
1680        })
1681        .unwrap();
1682        assert!(matches!(schema.resolve(&FieldType::Any), Resolved::Any));
1683    }
1684
1685    #[test]
1686    fn field_type_from_conversions() {
1687        let ft: FieldType = STRING.into();
1688        assert_eq!(ft, FieldType::Scalar(STRING));
1689        let ft2: FieldType = Ref::new("X").into();
1690        assert_eq!(ft2, FieldType::Ref(Ref::new("X")));
1691    }
1692
1693    #[test]
1694    fn ref_display() {
1695        assert_eq!(Ref::new("Foo").to_string(), "ref(Foo)");
1696    }
1697
1698    #[test]
1699    fn error_code_as_str_covers_every_variant() {
1700        assert_eq!(
1701            ErrorCode::UnexpectedField.as_str(ErrorFamily::Validate),
1702            "validate.unexpected-field"
1703        );
1704        assert_eq!(
1705            ErrorCode::UnexpectedField.as_str(ErrorFamily::Materialize),
1706            "validate.unexpected-field"
1707        );
1708        assert_eq!(
1709            ErrorCode::Cardinality.as_str(ErrorFamily::Validate),
1710            "validate.cardinality"
1711        );
1712        assert_eq!(
1713            ErrorCode::Cardinality.as_str(ErrorFamily::Materialize),
1714            "validate.cardinality"
1715        );
1716        assert_eq!(
1717            ErrorCode::TypeMismatch.as_str(ErrorFamily::Validate),
1718            "validate.type-mismatch"
1719        );
1720        assert_eq!(
1721            ErrorCode::TypeMismatch.as_str(ErrorFamily::Materialize),
1722            "materialize.inexact-conversion"
1723        );
1724        assert_eq!(
1725            ErrorCode::NullNotAllowed.as_str(ErrorFamily::Validate),
1726            "validate.null-not-allowed"
1727        );
1728        assert_eq!(
1729            ErrorCode::NullNotAllowed.as_str(ErrorFamily::Materialize),
1730            "validate.null-not-allowed"
1731        );
1732        assert_eq!(
1733            ErrorCode::ShapeMismatch.as_str(ErrorFamily::Validate),
1734            "validate.shape-mismatch"
1735        );
1736        assert_eq!(
1737            ErrorCode::ShapeMismatch.as_str(ErrorFamily::Materialize),
1738            "validate.shape-mismatch"
1739        );
1740    }
1741
1742    #[test]
1743    fn value_kind_name_covers_every_variant() {
1744        assert_eq!(value_kind_name(&DocScalar::Null), "null");
1745        assert_eq!(value_kind_name(&DocScalar::Bool(true)), "boolean");
1746        assert_eq!(value_kind_name(&DocScalar::Int((1).into())), "integer");
1747        assert_eq!(value_kind_name(&DocScalar::Float(1.0)), "number");
1748        assert_eq!(value_kind_name(&DocScalar::Str("x".into())), "string");
1749        assert_eq!(
1750            value_kind_name(&DocScalar::Date("2024-01-01".into())),
1751            "date"
1752        );
1753        assert_eq!(value_kind_name(&DocScalar::Time("12:00:00".into())), "time");
1754        assert_eq!(
1755            value_kind_name(&DocScalar::Datetime("2024-01-01T12:00:00".into())),
1756            "datetime"
1757        );
1758    }
1759}