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