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;
30pub mod system;
31pub mod unit_verdict;
32mod walk;
33
34pub use classify::{absence_refused, category_from_word, classify, explain, is_above_high_water};
35// Re-exported under its earlier path (ADR-0008 decision 9).
36pub use ridl_ir::v2::{LoadError, load_ir_json};
37pub use system::{SystemChange, SystemHeading, diff_systems};
38pub use unit_verdict::{UnitVerdicts, unit_verdict};
39
40#[cfg(test)]
41mod tests;
42
43/// The compatibility verdict of a change or of a whole report — ordered so
44/// `Breaking > Compatible > Identical` and a report's verdict is the maximum
45/// over its changes.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
47pub enum Verdict {
48    /// No differences at all.
49    Identical,
50    /// A consumer built against the old snapshot still works.
51    Compatible,
52    /// A consumer built against the old snapshot may break.
53    Breaking,
54}
55
56/// Declares the [`Category`] vocabulary once (ADR-0008 decision 21).
57///
58/// One list of variants expands to both the enum and the [`CATEGORIES`] array
59/// that `ridl diff --explain` iterates, so a variant that never reaches
60/// `CATEGORIES` cannot be written: there is no second list to forget. This
61/// replaces the guard PR #163 shipped, which expanded one list into an
62/// exhaustive `match` and an array *inside the test* — that narrowed the gap but
63/// left `CATEGORIES` shadowed rather than produced, and an assertion comparing
64/// two lists can be defeated by editing what feeds it.
65///
66/// A new variant therefore stops three functions compiling — [`classify`],
67/// [`explain`], and [`category_word`] — and reaches `CATEGORIES` with no second
68/// edit. The escape rustc's own `help:` text proposes for those three errors is
69/// a wildcard arm. Each of the three functions denies
70/// `clippy::wildcard_enum_match_arm` and
71/// `clippy::match_wildcard_for_single_variants` for exactly that reason. The
72/// second lint is the load-bearing one: the first does not fire when the
73/// wildcard covers a single variant, which is precisely the added-variant case,
74/// so denying it alone leaves clippy green.
75///
76/// How far the wildcard gets before clippy stops it depends on what its body
77/// says. `_ => todo!()` panics the `--explain` coverage test, and a bare
78/// `_ => "unknown"` fails it — the row names no verdict. A wildcard whose text
79/// happens to contain "compatible" passes all 115 tests. So the escape is real
80/// but narrow, and `cargo test` alone catches the two careless spellings of it.
81/// `just build` runs clippy so that the third is caught too.
82///
83/// What this does **not** close: rustc forces *an* arm, not the right one. A
84/// new variant given an explicit arm that classifies compatible, or whose rule
85/// row describes the wrong rule, still compiles and still passes. The [`explain`]
86/// coverage test checks that each row names a verdict and that its word
87/// round-trips; neither is proof that the row is correct.
88macro_rules! declare_categories {
89    (
90        $(#[$enum_meta:meta])*
91        $vis:vis enum $name:ident {
92            $(
93                $(#[$variant_meta:meta])*
94                $variant:ident,
95            )+
96        }
97    ) => {
98        $(#[$enum_meta])*
99        $vis enum $name {
100            $(
101                $(#[$variant_meta])*
102                $variant,
103            )+
104        }
105
106        /// Every category, in the order `--explain` lists them when asked for an
107        /// unknown one.
108        ///
109        /// Generated from the [`Category`] declaration by
110        /// [`declare_categories!`], not maintained beside it.
111        //
112        // The length counts the variants rather than being written down:
113        // `${count(...)}` is still unstable (rust-lang/rust#83527), so the
114        // count comes from the same repetition that fills the array.
115        pub const CATEGORIES: [$name; [$(stringify!($variant)),+].len()] =
116            [$($name::$variant),+];
117    };
118}
119
120declare_categories! {
121    /// The kind of a single difference. The walk emits the structural categories;
122    /// the classifier maps them to directional verdicts.
123    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
124    pub enum Category {
125        /// A package-level declaration, interface, or service present only in the
126        /// new snapshot.
127        DeclAdded,
128        /// A package-level declaration, interface, or service present only in the
129        /// old snapshot. An interface is matched by its `interfaces.lock`
130        /// number (lock design §7), so an interface here is one whose number
131        /// is gone from the new snapshot and not retired there.
132        DeclRemoved,
133        /// An interface whose `interfaces.lock` number is the same on both
134        /// sides and whose name changed (lock design §7). The number is the
135        /// interface's identity and its routing key, so nothing moves on the
136        /// wire; a rename is visible in source — it changes the generated
137        /// identity-table names in both wire backends — so the text report
138        /// lists it under the heading ([`heading`]). The path carries the new
139        /// name; the detail carries the old and the new name.
140        InterfaceRenamed,
141        /// An interface whose number the old snapshot held and the new
142        /// snapshot's retired entries list (lock design §7): the sanctioned
143        /// removal of an interface, recorded by `ridl lock --retire`.
144        InterfaceRetired,
145        /// An interface matched by name across a provisional old side whose
146        /// number is the same on both sides and whose provisional flag went
147        /// from true to false: the number `ridl lock` froze in place. Nothing
148        /// moves on the wire, but the catalog hash covers the number and the
149        /// flag, so the hash changes and the text report lists it under the
150        /// heading "compatible on the wire, may change the catalog hash"
151        /// ([`heading`]). A number 0 on
152        /// either side, from a snapshot written before the lock existed, is
153        /// not compared. The path carries the name; the detail carries the
154        /// old and the new number, each marked when it is provisional.
155        InterfaceFrozen,
156        /// A matched interface whose number differs between the two sides,
157        /// whatever the two flags: a provisional number moved by a sibling
158        /// added before it, or frozen as another number. The number is the
159        /// routing key, so the change is breaking. A number 0 on either side,
160        /// from a snapshot written before the lock existed, is not compared. The
161        /// detail carries the old and the new number, each marked when it is
162        /// provisional.
163        InterfaceNumberChanged,
164        /// A surviving composite member whose slot in the body changed — a
165        /// struct field, enum value, enum-set bit or union arm — reported only
166        /// when both bodies hold the same member names. For a struct field or
167        /// union arm the slot is the ordinal, which is wire identity (typl
168        /// §7.4), and the detail carries the old and new ordinal. An enum value
169        /// or enum-set bit takes its identity from the explicit number it
170        /// declares instead (typl §8, §9), so its slot is that number: the
171        /// category reports a member whose number changed, with the old and
172        /// new value or bit in the detail. When every difference between the
173        /// two bodies is a changed number, the container's
174        /// [`Category::ConstraintChanged`] is not reported beside it. A body
175        /// of an enum or an enum set that differs only in order is
176        /// [`Category::EnumReordered`] instead.
177        MemberReordered,
178        /// An enum or enum-set body whose difference is only order: its live
179        /// values or bits reordered with no number changed, the enum's
180        /// `reserved` list reordered with no entry added, removed or changed,
181        /// or both. A value or bit takes its identity from the explicit number
182        /// it declares (typl §8, §9), so nothing moves on the wire. The catalog
183        /// hash covers only the declarations an interface reaches; when an
184        /// interface reaches the enum or enum set, the order of its values or
185        /// bits, and of an enum's reserved list, is part of the hash, so the
186        /// hash changes then. The text report lists it under the heading that
187        /// [`Category::InterfaceFrozen`] shares ([`heading`]). The path is the
188        /// container's; there is no detail.
189        EnumReordered,
190        /// A new interaction added at the end of an interface (no earlier
191        /// interaction shifted).
192        InteractionAppended,
193        /// A new interaction added before the end — an earlier interaction now
194        /// sits after it (ridl §11: insert shifts ordinals, a wire break).
195        InteractionInserted,
196        /// A surviving interaction whose relative order within the interface
197        /// changed (ridl §11: reorder shifts ordinals, a wire break).
198        InteractionReordered,
199        /// An interaction removed without leaving a `reserved` tombstone.
200        InteractionRemoved,
201        /// An interaction removed and replaced by a `reserved` tombstone in the
202        /// same slot (ridl §11).
203        InteractionRetired,
204        /// A surviving interaction whose kind changed (signal ↔ event, etc.).
205        KindChanged,
206        /// A signal/event/fixed payload type changed.
207        PayloadChanged,
208        /// A query return type changed.
209        ReturnChanged,
210        /// A command/query parameter list changed.
211        ParamsChanged,
212        /// A signal/event resolved timing changed.
213        TimingChanged,
214        /// A command/query declared RPC bound changed (ADR-0015 decision 8).
215        /// Separate from [`Category::TimingChanged`] because the direction of
216        /// `min` inverts: on an RPC it constrains the caller, not the
217        /// provider, so the signal/event rule must not be inherited silently.
218        RpcBoundChanged,
219        /// The payload limit declared for an interaction changed, was added or
220        /// was removed (ADR-0030 decision 13). Any change is breaking: the
221        /// limit is part of the catalog hash, so peers with different limits
222        /// do not attach.
223        LimitChanged,
224        /// A command/query require/ensure clause set changed.
225        ContractChanged,
226        /// A name-paired interaction's family or operation shape changed
227        /// (ADR-0012 decision 6).
228        InteractionClassChanged,
229        /// A derived wire width or scalar backing changed.
230        WidthChanged,
231        /// A scalar constraint (range, step, length, pattern) changed, or a
232        /// composite member changed in place.
233        ConstraintChanged,
234        /// A resolved or declared init value changed.
235        InitChanged,
236        /// A name that was a `reserved` tombstone is live again — an
237        /// interaction inside an interface body.
238        ReservedNameRedeclared,
239        /// A service switched between the named list and an inline shape.
240        /// Narrowed to the form switch by ADR-0015 decision 19: a changed
241        /// list is read as a set by the two `ServiceInterface*` categories
242        /// below.
243        ServiceChanged,
244        /// An interface in a service's set that the old snapshot's set did not
245        /// hold (ADR-0015 decision 19 as amended on 2026-09-15: a service's
246        /// list is a set of interface references).
247        ServiceInterfaceAdded,
248        /// An interface the old snapshot's set held that the new one does not.
249        /// Compatible on the wire — the routing key does not contain the
250        /// service — and visible in source, so the text report lists it under
251        /// that heading ([`heading`]).
252        ServiceInterfaceRemoved,
253        /// Only doc comment, labels, or deprecation metadata changed.
254        DocOnly,
255        /// The visibility a declaration is published at changed. Separate from
256        /// [`Category::DocOnly`] because `internal` removes the declaration from
257        /// every out-of-package consumer (ADR-0002 §8), so the change has a
258        /// direction.
259        VisibilityChanged,
260    }
261}
262
263/// One difference between two snapshots, with an honest path into the IR and
264/// the rendered before/after values where they apply.
265#[derive(Clone, Debug, PartialEq, Eq)]
266pub struct Change {
267    /// A slash-separated path, e.g. `veh.cluster/VehicleStatus/doorOpened`.
268    pub path: String,
269    pub category: Category,
270    pub verdict: Verdict,
271    /// The rendered old value, absent when the change is an addition.
272    pub before: Option<String>,
273    /// The rendered new value, absent when the change is a removal.
274    pub after: Option<String>,
275}
276
277impl Change {
278    /// The dotted name of the package the change belongs to: the first
279    /// segment of [`path`](Change::path). The walk builds the path as
280    /// `<package>/<name>[/<member>...]`, and this accessor and
281    /// [`declaration`](Change::declaration) are the one place that reads that
282    /// layout back.
283    pub fn package(&self) -> &str {
284        self.path.split('/').next().unwrap_or_default()
285    }
286
287    /// The bare name of the package-level declaration, interface or service
288    /// the change belongs to: the second segment of [`path`](Change::path), or
289    /// `None` for a change to the package itself.
290    pub fn declaration(&self) -> Option<&str> {
291        self.path.split('/').nth(1)
292    }
293}
294
295/// The result of a comparison: every change and the report-level verdict (the
296/// maximum verdict over the changes; [`Verdict::Identical`] when there are
297/// none).
298#[derive(Clone, Debug, PartialEq, Eq)]
299pub struct DiffReport {
300    pub changes: Vec<Change>,
301    pub verdict: Verdict,
302    /// The changes to the lowered system, each under its heading and with no
303    /// verdict (rsdl reference §14). They never enter `verdict`. Empty unless
304    /// both sides carry a system ([`diff_workspaces`]).
305    pub system: Vec<SystemChange>,
306}
307
308/// Appends one change.
309///
310/// The verdict is stamped breaking here and settled by [`classify`] once the
311/// walk of the containing package pair is complete — the classifier needs both
312/// snapshots, which the walk does not carry down to every emission site.
313/// Breaking is the safe placeholder: a change that somehow escaped
314/// classification would gate rather than pass.
315pub(crate) fn emit(
316    changes: &mut Vec<Change>,
317    path: String,
318    category: Category,
319    before: Option<String>,
320    after: Option<String>,
321) {
322    changes.push(Change {
323        path,
324        category,
325        verdict: Verdict::Breaking,
326        before,
327        after,
328    });
329}
330
331/// Whether an interface carries an identity: a frozen, non-zero number from
332/// its unit's `interfaces.lock` (lock design §7). A provisional number is
333/// no identity, and `number` 0 — never allocated — marks a snapshot published
334/// before the lock existed. The walk matches by number only when both sides
335/// have one, and the classifier re-finds the old side the same way.
336pub(crate) fn frozen(interface: &ridl_ir::v2::Interface) -> bool {
337    interface.number != 0 && !interface.provisional
338}
339
340/// Settles the verdict of every change the walk of one package pair produced.
341/// `scope` is every package a reference to another package's declaration
342/// resolves against ([`classify::classify_in`]).
343///
344/// A change's new side is in `new`, except for an interface whose number
345/// moved to another package of the unit: the walk puts that package first in
346/// the change's path, and the classifier reads the new side from it, so the
347/// package is taken from `scope`, which holds every package of the new set.
348fn classify_all(changes: &mut [Change], old: &Package, new: &Package, scope: &[&Package]) {
349    for change in changes {
350        let held_by = change.path.split('/').next().unwrap_or_default();
351        let new = if held_by == new.name {
352            new
353        } else {
354            scope
355                .iter()
356                .copied()
357                .find(|package| package.name == held_by)
358                .unwrap_or(new)
359        };
360        change.verdict = classify::classify_in(change, old, new, scope);
361    }
362}
363
364/// Assembles a [`DiffReport`], deriving the report verdict as the maximum over
365/// its changes.
366pub(crate) fn report(changes: Vec<Change>) -> DiffReport {
367    let verdict = changes
368        .iter()
369        .map(|change| change.verdict)
370        .max()
371        .unwrap_or(Verdict::Identical);
372    DiffReport {
373        changes,
374        verdict,
375        system: Vec::new(),
376    }
377}
378
379/// Compares two resolved packages. Matched packages share a name; the new
380/// package's name is used as the path prefix.
381///
382/// Only the pair is in hand, so a type from another package does not resolve,
383/// and a type the diff cannot resolve is reported as breaking: a struct field
384/// appended with such a type classifies breaking (driftsys/ridl#598).
385/// [`diff_sets_in`] resolves it.
386pub fn diff_packages(old: &Package, new: &Package) -> DiffReport {
387    let matching = walk::Matching::new(&[old], &[new]);
388    let mut changes = Vec::new();
389    walk::walk_packages(old, new, &matching, &mut changes);
390    classify_all(&mut changes, old, new, &[]);
391    report(changes)
392}
393
394/// Compares two sets of resolved packages, matching by package name, and
395/// interface numbers within each unit ([`walk::Matching`]). A package present
396/// only on the new side is a [`Category::DeclAdded`]; matched packages are
397/// walked pairwise. A package present only on the old side is a
398/// [`Category::DeclRemoved`] when its whole unit is gone from the new side,
399/// and is walked declaration by declaration when the unit is still there, so
400/// that an interface whose number moved to another package of the unit is
401/// [`Category::InterfaceRenamed`] and one the unit retired is
402/// [`Category::InterfaceRetired`].
403///
404/// [`diff_sets_in`] with no context: a type from a package outside `new`, such
405/// as `ridl.std`, which no snapshot carries, does not resolve, and a type the
406/// diff cannot resolve is reported as breaking.
407pub fn diff_sets(old: &[Package], new: &[Package]) -> DiffReport {
408    diff_sets_in(old, new, &[])
409}
410
411/// [`diff_sets`], with `context`: packages that a type reference resolves
412/// against without being compared. The `ridl` CLI passes the built-in
413/// `ridl.std` here, so a struct field appended with a `ridl.std` type is judged
414/// by that type's declaration (driftsys/ridl#598).
415pub fn diff_sets_in(old: &[Package], new: &[Package], context: &[Package]) -> DiffReport {
416    let old: Vec<&Package> = old.iter().collect();
417    let new: Vec<&Package> = new.iter().collect();
418    let context: Vec<&Package> = context.iter().collect();
419    diff_refs_in(&old, &new, &context)
420}
421
422/// [`diff_sets_in`] over borrowed packages, for a caller that holds the
423/// packages by reference and would otherwise clone them.
424pub fn diff_refs_in(old: &[&Package], new: &[&Package], context: &[&Package]) -> DiffReport {
425    use std::collections::BTreeMap;
426
427    use ridl_ir::v2::{members_of_unit, unit_of};
428
429    let scope: Vec<&Package> = new.iter().chain(context).copied().collect();
430    let matching = walk::Matching::new(old, new);
431
432    let old_by: BTreeMap<&str, &Package> =
433        old.iter().map(|pkg| (pkg.name.as_str(), *pkg)).collect();
434    let new_by: BTreeMap<&str, &Package> =
435        new.iter().map(|pkg| (pkg.name.as_str(), *pkg)).collect();
436
437    // Each matched pair is walked and classified against its own two snapshots,
438    // because the classifier resolves a change's path back into the packages it
439    // came from.
440    let mut changes = Vec::new();
441    for (name, old_pkg) in &old_by {
442        match new_by.get(name) {
443            Some(new_pkg) => {
444                let mut pair = Vec::new();
445                walk::walk_packages(old_pkg, new_pkg, &matching, &mut pair);
446                classify_all(&mut pair, old_pkg, new_pkg, &scope);
447                changes.append(&mut pair);
448            }
449            // A package gone while its unit stays: walked against a package
450            // with nothing in it, so that each shape is matched in the unit,
451            // retired by the unit, or removed, and each other declaration is
452            // removed on its own line.
453            None if members_of_unit(unit_of(old_pkg), new).next().is_some() => {
454                let gone = Package {
455                    name: old_pkg.name.clone(),
456                    unit: old_pkg.unit.clone(),
457                    ..Default::default()
458                };
459                let mut pair = Vec::new();
460                walk::walk_packages(old_pkg, &gone, &matching, &mut pair);
461                classify_all(&mut pair, old_pkg, &gone, &scope);
462                changes.append(&mut pair);
463            }
464            // A package present on one side only: the change classifies on its
465            // category alone, so the one snapshot stands for both.
466            None => {
467                let mut pair = Vec::new();
468                emit(
469                    &mut pair,
470                    (*name).to_string(),
471                    Category::DeclRemoved,
472                    Some(format!("package {name}")),
473                    None,
474                );
475                classify_all(&mut pair, old_pkg, old_pkg, &[]);
476                changes.append(&mut pair);
477            }
478        }
479    }
480    for (name, new_pkg) in &new_by {
481        if !old_by.contains_key(name) {
482            let mut pair = Vec::new();
483            emit(
484                &mut pair,
485                (*name).to_string(),
486                Category::DeclAdded,
487                None,
488                Some(format!("package {name}")),
489            );
490            classify_all(&mut pair, new_pkg, new_pkg, &[]);
491            changes.append(&mut pair);
492        }
493    }
494    report(changes)
495}
496
497/// Compares two workspaces at the system (rsdl reference §14): the packages by
498/// the ridl categories, exactly as [`diff_sets`] does, and — when both sides
499/// carry a lowered system — the system's placement and composition changes,
500/// which carry no verdict. A system on one side only is not compared: there is
501/// nothing to compare it against.
502///
503/// `context` is passed to [`diff_sets_in`].
504pub fn diff_workspaces(
505    old: &[Package],
506    old_system: Option<&System>,
507    new: &[Package],
508    new_system: Option<&System>,
509    context: &[Package],
510) -> DiffReport {
511    let mut report = diff_sets_in(old, new, context);
512    if let (Some(old_system), Some(new_system)) = (old_system, new_system) {
513        report.system = diff_systems(old_system, new_system);
514    }
515    report
516}
517
518/// The stable lowercase word for a verdict — used by both the text and JSON
519/// renderers so the two stay in lockstep.
520pub(crate) fn verdict_word(verdict: Verdict) -> &'static str {
521    match verdict {
522        Verdict::Identical => "identical",
523        Verdict::Compatible => "compatible",
524        Verdict::Breaking => "breaking",
525    }
526}
527
528/// The stable snake_case word for a category — the single source of truth for
529/// both renderers and for `ridl diff --explain`, which takes a category exactly
530/// as the report prints it.
531// A new variant must be given a real arm here, not swept into a
532// catch-all: rustc forces *an* arm, and the arm its `help:` text
533// proposes is `_ =>`, which classifies the new variant silently. The
534// two lints below reject a wildcard over `Category` — the first when
535// it covers several variants, the second when it covers exactly one,
536// which is the case one added variant creates.
537#[deny(
538    clippy::wildcard_enum_match_arm,
539    clippy::match_wildcard_for_single_variants
540)]
541pub fn category_word(category: Category) -> &'static str {
542    match category {
543        Category::DeclAdded => "decl_added",
544        Category::DeclRemoved => "decl_removed",
545        Category::InterfaceRenamed => "interface_renamed",
546        Category::InterfaceRetired => "interface_retired",
547        Category::InterfaceFrozen => "interface_frozen",
548        Category::InterfaceNumberChanged => "interface_number_changed",
549        Category::MemberReordered => "member_reordered",
550        Category::EnumReordered => "enum_reordered",
551        Category::InteractionAppended => "interaction_appended",
552        Category::InteractionInserted => "interaction_inserted",
553        Category::InteractionReordered => "interaction_reordered",
554        Category::InteractionRemoved => "interaction_removed",
555        Category::InteractionRetired => "interaction_retired",
556        Category::KindChanged => "kind_changed",
557        Category::PayloadChanged => "payload_changed",
558        Category::ReturnChanged => "return_changed",
559        Category::ParamsChanged => "params_changed",
560        Category::TimingChanged => "timing_changed",
561        Category::RpcBoundChanged => "rpc_bound_changed",
562        Category::LimitChanged => "limit_changed",
563        Category::ContractChanged => "contract_changed",
564        Category::InteractionClassChanged => "interaction_class_changed",
565        Category::WidthChanged => "width_changed",
566        Category::ConstraintChanged => "constraint_changed",
567        Category::InitChanged => "init_changed",
568        Category::ReservedNameRedeclared => "reserved_name_redeclared",
569        Category::ServiceChanged => "service_changed",
570        Category::ServiceInterfaceAdded => "service_interface_added",
571        Category::ServiceInterfaceRemoved => "service_interface_removed",
572        Category::DocOnly => "doc_only",
573        Category::VisibilityChanged => "visibility_changed",
574    }
575}
576
577/// The heading a category's changes are grouped under in the text report, or
578/// `None` for a category listed plainly. Two headings exist. "compatible on
579/// the wire, visible in source" is for a change that exits 0 but that a
580/// consumer sees in its source — an interface renamed on its number changes
581/// the generated identity-table names in both wire backends, and an interface
582/// leaving a service's set stops the `service.member` addresses of that
583/// interface resolving under the service. "compatible on the wire, may change
584/// the catalog hash" is for a change that exits 0 but can change the catalog
585/// hash, which a generated face compares with its peer's — a number `ridl
586/// lock` froze in place, which always changes it, and an enum or enum-set body
587/// reordered, which changes it when an interface reaches the enum or enum set
588/// (the hash covers only the declarations an interface reaches). Each such
589/// category's `--explain` text states its own consequence; the JSON report
590/// carries the category word and no heading field.
591///
592/// Wildcard arms are denied for the reason `category_word` gives.
593#[deny(
594    clippy::wildcard_enum_match_arm,
595    clippy::match_wildcard_for_single_variants
596)]
597pub fn heading(category: Category) -> Option<&'static str> {
598    match category {
599        Category::InterfaceRenamed | Category::ServiceInterfaceRemoved => {
600            Some("compatible on the wire, visible in source")
601        }
602        Category::InterfaceFrozen | Category::EnumReordered => {
603            Some("compatible on the wire, may change the catalog hash")
604        }
605        Category::DeclAdded
606        | Category::DeclRemoved
607        | Category::InterfaceRetired
608        | Category::InterfaceNumberChanged
609        | Category::MemberReordered
610        | Category::InteractionAppended
611        | Category::InteractionInserted
612        | Category::InteractionReordered
613        | Category::InteractionRemoved
614        | Category::InteractionRetired
615        | Category::KindChanged
616        | Category::PayloadChanged
617        | Category::ReturnChanged
618        | Category::ParamsChanged
619        | Category::TimingChanged
620        | Category::RpcBoundChanged
621        | Category::LimitChanged
622        | Category::ContractChanged
623        | Category::InteractionClassChanged
624        | Category::WidthChanged
625        | Category::ConstraintChanged
626        | Category::InitChanged
627        | Category::ReservedNameRedeclared
628        | Category::ServiceChanged
629        | Category::ServiceInterfaceAdded
630        | Category::DocOnly
631        | Category::VisibilityChanged => None,
632    }
633}
634
635impl serde::Serialize for Verdict {
636    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
637        serializer.serialize_str(verdict_word(*self))
638    }
639}
640
641impl serde::Serialize for Category {
642    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
643        serializer.serialize_str(category_word(*self))
644    }
645}
646
647impl serde::Serialize for Change {
648    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
649        use serde::ser::SerializeStruct as _;
650        let mut state = serializer.serialize_struct("Change", 5)?;
651        state.serialize_field("path", &self.path)?;
652        state.serialize_field("category", &self.category)?;
653        state.serialize_field("verdict", &self.verdict)?;
654        state.serialize_field("before", &self.before)?;
655        state.serialize_field("after", &self.after)?;
656        state.end()
657    }
658}
659
660/// Renders a report as a human-readable summary: the report verdict on the
661/// first line, then one indented line per change. A change whose category has
662/// no [`heading`] is listed first, in report order; then each heading is
663/// printed once, as its own line ending in a colon, followed by the changes
664/// under it, in report order. Headings come in [`CATEGORIES`] order.
665///
666/// After the contract changes come the system headings (rsdl reference §14):
667/// each heading that has a change on its own line, then one indented line per
668/// change under it, in the `path: before -> after` form and with no verdict.
669pub fn render_text(report: &DiffReport) -> String {
670    let mut out = String::new();
671    out.push_str(verdict_word(report.verdict));
672    out.push('\n');
673    for change in &report.changes {
674        if heading(change.category).is_none() {
675            push_change_line(&mut out, change);
676        }
677    }
678    let mut printed: Vec<&'static str> = Vec::new();
679    for category in CATEGORIES {
680        let Some(title) = heading(category) else {
681            continue;
682        };
683        if printed.contains(&title) {
684            continue;
685        }
686        printed.push(title);
687        let mut under = report
688            .changes
689            .iter()
690            .filter(|change| heading(change.category) == Some(title))
691            .peekable();
692        if under.peek().is_none() {
693            continue;
694        }
695        out.push_str(title);
696        out.push_str(":\n");
697        for change in under {
698            push_change_line(&mut out, change);
699        }
700    }
701    for heading in [
702        SystemHeading::PlacementChanged,
703        SystemHeading::CompositionChanged,
704    ] {
705        let mut listed = report
706            .system
707            .iter()
708            .filter(|change| change.heading == heading)
709            .peekable();
710        if listed.peek().is_none() {
711            continue;
712        }
713        out.push_str(system::heading_text(heading));
714        out.push('\n');
715        for change in listed {
716            out.push_str("  ");
717            out.push_str(&change.path);
718            push_values(&mut out, change.before.as_ref(), change.after.as_ref());
719            out.push('\n');
720        }
721    }
722    out
723}
724
725/// One change of the text report: `  [verdict] category path`, then
726/// `: before -> after` where the sides apply.
727fn push_change_line(out: &mut String, change: &Change) {
728    out.push_str("  [");
729    out.push_str(verdict_word(change.verdict));
730    out.push_str("] ");
731    out.push_str(category_word(change.category));
732    out.push(' ');
733    out.push_str(&change.path);
734    push_values(out, change.before.as_ref(), change.after.as_ref());
735    out.push('\n');
736}
737
738/// The rendered `before -> after` tail of one change line, shared by contract
739/// changes and system changes.
740fn push_values(out: &mut String, before: Option<&String>, after: Option<&String>) {
741    match (before, after) {
742        (Some(before), Some(after)) => {
743            out.push_str(": ");
744            out.push_str(before);
745            out.push_str(" -> ");
746            out.push_str(after);
747        }
748        (Some(before), None) => {
749            out.push_str(": ");
750            out.push_str(before);
751            out.push_str(" -> (removed)");
752        }
753        (None, Some(after)) => {
754            out.push_str(": (absent) -> ");
755            out.push_str(after);
756        }
757        (None, None) => {}
758    }
759}
760
761/// Renders a report as machine-readable JSON with the stable schema
762/// `{"verdict", "changes": [{"path", "category", "verdict", "before",
763/// "after"}]}`.
764pub fn render_json(report: &DiffReport) -> String {
765    #[derive(serde::Serialize)]
766    struct JsonSystemChange<'a> {
767        path: &'a str,
768        before: Option<&'a str>,
769        after: Option<&'a str>,
770    }
771
772    #[derive(serde::Serialize)]
773    struct JsonReport<'a> {
774        verdict: Verdict,
775        changes: &'a [Change],
776        #[serde(skip_serializing_if = "Vec::is_empty")]
777        placement_changed: Vec<JsonSystemChange<'a>>,
778        #[serde(skip_serializing_if = "Vec::is_empty")]
779        composition_changed: Vec<JsonSystemChange<'a>>,
780    }
781
782    let under = |heading: SystemHeading| -> Vec<JsonSystemChange<'_>> {
783        report
784            .system
785            .iter()
786            .filter(|change| change.heading == heading)
787            .map(|change| JsonSystemChange {
788                path: &change.path,
789                before: change.before.as_deref(),
790                after: change.after.as_deref(),
791            })
792            .collect()
793    };
794    serde_json::to_string_pretty(&JsonReport {
795        verdict: report.verdict,
796        changes: &report.changes,
797        placement_changed: under(SystemHeading::PlacementChanged),
798        composition_changed: under(SystemHeading::CompositionChanged),
799    })
800    .expect("a diff report holds only string-representable values, so serialization cannot fail")
801}