Skip to main content

nextjson/
compat.rs

1//! Version-compatibility checking between two schemas.
2//!
3//! The same-derived-schema contract lets protocol evolution be checked as a
4//! pure function of two [`TypeSchema`] values: given the schema a previous
5//! release shipped and the schema the next release ships, [`check`] reports
6//! every change that can break an old reader consuming new data (forward
7//! compatibility) or a new reader consuming old data (backward
8//! compatibility), plus lower-severity risks and semantic notes.
9//!
10//! Directional meaning (matching how decoders behave):
11//!
12//! - **forward** — an old reader decodes data produced by the new schema;
13//! - **backward** — a new reader decodes data produced by the old schema.
14//!
15//! Reported classes:
16//!
17//! - `Critical` — a guaranteed (or near-guaranteed) decode break in one or
18//!   both directions: added required field, removed required field, renamed
19//!   field or variant, type-family change, added/removed enum variant, tag
20//!   representation change, requiredness narrowing.
21//! - `Warning` — no guaranteed break, but data-loss or edge-case risk:
22//!   narrowed integer range, integer-to-float, added optionality.
23//! - `Note` — behavior-compatible but semantically different: default value
24//!   changed, safety policy changed, optionality widened.
25//!
26//! This is a *static* report: it cannot know the actual values in the wild.
27//! A `Warning` (e.g. `i32` → `u8`) is safe only if the real data never
28//! exceeds the new range.
29
30use alloc::format;
31use alloc::string::{String, ToString};
32use alloc::vec::Vec;
33
34use crate::schema::{NsonSchema, TypeSchema};
35
36/// Severity of a compatibility issue.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
38pub enum Severity {
39    /// Behavior-compatible but semantically different.
40    Note,
41    /// Possible data loss or edge-case failure.
42    Warning,
43    /// Decode break in at least one direction.
44    Critical,
45}
46
47/// The class of a compatibility issue.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub enum CompatKind {
50    /// A field was added. `required` says whether it is required in the new
51    /// schema (a required addition breaks backward compatibility).
52    FieldAdded {
53        /// Whether the added field is required in the new schema.
54        required: bool,
55    },
56    /// A field was removed. `was_required` says whether it was required in
57    /// the old schema (a required removal breaks forward compatibility).
58    FieldRemoved {
59        /// Whether the removed field was required in the old schema.
60        was_required: bool,
61    },
62    /// A field changed its serialized name (same original Rust name).
63    FieldRenamed {
64        /// Old serialized name.
65        old: &'static str,
66        /// New serialized name.
67        new: &'static str,
68    },
69    /// The type family changed (string → number, struct → seq, ...).
70    TypeChanged {
71        /// Old type name.
72        old: &'static str,
73        /// New type name.
74        new: &'static str,
75    },
76    /// The inclusive integer range was narrowed (old values may not fit).
77    RangeNarrowed {
78        /// Old inclusive range `(min, max)`.
79        old: (i128, i128),
80        /// New inclusive range `(min, max)`.
81        new: (i128, i128),
82    },
83    /// An integer became a float (fractional new values may break old
84    /// readers).
85    IntToFloat,
86    /// A float became an integer (fractional old values may break new
87    /// readers).
88    FloatToInt,
89    /// An enum variant was added (old readers may see an unknown variant).
90    VariantAdded {
91        /// The added variant's serialized name.
92        name: &'static str,
93    },
94    /// An enum variant was removed (new readers may see an unknown variant).
95    VariantRemoved {
96        /// The removed variant's serialized name.
97        name: &'static str,
98    },
99    /// An enum variant changed its serialized name.
100    VariantRenamed {
101        /// Old serialized variant name.
102        old: &'static str,
103        /// New serialized variant name.
104        new: &'static str,
105    },
106    /// The enum tag / content / untagged representation changed.
107    TagChanged {
108        /// Old tag name.
109        old: Option<&'static str>,
110        /// New tag name.
111        new: Option<&'static str>,
112    },
113    /// The overall value shape changed (struct → enum, tuple arity, ...).
114    ShapeChanged {
115        /// Old shape name.
116        old: &'static str,
117        /// New shape name.
118        new: &'static str,
119    },
120    /// A required field became optional (new data may contain `null`).
121    OptionalAdded,
122    /// An optional field became required (old data may lack it).
123    OptionalRemoved,
124    /// A field's default value changed.
125    DefaultChanged {
126        /// The field whose default changed.
127        field: &'static str,
128    },
129    /// A field's safety policy changed (never wire-breaking).
130    PolicyChanged {
131        /// The field whose policy changed.
132        field: &'static str,
133    },
134}
135
136/// A single compatibility finding.
137#[derive(Clone, Debug, PartialEq, Eq)]
138pub struct CompatIssue {
139    /// How severe the change is.
140    pub severity: Severity,
141    /// Dotted path to the changed node (e.g. `user.address.city`).
142    pub path: String,
143    /// The class of change.
144    pub kind: CompatKind,
145    /// Human-readable explanation.
146    pub message: String,
147}
148
149/// The full result of a schema diff.
150#[derive(Clone, Debug, PartialEq, Eq)]
151pub struct CompatReport {
152    /// Whether an old reader can consume data produced by the new schema.
153    pub forward_compatible: bool,
154    /// Whether a new reader can consume data produced by the old schema.
155    pub backward_compatible: bool,
156    /// Every detected change.
157    pub issues: Vec<CompatIssue>,
158}
159
160impl CompatReport {
161    /// No `Critical` issues (warnings and notes are tolerated).
162    pub fn is_compatible(&self) -> bool {
163        !self.issues.iter().any(|i| i.severity == Severity::Critical)
164    }
165    /// The most severe issue, if any.
166    pub fn worst_severity(&self) -> Option<Severity> {
167        self.issues.iter().map(|i| i.severity).max()
168    }
169}
170
171/// Diff two schemas and report every change.
172///
173/// `old` is the schema a previous release shipped; `new` is the schema the
174/// next release ships. The report is a pure function of the two schemas; it
175/// cannot know the actual data in the field.
176pub 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
195/// Diff the compile-time schemas of two types.
196pub fn check_between<O: NsonSchema, N: NsonSchema>() -> CompatReport {
197    check(O::SCHEMA, N::SCHEMA)
198}
199
200/// Which directions a kind can break (`(forward, backward)`).
201fn 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                // Same range (e.g. `isize` vs `i64` on 64-bit): wire-compatible.
348                if ra == rb {
349                    return;
350                }
351                if rb.0 <= ra.0 && rb.1 >= ra.1 {
352                    // New range contains the old range: every old value fits.
353                    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/// Helper for tests: a required field with no policy.
556#[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        // i8 -> i16: every old value fits.
656        let r = check(TypeSchema::I8, TypeSchema::I16);
657        assert!(r.is_compatible());
658        assert!(r.issues.is_empty());
659
660        // i16 -> i8: values outside [-128,127] are lost.
661        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        // isize -> i64 on a 64-bit target: same range, wire-compatible.
669        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        // Option<i32> -> i32: old data may be null.
816        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        // i32 -> Option<i32>: new data may be null.
824        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        // i8 -> i32 is a widening: fully compatible, no issues.
868        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}