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;
33pub mod unit_verdict;
34mod walk;
35
36pub use classify::{absence_refused, category_from_word, classify, explain};
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 /// A command/query require/ensure clause set changed.
220 ContractChanged,
221 /// A derived wire width or scalar backing changed.
222 WidthChanged,
223 /// A scalar constraint (range, step, length, pattern) changed, or a
224 /// composite member changed in place.
225 ConstraintChanged,
226 /// A resolved or declared init value changed.
227 InitChanged,
228 /// A name that was a `reserved` tombstone is live again — an
229 /// interaction inside an interface body.
230 ReservedNameRedeclared,
231 /// A service switched between the named list and an inline shape.
232 /// Narrowed to the form switch by ADR-0015 decision 19: a changed
233 /// list is read as a set by the two `ServiceInterface*` categories
234 /// below.
235 ServiceChanged,
236 /// An interface in a service's set that the old snapshot's set did not
237 /// hold (ADR-0015 decision 19 as amended on 2026-09-15: a service's
238 /// list is a set of interface references).
239 ServiceInterfaceAdded,
240 /// An interface the old snapshot's set held that the new one does not.
241 /// Compatible on the wire — the routing key does not contain the
242 /// service — and visible in source, so the text report lists it under
243 /// that heading ([`heading`]).
244 ServiceInterfaceRemoved,
245 /// Only doc comment, labels, or deprecation metadata changed.
246 DocOnly,
247 /// The visibility a declaration is published at changed. Separate from
248 /// [`Category::DocOnly`] because `internal` removes the declaration from
249 /// every out-of-package consumer (ADR-0002 §8), so the change has a
250 /// direction.
251 VisibilityChanged,
252 }
253}
254
255/// One difference between two snapshots, with an honest path into the IR and
256/// the rendered before/after values where they apply.
257#[derive(Clone, Debug, PartialEq, Eq)]
258pub struct Change {
259 /// A slash-separated path, e.g. `veh.cluster/VehicleStatus/doorOpened`.
260 pub path: String,
261 pub category: Category,
262 pub verdict: Verdict,
263 /// The rendered old value, absent when the change is an addition.
264 pub before: Option<String>,
265 /// The rendered new value, absent when the change is a removal.
266 pub after: Option<String>,
267}
268
269impl Change {
270 /// The dotted name of the package the change belongs to: the first
271 /// segment of [`path`](Change::path). The walk builds the path as
272 /// `<package>/<name>[/<member>...]`, and this accessor and
273 /// [`declaration`](Change::declaration) are the one place that reads that
274 /// layout back.
275 pub fn package(&self) -> &str {
276 self.path.split('/').next().unwrap_or_default()
277 }
278
279 /// The bare name of the package-level declaration, interface or service
280 /// the change belongs to: the second segment of [`path`](Change::path), or
281 /// `None` for a change to the package itself.
282 pub fn declaration(&self) -> Option<&str> {
283 self.path.split('/').nth(1)
284 }
285}
286
287/// The result of a comparison: every change and the report-level verdict (the
288/// maximum verdict over the changes; [`Verdict::Identical`] when there are
289/// none).
290#[derive(Clone, Debug, PartialEq, Eq)]
291pub struct DiffReport {
292 pub changes: Vec<Change>,
293 pub verdict: Verdict,
294 /// The changes to the lowered system, each under its heading and with no
295 /// verdict (rsdl reference §14). They never enter `verdict`. Empty unless
296 /// both sides carry a system ([`diff_workspaces`]).
297 pub system: Vec<SystemChange>,
298}
299
300/// An error loading an `.ir.json` snapshot.
301#[derive(Debug)]
302pub enum LoadError {
303 /// The file could not be read.
304 Io(std::io::Error),
305 /// The file was not valid IR v2 JSON.
306 Parse(String),
307}
308
309impl std::fmt::Display for LoadError {
310 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311 match self {
312 LoadError::Io(err) => write!(f, "cannot read the IR snapshot: {err}"),
313 LoadError::Parse(err) => write!(f, "the IR snapshot is not valid IR v2 JSON: {err}"),
314 }
315 }
316}
317
318impl std::error::Error for LoadError {}
319
320/// Appends one change.
321///
322/// The verdict is stamped breaking here and settled by [`classify`] once the
323/// walk of the containing package pair is complete — the classifier needs both
324/// snapshots, which the walk does not carry down to every emission site.
325/// Breaking is the safe placeholder: a change that somehow escaped
326/// classification would gate rather than pass.
327pub(crate) fn emit(
328 changes: &mut Vec<Change>,
329 path: String,
330 category: Category,
331 before: Option<String>,
332 after: Option<String>,
333) {
334 changes.push(Change {
335 path,
336 category,
337 verdict: Verdict::Breaking,
338 before,
339 after,
340 });
341}
342
343/// Whether an interface carries an identity: a frozen, non-zero number from
344/// its unit's `interfaces.lock` (lock design §7). A provisional number is
345/// no identity, and `number` 0 — never allocated — marks a snapshot published
346/// before the lock existed. The walk matches by number only when both sides
347/// have one, and the classifier re-finds the old side the same way.
348pub(crate) fn frozen(interface: &ridl_ir::v2::Interface) -> bool {
349 interface.number != 0 && !interface.provisional
350}
351
352/// Settles the verdict of every change the walk of one package pair produced.
353/// `scope` is every package a reference to another package's declaration
354/// resolves against ([`classify::classify_in`]).
355///
356/// A change's new side is in `new`, except for an interface whose number
357/// moved to another package of the unit: the walk puts that package first in
358/// the change's path, and the classifier reads the new side from it, so the
359/// package is taken from `scope`, which holds every package of the new set.
360fn classify_all(changes: &mut [Change], old: &Package, new: &Package, scope: &[&Package]) {
361 for change in changes {
362 let held_by = change.path.split('/').next().unwrap_or_default();
363 let new = if held_by == new.name {
364 new
365 } else {
366 scope
367 .iter()
368 .copied()
369 .find(|package| package.name == held_by)
370 .unwrap_or(new)
371 };
372 change.verdict = classify::classify_in(change, old, new, scope);
373 }
374}
375
376/// Assembles a [`DiffReport`], deriving the report verdict as the maximum over
377/// its changes.
378pub(crate) fn report(changes: Vec<Change>) -> DiffReport {
379 let verdict = changes
380 .iter()
381 .map(|change| change.verdict)
382 .max()
383 .unwrap_or(Verdict::Identical);
384 DiffReport {
385 changes,
386 verdict,
387 system: Vec::new(),
388 }
389}
390
391/// Compares two resolved packages. Matched packages share a name; the new
392/// package's name is used as the path prefix.
393///
394/// Only the pair is in hand, so a type from another package does not resolve,
395/// and a type the diff cannot resolve is reported as breaking: a struct field
396/// appended with such a type classifies breaking (driftsys/ridl#598).
397/// [`diff_sets_in`] resolves it.
398pub fn diff_packages(old: &Package, new: &Package) -> DiffReport {
399 let matching = walk::Matching::new(&[old], &[new]);
400 let mut changes = Vec::new();
401 walk::walk_packages(old, new, &matching, &mut changes);
402 classify_all(&mut changes, old, new, &[]);
403 report(changes)
404}
405
406/// Compares two sets of resolved packages, matching by package name, and
407/// interface numbers within each unit ([`walk::Matching`]). A package present
408/// only on the new side is a [`Category::DeclAdded`]; matched packages are
409/// walked pairwise. A package present only on the old side is a
410/// [`Category::DeclRemoved`] when its whole unit is gone from the new side,
411/// and is walked declaration by declaration when the unit is still there, so
412/// that an interface whose number moved to another package of the unit is
413/// [`Category::InterfaceRenamed`] and one the unit retired is
414/// [`Category::InterfaceRetired`].
415///
416/// [`diff_sets_in`] with no context: a type from a package outside `new`, such
417/// as `ridl.std`, which no snapshot carries, does not resolve, and a type the
418/// diff cannot resolve is reported as breaking.
419pub fn diff_sets(old: &[Package], new: &[Package]) -> DiffReport {
420 diff_sets_in(old, new, &[])
421}
422
423/// [`diff_sets`], with `context`: packages that a type reference resolves
424/// against without being compared. The `ridl` CLI passes the built-in
425/// `ridl.std` here, so a struct field appended with a `ridl.std` type is judged
426/// by that type's declaration (driftsys/ridl#598).
427pub fn diff_sets_in(old: &[Package], new: &[Package], context: &[Package]) -> DiffReport {
428 let old: Vec<&Package> = old.iter().collect();
429 let new: Vec<&Package> = new.iter().collect();
430 let context: Vec<&Package> = context.iter().collect();
431 diff_refs_in(&old, &new, &context)
432}
433
434/// [`diff_sets_in`] over borrowed packages, for a caller that holds the
435/// packages by reference and would otherwise clone them.
436pub fn diff_refs_in(old: &[&Package], new: &[&Package], context: &[&Package]) -> DiffReport {
437 use std::collections::BTreeMap;
438
439 use ridl_ir::v2::{members_of_unit, unit_of};
440
441 let scope: Vec<&Package> = new.iter().chain(context).copied().collect();
442 let matching = walk::Matching::new(old, new);
443
444 let old_by: BTreeMap<&str, &Package> =
445 old.iter().map(|pkg| (pkg.name.as_str(), *pkg)).collect();
446 let new_by: BTreeMap<&str, &Package> =
447 new.iter().map(|pkg| (pkg.name.as_str(), *pkg)).collect();
448
449 // Each matched pair is walked and classified against its own two snapshots,
450 // because the classifier resolves a change's path back into the packages it
451 // came from.
452 let mut changes = Vec::new();
453 for (name, old_pkg) in &old_by {
454 match new_by.get(name) {
455 Some(new_pkg) => {
456 let mut pair = Vec::new();
457 walk::walk_packages(old_pkg, new_pkg, &matching, &mut pair);
458 classify_all(&mut pair, old_pkg, new_pkg, &scope);
459 changes.append(&mut pair);
460 }
461 // A package gone while its unit stays: walked against a package
462 // with nothing in it, so that each shape is matched in the unit,
463 // retired by the unit, or removed, and each other declaration is
464 // removed on its own line.
465 None if members_of_unit(unit_of(old_pkg), new).next().is_some() => {
466 let gone = Package {
467 name: old_pkg.name.clone(),
468 unit: old_pkg.unit.clone(),
469 ..Default::default()
470 };
471 let mut pair = Vec::new();
472 walk::walk_packages(old_pkg, &gone, &matching, &mut pair);
473 classify_all(&mut pair, old_pkg, &gone, &scope);
474 changes.append(&mut pair);
475 }
476 // A package present on one side only: the change classifies on its
477 // category alone, so the one snapshot stands for both.
478 None => {
479 let mut pair = Vec::new();
480 emit(
481 &mut pair,
482 (*name).to_string(),
483 Category::DeclRemoved,
484 Some(format!("package {name}")),
485 None,
486 );
487 classify_all(&mut pair, old_pkg, old_pkg, &[]);
488 changes.append(&mut pair);
489 }
490 }
491 }
492 for (name, new_pkg) in &new_by {
493 if !old_by.contains_key(name) {
494 let mut pair = Vec::new();
495 emit(
496 &mut pair,
497 (*name).to_string(),
498 Category::DeclAdded,
499 None,
500 Some(format!("package {name}")),
501 );
502 classify_all(&mut pair, new_pkg, new_pkg, &[]);
503 changes.append(&mut pair);
504 }
505 }
506 report(changes)
507}
508
509/// Compares two workspaces at the system (rsdl reference §14): the packages by
510/// the ridl categories, exactly as [`diff_sets`] does, and — when both sides
511/// carry a lowered system — the system's placement and composition changes,
512/// which carry no verdict. A system on one side only is not compared: there is
513/// nothing to compare it against.
514///
515/// `context` is passed to [`diff_sets_in`].
516pub fn diff_workspaces(
517 old: &[Package],
518 old_system: Option<&System>,
519 new: &[Package],
520 new_system: Option<&System>,
521 context: &[Package],
522) -> DiffReport {
523 let mut report = diff_sets_in(old, new, context);
524 if let (Some(old_system), Some(new_system)) = (old_system, new_system) {
525 report.system = diff_systems(old_system, new_system);
526 }
527 report
528}
529
530/// Loads an `.ir.json` snapshot written by `ridl build --emit ir-json` —
531/// canonical protobuf JSON, read through the one reader every surface shares
532/// (ADR-0014 decision 1).
533pub fn load_ir_json(path: &Path) -> Result<Package, LoadError> {
534 let text = std::fs::read_to_string(path).map_err(LoadError::Io)?;
535 ridl_ir::v2::from_json(&text).map_err(|err| LoadError::Parse(err.to_string()))
536}
537
538/// The stable lowercase word for a verdict — used by both the text and JSON
539/// renderers so the two stay in lockstep.
540pub(crate) fn verdict_word(verdict: Verdict) -> &'static str {
541 match verdict {
542 Verdict::Identical => "identical",
543 Verdict::Compatible => "compatible",
544 Verdict::Breaking => "breaking",
545 }
546}
547
548/// The stable snake_case word for a category — the single source of truth for
549/// both renderers and for `ridl diff --explain`, which takes a category exactly
550/// as the report prints it.
551// A new variant must be given a real arm here, not swept into a
552// catch-all: rustc forces *an* arm, and the arm its `help:` text
553// proposes is `_ =>`, which classifies the new variant silently. The
554// two lints below reject a wildcard over `Category` — the first when
555// it covers several variants, the second when it covers exactly one,
556// which is the case one added variant creates.
557#[deny(
558 clippy::wildcard_enum_match_arm,
559 clippy::match_wildcard_for_single_variants
560)]
561pub fn category_word(category: Category) -> &'static str {
562 match category {
563 Category::DeclAdded => "decl_added",
564 Category::DeclRemoved => "decl_removed",
565 Category::InterfaceRenamed => "interface_renamed",
566 Category::InterfaceRetired => "interface_retired",
567 Category::InterfaceFrozen => "interface_frozen",
568 Category::InterfaceNumberChanged => "interface_number_changed",
569 Category::MemberReordered => "member_reordered",
570 Category::EnumReordered => "enum_reordered",
571 Category::InteractionAppended => "interaction_appended",
572 Category::InteractionInserted => "interaction_inserted",
573 Category::InteractionReordered => "interaction_reordered",
574 Category::InteractionRemoved => "interaction_removed",
575 Category::InteractionRetired => "interaction_retired",
576 Category::KindChanged => "kind_changed",
577 Category::PayloadChanged => "payload_changed",
578 Category::ReturnChanged => "return_changed",
579 Category::ParamsChanged => "params_changed",
580 Category::TimingChanged => "timing_changed",
581 Category::RpcBoundChanged => "rpc_bound_changed",
582 Category::ContractChanged => "contract_changed",
583 Category::WidthChanged => "width_changed",
584 Category::ConstraintChanged => "constraint_changed",
585 Category::InitChanged => "init_changed",
586 Category::ReservedNameRedeclared => "reserved_name_redeclared",
587 Category::ServiceChanged => "service_changed",
588 Category::ServiceInterfaceAdded => "service_interface_added",
589 Category::ServiceInterfaceRemoved => "service_interface_removed",
590 Category::DocOnly => "doc_only",
591 Category::VisibilityChanged => "visibility_changed",
592 }
593}
594
595/// The heading a category's changes are grouped under in the text report, or
596/// `None` for a category listed plainly. Two headings exist. "compatible on
597/// the wire, visible in source" is for a change that exits 0 but that a
598/// consumer sees in its source — an interface renamed on its number changes
599/// the generated identity-table names in both wire backends, and an interface
600/// leaving a service's set stops the `service.member` addresses of that
601/// interface resolving under the service. "compatible on the wire, may change
602/// the catalog hash" is for a change that exits 0 but can change the catalog
603/// hash, which a generated face compares with its peer's — a number `ridl
604/// lock` froze in place, which always changes it, and an enum or enum-set body
605/// reordered, which changes it when an interface reaches the enum or enum set
606/// (the hash covers only the declarations an interface reaches). Each such
607/// category's `--explain` text states its own consequence; the JSON report
608/// carries the category word and no heading field.
609///
610/// Wildcard arms are denied for the reason `category_word` gives.
611#[deny(
612 clippy::wildcard_enum_match_arm,
613 clippy::match_wildcard_for_single_variants
614)]
615pub fn heading(category: Category) -> Option<&'static str> {
616 match category {
617 Category::InterfaceRenamed | Category::ServiceInterfaceRemoved => {
618 Some("compatible on the wire, visible in source")
619 }
620 Category::InterfaceFrozen | Category::EnumReordered => {
621 Some("compatible on the wire, may change the catalog hash")
622 }
623 Category::DeclAdded
624 | Category::DeclRemoved
625 | Category::InterfaceRetired
626 | Category::InterfaceNumberChanged
627 | Category::MemberReordered
628 | Category::InteractionAppended
629 | Category::InteractionInserted
630 | Category::InteractionReordered
631 | Category::InteractionRemoved
632 | Category::InteractionRetired
633 | Category::KindChanged
634 | Category::PayloadChanged
635 | Category::ReturnChanged
636 | Category::ParamsChanged
637 | Category::TimingChanged
638 | Category::RpcBoundChanged
639 | Category::ContractChanged
640 | Category::WidthChanged
641 | Category::ConstraintChanged
642 | Category::InitChanged
643 | Category::ReservedNameRedeclared
644 | Category::ServiceChanged
645 | Category::ServiceInterfaceAdded
646 | Category::DocOnly
647 | Category::VisibilityChanged => None,
648 }
649}
650
651impl serde::Serialize for Verdict {
652 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
653 serializer.serialize_str(verdict_word(*self))
654 }
655}
656
657impl serde::Serialize for Category {
658 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
659 serializer.serialize_str(category_word(*self))
660 }
661}
662
663impl serde::Serialize for Change {
664 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
665 use serde::ser::SerializeStruct as _;
666 let mut state = serializer.serialize_struct("Change", 5)?;
667 state.serialize_field("path", &self.path)?;
668 state.serialize_field("category", &self.category)?;
669 state.serialize_field("verdict", &self.verdict)?;
670 state.serialize_field("before", &self.before)?;
671 state.serialize_field("after", &self.after)?;
672 state.end()
673 }
674}
675
676/// Renders a report as a human-readable summary: the report verdict on the
677/// first line, then one indented line per change. A change whose category has
678/// no [`heading`] is listed first, in report order; then each heading is
679/// printed once, as its own line ending in a colon, followed by the changes
680/// under it, in report order. Headings come in [`CATEGORIES`] order.
681///
682/// After the contract changes come the system headings (rsdl reference §14):
683/// each heading that has a change on its own line, then one indented line per
684/// change under it, in the `path: before -> after` form and with no verdict.
685pub fn render_text(report: &DiffReport) -> String {
686 let mut out = String::new();
687 out.push_str(verdict_word(report.verdict));
688 out.push('\n');
689 for change in &report.changes {
690 if heading(change.category).is_none() {
691 push_change_line(&mut out, change);
692 }
693 }
694 let mut printed: Vec<&'static str> = Vec::new();
695 for category in CATEGORIES {
696 let Some(title) = heading(category) else {
697 continue;
698 };
699 if printed.contains(&title) {
700 continue;
701 }
702 printed.push(title);
703 let mut under = report
704 .changes
705 .iter()
706 .filter(|change| heading(change.category) == Some(title))
707 .peekable();
708 if under.peek().is_none() {
709 continue;
710 }
711 out.push_str(title);
712 out.push_str(":\n");
713 for change in under {
714 push_change_line(&mut out, change);
715 }
716 }
717 for heading in [
718 SystemHeading::PlacementChanged,
719 SystemHeading::CompositionChanged,
720 ] {
721 let mut listed = report
722 .system
723 .iter()
724 .filter(|change| change.heading == heading)
725 .peekable();
726 if listed.peek().is_none() {
727 continue;
728 }
729 out.push_str(system::heading_text(heading));
730 out.push('\n');
731 for change in listed {
732 out.push_str(" ");
733 out.push_str(&change.path);
734 push_values(&mut out, change.before.as_ref(), change.after.as_ref());
735 out.push('\n');
736 }
737 }
738 out
739}
740
741/// One change of the text report: ` [verdict] category path`, then
742/// `: before -> after` where the sides apply.
743fn push_change_line(out: &mut String, change: &Change) {
744 out.push_str(" [");
745 out.push_str(verdict_word(change.verdict));
746 out.push_str("] ");
747 out.push_str(category_word(change.category));
748 out.push(' ');
749 out.push_str(&change.path);
750 push_values(out, change.before.as_ref(), change.after.as_ref());
751 out.push('\n');
752}
753
754/// The rendered `before -> after` tail of one change line, shared by contract
755/// changes and system changes.
756fn push_values(out: &mut String, before: Option<&String>, after: Option<&String>) {
757 match (before, after) {
758 (Some(before), Some(after)) => {
759 out.push_str(": ");
760 out.push_str(before);
761 out.push_str(" -> ");
762 out.push_str(after);
763 }
764 (Some(before), None) => {
765 out.push_str(": ");
766 out.push_str(before);
767 out.push_str(" -> (removed)");
768 }
769 (None, Some(after)) => {
770 out.push_str(": (absent) -> ");
771 out.push_str(after);
772 }
773 (None, None) => {}
774 }
775}
776
777/// Renders a report as machine-readable JSON with the stable schema
778/// `{"verdict", "changes": [{"path", "category", "verdict", "before",
779/// "after"}]}`.
780pub fn render_json(report: &DiffReport) -> String {
781 #[derive(serde::Serialize)]
782 struct JsonSystemChange<'a> {
783 path: &'a str,
784 before: Option<&'a str>,
785 after: Option<&'a str>,
786 }
787
788 #[derive(serde::Serialize)]
789 struct JsonReport<'a> {
790 verdict: Verdict,
791 changes: &'a [Change],
792 #[serde(skip_serializing_if = "Vec::is_empty")]
793 placement_changed: Vec<JsonSystemChange<'a>>,
794 #[serde(skip_serializing_if = "Vec::is_empty")]
795 composition_changed: Vec<JsonSystemChange<'a>>,
796 }
797
798 let under = |heading: SystemHeading| -> Vec<JsonSystemChange<'_>> {
799 report
800 .system
801 .iter()
802 .filter(|change| change.heading == heading)
803 .map(|change| JsonSystemChange {
804 path: &change.path,
805 before: change.before.as_deref(),
806 after: change.after.as_deref(),
807 })
808 .collect()
809 };
810 serde_json::to_string_pretty(&JsonReport {
811 verdict: report.verdict,
812 changes: &report.changes,
813 placement_changed: under(SystemHeading::PlacementChanged),
814 composition_changed: under(SystemHeading::CompositionChanged),
815 })
816 .expect("a diff report holds only string-representable values, so serialization cannot fail")
817}