Skip to main content

fhir_diff/
diff.rs

1//! Changes between two versions of a package, profile by profile and element by element, each
2//! classified by what it does to existing data and implementations.
3//!
4//! **Breaking**: an instance valid against the old profile may be invalid against the new one,
5//! or an implementer gains an obligation — a profile removed, `min` up, `max` down, a type or
6//! reference target removed, a new mustSupport, a stronger binding, a new error invariant, a new
7//! or changed fixed/pattern value, slicing closed, a required slice added.
8//! **Warning**: may break, depending on content the diff does not see — a different value set
9//! under a required or extensible binding, a changed invariant expression, a different base, a
10//! changed modifier flag, a new warning invariant.
11//! **Info**: relaxations and additions that keep old instances valid.
12
13use 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
37/// Every rule, with its severity and what it means.
38pub 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/// One change.
192#[derive(Debug, Clone, PartialEq, Serialize)]
193pub struct Change {
194    pub rule: &'static str,
195    pub severity: Severity,
196    /// Element path, slices written `:name` (`Patient.identifier:mrn.system`).
197    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/// The changes of one profile.
215#[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    /// File in the new package (the old one for a removed profile).
228    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/// The diff of two packages.
239#[derive(Debug, Clone, Serialize)]
240pub struct Report {
241    pub old: String,
242    pub new: String,
243    pub profiles: Vec<ProfileDiff>,
244    /// Definitions that could not be read, and bases that were missing or taken from the other
245    /// package.
246    #[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/// Which profiles to compare.
265#[derive(Debug, Clone, Default)]
266pub struct Filter {
267    /// URLs or names; empty compares all.
268    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
281/// Compare the profiles of two pools.
282pub 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    // A profile is chosen on either side: renamed between versions (US Core `USCoreLocation` →
285    // `USCoreLocationProfile`), its canonical URL still pairs the two.
286    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    /// Compare two versions of one element; `type_name` is the element's own type, for
452    /// looking up children only one version constrains.
453    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    /// Compare an element; a choice `variant` (`valueQuantity`) repeats what its choice
458    /// element says about cardinality, flags, invariants and type slices, so only its own
459    /// binding, value and children are compared.
460    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    /// What an element only one version constrains is in the other: its core definition,
544    /// else the same element without constraints.
545    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                // The core definition has no nested constraints of its own here.
553                base.children.clear();
554                base
555            }
556            // Not a core element (a backbone child, a logical model): compare the
557            // constraints alone, keeping the element's shape.
558            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        // A node that names no type allows its base's types.
609        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                // `Patient` → `us-core-patient`: the same type, now with a profile.
642                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        // Profiles cannot change isModifier on an element; an extension definition can
696        // (its root): compare it there only.
697        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                    // Only an extensible or required binding can make existing data invalid.
729                    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}