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