Skip to main content

markdown_contract/
schema.rs

1//! The compiled schema runtime: the engine's stand-in for the Zod schemas the TS engine
2//! builds from the `mcVersion: 2` JSON Schema subset.
3//!
4//! A [`Schema`] validates one YAML-derived value (a frontmatter field, a table cell, a
5//! list item) and reports [`Issue`]s whose [`IssueKind`]s mirror the Zod issue codes the
6//! TS content plane maps to finding ids. The named [`StringFormat`]s replicate zod v4's
7//! validators (`zod/v4/core/regexes.js`): regexes ported where the `regex` crate allows,
8//! lookaheads hand-coded where it does not, and JS's ASCII `\d` written as `[0-9]`.
9//!
10//! Values are `serde_json::Value`s. A missing key (JS `undefined`) is `None`, distinct
11//! from JSON `null`.
12//!
13//! The YAML-to-[`Schema`] compiler is `declarative/schema_v2.rs`; this module is the
14//! runtime only.
15
16use std::sync::OnceLock;
17
18use regex::Regex;
19
20use crate::js;
21
22// ── Path segments & issues (the Zod issue subset the planes consume) ─────────────────
23
24/// One segment of an issue path — a mapping key or a sequence index.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub(crate) enum PathSeg {
27    Key(String),
28    Index(usize),
29}
30
31impl std::fmt::Display for PathSeg {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            PathSeg::Key(k) => f.write_str(k),
35            PathSeg::Index(i) => write!(f, "[{i}]"),
36        }
37    }
38}
39
40/// Render a path as a readable key reference: `[]` → `""`, `[a, b]` → `a.b`,
41/// `[related, 0]` → `related[0]` — the TS `formatKeyPath`.
42pub(crate) fn format_key_path(path: &[PathSeg]) -> String {
43    let mut s = String::new();
44    for seg in path {
45        match seg {
46            PathSeg::Index(i) => s.push_str(&format!("[{i}]")),
47            PathSeg::Key(k) => {
48                if !s.is_empty() {
49                    s.push('.');
50                }
51                s.push_str(k);
52            }
53        }
54    }
55    s
56}
57
58/// The Zod issue codes the TS content plane maps to finding ids (`frontmatterIdFor`).
59#[derive(Debug, Clone, PartialEq)]
60pub(crate) enum IssueKind {
61    /// Wrong JS type, including a missing required key (received `undefined`).
62    InvalidType { expected: &'static str },
63    /// An enum or const mismatch; `values` is the allowed set.
64    InvalidValue { values: Vec<String> },
65    /// A string, number or array below its minimum.
66    TooSmall,
67    /// A string, number or array above its maximum.
68    TooBig,
69    /// A `pattern` (`format: "regex"`) or named-format violation.
70    InvalidFormat { format: String },
71    /// A strict object's unknown keys, all in one issue.
72    UnrecognizedKeys { keys: Vec<String> },
73}
74
75/// One validation issue: where (`path`) and what (`kind`).
76#[derive(Debug, Clone, PartialEq)]
77pub(crate) struct Issue {
78    pub(crate) path: Vec<PathSeg>,
79    pub(crate) kind: IssueKind,
80}
81
82// ── The schema model ──────────────────────────────────────────────────────────────────
83
84/// A literal value for `const` (YAML scalars only).
85#[derive(Debug, Clone, PartialEq)]
86#[allow(missing_docs)]
87pub enum ConstValue {
88    String(String),
89    Number(f64),
90    Bool(bool),
91    Null,
92}
93
94impl ConstValue {
95    fn matches(&self, v: &serde_json::Value) -> bool {
96        match (self, v) {
97            (ConstValue::String(s), serde_json::Value::String(x)) => s == x,
98            (ConstValue::Number(n), serde_json::Value::Number(x)) => {
99                x.as_f64().is_some_and(|f| f == *n)
100            }
101            (ConstValue::Bool(b), serde_json::Value::Bool(x)) => b == x,
102            (ConstValue::Null, serde_json::Value::Null) => true,
103            _ => false,
104        }
105    }
106
107    /// The value as it reads in a message or `values` list.
108    pub(crate) fn render(&self) -> String {
109        match self {
110            ConstValue::String(s) => s.clone(),
111            ConstValue::Number(n) => js::num(*n),
112            ConstValue::Bool(b) => b.to_string(),
113            ConstValue::Null => "null".into(),
114        }
115    }
116}
117
118/// One compiled schema node. Bounds are `f64` because JSON Schema bounds are numbers.
119#[derive(Debug, Clone)]
120pub enum Schema {
121    /// `type: string` with optional `minLength` / `maxLength` (UTF-16 length) and
122    /// `pattern`.
123    String {
124        /// the shortest length
125        min: Option<f64>,
126        /// the longest length
127        max: Option<f64>,
128        /// a regular expression the string must match
129        pattern: Option<Regex>,
130    },
131    /// `type: string` with `format: <name>`, a named zod string format. The format
132    /// composes with `minLength` / `maxLength` / `pattern`, as the chained
133    /// `z.email().min(…).regex(…)` does.
134    Format {
135        /// the named format
136        format: StringFormat,
137        /// the shortest length
138        min: Option<f64>,
139        /// the longest length
140        max: Option<f64>,
141        /// a regular expression the string must match
142        pattern: Option<Regex>,
143    },
144    /// `type: number`, or `type: integer` (`int: true`), with optional `minimum` /
145    /// `maximum`.
146    Number {
147        /// whether only integers are allowed
148        int: bool,
149        /// the smallest value
150        min: Option<f64>,
151        /// the largest value
152        max: Option<f64>,
153    },
154    /// `type: boolean`
155    Boolean,
156    /// `type: array` with an `items` schema (`of`) and optional `minItems` / `maxItems`.
157    Array {
158        /// the schema for each element
159        of: Box<Schema>,
160        /// the fewest elements
161        min: Option<f64>,
162        /// the most elements
163        max: Option<f64>,
164    },
165    /// `type: object`: `properties` in declaration order; `strict`
166    /// (`additionalProperties: false`) rejects unknown keys.
167    Object {
168        /// the fields, in declaration order
169        fields: Vec<(String, Schema)>,
170        /// whether unknown keys are rejected
171        strict: bool,
172    },
173    /// `enum: [...]`: a closed set of strings.
174    Enum(Vec<String>),
175    /// `const: <scalar>`: an exact literal.
176    Const(ConstValue),
177    /// `type: [T, "null"]`: also admit `null`.
178    Nullable(Box<Schema>),
179    /// `default: <value>`: a missing value passes (the default substitutes).
180    Default(Box<Schema>),
181    /// A property not listed in `required`: a missing value passes.
182    Optional(Box<Schema>),
183    /// `description:`, carried for `Finding.hint`. Validation ignores it.
184    Described {
185        /// the wrapped schema
186        inner: Box<Schema>,
187        /// the description text
188        description: String,
189    },
190}
191
192impl Schema {
193    /// Validate `value` (`None` is a missing key), collecting every issue, as Zod's
194    /// `safeParse` does.
195    pub(crate) fn safe_parse(&self, value: Option<&serde_json::Value>) -> Result<(), Vec<Issue>> {
196        let mut issues = Vec::new();
197        check(self, value, &mut Vec::new(), &mut issues);
198        if issues.is_empty() {
199            Ok(())
200        } else {
201            Err(issues)
202        }
203    }
204}
205
206// ── Description resolution (v2 `description:` → `Finding.hint`) ─────────────
207
208/// Unwrap the annotation / value wrappers down to the structural node, recording the
209/// deepest `description` passed on the way into `best`.
210fn unwrap_describing<'a>(mut schema: &'a Schema, best: &mut Option<&'a str>) -> &'a Schema {
211    loop {
212        match schema {
213            Schema::Described { inner, description } => {
214                *best = Some(description);
215                schema = inner;
216            }
217            Schema::Optional(inner) | Schema::Default(inner) | Schema::Nullable(inner) => {
218                schema = inner;
219            }
220            other => return other,
221        }
222    }
223}
224
225/// The nearest enclosing `description` for an issue at `path`: walk the compiled schema
226/// from the root along the path, and return the DEEPEST description seen (the failing
227/// field's own, else its closest ancestor's). A path segment that does not resolve (an
228/// unknown key on a strict object) stops the walk and keeps what was collected.
229pub(crate) fn description_along_path<'a>(schema: &'a Schema, path: &[PathSeg]) -> Option<&'a str> {
230    let mut best = None;
231    let mut node = unwrap_describing(schema, &mut best);
232    for seg in path {
233        let next = match (node, seg) {
234            (Schema::Object { fields, .. }, PathSeg::Key(k)) => {
235                match fields.iter().find(|(key, _)| key == k) {
236                    Some((_, field)) => field,
237                    None => return best,
238                }
239            }
240            (Schema::Array { of, .. }, PathSeg::Index(_)) => of,
241            _ => return best,
242        };
243        node = unwrap_describing(next, &mut best);
244    }
245    best
246}
247
248/// The JS `typeof`-style type name of a value (distinguishing null and array from
249/// object) — the TS `typeName`, with `None` as `undefined`.
250pub(crate) fn type_name(v: Option<&serde_json::Value>) -> &'static str {
251    match v {
252        None => "undefined",
253        Some(serde_json::Value::Null) => "null",
254        Some(serde_json::Value::Bool(_)) => "boolean",
255        Some(serde_json::Value::Number(_)) => "number",
256        Some(serde_json::Value::String(_)) => "string",
257        Some(serde_json::Value::Array(_)) => "array",
258        Some(serde_json::Value::Object(_)) => "object",
259    }
260}
261
262/// The UTF-16 length of a string — JS `.length`, which zod's string bounds measure.
263fn utf16_len(s: &str) -> usize {
264    s.encode_utf16().count()
265}
266
267fn issue(issues: &mut Vec<Issue>, path: &[PathSeg], kind: IssueKind) {
268    issues.push(Issue {
269        path: path.to_vec(),
270        kind,
271    });
272}
273
274/// The recursive checker. Wrappers evaluate outside-in (`optional` admits a missing
275/// value before `default`, before `nullable`, before the base — the TS wrapper order).
276fn check(
277    schema: &Schema,
278    value: Option<&serde_json::Value>,
279    path: &mut Vec<PathSeg>,
280    issues: &mut Vec<Issue>,
281) {
282    match schema {
283        Schema::Optional(inner) | Schema::Default(inner) => {
284            if value.is_none() {
285                return; // undefined passes (optional) / substitutes (default)
286            }
287            check(inner, value, path, issues);
288        }
289        Schema::Nullable(inner) => {
290            if matches!(value, Some(serde_json::Value::Null)) {
291                return;
292            }
293            check(inner, value, path, issues);
294        }
295        Schema::Described { inner, .. } => check(inner, value, path, issues),
296        Schema::String { min, max, pattern } => {
297            check_string(value, *min, *max, pattern.as_ref(), path, issues)
298        }
299        Schema::Format {
300            format,
301            min,
302            max,
303            pattern,
304        } => check_format(value, format, *min, *max, pattern.as_ref(), path, issues),
305        Schema::Number { int, min, max } => check_number(value, *int, *min, *max, path, issues),
306        Schema::Boolean => {
307            if !matches!(value, Some(serde_json::Value::Bool(_))) {
308                issue(
309                    issues,
310                    path,
311                    IssueKind::InvalidType {
312                        expected: "boolean",
313                    },
314                );
315            }
316        }
317        Schema::Array { of, min, max } => check_array(value, of, *min, *max, path, issues),
318        Schema::Object { fields, strict } => check_object(value, fields, *strict, path, issues),
319        Schema::Enum(options) => {
320            let ok = matches!(value, Some(serde_json::Value::String(s)) if options.contains(s));
321            if !ok {
322                issue(
323                    issues,
324                    path,
325                    IssueKind::InvalidValue {
326                        values: options.clone(),
327                    },
328                );
329            }
330        }
331        Schema::Const(c) => {
332            let ok = value.is_some_and(|v| c.matches(v));
333            if !ok {
334                issue(
335                    issues,
336                    path,
337                    IssueKind::InvalidValue {
338                        values: vec![c.render()],
339                    },
340                );
341            }
342        }
343    }
344}
345
346fn check_string(
347    value: Option<&serde_json::Value>,
348    min: Option<f64>,
349    max: Option<f64>,
350    pattern: Option<&Regex>,
351    path: &[PathSeg],
352    issues: &mut Vec<Issue>,
353) {
354    let Some(serde_json::Value::String(s)) = value else {
355        issue(issues, path, IssueKind::InvalidType { expected: "string" });
356        return;
357    };
358    let len = utf16_len(s) as f64;
359    if let Some(min) = min
360        && len < min
361    {
362        issue(issues, path, IssueKind::TooSmall);
363    }
364    if let Some(max) = max
365        && len > max
366    {
367        issue(issues, path, IssueKind::TooBig);
368    }
369    if let Some(re) = pattern
370        && !re.is_match(s)
371    {
372        issue(
373            issues,
374            path,
375            IssueKind::InvalidFormat {
376                format: "regex".into(),
377            },
378        );
379    }
380}
381
382/// The named-format string check, with the v2 composed constraints chained after it in
383/// zod's registration order (the format is the constructor's own check, then `.min()` /
384/// `.max()` / `.regex()`): each violated constraint reports its own issue.
385fn check_format(
386    value: Option<&serde_json::Value>,
387    format: &StringFormat,
388    min: Option<f64>,
389    max: Option<f64>,
390    pattern: Option<&Regex>,
391    path: &[PathSeg],
392    issues: &mut Vec<Issue>,
393) {
394    let Some(serde_json::Value::String(s)) = value else {
395        issue(issues, path, IssueKind::InvalidType { expected: "string" });
396        return;
397    };
398    if !format.is_match(s) {
399        issue(
400            issues,
401            path,
402            IssueKind::InvalidFormat {
403                format: format.name().into(),
404            },
405        );
406    }
407    let len = utf16_len(s) as f64;
408    if let Some(min) = min
409        && len < min
410    {
411        issue(issues, path, IssueKind::TooSmall);
412    }
413    if let Some(max) = max
414        && len > max
415    {
416        issue(issues, path, IssueKind::TooBig);
417    }
418    if let Some(re) = pattern
419        && !re.is_match(s)
420    {
421        issue(
422            issues,
423            path,
424            IssueKind::InvalidFormat {
425                format: "regex".into(),
426            },
427        );
428    }
429}
430
431fn check_number(
432    value: Option<&serde_json::Value>,
433    int: bool,
434    min: Option<f64>,
435    max: Option<f64>,
436    path: &[PathSeg],
437    issues: &mut Vec<Issue>,
438) {
439    let n = match value {
440        Some(serde_json::Value::Number(n)) => n.as_f64(),
441        _ => None,
442    };
443    let Some(n) = n else {
444        issue(
445            issues,
446            path,
447            IssueKind::InvalidType {
448                expected: if int { "int" } else { "number" },
449            },
450        );
451        return;
452    };
453    // zod's z.int(): a fraction is invalid_type and stops; an integer outside the safe
454    // range is too_big or too_small, and the min/max checks still run.
455    if int {
456        if n.fract() != 0.0 {
457            issue(issues, path, IssueKind::InvalidType { expected: "int" });
458            return;
459        }
460        if n.abs() > 9_007_199_254_740_991.0 {
461            let kind = if n > 0.0 {
462                IssueKind::TooBig
463            } else {
464                IssueKind::TooSmall
465            };
466            issue(issues, path, kind);
467        }
468    }
469    if let Some(min) = min
470        && n < min
471    {
472        issue(issues, path, IssueKind::TooSmall);
473    }
474    if let Some(max) = max
475        && n > max
476    {
477        issue(issues, path, IssueKind::TooBig);
478    }
479}
480
481fn check_array(
482    value: Option<&serde_json::Value>,
483    of: &Schema,
484    min: Option<f64>,
485    max: Option<f64>,
486    path: &mut Vec<PathSeg>,
487    issues: &mut Vec<Issue>,
488) {
489    let Some(serde_json::Value::Array(items)) = value else {
490        issue(issues, path, IssueKind::InvalidType { expected: "array" });
491        return;
492    };
493    let len = items.len() as f64;
494    if let Some(min) = min
495        && len < min
496    {
497        issue(issues, path, IssueKind::TooSmall);
498    }
499    if let Some(max) = max
500        && len > max
501    {
502        issue(issues, path, IssueKind::TooBig);
503    }
504    for (i, item) in items.iter().enumerate() {
505        path.push(PathSeg::Index(i));
506        check(of, Some(item), path, issues);
507        path.pop();
508    }
509}
510
511fn check_object(
512    value: Option<&serde_json::Value>,
513    fields: &[(String, Schema)],
514    strict: bool,
515    path: &mut Vec<PathSeg>,
516    issues: &mut Vec<Issue>,
517) {
518    let Some(serde_json::Value::Object(map)) = value else {
519        issue(issues, path, IssueKind::InvalidType { expected: "object" });
520        return;
521    };
522    // Declared fields first (declaration order), then the strict unknown-key sweep —
523    // matching zod's issue emission order.
524    for (key, schema) in fields {
525        path.push(PathSeg::Key(key.clone()));
526        check(schema, map.get(key), path, issues);
527        path.pop();
528    }
529    if strict {
530        let unknown: Vec<String> = map
531            .keys()
532            .filter(|k| !fields.iter().any(|(f, _)| f == *k))
533            .cloned()
534            .collect();
535        if !unknown.is_empty() {
536            issue(issues, path, IssueKind::UnrecognizedKeys { keys: unknown });
537        }
538    }
539}
540
541// ── Named string formats (zod v4 parity) ──────────────────────────────────────────────
542
543/// A format regex, compiled on first use and cached for the life of the process.
544macro_rules! re {
545    ($source:expr $(,)?) => {{
546        static RE: OnceLock<Regex> = OnceLock::new();
547        RE.get_or_init(|| Regex::new(&$source).expect("static format regex compiles"))
548    }};
549}
550
551/// The `format` names, each replicating zod v4's validator for that format.
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553#[allow(missing_docs)]
554pub enum StringFormat {
555    /// zod's practical email: no leading dot, no `..`, a dotted domain.
556    Email,
557    /// What the WHATWG `URL` constructor accepts, approximated.
558    Url,
559    /// Versions 1 to 8, plus the nil and max UUIDs.
560    Uuid,
561    Hostname,
562    /// An ISO date and time in UTC: a trailing `Z` is required, seconds are optional.
563    Datetime,
564    /// An ISO date, leap years included.
565    Date,
566    /// `hh:mm`, with optional seconds and fraction.
567    Time,
568    /// An ISO 8601 duration, with weeks only on their own.
569    Duration,
570    Ipv4,
571    Ipv6,
572    Cidrv4,
573    Cidrv6,
574    Nanoid,
575    Cuid,
576    Cuid2,
577    Ulid,
578    Base64,
579    Emoji,
580    /// An E.164 phone number: `+`, then 7 to 15 digits.
581    E164,
582}
583
584/// Every supported format, in the TS `STRING_FORMATS` declaration order (error messages
585/// list them in this order).
586pub(crate) const STRING_FORMATS: &[(&str, StringFormat)] = &[
587    ("email", StringFormat::Email),
588    ("url", StringFormat::Url),
589    ("uuid", StringFormat::Uuid),
590    ("hostname", StringFormat::Hostname),
591    ("datetime", StringFormat::Datetime),
592    ("date", StringFormat::Date),
593    ("time", StringFormat::Time),
594    ("duration", StringFormat::Duration),
595    ("ipv4", StringFormat::Ipv4),
596    ("ipv6", StringFormat::Ipv6),
597    ("cidrv4", StringFormat::Cidrv4),
598    ("cidrv6", StringFormat::Cidrv6),
599    ("nanoid", StringFormat::Nanoid),
600    ("cuid", StringFormat::Cuid),
601    ("cuid2", StringFormat::Cuid2),
602    ("ulid", StringFormat::Ulid),
603    ("base64", StringFormat::Base64),
604    ("emoji", StringFormat::Emoji),
605    ("e164", StringFormat::E164),
606];
607
608impl StringFormat {
609    /// The format's name, as written in a contract and in the zod issue's `format`.
610    pub fn name(&self) -> &'static str {
611        STRING_FORMATS
612            .iter()
613            .find(|(_, f)| f == self)
614            .map(|(n, _)| *n)
615            .expect("every format is named")
616    }
617
618    /// Parse a vocabulary name into its format.
619    pub(crate) fn from_name(name: &str) -> Option<Self> {
620        STRING_FORMATS
621            .iter()
622            .find(|(n, _)| *n == name)
623            .map(|(_, f)| *f)
624    }
625
626    /// Whether `s` satisfies this format. Each arm replicates zod v4's validator
627    /// (`v4/core/regexes.js`); lookaheads the `regex` crate lacks are hand-coded.
628    pub(crate) fn is_match(&self, s: &str) -> bool {
629        match self {
630            StringFormat::Email => is_email(s),
631            StringFormat::Url => is_url(s),
632            StringFormat::Uuid => re!(
633                r"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
634            )
635            .is_match(s),
636            StringFormat::Hostname => is_hostname(s),
637            StringFormat::Datetime => re!(&format!(
638                "^{DATE_SOURCE}T(?:{TIME_SOURCE}(?:Z))$"
639            ))
640            .is_match(s),
641            StringFormat::Date => re!(&format!("^{DATE_SOURCE}$")).is_match(s),
642            StringFormat::Time => re!(&format!("^{TIME_SOURCE}$")).is_match(s),
643            StringFormat::Duration => is_duration(s),
644            StringFormat::Ipv4 => re!(
645                r"^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$",
646            )
647            .is_match(s),
648            StringFormat::Ipv6 => is_ipv6(s),
649            StringFormat::Cidrv4 => re!(
650                r"^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])/([0-9]|[1-2][0-9]|3[0-2])$",
651            )
652            .is_match(s),
653            StringFormat::Cidrv6 => is_cidrv6(s),
654            StringFormat::Nanoid => re!(r"^[a-zA-Z0-9_-]{21}$").is_match(s),
655            StringFormat::Cuid => re!(r"^[cC][0-9a-z]{6,}$").is_match(s),
656            StringFormat::Cuid2 => re!(r"^[0-9a-z]+$").is_match(s),
657            StringFormat::Ulid => re!(r"^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$").is_match(s),
658            StringFormat::Base64 => re!(
659                r"^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$",
660            )
661            .is_match(s),
662            StringFormat::Emoji => re!(r"^(\p{Extended_Pictographic}|\p{Emoji_Component})+$")
663                .is_match(s),
664            StringFormat::E164 => re!(r"^\+[1-9][0-9]{6,14}$").is_match(s),
665        }
666    }
667}
668
669/// zod's leap-year-aware ISO date source (ported verbatim).
670const DATE_SOURCE: &str = r"(?:(?:[0-9][0-9][2468][048]|[0-9][0-9][13579][26]|[0-9][0-9]0[48]|[02468][048]00|[13579][26]00)-02-29|[0-9]{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])|(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)|(?:02)-(?:0[1-9]|1[0-9]|2[0-8])))";
671
672/// zod's default-precision time source: `hh:mm`, optional `:ss` and fraction.
673const TIME_SOURCE: &str = r"(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9](?:\.[0-9]+)?)?";
674
675/// zod validates ipv6 by WHATWG-URL-parsing `http://[value]` — RFC 4291 textual forms
676/// including `::` compression and embedded IPv4, which `std::net::Ipv6Addr` parses the
677/// same way.
678fn is_ipv6(s: &str) -> bool {
679    s.parse::<std::net::Ipv6Addr>().is_ok()
680}
681
682/// zod's cidrv6 check (ported from `$ZodCIDRv6`): exactly one `/`, a canonical decimal
683/// prefix 0–128 (`Number(prefix)` must round-trip), and a URL-parsable address.
684fn is_cidrv6(s: &str) -> bool {
685    let mut parts = s.split('/');
686    let (Some(address), Some(prefix), None) = (parts.next(), parts.next(), parts.next()) else {
687        return false;
688    };
689    let canonical = prefix
690        .parse::<u32>()
691        .is_ok_and(|n| n <= 128 && n.to_string() == prefix);
692    canonical && is_ipv6(address)
693}
694
695/// zod's practical email: `/^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/`
696/// — the two lookaheads (no leading dot, no `..`) hand-coded.
697fn is_email(s: &str) -> bool {
698    if s.starts_with('.') || s.contains("..") {
699        return false;
700    }
701    re!(r"^([A-Za-z0-9_'+\-.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9-]*\.)+[A-Za-z]{2,}$")
702        .is_match(s)
703}
704
705/// zod's `z.url()` accepts what the WHATWG `new URL(...)` constructor accepts. This is a
706/// pragmatic approximation: an absolute URL with a scheme; the special schemes
707/// (http/https/ws/wss/ftp/file) additionally follow WHATWG host rules.
708fn is_url(s: &str) -> bool {
709    let s = s.trim_matches(|c: char| c <= ' '); // WHATWG strips leading/trailing C0 and space
710    let Some(colon) = s.find(':') else {
711        return false;
712    };
713    let scheme = &s[..colon];
714    let mut chars = scheme.chars();
715    let scheme_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
716        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
717    if !scheme_ok {
718        return false;
719    }
720    let rest = &s[colon + 1..];
721    let special = matches!(
722        scheme.to_ascii_lowercase().as_str(),
723        "http" | "https" | "ws" | "wss" | "ftp" | "file"
724    );
725    if !special {
726        return true; // a non-special scheme accepts any opaque path (e.g. mailto:x)
727    }
728    if scheme.eq_ignore_ascii_case("file") {
729        return true; // file: accepts an empty host
730    }
731    // Special schemes need a non-empty host after `//` (WHATWG tolerates extra slashes).
732    let after = rest.trim_start_matches(['/', '\\']);
733    let host = after.split(['/', '?', '#']).next().unwrap_or("");
734    let host = host.rsplit('@').next().unwrap_or("");
735    !host.is_empty() && !host.starts_with(':')
736}
737
738/// zod's hostname: `(?=.{1,253}\.?$)` length lookahead hand-coded, body verbatim.
739fn is_hostname(s: &str) -> bool {
740    let len = s.strip_suffix('.').unwrap_or(s).len();
741    if len == 0 || len > 253 || s.is_empty() {
742        return false;
743    }
744    re!(r"^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[-0-9a-zA-Z]{0,61}[0-9a-zA-Z])?)*\.?$").is_match(s)
745}
746
747/// zod's ISO-8601-1 duration:
748/// `/^P(?:([0-9]+W)|(?!.*W)(?=[0-9]|T[0-9])([0-9]+Y)?([0-9]+M)?([0-9]+D)?(T(?=[0-9])([0-9]+H)?([0-9]+M)?([0-9]+([.,][0-9]+)?S)?)?)$/`
749/// — the weeks-only branch, else the lookaheads hand-coded around the component regex.
750fn is_duration(s: &str) -> bool {
751    let Some(body) = s.strip_prefix('P') else {
752        return false;
753    };
754    if re!(r"^[0-9]+W$").is_match(body) {
755        return true;
756    }
757    // (?!.*W): no W in the non-week form; (?=[0-9]|T[0-9]): at least one leading component.
758    if body.contains('W') || !re!(r"^(?:[0-9]|T[0-9])").is_match(body) {
759        return false;
760    }
761    re!(r"^([0-9]+Y)?([0-9]+M)?([0-9]+D)?(T([0-9]+H)?([0-9]+M)?([0-9]+([.,][0-9]+)?S)?)?$").is_match(body)
762        // (?=[0-9]) after T: a bare trailing `T` is invalid.
763        && !body.ends_with('T')
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use serde_json::json;
770
771    fn kinds(schema: &Schema, v: &serde_json::Value) -> Vec<Issue> {
772        schema.safe_parse(Some(v)).err().unwrap_or_default()
773    }
774
775    // Contract first: a string schema accepts a string and reports the exact issue
776    // stream Zod would — type, bounds, pattern — each addressed to its path.
777    #[test]
778    fn string_type_bounds_and_pattern() {
779        let s = Schema::String {
780            min: Some(2.0),
781            max: Some(4.0),
782            pattern: Some(Regex::new("^a").unwrap()),
783        };
784        assert!(s.safe_parse(Some(&json!("abc"))).is_ok());
785        assert_eq!(
786            kinds(&s, &json!(7))[0].kind,
787            IssueKind::InvalidType { expected: "string" }
788        );
789        assert_eq!(kinds(&s, &json!("a"))[0].kind, IssueKind::TooSmall);
790        assert_eq!(kinds(&s, &json!("aaaaa"))[0].kind, IssueKind::TooBig);
791        assert_eq!(
792            kinds(&s, &json!("bc"))[0].kind,
793            IssueKind::InvalidFormat {
794                format: "regex".into()
795            }
796        );
797    }
798
799    #[test]
800    fn enum_and_const_report_invalid_value() {
801        let e = Schema::Enum(vec!["a".into(), "b".into()]);
802        assert!(e.safe_parse(Some(&json!("a"))).is_ok());
803        assert_eq!(
804            kinds(&e, &json!("c"))[0].kind,
805            IssueKind::InvalidValue {
806                values: vec!["a".into(), "b".into()]
807            }
808        );
809        let c = Schema::Const(ConstValue::Number(3.0));
810        assert!(c.safe_parse(Some(&json!(3))).is_ok());
811        assert!(c.safe_parse(Some(&json!(4))).is_err());
812    }
813
814    #[test]
815    fn strict_object_reports_missing_required_and_unknown_keys() {
816        let o = Schema::Object {
817            fields: vec![(
818                "id".into(),
819                Schema::String {
820                    min: None,
821                    max: None,
822                    pattern: None,
823                },
824            )],
825            strict: true,
826        };
827        let issues = kinds(&o, &json!({"extra": 1}));
828        assert_eq!(
829            issues[0],
830            Issue {
831                path: vec![PathSeg::Key("id".into())],
832                kind: IssueKind::InvalidType { expected: "string" }
833            }
834        );
835        assert_eq!(
836            issues[1],
837            Issue {
838                path: vec![],
839                kind: IssueKind::UnrecognizedKeys {
840                    keys: vec!["extra".into()]
841                }
842            }
843        );
844    }
845
846    #[test]
847    fn wrappers_admit_missing_null_and_defaulted_values() {
848        let base = || Box::new(Schema::Boolean);
849        assert!(Schema::Optional(base()).safe_parse(None).is_ok());
850        assert!(Schema::Default(base()).safe_parse(None).is_ok());
851        assert!(
852            Schema::Nullable(base())
853                .safe_parse(Some(&json!(null)))
854                .is_ok()
855        );
856        // A required (unwrapped) schema rejects a missing value as invalid_type.
857        assert_eq!(
858            Schema::Boolean.safe_parse(None).unwrap_err()[0].kind,
859            IssueKind::InvalidType {
860                expected: "boolean"
861            }
862        );
863    }
864
865    #[test]
866    fn array_recurses_with_index_paths() {
867        let a = Schema::Array {
868            of: Box::new(Schema::String {
869                min: None,
870                max: None,
871                pattern: None,
872            }),
873            min: Some(1.0),
874            max: None,
875        };
876        assert!(a.safe_parse(Some(&json!(["x"]))).is_ok());
877        assert_eq!(kinds(&a, &json!([]))[0].kind, IssueKind::TooSmall);
878        let issues = kinds(&a, &json!(["ok", 5]));
879        assert_eq!(issues[0].path, vec![PathSeg::Index(1)]);
880    }
881
882    // zod's z.int(): a fraction is invalid_type and stops; an unsafe integer is too_big or
883    // too_small, and the bounds are still checked.
884    #[test]
885    fn number_int_is_a_safe_integer_gate() {
886        let n = Schema::Number {
887            int: true,
888            min: Some(0.0),
889            max: Some(10.0),
890        };
891        assert!(n.safe_parse(Some(&json!(5))).is_ok());
892        assert_eq!(
893            kinds(&n, &json!(1.5)),
894            vec![Issue {
895                path: vec![],
896                kind: IssueKind::InvalidType { expected: "int" }
897            }]
898        );
899        assert_eq!(kinds(&n, &json!(-1))[0].kind, IssueKind::TooSmall);
900        assert_eq!(kinds(&n, &json!(11))[0].kind, IssueKind::TooBig);
901        let big = kinds(&n, &json!(9_007_199_254_740_993u64));
902        assert_eq!(
903            big.iter().map(|i| i.kind.clone()).collect::<Vec<_>>(),
904            vec![IssueKind::TooBig, IssueKind::TooBig]
905        );
906        let small = kinds(&n, &json!(-9_007_199_254_740_993i64));
907        assert_eq!(
908            small.iter().map(|i| i.kind.clone()).collect::<Vec<_>>(),
909            vec![IssueKind::TooSmall, IssueKind::TooSmall]
910        );
911    }
912
913    // JS's `\d` is ASCII; a full-width digit is not a date.
914    #[test]
915    fn format_digits_are_ascii() {
916        assert!(!StringFormat::Date.is_match("\u{FF12}\u{FF10}\u{FF12}\u{FF14}-01-02"));
917        assert!(!StringFormat::E164.is_match("+1\u{0663}15550132"));
918    }
919
920    #[test]
921    fn const_numbers_render_as_js_does() {
922        assert_eq!(ConstValue::Number(1e21).render(), "1e+21");
923        assert_eq!(ConstValue::Number(9.3e18).render(), "9300000000000000000");
924        assert_eq!(ConstValue::Number(-0.0).render(), "0");
925    }
926
927    // ── Formats: zod v4 parity spot checks (precise for date/datetime/email/url/uuid) ──
928
929    #[test]
930    fn date_format_is_leap_year_aware() {
931        let f = StringFormat::Date;
932        assert!(f.is_match("2024-02-29")); // leap year
933        assert!(!f.is_match("2023-02-29"));
934        assert!(f.is_match("2023-12-31"));
935        assert!(!f.is_match("2023-13-01"));
936        assert!(!f.is_match("2023-04-31"));
937    }
938
939    #[test]
940    fn datetime_requires_utc_z_and_allows_optional_seconds() {
941        let f = StringFormat::Datetime;
942        assert!(f.is_match("2024-01-02T03:04:05Z"));
943        assert!(f.is_match("2024-01-02T03:04Z")); // seconds optional (zod v4 default)
944        assert!(f.is_match("2024-01-02T03:04:05.123Z"));
945        assert!(!f.is_match("2024-01-02T03:04:05")); // no offset → Z required
946        assert!(!f.is_match("2024-01-02T03:04:05+02:00")); // offset off by default
947        assert!(!f.is_match("2024-01-02 03:04:05Z"));
948    }
949
950    #[test]
951    fn email_matches_zods_practical_regex() {
952        let f = StringFormat::Email;
953        assert!(f.is_match("a.b+c@example.co"));
954        assert!(!f.is_match(".lead@example.com")); // (?!\.)
955        assert!(!f.is_match("a..b@example.com")); // (?!.*\.\.)
956        assert!(!f.is_match("a@example"));
957        assert!(!f.is_match("a b@example.com"));
958    }
959
960    #[test]
961    fn url_accepts_whatwg_parsable_urls() {
962        let f = StringFormat::Url;
963        assert!(f.is_match("https://example.com/path?q=1"));
964        assert!(f.is_match("mailto:user@example.com")); // non-special scheme
965        assert!(!f.is_match("example.com")); // no scheme
966        assert!(!f.is_match("http://")); // special scheme, empty host
967    }
968
969    #[test]
970    fn uuid_admits_versions_1_to_8_plus_nil_and_max() {
971        let f = StringFormat::Uuid;
972        assert!(f.is_match("6ba7b810-9dad-11d1-80b4-00c04fd430c8"));
973        assert!(f.is_match("00000000-0000-0000-0000-000000000000"));
974        assert!(!f.is_match("6ba7b810-9dad-01d1-80b4-00c04fd430c8")); // version 0
975        assert!(!f.is_match("6ba7b810-9dad-11d1-c0b4-00c04fd430c8")); // bad variant
976    }
977
978    #[test]
979    fn remaining_formats_spot_checks() {
980        assert!(StringFormat::Time.is_match("23:59:59.999"));
981        assert!(!StringFormat::Time.is_match("24:00"));
982        assert!(StringFormat::Duration.is_match("P3Y6M4DT12H30M5S"));
983        assert!(StringFormat::Duration.is_match("P4W"));
984        assert!(!StringFormat::Duration.is_match("P4WT1H")); // weeks don't mix (8601-1)
985        assert!(!StringFormat::Duration.is_match("P1YT")); // bare trailing T
986        assert!(StringFormat::Ipv4.is_match("192.168.0.1"));
987        assert!(!StringFormat::Ipv4.is_match("256.0.0.1"));
988        assert!(StringFormat::Ipv6.is_match("2001:db8::1"));
989        assert!(StringFormat::Ipv6.is_match("::ffff:192.0.2.1")); // embedded v4
990        assert!(!StringFormat::Ipv6.is_match("2001:db8"));
991        assert!(StringFormat::Cidrv4.is_match("10.0.0.0/8"));
992        assert!(StringFormat::Cidrv6.is_match("2001:db8::/32"));
993        assert!(!StringFormat::Cidrv6.is_match("2001:db8::/032")); // non-canonical prefix
994        assert!(!StringFormat::Cidrv6.is_match("2001:db8::/129"));
995        assert!(StringFormat::Hostname.is_match("sub.example-1.com"));
996        assert!(!StringFormat::Hostname.is_match("-bad.com"));
997        assert!(StringFormat::Nanoid.is_match("V1StGXR8_Z5jdHi6B-myT"));
998        assert!(StringFormat::Cuid.is_match("cjld2cjxh0000qzrmn831i7rn"));
999        assert!(StringFormat::Cuid2.is_match("tz4a98xxat96iws9zmbrgj3a"));
1000        assert!(StringFormat::Ulid.is_match("01ARZ3NDEKTSV4RRFFQ69G5FAV"));
1001        assert!(StringFormat::Base64.is_match("aGVsbG8="));
1002        assert!(!StringFormat::Base64.is_match("aGVsbG8"));
1003        assert!(StringFormat::E164.is_match("+14155550132"));
1004        assert!(!StringFormat::E164.is_match("+04155550132"));
1005        assert!(StringFormat::Emoji.is_match("🎉"));
1006        assert!(!StringFormat::Emoji.is_match("x"));
1007    }
1008
1009    // v2 composition: one string schema carries a named format AND bounds AND
1010    // a pattern; every violated constraint reports its own issue, in zod's chained order.
1011    #[test]
1012    fn format_composes_with_bounds_and_pattern() {
1013        let s = Schema::Format {
1014            format: StringFormat::Email,
1015            min: Some(30.0),
1016            max: None,
1017            pattern: Some(Regex::new("^ops-").unwrap()),
1018        };
1019        assert!(
1020            s.safe_parse(Some(&json!("ops-team+alerts@example-corp.com")))
1021                .is_ok()
1022        );
1023        let issues = kinds(&s, &json!("not an email"));
1024        assert_eq!(
1025            issues.iter().map(|i| i.kind.clone()).collect::<Vec<_>>(),
1026            vec![
1027                IssueKind::InvalidFormat {
1028                    format: "email".into()
1029                },
1030                IssueKind::TooSmall,
1031                IssueKind::InvalidFormat {
1032                    format: "regex".into()
1033                },
1034            ]
1035        );
1036        // A valid email that only breaks one chained constraint reports only that one.
1037        assert_eq!(
1038            kinds(&s, &json!("ops-abcdefghijklmnop@example.com")),
1039            vec![]
1040        );
1041    }
1042
1043    // v2 `description`: the wrapper is validation-transparent, and the walk
1044    // resolves the nearest enclosing description along an issue path.
1045    #[test]
1046    fn described_delegates_and_resolves_along_paths() {
1047        let field = Schema::Described {
1048            inner: Box::new(Schema::Boolean),
1049            description: "the field".into(),
1050        };
1051        assert!(field.safe_parse(Some(&json!(true))).is_ok());
1052        assert!(field.safe_parse(Some(&json!(1))).is_err());
1053
1054        let root = Schema::Described {
1055            inner: Box::new(Schema::Object {
1056                fields: vec![
1057                    ("flag".into(), field),
1058                    ("bare".into(), Schema::Boolean),
1059                    (
1060                        "items".into(),
1061                        Schema::Array {
1062                            of: Box::new(Schema::Described {
1063                                inner: Box::new(Schema::Boolean),
1064                                description: "each item".into(),
1065                            }),
1066                            min: None,
1067                            max: None,
1068                        },
1069                    ),
1070                ],
1071                strict: true,
1072            }),
1073            description: "the root".into(),
1074        };
1075        let key = |k: &str| PathSeg::Key(k.to_string());
1076        // Nearest wins: the field's own description beats the root's.
1077        assert_eq!(
1078            description_along_path(&root, &[key("flag")]),
1079            Some("the field")
1080        );
1081        // No field description → the enclosing root's.
1082        assert_eq!(
1083            description_along_path(&root, &[key("bare")]),
1084            Some("the root")
1085        );
1086        assert_eq!(
1087            description_along_path(&root, &[key("items"), PathSeg::Index(0)]),
1088            Some("each item")
1089        );
1090        // An unresolvable tail (unknown key) keeps what the walk collected.
1091        assert_eq!(
1092            description_along_path(&root, &[key("nope")]),
1093            Some("the root")
1094        );
1095    }
1096
1097    #[test]
1098    fn format_key_path_renders_keys_and_indices() {
1099        assert_eq!(format_key_path(&[]), "");
1100        assert_eq!(
1101            format_key_path(&[PathSeg::Key("a".into()), PathSeg::Key("b".into())]),
1102            "a.b"
1103        );
1104        assert_eq!(
1105            format_key_path(&[PathSeg::Key("related".into()), PathSeg::Index(0)]),
1106            "related[0]"
1107        );
1108    }
1109}