1use alloc::format;
31use alloc::string::{String, ToString};
32use alloc::vec::Vec;
33
34use crate::schema::{NsonSchema, TypeSchema};
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
38pub enum Severity {
39 Note,
41 Warning,
43 Critical,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
49pub enum CompatKind {
50 FieldAdded {
53 required: bool,
55 },
56 FieldRemoved {
59 was_required: bool,
61 },
62 FieldRenamed {
64 old: &'static str,
66 new: &'static str,
68 },
69 TypeChanged {
71 old: &'static str,
73 new: &'static str,
75 },
76 RangeNarrowed {
78 old: (i128, i128),
80 new: (i128, i128),
82 },
83 IntToFloat,
86 FloatToInt,
89 VariantAdded {
91 name: &'static str,
93 },
94 VariantRemoved {
96 name: &'static str,
98 },
99 VariantRenamed {
101 old: &'static str,
103 new: &'static str,
105 },
106 TagChanged {
108 old: Option<&'static str>,
110 new: Option<&'static str>,
112 },
113 ShapeChanged {
115 old: &'static str,
117 new: &'static str,
119 },
120 OptionalAdded,
122 OptionalRemoved,
124 DefaultChanged {
126 field: &'static str,
128 },
129 PolicyChanged {
131 field: &'static str,
133 },
134}
135
136#[derive(Clone, Debug, PartialEq, Eq)]
138pub struct CompatIssue {
139 pub severity: Severity,
141 pub path: String,
143 pub kind: CompatKind,
145 pub message: String,
147}
148
149#[derive(Clone, Debug, PartialEq, Eq)]
151pub struct CompatReport {
152 pub forward_compatible: bool,
154 pub backward_compatible: bool,
156 pub issues: Vec<CompatIssue>,
158}
159
160impl CompatReport {
161 pub fn is_compatible(&self) -> bool {
163 !self.issues.iter().any(|i| i.severity == Severity::Critical)
164 }
165 pub fn worst_severity(&self) -> Option<Severity> {
167 self.issues.iter().map(|i| i.severity).max()
168 }
169}
170
171pub fn check(old: TypeSchema, new: TypeSchema) -> CompatReport {
177 let mut report = CompatReport {
178 forward_compatible: true,
179 backward_compatible: true,
180 issues: Vec::new(),
181 };
182 diff(&mut report, "", old, new);
183 for issue in &report.issues {
184 let (breaks_forward, breaks_backward) = direction(&issue.kind);
185 if breaks_forward {
186 report.forward_compatible = false;
187 }
188 if breaks_backward {
189 report.backward_compatible = false;
190 }
191 }
192 report
193}
194
195pub fn check_between<O: NsonSchema, N: NsonSchema>() -> CompatReport {
197 check(O::SCHEMA, N::SCHEMA)
198}
199
200fn direction(kind: &CompatKind) -> (bool, bool) {
202 match kind {
203 CompatKind::FieldAdded { required: true } => (false, true),
204 CompatKind::FieldAdded { required: false } => (false, false),
205 CompatKind::FieldRemoved { was_required: true } => (true, false),
206 CompatKind::FieldRemoved {
207 was_required: false,
208 } => (false, false),
209 CompatKind::FieldRenamed { .. } => (true, true),
210 CompatKind::TypeChanged { .. } => (true, true),
211 CompatKind::RangeNarrowed { .. } => (false, true),
212 CompatKind::IntToFloat => (true, false),
213 CompatKind::FloatToInt => (false, true),
214 CompatKind::VariantAdded { .. } => (true, false),
215 CompatKind::VariantRemoved { .. } => (false, true),
216 CompatKind::VariantRenamed { .. } => (true, true),
217 CompatKind::TagChanged { .. } => (true, true),
218 CompatKind::ShapeChanged { .. } => (true, true),
219 CompatKind::OptionalAdded => (true, false),
220 CompatKind::OptionalRemoved => (false, true),
221 CompatKind::DefaultChanged { .. } => (false, false),
222 CompatKind::PolicyChanged { .. } => (false, false),
223 }
224}
225
226fn push(report: &mut CompatReport, severity: Severity, path: &str, kind: CompatKind) {
227 let message = message(&kind, path);
228 report.issues.push(CompatIssue {
229 severity,
230 path: path.to_string(),
231 kind,
232 message,
233 });
234}
235
236fn message(kind: &CompatKind, path: &str) -> String {
237 match kind {
238 CompatKind::FieldAdded { required: true } => {
239 format!("field `{path}` was added as required: new readers cannot read old data")
240 }
241 CompatKind::FieldAdded { required: false } => {
242 format!("field `{path}` was added (optional): compatible")
243 }
244 CompatKind::FieldRemoved { was_required: true } => {
245 format!("required field `{path}` was removed: old readers cannot read new data")
246 }
247 CompatKind::FieldRemoved {
248 was_required: false,
249 } => {
250 format!("optional field `{path}` was removed: compatible")
251 }
252 CompatKind::FieldRenamed { old, new } => {
253 format!("field `{path}` was renamed from `{old}` to `{new}` on the wire")
254 }
255 CompatKind::TypeChanged { old, new } => {
256 format!("field `{path}` changed type from `{old}` to `{new}`")
257 }
258 CompatKind::RangeNarrowed { old, new } => {
259 format!(
260 "field `{path}` integer range narrowed from {old:?} to {new:?}: \
261 values outside the new range cannot be represented"
262 )
263 }
264 CompatKind::IntToFloat => {
265 format!("field `{path}` changed from an integer to a float: fractional new values may not decode with old readers")
266 }
267 CompatKind::FloatToInt => {
268 format!("field `{path}` changed from a float to an integer: fractional old values may not decode with new readers")
269 }
270 CompatKind::VariantAdded { name } => {
271 format!("enum variant `{name}` was added: old readers may see an unknown variant")
272 }
273 CompatKind::VariantRemoved { name } => {
274 format!("enum variant `{name}` was removed: new readers may see an unknown variant")
275 }
276 CompatKind::VariantRenamed { old, new } => {
277 format!("enum variant `{old}` was renamed to `{new}` on the wire")
278 }
279 CompatKind::TagChanged { old, new } => {
280 format!("enum tag representation changed from `{old:?}` to `{new:?}`")
281 }
282 CompatKind::ShapeChanged { old, new } => {
283 format!("`{path}` shape changed from `{old}` to `{new}`")
284 }
285 CompatKind::OptionalAdded => {
286 format!(
287 "field `{path}` became optional: new data may contain null and fail old readers"
288 )
289 }
290 CompatKind::OptionalRemoved => {
291 format!("field `{path}` became required: old data may lack it and fail new readers")
292 }
293 CompatKind::DefaultChanged { field } => {
294 format!("default value of field `{field}` changed (behavior-compatible, semantically different)")
295 }
296 CompatKind::PolicyChanged { field } => {
297 format!("safety policy of `{field}` changed (not wire-breaking)")
298 }
299 }
300}
301
302fn diff(report: &mut CompatReport, path: &str, old: TypeSchema, new: TypeSchema) {
303 use TypeSchema::*;
304 if old == new {
305 return;
306 }
307 match (old, new) {
308 (Optional(oi), Optional(ni)) => diff(report, path, *oi, *ni),
309 (Optional(oi), ni) => {
310 push(
311 report,
312 Severity::Critical,
313 path,
314 CompatKind::OptionalRemoved,
315 );
316 diff(report, path, *oi, ni);
317 }
318 (oi, Optional(ni)) => {
319 push(report, Severity::Warning, path, CompatKind::OptionalAdded);
320 diff(report, path, oi, *ni);
321 }
322 (Seq(oi), Seq(ni)) => diff(report, path, *oi, *ni),
323 (Map(oi), Map(ni)) => diff(report, path, *oi, *ni),
324 (Tuple(oi), Tuple(ni)) => {
325 if oi.len() != ni.len() {
326 push(
327 report,
328 Severity::Critical,
329 path,
330 CompatKind::ShapeChanged {
331 old: "tuple",
332 new: "tuple",
333 },
334 );
335 }
336 for (idx, (o, n)) in oi.iter().zip(ni.iter()).enumerate() {
337 diff(report, &format!("{path}[{idx}]"), *o, *n);
338 }
339 }
340 (Struct(os), Struct(ns)) => diff_struct(report, path, os, ns),
341 (Enum(oe), Enum(ne)) => diff_enum(report, path, oe, ne),
342 (a, b) => {
343 if let (Some(ra), Some(rb)) = (
344 crate::schema::integer_range(a),
345 crate::schema::integer_range(b),
346 ) {
347 if ra == rb {
349 return;
350 }
351 if rb.0 <= ra.0 && rb.1 >= ra.1 {
352 return;
354 }
355 push(
356 report,
357 Severity::Warning,
358 path,
359 CompatKind::RangeNarrowed { old: ra, new: rb },
360 );
361 } else if crate::schema::integer_range(a).is_some() && crate::schema::is_float_type(b) {
362 push(report, Severity::Warning, path, CompatKind::IntToFloat);
363 } else if crate::schema::is_float_type(a) && crate::schema::integer_range(b).is_some() {
364 push(report, Severity::Critical, path, CompatKind::FloatToInt);
365 } else {
366 push(
367 report,
368 Severity::Critical,
369 path,
370 CompatKind::TypeChanged {
371 old: a.name(),
372 new: b.name(),
373 },
374 );
375 }
376 }
377 }
378}
379
380fn diff_struct(
381 report: &mut CompatReport,
382 path: &str,
383 os: &crate::schema::StructSchema,
384 ns: &crate::schema::StructSchema,
385) {
386 if os.transparent != ns.transparent {
387 push(
388 report,
389 Severity::Critical,
390 path,
391 CompatKind::ShapeChanged {
392 old: "struct",
393 new: "struct",
394 },
395 );
396 }
397 if os.max_depth != ns.max_depth || os.deny_unknown_fields != ns.deny_unknown_fields {
398 push(
399 report,
400 Severity::Note,
401 path,
402 CompatKind::PolicyChanged { field: os.name },
403 );
404 }
405 for of in os.fields {
406 let p = format!("{path}.{}", of.name);
407 match ns.fields.iter().find(|nf| nf.name == of.name) {
408 Some(nf) => {
409 diff(report, &p, of.ty, nf.ty);
410 if of.policy != nf.policy {
411 push(
412 report,
413 Severity::Note,
414 &p,
415 CompatKind::PolicyChanged { field: of.name },
416 );
417 }
418 match (of.required, nf.required) {
419 (false, true) => {
420 push(report, Severity::Critical, &p, CompatKind::OptionalRemoved);
421 }
422 (true, false) => {
423 push(report, Severity::Note, &p, CompatKind::OptionalAdded);
424 }
425 _ => {}
426 }
427 }
428 None => {
429 if let Some(nf) = ns.fields.iter().find(|nf| nf.orig == of.orig) {
430 push(
431 report,
432 Severity::Critical,
433 &p,
434 CompatKind::FieldRenamed {
435 old: of.name,
436 new: nf.name,
437 },
438 );
439 } else {
440 let severity = if of.required {
441 Severity::Critical
442 } else {
443 Severity::Note
444 };
445 push(
446 report,
447 severity,
448 &p,
449 CompatKind::FieldRemoved {
450 was_required: of.required,
451 },
452 );
453 }
454 }
455 }
456 }
457 for nf in ns.fields {
458 if !os
459 .fields
460 .iter()
461 .any(|of| of.name == nf.name || of.orig == nf.orig)
462 {
463 push(
464 report,
465 if nf.required {
466 Severity::Critical
467 } else {
468 Severity::Note
469 },
470 &format!("{path}.{}", nf.name),
471 CompatKind::FieldAdded {
472 required: nf.required,
473 },
474 );
475 }
476 }
477}
478
479fn diff_enum(
480 report: &mut CompatReport,
481 path: &str,
482 oe: &crate::schema::EnumSchema,
483 ne: &crate::schema::EnumSchema,
484) {
485 if oe.tag != ne.tag || oe.content != ne.content || oe.untagged != ne.untagged {
486 push(
487 report,
488 Severity::Critical,
489 path,
490 CompatKind::TagChanged {
491 old: oe.tag,
492 new: ne.tag,
493 },
494 );
495 }
496 if oe.max_depth != ne.max_depth || oe.deny_unknown_fields != ne.deny_unknown_fields {
497 push(
498 report,
499 Severity::Note,
500 path,
501 CompatKind::PolicyChanged { field: oe.name },
502 );
503 }
504 for ov in oe.variants {
505 match ne.variants.iter().find(|nv| nv.name == ov.name) {
506 Some(nv) => {
507 diff(report, &format!("{path}.{}", ov.name), ov.ty, nv.ty);
508 if ov.policy != nv.policy {
509 push(
510 report,
511 Severity::Note,
512 &format!("{path}.{}", ov.name),
513 CompatKind::PolicyChanged { field: ov.name },
514 );
515 }
516 }
517 None => {
518 if let Some(nv) = ne.variants.iter().find(|nv| nv.orig == ov.orig) {
519 push(
520 report,
521 Severity::Critical,
522 path,
523 CompatKind::VariantRenamed {
524 old: ov.name,
525 new: nv.name,
526 },
527 );
528 } else {
529 push(
530 report,
531 Severity::Critical,
532 path,
533 CompatKind::VariantRemoved { name: ov.name },
534 );
535 }
536 }
537 }
538 }
539 for nv in ne.variants {
540 if !oe
541 .variants
542 .iter()
543 .any(|ov| ov.name == nv.name || ov.orig == nv.orig)
544 {
545 push(
546 report,
547 Severity::Critical,
548 path,
549 CompatKind::VariantAdded { name: nv.name },
550 );
551 }
552 }
553}
554
555#[cfg(test)]
557const fn field(name: &'static str, ty: TypeSchema) -> crate::schema::FieldSchema {
558 crate::schema::FieldSchema {
559 name,
560 orig: name,
561 required: true,
562 flattened: false,
563 policy: crate::schema::Policy {
564 max_str_len: None,
565 max_items: None,
566 min: None,
567 max: None,
568 sensitive: false,
569 },
570 ty,
571 }
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use crate::schema::{EnumSchema, FieldSchema, StructSchema, VariantSchema};
578
579 const EMPTY: crate::schema::Policy = crate::schema::Policy {
580 max_str_len: None,
581 max_items: None,
582 min: None,
583 max: None,
584 sensitive: false,
585 };
586
587 #[test]
588 fn identical_schemas_are_compatible() {
589 const A: TypeSchema = TypeSchema::Struct(&StructSchema {
590 name: "S",
591 transparent: false,
592 max_depth: None,
593 deny_unknown_fields: false,
594 fields: &[field("x", TypeSchema::I32)],
595 });
596 let r = check(A, A);
597 assert!(r.is_compatible());
598 assert!(r.forward_compatible && r.backward_compatible);
599 assert!(r.issues.is_empty());
600 }
601
602 #[test]
603 fn added_required_field_breaks_backward_only() {
604 const OLD: TypeSchema = TypeSchema::Struct(&StructSchema {
605 name: "S",
606 transparent: false,
607 max_depth: None,
608 deny_unknown_fields: false,
609 fields: &[field("x", TypeSchema::I32)],
610 });
611 const NEW: TypeSchema = TypeSchema::Struct(&StructSchema {
612 name: "S",
613 transparent: false,
614 max_depth: None,
615 deny_unknown_fields: false,
616 fields: &[field("x", TypeSchema::I32), field("y", TypeSchema::I32)],
617 });
618 let r = check(OLD, NEW);
619 assert!(!r.backward_compatible);
620 assert!(r.forward_compatible);
621 assert_eq!(r.worst_severity(), Some(Severity::Critical));
622 assert!(r
623 .issues
624 .iter()
625 .any(|i| matches!(i.kind, CompatKind::FieldAdded { required: true })));
626 }
627
628 #[test]
629 fn removed_required_field_breaks_forward_only() {
630 const OLD: TypeSchema = TypeSchema::Struct(&StructSchema {
631 name: "S",
632 transparent: false,
633 max_depth: None,
634 deny_unknown_fields: false,
635 fields: &[field("x", TypeSchema::I32)],
636 });
637 const NEW: TypeSchema = TypeSchema::Struct(&StructSchema {
638 name: "S",
639 transparent: false,
640 max_depth: None,
641 deny_unknown_fields: false,
642 fields: &[],
643 });
644 let r = check(OLD, NEW);
645 assert!(!r.forward_compatible);
646 assert!(r.backward_compatible);
647 assert!(r
648 .issues
649 .iter()
650 .any(|i| matches!(i.kind, CompatKind::FieldRemoved { was_required: true })));
651 }
652
653 #[test]
654 fn widened_range_is_compatible_narrowed_is_warning() {
655 let r = check(TypeSchema::I8, TypeSchema::I16);
657 assert!(r.is_compatible());
658 assert!(r.issues.is_empty());
659
660 let r = check(TypeSchema::I16, TypeSchema::I8);
662 assert_eq!(r.worst_severity(), Some(Severity::Warning));
663 assert!(r
664 .issues
665 .iter()
666 .any(|i| matches!(i.kind, CompatKind::RangeNarrowed { .. })));
667
668 let r = check(TypeSchema::Isize, TypeSchema::I64);
670 assert!(r.is_compatible());
671 }
672
673 #[test]
674 fn int_to_float_and_back() {
675 assert_eq!(
676 check(TypeSchema::I32, TypeSchema::F64).worst_severity(),
677 Some(Severity::Warning)
678 );
679 assert_eq!(
680 check(TypeSchema::F64, TypeSchema::I32).worst_severity(),
681 Some(Severity::Critical)
682 );
683 }
684
685 #[test]
686 fn renamed_field_breaks_both() {
687 const OLD: TypeSchema = TypeSchema::Struct(&StructSchema {
688 name: "S",
689 transparent: false,
690 max_depth: None,
691 deny_unknown_fields: false,
692 fields: &[field("user_id", TypeSchema::U64)],
693 });
694 const NEW: TypeSchema = TypeSchema::Struct(&StructSchema {
695 name: "S",
696 transparent: false,
697 max_depth: None,
698 deny_unknown_fields: false,
699 fields: &[FieldSchema {
700 name: "userId",
701 orig: "user_id",
702 required: true,
703 flattened: false,
704 policy: crate::schema::Policy {
705 max_str_len: None,
706 max_items: None,
707 min: None,
708 max: None,
709 sensitive: false,
710 },
711 ty: TypeSchema::U64,
712 }],
713 });
714 let r = check(OLD, NEW);
715 assert!(!r.forward_compatible);
716 assert!(!r.backward_compatible);
717 assert!(r.issues.iter().any(|i| matches!(
718 i.kind,
719 CompatKind::FieldRenamed {
720 old: "user_id",
721 new: "userId"
722 }
723 )));
724 }
725
726 #[test]
727 fn enum_variant_added_and_removed() {
728 const E_ONE: TypeSchema = TypeSchema::Enum(&EnumSchema {
729 name: "E",
730 tag: None,
731 content: None,
732 untagged: false,
733 max_depth: None,
734 deny_unknown_fields: false,
735 default_tag: "type",
736 variants: &[
737 VariantSchema {
738 name: "One",
739 orig: "One",
740 policy: EMPTY,
741 ty: TypeSchema::Unit,
742 },
743 VariantSchema {
744 name: "Two",
745 orig: "Two",
746 policy: EMPTY,
747 ty: TypeSchema::Unit,
748 },
749 ],
750 });
751 const E_TWO: TypeSchema = TypeSchema::Enum(&EnumSchema {
752 name: "E",
753 tag: None,
754 content: None,
755 untagged: false,
756 max_depth: None,
757 deny_unknown_fields: false,
758 default_tag: "type",
759 variants: &[
760 VariantSchema {
761 name: "One",
762 orig: "One",
763 policy: EMPTY,
764 ty: TypeSchema::Unit,
765 },
766 VariantSchema {
767 name: "Two",
768 orig: "Two",
769 policy: EMPTY,
770 ty: TypeSchema::Unit,
771 },
772 VariantSchema {
773 name: "Three",
774 orig: "Three",
775 policy: EMPTY,
776 ty: TypeSchema::Unit,
777 },
778 ],
779 });
780 const E_THREE: TypeSchema = TypeSchema::Enum(&EnumSchema {
781 name: "E",
782 tag: None,
783 content: None,
784 untagged: false,
785 max_depth: None,
786 deny_unknown_fields: false,
787 default_tag: "type",
788 variants: &[VariantSchema {
789 name: "One",
790 orig: "One",
791 policy: EMPTY,
792 ty: TypeSchema::Unit,
793 }],
794 });
795
796 let added = check(E_ONE, E_TWO);
797 assert!(!added.forward_compatible);
798 assert!(added.backward_compatible);
799 assert!(added
800 .issues
801 .iter()
802 .any(|i| matches!(i.kind, CompatKind::VariantAdded { name: "Three" })));
803
804 let removed = check(E_ONE, E_THREE);
805 assert!(removed.forward_compatible);
806 assert!(!removed.backward_compatible);
807 assert!(removed
808 .issues
809 .iter()
810 .any(|i| matches!(i.kind, CompatKind::VariantRemoved { name: "Two" })));
811 }
812
813 #[test]
814 fn optionality_changes() {
815 let r = check(TypeSchema::Optional(&TypeSchema::I32), TypeSchema::I32);
817 assert!(!r.backward_compatible);
818 assert!(r
819 .issues
820 .iter()
821 .any(|i| matches!(i.kind, CompatKind::OptionalRemoved)));
822
823 let r = check(TypeSchema::I32, TypeSchema::Optional(&TypeSchema::I32));
825 assert!(!r.forward_compatible);
826 assert!(r
827 .issues
828 .iter()
829 .any(|i| matches!(i.kind, CompatKind::OptionalAdded)));
830 }
831
832 #[test]
833 fn nested_widening_is_compatible() {
834 const OLD: TypeSchema = TypeSchema::Struct(&StructSchema {
835 name: "Outer",
836 transparent: false,
837 max_depth: None,
838 deny_unknown_fields: false,
839 fields: &[field(
840 "inner",
841 TypeSchema::Struct(&StructSchema {
842 name: "Inner",
843 transparent: false,
844 max_depth: None,
845 deny_unknown_fields: false,
846 fields: &[field("a", TypeSchema::I8)],
847 }),
848 )],
849 });
850 const NEW: TypeSchema = TypeSchema::Struct(&StructSchema {
851 name: "Outer",
852 transparent: false,
853 max_depth: None,
854 deny_unknown_fields: false,
855 fields: &[field(
856 "inner",
857 TypeSchema::Struct(&StructSchema {
858 name: "Inner",
859 transparent: false,
860 max_depth: None,
861 deny_unknown_fields: false,
862 fields: &[field("a", TypeSchema::I32)],
863 }),
864 )],
865 });
866 let r = check(OLD, NEW);
867 assert!(r.is_compatible());
869 assert!(r.issues.is_empty());
870 }
871
872 #[test]
873 fn tag_change_is_critical() {
874 const PLAIN: TypeSchema = TypeSchema::Enum(&EnumSchema {
875 name: "E",
876 tag: None,
877 content: None,
878 untagged: false,
879 max_depth: None,
880 deny_unknown_fields: false,
881 default_tag: "type",
882 variants: &[VariantSchema {
883 name: "A",
884 orig: "A",
885 policy: EMPTY,
886 ty: TypeSchema::Unit,
887 }],
888 });
889 const TAGGED: TypeSchema = TypeSchema::Enum(&EnumSchema {
890 name: "E",
891 tag: Some("type"),
892 content: None,
893 untagged: false,
894 max_depth: None,
895 deny_unknown_fields: false,
896 default_tag: "type",
897 variants: &[VariantSchema {
898 name: "A",
899 orig: "A",
900 policy: EMPTY,
901 ty: TypeSchema::Unit,
902 }],
903 });
904 let r = check(PLAIN, TAGGED);
905 assert_eq!(r.worst_severity(), Some(Severity::Critical));
906 assert!(r
907 .issues
908 .iter()
909 .any(|i| matches!(i.kind, CompatKind::TagChanged { .. })));
910 }
911
912 #[test]
913 fn shape_change_is_critical() {
914 let r = check(TypeSchema::Str, TypeSchema::Seq(&TypeSchema::Str));
915 assert_eq!(r.worst_severity(), Some(Severity::Critical));
916 assert!(r
917 .issues
918 .iter()
919 .any(|i| matches!(i.kind, CompatKind::TypeChanged { .. })));
920 }
921}