1use 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#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum SchemaError {
30 UnsupportedFeature(&'static str),
32 DuplicateTypeId(TypeId),
33 DanglingRef(TypeId),
34 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 InvalidNumericRestriction {
97 error: NumericRestrictionError,
98 },
99 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
221pub 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
250pub 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 Err(RefResolutionError::RecursiveRef(cycle_id)) => {
290 errors.push(SchemaError::RecursiveAlias(cycle_id));
291 }
292 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 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
451enum MapKeyKind {
454 Primitive,
456 NonPrimitive,
459 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 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
546fn 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 }
595
596fn check_url_spec(_spec: &UrlRestrictions, _errors: &mut Vec<SchemaError>) {
597 }
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 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 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
861fn 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
889fn 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 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}