Skip to main content

ridl_diff/
lib.rs

1//! The `ridl diff` IR-snapshot compare engine.
2//!
3//! The engine compares two resolved IR v2 snapshots and classifies every
4//! difference as a [`Change`] with a [`Category`] and a [`Verdict`]. It reads
5//! only the IR — never source — so the comparison is honest against exactly
6//! what a backend sees (ADR-0008 decision 14, concept note §9.1). Placement is
7//! deliberate: this crate is an engine surfaced by the `ridl` facade, never by
8//! `ridlc`, so the compiler stays a pure source→IR function (the ISO 26262
9//! tool-qualification boundary, ADR-0008 decision 9).
10//!
11//! The comparison has two halves. The walk ([`walk`]) says *what* structurally
12//! differs, emitting one [`Change`] per difference with a [`Category`]; the
13//! classifier ([`classify`]) says which *direction* that difference moved
14//! in and settles its [`Verdict`]. Splitting them is what lets a single
15//! structural category — an appended interaction, a changed timing — carry
16//! opposite verdicts depending on the direction, without the walk needing both
17//! snapshots at every emission site.
18//!
19//! This module owns the vocabulary ([`Verdict`], [`Category`], [`Change`],
20//! [`DiffReport`]), the set-level comparison ([`diff_sets`], and
21//! [`diff_workspaces`] with the system headings of [`system`]), and rendering
22//! ([`render_text`], [`render_json`]). It re-exports the snapshot loader
23//! [`load_ir_json`] from `ridl-ir`, which owns it. The
24//! classification table itself is documented per category by [`explain`], which
25//! `ridl diff --explain` prints.
26
27use ridl_ir::v2::{Package, System};
28
29mod classify;
30mod remote_inline;
31pub mod system;
32pub mod unit_verdict;
33mod walk;
34
35pub use classify::{absence_refused, category_from_word, classify, explain, is_above_high_water};
36// Re-exported under its earlier path (ADR-0008 decision 9).
37pub use ridl_ir::v2::{LoadError, load_ir_json};
38pub use system::{SystemChange, SystemHeading, diff_systems};
39pub use unit_verdict::{UnitVerdicts, unit_verdict};
40
41#[cfg(test)]
42mod tests;
43
44/// The compatibility verdict of a change or of a whole report — ordered so
45/// `Breaking > Compatible > Identical` and a report's verdict is the maximum
46/// over its changes.
47#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
48pub enum Verdict {
49    /// No differences at all.
50    Identical,
51    /// A consumer built against the old snapshot still works.
52    Compatible,
53    /// A consumer built against the old snapshot may break.
54    Breaking,
55}
56
57/// Declares the [`Category`] vocabulary once (ADR-0008 decision 21).
58///
59/// One list of variants expands to both the enum and the [`CATEGORIES`] array
60/// that `ridl diff --explain` iterates, so a variant that never reaches
61/// `CATEGORIES` cannot be written: there is no second list to forget. This
62/// replaces the guard PR #163 shipped, which expanded one list into an
63/// exhaustive `match` and an array *inside the test* — that narrowed the gap but
64/// left `CATEGORIES` shadowed rather than produced, and an assertion comparing
65/// two lists can be defeated by editing what feeds it.
66///
67/// A new variant therefore stops three functions compiling — [`classify`],
68/// [`explain`], and [`category_word`] — and reaches `CATEGORIES` with no second
69/// edit. The escape rustc's own `help:` text proposes for those three errors is
70/// a wildcard arm. Each of the three functions denies
71/// `clippy::wildcard_enum_match_arm` and
72/// `clippy::match_wildcard_for_single_variants` for exactly that reason. The
73/// second lint is the load-bearing one: the first does not fire when the
74/// wildcard covers a single variant, which is precisely the added-variant case,
75/// so denying it alone leaves clippy green.
76///
77/// How far the wildcard gets before clippy stops it depends on what its body
78/// says. `_ => todo!()` panics the `--explain` coverage test, and a bare
79/// `_ => "unknown"` fails it — the row names no verdict. A wildcard whose text
80/// happens to contain "compatible" passes all 115 tests. So the escape is real
81/// but narrow, and `cargo test` alone catches the two careless spellings of it.
82/// `just build` runs clippy so that the third is caught too.
83///
84/// What this does **not** close: rustc forces *an* arm, not the right one. A
85/// new variant given an explicit arm that classifies compatible, or whose rule
86/// row describes the wrong rule, still compiles and still passes. The [`explain`]
87/// coverage test checks that each row names a verdict and that its word
88/// round-trips; neither is proof that the row is correct.
89macro_rules! declare_categories {
90    (
91        $(#[$enum_meta:meta])*
92        $vis:vis enum $name:ident {
93            $(
94                $(#[$variant_meta:meta])*
95                $variant:ident,
96            )+
97        }
98    ) => {
99        $(#[$enum_meta])*
100        $vis enum $name {
101            $(
102                $(#[$variant_meta])*
103                $variant,
104            )+
105        }
106
107        /// Every category, in the order `--explain` lists them when asked for an
108        /// unknown one.
109        ///
110        /// Generated from the [`Category`] declaration by
111        /// [`declare_categories!`], not maintained beside it.
112        //
113        // The length counts the variants rather than being written down:
114        // `${count(...)}` is still unstable (rust-lang/rust#83527), so the
115        // count comes from the same repetition that fills the array.
116        pub const CATEGORIES: [$name; [$(stringify!($variant)),+].len()] =
117            [$($name::$variant),+];
118    };
119}
120
121declare_categories! {
122    /// The kind of a single difference. The walk emits the structural categories;
123    /// the classifier maps them to directional verdicts.
124    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
125    pub enum Category {
126        /// A package-level declaration, interface, or service present only in the
127        /// new snapshot.
128        DeclAdded,
129        /// A package-level declaration, interface, or service present only in the
130        /// old snapshot. An interface is matched by its `interfaces.lock`
131        /// number (lock design §7), so an interface here is one whose number
132        /// is gone from the new snapshot and not retired there.
133        DeclRemoved,
134        /// An interface whose `interfaces.lock` number is the same on both
135        /// sides and whose name changed (lock design §7). The number is the
136        /// interface's identity and its routing key, so nothing moves on the
137        /// wire; a rename is visible in source — it changes the generated
138        /// identity-table names in both wire backends — so the text report
139        /// lists it under the heading ([`heading`]). The path carries the new
140        /// name; the detail carries the old and the new name.
141        InterfaceRenamed,
142        /// An interface whose number the old snapshot held and the new
143        /// snapshot's retired entries list (lock design §7): the sanctioned
144        /// removal of an interface, recorded by `ridl lock --retire`.
145        InterfaceRetired,
146        /// An interface matched by name across a provisional old side whose
147        /// number is the same on both sides and whose provisional flag went
148        /// from true to false: the number `ridl lock` froze in place. Nothing
149        /// moves on the wire, but the catalog hash covers the number and the
150        /// flag, so the hash changes and the text report lists it under the
151        /// heading "compatible on the wire, may change the catalog hash"
152        /// ([`heading`]). A number 0 on
153        /// either side, from a snapshot written before the lock existed, is
154        /// not compared. The path carries the name; the detail carries the
155        /// old and the new number, each marked when it is provisional.
156        InterfaceFrozen,
157        /// A matched interface whose number differs between the two sides,
158        /// whatever the two flags: a provisional number moved by a sibling
159        /// added before it, or frozen as another number. The number is the
160        /// routing key, so the change is breaking. A number 0 on either side,
161        /// from a snapshot written before the lock existed, is not compared. The
162        /// detail carries the old and the new number, each marked when it is
163        /// provisional.
164        InterfaceNumberChanged,
165        /// A surviving composite member whose slot in the body changed — a
166        /// struct field, enum value, enum-set bit or union arm — reported only
167        /// when both bodies hold the same member names. For a struct field or
168        /// union arm the slot is the ordinal, which is wire identity (typl
169        /// §7.4), and the detail carries the old and new ordinal. An enum value
170        /// or enum-set bit takes its identity from the explicit number it
171        /// declares instead (typl §8, §9), so its slot is that number: the
172        /// category reports a member whose number changed, with the old and
173        /// new value or bit in the detail. When every difference between the
174        /// two bodies is a changed number, the container's
175        /// [`Category::ConstraintChanged`] is not reported beside it. A body
176        /// of an enum or an enum set that differs only in order is
177        /// [`Category::EnumReordered`] instead.
178        MemberReordered,
179        /// An enum or enum-set body whose difference is only order: its live
180        /// values or bits reordered with no number changed, the enum's
181        /// `reserved` list reordered with no entry added, removed or changed,
182        /// or both. A value or bit takes its identity from the explicit number
183        /// it declares (typl §8, §9), so nothing moves on the wire. The catalog
184        /// hash covers only the declarations an interface reaches; when an
185        /// interface reaches the enum or enum set, the order of its values or
186        /// bits, and of an enum's reserved list, is part of the hash, so the
187        /// hash changes then. The text report lists it under the heading that
188        /// [`Category::InterfaceFrozen`] shares ([`heading`]). The path is the
189        /// container's; there is no detail.
190        EnumReordered,
191        /// A new interaction added at the end of an interface (no earlier
192        /// interaction shifted).
193        InteractionAppended,
194        /// A new interaction added before the end — an earlier interaction now
195        /// sits after it (ridl §11: insert shifts ordinals, a wire break).
196        InteractionInserted,
197        /// A surviving interaction whose relative order within the interface
198        /// changed (ridl §11: reorder shifts ordinals, a wire break).
199        InteractionReordered,
200        /// An interaction removed without leaving a `reserved` tombstone.
201        InteractionRemoved,
202        /// An interaction removed and replaced by a `reserved` tombstone in the
203        /// same slot (ridl §11).
204        InteractionRetired,
205        /// A surviving interaction whose kind changed (signal ↔ event, etc.).
206        KindChanged,
207        /// A signal/event/fixed payload type changed.
208        PayloadChanged,
209        /// A query return type changed.
210        ReturnChanged,
211        /// A command/query parameter list changed.
212        ParamsChanged,
213        /// A signal/event resolved timing changed.
214        TimingChanged,
215        /// A command/query declared RPC bound changed (ADR-0015 decision 8).
216        /// Separate from [`Category::TimingChanged`] because the direction of
217        /// `min` inverts: on an RPC it constrains the caller, not the
218        /// provider, so the signal/event rule must not be inherited silently.
219        RpcBoundChanged,
220        /// The payload limit declared for an interaction changed, was added or
221        /// was removed (ADR-0030 decision 13). Any change is breaking: the
222        /// limit is part of the catalog hash, so peers with different limits
223        /// do not attach.
224        LimitChanged,
225        /// A command/query require/ensure clause set changed.
226        ContractChanged,
227        /// A name-paired interaction's family or operation shape changed
228        /// (ADR-0012 decision 6).
229        InteractionClassChanged,
230        /// A derived wire width or scalar backing changed.
231        WidthChanged,
232        /// A scalar constraint (range, step, length, pattern) changed, or a
233        /// composite member changed in place.
234        ConstraintChanged,
235        /// A resolved or declared init value changed.
236        InitChanged,
237        /// A name that was a `reserved` tombstone is live again — an
238        /// interaction inside an interface body.
239        ReservedNameRedeclared,
240        /// A service switched between the named list and an inline shape.
241        /// Narrowed to the form switch by ADR-0015 decision 19: a changed
242        /// list is read as a set by the two `ServiceInterface*` categories
243        /// below.
244        ServiceChanged,
245        /// An interface in a service's set that the old snapshot's set did not
246        /// hold (ADR-0015 decision 19 as amended on 2026-09-15: a service's
247        /// list is a set of interface references).
248        ServiceInterfaceAdded,
249        /// An interface the old snapshot's set held that the new one does not.
250        /// Compatible on the wire — the routing key does not contain the
251        /// service — and visible in source, so the text report lists it under
252        /// that heading ([`heading`]).
253        ServiceInterfaceRemoved,
254        /// Only doc comment, labels, or deprecation metadata changed.
255        DocOnly,
256        /// The visibility a declaration is published at changed. Separate from
257        /// [`Category::DocOnly`] because `internal` removes the declaration from
258        /// every out-of-package consumer (ADR-0002 §8), so the change has a
259        /// direction.
260        VisibilityChanged,
261        /// A struct field appended, compatible under the generic rule, to a
262        /// struct another head of the old side holds inline, when the field
263        /// does not fit entirely inside the old head: the layout law places
264        /// any of its bytes past the old head's size (ADR-0031 decision 10,
265        /// the inline-append rule;
266        /// `ridl diff --explain podbuf_inline_append` states it in full).
267        /// Breaking when the comparison is judged for the `podbuf` encoding,
268        /// compatible otherwise, listed under the heading "breaking under
269        /// podbuf" ([`heading`]). The path is the appended field's; the
270        /// detail names each inline use of the old side, a struct field as
271        /// `<package>/<struct>/<field>` and an interaction's request, reply
272        /// or fixed payload as `<package>/<interface>/<interaction>`.
273        PodbufInlineAppend,
274        /// A remote declaration held inline by an old consumer has a larger podbuf size.
275        PodbufRemoteInlineGrowth,
276        /// An enum value, an enum-set flag or a union arm appended, compatible
277        /// under the generic rule, after which the podbuf integer of the enum
278        /// or the enum set or the tag of the union is a different integer, by
279        /// width or by signedness (ADR-0031 decision 10, the width rule;
280        /// `ridl diff --explain podbuf_width_change` states it in full).
281        /// Breaking when `podbuf` is among the encodings the comparison is
282        /// judged for, compatible otherwise. Reported once per declaration,
283        /// at `<package>/<decl>`, with the old and the new integer.
284        PodbufWidthChange,
285        /// Superseded by [`Category::StrictEnumAppend`], which is breaking for
286        /// every encoding. The variant stays so that a report or a consumer
287        /// that names the category still compiles; `ridl diff` no longer
288        /// reports it.
289        PodbufStrictEnumAppend,
290        /// An enum value or an enum-set flag appended to an enum or an enum
291        /// set that is not `open` (ADR-0031 decision 12). A reader built from
292        /// the old declaration refuses a value or a flag it does not name, in
293        /// the `verify` of podbuf and in the codec of every other encoding
294        /// that checks the declaration, so the append is breaking for every
295        /// encoding. Reported once per declaration, at `<package>/<decl>`,
296        /// with the appended names in the detail. The same append to an
297        /// `open` declaration is compatible.
298        StrictEnumAppend,
299        /// An enum or an enum set that gained the `open` keyword: every
300        /// reader of the old declaration already accepts the values it
301        /// named, so the change is compatible. Reported at
302        /// `<package>/<decl>`.
303        EnumOpened,
304        /// An enum or an enum set that lost the `open` keyword: a reader of
305        /// the new declaration refuses a value that a producer of the old one
306        /// may still send, so the change is breaking. Reported at
307        /// `<package>/<decl>`.
308        EnumClosed,
309        /// An enum value or an enum-set flag appended, compatible under the
310        /// generic rule, after which the FlatBuffers integer of the enum or
311        /// the enum set is a different integer (ADR-0019 decision 10;
312        /// `ridl diff --explain flatbuffers_width_change` states it in full).
313        /// Breaking when `flatbuffers` is among the encodings the comparison
314        /// is judged for, compatible otherwise. Reported once per declaration,
315        /// at `<package>/<decl>`, with the old and the new integer.
316        /// With per-side contexts, a remote declaration reached by a surviving
317        /// old consumer use is reported once per consumer path and remote
318        /// declaration, at the consumer path, with
319        /// `<remote declaration>: <integer>` values. A side without remote
320        /// context draws no remote width line; shared-context entry points
321        /// do not report remote widths.
322        FlatbuffersWidthChange,
323        /// An enum-set flag appended, compatible under the generic rule,
324        /// after which the proto3 scalar of the enum set is a different
325        /// scalar (ADR-0017 decision 9;
326        /// `ridl diff --explain proto3_width_change` states it in full).
327        /// Breaking when `proto3` is among the encodings the comparison is
328        /// judged for, compatible otherwise. Reported once per declaration,
329        /// at `<package>/<decl>`, with the old and the new proto3 scalar.
330        /// With per-side contexts, a remote declaration reached by a surviving
331        /// old consumer use is reported once per consumer path and remote
332        /// declaration, at the consumer path, with
333        /// `<remote declaration>: <integer>` values. A side without remote
334        /// context draws no remote width line; shared-context entry points
335        /// do not report remote widths.
336        Proto3WidthChange,
337    }
338}
339
340/// One difference between two snapshots, with an honest path into the IR and
341/// the rendered before/after values where they apply.
342#[derive(Clone, Debug, PartialEq, Eq)]
343pub struct Change {
344    /// A slash-separated path, e.g. `veh.cluster/VehicleStatus/doorOpened`.
345    pub path: String,
346    pub category: Category,
347    pub verdict: Verdict,
348    /// The rendered old value, absent when the change is an addition.
349    pub before: Option<String>,
350    /// The rendered new value, absent when the change is a removal.
351    pub after: Option<String>,
352}
353
354impl Change {
355    /// The dotted name of the package the change belongs to: the first
356    /// segment of [`path`](Change::path). The walk builds the path as
357    /// `<package>/<name>[/<member>...]`, and this accessor and
358    /// [`declaration`](Change::declaration) are the one place that reads that
359    /// layout back.
360    pub fn package(&self) -> &str {
361        self.path.split('/').next().unwrap_or_default()
362    }
363
364    /// The bare name of the package-level declaration, interface or service
365    /// the change belongs to: the second segment of [`path`](Change::path), or
366    /// `None` for a change to the package itself.
367    pub fn declaration(&self) -> Option<&str> {
368        self.path.split('/').nth(1)
369    }
370}
371
372/// The result of a comparison: every change and the report-level verdict (the
373/// maximum verdict over the changes; [`Verdict::Identical`] when there are
374/// none).
375#[derive(Clone, Debug, PartialEq, Eq)]
376pub struct DiffReport {
377    pub changes: Vec<Change>,
378    pub verdict: Verdict,
379    /// The changes to the lowered system, each under its heading (rsdl
380    /// reference §14). Only a change under
381    /// [`SystemHeading::InstanceNumberChanged`] enters `verdict`, as breaking;
382    /// the other headings carry no verdict. Empty unless
383    /// both sides carry a system ([`diff_workspaces`]).
384    pub system: Vec<SystemChange>,
385}
386
387/// Appends one change.
388///
389/// The verdict is stamped breaking here and settled by [`classify`] once the
390/// walk of the containing package pair is complete — the classifier needs both
391/// snapshots, which the walk does not carry down to every emission site.
392/// Breaking is the safe placeholder: a change that somehow escaped
393/// classification would gate rather than pass.
394pub(crate) fn emit(
395    changes: &mut Vec<Change>,
396    path: String,
397    category: Category,
398    before: Option<String>,
399    after: Option<String>,
400) {
401    changes.push(Change {
402        path,
403        category,
404        verdict: Verdict::Breaking,
405        before,
406        after,
407    });
408}
409
410/// Whether an interface carries an identity: a frozen, non-zero number from
411/// its unit's `interfaces.lock` (lock design §7). A provisional number is
412/// no identity, and `number` 0 — never allocated — marks a snapshot published
413/// before the lock existed. The walk matches by number only when both sides
414/// have one, and the classifier re-finds the old side the same way.
415pub(crate) fn frozen(interface: &ridl_ir::v2::Interface) -> bool {
416    interface.number != 0 && !interface.provisional
417}
418
419/// Settles the verdict of every change the walk of one package pair produced.
420/// `scope` is every package a reference to another package's declaration
421/// resolves against ([`classify::classify_in`]); `encodings` is the list the
422/// comparison is judged for ([`diff_refs_with`]).
423///
424/// A change's new side is in `new`, except for an interface whose number
425/// moved to another package of the unit: the walk puts that package first in
426/// the change's path, and the classifier reads the new side from it, so the
427/// package is taken from `scope`, which holds every package of the new set.
428fn classify_all(
429    changes: &mut [Change],
430    old: &Package,
431    new: &Package,
432    scope: &[&Package],
433    encodings: &[&str],
434) {
435    for change in changes {
436        let held_by = change.path.split('/').next().unwrap_or_default();
437        let new = if held_by == new.name {
438            new
439        } else {
440            scope
441                .iter()
442                .copied()
443                .find(|package| package.name == held_by)
444                .unwrap_or(new)
445        };
446        change.verdict = classify::classify_in(change, old, new, scope, encodings);
447    }
448}
449
450/// Assembles a [`DiffReport`], deriving the report verdict as the maximum over
451/// its changes.
452pub(crate) fn report(changes: Vec<Change>) -> DiffReport {
453    let verdict = changes
454        .iter()
455        .map(|change| change.verdict)
456        .max()
457        .unwrap_or(Verdict::Identical);
458    DiffReport {
459        changes,
460        verdict,
461        system: Vec::new(),
462    }
463}
464
465/// Compares two resolved packages. Matched packages share a name; the new
466/// package's name is used as the path prefix.
467///
468/// Only the pair is in hand, so a type from another package does not resolve,
469/// and a type the diff cannot resolve is reported as breaking: a struct field
470/// appended with such a type classifies breaking (driftsys/ridl#598).
471/// [`diff_sets_in`] resolves it.
472pub fn diff_packages(old: &Package, new: &Package) -> DiffReport {
473    let matching = walk::Matching::new(&[old], &[new]);
474    let mut changes = Vec::new();
475    walk::walk_packages(old, new, &matching, &mut changes);
476    let mut changes = walk::fold_strict_enum_appends(changes, old, new);
477    classify_all(&mut changes, old, new, &[], &[]);
478    report(changes)
479}
480
481/// Compares two sets of resolved packages, matching by package name, and
482/// interface numbers within each unit ([`walk::Matching`]). A package present
483/// only on the new side is a [`Category::DeclAdded`]; matched packages are
484/// walked pairwise. A package present only on the old side is a
485/// [`Category::DeclRemoved`] when its whole unit is gone from the new side,
486/// and is walked declaration by declaration when the unit is still there, so
487/// that an interface whose number moved to another package of the unit is
488/// [`Category::InterfaceRenamed`] and one the unit retired is
489/// [`Category::InterfaceRetired`].
490///
491/// [`diff_sets_in`] with no context: a type from a package outside `new`, such
492/// as `ridl.std`, which no snapshot carries, does not resolve, and a type the
493/// diff cannot resolve is reported as breaking.
494pub fn diff_sets(old: &[Package], new: &[Package]) -> DiffReport {
495    diff_sets_in(old, new, &[])
496}
497
498/// [`diff_sets`], with `context`: packages that a type reference resolves
499/// against without being compared. This entry point shares one context.
500/// `ridl diff` and the MCP server use per-side contexts through
501/// [`diff_workspaces_with_contexts`]: the built-in `ridl.std` plus each side's
502/// own remote packages, compiled from source or retained in a snapshot
503/// directory, so historical layouts never borrow current remote definitions.
504/// `ridl check`'s baseline desk check passes one shared context and judges no encoding.
505pub fn diff_sets_in(old: &[Package], new: &[Package], context: &[Package]) -> DiffReport {
506    let old: Vec<&Package> = old.iter().collect();
507    let new: Vec<&Package> = new.iter().collect();
508    let context: Vec<&Package> = context.iter().collect();
509    diff_refs_in(&old, &new, &context)
510}
511
512/// [`diff_sets_in`] over borrowed packages, for a caller that holds the
513/// packages by reference and would otherwise clone them. The comparison is
514/// judged for no encoding: [`diff_refs_with`] with an empty list.
515pub fn diff_refs_in(old: &[&Package], new: &[&Package], context: &[&Package]) -> DiffReport {
516    diff_refs_with(old, new, context, &[])
517}
518
519/// [`diff_refs_in`], judged for `encodings`: the names a `[backend.<name>]
520/// encodings` list writes (`flatbuffers`, `proto3`, `podbuf`). A snapshot
521/// does not carry the encodings a workspace builds, so the caller names them.
522///
523/// Each of the three names has rules of its own: a struct field appended that
524/// does not fit inside the old head of a struct another head of the old
525/// side holds inline is [`Category::PodbufInlineAppend`], and an enum value,
526/// an enum-set flag or a union arm appended that changes the integer of the
527/// enum or the enum set or the union's tag is [`Category::PodbufWidthChange`];
528/// each is
529/// breaking when `podbuf` is in `encodings` and compatible otherwise. An enum
530/// value or an enum-set flag appended to a declaration that is not `open` is
531/// [`Category::StrictEnumAppend`], breaking under every list. An enum value or
532/// an enum-set flag appended that changes the FlatBuffers integer is
533/// [`Category::FlatbuffersWidthChange`], breaking when `flatbuffers` is in
534/// `encodings` and compatible otherwise. An enum-set flag appended that
535/// changes the proto3 scalar is [`Category::Proto3WidthChange`], breaking
536/// when `proto3` is in `encodings` and compatible otherwise. Every other
537/// category is judged the same under every list. The CLI and the MCP tool
538/// refuse a name that is not one of the three before calling this; the engine
539/// does not check it.
540/// The podbuf rule lays the old side out over `old` and the context packages
541/// `old` does not name, and the new side over `new` and `context`; a package
542/// in both `new` and `context` is read at its first occurrence.
543pub fn diff_refs_with(
544    old: &[&Package],
545    new: &[&Package],
546    context: &[&Package],
547    encodings: &[&str],
548) -> DiffReport {
549    diff_refs_scoped(old, new, context, context, encodings)
550}
551
552/// The side whose remote evidence cannot establish inline compatibility.
553#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
554pub enum ContextSide {
555    Old,
556    New,
557}
558
559impl std::fmt::Display for ContextSide {
560    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
561        f.write_str(match self {
562            Self::Old => "old",
563            Self::New => "new",
564        })
565    }
566}
567
568/// Why remote evidence cannot establish inline compatibility.
569#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
570pub enum ContextIssueReason {
571    Missing,
572    Unlayable,
573    KindChanged,
574}
575
576/// Unusable evidence for a consumer-owned podbuf comparison.
577#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
578pub struct ContextIssue {
579    pub path: String,
580    pub declaration: String,
581    pub side: ContextSide,
582    pub reason: ContextIssueReason,
583}
584
585impl std::fmt::Display for ContextIssue {
586    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
587        match self.reason {
588            ContextIssueReason::Missing => write!(
589                f,
590                "{}: {} context is missing {}",
591                self.path, self.side, self.declaration
592            ),
593            ContextIssueReason::Unlayable => write!(
594                f,
595                "{}: {} context cannot lay out {}",
596                self.path, self.side, self.declaration
597            ),
598            ContextIssueReason::KindChanged => write!(
599                f,
600                "{}: {} context changes {} from a struct to another declaration kind",
601                self.path, self.side, self.declaration
602            ),
603        }
604    }
605}
606
607/// An explicit podbuf comparison cannot use its own side's remote evidence.
608#[derive(Clone, Debug, PartialEq, Eq)]
609pub struct ContextError {
610    pub issues: Vec<ContextIssue>,
611}
612
613impl std::fmt::Display for ContextError {
614    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
615        for (index, issue) in self.issues.iter().enumerate() {
616            if index != 0 {
617                f.write_str("; ")?;
618            }
619            write!(f, "{issue}")?;
620        }
621        Ok(())
622    }
623}
624impl std::error::Error for ContextError {}
625
626/// Compares packages using independent source-side resolution scopes.
627/// Missing remote evidence prevents only an explicit podbuf judgment.
628/// Context packages resolve references without being compared themselves.
629/// Local packages take precedence over context packages with the same name.
630/// Growth is reported once per old consumer path and remote declaration;
631/// issues are sorted by consumer path, declaration, then side.
632pub fn diff_refs_with_contexts(
633    old: &[&Package],
634    new: &[&Package],
635    old_context: &[&Package],
636    new_context: &[&Package],
637    encodings: &[&str],
638) -> Result<DiffReport, ContextError> {
639    let (result, issues) =
640        diff_refs_with_contexts_partial(old, new, old_context, new_context, encodings);
641    if encodings.contains(&"podbuf") && !issues.is_empty() {
642        return Err(ContextError { issues });
643    }
644    Ok(result)
645}
646
647/// Compares complete scopes while retaining independent missing-evidence issues.
648/// The report is not an affirmative compatibility judgment for any unit that
649/// owns or reaches an issue's consumer declaration. Such units require recovery;
650/// callers may use the report's proved verdicts for unaffected units.
651/// Remote FlatBuffers and proto3 widths are reported once per consumer path
652/// and remote declaration reached by a surviving old consumer use, at the
653/// consumer path, with `<remote declaration>: <integer>` values. Only the
654/// per-side context entry points report remote widths; a side without remote
655/// context draws no such line.
656pub fn diff_refs_with_contexts_partial(
657    old: &[&Package],
658    new: &[&Package],
659    old_context: &[&Package],
660    new_context: &[&Package],
661    encodings: &[&str],
662) -> (DiffReport, Vec<ContextIssue>) {
663    let old_scope: Vec<_> = old.iter().chain(old_context).copied().collect();
664    let new_scope: Vec<_> = new.iter().chain(new_context).copied().collect();
665    let (mut remote, issues) = remote_inline::compare(old, new, &old_scope, &new_scope, encodings);
666    let mut result = diff_refs_scoped(old, new, old_context, new_context, encodings);
667    result.changes.append(&mut remote);
668    (report(result.changes), issues)
669}
670
671fn diff_refs_scoped(
672    old: &[&Package],
673    new: &[&Package],
674    old_context: &[&Package],
675    new_context: &[&Package],
676    encodings: &[&str],
677) -> DiffReport {
678    use std::collections::BTreeMap;
679
680    use ridl_ir::v2::{members_of_unit, unit_of};
681
682    let scope: Vec<&Package> = new.iter().chain(new_context).copied().collect();
683    let matching = walk::Matching::new(old, new);
684
685    let old_by: BTreeMap<&str, &Package> =
686        old.iter().map(|pkg| (pkg.name.as_str(), *pkg)).collect();
687    let new_by: BTreeMap<&str, &Package> =
688        new.iter().map(|pkg| (pkg.name.as_str(), *pkg)).collect();
689    // Old packages take precedence over their own context, independently
690    // of the new side's definitions.
691    let old_scope: Vec<&Package> = old
692        .iter()
693        .copied()
694        .chain(
695            old_context
696                .iter()
697                .copied()
698                .filter(|pkg| !old_by.contains_key(pkg.name.as_str())),
699        )
700        .collect();
701
702    // Each matched pair is walked and classified against its own two snapshots,
703    // because the classifier resolves a change's path back into the packages it
704    // came from.
705    let mut changes = Vec::new();
706    for (name, old_pkg) in &old_by {
707        match new_by.get(name) {
708            Some(new_pkg) => {
709                let mut pair = Vec::new();
710                walk::walk_packages(old_pkg, new_pkg, &matching, &mut pair);
711                let mut pair = walk::fold_strict_enum_appends(pair, old_pkg, new_pkg);
712                classify_all(&mut pair, old_pkg, new_pkg, &scope, encodings);
713                // The encoding rules read the settled verdicts of the pair
714                // and the heads of both whole sides, so they run after the
715                // walk and the classifier of the pair.
716                let mut encoding_changes =
717                    walk::podbuf_inline_appends(&pair, old, &old_scope, &scope);
718                encoding_changes.append(&mut walk::podbuf_width_changes(&pair, old_pkg, new_pkg));
719                encoding_changes.append(&mut walk::flatbuffers_width_changes(
720                    &pair, old_pkg, new_pkg,
721                ));
722                encoding_changes.append(&mut walk::proto3_width_changes(&pair, old_pkg, new_pkg));
723                classify_all(&mut encoding_changes, old_pkg, new_pkg, &scope, encodings);
724                pair.append(&mut encoding_changes);
725                changes.append(&mut pair);
726            }
727            // A package gone while its unit stays: walked against a package
728            // with nothing in it, so that each shape is matched in the unit,
729            // retired by the unit, or removed, and each other declaration is
730            // removed on its own line.
731            None if members_of_unit(unit_of(old_pkg), new).next().is_some() => {
732                let gone = Package {
733                    name: old_pkg.name.clone(),
734                    unit: old_pkg.unit.clone(),
735                    ..Default::default()
736                };
737                let mut pair = Vec::new();
738                walk::walk_packages(old_pkg, &gone, &matching, &mut pair);
739                classify_all(&mut pair, old_pkg, &gone, &scope, encodings);
740                changes.append(&mut pair);
741            }
742            // A package present on one side only: the change classifies on its
743            // category alone, so the one snapshot stands for both.
744            None => {
745                let mut pair = Vec::new();
746                emit(
747                    &mut pair,
748                    (*name).to_string(),
749                    Category::DeclRemoved,
750                    Some(format!("package {name}")),
751                    None,
752                );
753                classify_all(&mut pair, old_pkg, old_pkg, &[], encodings);
754                changes.append(&mut pair);
755            }
756        }
757    }
758    for (name, new_pkg) in &new_by {
759        if !old_by.contains_key(name) {
760            let mut pair = Vec::new();
761            emit(
762                &mut pair,
763                (*name).to_string(),
764                Category::DeclAdded,
765                None,
766                Some(format!("package {name}")),
767            );
768            classify_all(&mut pair, new_pkg, new_pkg, &[], encodings);
769            changes.append(&mut pair);
770        }
771    }
772    report(changes)
773}
774
775/// Compares two workspaces at the system (rsdl reference §14): the packages by
776/// the ridl categories, exactly as [`diff_sets`] does, and — when both sides
777/// carry a lowered system — the system's placement and composition changes,
778/// which carry no verdict, and its instance number changes, which make the
779/// report breaking. A system on one side only is not compared: there is
780/// nothing to compare it against.
781///
782/// `context` is passed to [`diff_sets_in`]. The comparison is judged for no
783/// encoding: [`diff_workspaces_with`] with an empty list.
784pub fn diff_workspaces(
785    old: &[Package],
786    old_system: Option<&System>,
787    new: &[Package],
788    new_system: Option<&System>,
789    context: &[Package],
790) -> DiffReport {
791    diff_workspaces_with(old, old_system, new, new_system, context, &[])
792}
793
794/// [`diff_workspaces`], judged for `encodings` ([`diff_refs_with`]).
795pub fn diff_workspaces_with(
796    old: &[Package],
797    old_system: Option<&System>,
798    new: &[Package],
799    new_system: Option<&System>,
800    context: &[Package],
801    encodings: &[&str],
802) -> DiffReport {
803    let old: Vec<&Package> = old.iter().collect();
804    let new: Vec<&Package> = new.iter().collect();
805    let context: Vec<&Package> = context.iter().collect();
806    let mut report = diff_refs_with(&old, &new, &context, encodings);
807    if let (Some(old_system), Some(new_system)) = (old_system, new_system) {
808        report.system = diff_systems(old_system, new_system);
809        if report
810            .system
811            .iter()
812            .any(|change| change.heading == SystemHeading::InstanceNumberChanged)
813        {
814            report.verdict = Verdict::Breaking;
815        }
816    }
817    report
818}
819
820/// Compares workspace packages with independent side contexts and, when both
821/// sides carry a system, lists the system changes, of which only an instance
822/// number change enters the verdict, as breaking ([`diff_workspaces_with`]).
823/// Missing remote evidence is an error for an explicit podbuf comparison.
824pub fn diff_workspaces_with_contexts(
825    old: &[Package],
826    old_system: Option<&System>,
827    new: &[Package],
828    new_system: Option<&System>,
829    old_context: &[Package],
830    new_context: &[Package],
831    encodings: &[&str],
832) -> Result<DiffReport, ContextError> {
833    let old: Vec<&Package> = old.iter().collect();
834    let new: Vec<&Package> = new.iter().collect();
835    let old_context: Vec<&Package> = old_context.iter().collect();
836    let new_context: Vec<&Package> = new_context.iter().collect();
837    let mut report = diff_refs_with_contexts(&old, &new, &old_context, &new_context, encodings)?;
838    if let (Some(old_system), Some(new_system)) = (old_system, new_system) {
839        report.system = diff_systems(old_system, new_system);
840        if report
841            .system
842            .iter()
843            .any(|change| change.heading == SystemHeading::InstanceNumberChanged)
844        {
845            report.verdict = Verdict::Breaking;
846        }
847    }
848    Ok(report)
849}
850
851/// The stable lowercase word for a verdict — used by both the text and JSON
852/// renderers so the two stay in lockstep.
853pub(crate) fn verdict_word(verdict: Verdict) -> &'static str {
854    match verdict {
855        Verdict::Identical => "identical",
856        Verdict::Compatible => "compatible",
857        Verdict::Breaking => "breaking",
858    }
859}
860
861/// The stable snake_case word for a category — the single source of truth for
862/// both renderers and for `ridl diff --explain`, which takes a category exactly
863/// as the report prints it.
864// A new variant must be given a real arm here, not swept into a
865// catch-all: rustc forces *an* arm, and the arm its `help:` text
866// proposes is `_ =>`, which classifies the new variant silently. The
867// two lints below reject a wildcard over `Category` — the first when
868// it covers several variants, the second when it covers exactly one,
869// which is the case one added variant creates.
870#[deny(
871    clippy::wildcard_enum_match_arm,
872    clippy::match_wildcard_for_single_variants
873)]
874pub fn category_word(category: Category) -> &'static str {
875    match category {
876        Category::DeclAdded => "decl_added",
877        Category::DeclRemoved => "decl_removed",
878        Category::InterfaceRenamed => "interface_renamed",
879        Category::InterfaceRetired => "interface_retired",
880        Category::InterfaceFrozen => "interface_frozen",
881        Category::InterfaceNumberChanged => "interface_number_changed",
882        Category::MemberReordered => "member_reordered",
883        Category::EnumReordered => "enum_reordered",
884        Category::InteractionAppended => "interaction_appended",
885        Category::InteractionInserted => "interaction_inserted",
886        Category::InteractionReordered => "interaction_reordered",
887        Category::InteractionRemoved => "interaction_removed",
888        Category::InteractionRetired => "interaction_retired",
889        Category::KindChanged => "kind_changed",
890        Category::PayloadChanged => "payload_changed",
891        Category::ReturnChanged => "return_changed",
892        Category::ParamsChanged => "params_changed",
893        Category::TimingChanged => "timing_changed",
894        Category::RpcBoundChanged => "rpc_bound_changed",
895        Category::LimitChanged => "limit_changed",
896        Category::ContractChanged => "contract_changed",
897        Category::InteractionClassChanged => "interaction_class_changed",
898        Category::WidthChanged => "width_changed",
899        Category::ConstraintChanged => "constraint_changed",
900        Category::InitChanged => "init_changed",
901        Category::ReservedNameRedeclared => "reserved_name_redeclared",
902        Category::ServiceChanged => "service_changed",
903        Category::ServiceInterfaceAdded => "service_interface_added",
904        Category::ServiceInterfaceRemoved => "service_interface_removed",
905        Category::DocOnly => "doc_only",
906        Category::VisibilityChanged => "visibility_changed",
907        Category::PodbufInlineAppend => "podbuf_inline_append",
908        Category::PodbufRemoteInlineGrowth => "podbuf_remote_inline_growth",
909        Category::PodbufWidthChange => "podbuf_width_change",
910        Category::PodbufStrictEnumAppend => "podbuf_strict_enum_append",
911        Category::StrictEnumAppend => "strict_enum_append",
912        Category::EnumOpened => "enum_opened",
913        Category::EnumClosed => "enum_closed",
914        Category::FlatbuffersWidthChange => "flatbuffers_width_change",
915        Category::Proto3WidthChange => "proto3_width_change",
916    }
917}
918
919/// The heading a category's changes are grouped under in the text report, or
920/// `None` for a category listed plainly. Five headings exist. "compatible on
921/// the wire, visible in source" is for a change that exits 0 but that a
922/// consumer sees in its source — an interface renamed on its number changes
923/// the generated identity-table names in both wire backends, and an interface
924/// leaving a service's set stops the `service.member` addresses of that
925/// interface resolving under the service. "compatible on the wire, may change
926/// the catalog hash" is for a change that exits 0 but can change the catalog
927/// hash, which a generated face compares with its peer's — a number `ridl
928/// lock` froze in place, which always changes it, and an enum or enum-set body
929/// reordered, which changes it when an interface reaches the enum or enum set
930/// (the hash covers only the declarations an interface reaches). "breaking
931/// under podbuf" is for a change whose verdict depends on the encodings the
932/// comparison is judged for ([`diff_refs_with`]): it exits 0 unless `podbuf`
933/// is among them. "breaking under flatbuffers" follows the same rule for
934/// `flatbuffers`, and "breaking under proto3" for `proto3`. Each such
935/// category's `--explain` text states its own consequence; the JSON report
936/// carries the category word and no heading field.
937///
938/// Wildcard arms are denied for the reason `category_word` gives.
939#[deny(
940    clippy::wildcard_enum_match_arm,
941    clippy::match_wildcard_for_single_variants
942)]
943pub fn heading(category: Category) -> Option<&'static str> {
944    match category {
945        Category::InterfaceRenamed | Category::ServiceInterfaceRemoved => {
946            Some("compatible on the wire, visible in source")
947        }
948        Category::InterfaceFrozen | Category::EnumReordered => {
949            Some("compatible on the wire, may change the catalog hash")
950        }
951        Category::PodbufInlineAppend
952        | Category::PodbufRemoteInlineGrowth
953        | Category::PodbufWidthChange
954        | Category::PodbufStrictEnumAppend => Some("breaking under podbuf"),
955        Category::FlatbuffersWidthChange => Some("breaking under flatbuffers"),
956        Category::Proto3WidthChange => Some("breaking under proto3"),
957        Category::DeclAdded
958        | Category::DeclRemoved
959        | Category::InterfaceRetired
960        | Category::InterfaceNumberChanged
961        | Category::MemberReordered
962        | Category::InteractionAppended
963        | Category::InteractionInserted
964        | Category::InteractionReordered
965        | Category::InteractionRemoved
966        | Category::InteractionRetired
967        | Category::KindChanged
968        | Category::PayloadChanged
969        | Category::ReturnChanged
970        | Category::ParamsChanged
971        | Category::TimingChanged
972        | Category::RpcBoundChanged
973        | Category::LimitChanged
974        | Category::ContractChanged
975        | Category::InteractionClassChanged
976        | Category::WidthChanged
977        | Category::ConstraintChanged
978        | Category::InitChanged
979        | Category::ReservedNameRedeclared
980        | Category::ServiceChanged
981        | Category::ServiceInterfaceAdded
982        | Category::StrictEnumAppend
983        | Category::EnumOpened
984        | Category::EnumClosed
985        | Category::DocOnly
986        | Category::VisibilityChanged => None,
987    }
988}
989
990impl serde::Serialize for Verdict {
991    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
992        serializer.serialize_str(verdict_word(*self))
993    }
994}
995
996impl serde::Serialize for Category {
997    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
998        serializer.serialize_str(category_word(*self))
999    }
1000}
1001
1002impl serde::Serialize for Change {
1003    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1004        use serde::ser::SerializeStruct as _;
1005        let mut state = serializer.serialize_struct("Change", 5)?;
1006        state.serialize_field("path", &self.path)?;
1007        state.serialize_field("category", &self.category)?;
1008        state.serialize_field("verdict", &self.verdict)?;
1009        state.serialize_field("before", &self.before)?;
1010        state.serialize_field("after", &self.after)?;
1011        state.end()
1012    }
1013}
1014
1015/// Renders a report as a human-readable summary: the report verdict on the
1016/// first line, then one indented line per change. A change whose category has
1017/// no [`heading`] is listed first, in report order; then each heading is
1018/// printed once, as its own line ending in a colon, followed by the changes
1019/// under it, in report order. Headings come in [`CATEGORIES`] order.
1020///
1021/// After the contract changes come the system headings (rsdl reference §14):
1022/// each heading that has a change on its own line, then one indented line per
1023/// change under it, in the `path: before -> after` form. Only the instance
1024/// number heading carries a verdict, printed after it as `(breaking)`.
1025pub fn render_text(report: &DiffReport) -> String {
1026    let mut out = String::new();
1027    out.push_str(verdict_word(report.verdict));
1028    out.push('\n');
1029    for change in &report.changes {
1030        if heading(change.category).is_none() {
1031            push_change_line(&mut out, change);
1032        }
1033    }
1034    let mut printed: Vec<&'static str> = Vec::new();
1035    for category in CATEGORIES {
1036        let Some(title) = heading(category) else {
1037            continue;
1038        };
1039        if printed.contains(&title) {
1040            continue;
1041        }
1042        printed.push(title);
1043        let mut under = report
1044            .changes
1045            .iter()
1046            .filter(|change| heading(change.category) == Some(title))
1047            .peekable();
1048        if under.peek().is_none() {
1049            continue;
1050        }
1051        out.push_str(title);
1052        out.push_str(":\n");
1053        for change in under {
1054            push_change_line(&mut out, change);
1055        }
1056    }
1057    for heading in [
1058        SystemHeading::PlacementChanged,
1059        SystemHeading::CompositionChanged,
1060        SystemHeading::InstanceNumberChanged,
1061    ] {
1062        let mut listed = report
1063            .system
1064            .iter()
1065            .filter(|change| change.heading == heading)
1066            .peekable();
1067        if listed.peek().is_none() {
1068            continue;
1069        }
1070        out.push_str(system::heading_text(heading));
1071        if heading == SystemHeading::InstanceNumberChanged {
1072            out.push_str(" (breaking)");
1073        }
1074        out.push('\n');
1075        for change in listed {
1076            out.push_str("  ");
1077            out.push_str(&change.path);
1078            push_values(&mut out, change.before.as_ref(), change.after.as_ref());
1079            out.push('\n');
1080        }
1081    }
1082    out
1083}
1084
1085/// One change of the text report: `  [verdict] category path`, then
1086/// `: before -> after` where the sides apply.
1087fn push_change_line(out: &mut String, change: &Change) {
1088    out.push_str("  [");
1089    out.push_str(verdict_word(change.verdict));
1090    out.push_str("] ");
1091    out.push_str(category_word(change.category));
1092    out.push(' ');
1093    out.push_str(&change.path);
1094    push_values(out, change.before.as_ref(), change.after.as_ref());
1095    out.push('\n');
1096}
1097
1098/// The rendered `before -> after` tail of one change line, shared by contract
1099/// changes and system changes.
1100fn push_values(out: &mut String, before: Option<&String>, after: Option<&String>) {
1101    match (before, after) {
1102        (Some(before), Some(after)) => {
1103            out.push_str(": ");
1104            out.push_str(before);
1105            out.push_str(" -> ");
1106            out.push_str(after);
1107        }
1108        (Some(before), None) => {
1109            out.push_str(": ");
1110            out.push_str(before);
1111            out.push_str(" -> (removed)");
1112        }
1113        (None, Some(after)) => {
1114            out.push_str(": (absent) -> ");
1115            out.push_str(after);
1116        }
1117        (None, None) => {}
1118    }
1119}
1120
1121/// Renders a report as machine-readable JSON with the stable schema
1122/// `{"verdict", "changes": [{"path", "category", "verdict", "before",
1123/// "after"}]}`.
1124pub fn render_json(report: &DiffReport) -> String {
1125    #[derive(serde::Serialize)]
1126    struct JsonSystemChange<'a> {
1127        path: &'a str,
1128        before: Option<&'a str>,
1129        after: Option<&'a str>,
1130    }
1131
1132    #[derive(serde::Serialize)]
1133    struct JsonReport<'a> {
1134        verdict: Verdict,
1135        changes: &'a [Change],
1136        #[serde(skip_serializing_if = "Vec::is_empty")]
1137        placement_changed: Vec<JsonSystemChange<'a>>,
1138        #[serde(skip_serializing_if = "Vec::is_empty")]
1139        composition_changed: Vec<JsonSystemChange<'a>>,
1140        #[serde(skip_serializing_if = "Vec::is_empty")]
1141        instance_number_changed: Vec<JsonSystemChange<'a>>,
1142    }
1143
1144    let under = |heading: SystemHeading| -> Vec<JsonSystemChange<'_>> {
1145        report
1146            .system
1147            .iter()
1148            .filter(|change| change.heading == heading)
1149            .map(|change| JsonSystemChange {
1150                path: &change.path,
1151                before: change.before.as_deref(),
1152                after: change.after.as_deref(),
1153            })
1154            .collect()
1155    };
1156    serde_json::to_string_pretty(&JsonReport {
1157        verdict: report.verdict,
1158        changes: &report.changes,
1159        placement_changed: under(SystemHeading::PlacementChanged),
1160        composition_changed: under(SystemHeading::CompositionChanged),
1161        instance_number_changed: under(SystemHeading::InstanceNumberChanged),
1162    })
1163    .expect("a diff report holds only string-representable values, so serialization cannot fail")
1164}