Skip to main content

golem_schema/schema/validation/
well_formedness.rs

1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Golem Source License v1.1 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Structural well-formedness checks for a [`SchemaGraph`].
16
17use crate::schema::graph::{RefResolutionError, SchemaGraph};
18use crate::schema::metadata::TypeId;
19use crate::schema::schema_type::{
20    BinaryRestrictions, DiscriminatorRule, NumericRestrictionError, PathSpec, QuantitySpec,
21    QuantityValue, SchemaType, TextRestrictions, UnionBranch, UnionSpec, UrlRestrictions,
22};
23use std::collections::HashSet;
24use std::error::Error;
25use std::fmt::{self, Display, Formatter};
26
27/// All structural errors that can be raised by [`validate_graph`].
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum SchemaError {
30    /// Validation requires an optional Cargo feature.
31    UnsupportedFeature(&'static str),
32    DuplicateTypeId(TypeId),
33    DanglingRef(TypeId),
34    /// A named reference whose alias chain is a pure cycle
35    /// (`A -> B -> ... -> A`) that never bottoms out in a concrete type, so it
36    /// can never resolve to a usable `SchemaType`.
37    RecursiveAlias(TypeId),
38    EmptyVariant,
39    EmptyEnum,
40    EmptyUnion,
41    EmptyFlags,
42    DuplicateFieldName(String),
43    DuplicateVariantCase(String),
44    DuplicateEnumCase(String),
45    DuplicateFlagName(String),
46    DuplicateUnionTag(String),
47    MapKeyNotPrimitive,
48    FixedListZeroLength,
49    QuantityMinGreaterThanMax,
50    QuantityMinUnitMismatch {
51        base_unit: String,
52        min_unit: String,
53    },
54    QuantityMaxUnitMismatch {
55        base_unit: String,
56        max_unit: String,
57    },
58    QuantityComparisonOverflow {
59        base_unit: String,
60    },
61    UnionStringRuleOnNonStringBody {
62        tag: String,
63    },
64    UnionFieldRuleOnNonRecordBody {
65        tag: String,
66    },
67    UnionFieldEqualsLiteralOnNonStringField {
68        tag: String,
69        field_name: String,
70    },
71    UnionFieldRuleMissingField {
72        tag: String,
73        field_name: String,
74    },
75    UnionAmbiguousDiscriminators {
76        tag_a: String,
77        tag_b: String,
78        reason: String,
79    },
80    UnionUnsatisfiableFieldAbsent {
81        tag: String,
82        field_name: String,
83    },
84    InvalidRegex {
85        tag: String,
86        regex: String,
87        message: String,
88    },
89    InvalidTextRegex {
90        regex: String,
91        message: String,
92    },
93    TextLengthRangeInverted,
94    BinaryByteRangeInverted,
95    /// A numeric type's inline restrictions are not well-formed.
96    InvalidNumericRestriction {
97        error: NumericRestrictionError,
98    },
99    /// An `Option<X>` was declared where `X` is itself nullable on the
100    /// canonical JSON wire (option-of-option, option-of-union-with-nullable-
101    /// branch, option-of-ref-resolving-to-nullable). The canonical JSON
102    /// encoding `null | inner` collapses `Some(None)` and `None`, so the
103    /// nesting is rejected at construction time.
104    NullableNesting {
105        inner: String,
106    },
107}
108
109impl Display for SchemaError {
110    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
111        match self {
112            SchemaError::UnsupportedFeature(feature) => {
113                write!(f, "schema requires feature `{feature}`")
114            }
115            SchemaError::DuplicateTypeId(id) => write!(f, "duplicate type id `{id}`"),
116            SchemaError::DanglingRef(id) => write!(f, "dangling type reference `{id}`"),
117            SchemaError::RecursiveAlias(id) => {
118                write!(
119                    f,
120                    "type reference `{id}` forms a reference cycle with no concrete type"
121                )
122            }
123            SchemaError::EmptyVariant => write!(f, "variant has no cases"),
124            SchemaError::EmptyEnum => write!(f, "enum has no cases"),
125            SchemaError::EmptyUnion => write!(f, "union has no branches"),
126            SchemaError::EmptyFlags => write!(f, "flags has no entries"),
127            SchemaError::DuplicateFieldName(name) => write!(f, "duplicate field `{name}`"),
128            SchemaError::DuplicateVariantCase(name) => {
129                write!(f, "duplicate variant case `{name}`")
130            }
131            SchemaError::DuplicateEnumCase(name) => write!(f, "duplicate enum case `{name}`"),
132            SchemaError::DuplicateFlagName(name) => write!(f, "duplicate flag `{name}`"),
133            SchemaError::DuplicateUnionTag(tag) => write!(f, "duplicate union branch tag `{tag}`"),
134            SchemaError::MapKeyNotPrimitive => {
135                write!(f, "map key must be a primitive type")
136            }
137            SchemaError::FixedListZeroLength => write!(f, "fixed-list length must be > 0"),
138            SchemaError::QuantityMinGreaterThanMax => {
139                write!(f, "quantity min is greater than max")
140            }
141            SchemaError::QuantityMinUnitMismatch {
142                base_unit,
143                min_unit,
144            } => write!(
145                f,
146                "quantity min unit mismatch: base `{base_unit}`, min `{min_unit}`"
147            ),
148            SchemaError::QuantityMaxUnitMismatch {
149                base_unit,
150                max_unit,
151            } => write!(
152                f,
153                "quantity max unit mismatch: base `{base_unit}`, max `{max_unit}`"
154            ),
155            SchemaError::QuantityComparisonOverflow { base_unit } => write!(
156                f,
157                "quantity range comparison overflowed in base unit `{base_unit}`"
158            ),
159            SchemaError::UnionStringRuleOnNonStringBody { tag } => write!(
160                f,
161                "union branch `{tag}` uses a string-pattern rule but its body's canonical raw \
162                 JSON representation is not a string"
163            ),
164            SchemaError::UnionFieldRuleOnNonRecordBody { tag } => write!(
165                f,
166                "union branch `{tag}` uses a field rule but body is not record-shaped"
167            ),
168            SchemaError::UnionFieldEqualsLiteralOnNonStringField { tag, field_name } => write!(
169                f,
170                "union branch `{tag}` references field `{field_name}` for a literal comparison \
171                 but the field's canonical raw JSON representation is not a string"
172            ),
173            SchemaError::UnionFieldRuleMissingField { tag, field_name } => write!(
174                f,
175                "union branch `{tag}` references record field `{field_name}` that does not exist"
176            ),
177            SchemaError::UnionAmbiguousDiscriminators {
178                tag_a,
179                tag_b,
180                reason,
181            } => write!(
182                f,
183                "union branches `{tag_a}` and `{tag_b}` have overlapping discriminators ({reason})"
184            ),
185            SchemaError::UnionUnsatisfiableFieldAbsent { tag, field_name } => write!(
186                f,
187                "union branch `{tag}` uses field-absent on `{field_name}` but the record body \
188                 declares that field"
189            ),
190            SchemaError::InvalidRegex {
191                tag,
192                regex,
193                message,
194            } => write!(
195                f,
196                "union branch `{tag}` regex `{regex}` failed to compile: {message}"
197            ),
198            SchemaError::InvalidTextRegex { regex, message } => {
199                write!(f, "text regex `{regex}` failed to compile: {message}")
200            }
201            SchemaError::TextLengthRangeInverted => {
202                write!(f, "text min-length is greater than max-length")
203            }
204            SchemaError::InvalidNumericRestriction { error } => {
205                write!(f, "invalid numeric restriction: {error}")
206            }
207            SchemaError::BinaryByteRangeInverted => {
208                write!(f, "binary min-bytes is greater than max-bytes")
209            }
210            SchemaError::NullableNesting { inner } => write!(
211                f,
212                "option<{inner}> is invalid because the inner type is also nullable; \
213                 use a variant with explicit cases to distinguish absence from explicit none"
214            ),
215        }
216    }
217}
218
219impl Error for SchemaError {}
220
221/// Validate a [`SchemaGraph`] for structural well-formedness.
222///
223/// Returns the full list of collected errors. Ordering is deterministic:
224/// errors are reported in the order they are discovered while walking the
225/// graph.
226pub fn validate_graph(graph: &SchemaGraph) -> Result<(), Vec<SchemaError>> {
227    let mut errors = Vec::new();
228
229    let mut seen_ids: HashSet<&TypeId> = HashSet::new();
230    for def in &graph.defs {
231        if !seen_ids.insert(&def.id) {
232            errors.push(SchemaError::DuplicateTypeId(def.id.clone()));
233        }
234    }
235
236    let known_ids: HashSet<TypeId> = graph.defs.iter().map(|d| d.id.clone()).collect();
237
238    for def in &graph.defs {
239        check_type(graph, &def.body, &known_ids, &mut errors);
240    }
241    check_type(graph, &graph.root, &known_ids, &mut errors);
242
243    if errors.is_empty() {
244        Ok(())
245    } else {
246        Err(errors)
247    }
248}
249
250/// Validate a single [`SchemaType`] as a root against an existing graph's
251/// definitions for structural well-formedness.
252///
253/// Unlike [`validate_graph`], this does not validate the graph's own
254/// [`SchemaGraph::root`] or its definition bodies; only `ty` is walked, with
255/// [`SchemaType::Ref`]s resolved against `graph.defs`. This is for callers (such
256/// as the tool validator) that embed many bare `SchemaType` roots which share a
257/// single definitions carrier whose own `root` is an unused placeholder.
258///
259/// Errors are returned in deterministic discovery order.
260pub fn validate_root_type(graph: &SchemaGraph, ty: &SchemaType) -> Result<(), Vec<SchemaError>> {
261    let known_ids: HashSet<TypeId> = graph.defs.iter().map(|d| d.id.clone()).collect();
262    let mut errors = Vec::new();
263    check_type(graph, ty, &known_ids, &mut errors);
264    if errors.is_empty() {
265        Ok(())
266    } else {
267        Err(errors)
268    }
269}
270
271fn check_type(
272    graph: &SchemaGraph,
273    ty: &SchemaType,
274    known: &HashSet<TypeId>,
275    errors: &mut Vec<SchemaError>,
276) {
277    match ty {
278        SchemaType::Ref { id, .. } => {
279            if !known.contains(id) {
280                errors.push(SchemaError::DanglingRef(id.clone()));
281            } else {
282                match graph.resolve_ref(ty) {
283                    Ok(_) => {}
284                    // The id exists, but its alias chain is a pure cycle that
285                    // never resolves to a concrete type. A legitimate recursive
286                    // type (whose cycle passes through a value-shrinking
287                    // constructor such as `list`/`record`) resolves to that
288                    // constructor at the top-level alias step and is unaffected.
289                    Err(RefResolutionError::RecursiveRef(cycle_id)) => {
290                        errors.push(SchemaError::RecursiveAlias(cycle_id));
291                    }
292                    // The id exists, but following its alias chain reaches a
293                    // reference to a definition that is absent from the graph.
294                    // Report the dangling target so a root alias chain ending in
295                    // a missing definition is rejected at the chain's tail.
296                    Err(RefResolutionError::DanglingRef(target)) => {
297                        errors.push(SchemaError::DanglingRef(target));
298                    }
299                }
300            }
301        }
302
303        SchemaType::Record { fields, .. } => {
304            let mut seen: HashSet<&str> = HashSet::new();
305            for field in fields {
306                if !seen.insert(field.name.as_str()) {
307                    errors.push(SchemaError::DuplicateFieldName(field.name.clone()));
308                }
309                check_type(graph, &field.body, known, errors);
310            }
311        }
312
313        SchemaType::Variant { cases, .. } => {
314            if cases.is_empty() {
315                errors.push(SchemaError::EmptyVariant);
316            }
317            let mut seen: HashSet<&str> = HashSet::new();
318            for case in cases {
319                if !seen.insert(case.name.as_str()) {
320                    errors.push(SchemaError::DuplicateVariantCase(case.name.clone()));
321                }
322                if let Some(p) = &case.payload {
323                    check_type(graph, p, known, errors);
324                }
325            }
326        }
327
328        SchemaType::Enum { cases, .. } => {
329            if cases.is_empty() {
330                errors.push(SchemaError::EmptyEnum);
331            }
332            let mut seen: HashSet<&str> = HashSet::new();
333            for case in cases {
334                if !seen.insert(case.as_str()) {
335                    errors.push(SchemaError::DuplicateEnumCase(case.clone()));
336                }
337            }
338        }
339
340        SchemaType::Flags { flags, .. } => {
341            if flags.is_empty() {
342                errors.push(SchemaError::EmptyFlags);
343            }
344            let mut seen: HashSet<&str> = HashSet::new();
345            for flag in flags {
346                if !seen.insert(flag.as_str()) {
347                    errors.push(SchemaError::DuplicateFlagName(flag.clone()));
348                }
349            }
350        }
351
352        SchemaType::Tuple { elements, .. } => {
353            for e in elements {
354                check_type(graph, e, known, errors);
355            }
356        }
357        SchemaType::List { element, .. } => check_type(graph, element, known, errors),
358        SchemaType::FixedList {
359            element, length, ..
360        } => {
361            if *length == 0 {
362                errors.push(SchemaError::FixedListZeroLength);
363            }
364            check_type(graph, element, known, errors);
365        }
366        SchemaType::Map { key, value, .. } => {
367            // A key that resolves to a concrete non-primitive type is rejected.
368            // A key that does not resolve to a concrete type (a dangling
369            // reference or a pure alias cycle) is *not* reported here: that
370            // failure is reported by the `check_type(key, ...)` recursion below,
371            // and a `MapKeyNotPrimitive` on top of it would be misleading cascade
372            // noise.
373            if let MapKeyKind::NonPrimitive = classify_map_key(graph, key) {
374                errors.push(SchemaError::MapKeyNotPrimitive);
375            }
376            check_type(graph, key, known, errors);
377            check_type(graph, value, known, errors);
378        }
379        SchemaType::Option { inner, .. } => {
380            if is_nullable(graph, inner, &mut HashSet::new()) {
381                errors.push(SchemaError::NullableNesting {
382                    inner: describe_nullable(inner),
383                });
384            }
385            check_type(graph, inner, known, errors);
386        }
387        SchemaType::Result { spec, .. } => {
388            if let Some(t) = &spec.ok {
389                check_type(graph, t, known, errors);
390            }
391            if let Some(t) = &spec.err {
392                check_type(graph, t, known, errors);
393            }
394        }
395
396        SchemaType::Quantity { spec, .. } => check_quantity(spec, errors),
397
398        SchemaType::Text { restrictions, .. } => check_text_restrictions(restrictions, errors),
399        SchemaType::Binary { restrictions, .. } => check_binary_restrictions(restrictions, errors),
400        SchemaType::Path { spec, .. } => check_path_spec(spec, errors),
401        SchemaType::Url { restrictions, .. } => check_url_spec(restrictions, errors),
402
403        SchemaType::Union { spec, .. } => {
404            validate_union(graph, spec, known, errors);
405        }
406
407        SchemaType::Future { inner, .. } => {
408            if let Some(t) = inner {
409                check_type(graph, t, known, errors);
410            }
411        }
412        SchemaType::Stream { inner, .. } => {
413            if let Some(t) = inner {
414                check_type(graph, t, known, errors);
415            }
416        }
417
418        SchemaType::S8 { restrictions, .. }
419        | SchemaType::S16 { restrictions, .. }
420        | SchemaType::S32 { restrictions, .. }
421        | SchemaType::S64 { restrictions, .. }
422        | SchemaType::U8 { restrictions, .. }
423        | SchemaType::U16 { restrictions, .. }
424        | SchemaType::U32 { restrictions, .. }
425        | SchemaType::U64 { restrictions, .. }
426        | SchemaType::F32 { restrictions, .. }
427        | SchemaType::F64 { restrictions, .. } => {
428            if let Some(restrictions) = restrictions {
429                let repr = ty.numeric_repr().expect("numeric variant => numeric repr");
430                if let Err(error) = restrictions.validate_for_repr(repr) {
431                    errors.push(SchemaError::InvalidNumericRestriction { error });
432                }
433            }
434        }
435
436        SchemaType::Secret { spec, .. } => {
437            check_type(graph, &spec.inner, known, errors);
438        }
439
440        SchemaType::Bool { .. }
441        | SchemaType::Char { .. }
442        | SchemaType::String { .. }
443        | SchemaType::Uuid { .. }
444        | SchemaType::Datetime { .. }
445        | SchemaType::Duration { .. }
446        | SchemaType::QuotaToken { .. }
447        | SchemaType::PermissionCard { .. } => {}
448    }
449}
450
451/// Classification of a map key type after resolving named references (with
452/// cycle detection).
453enum MapKeyKind {
454    /// The key resolves to a primitive type and is valid.
455    Primitive,
456    /// The key resolves to a concrete non-primitive type (or a reference
457    /// cycle, which can never be primitive) and is invalid.
458    NonPrimitive,
459    /// The key is (or resolves through) a dangling reference, so its
460    /// primitiveness cannot be determined; the dangling reference is reported
461    /// separately.
462    Unresolved,
463}
464
465fn classify_map_key(graph: &SchemaGraph, ty: &SchemaType) -> MapKeyKind {
466    let mut visited: HashSet<TypeId> = HashSet::new();
467    let mut current = ty;
468    loop {
469        match current {
470            SchemaType::Ref { id, .. } => {
471                if !visited.insert(id.clone()) {
472                    // A pure alias cycle never resolves to a concrete type, so
473                    // its primitiveness is unknown. It is reported as a
474                    // `RecursiveAlias` by the `check_type(key, ...)` recursion; a
475                    // `MapKeyNotPrimitive` on top of that would be misleading
476                    // cascade noise.
477                    return MapKeyKind::Unresolved;
478                }
479                match graph.lookup(id) {
480                    Some(def) => current = &def.body,
481                    None => return MapKeyKind::Unresolved,
482                }
483            }
484            other => {
485                return if is_primitive_key(other) {
486                    MapKeyKind::Primitive
487                } else {
488                    MapKeyKind::NonPrimitive
489                };
490            }
491        }
492    }
493}
494
495fn is_primitive_key(ty: &SchemaType) -> bool {
496    matches!(
497        ty,
498        SchemaType::Bool { .. }
499            | SchemaType::S8 { .. }
500            | SchemaType::S16 { .. }
501            | SchemaType::S32 { .. }
502            | SchemaType::S64 { .. }
503            | SchemaType::U8 { .. }
504            | SchemaType::U16 { .. }
505            | SchemaType::U32 { .. }
506            | SchemaType::U64 { .. }
507            | SchemaType::F32 { .. }
508            | SchemaType::F64 { .. }
509            | SchemaType::Char { .. }
510            | SchemaType::String { .. }
511    )
512}
513
514fn check_quantity(spec: &QuantitySpec, errors: &mut Vec<SchemaError>) {
515    if let Some(min) = &spec.min
516        && min.unit != spec.base_unit
517    {
518        errors.push(SchemaError::QuantityMinUnitMismatch {
519            base_unit: spec.base_unit.clone(),
520            min_unit: min.unit.clone(),
521        });
522    }
523    if let Some(max) = &spec.max
524        && max.unit != spec.base_unit
525    {
526        errors.push(SchemaError::QuantityMaxUnitMismatch {
527            base_unit: spec.base_unit.clone(),
528            max_unit: max.unit.clone(),
529        });
530    }
531
532    if let (Some(min), Some(max)) = (&spec.min, &spec.max)
533        && min.unit == spec.base_unit
534        && max.unit == spec.base_unit
535    {
536        match quantity_le(min, max) {
537            Some(true) => {}
538            Some(false) => errors.push(SchemaError::QuantityMinGreaterThanMax),
539            None => errors.push(SchemaError::QuantityComparisonOverflow {
540                base_unit: spec.base_unit.clone(),
541            }),
542        }
543    }
544}
545
546/// Compare two [`QuantityValue`]s with the same unit, treating each as
547/// `mantissa * 10^(-scale)`. Returns `Some(true)` iff `a <= b`, `Some(false)`
548/// iff `a > b`, and `None` if rescaling overflows.
549fn quantity_le(a: &QuantityValue, b: &QuantityValue) -> Option<bool> {
550    let common = a.scale.max(b.scale);
551    let a_shift = (common - a.scale).max(0) as u32;
552    let b_shift = (common - b.scale).max(0) as u32;
553
554    let ten: i128 = 10;
555    let a_factor = ten.checked_pow(a_shift)?;
556    let b_factor = ten.checked_pow(b_shift)?;
557    let a_canon = (a.mantissa as i128).checked_mul(a_factor)?;
558    let b_canon = (b.mantissa as i128).checked_mul(b_factor)?;
559    Some(a_canon <= b_canon)
560}
561
562fn check_text_restrictions(restrictions: &TextRestrictions, errors: &mut Vec<SchemaError>) {
563    if let (Some(min), Some(max)) = (restrictions.min_length, restrictions.max_length)
564        && min > max
565    {
566        errors.push(SchemaError::TextLengthRangeInverted);
567    }
568    #[cfg(not(feature = "regex"))]
569    if restrictions.regex.is_some() {
570        errors.push(SchemaError::UnsupportedFeature("regex"));
571    }
572    #[cfg(feature = "regex")]
573    if let Some(regex) = &restrictions.regex
574        && let Err(e) = regex::Regex::new(regex.as_str())
575    {
576        errors.push(SchemaError::InvalidTextRegex {
577            regex: regex.clone(),
578            message: e.to_string(),
579        });
580    }
581}
582
583fn check_binary_restrictions(restrictions: &BinaryRestrictions, errors: &mut Vec<SchemaError>) {
584    if let (Some(min), Some(max)) = (restrictions.min_bytes, restrictions.max_bytes)
585        && min > max
586    {
587        errors.push(SchemaError::BinaryByteRangeInverted);
588    }
589}
590
591fn check_path_spec(_spec: &PathSpec, _errors: &mut Vec<SchemaError>) {
592    // PathSpec has no regex today; nothing to validate beyond structural
593    // shape.
594}
595
596fn check_url_spec(_spec: &UrlRestrictions, _errors: &mut Vec<SchemaError>) {
597    // URL schema structure has no constraints requiring the URL parser.
598}
599
600fn validate_union(
601    graph: &SchemaGraph,
602    spec: &UnionSpec,
603    known: &HashSet<TypeId>,
604    errors: &mut Vec<SchemaError>,
605) {
606    if spec.branches.is_empty() {
607        errors.push(SchemaError::EmptyUnion);
608    }
609    let mut seen: HashSet<&str> = HashSet::new();
610    for branch in &spec.branches {
611        if !seen.insert(branch.tag.as_str()) {
612            errors.push(SchemaError::DuplicateUnionTag(branch.tag.clone()));
613        }
614        check_union_branch(graph, branch, errors);
615        check_type(graph, &branch.body, known, errors);
616    }
617
618    // Discriminator ambiguity check.
619    for i in 0..spec.branches.len() {
620        for j in (i + 1)..spec.branches.len() {
621            let a = &spec.branches[i];
622            let b = &spec.branches[j];
623            if let DiscriminatorPairClassification::Reject(reason) =
624                classify_discriminator_pair(&a.discriminator, &b.discriminator)
625            {
626                errors.push(SchemaError::UnionAmbiguousDiscriminators {
627                    tag_a: a.tag.clone(),
628                    tag_b: b.tag.clone(),
629                    reason,
630                });
631            }
632        }
633    }
634}
635
636fn check_union_branch(graph: &SchemaGraph, branch: &UnionBranch, errors: &mut Vec<SchemaError>) {
637    let shape = resolved_shape(graph, &branch.body, &mut HashSet::new());
638    // The branch body is a dangling/recursive ref: its shape is unknown, so any
639    // shape-vs-discriminator mismatch would be misleading noise on top of the
640    // unresolved-reference error `check_type` already reports. Body-shape-
641    // independent problems (an invalid regex) are still checked below.
642    let shape_known = !matches!(shape, BodyShape::Unresolved);
643    match &branch.discriminator {
644        DiscriminatorRule::Prefix { .. }
645        | DiscriminatorRule::Suffix { .. }
646        | DiscriminatorRule::Contains { .. } => {
647            if shape_known && !matches!(shape, BodyShape::String) {
648                errors.push(SchemaError::UnionStringRuleOnNonStringBody {
649                    tag: branch.tag.clone(),
650                });
651            }
652        }
653        DiscriminatorRule::Regex { regex } => {
654            if shape_known && !matches!(shape, BodyShape::String) {
655                errors.push(SchemaError::UnionStringRuleOnNonStringBody {
656                    tag: branch.tag.clone(),
657                });
658            }
659            #[cfg(not(feature = "regex"))]
660            {
661                let _ = regex;
662                errors.push(SchemaError::UnsupportedFeature("regex"));
663            }
664            #[cfg(feature = "regex")]
665            if regex.is_empty() {
666                errors.push(SchemaError::InvalidRegex {
667                    tag: branch.tag.clone(),
668                    regex: regex.clone(),
669                    message: "regex must be non-empty".to_string(),
670                });
671            } else if let Err(e) = regex::Regex::new(regex.as_str()) {
672                errors.push(SchemaError::InvalidRegex {
673                    tag: branch.tag.clone(),
674                    regex: regex.clone(),
675                    message: e.to_string(),
676                });
677            }
678        }
679        DiscriminatorRule::FieldEquals(field_disc) => match shape {
680            BodyShape::Record(fields) => {
681                match fields.iter().find(|(n, _)| n == &field_disc.field_name) {
682                    None => errors.push(SchemaError::UnionFieldRuleMissingField {
683                        tag: branch.tag.clone(),
684                        field_name: field_disc.field_name.clone(),
685                    }),
686                    Some((_, ty)) => {
687                        let field_shape = resolved_shape(graph, ty, &mut HashSet::new());
688                        if field_disc.literal.is_some()
689                            && !matches!(field_shape, BodyShape::String | BodyShape::Unresolved)
690                        {
691                            errors.push(SchemaError::UnionFieldEqualsLiteralOnNonStringField {
692                                tag: branch.tag.clone(),
693                                field_name: field_disc.field_name.clone(),
694                            });
695                        }
696                    }
697                }
698            }
699            BodyShape::Unresolved => {}
700            _ => errors.push(SchemaError::UnionFieldRuleOnNonRecordBody {
701                tag: branch.tag.clone(),
702            }),
703        },
704        DiscriminatorRule::FieldAbsent { field_name } => match shape {
705            BodyShape::Record(fields) => {
706                if fields.iter().any(|(n, _)| n == field_name) {
707                    errors.push(SchemaError::UnionUnsatisfiableFieldAbsent {
708                        tag: branch.tag.clone(),
709                        field_name: field_name.clone(),
710                    });
711                }
712            }
713            BodyShape::Unresolved => {}
714            _ => errors.push(SchemaError::UnionFieldRuleOnNonRecordBody {
715                tag: branch.tag.clone(),
716            }),
717        },
718    }
719}
720
721#[derive(Clone, Debug, PartialEq, Eq)]
722enum DiscriminatorPairClassification {
723    Reject(String),
724    Disjoint,
725    Indeterminate,
726}
727
728fn classify_discriminator_pair(
729    a: &DiscriminatorRule,
730    b: &DiscriminatorRule,
731) -> DiscriminatorPairClassification {
732    match (a, b) {
733        (DiscriminatorRule::Prefix { prefix: pa }, DiscriminatorRule::Prefix { prefix: pb }) => {
734            if pa.starts_with(pb.as_str()) || pb.starts_with(pa.as_str()) {
735                DiscriminatorPairClassification::Reject(format!(
736                    "prefix `{pa}` and prefix `{pb}` overlap"
737                ))
738            } else {
739                DiscriminatorPairClassification::Disjoint
740            }
741        }
742        (DiscriminatorRule::Suffix { suffix: sa }, DiscriminatorRule::Suffix { suffix: sb }) => {
743            if sa.ends_with(sb.as_str()) || sb.ends_with(sa.as_str()) {
744                DiscriminatorPairClassification::Reject(format!(
745                    "suffix `{sa}` and suffix `{sb}` overlap"
746                ))
747            } else {
748                DiscriminatorPairClassification::Disjoint
749            }
750        }
751        (DiscriminatorRule::Prefix { prefix }, DiscriminatorRule::Suffix { suffix })
752        | (DiscriminatorRule::Suffix { suffix }, DiscriminatorRule::Prefix { prefix }) => {
753            DiscriminatorPairClassification::Reject(format!(
754                "prefix `{prefix}` and suffix `{suffix}` overlap"
755            ))
756        }
757        (DiscriminatorRule::Prefix { prefix }, DiscriminatorRule::Contains { substring })
758        | (DiscriminatorRule::Contains { substring }, DiscriminatorRule::Prefix { prefix }) => {
759            DiscriminatorPairClassification::Reject(format!(
760                "prefix `{prefix}` and contains `{substring}` overlap"
761            ))
762        }
763        (DiscriminatorRule::Suffix { suffix }, DiscriminatorRule::Contains { substring })
764        | (DiscriminatorRule::Contains { substring }, DiscriminatorRule::Suffix { suffix }) => {
765            DiscriminatorPairClassification::Reject(format!(
766                "suffix `{suffix}` and contains `{substring}` overlap"
767            ))
768        }
769        (
770            DiscriminatorRule::Contains { substring: ca },
771            DiscriminatorRule::Contains { substring: cb },
772        ) => DiscriminatorPairClassification::Reject(format!(
773            "contains `{ca}` and contains `{cb}` overlap"
774        )),
775        (DiscriminatorRule::Regex { regex: ra }, DiscriminatorRule::Regex { regex: rb }) => {
776            if ra == rb {
777                DiscriminatorPairClassification::Reject(format!("both branches share regex `{ra}`"))
778            } else {
779                DiscriminatorPairClassification::Indeterminate
780            }
781        }
782        (
783            DiscriminatorRule::Regex { .. },
784            DiscriminatorRule::Prefix { .. }
785            | DiscriminatorRule::Suffix { .. }
786            | DiscriminatorRule::Contains { .. },
787        )
788        | (
789            DiscriminatorRule::Prefix { .. }
790            | DiscriminatorRule::Suffix { .. }
791            | DiscriminatorRule::Contains { .. },
792            DiscriminatorRule::Regex { .. },
793        ) => DiscriminatorPairClassification::Indeterminate,
794        (
795            DiscriminatorRule::Prefix { .. }
796            | DiscriminatorRule::Suffix { .. }
797            | DiscriminatorRule::Contains { .. }
798            | DiscriminatorRule::Regex { .. },
799            DiscriminatorRule::FieldEquals(_) | DiscriminatorRule::FieldAbsent { .. },
800        )
801        | (
802            DiscriminatorRule::FieldEquals(_) | DiscriminatorRule::FieldAbsent { .. },
803            DiscriminatorRule::Prefix { .. }
804            | DiscriminatorRule::Suffix { .. }
805            | DiscriminatorRule::Contains { .. }
806            | DiscriminatorRule::Regex { .. },
807        ) => DiscriminatorPairClassification::Disjoint,
808        (DiscriminatorRule::FieldEquals(fa), DiscriminatorRule::FieldEquals(fb)) => {
809            if fa.field_name != fb.field_name {
810                return DiscriminatorPairClassification::Reject(format!(
811                    "field-equals on `{}` and `{}` can match the same object",
812                    fa.field_name, fb.field_name
813                ));
814            }
815            match (&fa.literal, &fb.literal) {
816                (None, _) | (_, None) => DiscriminatorPairClassification::Reject(format!(
817                    "field-equals on `{}` without literal overlaps another field-equals on the \
818                     same field",
819                    fa.field_name
820                )),
821                (Some(la), Some(lb)) if la == lb => {
822                    DiscriminatorPairClassification::Reject(format!(
823                        "two field-equals on `{}` share literal `{la}`",
824                        fa.field_name
825                    ))
826                }
827                _ => DiscriminatorPairClassification::Disjoint,
828            }
829        }
830        (
831            DiscriminatorRule::FieldAbsent { field_name: fa },
832            DiscriminatorRule::FieldAbsent { field_name: fb },
833        ) => DiscriminatorPairClassification::Reject(format!(
834            "field-absent on `{fa}` and `{fb}` can match the same object"
835        )),
836        (
837            DiscriminatorRule::FieldEquals(field_equals),
838            DiscriminatorRule::FieldAbsent {
839                field_name: field_absent,
840            },
841        )
842        | (
843            DiscriminatorRule::FieldAbsent {
844                field_name: field_absent,
845            },
846            DiscriminatorRule::FieldEquals(field_equals),
847        ) => {
848            if field_equals.field_name == *field_absent {
849                DiscriminatorPairClassification::Disjoint
850            } else {
851                DiscriminatorPairClassification::Reject(format!(
852                    "field-equals on `{}` and field-absent on `{field_absent}` can match the same \
853                     object",
854                    field_equals.field_name
855                ))
856            }
857        }
858    }
859}
860
861/// Whether `ty` can be encoded as JSON `null` on the canonical wire.
862///
863/// True when `ty` is an `Option<_>`, a `Union` whose any branch body is
864/// nullable, or a `Ref` resolving (after cycle-aware traversal) to a
865/// nullable type. The traversal terminates on cycles by treating any
866/// re-entered [`TypeId`] as non-nullable.
867fn is_nullable(graph: &SchemaGraph, ty: &SchemaType, visited: &mut HashSet<TypeId>) -> bool {
868    match ty {
869        SchemaType::Option { .. } => true,
870        SchemaType::Union { spec, .. } => spec
871            .branches
872            .iter()
873            .any(|b| is_nullable(graph, &b.body, visited)),
874        SchemaType::Ref { id, .. } => {
875            if !visited.insert(id.clone()) {
876                return false;
877            }
878            let result = match graph.lookup(id) {
879                Some(def) => is_nullable(graph, &def.body, visited),
880                None => false,
881            };
882            visited.remove(id);
883            result
884        }
885        _ => false,
886    }
887}
888
889/// Short textual description of a nullable type used in the
890/// [`SchemaError::NullableNesting`] message.
891fn describe_nullable(ty: &SchemaType) -> String {
892    match ty {
893        SchemaType::Option { .. } => "option<_>".to_string(),
894        SchemaType::Union { .. } => "union".to_string(),
895        SchemaType::Ref { id, .. } => format!("ref `{id}`"),
896        _ => "nullable".to_string(),
897    }
898}
899
900#[derive(Clone, Debug)]
901enum BodyShape<'a> {
902    String,
903    Record(Vec<(String, &'a SchemaType)>),
904    Other,
905    /// The body is a [`SchemaType::Ref`] that does not resolve to a concrete
906    /// type (a dangling reference or a pure alias cycle). The shape is unknown,
907    /// so shape-dependent discriminator checks are skipped; the unresolved
908    /// reference itself is reported separately by [`check_type`].
909    Unresolved,
910}
911
912fn resolved_shape<'a>(
913    graph: &'a SchemaGraph,
914    ty: &'a SchemaType,
915    visited: &mut HashSet<TypeId>,
916) -> BodyShape<'a> {
917    match ty {
918        SchemaType::Ref { id, .. } => {
919            if !visited.insert(id.clone()) {
920                return BodyShape::Unresolved;
921            }
922            match graph.lookup(id) {
923                Some(def) => resolved_shape(graph, &def.body, visited),
924                None => BodyShape::Unresolved,
925            }
926        }
927        SchemaType::String { .. } | SchemaType::Url { .. } | SchemaType::Path { .. } => {
928            BodyShape::String
929        }
930        SchemaType::Record { fields, .. } => BodyShape::Record(
931            fields
932                .iter()
933                .map(|f| (f.name.clone(), &f.body))
934                .collect::<Vec<_>>(),
935        ),
936        _ => BodyShape::Other,
937    }
938}
939
940#[cfg(test)]
941mod discriminator_pair_tests {
942    use super::{DiscriminatorPairClassification, classify_discriminator_pair};
943    use crate::schema::schema_type::{DiscriminatorRule, FieldDiscriminator};
944    use test_r::test;
945
946    #[derive(Clone, Copy, Debug)]
947    enum ExpectedClassification {
948        Reject,
949        Disjoint,
950        Indeterminate,
951    }
952
953    struct ClassificationCase {
954        name: &'static str,
955        left: DiscriminatorRule,
956        right: DiscriminatorRule,
957        expected: ExpectedClassification,
958    }
959
960    fn prefix(value: &str) -> DiscriminatorRule {
961        DiscriminatorRule::Prefix {
962            prefix: value.to_string(),
963        }
964    }
965
966    fn suffix(value: &str) -> DiscriminatorRule {
967        DiscriminatorRule::Suffix {
968            suffix: value.to_string(),
969        }
970    }
971
972    fn contains(value: &str) -> DiscriminatorRule {
973        DiscriminatorRule::Contains {
974            substring: value.to_string(),
975        }
976    }
977
978    fn regex(value: &str) -> DiscriminatorRule {
979        DiscriminatorRule::Regex {
980            regex: value.to_string(),
981        }
982    }
983
984    fn field_equals(field_name: &str, literal: Option<&str>) -> DiscriminatorRule {
985        DiscriminatorRule::FieldEquals(FieldDiscriminator {
986            field_name: field_name.to_string(),
987            literal: literal.map(str::to_string),
988        })
989    }
990
991    fn field_absent(field_name: &str) -> DiscriminatorRule {
992        DiscriminatorRule::FieldAbsent {
993            field_name: field_name.to_string(),
994        }
995    }
996
997    #[test]
998    fn discriminator_pair_classification_matches_portable_matrix() {
999        use ExpectedClassification::{Disjoint, Indeterminate, Reject};
1000
1001        let cases = vec![
1002            ClassificationCase {
1003                name: "prefix_prefix_nested_reject",
1004                left: prefix("a"),
1005                right: prefix("ab"),
1006                expected: Reject,
1007            },
1008            ClassificationCase {
1009                name: "prefix_prefix_disjoint",
1010                left: prefix("a"),
1011                right: prefix("b"),
1012                expected: Disjoint,
1013            },
1014            ClassificationCase {
1015                name: "empty_prefix_prefix_reject",
1016                left: prefix(""),
1017                right: prefix("a"),
1018                expected: Reject,
1019            },
1020            ClassificationCase {
1021                name: "suffix_suffix_nested_reject",
1022                left: suffix("ing"),
1023                right: suffix("ng"),
1024                expected: Reject,
1025            },
1026            ClassificationCase {
1027                name: "suffix_suffix_disjoint",
1028                left: suffix("a"),
1029                right: suffix("b"),
1030                expected: Disjoint,
1031            },
1032            ClassificationCase {
1033                name: "empty_suffix_suffix_reject",
1034                left: suffix(""),
1035                right: suffix("a"),
1036                expected: Reject,
1037            },
1038            ClassificationCase {
1039                name: "prefix_suffix_reject",
1040                left: prefix("a"),
1041                right: suffix("b"),
1042                expected: Reject,
1043            },
1044            ClassificationCase {
1045                name: "prefix_contains_reject",
1046                left: prefix("a"),
1047                right: contains("b"),
1048                expected: Reject,
1049            },
1050            ClassificationCase {
1051                name: "suffix_contains_reject",
1052                left: suffix("a"),
1053                right: contains("b"),
1054                expected: Reject,
1055            },
1056            ClassificationCase {
1057                name: "contains_contains_reject",
1058                left: contains("a"),
1059                right: contains("b"),
1060                expected: Reject,
1061            },
1062            ClassificationCase {
1063                name: "regex_regex_identical_reject",
1064                left: regex("a.*"),
1065                right: regex("a.*"),
1066                expected: Reject,
1067            },
1068            ClassificationCase {
1069                name: "regex_regex_distinct_indeterminate",
1070                left: regex("a.*"),
1071                right: regex(".*a"),
1072                expected: Indeterminate,
1073            },
1074            ClassificationCase {
1075                name: "regex_prefix_indeterminate",
1076                left: regex("^a"),
1077                right: prefix("a"),
1078                expected: Indeterminate,
1079            },
1080            ClassificationCase {
1081                name: "regex_empty_prefix_indeterminate",
1082                left: regex("^a"),
1083                right: prefix(""),
1084                expected: Indeterminate,
1085            },
1086            ClassificationCase {
1087                name: "regex_suffix_indeterminate",
1088                left: regex("a$"),
1089                right: suffix("a"),
1090                expected: Indeterminate,
1091            },
1092            ClassificationCase {
1093                name: "regex_empty_suffix_indeterminate",
1094                left: regex("a$"),
1095                right: suffix(""),
1096                expected: Indeterminate,
1097            },
1098            ClassificationCase {
1099                name: "regex_contains_indeterminate",
1100                left: regex("a"),
1101                right: contains("a"),
1102                expected: Indeterminate,
1103            },
1104            ClassificationCase {
1105                name: "regex_empty_contains_indeterminate",
1106                left: regex("a"),
1107                right: contains(""),
1108                expected: Indeterminate,
1109            },
1110            ClassificationCase {
1111                name: "prefix_field_equals_disjoint",
1112                left: prefix("a"),
1113                right: field_equals("kind", Some("a")),
1114                expected: Disjoint,
1115            },
1116            ClassificationCase {
1117                name: "prefix_field_absent_disjoint",
1118                left: prefix("a"),
1119                right: field_absent("kind"),
1120                expected: Disjoint,
1121            },
1122            ClassificationCase {
1123                name: "suffix_field_equals_disjoint",
1124                left: suffix("a"),
1125                right: field_equals("kind", Some("a")),
1126                expected: Disjoint,
1127            },
1128            ClassificationCase {
1129                name: "suffix_field_absent_disjoint",
1130                left: suffix("a"),
1131                right: field_absent("kind"),
1132                expected: Disjoint,
1133            },
1134            ClassificationCase {
1135                name: "contains_field_equals_disjoint",
1136                left: contains("a"),
1137                right: field_equals("kind", Some("a")),
1138                expected: Disjoint,
1139            },
1140            ClassificationCase {
1141                name: "contains_field_absent_disjoint",
1142                left: contains("a"),
1143                right: field_absent("kind"),
1144                expected: Disjoint,
1145            },
1146            ClassificationCase {
1147                name: "regex_field_equals_disjoint",
1148                left: regex("a"),
1149                right: field_equals("kind", Some("a")),
1150                expected: Disjoint,
1151            },
1152            ClassificationCase {
1153                name: "regex_field_absent_disjoint",
1154                left: regex("a"),
1155                right: field_absent("kind"),
1156                expected: Disjoint,
1157            },
1158            ClassificationCase {
1159                name: "field_equals_same_field_different_literals_disjoint",
1160                left: field_equals("kind", Some("a")),
1161                right: field_equals("kind", Some("b")),
1162                expected: Disjoint,
1163            },
1164            ClassificationCase {
1165                name: "field_equals_same_field_same_literal_reject",
1166                left: field_equals("kind", Some("a")),
1167                right: field_equals("kind", Some("a")),
1168                expected: Reject,
1169            },
1170            ClassificationCase {
1171                name: "field_equals_same_field_one_literal_absent_reject",
1172                left: field_equals("kind", None),
1173                right: field_equals("kind", Some("a")),
1174                expected: Reject,
1175            },
1176            ClassificationCase {
1177                name: "field_equals_same_field_both_literals_absent_reject",
1178                left: field_equals("kind", None),
1179                right: field_equals("kind", None),
1180                expected: Reject,
1181            },
1182            ClassificationCase {
1183                name: "field_equals_different_fields_reject",
1184                left: field_equals("left", Some("a")),
1185                right: field_equals("right", Some("b")),
1186                expected: Reject,
1187            },
1188            ClassificationCase {
1189                name: "field_absent_same_field_reject",
1190                left: field_absent("kind"),
1191                right: field_absent("kind"),
1192                expected: Reject,
1193            },
1194            ClassificationCase {
1195                name: "field_absent_different_fields_reject",
1196                left: field_absent("left"),
1197                right: field_absent("right"),
1198                expected: Reject,
1199            },
1200            ClassificationCase {
1201                name: "field_equals_field_absent_same_field_disjoint",
1202                left: field_equals("kind", Some("a")),
1203                right: field_absent("kind"),
1204                expected: Disjoint,
1205            },
1206            ClassificationCase {
1207                name: "field_equals_field_absent_different_fields_reject",
1208                left: field_equals("left", Some("a")),
1209                right: field_absent("right"),
1210                expected: Reject,
1211            },
1212        ];
1213
1214        for case in cases {
1215            assert_classification(
1216                case.name,
1217                "forward",
1218                classify_discriminator_pair(&case.left, &case.right),
1219                case.expected,
1220            );
1221            assert_classification(
1222                case.name,
1223                "reverse",
1224                classify_discriminator_pair(&case.right, &case.left),
1225                case.expected,
1226            );
1227        }
1228    }
1229
1230    fn assert_classification(
1231        name: &str,
1232        order: &str,
1233        actual: DiscriminatorPairClassification,
1234        expected: ExpectedClassification,
1235    ) {
1236        let matches = matches!(
1237            (&actual, expected),
1238            (
1239                DiscriminatorPairClassification::Reject(_),
1240                ExpectedClassification::Reject
1241            ) | (
1242                DiscriminatorPairClassification::Disjoint,
1243                ExpectedClassification::Disjoint
1244            ) | (
1245                DiscriminatorPairClassification::Indeterminate,
1246                ExpectedClassification::Indeterminate
1247            )
1248        );
1249        assert!(
1250            matches,
1251            "{name} ({order}): expected {expected:?}, got {actual:?}"
1252        );
1253    }
1254}