1use ridl_ir::v2::{Component, Deployment, InterfaceRef, System};
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25pub enum SystemHeading {
26 PlacementChanged,
28 CompositionChanged,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct SystemChange {
35 pub heading: SystemHeading,
36 pub path: String,
40 pub before: Option<String>,
42 pub after: Option<String>,
44}
45
46pub fn heading_text(heading: SystemHeading) -> &'static str {
48 match heading {
49 SystemHeading::PlacementChanged => "placement changed",
50 SystemHeading::CompositionChanged => "composition changed",
51 }
52}
53
54pub 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
118fn 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
164fn 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
190fn 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
265fn 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
275fn 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 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 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 #[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 #[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 #[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 assert!(diff_systems(&base(), &new).is_empty());
490 assert!(diff_systems(&new, &base()).is_empty());
491 }
492
493 #[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 #[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 #[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}