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.
14//! - **Composition changed** — a change to the closure or to a component's
15//!   lines: a component added to or removed from the system, an `offers` or
16//!   `requires` line added or removed, `instances` changed, a component made
17//!   `external`.
18//!
19//! Both read the lowered `System` of each side and nothing else.
20
21use ridl_ir::v2::{Component, Deployment, InterfaceRef, System};
22
23/// The heading a system change is listed under (rsdl reference §14).
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25pub enum SystemHeading {
26    /// A change in a deployment.
27    PlacementChanged,
28    /// A change to the closure or to a component's lines.
29    CompositionChanged,
30}
31
32/// One change to the system, listed under its heading with no verdict.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct SystemChange {
35    pub heading: SystemHeading,
36    /// A slash-separated path: a component's qualified name, then `offers`,
37    /// `requires`, `instances` or `external`; or a deployment's name, then a
38    /// machine or an instance.
39    pub path: String,
40    /// The rendered old value, absent when the change is an addition.
41    pub before: Option<String>,
42    /// The rendered new value, absent when the change is a removal.
43    pub after: Option<String>,
44}
45
46/// The heading as the text report prints it: `placement changed`.
47pub fn heading_text(heading: SystemHeading) -> &'static str {
48    match heading {
49        SystemHeading::PlacementChanged => "placement changed",
50        SystemHeading::CompositionChanged => "composition changed",
51    }
52}
53
54/// Compares two lowered systems: the composition changes first, in the new
55/// closure's order and then the removed components, and then the placement
56/// changes, deployment by deployment in the new system's order and then the
57/// removed deployments. The doc fields (`doc`, the doc links, `see`,
58/// `since`) are never read: a doc edit is neither a placement nor a
59/// composition change, and a system change has no verdict (ADR-0026). The
60/// sizing fields (`Deployment.sizing`, `Placement.sizing`) are never read
61/// either: a changed `depth`, `slots` or `budget` changes no link and no
62/// contract (rsdl §14).
63pub fn diff_systems(old: &System, new: &System) -> Vec<SystemChange> {
64    let mut changes = Vec::new();
65    for component in &new.components {
66        let name = component.qualified_name();
67        match old
68            .components
69            .iter()
70            .find(|before| before.qualified_name() == name)
71        {
72            Some(before) => compare_components(before, component, &mut changes),
73            None => composition(&mut changes, name, None, Some("in the closure")),
74        }
75    }
76    for component in &old.components {
77        let name = component.qualified_name();
78        if !new
79            .components
80            .iter()
81            .any(|after| after.qualified_name() == name)
82        {
83            composition(&mut changes, name, Some("in the closure"), None);
84        }
85    }
86    for deployment in &new.deployments {
87        match old
88            .deployments
89            .iter()
90            .find(|before| before.name == deployment.name)
91        {
92            Some(before) => compare_deployments(before, deployment, &mut changes),
93            None => placement(
94                &mut changes,
95                deployment.name.clone(),
96                None,
97                Some("deployment"),
98            ),
99        }
100    }
101    for deployment in &old.deployments {
102        if !new
103            .deployments
104            .iter()
105            .any(|after| after.name == deployment.name)
106        {
107            placement(
108                &mut changes,
109                deployment.name.clone(),
110                Some("deployment"),
111                None,
112            );
113        }
114    }
115    changes
116}
117
118/// A component in both closures: its `offers` and `requires` lines, its
119/// instances and its `external` flag.
120fn compare_components(old: &Component, new: &Component, changes: &mut Vec<SystemChange>) {
121    let name = new.qualified_name();
122    let old_offers: Vec<&str> = old
123        .offers
124        .iter()
125        .map(|offer| offer.service.as_str())
126        .collect();
127    let new_offers: Vec<&str> = new
128        .offers
129        .iter()
130        .map(|offer| offer.service.as_str())
131        .collect();
132    lines(changes, &name, "offers", &old_offers, &new_offers);
133    let old_requires: Vec<String> = old
134        .requires
135        .iter()
136        .map(|require| interface_text(require.interface.as_ref()))
137        .collect();
138    let new_requires: Vec<String> = new
139        .requires
140        .iter()
141        .map(|require| interface_text(require.interface.as_ref()))
142        .collect();
143    let old_requires: Vec<&str> = old_requires.iter().map(String::as_str).collect();
144    let new_requires: Vec<&str> = new_requires.iter().map(String::as_str).collect();
145    lines(changes, &name, "requires", &old_requires, &new_requires);
146    if old.instances != new.instances {
147        changes.push(SystemChange {
148            heading: SystemHeading::CompositionChanged,
149            path: format!("{name}/instances"),
150            before: Some(instance_list(&old.instances)),
151            after: Some(instance_list(&new.instances)),
152        });
153    }
154    if old.external != new.external {
155        changes.push(SystemChange {
156            heading: SystemHeading::CompositionChanged,
157            path: format!("{name}/external"),
158            before: Some(old.external.to_string()),
159            after: Some(new.external.to_string()),
160        });
161    }
162}
163
164/// The lines of one keyword that one side has and the other does not.
165fn lines(
166    changes: &mut Vec<SystemChange>,
167    component: &str,
168    keyword: &str,
169    old: &[&str],
170    new: &[&str],
171) {
172    for added in new.iter().filter(|line| !old.contains(line)) {
173        composition(
174            changes,
175            format!("{component}/{keyword}/{added}"),
176            None,
177            Some(&format!("{keyword} {added}")),
178        );
179    }
180    for removed in old.iter().filter(|line| !new.contains(line)) {
181        composition(
182            changes,
183            format!("{component}/{keyword}/{removed}"),
184            Some(&format!("{keyword} {removed}")),
185            None,
186        );
187    }
188}
189
190/// A deployment in both systems: its machines, their `external` flags, and
191/// the machine of every instance placed on both sides.
192fn compare_deployments(old: &Deployment, new: &Deployment, changes: &mut Vec<SystemChange>) {
193    for machine in &new.machines {
194        let path = format!("{}/{}", new.name, machine.name);
195        match old
196            .machines
197            .iter()
198            .find(|before| before.name == machine.name)
199        {
200            Some(before) if before.external != machine.external => changes.push(SystemChange {
201                heading: SystemHeading::PlacementChanged,
202                path: format!("{path}/external"),
203                before: Some(before.external.to_string()),
204                after: Some(machine.external.to_string()),
205            }),
206            Some(_) => {}
207            None => placement(changes, path, None, Some("machine")),
208        }
209    }
210    for machine in &old.machines {
211        if !new.machines.iter().any(|after| after.name == machine.name) {
212            placement(
213                changes,
214                format!("{}/{}", new.name, machine.name),
215                Some("machine"),
216                None,
217            );
218        }
219    }
220    for placed in &new.placements {
221        let moved_from = old.placements.iter().find(|before| {
222            before.component == placed.component
223                && before.instance == placed.instance
224                && before.machine != placed.machine
225        });
226        if let Some(before) = moved_from {
227            changes.push(SystemChange {
228                heading: SystemHeading::PlacementChanged,
229                path: format!("{}/{}.{}", new.name, placed.component, placed.instance),
230                before: Some(before.machine.clone()),
231                after: Some(placed.machine.clone()),
232            });
233        }
234    }
235}
236
237fn composition(
238    changes: &mut Vec<SystemChange>,
239    path: String,
240    before: Option<&str>,
241    after: Option<&str>,
242) {
243    changes.push(SystemChange {
244        heading: SystemHeading::CompositionChanged,
245        path,
246        before: before.map(str::to_string),
247        after: after.map(str::to_string),
248    });
249}
250
251fn placement(
252    changes: &mut Vec<SystemChange>,
253    path: String,
254    before: Option<&str>,
255    after: Option<&str>,
256) {
257    changes.push(SystemChange {
258        heading: SystemHeading::PlacementChanged,
259        path,
260        before: before.map(str::to_string),
261        after: after.map(str::to_string),
262    });
263}
264
265/// `catalog.Name` for a declared interface, the service's dotted name for an
266/// inline shape.
267fn interface_text(interface: Option<&InterfaceRef>) -> String {
268    match interface {
269        Some(interface) if interface.inline => interface.name.clone(),
270        Some(interface) => format!("{}.{}", interface.catalog, interface.name),
271        None => String::new(),
272    }
273}
274
275/// `(primary, backup)`, as `instances` is written.
276fn instance_list(instances: &[String]) -> String {
277    format!("({})", instances.join(", "))
278}
279
280#[cfg(test)]
281mod tests {
282    use ridl_ir::v2;
283
284    use super::*;
285
286    fn component(name: &str, instances: &[&str], offers: &[&str], requires: &[&str]) -> Component {
287        Component {
288            name: name.to_string(),
289            package: "veh.topology".to_string(),
290            implicit: false,
291            external: false,
292            instances: instances
293                .iter()
294                .map(|instance| instance.to_string())
295                .collect(),
296            offers: offers
297                .iter()
298                .map(|service| v2::Offer {
299                    service: service.to_string(),
300                    attributes: Vec::new(),
301                    doc: String::new(),
302                    links: Vec::new(),
303                    see: Vec::new(),
304                    since: Vec::new(),
305                })
306                .collect(),
307            requires: requires
308                .iter()
309                .map(|interface| v2::Require {
310                    interface: Some(v2::InterfaceRef {
311                        catalog: "veh.adas".to_string(),
312                        name: interface.to_string(),
313                        inline: false,
314                    }),
315                    service: String::new(),
316                    producer: String::new(),
317                    attributes: Vec::new(),
318                    doc: String::new(),
319                    links: Vec::new(),
320                    see: Vec::new(),
321                    since: Vec::new(),
322                })
323                .collect(),
324            labels: Vec::new(),
325            attributes: Vec::new(),
326            doc: String::new(),
327            links: Vec::new(),
328            see: Vec::new(),
329            since: Vec::new(),
330        }
331    }
332
333    fn machine(name: &str, external: bool) -> v2::Machine {
334        v2::Machine {
335            name: name.to_string(),
336            external,
337            labels: Vec::new(),
338            attributes: Vec::new(),
339            doc: String::new(),
340            links: Vec::new(),
341            see: Vec::new(),
342            since: Vec::new(),
343        }
344    }
345
346    fn placed(component: &str, instance: &str, machine: &str) -> v2::Placement {
347        v2::Placement {
348            component: format!("veh.topology.{component}"),
349            instance: instance.to_string(),
350            machine: machine.to_string(),
351            attributes: Vec::new(),
352            sizing: None,
353        }
354    }
355
356    /// Appendix A reduced to `Cruise` and `Panel` in `Production`.
357    fn base() -> System {
358        System {
359            name: "Vehicle".to_string(),
360            package: "veh.topology".to_string(),
361            components: vec![
362                component(
363                    "Cruise",
364                    &["primary", "backup"],
365                    &["veh.adas.cruise"],
366                    &["LaneAssist"],
367                ),
368                component("Panel", &["Unit"], &[], &["CruiseControl"]),
369            ],
370            deployments: vec![Deployment {
371                name: "Production".to_string(),
372                package: "veh.topology".to_string(),
373                machines: vec![machine("AdasHpc", false), machine("Cockpit", false)],
374                placements: vec![
375                    placed("Cruise", "primary", "AdasHpc"),
376                    placed("Cruise", "backup", "Cockpit"),
377                    placed("Panel", "Unit", "Cockpit"),
378                ],
379                ..Deployment::default()
380            }],
381            ..System::default()
382        }
383    }
384
385    /// Each change as one line, `<heading>: <path>: <before> -> <after>`.
386    fn lines(changes: &[SystemChange]) -> Vec<String> {
387        changes
388            .iter()
389            .map(|change| {
390                let mut line = format!("{}: {}", heading_text(change.heading), change.path);
391                crate::push_values(&mut line, change.before.as_ref(), change.after.as_ref());
392                line
393            })
394            .collect()
395    }
396
397    #[test]
398    fn an_unchanged_system_has_no_system_change() {
399        assert!(diff_systems(&base(), &base()).is_empty());
400    }
401
402    /// The region map is not compared, so a change to a region's catalog hash
403    /// lists no system change.
404    #[test]
405    fn a_region_hash_change_is_not_a_system_change() {
406        let region = |hash: u8| v2::Region {
407            catalog: "veh.adas".to_string(),
408            interfaces: Vec::new(),
409            hash: vec![hash; 32],
410        };
411        let old = System {
412            regions: vec![region(1)],
413            ..base()
414        };
415        let new = System {
416            regions: vec![region(2)],
417            ..base()
418        };
419        assert!(diff_systems(&old, &new).is_empty());
420    }
421
422    /// A doc edit is neither a placement nor a composition change, so the
423    /// doc fields of every system message are ignored (ADR-0026).
424    #[test]
425    fn system_doc_changes_are_not_reported() {
426        let see = v2::DocLink {
427            text: "Other".to_string(),
428            target: "veh.topology.Other".to_string(),
429            ..Default::default()
430        };
431        macro_rules! document {
432            ($carrier:expr) => {{
433                let carrier = &mut $carrier;
434                carrier.doc = "documented".to_string();
435                carrier.links.push(see.clone());
436                carrier.see.push(see.clone());
437                carrier.since.push("1.2".to_string());
438            }};
439        }
440        let mut old = base();
441        old.members.push(v2::MemberLine {
442            component: "veh.topology.Cruise".to_string(),
443            ..Default::default()
444        });
445        old.distributions.push(v2::Distribution {
446            name: "Adas".to_string(),
447            package: "veh.topology".to_string(),
448            members: old.members.clone(),
449            ..Default::default()
450        });
451        let mut new = old.clone();
452        document!(new);
453        document!(new.members[0]);
454        document!(new.components[0]);
455        document!(new.components[0].offers[0]);
456        document!(new.components[0].requires[0]);
457        document!(new.distributions[0]);
458        document!(new.distributions[0].members[0]);
459        let deployment = &mut new.deployments[0];
460        deployment.doc = "documented".to_string();
461        deployment.doc_links.push(see.clone());
462        deployment.see.push(see.clone());
463        deployment.since.push("1.2".to_string());
464        document!(new.deployments[0].machines[0]);
465        assert_ne!(old, new);
466        assert!(diff_systems(&old, &new).is_empty());
467    }
468
469    /// A changed sizing value, on the deployment or on a placement, is
470    /// neither a placement nor a composition change (rsdl §14).
471    #[test]
472    fn system_sizing_changes_are_not_reported() {
473        let sized = |depth: u32, slots: u32, budget: u64| v2::Sizing {
474            depth: Some(depth),
475            slots: Some(slots),
476            budget: Some(budget),
477        };
478        let mut old = base();
479        old.deployments[0].sizing = Some(sized(3, 16, 4096));
480        old.deployments[0].placements[0].sizing = Some(sized(3, 16, 4096));
481        let mut new = old.clone();
482        new.deployments[0].sizing = Some(sized(300, 64, 65536));
483        new.deployments[0].placements[0].sizing = Some(sized(300, 64, 65536));
484        assert_ne!(old, new);
485        assert!(diff_systems(&old, &new).is_empty());
486
487        // Declaring a value where none was, and removing one, are not
488        // reported either.
489        assert!(diff_systems(&base(), &new).is_empty());
490        assert!(diff_systems(&new, &base()).is_empty());
491    }
492
493    /// A system change is rendered under its heading, after the contract
494    /// changes and with no verdict, and leaves the report verdict alone; a
495    /// system on one side only is not compared (rsdl §14).
496    #[test]
497    fn system_changes_render_under_their_headings_with_no_verdict() {
498        let old = base();
499        let mut new = base();
500        new.deployments[0].placements[1].machine = "AdasHpc".to_string();
501        new.components[1].external = true;
502
503        let report = crate::diff_workspaces(&[], Some(&old), &[], Some(&new), &[]);
504        assert_eq!(report.verdict, crate::Verdict::Identical);
505        assert_eq!(
506            crate::render_text(&report),
507            "identical\n\
508             placement changed\n\
509             \x20 Production/veh.topology.Cruise.backup: Cockpit -> AdasHpc\n\
510             composition changed\n\
511             \x20 veh.topology.Panel/external: false -> true\n"
512        );
513        let json: serde_json::Value =
514            serde_json::from_str(&crate::render_json(&report)).expect("the report is JSON");
515        assert_eq!(
516            json["placement_changed"],
517            serde_json::json!([{
518                "path": "Production/veh.topology.Cruise.backup",
519                "before": "Cockpit",
520                "after": "AdasHpc"
521            }])
522        );
523        assert_eq!(json["composition_changed"][0]["after"], "true");
524
525        let one_side = crate::diff_workspaces(&[], None, &[], Some(&new), &[]);
526        assert!(one_side.system.is_empty());
527        let json: serde_json::Value =
528            serde_json::from_str(&crate::render_json(&one_side)).expect("the report is JSON");
529        assert_eq!(
530            json,
531            serde_json::json!({ "verdict": "identical", "changes": [] })
532        );
533    }
534
535    /// rsdl §14: an instance moved to another machine, a machine added,
536    /// removed or made `external`, and a deployment added or removed are
537    /// placement changes.
538    #[test]
539    fn a_deployment_change_is_listed_under_placement_changed() {
540        let old = base();
541        let mut new = base();
542        new.deployments[0].placements[1].machine = "AdasHpc".to_string();
543        new.deployments[0].machines[1].external = true;
544        new.deployments[0].machines.push(machine("Cloud", true));
545        new.deployments.push(Deployment {
546            name: "Bench".to_string(),
547            ..Deployment::default()
548        });
549        assert_eq!(
550            lines(&diff_systems(&old, &new)),
551            [
552                "placement changed: Production/Cockpit/external: false -> true",
553                "placement changed: Production/Cloud: (absent) -> machine",
554                "placement changed: Production/veh.topology.Cruise.backup: Cockpit -> AdasHpc",
555                "placement changed: Bench: (absent) -> deployment",
556            ]
557        );
558        assert_eq!(
559            lines(&diff_systems(&new, &old)),
560            [
561                "placement changed: Production/Cockpit/external: true -> false",
562                "placement changed: Production/Cloud: machine -> (removed)",
563                "placement changed: Production/veh.topology.Cruise.backup: AdasHpc -> Cockpit",
564                "placement changed: Bench: deployment -> (removed)",
565            ]
566        );
567    }
568
569    /// rsdl §14: a component added to or removed from the system, an `offers`
570    /// or `requires` line added or removed, `instances` changed, and a
571    /// component made `external` are composition changes.
572    #[test]
573    fn a_closure_or_line_change_is_listed_under_composition_changed() {
574        let old = base();
575        let mut new = base();
576        new.components[0].instances = vec!["Unit".to_string()];
577        new.components[0].requires.clear();
578        new.components[1].external = true;
579        new.components[1].offers.push(v2::Offer {
580            service: "veh.hmi.panel".to_string(),
581            attributes: Vec::new(),
582            doc: String::new(),
583            links: Vec::new(),
584            see: Vec::new(),
585            since: Vec::new(),
586        });
587        new.components
588            .push(component("Lane", &["Unit"], &["veh.adas.lane"], &[]));
589        assert_eq!(
590            lines(&diff_systems(&old, &new)),
591            [
592                "composition changed: veh.topology.Cruise/requires/veh.adas.LaneAssist: \
593                 requires veh.adas.LaneAssist -> (removed)",
594                "composition changed: veh.topology.Cruise/instances: (primary, backup) -> (Unit)",
595                "composition changed: veh.topology.Panel/offers/veh.hmi.panel: \
596                 (absent) -> offers veh.hmi.panel",
597                "composition changed: veh.topology.Panel/external: false -> true",
598                "composition changed: veh.topology.Lane: (absent) -> in the closure",
599            ]
600        );
601        assert_eq!(
602            lines(&diff_systems(&new, &old)).last().map(String::as_str),
603            Some("composition changed: veh.topology.Lane: in the closure -> (removed)")
604        );
605    }
606}