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