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 "base-changed",
51 Severity::Warning,
52 "The profile is based on a different definition",
53 ),
54 (
55 "min-increased",
56 Severity::Breaking,
57 "The minimum cardinality went up",
58 ),
59 (
60 "min-decreased",
61 Severity::Info,
62 "The minimum cardinality went down",
63 ),
64 (
65 "max-decreased",
66 Severity::Breaking,
67 "The maximum cardinality went down",
68 ),
69 (
70 "max-increased",
71 Severity::Info,
72 "The maximum cardinality went up",
73 ),
74 (
75 "type-removed",
76 Severity::Breaking,
77 "A type the element allowed is no longer allowed",
78 ),
79 (
80 "type-added",
81 Severity::Info,
82 "The element allows another type",
83 ),
84 (
85 "target-removed",
86 Severity::Breaking,
87 "A reference target type is no longer allowed",
88 ),
89 (
90 "target-narrowed",
91 Severity::Warning,
92 "A reference target was narrowed to a profile of the same type",
93 ),
94 (
95 "target-added",
96 Severity::Info,
97 "A reference may point to another profile",
98 ),
99 (
100 "must-support-added",
101 Severity::Breaking,
102 "The element became mustSupport",
103 ),
104 (
105 "must-support-removed",
106 Severity::Info,
107 "The element is no longer mustSupport",
108 ),
109 (
110 "binding-stronger",
111 Severity::Breaking,
112 "The binding became extensible or required, or required from extensible",
113 ),
114 (
115 "binding-stronger-advisory",
116 Severity::Info,
117 "The binding strength went up but stays advisory (example → preferred)",
118 ),
119 (
120 "binding-weaker",
121 Severity::Info,
122 "The binding strength went down",
123 ),
124 (
125 "binding-added",
126 Severity::Breaking,
127 "A required or extensible binding was added",
128 ),
129 (
130 "value-set-changed",
131 Severity::Warning,
132 "A required or extensible binding names another value set",
133 ),
134 (
135 "invariant-added",
136 Severity::Breaking,
137 "An error invariant was added",
138 ),
139 (
140 "invariant-warning-added",
141 Severity::Warning,
142 "A warning invariant was added",
143 ),
144 (
145 "invariant-changed",
146 Severity::Warning,
147 "An invariant's expression changed",
148 ),
149 (
150 "invariant-removed",
151 Severity::Info,
152 "An invariant was removed",
153 ),
154 (
155 "fixed-added",
156 Severity::Breaking,
157 "A fixed or pattern value was added",
158 ),
159 (
160 "fixed-changed",
161 Severity::Breaking,
162 "The fixed or pattern value changed",
163 ),
164 (
165 "fixed-removed",
166 Severity::Info,
167 "The fixed or pattern value was removed",
168 ),
169 (
170 "slicing-closed",
171 Severity::Breaking,
172 "Slicing became closed",
173 ),
174 ("slicing-opened", Severity::Info, "Slicing became open"),
175 ("slice-added", Severity::Info, "An optional slice was added"),
176 ("slice-removed", Severity::Warning, "A slice was removed"),
177 (
178 "modifier-changed",
179 Severity::Warning,
180 "The isModifier flag changed",
181 ),
182];
183
184pub fn severity_of(rule: &str) -> Severity {
185 RULES
186 .iter()
187 .find(|(id, _, _)| *id == rule)
188 .map_or(Severity::Info, |(_, severity, _)| *severity)
189}
190
191#[derive(Debug, Clone, PartialEq, Serialize)]
193pub struct Change {
194 pub rule: &'static str,
195 pub severity: Severity,
196 pub path: String,
198 pub message: String,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub old: Option<String>,
201 #[serde(skip_serializing_if = "Option::is_none")]
202 pub new: Option<String>,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
206#[serde(rename_all = "lowercase")]
207pub enum Status {
208 Added,
209 Removed,
210 Changed,
211 Unchanged,
212}
213
214#[derive(Debug, Clone, Serialize)]
216pub struct ProfileDiff {
217 pub url: String,
218 pub name: String,
219 #[serde(skip_serializing_if = "Option::is_none")]
220 pub title: Option<String>,
221 pub type_name: String,
222 pub status: Status,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub old_version: Option<String>,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub new_version: Option<String>,
227 pub file: String,
229 pub changes: Vec<Change>,
230}
231
232impl ProfileDiff {
233 pub fn worst(&self) -> Option<Severity> {
234 self.changes.iter().map(|c| c.severity).max()
235 }
236}
237
238#[derive(Debug, Clone, Serialize)]
240pub struct Report {
241 pub old: String,
242 pub new: String,
243 pub profiles: Vec<ProfileDiff>,
244 #[serde(skip_serializing_if = "Vec::is_empty")]
247 pub notes: Vec<String>,
248}
249
250impl Report {
251 pub fn count(&self, severity: Severity) -> usize {
252 self.profiles
253 .iter()
254 .flat_map(|p| &p.changes)
255 .filter(|c| c.severity == severity)
256 .count()
257 }
258
259 pub fn worst(&self) -> Option<Severity> {
260 self.profiles.iter().filter_map(ProfileDiff::worst).max()
261 }
262}
263
264#[derive(Debug, Clone, Default)]
266pub struct Filter {
267 pub profiles: Vec<String>,
269}
270
271impl Filter {
272 fn admits(&self, profile: &Profile) -> bool {
273 self.profiles.is_empty()
274 || self
275 .profiles
276 .iter()
277 .any(|p| p == &profile.url || p == &profile.name)
278 }
279}
280
281pub fn diff(old_id: &str, old: &Pool, new_id: &str, new: &Pool, filter: &Filter) -> Report {
283 let (old_all, new_all) = (old.profiles(), new.profiles());
284 let chosen: BTreeSet<String> = old_all
287 .iter()
288 .chain(&new_all)
289 .filter(|p| filter.admits(p))
290 .map(|p| p.url.clone())
291 .collect();
292 let keep = |profiles: Vec<Profile>| -> BTreeMap<String, Profile> {
293 profiles
294 .into_iter()
295 .filter(|p| chosen.contains(&p.url))
296 .map(|p| (p.url.clone(), p))
297 .collect()
298 };
299 let (old_profiles, new_profiles) = (keep(old_all), keep(new_all));
300 let urls: BTreeSet<&String> = old_profiles.keys().chain(new_profiles.keys()).collect();
301 let mut profiles = Vec::new();
302 for url in urls {
303 let entry = match (old_profiles.get(url), new_profiles.get(url)) {
304 (Some(o), Some(n)) => {
305 let mut changes = Vec::new();
306 if o.base != n.base {
307 changes.push(change(
308 "base-changed",
309 &n.type_name,
310 "The profile is based on another definition",
311 o.base.clone(),
312 n.base.clone(),
313 ));
314 }
315 let mut walker = Walker {
316 old,
317 new,
318 changes: &mut changes,
319 };
320 walker.node(&n.type_name, &n.type_name, &o.tree, &n.tree);
321 ProfileDiff {
322 url: url.clone(),
323 name: n.name.clone(),
324 title: n.title.clone(),
325 type_name: n.type_name.clone(),
326 status: if changes.is_empty() {
327 Status::Unchanged
328 } else {
329 Status::Changed
330 },
331 old_version: o.version.clone(),
332 new_version: n.version.clone(),
333 file: n.file.clone(),
334 changes,
335 }
336 }
337 (Some(o), None) => ProfileDiff {
338 url: url.clone(),
339 name: o.name.clone(),
340 title: o.title.clone(),
341 type_name: o.type_name.clone(),
342 status: Status::Removed,
343 old_version: o.version.clone(),
344 new_version: None,
345 file: o.file.clone(),
346 changes: vec![change(
347 "profile-removed",
348 &o.type_name,
349 "The profile is not in the new package",
350 Some(url.clone()),
351 None,
352 )],
353 },
354 (None, Some(n)) => ProfileDiff {
355 url: url.clone(),
356 name: n.name.clone(),
357 title: n.title.clone(),
358 type_name: n.type_name.clone(),
359 status: Status::Added,
360 old_version: None,
361 new_version: n.version.clone(),
362 file: n.file.clone(),
363 changes: vec![change(
364 "profile-added",
365 &n.type_name,
366 "The profile is new",
367 None,
368 Some(url.clone()),
369 )],
370 },
371 (None, None) => continue,
372 };
373 profiles.push(entry);
374 }
375 let notes = old
376 .skipped
377 .iter()
378 .map(|(file, reason)| format!("{old_id}: {file}: {reason}"))
379 .chain(
380 new.skipped
381 .iter()
382 .map(|(file, reason)| format!("{new_id}: {file}: {reason}")),
383 )
384 .chain(old.missing.borrow().iter().map(|base| {
385 format!("{old_id}: base {base} is in no loaded package; compared over the core type")
386 }))
387 .chain(new.missing.borrow().iter().map(|base| {
388 format!("{new_id}: base {base} is in no loaded package; compared over the core type")
389 }))
390 .chain(old.borrowed.iter().map(|base| {
391 format!("{old_id}: base {base} is in no listed dependency; taken from {new_id}'s")
392 }))
393 .chain(new.borrowed.iter().map(|base| {
394 format!("{new_id}: base {base} is in no listed dependency; taken from {old_id}'s")
395 }))
396 .collect();
397 Report {
398 old: old_id.to_owned(),
399 new: new_id.to_owned(),
400 profiles,
401 notes,
402 }
403}
404
405fn change(
406 rule: &'static str,
407 path: &str,
408 message: &str,
409 old: Option<String>,
410 new: Option<String>,
411) -> Change {
412 Change {
413 rule,
414 severity: severity_of(rule),
415 path: path.to_owned(),
416 message: message.to_owned(),
417 old,
418 new,
419 }
420}
421
422fn cardinality(max: Option<u32>) -> String {
423 max.map_or_else(|| "*".to_owned(), |m| m.to_string())
424}
425
426struct Walker<'a> {
427 old: &'a Pool,
428 new: &'a Pool,
429 changes: &'a mut Vec<Change>,
430}
431
432impl Walker<'_> {
433 fn push(
434 &mut self,
435 rule: &'static str,
436 path: &str,
437 message: String,
438 old: Option<String>,
439 new: Option<String>,
440 ) {
441 self.changes.push(Change {
442 rule,
443 severity: severity_of(rule),
444 path: path.to_owned(),
445 message,
446 old,
447 new,
448 });
449 }
450
451 fn node(&mut self, path: &str, type_name: &str, old: &Node, new: &Node) {
454 self.element(path, type_name, old, new, false);
455 }
456
457 fn element(&mut self, path: &str, type_name: &str, old: &Node, new: &Node, variant: bool) {
461 if !variant {
462 self.cardinality(path, old, new);
463 self.types(path, old, new);
464 self.flags(path, old, new);
465 self.constraints(path, old, new);
466 self.slicing(path, old, new);
467 }
468 self.binding(path, old, new);
469 self.pattern(path, old, new);
470
471 let names: BTreeSet<&String> = old.children.keys().chain(new.children.keys()).collect();
472 for name in names {
473 let child_path = format!("{path}.{name}");
474 let (o, n) = (old.children.get(name), new.children.get(name));
475 let child_type = n
476 .or(o)
477 .and_then(|c| (c.types.len() == 1).then(|| c.types.iter().next().cloned()))
478 .flatten()
479 .unwrap_or_default();
480 let variant = is_variant(&new.children, name) || is_variant(&old.children, name);
481 match (o, n) {
482 (Some(o), Some(n)) => self.element(&child_path, &child_type, o, n, variant),
483 (Some(o), None) => {
484 let base = self.base(type_name, name, o);
485 self.element(&child_path, &child_type, o, &base, variant);
486 }
487 (None, Some(n)) => {
488 let base = self.base(type_name, name, n);
489 self.element(&child_path, &child_type, &base, n, variant);
490 }
491 (None, None) => {}
492 }
493 }
494
495 if variant {
496 return;
497 }
498 let slices: BTreeSet<&String> = old.slices.keys().chain(new.slices.keys()).collect();
499 for slice in slices {
500 let slice_path = format!("{path}:{slice}");
501 match (old.slices.get(slice), new.slices.get(slice)) {
502 (Some(o), Some(n)) => self.node(&slice_path, type_name, o, n),
503 (Some(_), None) => self.push(
504 "slice-removed",
505 &slice_path,
506 format!("Slice {slice} was removed"),
507 Some(slice.clone()),
508 None,
509 ),
510 (None, Some(n)) => {
511 if n.min > 0 {
512 self.push(
513 "min-increased",
514 &slice_path,
515 format!("Required slice {slice} was added (min {})", n.min),
516 Some("0".into()),
517 Some(n.min.to_string()),
518 );
519 } else {
520 self.push(
521 "slice-added",
522 &slice_path,
523 format!("Optional slice {slice} was added"),
524 None,
525 Some(slice.clone()),
526 );
527 }
528 if n.must_support {
529 self.push(
530 "must-support-added",
531 &slice_path,
532 format!("New slice {slice} is mustSupport"),
533 None,
534 Some("true".into()),
535 );
536 }
537 }
538 (None, None) => {}
539 }
540 }
541 }
542
543 fn base(&self, type_name: &str, name: &str, present: &Node) -> Node {
546 let core = self
547 .new
548 .base_node(type_name, name)
549 .or_else(|| self.old.base_node(type_name, name));
550 match core {
551 Some(mut base) => {
552 base.children.clear();
554 base
555 }
556 None => Node {
559 types: present.types.clone(),
560 max: present.max,
561 targets: present.targets.clone(),
562 ..Node::unconstrained()
563 },
564 }
565 }
566
567 fn cardinality(&mut self, path: &str, old: &Node, new: &Node) {
568 if new.min > old.min {
569 self.push(
570 "min-increased",
571 path,
572 format!("min {} → {}", old.min, new.min),
573 Some(old.min.to_string()),
574 Some(new.min.to_string()),
575 );
576 } else if new.min < old.min {
577 self.push(
578 "min-decreased",
579 path,
580 format!("min {} → {}", old.min, new.min),
581 Some(old.min.to_string()),
582 Some(new.min.to_string()),
583 );
584 }
585 let narrower = match (old.max, new.max) {
586 (None, Some(_)) => Some(true),
587 (Some(_), None) => Some(false),
588 (Some(a), Some(b)) if b < a => Some(true),
589 (Some(a), Some(b)) if b > a => Some(false),
590 _ => None,
591 };
592 if let Some(narrower) = narrower {
593 self.push(
594 if narrower {
595 "max-decreased"
596 } else {
597 "max-increased"
598 },
599 path,
600 format!("max {} → {}", cardinality(old.max), cardinality(new.max)),
601 Some(cardinality(old.max)),
602 Some(cardinality(new.max)),
603 );
604 }
605 }
606
607 fn types(&mut self, path: &str, old: &Node, new: &Node) {
608 if !old.types.is_empty() && !new.types.is_empty() {
610 for removed in old.types.difference(&new.types) {
611 self.push(
612 "type-removed",
613 path,
614 format!("Type {removed} is no longer allowed"),
615 Some(removed.clone()),
616 None,
617 );
618 }
619 for added in new.types.difference(&old.types) {
620 self.push(
621 "type-added",
622 path,
623 format!("Type {added} is allowed"),
624 None,
625 Some(added.clone()),
626 );
627 }
628 }
629 if old.targets.is_empty() && !new.targets.is_empty() {
630 self.push(
631 "target-removed",
632 path,
633 format!("References are restricted to {}", join(&new.targets)),
634 None,
635 Some(join(&new.targets)),
636 );
637 } else if !new.targets.is_empty() {
638 let mut added: Vec<&String> = new.targets.difference(&old.targets).collect();
639 for removed in old.targets.difference(&new.targets) {
640 let removed_type = self.old.type_of(removed);
641 let narrowed = added
643 .iter()
644 .position(|a| removed_type.is_some() && self.new.type_of(a) == removed_type);
645 match narrowed {
646 Some(index) => {
647 let to = added.remove(index);
648 self.push(
649 "target-narrowed",
650 path,
651 format!("Target {removed} narrowed to {to}"),
652 Some(removed.clone()),
653 Some(to.clone()),
654 );
655 }
656 None => self.push(
657 "target-removed",
658 path,
659 format!("Target {removed} is no longer allowed"),
660 Some(removed.clone()),
661 None,
662 ),
663 }
664 }
665 for added in added {
666 self.push(
667 "target-added",
668 path,
669 format!("Target {added} is allowed"),
670 None,
671 Some(added.clone()),
672 );
673 }
674 }
675 }
676
677 fn flags(&mut self, path: &str, old: &Node, new: &Node) {
678 match (old.must_support, new.must_support) {
679 (false, true) => self.push(
680 "must-support-added",
681 path,
682 "Became mustSupport".into(),
683 Some("false".into()),
684 Some("true".into()),
685 ),
686 (true, false) => self.push(
687 "must-support-removed",
688 path,
689 "No longer mustSupport".into(),
690 Some("true".into()),
691 Some("false".into()),
692 ),
693 _ => {}
694 }
695 if !path.contains('.') && old.is_modifier != new.is_modifier {
698 self.push(
699 "modifier-changed",
700 path,
701 format!("isModifier {} → {}", old.is_modifier, new.is_modifier),
702 Some(old.is_modifier.to_string()),
703 Some(new.is_modifier.to_string()),
704 );
705 }
706 }
707
708 fn binding(&mut self, path: &str, old: &Node, new: &Node) {
709 let describe = |b: &crate::model::Binding| {
710 format!(
711 "{} {}",
712 b.strength.as_str(),
713 b.value_set.as_deref().unwrap_or("(no value set)")
714 )
715 };
716 match (&old.binding, &new.binding) {
717 (None, Some(n)) if n.strength >= Strength::Extensible => {
718 self.push(
719 "binding-added",
720 path,
721 format!("Bound {}", describe(n)),
722 None,
723 Some(describe(n)),
724 );
725 }
726 (Some(o), Some(n)) => {
727 if n.strength > o.strength {
728 let rule = if n.strength >= Strength::Extensible {
730 "binding-stronger"
731 } else {
732 "binding-stronger-advisory"
733 };
734 self.push(
735 rule,
736 path,
737 format!("{} → {}", o.strength.as_str(), n.strength.as_str()),
738 Some(describe(o)),
739 Some(describe(n)),
740 );
741 } else if n.strength < o.strength {
742 self.push(
743 "binding-weaker",
744 path,
745 format!("{} → {}", o.strength.as_str(), n.strength.as_str()),
746 Some(describe(o)),
747 Some(describe(n)),
748 );
749 }
750 let plain = |vs: &Option<String>| {
751 vs.as_deref()
752 .map(|v| v.split('|').next().unwrap_or(v).to_owned())
753 };
754 if plain(&o.value_set) != plain(&n.value_set) && n.strength >= Strength::Extensible
755 {
756 self.push(
757 "value-set-changed",
758 path,
759 format!(
760 "Value set {} → {}",
761 o.value_set.as_deref().unwrap_or("-"),
762 n.value_set.as_deref().unwrap_or("-")
763 ),
764 o.value_set.clone(),
765 n.value_set.clone(),
766 );
767 }
768 }
769 _ => {}
770 }
771 }
772
773 fn constraints(&mut self, path: &str, old: &Node, new: &Node) {
774 for (key, constraint) in &new.constraints {
775 match old.constraints.get(key) {
776 None => {
777 let rule = if constraint.severity == "error" {
778 "invariant-added"
779 } else {
780 "invariant-warning-added"
781 };
782 self.push(
783 rule,
784 path,
785 format!("Invariant {key}: {}", constraint.human),
786 None,
787 Some(constraint.expression.clone()),
788 );
789 }
790 Some(previous) if previous.expression != constraint.expression => {
791 self.push(
792 "invariant-changed",
793 path,
794 format!("Invariant {key} changed"),
795 Some(previous.expression.clone()),
796 Some(constraint.expression.clone()),
797 );
798 }
799 Some(_) => {}
800 }
801 }
802 for (key, constraint) in &old.constraints {
803 if !new.constraints.contains_key(key) {
804 self.push(
805 "invariant-removed",
806 path,
807 format!("Invariant {key} was removed"),
808 Some(constraint.expression.clone()),
809 None,
810 );
811 }
812 }
813 }
814
815 fn pattern(&mut self, path: &str, old: &Node, new: &Node) {
816 let text = |v: &serde_json::Value| v.to_string();
817 match (&old.pattern, &new.pattern) {
818 (None, Some(n)) => self.push(
819 "fixed-added",
820 path,
821 format!("Fixed/pattern {}", text(n)),
822 None,
823 Some(text(n)),
824 ),
825 (Some(o), None) => self.push(
826 "fixed-removed",
827 path,
828 "Fixed/pattern value removed".into(),
829 Some(text(o)),
830 None,
831 ),
832 (Some(o), Some(n)) if o != n => self.push(
833 "fixed-changed",
834 path,
835 "Fixed/pattern value changed".into(),
836 Some(text(o)),
837 Some(text(n)),
838 ),
839 _ => {}
840 }
841 }
842
843 fn slicing(&mut self, path: &str, old: &Node, new: &Node) {
844 let rules = |node: &Node| node.slicing.as_ref().map(|s| s.rules.clone());
845 match (rules(old).as_deref(), rules(new).as_deref()) {
846 (Some("closed"), Some(r)) if r != "closed" => self.push(
847 "slicing-opened",
848 path,
849 format!("Slicing closed → {r}"),
850 Some("closed".into()),
851 Some(r.to_owned()),
852 ),
853 (o, Some("closed")) if o != Some("closed") => self.push(
854 "slicing-closed",
855 path,
856 format!("Slicing {} → closed", o.unwrap_or("none")),
857 o.map(str::to_owned),
858 Some("closed".into()),
859 ),
860 _ => {}
861 }
862 }
863}
864
865fn join(set: &BTreeSet<String>) -> String {
866 set.iter().cloned().collect::<Vec<_>>().join(", ")
867}