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