Skip to main content

ridl_diff/
unit_verdict.rs

1//! The verdict of a [`DiffReport`] restricted to one unit.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use ridl_ir::catalog_hash::reachable_decls;
6use ridl_ir::v2::{Package, unit_of};
7
8use crate::{DiffReport, Verdict};
9
10/// The verdict of `report` over the changes that concern `unit`: the maximum
11/// [`Change::verdict`](crate::Change::verdict) of those changes, or
12/// [`Verdict::Identical`] when none concerns the unit. `old` and `new` are the
13/// two package sets the report compared.
14///
15/// A caller that asks for several units of one diff builds a
16/// [`UnitVerdicts`] once and calls [`UnitVerdicts::verdict`] per unit; this
17/// function builds one for a single call.
18///
19/// The change's package and declaration come from
20/// [`Change::package`](crate::Change::package) and
21/// [`Change::declaration`](crate::Change::declaration): the dotted name of
22/// the package that holds the new side, or the old side when the package is
23/// gone, and, when present, the bare name of a package-level declaration,
24/// interface or service of that package. A package name holds dots and a
25/// declaration name holds none, so the two joined by `.` are the
26/// declaration's canonical name, the key that [`reachable_decls`] uses.
27///
28/// A change concerns `unit` when either holds:
29///
30/// - its package's unit ([`unit_of`]) is `unit`, in `old` or in `new`;
31/// - its package and declaration, joined by `.`, are a key of
32///   `reachable_decls(unit, old)` or of `reachable_decls(unit, new)`: a
33///   declaration of another unit that an interface shape of `unit` reaches on
34///   either side.
35///
36/// So a breaking change to a struct of another unit that `unit` reaches is
37/// breaking for `unit`, and a breaking change in a unit that `unit` does not
38/// reach leaves `unit`'s verdict unchanged. The report's own
39/// [`DiffReport::verdict`] and its [`DiffReport::system`] changes are not
40/// read.
41pub fn unit_verdict(report: &DiffReport, unit: &str, old: &[Package], new: &[Package]) -> Verdict {
42    UnitVerdicts::new(report, old, new).verdict(unit)
43}
44
45/// The per-unit verdicts of one diff, with the work every unit shares done
46/// once: the package references of both sides, the package names of each
47/// unit and the canonical key of each change. See [`unit_verdict`] for what
48/// concerns a unit.
49pub struct UnitVerdicts<'a> {
50    old: Vec<&'a Package>,
51    new: Vec<&'a Package>,
52    /// The names of the packages of each unit, on either side.
53    names_by_unit: BTreeMap<&'a str, BTreeSet<&'a str>>,
54    /// Each change's package, canonical declaration key (absent for a change
55    /// to the package itself) and verdict.
56    changes: Vec<(&'a str, Option<String>, Verdict)>,
57}
58
59impl<'a> UnitVerdicts<'a> {
60    /// Prepares the verdicts of `report`, the diff of `old` against `new`.
61    pub fn new(report: &'a DiffReport, old: &'a [Package], new: &'a [Package]) -> Self {
62        let old: Vec<&Package> = old.iter().collect();
63        let new: Vec<&Package> = new.iter().collect();
64        Self::from_refs(report, &old, &new)
65    }
66
67    /// [`UnitVerdicts::new`] over borrowed packages, for a caller that holds
68    /// the packages by reference and would otherwise clone them.
69    pub fn from_refs(report: &'a DiffReport, old: &[&'a Package], new: &[&'a Package]) -> Self {
70        let mut names_by_unit: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
71        for package in old.iter().chain(new) {
72            names_by_unit
73                .entry(unit_of(package))
74                .or_default()
75                .insert(package.name.as_str());
76        }
77        Self {
78            old: old.to_vec(),
79            new: new.to_vec(),
80            names_by_unit,
81            changes: report
82                .changes
83                .iter()
84                .map(|change| {
85                    let package = change.package();
86                    let canonical = change.declaration().map(|name| format!("{package}.{name}"));
87                    (package, canonical, change.verdict)
88                })
89                .collect(),
90        }
91    }
92
93    /// The verdict over the changes that concern `unit`.
94    pub fn verdict(&self, unit: &str) -> Verdict {
95        let no_packages = BTreeSet::new();
96        let packages = self.names_by_unit.get(unit).unwrap_or(&no_packages);
97        let reached_old = reachable_decls(unit, &self.old);
98        let reached_new = reachable_decls(unit, &self.new);
99
100        self.changes
101            .iter()
102            .filter(|(package, canonical, _)| {
103                packages.contains(package)
104                    || canonical.as_ref().is_some_and(|canonical| {
105                        reached_old.contains_key(canonical) || reached_new.contains_key(canonical)
106                    })
107            })
108            .map(|(_, _, verdict)| *verdict)
109            .max()
110            .unwrap_or(Verdict::Identical)
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use ridl_ir::v2::{
117        Decl, EnumDef, EnumValue, EventDef, Field, FieldType, IntWidth, Interface, Package,
118        Reserved, SignalDef, StructDef, StructMember, TypeDef, Visibility, backing, decl,
119        field_type, struct_member, type_def,
120    };
121
122    use super::unit_verdict;
123    use crate::{Verdict, diff_sets};
124
125    /// An inline integer scalar of the given width.
126    fn int(width: IntWidth) -> FieldType {
127        FieldType {
128            optional: false,
129            kind: Some(field_type::Kind::InlineScalar(Box::new(TypeDef {
130                backing: Some(ridl_ir::v2::Backing {
131                    kind: Some(backing::Kind::Primitive(
132                        ridl_ir::v2::PrimitiveType::Integer as i32,
133                    )),
134                }),
135                width: Some(type_def::Width::IntWidth(width as i32)),
136                ..Default::default()
137            }))),
138        }
139    }
140
141    /// `struct <name> { <field>: <width> }`.
142    fn one_field_struct(name: &str, field: &str, width: IntWidth) -> Decl {
143        Decl {
144            name: name.to_owned(),
145            visibility: Visibility::Public as i32,
146            kind: Some(decl::Kind::StructDef(StructDef {
147                members: vec![StructMember {
148                    member: Some(struct_member::Member::Field(Box::new(Field {
149                        name: field.to_owned(),
150                        ordinal: 1,
151                        r#type: Some(int(width)),
152                        ..Default::default()
153                    }))),
154                }],
155                fixed_layout: false,
156            })),
157            ..Default::default()
158        }
159    }
160
161    fn package(name: &str, decls: Vec<Decl>, interfaces: Vec<Interface>) -> Package {
162        Package {
163            name: name.to_owned(),
164            decls,
165            interfaces,
166            unit: name.to_owned(),
167            ..Default::default()
168        }
169    }
170
171    /// Unit `a`: one interface whose signal carries `payload`.
172    fn unit_a(payload: &str) -> Package {
173        let signal = Decl {
174            name: "status".to_owned(),
175            ordinal: 1,
176            kind: Some(decl::Kind::SignalDef(SignalDef {
177                payload: payload.to_owned(),
178                ..Default::default()
179            })),
180            ..Default::default()
181        };
182        package(
183            "a",
184            Vec::new(),
185            vec![Interface {
186                name: "Status".to_owned(),
187                visibility: Visibility::Public as i32,
188                number: 1,
189                provisional: false,
190                interactions: vec![signal],
191                ..Default::default()
192            }],
193        )
194    }
195
196    fn unit_b(width: IntWidth) -> Package {
197        package("b", vec![one_field_struct("S", "x", width)], Vec::new())
198    }
199
200    fn unit_c(width: IntWidth) -> Package {
201        package("c", vec![one_field_struct("T", "y", width)], Vec::new())
202    }
203
204    fn baseline() -> Vec<Package> {
205        vec![unit_a("b.S"), unit_b(IntWidth::I32), unit_c(IntWidth::I32)]
206    }
207
208    #[test]
209    fn a_break_in_a_reached_declaration_of_another_unit_concerns_this_unit() {
210        let old = baseline();
211        let new = vec![unit_a("b.S"), unit_b(IntWidth::I64), unit_c(IntWidth::I32)];
212        let report = diff_sets(&old, &new);
213        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
214        assert_eq!(unit_verdict(&report, "c", &old, &new), Verdict::Identical);
215    }
216
217    #[test]
218    fn a_break_in_an_unreached_unit_does_not_concern_this_unit() {
219        let old = baseline();
220        let new = vec![unit_a("b.S"), unit_b(IntWidth::I32), unit_c(IntWidth::I64)];
221        let report = diff_sets(&old, &new);
222        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Identical);
223        assert_eq!(unit_verdict(&report, "c", &old, &new), Verdict::Breaking);
224    }
225
226    #[test]
227    fn an_appended_interaction_is_compatible_for_its_own_unit() {
228        let old = baseline();
229        let mut a = unit_a("b.S");
230        a.interfaces[0].interactions.push(appended_event("b.S"));
231        let new = vec![a, unit_b(IntWidth::I32), unit_c(IntWidth::I32)];
232        let report = diff_sets(&old, &new);
233        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Compatible);
234        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Identical);
235    }
236
237    #[test]
238    fn a_package_added_to_a_unit_concerns_that_unit() {
239        let old = baseline();
240        let mut sub = package(
241            "a.sub",
242            vec![one_field_struct("U", "z", IntWidth::I32)],
243            Vec::new(),
244        );
245        sub.unit = "a".to_owned();
246        let mut new = baseline();
247        new.push(sub);
248        let report = diff_sets(&old, &new);
249        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Compatible);
250        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Identical);
251    }
252
253    /// An event appended to unit `a`'s interface, with payload `payload`.
254    fn appended_event(payload: &str) -> Decl {
255        Decl {
256            name: "alarm".to_owned(),
257            ordinal: 2,
258            kind: Some(decl::Kind::EventDef(EventDef {
259                payload: payload.to_owned(),
260                timing: None,
261                family: ridl_ir::v2::Family::Dispatch as i32,
262            })),
263            ..Default::default()
264        }
265    }
266
267    // The change to unit `a`'s own package, an appended event, is compatible,
268    // so only the new-side reach of `c.T` makes `a` breaking.
269    #[test]
270    fn a_declaration_reached_only_on_the_new_side_concerns_the_unit() {
271        let old = baseline();
272        let mut a = unit_a("b.S");
273        a.interfaces[0].interactions.push(appended_event("c.T"));
274        let new = vec![a, unit_b(IntWidth::I32), unit_c(IntWidth::I64)];
275        let report = diff_sets(&old, &new);
276        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
277        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Identical);
278    }
279
280    // The mirror of `a_declaration_reached_only_on_the_new_side_concerns_the_unit`:
281    // the interaction that reached `c.T` is retired to a tombstone in its own
282    // slot, which is compatible, so only the old-side reach of `c.T` makes `a`
283    // breaking.
284    #[test]
285    fn a_break_reached_only_through_a_retired_interaction_concerns_the_unit() {
286        let mut a_old = unit_a("b.S");
287        a_old.interfaces[0].interactions.push(appended_event("c.T"));
288        let old = vec![a_old, unit_b(IntWidth::I32), unit_c(IntWidth::I32)];
289        let mut a_new = unit_a("b.S");
290        a_new.interfaces[0].interactions.push(Decl {
291            ordinal: 2,
292            kind: Some(decl::Kind::ReservedSlot(Reserved {
293                ordinal: 2,
294                name: Some("alarm".to_owned()),
295                value: None,
296            })),
297            ..Default::default()
298        });
299        let new = vec![a_new, unit_b(IntWidth::I32), unit_c(IntWidth::I64)];
300        let report = diff_sets(&old, &new);
301        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
302        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Identical);
303    }
304
305    /// Unit `b` where `struct S { t: c.T }`, so `b.S` reaches `c.T`.
306    fn unit_b_referencing_c() -> Package {
307        let mut s = one_field_struct("S", "t", IntWidth::I32);
308        if let Some(decl::Kind::StructDef(def)) = &mut s.kind
309            && let Some(struct_member::Member::Field(field)) = &mut def.members[0].member
310        {
311            field.r#type = Some(FieldType {
312                optional: false,
313                kind: Some(field_type::Kind::Named("c.T".to_owned())),
314            });
315        }
316        package("b", vec![s], Vec::new())
317    }
318
319    // `a` reaches `b.S`, which reaches `c.T`. The break is in `c.T` only, so
320    // it concerns `a` only through two hops. Unit `d` reaches nothing.
321    #[test]
322    fn a_break_reached_through_two_hops_concerns_the_unit() {
323        let old = vec![
324            unit_a("b.S"),
325            unit_b_referencing_c(),
326            unit_c(IntWidth::I32),
327            package("d", Vec::new(), Vec::new()),
328        ];
329        let mut new = old.clone();
330        new[2] = unit_c(IntWidth::I64);
331        let report = diff_sets(&old, &new);
332        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
333        assert_eq!(unit_verdict(&report, "d", &old, &new), Verdict::Identical);
334    }
335
336    /// Package `a.sub` of unit `a`, holding `struct U { z: i32 }`.
337    fn sub_package() -> Package {
338        let mut sub = package(
339            "a.sub",
340            vec![one_field_struct("U", "z", IntWidth::I32)],
341            Vec::new(),
342        );
343        sub.unit = "a".to_owned();
344        sub
345    }
346
347    // The removed package exists only on the old side, so only the old-side
348    // package rule makes the removal concern unit `a`.
349    #[test]
350    fn a_package_removed_from_a_unit_concerns_that_unit() {
351        let mut old = baseline();
352        old.push(sub_package());
353        let new = baseline();
354        let report = diff_sets(&old, &new);
355        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
356        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Identical);
357    }
358
359    /// Unit `a` with its interface's number set to `number` and its
360    /// provisional flag to `provisional`, beside units `b` and `c`.
361    fn numbered(number: u32, provisional: bool) -> Vec<Package> {
362        let mut a = unit_a("b.S");
363        a.interfaces[0].number = number;
364        a.interfaces[0].provisional = provisional;
365        vec![a, unit_b(IntWidth::I32), unit_c(IntWidth::I32)]
366    }
367
368    // `ridl lock` freezing the number in place changes the catalog hash and
369    // is compatible, so the unit's verdict keeps the compatible-hash list
370    // that `write_catalog_histories` carries forward.
371    #[test]
372    fn a_number_frozen_in_place_is_compatible_for_its_unit() {
373        let old = numbered(1, true);
374        let new = numbered(1, false);
375        let report = diff_sets(&old, &new);
376        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Compatible);
377        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Identical);
378    }
379
380    // A moved number is a new routing key, so the unit's verdict is breaking
381    // and `write_catalog_histories` restarts the compatible-hash list.
382    #[test]
383    fn a_moved_number_is_breaking_for_its_unit() {
384        let old = numbered(1, true);
385        let new = numbered(2, true);
386        let report = diff_sets(&old, &new);
387        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
388        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Identical);
389    }
390
391    /// Unit `b` holding `enum Mode` with `values` in the given order and the
392    /// retired numbers `retired` in the given order.
393    fn unit_b_with_enum(values: &[(&str, i64)], retired: &[i64]) -> Package {
394        let mode = Decl {
395            name: "Mode".to_owned(),
396            visibility: Visibility::Public as i32,
397            kind: Some(decl::Kind::EnumDef(EnumDef {
398                values: values
399                    .iter()
400                    .map(|(name, value)| EnumValue {
401                        name: (*name).to_owned(),
402                        value: *value,
403                        ..Default::default()
404                    })
405                    .collect(),
406                reserved: retired
407                    .iter()
408                    .map(|value| Reserved {
409                        value: Some(*value),
410                        ..Default::default()
411                    })
412                    .collect(),
413            })),
414            ..Default::default()
415        };
416        package("b", vec![mode], Vec::new())
417    }
418
419    // A textual reorder of an enum's values moves nothing on the wire, so the
420    // unit's verdict is compatible. The fixture has no interface, so no
421    // catalog hash is involved here.
422    #[test]
423    fn an_enum_value_reorder_is_compatible_for_its_unit() {
424        let old = vec![unit_b_with_enum(&[("OFF", 0), ("ON", 1)], &[])];
425        let new = vec![unit_b_with_enum(&[("ON", 1), ("OFF", 0)], &[])];
426        let report = diff_sets(&old, &new);
427        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Compatible);
428    }
429
430    // The same holds for a reordered `reserved` list.
431    #[test]
432    fn an_enum_reserved_list_reorder_is_compatible_for_its_unit() {
433        let old = vec![unit_b_with_enum(&[("OFF", 0)], &[3, 4])];
434        let new = vec![unit_b_with_enum(&[("OFF", 0)], &[4, 3])];
435        let report = diff_sets(&old, &new);
436        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Compatible);
437    }
438
439    // The compatible package addition is reported after the breaking change to
440    // `b.S`, so a fold that keeps the last verdict instead of the maximum
441    // returns `Compatible`.
442    #[test]
443    fn the_unit_verdict_is_the_maximum_over_its_changes() {
444        let old = baseline();
445        let mut new = vec![unit_a("b.S"), unit_b(IntWidth::I64), unit_c(IntWidth::I32)];
446        new.push(sub_package());
447        let report = diff_sets(&old, &new);
448        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
449        assert_eq!(unit_verdict(&report, "c", &old, &new), Verdict::Identical);
450    }
451
452    // The walk's path layout is `<package>/<name>[/<member>...]`; the verdicts
453    // read it through the accessors, so a change of layout fails here.
454    #[test]
455    fn a_walked_change_names_its_package_and_declaration() {
456        let old = baseline();
457        let new = vec![unit_a("b.S"), unit_b(IntWidth::I64), unit_c(IntWidth::I32)];
458        let report = diff_sets(&old, &new);
459        let change = report
460            .changes
461            .iter()
462            .find(|change| change.package() == "b")
463            .expect("the width change in b.S is reported");
464        assert_eq!(change.declaration(), Some("S"));
465    }
466
467    #[test]
468    fn a_change_matches_by_its_structured_package_and_declaration() {
469        let old = baseline();
470        let new = baseline();
471        let mut report = diff_sets(&old, &new);
472        report.changes.push(crate::Change {
473            path: "b/S/x".to_owned(),
474            category: crate::Category::DeclRemoved,
475            verdict: Verdict::Breaking,
476            before: None,
477            after: None,
478        });
479        // The change names declaration `S` of package `b`, which unit `a`
480        // reaches through its signal payload.
481        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
482        assert_eq!(unit_verdict(&report, "c", &old, &new), Verdict::Identical);
483    }
484
485    #[test]
486    fn one_prepared_value_answers_several_units_in_any_order() {
487        let old = baseline();
488        let new = vec![unit_a("b.S"), unit_b(IntWidth::I64), unit_c(IntWidth::I32)];
489        let report = diff_sets(&old, &new);
490        let prepared = super::UnitVerdicts::new(&report, &old, &new);
491        assert_eq!(prepared.verdict("c"), Verdict::Identical);
492        assert_eq!(prepared.verdict("a"), Verdict::Breaking);
493        assert_eq!(prepared.verdict("c"), Verdict::Identical);
494        assert_eq!(prepared.verdict("b"), Verdict::Breaking);
495        assert_eq!(prepared.verdict("absent"), Verdict::Identical);
496        assert_eq!(prepared.verdict("a"), Verdict::Breaking);
497    }
498
499    #[test]
500    fn a_change_to_a_package_has_no_declaration() {
501        let change = |path: &str| crate::Change {
502            path: path.to_owned(),
503            category: crate::Category::DeclRemoved,
504            verdict: Verdict::Breaking,
505            before: None,
506            after: None,
507        };
508        assert_eq!(change("veh.cluster").package(), "veh.cluster");
509        assert_eq!(change("veh.cluster").declaration(), None);
510        assert_eq!(
511            change("veh.cluster/Status/doorOpened").package(),
512            "veh.cluster"
513        );
514        assert_eq!(
515            change("veh.cluster/Status/doorOpened").declaration(),
516            Some("Status")
517        );
518    }
519}