Skip to main content

ridl_diff/
system.rs

1//! `ridl diff` at the system (rsdl reference §14): the changes to a lowered
2//! system that are not contract changes.
3//!
4//! The contracts of a workspace are compared by the ridl categories, through
5//! [`crate::diff_sets`]. What rsdl adds is structure — which components exist,
6//! their lines, and where each instance runs — and a change to it changes the
7//! derived links and never the contract. Such a change is listed under one of
8//! two headings and carries no verdict: neither compatible nor breaking. Which
9//! of them are breaking, and for whom, is the stability policy's. A third
10//! heading, instance number changed, is the exception and is breaking.
11//!
12//! - **Placement changed** — a change in a deployment: a deployment added or
13//!   removed, a machine added, removed or made `external`, an instance moved to
14//!   another machine, or a `labels`, `deprecated` or backend key of the
15//!   deployment, of a machine or of a placement line changed.
16//! - **Composition changed** — a change to the closure or to a component's
17//!   lines: a component added to or removed from the system, an `offers` or
18//!   `requires` line added or removed, `instances` changed, a component made
19//!   `external`, a distribution added or removed, a member line added to or
20//!   removed from a distribution, or a `labels`, `deprecated`, `tier` or
21//!   backend key of the system, of a component, of a distribution or of one
22//!   of their lines changed.
23//!
24//! - **Instance number changed** — a locked instance number differs between
25//!   the two sides, or a number moved to another instance whose old holder is
26//!   still present. A provisional old number and a rename are not listed.
27//!
28//! All read the lowered `System` of each side and nothing else.
29
30use ridl_ir::v2::{self, Component, Deployment, InterfaceRef, System};
31
32/// The heading a system change is listed under (rsdl reference §14).
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34pub enum SystemHeading {
35    /// A change in a deployment.
36    PlacementChanged,
37    /// A change to the closure or to a component's lines.
38    CompositionChanged,
39    /// A locked instance number changed. The only system heading that carries
40    /// a verdict: breaking.
41    InstanceNumberChanged,
42}
43
44/// One change to the system, listed under its heading. Only a change under
45/// [`SystemHeading::InstanceNumberChanged`] carries a verdict: breaking.
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct SystemChange {
48    pub heading: SystemHeading,
49    /// A slash-separated path naming the affected system, component,
50    /// distribution, deployment, machine, instance, member or attribute.
51    pub path: String,
52    /// The rendered old value, absent when the change is an addition.
53    pub before: Option<String>,
54    /// The rendered new value, absent when the change is a removal.
55    pub after: Option<String>,
56}
57
58/// The heading as the text report prints it: `placement changed`.
59pub fn heading_text(heading: SystemHeading) -> &'static str {
60    match heading {
61        SystemHeading::PlacementChanged => "placement changed",
62        SystemHeading::CompositionChanged => "composition changed",
63        SystemHeading::InstanceNumberChanged => "instance number changed",
64    }
65}
66
67/// Compares two lowered systems: the composition changes first, in the new
68/// closure's order and then the removed components, then the placement
69/// changes, deployment by deployment in the new system's order and then the
70/// removed deployments, and then the instance number changes. The doc fields
71/// (`doc`, the doc links, `see`, `since`) are never read: a doc edit is
72/// neither a placement nor a composition change, and a placement or
73/// composition change has no verdict (ADR-0026). The
74/// sizing fields (`Deployment.sizing`, `Placement.sizing`) are never read: the
75/// registry records them as not reported, reason sizing
76/// (`ridl_core::attributes`).
77pub fn diff_systems(old: &System, new: &System) -> Vec<SystemChange> {
78    let mut changes = Vec::new();
79    let system_path = format!("{}.{}", new.package, new.name);
80    declaration_attrs(
81        &mut changes,
82        SystemHeading::CompositionChanged,
83        &system_path,
84        (&old.labels, old.deprecated.as_ref(), &old.attributes),
85        (&new.labels, new.deprecated.as_ref(), &new.attributes),
86    );
87    for line in &new.members {
88        if let Some(before) = old
89            .members
90            .iter()
91            .find(|before| before.component == line.component)
92        {
93            line_attrs(
94                &mut changes,
95                SystemHeading::CompositionChanged,
96                &format!("{system_path}/{}", line.component),
97                &before.attributes,
98                &line.attributes,
99            );
100        }
101    }
102    for component in &new.components {
103        let name = component.qualified_name();
104        match old
105            .components
106            .iter()
107            .find(|before| before.qualified_name() == name)
108        {
109            Some(before) => compare_components(before, component, &mut changes),
110            None => composition(&mut changes, name, None, Some("in the closure")),
111        }
112    }
113    for component in &old.components {
114        let name = component.qualified_name();
115        if !new
116            .components
117            .iter()
118            .any(|after| after.qualified_name() == name)
119        {
120            composition(&mut changes, name, Some("in the closure"), None);
121        }
122    }
123    let empty_distribution = v2::Distribution::default();
124    for distribution in &new.distributions {
125        let name = distribution.qualified_name();
126        let before = old
127            .distributions
128            .iter()
129            .find(|before| before.qualified_name() == name);
130        if before.is_none() {
131            composition(&mut changes, name.clone(), None, Some("distribution"));
132        }
133        compare_distribution(
134            before.unwrap_or(&empty_distribution),
135            distribution,
136            &name,
137            &mut changes,
138        );
139    }
140    for distribution in &old.distributions {
141        let name = distribution.qualified_name();
142        if !new
143            .distributions
144            .iter()
145            .any(|after| after.qualified_name() == name)
146        {
147            composition(&mut changes, name.clone(), Some("distribution"), None);
148            compare_distribution(distribution, &empty_distribution, &name, &mut changes);
149        }
150    }
151    for deployment in &new.deployments {
152        match old
153            .deployments
154            .iter()
155            .find(|before| before.name == deployment.name)
156        {
157            Some(before) => compare_deployments(before, deployment, &mut changes),
158            None => placement(
159                &mut changes,
160                deployment.name.clone(),
161                None,
162                Some("deployment"),
163            ),
164        }
165    }
166    for deployment in &old.deployments {
167        if !new
168            .deployments
169            .iter()
170            .any(|after| after.name == deployment.name)
171        {
172            placement(
173                &mut changes,
174                deployment.name.clone(),
175                Some("deployment"),
176                None,
177            );
178        }
179    }
180    instance_numbers(old, new, &mut changes);
181    changes
182}
183
184/// The `component/instance` key and the number of every instance of a system,
185/// in closure order, with whether the number is provisional.
186fn numbered_instances(system: &System) -> Vec<(String, i32, bool)> {
187    let mut numbered = Vec::new();
188    for component in &system.components {
189        let name = component.qualified_name();
190        for entry in &component.numbers {
191            numbered.push((
192                format!("{name}/{}", entry.instance),
193                entry.number,
194                entry.provisional,
195            ));
196        }
197    }
198    numbered
199}
200
201/// The instance numbers that changed. A key whose old number is locked and
202/// whose new number differs is listed. So is a new key that holds a locked
203/// number of the old side that another key held, while that other key is
204/// still present on the new side. When the old holder is absent, the change is
205/// a rename, which `ridl lock --rename` carries with the number, and it is not
206/// listed. A provisional old number is never listed, and a key on one side
207/// only is not listed for its own number.
208fn instance_numbers(old: &System, new: &System, changes: &mut Vec<SystemChange>) {
209    let old = numbered_instances(old);
210    let new = numbered_instances(new);
211    for (key, number, _) in &new {
212        if let Some((_, before, provisional)) = old.iter().find(|(old_key, ..)| old_key == key)
213            && !provisional
214            && before != number
215        {
216            changes.push(SystemChange {
217                heading: SystemHeading::InstanceNumberChanged,
218                path: key.clone(),
219                before: Some(before.to_string()),
220                after: Some(number.to_string()),
221            });
222        }
223    }
224    for (key, number, _) in &new {
225        if old.iter().any(|(old_key, ..)| old_key == key) {
226            continue;
227        }
228        let holder = old
229            .iter()
230            .find(|(old_key, held, provisional)| held == number && !provisional && old_key != key);
231        if let Some((holder, ..)) = holder
232            && new.iter().any(|(new_key, ..)| new_key == holder)
233        {
234            changes.push(SystemChange {
235                heading: SystemHeading::InstanceNumberChanged,
236                path: key.clone(),
237                before: Some(format!("{number} ({holder})")),
238                after: Some(number.to_string()),
239            });
240        }
241    }
242}
243
244/// A component in both closures: its `offers` and `requires` lines, its
245/// instances and its `external` flag.
246fn compare_components(old: &Component, new: &Component, changes: &mut Vec<SystemChange>) {
247    let name = new.qualified_name();
248    let old_offers: Vec<&str> = old
249        .offers
250        .iter()
251        .map(|offer| offer.service.as_str())
252        .collect();
253    let new_offers: Vec<&str> = new
254        .offers
255        .iter()
256        .map(|offer| offer.service.as_str())
257        .collect();
258    lines(changes, &name, "offers", &old_offers, &new_offers);
259    let old_requires: Vec<String> = old
260        .requires
261        .iter()
262        .map(|require| interface_text(require.interface.as_ref()))
263        .collect();
264    let new_requires: Vec<String> = new
265        .requires
266        .iter()
267        .map(|require| interface_text(require.interface.as_ref()))
268        .collect();
269    let old_requires: Vec<&str> = old_requires.iter().map(String::as_str).collect();
270    let new_requires: Vec<&str> = new_requires.iter().map(String::as_str).collect();
271    lines(changes, &name, "requires", &old_requires, &new_requires);
272    if old.instances != new.instances {
273        changes.push(SystemChange {
274            heading: SystemHeading::CompositionChanged,
275            path: format!("{name}/instances"),
276            before: Some(instance_list(&old.instances)),
277            after: Some(instance_list(&new.instances)),
278        });
279    }
280    if old.external != new.external {
281        changes.push(SystemChange {
282            heading: SystemHeading::CompositionChanged,
283            path: format!("{name}/external"),
284            before: Some(old.external.to_string()),
285            after: Some(new.external.to_string()),
286        });
287    }
288    declaration_attrs(
289        changes,
290        SystemHeading::CompositionChanged,
291        &name,
292        (&old.labels, old.deprecated.as_ref(), &old.attributes),
293        (&new.labels, new.deprecated.as_ref(), &new.attributes),
294    );
295    for offer in &new.offers {
296        if let Some(before) = old
297            .offers
298            .iter()
299            .find(|before| before.service == offer.service)
300        {
301            line_attrs(
302                changes,
303                SystemHeading::CompositionChanged,
304                &format!("{name}/offers/{}", offer.service),
305                &before.attributes,
306                &offer.attributes,
307            );
308        }
309    }
310    for require in &new.requires {
311        let interface = interface_text(require.interface.as_ref());
312        if let Some(before) = old
313            .requires
314            .iter()
315            .find(|before| interface_text(before.interface.as_ref()) == interface)
316        {
317            line_attrs(
318                changes,
319                SystemHeading::CompositionChanged,
320                &format!("{name}/requires/{interface}"),
321                &before.attributes,
322                &require.attributes,
323            );
324        }
325    }
326}
327
328/// The lines of one keyword that one side has and the other does not.
329fn lines(
330    changes: &mut Vec<SystemChange>,
331    component: &str,
332    keyword: &str,
333    old: &[&str],
334    new: &[&str],
335) {
336    for added in new.iter().filter(|line| !old.contains(line)) {
337        composition(
338            changes,
339            format!("{component}/{keyword}/{added}"),
340            None,
341            Some(&format!("{keyword} {added}")),
342        );
343    }
344    for removed in old.iter().filter(|line| !new.contains(line)) {
345        composition(
346            changes,
347            format!("{component}/{keyword}/{removed}"),
348            Some(&format!("{keyword} {removed}")),
349            None,
350        );
351    }
352}
353
354/// A deployment in both systems: its machines, their `external` flags, and
355/// the machine of every instance placed on both sides.
356fn compare_deployments(old: &Deployment, new: &Deployment, changes: &mut Vec<SystemChange>) {
357    declaration_attrs(
358        changes,
359        SystemHeading::PlacementChanged,
360        &new.name,
361        (&old.labels, old.deprecated.as_ref(), &old.attributes),
362        (&new.labels, new.deprecated.as_ref(), &new.attributes),
363    );
364    for machine in &new.machines {
365        let path = format!("{}/{}", new.name, machine.name);
366        match old
367            .machines
368            .iter()
369            .find(|before| before.name == machine.name)
370        {
371            Some(before) if before.external != machine.external => changes.push(SystemChange {
372                heading: SystemHeading::PlacementChanged,
373                path: format!("{path}/external"),
374                before: Some(before.external.to_string()),
375                after: Some(machine.external.to_string()),
376            }),
377            Some(_) => {}
378            None => placement(changes, path, None, Some("machine")),
379        }
380        if let Some(before) = old
381            .machines
382            .iter()
383            .find(|before| before.name == machine.name)
384        {
385            declaration_attrs(
386                changes,
387                SystemHeading::PlacementChanged,
388                &format!("{}/{}", new.name, machine.name),
389                (
390                    &before.labels,
391                    before.deprecated.as_ref(),
392                    &before.attributes,
393                ),
394                (
395                    &machine.labels,
396                    machine.deprecated.as_ref(),
397                    &machine.attributes,
398                ),
399            );
400        }
401    }
402    for machine in &old.machines {
403        if !new.machines.iter().any(|after| after.name == machine.name) {
404            placement(
405                changes,
406                format!("{}/{}", new.name, machine.name),
407                Some("machine"),
408                None,
409            );
410        }
411    }
412    for placed in &new.placements {
413        let paired = old.placements.iter().find(|before| {
414            before.component == placed.component && before.instance == placed.instance
415        });
416        let Some(before) = paired else {
417            continue;
418        };
419        if before.machine != placed.machine {
420            changes.push(SystemChange {
421                heading: SystemHeading::PlacementChanged,
422                path: format!("{}/{}.{}", new.name, placed.component, placed.instance),
423                before: Some(before.machine.clone()),
424                after: Some(placed.machine.clone()),
425            });
426        }
427        line_attrs(
428            changes,
429            SystemHeading::PlacementChanged,
430            &format!("{}/{}.{}", new.name, placed.component, placed.instance),
431            &before.attributes,
432            &placed.attributes,
433        );
434    }
435}
436
437fn compare_distribution(
438    before: &v2::Distribution,
439    after: &v2::Distribution,
440    name: &str,
441    changes: &mut Vec<SystemChange>,
442) {
443    value_change(
444        changes,
445        SystemHeading::CompositionChanged,
446        format!("{name}/tier"),
447        tier_text(before.tier),
448        tier_text(after.tier),
449    );
450    declaration_attrs(
451        changes,
452        SystemHeading::CompositionChanged,
453        name,
454        (
455            &before.labels,
456            before.deprecated.as_ref(),
457            &before.attributes,
458        ),
459        (&after.labels, after.deprecated.as_ref(), &after.attributes),
460    );
461    // A member line on one side only is compared with an empty side, so that
462    // its backend keys are listed with it.
463    for line in &after.members {
464        let path = format!("{name}/{}", line.component);
465        let earlier = before
466            .members
467            .iter()
468            .find(|earlier| earlier.component == line.component);
469        if earlier.is_none() {
470            composition(changes, path.clone(), None, Some("member"));
471        }
472        line_attrs(
473            changes,
474            SystemHeading::CompositionChanged,
475            &path,
476            earlier.map_or(&[], |earlier| &earlier.attributes),
477            &line.attributes,
478        );
479    }
480    for line in &before.members {
481        if !after
482            .members
483            .iter()
484            .any(|later| later.component == line.component)
485        {
486            let path = format!("{name}/{}", line.component);
487            composition(changes, path.clone(), Some("member"), None);
488            line_attrs(
489                changes,
490                SystemHeading::CompositionChanged,
491                &path,
492                &line.attributes,
493                &[],
494            );
495        }
496    }
497}
498
499fn composition(
500    changes: &mut Vec<SystemChange>,
501    path: String,
502    before: Option<&str>,
503    after: Option<&str>,
504) {
505    changes.push(SystemChange {
506        heading: SystemHeading::CompositionChanged,
507        path,
508        before: before.map(str::to_string),
509        after: after.map(str::to_string),
510    });
511}
512
513fn placement(
514    changes: &mut Vec<SystemChange>,
515    path: String,
516    before: Option<&str>,
517    after: Option<&str>,
518) {
519    changes.push(SystemChange {
520        heading: SystemHeading::PlacementChanged,
521        path,
522        before: before.map(str::to_string),
523        after: after.map(str::to_string),
524    });
525}
526
527/// `(A, B)` as `labels` is written; `()` when empty.
528fn label_list(labels: &[String]) -> String {
529    format!("({})", labels.join(", "))
530}
531
532/// `[ ns.key = value, ns.flag ]` as a backend key block is written; `[]`
533/// when empty.
534fn attribute_list(attributes: &[v2::Attribute]) -> String {
535    if attributes.is_empty() {
536        return "[]".to_string();
537    }
538    let items: Vec<String> = attributes
539        .iter()
540        .map(|attribute| match &attribute.value {
541            Some(value) => format!(
542                "{}.{} = {}",
543                attribute.namespace,
544                attribute.key,
545                value_text(value)
546            ),
547            None => format!("{}.{}", attribute.namespace, attribute.key),
548        })
549        .collect();
550    format!("[ {} ]", items.join(", "))
551}
552
553/// An attribute value as written: a scalar as its text, a list as `(a, b)`.
554fn value_text(value: &v2::AttributeValue) -> String {
555    match &value.kind {
556        Some(v2::attribute_value::Kind::Scalar(text)) => text.clone(),
557        Some(v2::attribute_value::Kind::List(list)) => {
558            let items: Vec<String> = list.items.iter().map(value_text).collect();
559            format!("({})", items.join(", "))
560        }
561        None => String::new(),
562    }
563}
564
565/// `PLATFORM` or `APPLICATION`, or `None` when the distribution has no tier.
566/// A value this build does not know renders as `tier(<n>)`, so that a
567/// snapshot written by a later toolchain does not read as "no tier".
568fn tier_text(tier: i32) -> Option<String> {
569    match v2::Tier::try_from(tier) {
570        Ok(v2::Tier::Platform) => Some("PLATFORM".to_string()),
571        Ok(v2::Tier::Application) => Some("APPLICATION".to_string()),
572        Ok(v2::Tier::Unspecified) => None,
573        Err(_) => Some(format!("tier({tier})")),
574    }
575}
576
577/// One before-and-after line when the two values differ.
578fn value_change(
579    changes: &mut Vec<SystemChange>,
580    heading: SystemHeading,
581    path: String,
582    before: Option<String>,
583    after: Option<String>,
584) {
585    if before != after {
586        changes.push(SystemChange {
587            heading,
588            path,
589            before,
590            after,
591        });
592    }
593}
594
595/// The `labels`, `deprecated` and backend keys of a declaration, under
596/// `heading` at `path`.
597fn declaration_attrs(
598    changes: &mut Vec<SystemChange>,
599    heading: SystemHeading,
600    path: &str,
601    old: (&[String], Option<&String>, &[v2::Attribute]),
602    new: (&[String], Option<&String>, &[v2::Attribute]),
603) {
604    value_change(
605        changes,
606        heading,
607        format!("{path}/labels"),
608        Some(label_list(old.0)),
609        Some(label_list(new.0)),
610    );
611    value_change(
612        changes,
613        heading,
614        format!("{path}/deprecated"),
615        old.1.map(|reason| format!("{reason:?}")),
616        new.1.map(|reason| format!("{reason:?}")),
617    );
618    line_attrs(changes, heading, path, old.2, new.2);
619}
620
621/// The backend keys of a line, under `heading` at `path`.
622fn line_attrs(
623    changes: &mut Vec<SystemChange>,
624    heading: SystemHeading,
625    path: &str,
626    old: &[v2::Attribute],
627    new: &[v2::Attribute],
628) {
629    value_change(
630        changes,
631        heading,
632        format!("{path}/attributes"),
633        Some(attribute_list(old)),
634        Some(attribute_list(new)),
635    );
636}
637
638/// `catalog.Name` for a declared interface, the service's dotted name for an
639/// inline shape.
640fn interface_text(interface: Option<&InterfaceRef>) -> String {
641    match interface {
642        Some(interface) if interface.inline => interface.name.clone(),
643        Some(interface) => format!("{}.{}", interface.catalog, interface.name),
644        None => String::new(),
645    }
646}
647
648/// `(primary, backup)`, as `instances` is written.
649fn instance_list(instances: &[String]) -> String {
650    format!("({})", instances.join(", "))
651}
652
653#[cfg(test)]
654mod tests {
655    use ridl_ir::v2;
656
657    use super::*;
658
659    fn component(name: &str, instances: &[&str], offers: &[&str], requires: &[&str]) -> Component {
660        Component {
661            name: name.to_string(),
662            package: "veh.topology".to_string(),
663            implicit: false,
664            external: false,
665            instances: instances
666                .iter()
667                .map(|instance| instance.to_string())
668                .collect(),
669            numbers: Vec::new(),
670            offers: offers
671                .iter()
672                .map(|service| v2::Offer {
673                    service: service.to_string(),
674                    attributes: Vec::new(),
675                    doc: String::new(),
676                    links: Vec::new(),
677                    see: Vec::new(),
678                    since: Vec::new(),
679                })
680                .collect(),
681            requires: requires
682                .iter()
683                .map(|interface| v2::Require {
684                    interface: Some(v2::InterfaceRef {
685                        catalog: "veh.adas".to_string(),
686                        name: interface.to_string(),
687                        inline: false,
688                    }),
689                    service: String::new(),
690                    producer: String::new(),
691                    attributes: Vec::new(),
692                    doc: String::new(),
693                    links: Vec::new(),
694                    see: Vec::new(),
695                    since: Vec::new(),
696                })
697                .collect(),
698            labels: Vec::new(),
699            attributes: Vec::new(),
700            doc: String::new(),
701            links: Vec::new(),
702            see: Vec::new(),
703            since: Vec::new(),
704            deprecated: None,
705        }
706    }
707
708    fn machine(name: &str, external: bool) -> v2::Machine {
709        v2::Machine {
710            name: name.to_string(),
711            external,
712            labels: Vec::new(),
713            attributes: Vec::new(),
714            doc: String::new(),
715            links: Vec::new(),
716            see: Vec::new(),
717            since: Vec::new(),
718            deprecated: None,
719        }
720    }
721
722    fn placed(component: &str, instance: &str, machine: &str) -> v2::Placement {
723        v2::Placement {
724            component: format!("veh.topology.{component}"),
725            instance: instance.to_string(),
726            machine: machine.to_string(),
727            attributes: Vec::new(),
728            sizing: None,
729        }
730    }
731
732    /// Appendix A reduced to `Cruise` and `Panel` in `Production`.
733    fn base() -> System {
734        System {
735            name: "Vehicle".to_string(),
736            package: "veh.topology".to_string(),
737            components: vec![
738                component(
739                    "Cruise",
740                    &["primary", "backup"],
741                    &["veh.adas.cruise"],
742                    &["LaneAssist"],
743                ),
744                component("Panel", &["Unit"], &[], &["CruiseControl"]),
745            ],
746            deployments: vec![Deployment {
747                name: "Production".to_string(),
748                package: "veh.topology".to_string(),
749                machines: vec![machine("AdasHpc", false), machine("Cockpit", false)],
750                placements: vec![
751                    placed("Cruise", "primary", "AdasHpc"),
752                    placed("Cruise", "backup", "Cockpit"),
753                    placed("Panel", "Unit", "Cockpit"),
754                ],
755                ..Deployment::default()
756            }],
757            ..System::default()
758        }
759    }
760
761    /// Each change as one line, `<heading>: <path>: <before> -> <after>`.
762    fn lines(changes: &[SystemChange]) -> Vec<String> {
763        changes
764            .iter()
765            .map(|change| {
766                let mut line = format!("{}: {}", heading_text(change.heading), change.path);
767                crate::push_values(&mut line, change.before.as_ref(), change.after.as_ref());
768                line
769            })
770            .collect()
771    }
772
773    fn backend(namespace: &str, key: &str, value: Option<&str>) -> v2::Attribute {
774        v2::Attribute {
775            namespace: namespace.to_string(),
776            key: key.to_string(),
777            value: value.map(|text| v2::AttributeValue {
778                kind: Some(v2::attribute_value::Kind::Scalar(text.to_string())),
779            }),
780        }
781    }
782
783    /// `base()` plus one distribution and the system's own member lines, so
784    /// that every rsdl site is present.
785    fn with_distribution() -> System {
786        let mut system = base();
787        system.members = vec![
788            v2::MemberLine {
789                component: "veh.topology.Cruise".to_string(),
790                ..Default::default()
791            },
792            v2::MemberLine {
793                component: "veh.topology.Panel".to_string(),
794                ..Default::default()
795            },
796        ];
797        system.distributions.push(v2::Distribution {
798            name: "Adas".to_string(),
799            package: "veh.topology".to_string(),
800            members: vec![v2::MemberLine {
801                component: "veh.topology.Cruise".to_string(),
802                ..Default::default()
803            }],
804            ..Default::default()
805        });
806        system
807    }
808
809    #[test]
810    fn bare_distributions_are_reported_in_both_directions() {
811        let present = with_distribution();
812        let mut absent = present.clone();
813        absent.distributions.clear();
814        // The bare member line is listed with its distribution and has no
815        // backend key to compare.
816        assert_eq!(
817            lines(&diff_systems(&absent, &present)),
818            [
819                "composition changed: veh.topology.Adas: (absent) -> distribution",
820                "composition changed: veh.topology.Adas/veh.topology.Cruise: (absent) -> member",
821            ]
822        );
823        assert_eq!(
824            lines(&diff_systems(&present, &absent)),
825            [
826                "composition changed: veh.topology.Adas: distribution -> (removed)",
827                "composition changed: veh.topology.Adas/veh.topology.Cruise: member -> (removed)",
828            ]
829        );
830    }
831
832    #[test]
833    fn added_and_removed_distributions_include_their_attributes() {
834        let mut old = with_distribution();
835        // An unchanged distribution with the same short name must not hide
836        // the changed distribution from a different package.
837        old.distributions.push(v2::Distribution {
838            name: "Adas".to_string(),
839            package: "veh.other".to_string(),
840            ..Default::default()
841        });
842        let mut new = old.clone();
843        let distribution = &mut new.distributions[0];
844        distribution.tier = v2::Tier::Platform as i32;
845        distribution.labels = vec!["QM".to_string()];
846        distribution.deprecated = Some("use Next".to_string());
847        distribution.attributes = vec![backend("sign", "key", Some("release"))];
848        let mut absent = old.clone();
849        absent
850            .distributions
851            .retain(|distribution| distribution.package != "veh.topology");
852        let added = diff_systems(&absent, &new);
853        let expected = [
854            ("veh.topology.Adas", None, Some("distribution")),
855            ("veh.topology.Adas/tier", None, Some("PLATFORM")),
856            ("veh.topology.Adas/labels", Some("()"), Some("(QM)")),
857            ("veh.topology.Adas/deprecated", None, Some("\"use Next\"")),
858            (
859                "veh.topology.Adas/attributes",
860                Some("[]"),
861                Some("[ sign.key = release ]"),
862            ),
863            (
864                "veh.topology.Adas/veh.topology.Cruise",
865                None,
866                Some("member"),
867            ),
868        ];
869        assert_eq!(added.len(), expected.len(), "{added:?}");
870        for (change, (path, before, after)) in added.iter().zip(expected) {
871            assert_eq!(change.heading, SystemHeading::CompositionChanged);
872            assert_eq!(change.path, path);
873            assert_eq!(change.before.as_deref(), before);
874            assert_eq!(change.after.as_deref(), after);
875        }
876        // The paired distribution keeps its member, so neither the
877        // distribution row nor the member row is listed.
878        assert_eq!(diff_systems(&old, &new), added[1..added.len() - 1]);
879        let removed = diff_systems(&new, &absent);
880        assert_eq!(removed.len(), added.len());
881        for (removed, added) in removed.iter().zip(&added) {
882            assert_eq!(removed.heading, added.heading);
883            assert_eq!(removed.path, added.path);
884            assert_eq!(removed.before, added.after);
885            assert_eq!(removed.after, added.before);
886        }
887    }
888
889    /// Every `Compared` row of the registry with an rsdl site is reported by
890    /// `diff_systems`, and every `NotReported` row is not. The mutation per
891    /// row is written here, so a row added to the registry without diff
892    /// support fails this test on the `panic!` arm.
893    #[test]
894    fn the_registry_rows_are_reported_as_recorded() {
895        use ridl_core::attributes::{ATTRIBUTES, AttrName, Diff, Site};
896        for row in ATTRIBUTES {
897            if !row
898                .sites
899                .iter()
900                .any(|site| !matches!(site, Site::Command | Site::Query))
901            {
902                continue;
903            }
904            let old = with_distribution();
905            let mut new = with_distribution();
906            let key = match row.name {
907                AttrName::Key(key) => key,
908                AttrName::Namespaced => "ns.key",
909            };
910            match key {
911                "instances" => new.components[0].instances = vec!["Unit".to_string()],
912                "external" => new.components[0].external = true,
913                "tier" => new.distributions[0].tier = v2::Tier::Application as i32,
914                "labels" => new.distributions[0].labels = vec!["QM".to_string()],
915                "deprecated" => new.deployments[0].deprecated = Some("use v2".to_string()),
916                "depth" => {
917                    new.deployments[0].sizing = Some(v2::Sizing {
918                        depth: Some(4),
919                        slots: None,
920                        budget: None,
921                    })
922                }
923                "slots" => {
924                    new.deployments[0].placements[0].sizing = Some(v2::Sizing {
925                        depth: None,
926                        slots: Some(4),
927                        budget: None,
928                    })
929                }
930                "budget" => {
931                    new.deployments[0].sizing = Some(v2::Sizing {
932                        depth: None,
933                        slots: None,
934                        budget: Some(4096),
935                    })
936                }
937                "ns.key" => new.deployments[0].machines[0].attributes.push(backend(
938                    "linux",
939                    "cpuset",
940                    Some("(2)"),
941                )),
942                other => panic!("registry row `{other}` has no mutation in this test"),
943            }
944            let changes = diff_systems(&old, &new);
945            match row.diff {
946                Diff::Compared(_) => assert!(!changes.is_empty(), "`{key}` must be reported"),
947                Diff::NotReported(reason) => assert!(
948                    changes.is_empty(),
949                    "`{key}` is recorded as not reported ({reason:?}) but was: {changes:?}"
950                ),
951            }
952        }
953    }
954
955    /// rsdl §14: `labels`, `deprecated`, `tier` and a backend key are listed
956    /// under the heading of the declaration they are on, as a before-and-after
957    /// line. Every site the registry names is exercised here: the system and
958    /// its member lines, a component with its `offers` and `requires` lines, a
959    /// distribution with its member lines, a deployment, a machine and a
960    /// placement line.
961    #[test]
962    fn attribute_changes_are_listed_under_the_heading_of_their_site() {
963        let old = with_distribution();
964        let mut new = with_distribution();
965        new.labels = vec!["QM".to_string()];
966        new.components[0].deprecated = Some("use Cruise2".to_string());
967        new.distributions[0].tier = v2::Tier::Platform as i32;
968        new.members[0]
969            .attributes
970            .push(backend("deb", "section", Some("net")));
971        new.components[0].requires[0]
972            .attributes
973            .push(backend("someip", "clientId", Some("17")));
974        new.distributions[0].members[0]
975            .attributes
976            .push(backend("sign", "key", None));
977        new.components[0].offers[0]
978            .attributes
979            .push(backend("someip", "serviceId", Some("4660")));
980        new.deployments[0]
981            .attributes
982            .push(backend("rust", "crate", Some("\"s\"")));
983        new.deployments[0].machines[0].labels = vec!["ASIL_B".to_string()];
984        new.deployments[0].placements[0]
985            .attributes
986            .push(backend("linux", "cpuset", Some("(2)")));
987        assert_eq!(
988            lines(&diff_systems(&old, &new)),
989            [
990                "composition changed: veh.topology.Vehicle/labels: () -> (QM)",
991                "composition changed: veh.topology.Vehicle/veh.topology.Cruise/attributes: \
992                 [] -> [ deb.section = net ]",
993                "composition changed: veh.topology.Cruise/deprecated: (absent) -> \"use Cruise2\"",
994                "composition changed: veh.topology.Cruise/offers/veh.adas.cruise/attributes: \
995                 [] -> [ someip.serviceId = 4660 ]",
996                "composition changed: veh.topology.Cruise/requires/veh.adas.LaneAssist/attributes: \
997                 [] -> [ someip.clientId = 17 ]",
998                "composition changed: veh.topology.Adas/tier: (absent) -> PLATFORM",
999                "composition changed: veh.topology.Adas/veh.topology.Cruise/attributes: \
1000                 [] -> [ sign.key ]",
1001                "placement changed: Production/attributes: [] -> [ rust.crate = \"s\" ]",
1002                "placement changed: Production/AdasHpc/labels: () -> (ASIL_B)",
1003                "placement changed: Production/veh.topology.Cruise.primary/attributes: \
1004                 [] -> [ linux.cpuset = (2) ]",
1005            ]
1006        );
1007    }
1008
1009    /// A value removed is reported, as the mirror of one added.
1010    #[test]
1011    fn an_attribute_removed_is_reported() {
1012        let mut old = with_distribution();
1013        old.components[0].deprecated = Some("use Cruise2".to_string());
1014        old.distributions[0].tier = v2::Tier::Platform as i32;
1015        let new = with_distribution();
1016        assert_eq!(
1017            lines(&diff_systems(&old, &new)),
1018            [
1019                "composition changed: veh.topology.Cruise/deprecated: \"use Cruise2\" -> (removed)",
1020                "composition changed: veh.topology.Adas/tier: PLATFORM -> (removed)",
1021            ]
1022        );
1023    }
1024
1025    /// rsdl §14: a component moved between two existing distributions is a
1026    /// composition change on each, and a member line's backend keys are
1027    /// compared with an empty side when the member is added or removed.
1028    #[test]
1029    fn a_member_moved_between_distributions_is_listed_under_composition_changed() {
1030        let member = |name: &str, attributes: Vec<v2::Attribute>| v2::MemberLine {
1031            component: format!("veh.topology.{name}"),
1032            attributes,
1033            ..Default::default()
1034        };
1035        let distribution = |name: &str, members: Vec<v2::MemberLine>| v2::Distribution {
1036            name: name.to_string(),
1037            package: "veh.topology".to_string(),
1038            members,
1039            ..Default::default()
1040        };
1041        let signed = || vec![backend("sign", "key", Some("release"))];
1042        let mut old = base();
1043        old.components
1044            .push(component("Cluster", &["Unit"], &[], &[]));
1045        old.components.push(component("Gauge", &["Unit"], &[], &[]));
1046        // Each component is in one distribution. `Gauge` stays in `Adas` and
1047        // `Cluster` stays in `Hmi` with its key unchanged, so a paired member
1048        // line is compared with its own earlier line and lists nothing.
1049        old.distributions = vec![
1050            distribution(
1051                "Adas",
1052                vec![member("Cruise", signed()), member("Gauge", Vec::new())],
1053            ),
1054            distribution("Body", vec![member("Panel", Vec::new())]),
1055            distribution("Hmi", vec![member("Cluster", signed())]),
1056        ];
1057        let mut new = old.clone();
1058        new.distributions[0].members = vec![member("Panel", signed()), member("Gauge", Vec::new())];
1059        new.distributions[1].members = vec![member("Cruise", Vec::new())];
1060        assert_eq!(
1061            lines(&diff_systems(&old, &new)),
1062            [
1063                "composition changed: veh.topology.Adas/veh.topology.Panel: (absent) -> member",
1064                "composition changed: veh.topology.Adas/veh.topology.Panel/attributes: \
1065                 [] -> [ sign.key = release ]",
1066                "composition changed: veh.topology.Adas/veh.topology.Cruise: member -> (removed)",
1067                "composition changed: veh.topology.Adas/veh.topology.Cruise/attributes: \
1068                 [ sign.key = release ] -> []",
1069                "composition changed: veh.topology.Body/veh.topology.Cruise: (absent) -> member",
1070                "composition changed: veh.topology.Body/veh.topology.Panel: member -> (removed)",
1071            ]
1072        );
1073        assert_eq!(
1074            lines(&diff_systems(&new, &old)),
1075            [
1076                "composition changed: veh.topology.Adas/veh.topology.Cruise: (absent) -> member",
1077                "composition changed: veh.topology.Adas/veh.topology.Cruise/attributes: \
1078                 [] -> [ sign.key = release ]",
1079                "composition changed: veh.topology.Adas/veh.topology.Panel: member -> (removed)",
1080                "composition changed: veh.topology.Adas/veh.topology.Panel/attributes: \
1081                 [ sign.key = release ] -> []",
1082                "composition changed: veh.topology.Body/veh.topology.Panel: (absent) -> member",
1083                "composition changed: veh.topology.Body/veh.topology.Cruise: member -> (removed)",
1084            ]
1085        );
1086
1087        // The members of an added or removed distribution are listed with it,
1088        // in the order of their lines, and a member line's keys are compared
1089        // with an empty side.
1090        let mut absent = old.clone();
1091        absent.distributions.remove(0);
1092        assert_eq!(
1093            lines(&diff_systems(&absent, &old)),
1094            [
1095                "composition changed: veh.topology.Adas: (absent) -> distribution",
1096                "composition changed: veh.topology.Adas/veh.topology.Cruise: (absent) -> member",
1097                "composition changed: veh.topology.Adas/veh.topology.Cruise/attributes: \
1098                 [] -> [ sign.key = release ]",
1099                "composition changed: veh.topology.Adas/veh.topology.Gauge: (absent) -> member",
1100            ]
1101        );
1102        assert_eq!(
1103            lines(&diff_systems(&old, &absent)),
1104            [
1105                "composition changed: veh.topology.Adas: distribution -> (removed)",
1106                "composition changed: veh.topology.Adas/veh.topology.Cruise: member -> (removed)",
1107                "composition changed: veh.topology.Adas/veh.topology.Cruise/attributes: \
1108                 [ sign.key = release ] -> []",
1109                "composition changed: veh.topology.Adas/veh.topology.Gauge: member -> (removed)",
1110            ]
1111        );
1112    }
1113
1114    /// A system whose components hold the given `(component, instance,
1115    /// number, provisional)` numbers; the component is a qualified name.
1116    fn numbered(entries: &[(&str, &str, i32, bool)]) -> System {
1117        let mut system = base();
1118        system.components.clear();
1119        for (qualified, instance, number, provisional) in entries {
1120            let (package, name) = qualified.rsplit_once('.').expect("a qualified name");
1121            let at = system
1122                .components
1123                .iter()
1124                .position(|c| c.qualified_name() == *qualified);
1125            let at = at.unwrap_or_else(|| {
1126                let mut made = component(name, &[], &[], &[]);
1127                made.package = package.to_string();
1128                system.components.push(made);
1129                system.components.len() - 1
1130            });
1131            system.components[at].numbers.push(v2::InstanceNumber {
1132                instance: instance.to_string(),
1133                number: *number,
1134                provisional: *provisional,
1135            });
1136        }
1137        system
1138    }
1139
1140    fn listed(changes: &[SystemChange]) -> Vec<&SystemChange> {
1141        changes
1142            .iter()
1143            .filter(|c| c.heading == SystemHeading::InstanceNumberChanged)
1144            .collect()
1145    }
1146
1147    #[test]
1148    fn a_changed_locked_number_is_listed() {
1149        let changes = diff_systems(
1150            &numbered(&[("veh.C", "a", 1, false)]),
1151            &numbered(&[("veh.C", "a", 2, false)]),
1152        );
1153        let listed = listed(&changes);
1154        assert_eq!(listed.len(), 1);
1155        assert_eq!(listed[0].path, "veh.C/a");
1156        assert_eq!(
1157            (listed[0].before.as_deref(), listed[0].after.as_deref()),
1158            (Some("1"), Some("2"))
1159        );
1160    }
1161
1162    #[test]
1163    fn a_number_given_to_another_instance_is_listed() {
1164        let changes = diff_systems(
1165            &numbered(&[("veh.C", "a", 1, false)]),
1166            &numbered(&[("veh.C", "a", 2, false), ("veh.C", "b", 1, false)]),
1167        );
1168        let listed = listed(&changes);
1169        assert_eq!(listed.len(), 2);
1170        assert!(
1171            listed
1172                .iter()
1173                .any(|c| c.path == "veh.C/b" && c.before.as_deref() == Some("1 (veh.C/a)"))
1174        );
1175    }
1176
1177    #[test]
1178    fn a_renamed_instance_that_keeps_its_number_is_not_listed() {
1179        let changes = diff_systems(
1180            &numbered(&[("veh.C", "a", 1, false)]),
1181            &numbered(&[("veh.C", "b", 1, false)]),
1182        );
1183        assert!(listed(&changes).is_empty());
1184    }
1185
1186    #[test]
1187    fn a_provisional_old_number_is_not_listed() {
1188        let changes = diff_systems(
1189            &numbered(&[("veh.C", "a", 1, true)]),
1190            &numbered(&[("veh.C", "a", 2, false)]),
1191        );
1192        assert!(listed(&changes).is_empty());
1193    }
1194
1195    /// A locked number that both sides hold for the same key is not a
1196    /// change: a lock-bearing diff with no renumbering lists nothing.
1197    #[test]
1198    fn an_unchanged_locked_number_is_not_listed() {
1199        let both = numbered(&[("veh.C", "a", 1, false), ("veh.C", "b", 2, false)]);
1200        assert!(listed(&diff_systems(&both, &both)).is_empty());
1201    }
1202
1203    /// A new key that takes a number its old holder held only provisionally
1204    /// is not listed, though the old holder is still present: the old number
1205    /// was never part of the lock.
1206    #[test]
1207    fn a_new_key_on_a_provisional_old_number_is_not_listed() {
1208        let changes = diff_systems(
1209            &numbered(&[("veh.C", "a", 1, true)]),
1210            &numbered(&[("veh.C", "a", 2, false), ("veh.C", "b", 1, false)]),
1211        );
1212        assert!(listed(&changes).is_empty());
1213    }
1214
1215    #[test]
1216    fn an_unchanged_system_has_no_system_change() {
1217        assert!(diff_systems(&base(), &base()).is_empty());
1218    }
1219
1220    /// The region map is not compared, so a change to a region's catalog hash
1221    /// lists no system change.
1222    #[test]
1223    fn a_region_hash_change_is_not_a_system_change() {
1224        let region = |hash: u8| v2::Region {
1225            catalog: "veh.adas".to_string(),
1226            interfaces: Vec::new(),
1227            hash: vec![hash; 32],
1228        };
1229        let old = System {
1230            regions: vec![region(1)],
1231            ..base()
1232        };
1233        let new = System {
1234            regions: vec![region(2)],
1235            ..base()
1236        };
1237        assert!(diff_systems(&old, &new).is_empty());
1238    }
1239
1240    /// A doc edit is neither a placement nor a composition change, so the
1241    /// doc fields of every system message are ignored (ADR-0026).
1242    #[test]
1243    fn system_doc_changes_are_not_reported() {
1244        let see = v2::DocLink {
1245            text: "Other".to_string(),
1246            target: "veh.topology.Other".to_string(),
1247            ..Default::default()
1248        };
1249        macro_rules! document {
1250            ($carrier:expr) => {{
1251                let carrier = &mut $carrier;
1252                carrier.doc = "documented".to_string();
1253                carrier.links.push(see.clone());
1254                carrier.see.push(see.clone());
1255                carrier.since.push("1.2".to_string());
1256            }};
1257        }
1258        let mut old = base();
1259        old.members.push(v2::MemberLine {
1260            component: "veh.topology.Cruise".to_string(),
1261            ..Default::default()
1262        });
1263        old.distributions.push(v2::Distribution {
1264            name: "Adas".to_string(),
1265            package: "veh.topology".to_string(),
1266            members: old.members.clone(),
1267            ..Default::default()
1268        });
1269        let mut new = old.clone();
1270        document!(new);
1271        document!(new.members[0]);
1272        document!(new.components[0]);
1273        document!(new.components[0].offers[0]);
1274        document!(new.components[0].requires[0]);
1275        document!(new.distributions[0]);
1276        document!(new.distributions[0].members[0]);
1277        let deployment = &mut new.deployments[0];
1278        deployment.doc = "documented".to_string();
1279        deployment.doc_links.push(see.clone());
1280        deployment.see.push(see.clone());
1281        deployment.since.push("1.2".to_string());
1282        document!(new.deployments[0].machines[0]);
1283        assert_ne!(old, new);
1284        assert!(diff_systems(&old, &new).is_empty());
1285    }
1286
1287    /// A changed sizing value, on the deployment or on a placement, is
1288    /// neither a placement nor a composition change (rsdl §14).
1289    #[test]
1290    fn system_sizing_changes_are_not_reported() {
1291        let sized = |depth: u32, slots: u32, budget: u64| v2::Sizing {
1292            depth: Some(depth),
1293            slots: Some(slots),
1294            budget: Some(budget),
1295        };
1296        let mut old = base();
1297        old.deployments[0].sizing = Some(sized(3, 16, 4096));
1298        old.deployments[0].placements[0].sizing = Some(sized(3, 16, 4096));
1299        let mut new = old.clone();
1300        new.deployments[0].sizing = Some(sized(300, 64, 65536));
1301        new.deployments[0].placements[0].sizing = Some(sized(300, 64, 65536));
1302        assert_ne!(old, new);
1303        assert!(diff_systems(&old, &new).is_empty());
1304
1305        // Declaring a value where none was, and removing one, are not
1306        // reported either.
1307        assert!(diff_systems(&base(), &new).is_empty());
1308        assert!(diff_systems(&new, &base()).is_empty());
1309    }
1310
1311    /// A system change is rendered under its heading, after the contract
1312    /// changes and with no verdict, and leaves the report verdict alone; a
1313    /// system on one side only is not compared (rsdl §14).
1314    #[test]
1315    fn system_changes_render_under_their_headings_with_no_verdict() {
1316        let old = base();
1317        let mut new = base();
1318        new.deployments[0].placements[1].machine = "AdasHpc".to_string();
1319        new.components[1].external = true;
1320
1321        let report = crate::diff_workspaces(&[], Some(&old), &[], Some(&new), &[]);
1322        assert_eq!(report.verdict, crate::Verdict::Identical);
1323        assert_eq!(
1324            crate::render_text(&report),
1325            "identical\n\
1326             placement changed\n\
1327             \x20 Production/veh.topology.Cruise.backup: Cockpit -> AdasHpc\n\
1328             composition changed\n\
1329             \x20 veh.topology.Panel/external: false -> true\n"
1330        );
1331        let json: serde_json::Value =
1332            serde_json::from_str(&crate::render_json(&report)).expect("the report is JSON");
1333        assert_eq!(
1334            json["placement_changed"],
1335            serde_json::json!([{
1336                "path": "Production/veh.topology.Cruise.backup",
1337                "before": "Cockpit",
1338                "after": "AdasHpc"
1339            }])
1340        );
1341        assert_eq!(json["composition_changed"][0]["after"], "true");
1342
1343        let one_side = crate::diff_workspaces(&[], None, &[], Some(&new), &[]);
1344        assert!(one_side.system.is_empty());
1345        let json: serde_json::Value =
1346            serde_json::from_str(&crate::render_json(&one_side)).expect("the report is JSON");
1347        assert_eq!(
1348            json,
1349            serde_json::json!({ "verdict": "identical", "changes": [] })
1350        );
1351    }
1352
1353    /// rsdl §14: an instance moved to another machine, a machine added,
1354    /// removed or made `external`, and a deployment added or removed are
1355    /// placement changes.
1356    #[test]
1357    fn a_deployment_change_is_listed_under_placement_changed() {
1358        let old = base();
1359        let mut new = base();
1360        new.deployments[0].placements[1].machine = "AdasHpc".to_string();
1361        new.deployments[0].machines[1].external = true;
1362        new.deployments[0].machines.push(machine("Cloud", true));
1363        new.deployments.push(Deployment {
1364            name: "Bench".to_string(),
1365            ..Deployment::default()
1366        });
1367        assert_eq!(
1368            lines(&diff_systems(&old, &new)),
1369            [
1370                "placement changed: Production/Cockpit/external: false -> true",
1371                "placement changed: Production/Cloud: (absent) -> machine",
1372                "placement changed: Production/veh.topology.Cruise.backup: Cockpit -> AdasHpc",
1373                "placement changed: Bench: (absent) -> deployment",
1374            ]
1375        );
1376        assert_eq!(
1377            lines(&diff_systems(&new, &old)),
1378            [
1379                "placement changed: Production/Cockpit/external: true -> false",
1380                "placement changed: Production/Cloud: machine -> (removed)",
1381                "placement changed: Production/veh.topology.Cruise.backup: AdasHpc -> Cockpit",
1382                "placement changed: Bench: deployment -> (removed)",
1383            ]
1384        );
1385    }
1386
1387    /// rsdl §14: a component added to or removed from the system, an `offers`
1388    /// or `requires` line added or removed, `instances` changed, and a
1389    /// component made `external` are composition changes.
1390    #[test]
1391    fn a_closure_or_line_change_is_listed_under_composition_changed() {
1392        let old = base();
1393        let mut new = base();
1394        new.components[0].instances = vec!["Unit".to_string()];
1395        new.components[0].requires.clear();
1396        new.components[1].external = true;
1397        new.components[1].offers.push(v2::Offer {
1398            service: "veh.hmi.panel".to_string(),
1399            attributes: Vec::new(),
1400            doc: String::new(),
1401            links: Vec::new(),
1402            see: Vec::new(),
1403            since: Vec::new(),
1404        });
1405        new.components
1406            .push(component("Lane", &["Unit"], &["veh.adas.lane"], &[]));
1407        assert_eq!(
1408            lines(&diff_systems(&old, &new)),
1409            [
1410                "composition changed: veh.topology.Cruise/requires/veh.adas.LaneAssist: \
1411                 requires veh.adas.LaneAssist -> (removed)",
1412                "composition changed: veh.topology.Cruise/instances: (primary, backup) -> (Unit)",
1413                "composition changed: veh.topology.Panel/offers/veh.hmi.panel: \
1414                 (absent) -> offers veh.hmi.panel",
1415                "composition changed: veh.topology.Panel/external: false -> true",
1416                "composition changed: veh.topology.Lane: (absent) -> in the closure",
1417            ]
1418        );
1419        assert_eq!(
1420            lines(&diff_systems(&new, &old)).last().map(String::as_str),
1421            Some("composition changed: veh.topology.Lane: in the closure -> (removed)")
1422        );
1423    }
1424}