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 (roadmap
10//! E4.5a).
11//!
12//! - **Placement changed** — a change in a deployment: a deployment added or
13//!   removed, a machine added, removed or made `external`, an instance moved to
14//!   another machine.
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`.
19//!
20//! Both read the lowered `System` of each side and nothing else.
21
22use ridl_ir::v2::{Component, Deployment, InterfaceRef, System};
23
24/// The heading a system change is listed under (rsdl reference §14).
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26pub enum SystemHeading {
27    /// A change in a deployment.
28    PlacementChanged,
29    /// A change to the closure or to a component's lines.
30    CompositionChanged,
31}
32
33/// One change to the system, listed under its heading with no verdict.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct SystemChange {
36    pub heading: SystemHeading,
37    /// A slash-separated path: a component's qualified name, then `offers`,
38    /// `requires`, `instances` or `external`; or a deployment's name, then a
39    /// machine or an instance.
40    pub path: String,
41    /// The rendered old value, absent when the change is an addition.
42    pub before: Option<String>,
43    /// The rendered new value, absent when the change is a removal.
44    pub after: Option<String>,
45}
46
47/// The heading as the text report prints it: `placement changed`.
48pub fn heading_text(heading: SystemHeading) -> &'static str {
49    match heading {
50        SystemHeading::PlacementChanged => "placement changed",
51        SystemHeading::CompositionChanged => "composition changed",
52    }
53}
54
55/// Compares two lowered systems: the composition changes first, in the new
56/// closure's order and then the removed components, and then the placement
57/// changes, deployment by deployment in the new system's order and then the
58/// removed deployments.
59pub fn diff_systems(old: &System, new: &System) -> Vec<SystemChange> {
60    let mut changes = Vec::new();
61    for component in &new.components {
62        let name = component.qualified_name();
63        match old
64            .components
65            .iter()
66            .find(|before| before.qualified_name() == name)
67        {
68            Some(before) => compare_components(before, component, &mut changes),
69            None => composition(&mut changes, name, None, Some("in the closure")),
70        }
71    }
72    for component in &old.components {
73        let name = component.qualified_name();
74        if !new
75            .components
76            .iter()
77            .any(|after| after.qualified_name() == name)
78        {
79            composition(&mut changes, name, Some("in the closure"), None);
80        }
81    }
82    for deployment in &new.deployments {
83        match old
84            .deployments
85            .iter()
86            .find(|before| before.name == deployment.name)
87        {
88            Some(before) => compare_deployments(before, deployment, &mut changes),
89            None => placement(
90                &mut changes,
91                deployment.name.clone(),
92                None,
93                Some("deployment"),
94            ),
95        }
96    }
97    for deployment in &old.deployments {
98        if !new
99            .deployments
100            .iter()
101            .any(|after| after.name == deployment.name)
102        {
103            placement(
104                &mut changes,
105                deployment.name.clone(),
106                Some("deployment"),
107                None,
108            );
109        }
110    }
111    changes
112}
113
114/// A component in both closures: its `offers` and `requires` lines, its
115/// instances and its `external` flag.
116fn compare_components(old: &Component, new: &Component, changes: &mut Vec<SystemChange>) {
117    let name = new.qualified_name();
118    let old_offers: Vec<&str> = old
119        .offers
120        .iter()
121        .map(|offer| offer.service.as_str())
122        .collect();
123    let new_offers: Vec<&str> = new
124        .offers
125        .iter()
126        .map(|offer| offer.service.as_str())
127        .collect();
128    lines(changes, &name, "offers", &old_offers, &new_offers);
129    let old_requires: Vec<String> = old
130        .requires
131        .iter()
132        .map(|require| interface_text(require.interface.as_ref()))
133        .collect();
134    let new_requires: Vec<String> = new
135        .requires
136        .iter()
137        .map(|require| interface_text(require.interface.as_ref()))
138        .collect();
139    let old_requires: Vec<&str> = old_requires.iter().map(String::as_str).collect();
140    let new_requires: Vec<&str> = new_requires.iter().map(String::as_str).collect();
141    lines(changes, &name, "requires", &old_requires, &new_requires);
142    if old.instances != new.instances {
143        changes.push(SystemChange {
144            heading: SystemHeading::CompositionChanged,
145            path: format!("{name}/instances"),
146            before: Some(instance_list(&old.instances)),
147            after: Some(instance_list(&new.instances)),
148        });
149    }
150    if old.external != new.external {
151        changes.push(SystemChange {
152            heading: SystemHeading::CompositionChanged,
153            path: format!("{name}/external"),
154            before: Some(old.external.to_string()),
155            after: Some(new.external.to_string()),
156        });
157    }
158}
159
160/// The lines of one keyword that one side has and the other does not.
161fn lines(
162    changes: &mut Vec<SystemChange>,
163    component: &str,
164    keyword: &str,
165    old: &[&str],
166    new: &[&str],
167) {
168    for added in new.iter().filter(|line| !old.contains(line)) {
169        composition(
170            changes,
171            format!("{component}/{keyword}/{added}"),
172            None,
173            Some(&format!("{keyword} {added}")),
174        );
175    }
176    for removed in old.iter().filter(|line| !new.contains(line)) {
177        composition(
178            changes,
179            format!("{component}/{keyword}/{removed}"),
180            Some(&format!("{keyword} {removed}")),
181            None,
182        );
183    }
184}
185
186/// A deployment in both systems: its machines, their `external` flags, and
187/// the machine of every instance placed on both sides.
188fn compare_deployments(old: &Deployment, new: &Deployment, changes: &mut Vec<SystemChange>) {
189    for machine in &new.machines {
190        let path = format!("{}/{}", new.name, machine.name);
191        match old
192            .machines
193            .iter()
194            .find(|before| before.name == machine.name)
195        {
196            Some(before) if before.external != machine.external => changes.push(SystemChange {
197                heading: SystemHeading::PlacementChanged,
198                path: format!("{path}/external"),
199                before: Some(before.external.to_string()),
200                after: Some(machine.external.to_string()),
201            }),
202            Some(_) => {}
203            None => placement(changes, path, None, Some("machine")),
204        }
205    }
206    for machine in &old.machines {
207        if !new.machines.iter().any(|after| after.name == machine.name) {
208            placement(
209                changes,
210                format!("{}/{}", new.name, machine.name),
211                Some("machine"),
212                None,
213            );
214        }
215    }
216    for placed in &new.placements {
217        let moved_from = old.placements.iter().find(|before| {
218            before.component == placed.component
219                && before.instance == placed.instance
220                && before.machine != placed.machine
221        });
222        if let Some(before) = moved_from {
223            changes.push(SystemChange {
224                heading: SystemHeading::PlacementChanged,
225                path: format!("{}/{}.{}", new.name, placed.component, placed.instance),
226                before: Some(before.machine.clone()),
227                after: Some(placed.machine.clone()),
228            });
229        }
230    }
231}
232
233fn composition(
234    changes: &mut Vec<SystemChange>,
235    path: String,
236    before: Option<&str>,
237    after: Option<&str>,
238) {
239    changes.push(SystemChange {
240        heading: SystemHeading::CompositionChanged,
241        path,
242        before: before.map(str::to_string),
243        after: after.map(str::to_string),
244    });
245}
246
247fn placement(
248    changes: &mut Vec<SystemChange>,
249    path: String,
250    before: Option<&str>,
251    after: Option<&str>,
252) {
253    changes.push(SystemChange {
254        heading: SystemHeading::PlacementChanged,
255        path,
256        before: before.map(str::to_string),
257        after: after.map(str::to_string),
258    });
259}
260
261/// `catalog.Name` for a declared interface, the service's dotted name for an
262/// inline shape.
263fn interface_text(interface: Option<&InterfaceRef>) -> String {
264    match interface {
265        Some(interface) if interface.inline => interface.name.clone(),
266        Some(interface) => format!("{}.{}", interface.catalog, interface.name),
267        None => String::new(),
268    }
269}
270
271/// `(primary, backup)`, as `instances` is written.
272fn instance_list(instances: &[String]) -> String {
273    format!("({})", instances.join(", "))
274}
275
276#[cfg(test)]
277mod tests {
278    use ridl_ir::v2;
279
280    use super::*;
281
282    fn component(name: &str, instances: &[&str], offers: &[&str], requires: &[&str]) -> Component {
283        Component {
284            name: name.to_string(),
285            package: "veh.topology".to_string(),
286            implicit: false,
287            external: false,
288            instances: instances
289                .iter()
290                .map(|instance| instance.to_string())
291                .collect(),
292            offers: offers
293                .iter()
294                .map(|service| v2::Offer {
295                    service: service.to_string(),
296                    attributes: Vec::new(),
297                })
298                .collect(),
299            requires: requires
300                .iter()
301                .map(|interface| v2::Require {
302                    interface: Some(v2::InterfaceRef {
303                        catalog: "veh.adas".to_string(),
304                        name: interface.to_string(),
305                        inline: false,
306                    }),
307                    service: String::new(),
308                    producer: String::new(),
309                    attributes: Vec::new(),
310                })
311                .collect(),
312            labels: Vec::new(),
313            attributes: Vec::new(),
314        }
315    }
316
317    fn machine(name: &str, external: bool) -> v2::Machine {
318        v2::Machine {
319            name: name.to_string(),
320            external,
321            labels: Vec::new(),
322            attributes: Vec::new(),
323        }
324    }
325
326    fn placed(component: &str, instance: &str, machine: &str) -> v2::Placement {
327        v2::Placement {
328            component: format!("veh.topology.{component}"),
329            instance: instance.to_string(),
330            machine: machine.to_string(),
331            attributes: Vec::new(),
332        }
333    }
334
335    /// Appendix A reduced to `Cruise` and `Panel` in `Production`.
336    fn base() -> System {
337        System {
338            name: "Vehicle".to_string(),
339            package: "veh.topology".to_string(),
340            components: vec![
341                component(
342                    "Cruise",
343                    &["primary", "backup"],
344                    &["veh.adas.cruise"],
345                    &["LaneAssist"],
346                ),
347                component("Panel", &["Unit"], &[], &["CruiseControl"]),
348            ],
349            deployments: vec![Deployment {
350                name: "Production".to_string(),
351                package: "veh.topology".to_string(),
352                machines: vec![machine("AdasHpc", false), machine("Cockpit", false)],
353                placements: vec![
354                    placed("Cruise", "primary", "AdasHpc"),
355                    placed("Cruise", "backup", "Cockpit"),
356                    placed("Panel", "Unit", "Cockpit"),
357                ],
358                ..Deployment::default()
359            }],
360            ..System::default()
361        }
362    }
363
364    /// Each change as one line, `<heading>: <path>: <before> -> <after>`.
365    fn lines(changes: &[SystemChange]) -> Vec<String> {
366        changes
367            .iter()
368            .map(|change| {
369                let mut line = format!("{}: {}", heading_text(change.heading), change.path);
370                crate::push_values(&mut line, change.before.as_ref(), change.after.as_ref());
371                line
372            })
373            .collect()
374    }
375
376    #[test]
377    fn an_unchanged_system_has_no_system_change() {
378        assert!(diff_systems(&base(), &base()).is_empty());
379    }
380
381    /// A system change is rendered under its heading, after the contract
382    /// changes and with no verdict, and leaves the report verdict alone; a
383    /// system on one side only is not compared (rsdl §14).
384    #[test]
385    fn system_changes_render_under_their_headings_with_no_verdict() {
386        let old = base();
387        let mut new = base();
388        new.deployments[0].placements[1].machine = "AdasHpc".to_string();
389        new.components[1].external = true;
390
391        let report = crate::diff_workspaces(&[], Some(&old), &[], Some(&new));
392        assert_eq!(report.verdict, crate::Verdict::Identical);
393        assert_eq!(
394            crate::render_text(&report),
395            "identical\n\
396             placement changed\n\
397             \x20 Production/veh.topology.Cruise.backup: Cockpit -> AdasHpc\n\
398             composition changed\n\
399             \x20 veh.topology.Panel/external: false -> true\n"
400        );
401        let json: serde_json::Value =
402            serde_json::from_str(&crate::render_json(&report)).expect("the report is JSON");
403        assert_eq!(
404            json["placement_changed"],
405            serde_json::json!([{
406                "path": "Production/veh.topology.Cruise.backup",
407                "before": "Cockpit",
408                "after": "AdasHpc"
409            }])
410        );
411        assert_eq!(json["composition_changed"][0]["after"], "true");
412
413        let one_side = crate::diff_workspaces(&[], None, &[], Some(&new));
414        assert!(one_side.system.is_empty());
415        let json: serde_json::Value =
416            serde_json::from_str(&crate::render_json(&one_side)).expect("the report is JSON");
417        assert_eq!(
418            json,
419            serde_json::json!({ "verdict": "identical", "changes": [] })
420        );
421    }
422
423    /// rsdl §14: an instance moved to another machine, a machine added,
424    /// removed or made `external`, and a deployment added or removed are
425    /// placement changes.
426    #[test]
427    fn a_deployment_change_is_listed_under_placement_changed() {
428        let old = base();
429        let mut new = base();
430        new.deployments[0].placements[1].machine = "AdasHpc".to_string();
431        new.deployments[0].machines[1].external = true;
432        new.deployments[0].machines.push(machine("Cloud", true));
433        new.deployments.push(Deployment {
434            name: "Bench".to_string(),
435            ..Deployment::default()
436        });
437        assert_eq!(
438            lines(&diff_systems(&old, &new)),
439            [
440                "placement changed: Production/Cockpit/external: false -> true",
441                "placement changed: Production/Cloud: (absent) -> machine",
442                "placement changed: Production/veh.topology.Cruise.backup: Cockpit -> AdasHpc",
443                "placement changed: Bench: (absent) -> deployment",
444            ]
445        );
446        assert_eq!(
447            lines(&diff_systems(&new, &old)),
448            [
449                "placement changed: Production/Cockpit/external: true -> false",
450                "placement changed: Production/Cloud: machine -> (removed)",
451                "placement changed: Production/veh.topology.Cruise.backup: AdasHpc -> Cockpit",
452                "placement changed: Bench: deployment -> (removed)",
453            ]
454        );
455    }
456
457    /// rsdl §14: a component added to or removed from the system, an `offers`
458    /// or `requires` line added or removed, `instances` changed, and a
459    /// component made `external` are composition changes.
460    #[test]
461    fn a_closure_or_line_change_is_listed_under_composition_changed() {
462        let old = base();
463        let mut new = base();
464        new.components[0].instances = vec!["Unit".to_string()];
465        new.components[0].requires.clear();
466        new.components[1].external = true;
467        new.components[1].offers.push(v2::Offer {
468            service: "veh.hmi.panel".to_string(),
469            attributes: Vec::new(),
470        });
471        new.components
472            .push(component("Lane", &["Unit"], &["veh.adas.lane"], &[]));
473        assert_eq!(
474            lines(&diff_systems(&old, &new)),
475            [
476                "composition changed: veh.topology.Cruise/requires/veh.adas.LaneAssist: \
477                 requires veh.adas.LaneAssist -> (removed)",
478                "composition changed: veh.topology.Cruise/instances: (primary, backup) -> (Unit)",
479                "composition changed: veh.topology.Panel/offers/veh.hmi.panel: \
480                 (absent) -> offers veh.hmi.panel",
481                "composition changed: veh.topology.Panel/external: false -> true",
482                "composition changed: veh.topology.Lane: (absent) -> in the closure",
483            ]
484        );
485        assert_eq!(
486            lines(&diff_systems(&new, &old)).last().map(String::as_str),
487            Some("composition changed: veh.topology.Lane: in the closure -> (removed)")
488        );
489    }
490}