1use ridl_ir::v2::{Component, Deployment, InterfaceRef, System};
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26pub enum SystemHeading {
27 PlacementChanged,
29 CompositionChanged,
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct SystemChange {
36 pub heading: SystemHeading,
37 pub path: String,
41 pub before: Option<String>,
43 pub after: Option<String>,
45}
46
47pub fn heading_text(heading: SystemHeading) -> &'static str {
49 match heading {
50 SystemHeading::PlacementChanged => "placement changed",
51 SystemHeading::CompositionChanged => "composition changed",
52 }
53}
54
55pub 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
114fn 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
160fn 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
186fn 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
261fn 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
271fn 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 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 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 #[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 #[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 #[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}