1use std::collections::{BTreeMap, BTreeSet};
14
15use serde::Serialize;
16
17use crate::model::{Node, Pool, Profile, Strength, is_variant};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
20#[serde(rename_all = "lowercase")]
21pub enum Severity {
22 Info,
23 Warning,
24 Breaking,
25}
26
27impl Severity {
28 pub fn as_str(self) -> &'static str {
29 match self {
30 Self::Info => "info",
31 Self::Warning => "warning",
32 Self::Breaking => "breaking",
33 }
34 }
35}
36
37pub const RULES: &[(&str, Severity, &str)] = &[
39 (
40 "profile-removed",
41 Severity::Breaking,
42 "A profile or extension definition was removed",
43 ),
44 (
45 "profile-added",
46 Severity::Info,
47 "A profile or extension definition was added",
48 ),
49 (
50 "profile-replaced",
51 Severity::Breaking,
52 "A profile gave way to one with another canonical URL: each is the only one of its type removed or added",
53 ),
54 (
55 "base-changed",
56 Severity::Warning,
57 "The profile is based on a different definition",
58 ),
59 (
60 "min-increased",
61 Severity::Breaking,
62 "The minimum cardinality went up",
63 ),
64 (
65 "min-decreased",
66 Severity::Info,
67 "The minimum cardinality went down",
68 ),
69 (
70 "max-decreased",
71 Severity::Breaking,
72 "The maximum cardinality went down",
73 ),
74 (
75 "max-increased",
76 Severity::Info,
77 "The maximum cardinality went up",
78 ),
79 (
80 "type-removed",
81 Severity::Breaking,
82 "A type the element allowed is no longer allowed",
83 ),
84 (
85 "type-added",
86 Severity::Info,
87 "The element allows another type",
88 ),
89 (
90 "target-removed",
91 Severity::Breaking,
92 "A reference target type is no longer allowed",
93 ),
94 (
95 "target-narrowed",
96 Severity::Warning,
97 "A reference target was narrowed to a profile of the same type",
98 ),
99 (
100 "target-added",
101 Severity::Info,
102 "A reference may point to another profile",
103 ),
104 (
105 "must-support-added",
106 Severity::Breaking,
107 "The element became mustSupport",
108 ),
109 (
110 "must-support-removed",
111 Severity::Info,
112 "The element is no longer mustSupport",
113 ),
114 (
115 "binding-stronger",
116 Severity::Breaking,
117 "The binding became extensible or required, or required from extensible",
118 ),
119 (
120 "binding-stronger-advisory",
121 Severity::Info,
122 "The binding strength went up but stays advisory (example → preferred)",
123 ),
124 (
125 "binding-weaker",
126 Severity::Info,
127 "The binding strength went down",
128 ),
129 (
130 "binding-added",
131 Severity::Breaking,
132 "A required or extensible binding was added",
133 ),
134 (
135 "value-set-changed",
136 Severity::Warning,
137 "A required or extensible binding names another value set",
138 ),
139 (
140 "invariant-added",
141 Severity::Breaking,
142 "An error invariant was added",
143 ),
144 (
145 "invariant-warning-added",
146 Severity::Warning,
147 "A warning invariant was added",
148 ),
149 (
150 "invariant-changed",
151 Severity::Warning,
152 "An invariant's expression changed",
153 ),
154 (
155 "invariant-moved",
156 Severity::Info,
157 "An invariant moved to another element and says the same from there",
158 ),
159 (
160 "invariant-removed",
161 Severity::Info,
162 "An invariant was removed",
163 ),
164 (
165 "fixed-added",
166 Severity::Breaking,
167 "A fixed or pattern value was added",
168 ),
169 (
170 "fixed-changed",
171 Severity::Breaking,
172 "The fixed or pattern value changed",
173 ),
174 (
175 "fixed-removed",
176 Severity::Info,
177 "The fixed or pattern value was removed",
178 ),
179 (
180 "slicing-closed",
181 Severity::Breaking,
182 "Slicing became closed",
183 ),
184 ("slicing-opened", Severity::Info, "Slicing became open"),
185 ("slice-added", Severity::Info, "An optional slice was added"),
186 ("slice-removed", Severity::Warning, "A slice was removed"),
187 (
188 "modifier-changed",
189 Severity::Warning,
190 "The isModifier flag changed",
191 ),
192];
193
194pub fn severity_of(rule: &str) -> Severity {
195 RULES
196 .iter()
197 .find(|(id, _, _)| *id == rule)
198 .map_or(Severity::Info, |(_, severity, _)| *severity)
199}
200
201#[derive(Debug, Clone, PartialEq, Serialize)]
203pub struct Change {
204 pub rule: &'static str,
205 pub severity: Severity,
206 pub path: String,
208 pub message: String,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 pub old: Option<String>,
211 #[serde(skip_serializing_if = "Option::is_none")]
212 pub new: Option<String>,
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
216#[serde(rename_all = "lowercase")]
217pub enum Status {
218 Added,
219 Removed,
220 Changed,
221 Unchanged,
222}
223
224#[derive(Debug, Clone, Serialize)]
226pub struct ProfileDiff {
227 pub url: String,
228 pub name: String,
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub title: Option<String>,
231 pub type_name: String,
232 pub status: Status,
233 #[serde(skip_serializing_if = "Option::is_none")]
235 pub replaces: Option<String>,
236 #[serde(skip_serializing_if = "Option::is_none")]
237 pub old_version: Option<String>,
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub new_version: Option<String>,
240 pub file: String,
242 pub changes: Vec<Change>,
243}
244
245impl ProfileDiff {
246 pub fn worst(&self) -> Option<Severity> {
247 self.changes.iter().map(|c| c.severity).max()
248 }
249}
250
251#[derive(Debug, Clone, Serialize)]
253pub struct Report {
254 pub old: String,
255 pub new: String,
256 pub profiles: Vec<ProfileDiff>,
257 #[serde(skip_serializing_if = "Vec::is_empty")]
260 pub notes: Vec<String>,
261}
262
263impl Report {
264 pub fn count(&self, severity: Severity) -> usize {
265 self.profiles
266 .iter()
267 .flat_map(|p| &p.changes)
268 .filter(|c| c.severity == severity)
269 .count()
270 }
271
272 pub fn worst(&self) -> Option<Severity> {
273 self.profiles.iter().filter_map(ProfileDiff::worst).max()
274 }
275}
276
277#[derive(Debug, Clone, Default)]
279pub struct Filter {
280 pub profiles: Vec<String>,
282}
283
284impl Filter {
285 fn admits(&self, profile: &Profile) -> bool {
286 self.profiles.is_empty()
287 || self
288 .profiles
289 .iter()
290 .any(|p| p == &profile.url || p == &profile.name)
291 }
292}
293
294pub fn diff(old_id: &str, old: &Pool, new_id: &str, new: &Pool, filter: &Filter) -> Report {
296 let (old_all, new_all) = (old.profiles(), new.profiles());
297 let chosen: BTreeSet<String> = old_all
300 .iter()
301 .chain(&new_all)
302 .filter(|p| filter.admits(p))
303 .map(|p| p.url.clone())
304 .collect();
305 let keep = |profiles: Vec<Profile>| -> BTreeMap<String, Profile> {
306 profiles
307 .into_iter()
308 .filter(|p| chosen.contains(&p.url))
309 .map(|p| (p.url.clone(), p))
310 .collect()
311 };
312 let (old_profiles, new_profiles) = (keep(old_all), keep(new_all));
313 let urls: BTreeSet<&String> = old_profiles.keys().chain(new_profiles.keys()).collect();
314 let replacements = replacements(&old_profiles, &new_profiles);
315 let replaced: BTreeSet<&String> = replacements.values().collect();
316 let mut profiles = Vec::new();
317 for url in urls {
318 if replaced.contains(url) {
319 continue;
320 }
321 let predecessor = replacements
322 .get(url)
323 .and_then(|previous| old_profiles.get(previous));
324 let entry = match (predecessor.or(old_profiles.get(url)), new_profiles.get(url)) {
325 (Some(o), Some(n)) => {
326 let mut changes = Vec::new();
327 if o.url != n.url {
328 changes.push(change(
329 "profile-replaced",
330 &n.type_name,
331 &format!("Takes over from {}", o.url),
332 Some(o.url.clone()),
333 Some(n.url.clone()),
334 ));
335 }
336 if o.base != n.base {
337 changes.push(change(
338 "base-changed",
339 &n.type_name,
340 "The profile is based on another definition",
341 o.base.clone(),
342 n.base.clone(),
343 ));
344 }
345 let mut walker = Walker {
346 old,
347 new,
348 changes: &mut changes,
349 invariants_added: Vec::new(),
350 invariants_removed: Vec::new(),
351 };
352 walker.node(&n.type_name, &n.type_name, &o.tree, &n.tree);
353 let (added, removed) = (walker.invariants_added, walker.invariants_removed);
354 fold_moved_invariants(&mut changes, &added, &removed);
355 ProfileDiff {
356 url: url.clone(),
357 name: n.name.clone(),
358 title: n.title.clone(),
359 type_name: n.type_name.clone(),
360 status: if changes.is_empty() {
361 Status::Unchanged
362 } else {
363 Status::Changed
364 },
365 replaces: (o.url != n.url).then(|| o.url.clone()),
366 old_version: o.version.clone(),
367 new_version: n.version.clone(),
368 file: n.file.clone(),
369 changes,
370 }
371 }
372 (Some(o), None) => ProfileDiff {
373 url: url.clone(),
374 name: o.name.clone(),
375 title: o.title.clone(),
376 type_name: o.type_name.clone(),
377 status: Status::Removed,
378 replaces: None,
379 old_version: o.version.clone(),
380 new_version: None,
381 file: o.file.clone(),
382 changes: vec![change(
383 "profile-removed",
384 &o.type_name,
385 "The profile is not in the new package",
386 Some(url.clone()),
387 None,
388 )],
389 },
390 (None, Some(n)) => ProfileDiff {
391 url: url.clone(),
392 name: n.name.clone(),
393 title: n.title.clone(),
394 type_name: n.type_name.clone(),
395 status: Status::Added,
396 replaces: None,
397 old_version: None,
398 new_version: n.version.clone(),
399 file: n.file.clone(),
400 changes: vec![change(
401 "profile-added",
402 &n.type_name,
403 "The profile is new",
404 None,
405 Some(url.clone()),
406 )],
407 },
408 (None, None) => continue,
409 };
410 profiles.push(entry);
411 }
412 let notes = old
413 .skipped
414 .iter()
415 .map(|(file, reason)| format!("{old_id}: {file}: {reason}"))
416 .chain(
417 new.skipped
418 .iter()
419 .map(|(file, reason)| format!("{new_id}: {file}: {reason}")),
420 )
421 .chain(old.missing.borrow().iter().map(|base| {
422 format!("{old_id}: base {base} is in no loaded package; compared over the core type")
423 }))
424 .chain(new.missing.borrow().iter().map(|base| {
425 format!("{new_id}: base {base} is in no loaded package; compared over the core type")
426 }))
427 .chain(old.borrowed.iter().map(|base| {
428 format!("{old_id}: base {base} is in no listed dependency; taken from {new_id}'s")
429 }))
430 .chain(new.borrowed.iter().map(|base| {
431 format!("{new_id}: base {base} is in no listed dependency; taken from {old_id}'s")
432 }))
433 .collect();
434 Report {
435 old: old_id.to_owned(),
436 new: new_id.to_owned(),
437 profiles,
438 notes,
439 }
440}
441
442fn replacements(
447 old: &BTreeMap<String, Profile>,
448 new: &BTreeMap<String, Profile>,
449) -> BTreeMap<String, String> {
450 let only_here = |side: &'_ BTreeMap<String, Profile>, other: &BTreeMap<String, Profile>| {
451 let mut by_type: BTreeMap<String, Vec<String>> = BTreeMap::new();
452 for profile in side.values() {
453 if !other.contains_key(&profile.url) && profile.type_name != "Extension" {
454 by_type
455 .entry(profile.type_name.clone())
456 .or_default()
457 .push(profile.url.clone());
458 }
459 }
460 by_type
461 };
462 let (removed, added) = (only_here(old, new), only_here(new, old));
463 removed
464 .iter()
465 .filter_map(
466 |(type_name, urls)| match (urls.as_slice(), added.get(type_name)?.as_slice()) {
467 ([previous], [next]) => Some((next.clone(), previous.clone())),
468 _ => None,
469 },
470 )
471 .collect()
472}
473
474struct InvariantSite {
476 key: String,
477 severity: String,
478 index: usize,
480}
481
482fn fold_moved_invariants(
485 changes: &mut Vec<Change>,
486 added: &[InvariantSite],
487 removed: &[InvariantSite],
488) {
489 let mut folded = BTreeSet::new();
490 for site in added {
491 let Some(from) = removed
492 .iter()
493 .find(|r| r.key == site.key && !folded.contains(&r.index))
494 else {
495 continue;
496 };
497 let origin = changes[from.index].clone();
498 let (old_expression, arrival) = (origin.old.unwrap_or_default(), &mut changes[site.index]);
499 let new_expression = arrival.new.clone().unwrap_or_default();
500 let same = from.severity == site.severity
501 && same_after_move(
502 &origin.path,
503 &old_expression,
504 &arrival.path,
505 &new_expression,
506 );
507 let (rule, message) = if same {
508 (
509 "invariant-moved",
510 format!("Invariant {} moved from {}", site.key, origin.path),
511 )
512 } else {
513 (
514 "invariant-changed",
515 format!(
516 "Invariant {} moved from {} and changed",
517 site.key, origin.path
518 ),
519 )
520 };
521 arrival.rule = rule;
522 arrival.severity = severity_of(rule);
523 arrival.message = message;
524 arrival.old = Some(old_expression);
525 folded.insert(from.index);
526 }
527 let mut index = 0;
528 changes.retain(|_| {
529 index += 1;
530 !folded.contains(&(index - 1))
531 });
532}
533
534fn same_after_move(from: &str, old: &str, to: &str, new: &str) -> bool {
537 match (to.strip_prefix(from), from.strip_prefix(to)) {
538 (Some(tail), _) if tail.starts_with('.') => relative_to(old, &tail[1..]) == new,
539 (_, Some(tail)) if tail.starts_with('.') => relative_to(new, &tail[1..]) == old,
540 _ => old == new,
541 }
542}
543
544fn relative_to(expression: &str, element: &str) -> String {
546 let prefix = format!("{element}.");
547 let mut result = String::new();
548 let mut rest = expression;
549 while let Some(at) = rest.find(&prefix) {
550 result.push_str(&rest[..at]);
551 let inside_a_path = result
552 .chars()
553 .next_back()
554 .is_some_and(|c| c.is_alphanumeric() || c == '.' || c == '_');
555 if inside_a_path {
556 result.push_str(&prefix);
557 }
558 rest = &rest[at + prefix.len()..];
559 }
560 result.push_str(rest);
561 result
562}
563
564fn change(
565 rule: &'static str,
566 path: &str,
567 message: &str,
568 old: Option<String>,
569 new: Option<String>,
570) -> Change {
571 Change {
572 rule,
573 severity: severity_of(rule),
574 path: path.to_owned(),
575 message: message.to_owned(),
576 old,
577 new,
578 }
579}
580
581fn cardinality(max: Option<u32>) -> String {
582 max.map_or_else(|| "*".to_owned(), |m| m.to_string())
583}
584
585struct Walker<'a> {
586 old: &'a Pool,
587 new: &'a Pool,
588 changes: &'a mut Vec<Change>,
589 invariants_added: Vec<InvariantSite>,
590 invariants_removed: Vec<InvariantSite>,
591}
592
593impl Walker<'_> {
594 fn push(
595 &mut self,
596 rule: &'static str,
597 path: &str,
598 message: String,
599 old: Option<String>,
600 new: Option<String>,
601 ) {
602 self.changes.push(Change {
603 rule,
604 severity: severity_of(rule),
605 path: path.to_owned(),
606 message,
607 old,
608 new,
609 });
610 }
611
612 fn node(&mut self, path: &str, type_name: &str, old: &Node, new: &Node) {
615 self.element(path, type_name, old, new, false, &BTreeMap::new());
616 }
617
618 fn element(
623 &mut self,
624 path: &str,
625 type_name: &str,
626 old: &Node,
627 new: &Node,
628 variant: bool,
629 old_siblings: &BTreeMap<String, Node>,
630 ) {
631 if !variant {
632 self.cardinality(path, old, new);
633 self.types(path, old, new);
634 self.flags(path, old, new);
635 self.constraints(path, old, new);
636 self.slicing(path, old, new);
637 }
638 self.binding(path, old, new);
639 self.pattern(path, old, new);
640
641 let names: BTreeSet<&String> = old.children.keys().chain(new.children.keys()).collect();
642 for name in names {
643 let child_path = format!("{path}.{name}");
644 let (o, n) = (old.children.get(name), new.children.get(name));
645 let child_type = n
646 .or(o)
647 .and_then(|c| (c.types.len() == 1).then(|| c.types.iter().next().cloned()))
648 .flatten()
649 .unwrap_or_default();
650 let variant = is_variant(&new.children, name) || is_variant(&old.children, name);
651 match (o, n) {
652 (Some(o), Some(n)) => {
653 if variant && choice_settled(old, new, name) {
654 self.flags(&child_path, o, n);
655 }
656 self.element(&child_path, &child_type, o, n, variant, &old.children);
657 }
658 (Some(o), None) => {
659 let base = self.base(type_name, name, o);
660 self.element(&child_path, &child_type, o, &base, variant, &old.children);
661 }
662 (None, Some(n)) => {
663 let base = self.base(type_name, name, n);
664 self.element(&child_path, &child_type, &base, n, variant, &old.children);
665 }
666 (None, None) => {}
667 }
668 }
669
670 if variant {
671 return;
672 }
673 let slices: BTreeSet<&String> = old.slices.keys().chain(new.slices.keys()).collect();
674 for slice in slices {
675 let slice_path = format!("{path}:{slice}");
676 match (old.slices.get(slice), new.slices.get(slice)) {
677 (Some(o), Some(n)) => self.node(&slice_path, type_name, o, n),
678 (Some(_), None) => self.push(
679 "slice-removed",
680 &slice_path,
681 format!("Slice {slice} was removed"),
682 Some(slice.clone()),
683 None,
684 ),
685 (None, Some(n)) => {
686 if n.min > 0 {
687 self.push(
688 "min-increased",
689 &slice_path,
690 format!("Required slice {slice} was added (min {})", n.min),
691 Some("0".into()),
692 Some(n.min.to_string()),
693 );
694 } else {
695 self.push(
696 "slice-added",
697 &slice_path,
698 format!("Optional slice {slice} was added"),
699 None,
700 Some(slice.clone()),
701 );
702 }
703 let supported = old_siblings.get(slice).is_some_and(|v| v.must_support);
706 if n.must_support && !supported {
707 self.push(
708 "must-support-added",
709 &slice_path,
710 format!("New slice {slice} is mustSupport"),
711 None,
712 Some("true".into()),
713 );
714 }
715 }
716 (None, None) => {}
717 }
718 }
719 }
720
721 fn base(&self, type_name: &str, name: &str, present: &Node) -> Node {
724 let core = self
725 .new
726 .base_node(type_name, name)
727 .or_else(|| self.old.base_node(type_name, name));
728 match core {
729 Some(mut base) => {
730 base.children.clear();
732 base
733 }
734 None => Node {
737 types: present.types.clone(),
738 max: present.max,
739 targets: present.targets.clone(),
740 ..Node::unconstrained()
741 },
742 }
743 }
744
745 fn cardinality(&mut self, path: &str, old: &Node, new: &Node) {
746 if new.min > old.min {
747 self.push(
748 "min-increased",
749 path,
750 format!("min {} → {}", old.min, new.min),
751 Some(old.min.to_string()),
752 Some(new.min.to_string()),
753 );
754 } else if new.min < old.min {
755 self.push(
756 "min-decreased",
757 path,
758 format!("min {} → {}", old.min, new.min),
759 Some(old.min.to_string()),
760 Some(new.min.to_string()),
761 );
762 }
763 let narrower = match (old.max, new.max) {
764 (None, Some(_)) => Some(true),
765 (Some(_), None) => Some(false),
766 (Some(a), Some(b)) if b < a => Some(true),
767 (Some(a), Some(b)) if b > a => Some(false),
768 _ => None,
769 };
770 if let Some(narrower) = narrower {
771 self.push(
772 if narrower {
773 "max-decreased"
774 } else {
775 "max-increased"
776 },
777 path,
778 format!("max {} → {}", cardinality(old.max), cardinality(new.max)),
779 Some(cardinality(old.max)),
780 Some(cardinality(new.max)),
781 );
782 }
783 }
784
785 fn types(&mut self, path: &str, old: &Node, new: &Node) {
786 if !old.types.is_empty() && !new.types.is_empty() {
788 for removed in old.types.difference(&new.types) {
789 self.push(
790 "type-removed",
791 path,
792 format!("Type {removed} is no longer allowed"),
793 Some(removed.clone()),
794 None,
795 );
796 }
797 for added in new.types.difference(&old.types) {
798 self.push(
799 "type-added",
800 path,
801 format!("Type {added} is allowed"),
802 None,
803 Some(added.clone()),
804 );
805 }
806 }
807 if old.targets.is_empty() && !new.targets.is_empty() {
808 self.push(
809 "target-removed",
810 path,
811 format!("References are restricted to {}", join(&new.targets)),
812 None,
813 Some(join(&new.targets)),
814 );
815 } else if !new.targets.is_empty() {
816 let mut added: Vec<&String> = new.targets.difference(&old.targets).collect();
817 for removed in old.targets.difference(&new.targets) {
818 let removed_type = self.old.type_of(removed);
819 let narrowed = added
821 .iter()
822 .position(|a| removed_type.is_some() && self.new.type_of(a) == removed_type);
823 match narrowed {
824 Some(index) => {
825 let to = added.remove(index);
826 self.push(
827 "target-narrowed",
828 path,
829 format!("Target {removed} narrowed to {to}"),
830 Some(removed.clone()),
831 Some(to.clone()),
832 );
833 }
834 None => self.push(
835 "target-removed",
836 path,
837 format!("Target {removed} is no longer allowed"),
838 Some(removed.clone()),
839 None,
840 ),
841 }
842 }
843 for added in added {
844 self.push(
845 "target-added",
846 path,
847 format!("Target {added} is allowed"),
848 None,
849 Some(added.clone()),
850 );
851 }
852 }
853 }
854
855 fn flags(&mut self, path: &str, old: &Node, new: &Node) {
856 match (old.must_support, new.must_support) {
857 (false, true) => self.push(
858 "must-support-added",
859 path,
860 "Became mustSupport".into(),
861 Some("false".into()),
862 Some("true".into()),
863 ),
864 (true, false) => self.push(
865 "must-support-removed",
866 path,
867 "No longer mustSupport".into(),
868 Some("true".into()),
869 Some("false".into()),
870 ),
871 _ => {}
872 }
873 if !path.contains('.') && old.is_modifier != new.is_modifier {
876 self.push(
877 "modifier-changed",
878 path,
879 format!("isModifier {} → {}", old.is_modifier, new.is_modifier),
880 Some(old.is_modifier.to_string()),
881 Some(new.is_modifier.to_string()),
882 );
883 }
884 }
885
886 fn binding(&mut self, path: &str, old: &Node, new: &Node) {
887 let describe = |b: &crate::model::Binding| {
888 format!(
889 "{} {}",
890 b.strength.as_str(),
891 b.value_set.as_deref().unwrap_or("(no value set)")
892 )
893 };
894 match (&old.binding, &new.binding) {
895 (None, Some(n)) if n.strength >= Strength::Extensible => {
896 self.push(
897 "binding-added",
898 path,
899 format!("Bound {}", describe(n)),
900 None,
901 Some(describe(n)),
902 );
903 }
904 (Some(o), Some(n)) => {
905 if n.strength > o.strength {
906 let rule = if n.strength >= Strength::Extensible {
908 "binding-stronger"
909 } else {
910 "binding-stronger-advisory"
911 };
912 self.push(
913 rule,
914 path,
915 format!("{} → {}", o.strength.as_str(), n.strength.as_str()),
916 Some(describe(o)),
917 Some(describe(n)),
918 );
919 } else if n.strength < o.strength {
920 self.push(
921 "binding-weaker",
922 path,
923 format!("{} → {}", o.strength.as_str(), n.strength.as_str()),
924 Some(describe(o)),
925 Some(describe(n)),
926 );
927 }
928 let plain = |vs: &Option<String>| {
929 vs.as_deref()
930 .map(|v| v.split('|').next().unwrap_or(v).to_owned())
931 };
932 if plain(&o.value_set) != plain(&n.value_set) && n.strength >= Strength::Extensible
933 {
934 self.push(
935 "value-set-changed",
936 path,
937 format!(
938 "Value set {} → {}",
939 o.value_set.as_deref().unwrap_or("-"),
940 n.value_set.as_deref().unwrap_or("-")
941 ),
942 o.value_set.clone(),
943 n.value_set.clone(),
944 );
945 }
946 }
947 _ => {}
948 }
949 }
950
951 fn constraints(&mut self, path: &str, old: &Node, new: &Node) {
952 for (key, constraint) in &new.constraints {
953 match old.constraints.get(key) {
954 None => {
955 let rule = if constraint.severity == "error" {
956 "invariant-added"
957 } else {
958 "invariant-warning-added"
959 };
960 self.invariants_added.push(InvariantSite {
961 key: key.clone(),
962 severity: constraint.severity.clone(),
963 index: self.changes.len(),
964 });
965 self.push(
966 rule,
967 path,
968 format!("Invariant {key}: {}", constraint.human),
969 None,
970 Some(constraint.expression.clone()),
971 );
972 }
973 Some(previous) if previous.expression != constraint.expression => {
974 self.push(
975 "invariant-changed",
976 path,
977 format!("Invariant {key} changed"),
978 Some(previous.expression.clone()),
979 Some(constraint.expression.clone()),
980 );
981 }
982 Some(_) => {}
983 }
984 }
985 for (key, constraint) in &old.constraints {
986 if !new.constraints.contains_key(key) {
987 self.invariants_removed.push(InvariantSite {
988 key: key.clone(),
989 severity: constraint.severity.clone(),
990 index: self.changes.len(),
991 });
992 self.push(
993 "invariant-removed",
994 path,
995 format!("Invariant {key} was removed"),
996 Some(constraint.expression.clone()),
997 None,
998 );
999 }
1000 }
1001 }
1002
1003 fn pattern(&mut self, path: &str, old: &Node, new: &Node) {
1004 let text = |v: &serde_json::Value| v.to_string();
1005 match (&old.pattern, &new.pattern) {
1006 (None, Some(n)) => self.push(
1007 "fixed-added",
1008 path,
1009 format!("Fixed/pattern {}", text(n)),
1010 None,
1011 Some(text(n)),
1012 ),
1013 (Some(o), None) => self.push(
1014 "fixed-removed",
1015 path,
1016 "Fixed/pattern value removed".into(),
1017 Some(text(o)),
1018 None,
1019 ),
1020 (Some(o), Some(n)) if o != n => self.push(
1021 "fixed-changed",
1022 path,
1023 "Fixed/pattern value changed".into(),
1024 Some(text(o)),
1025 Some(text(n)),
1026 ),
1027 _ => {}
1028 }
1029 }
1030
1031 fn slicing(&mut self, path: &str, old: &Node, new: &Node) {
1032 let rules = |node: &Node| node.slicing.as_ref().map(|s| s.rules.clone());
1033 match (rules(old).as_deref(), rules(new).as_deref()) {
1034 (Some("closed"), Some(r)) if r != "closed" => self.push(
1035 "slicing-opened",
1036 path,
1037 format!("Slicing closed → {r}"),
1038 Some("closed".into()),
1039 Some(r.to_owned()),
1040 ),
1041 (o, Some("closed")) if o != Some("closed") => self.push(
1042 "slicing-closed",
1043 path,
1044 format!("Slicing {} → closed", o.unwrap_or("none")),
1045 o.map(str::to_owned),
1046 Some("closed".into()),
1047 ),
1048 _ => {}
1049 }
1050 }
1051}
1052
1053fn choice_settled(old: &Node, new: &Node, variant: &str) -> bool {
1056 let choice = |parent: &'_ Node| {
1057 parent
1058 .children
1059 .values()
1060 .find(|c| c.types.contains(variant))
1061 .map(|c| (c.must_support, c.slices.is_empty()))
1062 };
1063 matches!(
1064 (choice(old), choice(new)),
1065 (Some((before, true)), Some((after, true))) if before == after
1066 )
1067}
1068
1069fn join(set: &BTreeSet<String>) -> String {
1070 set.iter().cloned().collect::<Vec<_>>().join(", ")
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use super::*;
1076
1077 #[test]
1078 fn an_expression_reads_the_same_from_a_child_element() {
1079 let old = "value.ofType(Quantity).system.empty() or value.ofType(Quantity).system = 'u'";
1080 let new = "ofType(Quantity).system.empty() or ofType(Quantity).system = 'u'";
1081 assert!(same_after_move(
1082 "Observation",
1083 old,
1084 "Observation.value",
1085 new
1086 ));
1087 assert!(same_after_move(
1088 "Observation.value",
1089 new,
1090 "Observation",
1091 old
1092 ));
1093 assert!(!same_after_move(
1095 "Observation",
1096 old,
1097 "Observation.value",
1098 old
1099 ));
1100 }
1101
1102 #[test]
1103 fn only_paths_that_start_with_the_element_are_rewritten() {
1104 assert_eq!(
1105 relative_to("a.value.b or value.c", "value"),
1106 "a.value.b or c"
1107 );
1108 assert_eq!(relative_to("myvalue.x", "value"), "myvalue.x");
1109 }
1110}