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            })),
262            ..Default::default()
263        }
264    }
265
266    // The change to unit `a`'s own package, an appended event, is compatible,
267    // so only the new-side reach of `c.T` makes `a` breaking.
268    #[test]
269    fn a_declaration_reached_only_on_the_new_side_concerns_the_unit() {
270        let old = baseline();
271        let mut a = unit_a("b.S");
272        a.interfaces[0].interactions.push(appended_event("c.T"));
273        let new = vec![a, unit_b(IntWidth::I32), unit_c(IntWidth::I64)];
274        let report = diff_sets(&old, &new);
275        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
276        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Identical);
277    }
278
279    // The mirror of `a_declaration_reached_only_on_the_new_side_concerns_the_unit`:
280    // the interaction that reached `c.T` is retired to a tombstone in its own
281    // slot, which is compatible, so only the old-side reach of `c.T` makes `a`
282    // breaking.
283    #[test]
284    fn a_break_reached_only_through_a_retired_interaction_concerns_the_unit() {
285        let mut a_old = unit_a("b.S");
286        a_old.interfaces[0].interactions.push(appended_event("c.T"));
287        let old = vec![a_old, unit_b(IntWidth::I32), unit_c(IntWidth::I32)];
288        let mut a_new = unit_a("b.S");
289        a_new.interfaces[0].interactions.push(Decl {
290            ordinal: 2,
291            kind: Some(decl::Kind::ReservedSlot(Reserved {
292                ordinal: 2,
293                name: Some("alarm".to_owned()),
294                value: None,
295            })),
296            ..Default::default()
297        });
298        let new = vec![a_new, unit_b(IntWidth::I32), unit_c(IntWidth::I64)];
299        let report = diff_sets(&old, &new);
300        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
301        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Identical);
302    }
303
304    /// Unit `b` where `struct S { t: c.T }`, so `b.S` reaches `c.T`.
305    fn unit_b_referencing_c() -> Package {
306        let mut s = one_field_struct("S", "t", IntWidth::I32);
307        if let Some(decl::Kind::StructDef(def)) = &mut s.kind
308            && let Some(struct_member::Member::Field(field)) = &mut def.members[0].member
309        {
310            field.r#type = Some(FieldType {
311                optional: false,
312                kind: Some(field_type::Kind::Named("c.T".to_owned())),
313            });
314        }
315        package("b", vec![s], Vec::new())
316    }
317
318    // `a` reaches `b.S`, which reaches `c.T`. The break is in `c.T` only, so
319    // it concerns `a` only through two hops. Unit `d` reaches nothing.
320    #[test]
321    fn a_break_reached_through_two_hops_concerns_the_unit() {
322        let old = vec![
323            unit_a("b.S"),
324            unit_b_referencing_c(),
325            unit_c(IntWidth::I32),
326            package("d", Vec::new(), Vec::new()),
327        ];
328        let mut new = old.clone();
329        new[2] = unit_c(IntWidth::I64);
330        let report = diff_sets(&old, &new);
331        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
332        assert_eq!(unit_verdict(&report, "d", &old, &new), Verdict::Identical);
333    }
334
335    /// Package `a.sub` of unit `a`, holding `struct U { z: i32 }`.
336    fn sub_package() -> Package {
337        let mut sub = package(
338            "a.sub",
339            vec![one_field_struct("U", "z", IntWidth::I32)],
340            Vec::new(),
341        );
342        sub.unit = "a".to_owned();
343        sub
344    }
345
346    // The removed package exists only on the old side, so only the old-side
347    // package rule makes the removal concern unit `a`.
348    #[test]
349    fn a_package_removed_from_a_unit_concerns_that_unit() {
350        let mut old = baseline();
351        old.push(sub_package());
352        let new = baseline();
353        let report = diff_sets(&old, &new);
354        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
355        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Identical);
356    }
357
358    /// Unit `a` with its interface's number set to `number` and its
359    /// provisional flag to `provisional`, beside units `b` and `c`.
360    fn numbered(number: u32, provisional: bool) -> Vec<Package> {
361        let mut a = unit_a("b.S");
362        a.interfaces[0].number = number;
363        a.interfaces[0].provisional = provisional;
364        vec![a, unit_b(IntWidth::I32), unit_c(IntWidth::I32)]
365    }
366
367    // `ridl lock` freezing the number in place changes the catalog hash and
368    // is compatible, so the unit's verdict keeps the compatible-hash list
369    // that `write_catalog_histories` carries forward.
370    #[test]
371    fn a_number_frozen_in_place_is_compatible_for_its_unit() {
372        let old = numbered(1, true);
373        let new = numbered(1, false);
374        let report = diff_sets(&old, &new);
375        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Compatible);
376        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Identical);
377    }
378
379    // A moved number is a new routing key, so the unit's verdict is breaking
380    // and `write_catalog_histories` restarts the compatible-hash list.
381    #[test]
382    fn a_moved_number_is_breaking_for_its_unit() {
383        let old = numbered(1, true);
384        let new = numbered(2, true);
385        let report = diff_sets(&old, &new);
386        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
387        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Identical);
388    }
389
390    /// Unit `b` holding `enum Mode` with `values` in the given order and the
391    /// retired numbers `retired` in the given order.
392    fn unit_b_with_enum(values: &[(&str, i64)], retired: &[i64]) -> Package {
393        let mode = Decl {
394            name: "Mode".to_owned(),
395            visibility: Visibility::Public as i32,
396            kind: Some(decl::Kind::EnumDef(EnumDef {
397                values: values
398                    .iter()
399                    .map(|(name, value)| EnumValue {
400                        name: (*name).to_owned(),
401                        value: *value,
402                        ..Default::default()
403                    })
404                    .collect(),
405                reserved: retired
406                    .iter()
407                    .map(|value| Reserved {
408                        value: Some(*value),
409                        ..Default::default()
410                    })
411                    .collect(),
412            })),
413            ..Default::default()
414        };
415        package("b", vec![mode], Vec::new())
416    }
417
418    // A textual reorder of an enum's values moves nothing on the wire, so the
419    // unit's verdict is compatible. The fixture has no interface, so no
420    // catalog hash is involved here.
421    #[test]
422    fn an_enum_value_reorder_is_compatible_for_its_unit() {
423        let old = vec![unit_b_with_enum(&[("OFF", 0), ("ON", 1)], &[])];
424        let new = vec![unit_b_with_enum(&[("ON", 1), ("OFF", 0)], &[])];
425        let report = diff_sets(&old, &new);
426        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Compatible);
427    }
428
429    // The same holds for a reordered `reserved` list.
430    #[test]
431    fn an_enum_reserved_list_reorder_is_compatible_for_its_unit() {
432        let old = vec![unit_b_with_enum(&[("OFF", 0)], &[3, 4])];
433        let new = vec![unit_b_with_enum(&[("OFF", 0)], &[4, 3])];
434        let report = diff_sets(&old, &new);
435        assert_eq!(unit_verdict(&report, "b", &old, &new), Verdict::Compatible);
436    }
437
438    // The compatible package addition is reported after the breaking change to
439    // `b.S`, so a fold that keeps the last verdict instead of the maximum
440    // returns `Compatible`.
441    #[test]
442    fn the_unit_verdict_is_the_maximum_over_its_changes() {
443        let old = baseline();
444        let mut new = vec![unit_a("b.S"), unit_b(IntWidth::I64), unit_c(IntWidth::I32)];
445        new.push(sub_package());
446        let report = diff_sets(&old, &new);
447        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
448        assert_eq!(unit_verdict(&report, "c", &old, &new), Verdict::Identical);
449    }
450
451    // The walk's path layout is `<package>/<name>[/<member>...]`; the verdicts
452    // read it through the accessors, so a change of layout fails here.
453    #[test]
454    fn a_walked_change_names_its_package_and_declaration() {
455        let old = baseline();
456        let new = vec![unit_a("b.S"), unit_b(IntWidth::I64), unit_c(IntWidth::I32)];
457        let report = diff_sets(&old, &new);
458        let change = report
459            .changes
460            .iter()
461            .find(|change| change.package() == "b")
462            .expect("the width change in b.S is reported");
463        assert_eq!(change.declaration(), Some("S"));
464    }
465
466    #[test]
467    fn a_change_matches_by_its_structured_package_and_declaration() {
468        let old = baseline();
469        let new = baseline();
470        let mut report = diff_sets(&old, &new);
471        report.changes.push(crate::Change {
472            path: "b/S/x".to_owned(),
473            category: crate::Category::DeclRemoved,
474            verdict: Verdict::Breaking,
475            before: None,
476            after: None,
477        });
478        // The change names declaration `S` of package `b`, which unit `a`
479        // reaches through its signal payload.
480        assert_eq!(unit_verdict(&report, "a", &old, &new), Verdict::Breaking);
481        assert_eq!(unit_verdict(&report, "c", &old, &new), Verdict::Identical);
482    }
483
484    #[test]
485    fn one_prepared_value_answers_several_units_in_any_order() {
486        let old = baseline();
487        let new = vec![unit_a("b.S"), unit_b(IntWidth::I64), unit_c(IntWidth::I32)];
488        let report = diff_sets(&old, &new);
489        let prepared = super::UnitVerdicts::new(&report, &old, &new);
490        assert_eq!(prepared.verdict("c"), Verdict::Identical);
491        assert_eq!(prepared.verdict("a"), Verdict::Breaking);
492        assert_eq!(prepared.verdict("c"), Verdict::Identical);
493        assert_eq!(prepared.verdict("b"), Verdict::Breaking);
494        assert_eq!(prepared.verdict("absent"), Verdict::Identical);
495        assert_eq!(prepared.verdict("a"), Verdict::Breaking);
496    }
497
498    #[test]
499    fn a_change_to_a_package_has_no_declaration() {
500        let change = |path: &str| crate::Change {
501            path: path.to_owned(),
502            category: crate::Category::DeclRemoved,
503            verdict: Verdict::Breaking,
504            before: None,
505            after: None,
506        };
507        assert_eq!(change("veh.cluster").package(), "veh.cluster");
508        assert_eq!(change("veh.cluster").declaration(), None);
509        assert_eq!(
510            change("veh.cluster/Status/doorOpened").package(),
511            "veh.cluster"
512        );
513        assert_eq!(
514            change("veh.cluster/Status/doorOpened").declaration(),
515            Some("Status")
516        );
517    }
518}