1use ridl_ir::v2::{self, Component, Deployment, InterfaceRef, System};
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34pub enum SystemHeading {
35 PlacementChanged,
37 CompositionChanged,
39 InstanceNumberChanged,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct SystemChange {
48 pub heading: SystemHeading,
49 pub path: String,
52 pub before: Option<String>,
54 pub after: Option<String>,
56}
57
58pub fn heading_text(heading: SystemHeading) -> &'static str {
60 match heading {
61 SystemHeading::PlacementChanged => "placement changed",
62 SystemHeading::CompositionChanged => "composition changed",
63 SystemHeading::InstanceNumberChanged => "instance number changed",
64 }
65}
66
67pub fn diff_systems(old: &System, new: &System) -> Vec<SystemChange> {
78 let mut changes = Vec::new();
79 let system_path = format!("{}.{}", new.package, new.name);
80 declaration_attrs(
81 &mut changes,
82 SystemHeading::CompositionChanged,
83 &system_path,
84 (&old.labels, old.deprecated.as_ref(), &old.attributes),
85 (&new.labels, new.deprecated.as_ref(), &new.attributes),
86 );
87 for line in &new.members {
88 if let Some(before) = old
89 .members
90 .iter()
91 .find(|before| before.component == line.component)
92 {
93 line_attrs(
94 &mut changes,
95 SystemHeading::CompositionChanged,
96 &format!("{system_path}/{}", line.component),
97 &before.attributes,
98 &line.attributes,
99 );
100 }
101 }
102 for component in &new.components {
103 let name = component.qualified_name();
104 match old
105 .components
106 .iter()
107 .find(|before| before.qualified_name() == name)
108 {
109 Some(before) => compare_components(before, component, &mut changes),
110 None => composition(&mut changes, name, None, Some("in the closure")),
111 }
112 }
113 for component in &old.components {
114 let name = component.qualified_name();
115 if !new
116 .components
117 .iter()
118 .any(|after| after.qualified_name() == name)
119 {
120 composition(&mut changes, name, Some("in the closure"), None);
121 }
122 }
123 let empty_distribution = v2::Distribution::default();
124 for distribution in &new.distributions {
125 let name = distribution.qualified_name();
126 let before = old
127 .distributions
128 .iter()
129 .find(|before| before.qualified_name() == name);
130 if before.is_none() {
131 composition(&mut changes, name.clone(), None, Some("distribution"));
132 }
133 compare_distribution(
134 before.unwrap_or(&empty_distribution),
135 distribution,
136 &name,
137 &mut changes,
138 );
139 }
140 for distribution in &old.distributions {
141 let name = distribution.qualified_name();
142 if !new
143 .distributions
144 .iter()
145 .any(|after| after.qualified_name() == name)
146 {
147 composition(&mut changes, name.clone(), Some("distribution"), None);
148 compare_distribution(distribution, &empty_distribution, &name, &mut changes);
149 }
150 }
151 for deployment in &new.deployments {
152 match old
153 .deployments
154 .iter()
155 .find(|before| before.name == deployment.name)
156 {
157 Some(before) => compare_deployments(before, deployment, &mut changes),
158 None => placement(
159 &mut changes,
160 deployment.name.clone(),
161 None,
162 Some("deployment"),
163 ),
164 }
165 }
166 for deployment in &old.deployments {
167 if !new
168 .deployments
169 .iter()
170 .any(|after| after.name == deployment.name)
171 {
172 placement(
173 &mut changes,
174 deployment.name.clone(),
175 Some("deployment"),
176 None,
177 );
178 }
179 }
180 instance_numbers(old, new, &mut changes);
181 changes
182}
183
184fn numbered_instances(system: &System) -> Vec<(String, i32, bool)> {
187 let mut numbered = Vec::new();
188 for component in &system.components {
189 let name = component.qualified_name();
190 for entry in &component.numbers {
191 numbered.push((
192 format!("{name}/{}", entry.instance),
193 entry.number,
194 entry.provisional,
195 ));
196 }
197 }
198 numbered
199}
200
201fn instance_numbers(old: &System, new: &System, changes: &mut Vec<SystemChange>) {
209 let old = numbered_instances(old);
210 let new = numbered_instances(new);
211 for (key, number, _) in &new {
212 if let Some((_, before, provisional)) = old.iter().find(|(old_key, ..)| old_key == key)
213 && !provisional
214 && before != number
215 {
216 changes.push(SystemChange {
217 heading: SystemHeading::InstanceNumberChanged,
218 path: key.clone(),
219 before: Some(before.to_string()),
220 after: Some(number.to_string()),
221 });
222 }
223 }
224 for (key, number, _) in &new {
225 if old.iter().any(|(old_key, ..)| old_key == key) {
226 continue;
227 }
228 let holder = old
229 .iter()
230 .find(|(old_key, held, provisional)| held == number && !provisional && old_key != key);
231 if let Some((holder, ..)) = holder
232 && new.iter().any(|(new_key, ..)| new_key == holder)
233 {
234 changes.push(SystemChange {
235 heading: SystemHeading::InstanceNumberChanged,
236 path: key.clone(),
237 before: Some(format!("{number} ({holder})")),
238 after: Some(number.to_string()),
239 });
240 }
241 }
242}
243
244fn compare_components(old: &Component, new: &Component, changes: &mut Vec<SystemChange>) {
247 let name = new.qualified_name();
248 let old_offers: Vec<&str> = old
249 .offers
250 .iter()
251 .map(|offer| offer.service.as_str())
252 .collect();
253 let new_offers: Vec<&str> = new
254 .offers
255 .iter()
256 .map(|offer| offer.service.as_str())
257 .collect();
258 lines(changes, &name, "offers", &old_offers, &new_offers);
259 let old_requires: Vec<String> = old
260 .requires
261 .iter()
262 .map(|require| interface_text(require.interface.as_ref()))
263 .collect();
264 let new_requires: Vec<String> = new
265 .requires
266 .iter()
267 .map(|require| interface_text(require.interface.as_ref()))
268 .collect();
269 let old_requires: Vec<&str> = old_requires.iter().map(String::as_str).collect();
270 let new_requires: Vec<&str> = new_requires.iter().map(String::as_str).collect();
271 lines(changes, &name, "requires", &old_requires, &new_requires);
272 if old.instances != new.instances {
273 changes.push(SystemChange {
274 heading: SystemHeading::CompositionChanged,
275 path: format!("{name}/instances"),
276 before: Some(instance_list(&old.instances)),
277 after: Some(instance_list(&new.instances)),
278 });
279 }
280 if old.external != new.external {
281 changes.push(SystemChange {
282 heading: SystemHeading::CompositionChanged,
283 path: format!("{name}/external"),
284 before: Some(old.external.to_string()),
285 after: Some(new.external.to_string()),
286 });
287 }
288 declaration_attrs(
289 changes,
290 SystemHeading::CompositionChanged,
291 &name,
292 (&old.labels, old.deprecated.as_ref(), &old.attributes),
293 (&new.labels, new.deprecated.as_ref(), &new.attributes),
294 );
295 for offer in &new.offers {
296 if let Some(before) = old
297 .offers
298 .iter()
299 .find(|before| before.service == offer.service)
300 {
301 line_attrs(
302 changes,
303 SystemHeading::CompositionChanged,
304 &format!("{name}/offers/{}", offer.service),
305 &before.attributes,
306 &offer.attributes,
307 );
308 }
309 }
310 for require in &new.requires {
311 let interface = interface_text(require.interface.as_ref());
312 if let Some(before) = old
313 .requires
314 .iter()
315 .find(|before| interface_text(before.interface.as_ref()) == interface)
316 {
317 line_attrs(
318 changes,
319 SystemHeading::CompositionChanged,
320 &format!("{name}/requires/{interface}"),
321 &before.attributes,
322 &require.attributes,
323 );
324 }
325 }
326}
327
328fn lines(
330 changes: &mut Vec<SystemChange>,
331 component: &str,
332 keyword: &str,
333 old: &[&str],
334 new: &[&str],
335) {
336 for added in new.iter().filter(|line| !old.contains(line)) {
337 composition(
338 changes,
339 format!("{component}/{keyword}/{added}"),
340 None,
341 Some(&format!("{keyword} {added}")),
342 );
343 }
344 for removed in old.iter().filter(|line| !new.contains(line)) {
345 composition(
346 changes,
347 format!("{component}/{keyword}/{removed}"),
348 Some(&format!("{keyword} {removed}")),
349 None,
350 );
351 }
352}
353
354fn compare_deployments(old: &Deployment, new: &Deployment, changes: &mut Vec<SystemChange>) {
357 declaration_attrs(
358 changes,
359 SystemHeading::PlacementChanged,
360 &new.name,
361 (&old.labels, old.deprecated.as_ref(), &old.attributes),
362 (&new.labels, new.deprecated.as_ref(), &new.attributes),
363 );
364 for machine in &new.machines {
365 let path = format!("{}/{}", new.name, machine.name);
366 match old
367 .machines
368 .iter()
369 .find(|before| before.name == machine.name)
370 {
371 Some(before) if before.external != machine.external => changes.push(SystemChange {
372 heading: SystemHeading::PlacementChanged,
373 path: format!("{path}/external"),
374 before: Some(before.external.to_string()),
375 after: Some(machine.external.to_string()),
376 }),
377 Some(_) => {}
378 None => placement(changes, path, None, Some("machine")),
379 }
380 if let Some(before) = old
381 .machines
382 .iter()
383 .find(|before| before.name == machine.name)
384 {
385 declaration_attrs(
386 changes,
387 SystemHeading::PlacementChanged,
388 &format!("{}/{}", new.name, machine.name),
389 (
390 &before.labels,
391 before.deprecated.as_ref(),
392 &before.attributes,
393 ),
394 (
395 &machine.labels,
396 machine.deprecated.as_ref(),
397 &machine.attributes,
398 ),
399 );
400 }
401 }
402 for machine in &old.machines {
403 if !new.machines.iter().any(|after| after.name == machine.name) {
404 placement(
405 changes,
406 format!("{}/{}", new.name, machine.name),
407 Some("machine"),
408 None,
409 );
410 }
411 }
412 for placed in &new.placements {
413 let paired = old.placements.iter().find(|before| {
414 before.component == placed.component && before.instance == placed.instance
415 });
416 let Some(before) = paired else {
417 continue;
418 };
419 if before.machine != placed.machine {
420 changes.push(SystemChange {
421 heading: SystemHeading::PlacementChanged,
422 path: format!("{}/{}.{}", new.name, placed.component, placed.instance),
423 before: Some(before.machine.clone()),
424 after: Some(placed.machine.clone()),
425 });
426 }
427 line_attrs(
428 changes,
429 SystemHeading::PlacementChanged,
430 &format!("{}/{}.{}", new.name, placed.component, placed.instance),
431 &before.attributes,
432 &placed.attributes,
433 );
434 }
435}
436
437fn compare_distribution(
438 before: &v2::Distribution,
439 after: &v2::Distribution,
440 name: &str,
441 changes: &mut Vec<SystemChange>,
442) {
443 value_change(
444 changes,
445 SystemHeading::CompositionChanged,
446 format!("{name}/tier"),
447 tier_text(before.tier),
448 tier_text(after.tier),
449 );
450 declaration_attrs(
451 changes,
452 SystemHeading::CompositionChanged,
453 name,
454 (
455 &before.labels,
456 before.deprecated.as_ref(),
457 &before.attributes,
458 ),
459 (&after.labels, after.deprecated.as_ref(), &after.attributes),
460 );
461 for line in &after.members {
464 let path = format!("{name}/{}", line.component);
465 let earlier = before
466 .members
467 .iter()
468 .find(|earlier| earlier.component == line.component);
469 if earlier.is_none() {
470 composition(changes, path.clone(), None, Some("member"));
471 }
472 line_attrs(
473 changes,
474 SystemHeading::CompositionChanged,
475 &path,
476 earlier.map_or(&[], |earlier| &earlier.attributes),
477 &line.attributes,
478 );
479 }
480 for line in &before.members {
481 if !after
482 .members
483 .iter()
484 .any(|later| later.component == line.component)
485 {
486 let path = format!("{name}/{}", line.component);
487 composition(changes, path.clone(), Some("member"), None);
488 line_attrs(
489 changes,
490 SystemHeading::CompositionChanged,
491 &path,
492 &line.attributes,
493 &[],
494 );
495 }
496 }
497}
498
499fn composition(
500 changes: &mut Vec<SystemChange>,
501 path: String,
502 before: Option<&str>,
503 after: Option<&str>,
504) {
505 changes.push(SystemChange {
506 heading: SystemHeading::CompositionChanged,
507 path,
508 before: before.map(str::to_string),
509 after: after.map(str::to_string),
510 });
511}
512
513fn placement(
514 changes: &mut Vec<SystemChange>,
515 path: String,
516 before: Option<&str>,
517 after: Option<&str>,
518) {
519 changes.push(SystemChange {
520 heading: SystemHeading::PlacementChanged,
521 path,
522 before: before.map(str::to_string),
523 after: after.map(str::to_string),
524 });
525}
526
527fn label_list(labels: &[String]) -> String {
529 format!("({})", labels.join(", "))
530}
531
532fn attribute_list(attributes: &[v2::Attribute]) -> String {
535 if attributes.is_empty() {
536 return "[]".to_string();
537 }
538 let items: Vec<String> = attributes
539 .iter()
540 .map(|attribute| match &attribute.value {
541 Some(value) => format!(
542 "{}.{} = {}",
543 attribute.namespace,
544 attribute.key,
545 value_text(value)
546 ),
547 None => format!("{}.{}", attribute.namespace, attribute.key),
548 })
549 .collect();
550 format!("[ {} ]", items.join(", "))
551}
552
553fn value_text(value: &v2::AttributeValue) -> String {
555 match &value.kind {
556 Some(v2::attribute_value::Kind::Scalar(text)) => text.clone(),
557 Some(v2::attribute_value::Kind::List(list)) => {
558 let items: Vec<String> = list.items.iter().map(value_text).collect();
559 format!("({})", items.join(", "))
560 }
561 None => String::new(),
562 }
563}
564
565fn tier_text(tier: i32) -> Option<String> {
569 match v2::Tier::try_from(tier) {
570 Ok(v2::Tier::Platform) => Some("PLATFORM".to_string()),
571 Ok(v2::Tier::Application) => Some("APPLICATION".to_string()),
572 Ok(v2::Tier::Unspecified) => None,
573 Err(_) => Some(format!("tier({tier})")),
574 }
575}
576
577fn value_change(
579 changes: &mut Vec<SystemChange>,
580 heading: SystemHeading,
581 path: String,
582 before: Option<String>,
583 after: Option<String>,
584) {
585 if before != after {
586 changes.push(SystemChange {
587 heading,
588 path,
589 before,
590 after,
591 });
592 }
593}
594
595fn declaration_attrs(
598 changes: &mut Vec<SystemChange>,
599 heading: SystemHeading,
600 path: &str,
601 old: (&[String], Option<&String>, &[v2::Attribute]),
602 new: (&[String], Option<&String>, &[v2::Attribute]),
603) {
604 value_change(
605 changes,
606 heading,
607 format!("{path}/labels"),
608 Some(label_list(old.0)),
609 Some(label_list(new.0)),
610 );
611 value_change(
612 changes,
613 heading,
614 format!("{path}/deprecated"),
615 old.1.map(|reason| format!("{reason:?}")),
616 new.1.map(|reason| format!("{reason:?}")),
617 );
618 line_attrs(changes, heading, path, old.2, new.2);
619}
620
621fn line_attrs(
623 changes: &mut Vec<SystemChange>,
624 heading: SystemHeading,
625 path: &str,
626 old: &[v2::Attribute],
627 new: &[v2::Attribute],
628) {
629 value_change(
630 changes,
631 heading,
632 format!("{path}/attributes"),
633 Some(attribute_list(old)),
634 Some(attribute_list(new)),
635 );
636}
637
638fn interface_text(interface: Option<&InterfaceRef>) -> String {
641 match interface {
642 Some(interface) if interface.inline => interface.name.clone(),
643 Some(interface) => format!("{}.{}", interface.catalog, interface.name),
644 None => String::new(),
645 }
646}
647
648fn instance_list(instances: &[String]) -> String {
650 format!("({})", instances.join(", "))
651}
652
653#[cfg(test)]
654mod tests {
655 use ridl_ir::v2;
656
657 use super::*;
658
659 fn component(name: &str, instances: &[&str], offers: &[&str], requires: &[&str]) -> Component {
660 Component {
661 name: name.to_string(),
662 package: "veh.topology".to_string(),
663 implicit: false,
664 external: false,
665 instances: instances
666 .iter()
667 .map(|instance| instance.to_string())
668 .collect(),
669 numbers: Vec::new(),
670 offers: offers
671 .iter()
672 .map(|service| v2::Offer {
673 service: service.to_string(),
674 attributes: Vec::new(),
675 doc: String::new(),
676 links: Vec::new(),
677 see: Vec::new(),
678 since: Vec::new(),
679 })
680 .collect(),
681 requires: requires
682 .iter()
683 .map(|interface| v2::Require {
684 interface: Some(v2::InterfaceRef {
685 catalog: "veh.adas".to_string(),
686 name: interface.to_string(),
687 inline: false,
688 }),
689 service: String::new(),
690 producer: String::new(),
691 attributes: Vec::new(),
692 doc: String::new(),
693 links: Vec::new(),
694 see: Vec::new(),
695 since: Vec::new(),
696 })
697 .collect(),
698 labels: Vec::new(),
699 attributes: Vec::new(),
700 doc: String::new(),
701 links: Vec::new(),
702 see: Vec::new(),
703 since: Vec::new(),
704 deprecated: None,
705 }
706 }
707
708 fn machine(name: &str, external: bool) -> v2::Machine {
709 v2::Machine {
710 name: name.to_string(),
711 external,
712 labels: Vec::new(),
713 attributes: Vec::new(),
714 doc: String::new(),
715 links: Vec::new(),
716 see: Vec::new(),
717 since: Vec::new(),
718 deprecated: None,
719 }
720 }
721
722 fn placed(component: &str, instance: &str, machine: &str) -> v2::Placement {
723 v2::Placement {
724 component: format!("veh.topology.{component}"),
725 instance: instance.to_string(),
726 machine: machine.to_string(),
727 attributes: Vec::new(),
728 sizing: None,
729 }
730 }
731
732 fn base() -> System {
734 System {
735 name: "Vehicle".to_string(),
736 package: "veh.topology".to_string(),
737 components: vec![
738 component(
739 "Cruise",
740 &["primary", "backup"],
741 &["veh.adas.cruise"],
742 &["LaneAssist"],
743 ),
744 component("Panel", &["Unit"], &[], &["CruiseControl"]),
745 ],
746 deployments: vec![Deployment {
747 name: "Production".to_string(),
748 package: "veh.topology".to_string(),
749 machines: vec![machine("AdasHpc", false), machine("Cockpit", false)],
750 placements: vec![
751 placed("Cruise", "primary", "AdasHpc"),
752 placed("Cruise", "backup", "Cockpit"),
753 placed("Panel", "Unit", "Cockpit"),
754 ],
755 ..Deployment::default()
756 }],
757 ..System::default()
758 }
759 }
760
761 fn lines(changes: &[SystemChange]) -> Vec<String> {
763 changes
764 .iter()
765 .map(|change| {
766 let mut line = format!("{}: {}", heading_text(change.heading), change.path);
767 crate::push_values(&mut line, change.before.as_ref(), change.after.as_ref());
768 line
769 })
770 .collect()
771 }
772
773 fn backend(namespace: &str, key: &str, value: Option<&str>) -> v2::Attribute {
774 v2::Attribute {
775 namespace: namespace.to_string(),
776 key: key.to_string(),
777 value: value.map(|text| v2::AttributeValue {
778 kind: Some(v2::attribute_value::Kind::Scalar(text.to_string())),
779 }),
780 }
781 }
782
783 fn with_distribution() -> System {
786 let mut system = base();
787 system.members = vec![
788 v2::MemberLine {
789 component: "veh.topology.Cruise".to_string(),
790 ..Default::default()
791 },
792 v2::MemberLine {
793 component: "veh.topology.Panel".to_string(),
794 ..Default::default()
795 },
796 ];
797 system.distributions.push(v2::Distribution {
798 name: "Adas".to_string(),
799 package: "veh.topology".to_string(),
800 members: vec![v2::MemberLine {
801 component: "veh.topology.Cruise".to_string(),
802 ..Default::default()
803 }],
804 ..Default::default()
805 });
806 system
807 }
808
809 #[test]
810 fn bare_distributions_are_reported_in_both_directions() {
811 let present = with_distribution();
812 let mut absent = present.clone();
813 absent.distributions.clear();
814 assert_eq!(
817 lines(&diff_systems(&absent, &present)),
818 [
819 "composition changed: veh.topology.Adas: (absent) -> distribution",
820 "composition changed: veh.topology.Adas/veh.topology.Cruise: (absent) -> member",
821 ]
822 );
823 assert_eq!(
824 lines(&diff_systems(&present, &absent)),
825 [
826 "composition changed: veh.topology.Adas: distribution -> (removed)",
827 "composition changed: veh.topology.Adas/veh.topology.Cruise: member -> (removed)",
828 ]
829 );
830 }
831
832 #[test]
833 fn added_and_removed_distributions_include_their_attributes() {
834 let mut old = with_distribution();
835 old.distributions.push(v2::Distribution {
838 name: "Adas".to_string(),
839 package: "veh.other".to_string(),
840 ..Default::default()
841 });
842 let mut new = old.clone();
843 let distribution = &mut new.distributions[0];
844 distribution.tier = v2::Tier::Platform as i32;
845 distribution.labels = vec!["QM".to_string()];
846 distribution.deprecated = Some("use Next".to_string());
847 distribution.attributes = vec![backend("sign", "key", Some("release"))];
848 let mut absent = old.clone();
849 absent
850 .distributions
851 .retain(|distribution| distribution.package != "veh.topology");
852 let added = diff_systems(&absent, &new);
853 let expected = [
854 ("veh.topology.Adas", None, Some("distribution")),
855 ("veh.topology.Adas/tier", None, Some("PLATFORM")),
856 ("veh.topology.Adas/labels", Some("()"), Some("(QM)")),
857 ("veh.topology.Adas/deprecated", None, Some("\"use Next\"")),
858 (
859 "veh.topology.Adas/attributes",
860 Some("[]"),
861 Some("[ sign.key = release ]"),
862 ),
863 (
864 "veh.topology.Adas/veh.topology.Cruise",
865 None,
866 Some("member"),
867 ),
868 ];
869 assert_eq!(added.len(), expected.len(), "{added:?}");
870 for (change, (path, before, after)) in added.iter().zip(expected) {
871 assert_eq!(change.heading, SystemHeading::CompositionChanged);
872 assert_eq!(change.path, path);
873 assert_eq!(change.before.as_deref(), before);
874 assert_eq!(change.after.as_deref(), after);
875 }
876 assert_eq!(diff_systems(&old, &new), added[1..added.len() - 1]);
879 let removed = diff_systems(&new, &absent);
880 assert_eq!(removed.len(), added.len());
881 for (removed, added) in removed.iter().zip(&added) {
882 assert_eq!(removed.heading, added.heading);
883 assert_eq!(removed.path, added.path);
884 assert_eq!(removed.before, added.after);
885 assert_eq!(removed.after, added.before);
886 }
887 }
888
889 #[test]
894 fn the_registry_rows_are_reported_as_recorded() {
895 use ridl_core::attributes::{ATTRIBUTES, AttrName, Diff, Site};
896 for row in ATTRIBUTES {
897 if !row
898 .sites
899 .iter()
900 .any(|site| !matches!(site, Site::Command | Site::Query))
901 {
902 continue;
903 }
904 let old = with_distribution();
905 let mut new = with_distribution();
906 let key = match row.name {
907 AttrName::Key(key) => key,
908 AttrName::Namespaced => "ns.key",
909 };
910 match key {
911 "instances" => new.components[0].instances = vec!["Unit".to_string()],
912 "external" => new.components[0].external = true,
913 "tier" => new.distributions[0].tier = v2::Tier::Application as i32,
914 "labels" => new.distributions[0].labels = vec!["QM".to_string()],
915 "deprecated" => new.deployments[0].deprecated = Some("use v2".to_string()),
916 "depth" => {
917 new.deployments[0].sizing = Some(v2::Sizing {
918 depth: Some(4),
919 slots: None,
920 budget: None,
921 })
922 }
923 "slots" => {
924 new.deployments[0].placements[0].sizing = Some(v2::Sizing {
925 depth: None,
926 slots: Some(4),
927 budget: None,
928 })
929 }
930 "budget" => {
931 new.deployments[0].sizing = Some(v2::Sizing {
932 depth: None,
933 slots: None,
934 budget: Some(4096),
935 })
936 }
937 "ns.key" => new.deployments[0].machines[0].attributes.push(backend(
938 "linux",
939 "cpuset",
940 Some("(2)"),
941 )),
942 other => panic!("registry row `{other}` has no mutation in this test"),
943 }
944 let changes = diff_systems(&old, &new);
945 match row.diff {
946 Diff::Compared(_) => assert!(!changes.is_empty(), "`{key}` must be reported"),
947 Diff::NotReported(reason) => assert!(
948 changes.is_empty(),
949 "`{key}` is recorded as not reported ({reason:?}) but was: {changes:?}"
950 ),
951 }
952 }
953 }
954
955 #[test]
962 fn attribute_changes_are_listed_under_the_heading_of_their_site() {
963 let old = with_distribution();
964 let mut new = with_distribution();
965 new.labels = vec!["QM".to_string()];
966 new.components[0].deprecated = Some("use Cruise2".to_string());
967 new.distributions[0].tier = v2::Tier::Platform as i32;
968 new.members[0]
969 .attributes
970 .push(backend("deb", "section", Some("net")));
971 new.components[0].requires[0]
972 .attributes
973 .push(backend("someip", "clientId", Some("17")));
974 new.distributions[0].members[0]
975 .attributes
976 .push(backend("sign", "key", None));
977 new.components[0].offers[0]
978 .attributes
979 .push(backend("someip", "serviceId", Some("4660")));
980 new.deployments[0]
981 .attributes
982 .push(backend("rust", "crate", Some("\"s\"")));
983 new.deployments[0].machines[0].labels = vec!["ASIL_B".to_string()];
984 new.deployments[0].placements[0]
985 .attributes
986 .push(backend("linux", "cpuset", Some("(2)")));
987 assert_eq!(
988 lines(&diff_systems(&old, &new)),
989 [
990 "composition changed: veh.topology.Vehicle/labels: () -> (QM)",
991 "composition changed: veh.topology.Vehicle/veh.topology.Cruise/attributes: \
992 [] -> [ deb.section = net ]",
993 "composition changed: veh.topology.Cruise/deprecated: (absent) -> \"use Cruise2\"",
994 "composition changed: veh.topology.Cruise/offers/veh.adas.cruise/attributes: \
995 [] -> [ someip.serviceId = 4660 ]",
996 "composition changed: veh.topology.Cruise/requires/veh.adas.LaneAssist/attributes: \
997 [] -> [ someip.clientId = 17 ]",
998 "composition changed: veh.topology.Adas/tier: (absent) -> PLATFORM",
999 "composition changed: veh.topology.Adas/veh.topology.Cruise/attributes: \
1000 [] -> [ sign.key ]",
1001 "placement changed: Production/attributes: [] -> [ rust.crate = \"s\" ]",
1002 "placement changed: Production/AdasHpc/labels: () -> (ASIL_B)",
1003 "placement changed: Production/veh.topology.Cruise.primary/attributes: \
1004 [] -> [ linux.cpuset = (2) ]",
1005 ]
1006 );
1007 }
1008
1009 #[test]
1011 fn an_attribute_removed_is_reported() {
1012 let mut old = with_distribution();
1013 old.components[0].deprecated = Some("use Cruise2".to_string());
1014 old.distributions[0].tier = v2::Tier::Platform as i32;
1015 let new = with_distribution();
1016 assert_eq!(
1017 lines(&diff_systems(&old, &new)),
1018 [
1019 "composition changed: veh.topology.Cruise/deprecated: \"use Cruise2\" -> (removed)",
1020 "composition changed: veh.topology.Adas/tier: PLATFORM -> (removed)",
1021 ]
1022 );
1023 }
1024
1025 #[test]
1029 fn a_member_moved_between_distributions_is_listed_under_composition_changed() {
1030 let member = |name: &str, attributes: Vec<v2::Attribute>| v2::MemberLine {
1031 component: format!("veh.topology.{name}"),
1032 attributes,
1033 ..Default::default()
1034 };
1035 let distribution = |name: &str, members: Vec<v2::MemberLine>| v2::Distribution {
1036 name: name.to_string(),
1037 package: "veh.topology".to_string(),
1038 members,
1039 ..Default::default()
1040 };
1041 let signed = || vec![backend("sign", "key", Some("release"))];
1042 let mut old = base();
1043 old.components
1044 .push(component("Cluster", &["Unit"], &[], &[]));
1045 old.components.push(component("Gauge", &["Unit"], &[], &[]));
1046 old.distributions = vec![
1050 distribution(
1051 "Adas",
1052 vec![member("Cruise", signed()), member("Gauge", Vec::new())],
1053 ),
1054 distribution("Body", vec![member("Panel", Vec::new())]),
1055 distribution("Hmi", vec![member("Cluster", signed())]),
1056 ];
1057 let mut new = old.clone();
1058 new.distributions[0].members = vec![member("Panel", signed()), member("Gauge", Vec::new())];
1059 new.distributions[1].members = vec![member("Cruise", Vec::new())];
1060 assert_eq!(
1061 lines(&diff_systems(&old, &new)),
1062 [
1063 "composition changed: veh.topology.Adas/veh.topology.Panel: (absent) -> member",
1064 "composition changed: veh.topology.Adas/veh.topology.Panel/attributes: \
1065 [] -> [ sign.key = release ]",
1066 "composition changed: veh.topology.Adas/veh.topology.Cruise: member -> (removed)",
1067 "composition changed: veh.topology.Adas/veh.topology.Cruise/attributes: \
1068 [ sign.key = release ] -> []",
1069 "composition changed: veh.topology.Body/veh.topology.Cruise: (absent) -> member",
1070 "composition changed: veh.topology.Body/veh.topology.Panel: member -> (removed)",
1071 ]
1072 );
1073 assert_eq!(
1074 lines(&diff_systems(&new, &old)),
1075 [
1076 "composition changed: veh.topology.Adas/veh.topology.Cruise: (absent) -> member",
1077 "composition changed: veh.topology.Adas/veh.topology.Cruise/attributes: \
1078 [] -> [ sign.key = release ]",
1079 "composition changed: veh.topology.Adas/veh.topology.Panel: member -> (removed)",
1080 "composition changed: veh.topology.Adas/veh.topology.Panel/attributes: \
1081 [ sign.key = release ] -> []",
1082 "composition changed: veh.topology.Body/veh.topology.Panel: (absent) -> member",
1083 "composition changed: veh.topology.Body/veh.topology.Cruise: member -> (removed)",
1084 ]
1085 );
1086
1087 let mut absent = old.clone();
1091 absent.distributions.remove(0);
1092 assert_eq!(
1093 lines(&diff_systems(&absent, &old)),
1094 [
1095 "composition changed: veh.topology.Adas: (absent) -> distribution",
1096 "composition changed: veh.topology.Adas/veh.topology.Cruise: (absent) -> member",
1097 "composition changed: veh.topology.Adas/veh.topology.Cruise/attributes: \
1098 [] -> [ sign.key = release ]",
1099 "composition changed: veh.topology.Adas/veh.topology.Gauge: (absent) -> member",
1100 ]
1101 );
1102 assert_eq!(
1103 lines(&diff_systems(&old, &absent)),
1104 [
1105 "composition changed: veh.topology.Adas: distribution -> (removed)",
1106 "composition changed: veh.topology.Adas/veh.topology.Cruise: member -> (removed)",
1107 "composition changed: veh.topology.Adas/veh.topology.Cruise/attributes: \
1108 [ sign.key = release ] -> []",
1109 "composition changed: veh.topology.Adas/veh.topology.Gauge: member -> (removed)",
1110 ]
1111 );
1112 }
1113
1114 fn numbered(entries: &[(&str, &str, i32, bool)]) -> System {
1117 let mut system = base();
1118 system.components.clear();
1119 for (qualified, instance, number, provisional) in entries {
1120 let (package, name) = qualified.rsplit_once('.').expect("a qualified name");
1121 let at = system
1122 .components
1123 .iter()
1124 .position(|c| c.qualified_name() == *qualified);
1125 let at = at.unwrap_or_else(|| {
1126 let mut made = component(name, &[], &[], &[]);
1127 made.package = package.to_string();
1128 system.components.push(made);
1129 system.components.len() - 1
1130 });
1131 system.components[at].numbers.push(v2::InstanceNumber {
1132 instance: instance.to_string(),
1133 number: *number,
1134 provisional: *provisional,
1135 });
1136 }
1137 system
1138 }
1139
1140 fn listed(changes: &[SystemChange]) -> Vec<&SystemChange> {
1141 changes
1142 .iter()
1143 .filter(|c| c.heading == SystemHeading::InstanceNumberChanged)
1144 .collect()
1145 }
1146
1147 #[test]
1148 fn a_changed_locked_number_is_listed() {
1149 let changes = diff_systems(
1150 &numbered(&[("veh.C", "a", 1, false)]),
1151 &numbered(&[("veh.C", "a", 2, false)]),
1152 );
1153 let listed = listed(&changes);
1154 assert_eq!(listed.len(), 1);
1155 assert_eq!(listed[0].path, "veh.C/a");
1156 assert_eq!(
1157 (listed[0].before.as_deref(), listed[0].after.as_deref()),
1158 (Some("1"), Some("2"))
1159 );
1160 }
1161
1162 #[test]
1163 fn a_number_given_to_another_instance_is_listed() {
1164 let changes = diff_systems(
1165 &numbered(&[("veh.C", "a", 1, false)]),
1166 &numbered(&[("veh.C", "a", 2, false), ("veh.C", "b", 1, false)]),
1167 );
1168 let listed = listed(&changes);
1169 assert_eq!(listed.len(), 2);
1170 assert!(
1171 listed
1172 .iter()
1173 .any(|c| c.path == "veh.C/b" && c.before.as_deref() == Some("1 (veh.C/a)"))
1174 );
1175 }
1176
1177 #[test]
1178 fn a_renamed_instance_that_keeps_its_number_is_not_listed() {
1179 let changes = diff_systems(
1180 &numbered(&[("veh.C", "a", 1, false)]),
1181 &numbered(&[("veh.C", "b", 1, false)]),
1182 );
1183 assert!(listed(&changes).is_empty());
1184 }
1185
1186 #[test]
1187 fn a_provisional_old_number_is_not_listed() {
1188 let changes = diff_systems(
1189 &numbered(&[("veh.C", "a", 1, true)]),
1190 &numbered(&[("veh.C", "a", 2, false)]),
1191 );
1192 assert!(listed(&changes).is_empty());
1193 }
1194
1195 #[test]
1198 fn an_unchanged_locked_number_is_not_listed() {
1199 let both = numbered(&[("veh.C", "a", 1, false), ("veh.C", "b", 2, false)]);
1200 assert!(listed(&diff_systems(&both, &both)).is_empty());
1201 }
1202
1203 #[test]
1207 fn a_new_key_on_a_provisional_old_number_is_not_listed() {
1208 let changes = diff_systems(
1209 &numbered(&[("veh.C", "a", 1, true)]),
1210 &numbered(&[("veh.C", "a", 2, false), ("veh.C", "b", 1, false)]),
1211 );
1212 assert!(listed(&changes).is_empty());
1213 }
1214
1215 #[test]
1216 fn an_unchanged_system_has_no_system_change() {
1217 assert!(diff_systems(&base(), &base()).is_empty());
1218 }
1219
1220 #[test]
1223 fn a_region_hash_change_is_not_a_system_change() {
1224 let region = |hash: u8| v2::Region {
1225 catalog: "veh.adas".to_string(),
1226 interfaces: Vec::new(),
1227 hash: vec![hash; 32],
1228 };
1229 let old = System {
1230 regions: vec![region(1)],
1231 ..base()
1232 };
1233 let new = System {
1234 regions: vec![region(2)],
1235 ..base()
1236 };
1237 assert!(diff_systems(&old, &new).is_empty());
1238 }
1239
1240 #[test]
1243 fn system_doc_changes_are_not_reported() {
1244 let see = v2::DocLink {
1245 text: "Other".to_string(),
1246 target: "veh.topology.Other".to_string(),
1247 ..Default::default()
1248 };
1249 macro_rules! document {
1250 ($carrier:expr) => {{
1251 let carrier = &mut $carrier;
1252 carrier.doc = "documented".to_string();
1253 carrier.links.push(see.clone());
1254 carrier.see.push(see.clone());
1255 carrier.since.push("1.2".to_string());
1256 }};
1257 }
1258 let mut old = base();
1259 old.members.push(v2::MemberLine {
1260 component: "veh.topology.Cruise".to_string(),
1261 ..Default::default()
1262 });
1263 old.distributions.push(v2::Distribution {
1264 name: "Adas".to_string(),
1265 package: "veh.topology".to_string(),
1266 members: old.members.clone(),
1267 ..Default::default()
1268 });
1269 let mut new = old.clone();
1270 document!(new);
1271 document!(new.members[0]);
1272 document!(new.components[0]);
1273 document!(new.components[0].offers[0]);
1274 document!(new.components[0].requires[0]);
1275 document!(new.distributions[0]);
1276 document!(new.distributions[0].members[0]);
1277 let deployment = &mut new.deployments[0];
1278 deployment.doc = "documented".to_string();
1279 deployment.doc_links.push(see.clone());
1280 deployment.see.push(see.clone());
1281 deployment.since.push("1.2".to_string());
1282 document!(new.deployments[0].machines[0]);
1283 assert_ne!(old, new);
1284 assert!(diff_systems(&old, &new).is_empty());
1285 }
1286
1287 #[test]
1290 fn system_sizing_changes_are_not_reported() {
1291 let sized = |depth: u32, slots: u32, budget: u64| v2::Sizing {
1292 depth: Some(depth),
1293 slots: Some(slots),
1294 budget: Some(budget),
1295 };
1296 let mut old = base();
1297 old.deployments[0].sizing = Some(sized(3, 16, 4096));
1298 old.deployments[0].placements[0].sizing = Some(sized(3, 16, 4096));
1299 let mut new = old.clone();
1300 new.deployments[0].sizing = Some(sized(300, 64, 65536));
1301 new.deployments[0].placements[0].sizing = Some(sized(300, 64, 65536));
1302 assert_ne!(old, new);
1303 assert!(diff_systems(&old, &new).is_empty());
1304
1305 assert!(diff_systems(&base(), &new).is_empty());
1308 assert!(diff_systems(&new, &base()).is_empty());
1309 }
1310
1311 #[test]
1315 fn system_changes_render_under_their_headings_with_no_verdict() {
1316 let old = base();
1317 let mut new = base();
1318 new.deployments[0].placements[1].machine = "AdasHpc".to_string();
1319 new.components[1].external = true;
1320
1321 let report = crate::diff_workspaces(&[], Some(&old), &[], Some(&new), &[]);
1322 assert_eq!(report.verdict, crate::Verdict::Identical);
1323 assert_eq!(
1324 crate::render_text(&report),
1325 "identical\n\
1326 placement changed\n\
1327 \x20 Production/veh.topology.Cruise.backup: Cockpit -> AdasHpc\n\
1328 composition changed\n\
1329 \x20 veh.topology.Panel/external: false -> true\n"
1330 );
1331 let json: serde_json::Value =
1332 serde_json::from_str(&crate::render_json(&report)).expect("the report is JSON");
1333 assert_eq!(
1334 json["placement_changed"],
1335 serde_json::json!([{
1336 "path": "Production/veh.topology.Cruise.backup",
1337 "before": "Cockpit",
1338 "after": "AdasHpc"
1339 }])
1340 );
1341 assert_eq!(json["composition_changed"][0]["after"], "true");
1342
1343 let one_side = crate::diff_workspaces(&[], None, &[], Some(&new), &[]);
1344 assert!(one_side.system.is_empty());
1345 let json: serde_json::Value =
1346 serde_json::from_str(&crate::render_json(&one_side)).expect("the report is JSON");
1347 assert_eq!(
1348 json,
1349 serde_json::json!({ "verdict": "identical", "changes": [] })
1350 );
1351 }
1352
1353 #[test]
1357 fn a_deployment_change_is_listed_under_placement_changed() {
1358 let old = base();
1359 let mut new = base();
1360 new.deployments[0].placements[1].machine = "AdasHpc".to_string();
1361 new.deployments[0].machines[1].external = true;
1362 new.deployments[0].machines.push(machine("Cloud", true));
1363 new.deployments.push(Deployment {
1364 name: "Bench".to_string(),
1365 ..Deployment::default()
1366 });
1367 assert_eq!(
1368 lines(&diff_systems(&old, &new)),
1369 [
1370 "placement changed: Production/Cockpit/external: false -> true",
1371 "placement changed: Production/Cloud: (absent) -> machine",
1372 "placement changed: Production/veh.topology.Cruise.backup: Cockpit -> AdasHpc",
1373 "placement changed: Bench: (absent) -> deployment",
1374 ]
1375 );
1376 assert_eq!(
1377 lines(&diff_systems(&new, &old)),
1378 [
1379 "placement changed: Production/Cockpit/external: true -> false",
1380 "placement changed: Production/Cloud: machine -> (removed)",
1381 "placement changed: Production/veh.topology.Cruise.backup: AdasHpc -> Cockpit",
1382 "placement changed: Bench: deployment -> (removed)",
1383 ]
1384 );
1385 }
1386
1387 #[test]
1391 fn a_closure_or_line_change_is_listed_under_composition_changed() {
1392 let old = base();
1393 let mut new = base();
1394 new.components[0].instances = vec!["Unit".to_string()];
1395 new.components[0].requires.clear();
1396 new.components[1].external = true;
1397 new.components[1].offers.push(v2::Offer {
1398 service: "veh.hmi.panel".to_string(),
1399 attributes: Vec::new(),
1400 doc: String::new(),
1401 links: Vec::new(),
1402 see: Vec::new(),
1403 since: Vec::new(),
1404 });
1405 new.components
1406 .push(component("Lane", &["Unit"], &["veh.adas.lane"], &[]));
1407 assert_eq!(
1408 lines(&diff_systems(&old, &new)),
1409 [
1410 "composition changed: veh.topology.Cruise/requires/veh.adas.LaneAssist: \
1411 requires veh.adas.LaneAssist -> (removed)",
1412 "composition changed: veh.topology.Cruise/instances: (primary, backup) -> (Unit)",
1413 "composition changed: veh.topology.Panel/offers/veh.hmi.panel: \
1414 (absent) -> offers veh.hmi.panel",
1415 "composition changed: veh.topology.Panel/external: false -> true",
1416 "composition changed: veh.topology.Lane: (absent) -> in the closure",
1417 ]
1418 );
1419 assert_eq!(
1420 lines(&diff_systems(&new, &old)).last().map(String::as_str),
1421 Some("composition changed: veh.topology.Lane: in the closure -> (removed)")
1422 );
1423 }
1424}