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 or replaced, `min` up, `max` down,
6//! a type or reference target removed, a new mustSupport, a stronger binding, a new error
7//! invariant, a new 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        "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/// One change.
202#[derive(Debug, Clone, PartialEq, Serialize)]
203pub struct Change {
204    pub rule: &'static str,
205    pub severity: Severity,
206    /// Element path, slices written `:name` (`Patient.identifier:mrn.system`).
207    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/// The changes of one profile.
225#[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    /// Canonical URL of the profile this one took over from.
234    #[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    /// File in the new package (the old one for a removed profile).
241    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/// The diff of two packages.
252#[derive(Debug, Clone, Serialize)]
253pub struct Report {
254    pub old: String,
255    pub new: String,
256    pub profiles: Vec<ProfileDiff>,
257    /// Definitions that could not be read, and bases that were missing or taken from the other
258    /// package.
259    #[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/// Which profiles to compare.
278#[derive(Debug, Clone, Default)]
279pub struct Filter {
280    /// URLs or names; empty compares all.
281    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
294/// Compare the profiles of two pools.
295pub 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    // A profile is chosen on either side: renamed between versions (US Core `USCoreLocation` →
298    // `USCoreLocationProfile`), its canonical URL still pairs the two.
299    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
442/// Profiles that took over from another under a new canonical URL, new URL to old. A package
443/// says nothing about this, so the pair is inferred, and only where it leaves no choice: the one
444/// profile of a type removed and the one added. Extensions are left out, since two of them on
445/// the same type say nothing about each other.
446fn 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
474/// An invariant found on one element and gone from another.
475struct InvariantSite {
476    key: String,
477    severity: String,
478    /// Its change in the profile's list.
479    index: usize,
480}
481
482/// An invariant that left one element and arrived on another is one change, not a removal and
483/// an addition.
484fn 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
534/// Whether two expressions say the same from their own elements: `value.ofType(Quantity)` on
535/// `Observation` is `ofType(Quantity)` on `Observation.value`.
536fn 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
544/// `expression` with the leading `element.` taken off every path that starts with it.
545fn 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    /// Compare two versions of one element; `type_name` is the element's own type, for
613    /// looking up children only one version constrains.
614    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    /// Compare an element; a choice `variant` (`valueQuantity`) repeats what its choice
619    /// element says about cardinality, flags, invariants and type slices, so only its own
620    /// binding, value and children are compared. `old_siblings` are the elements next to `old`,
621    /// where a choice keeps its variants.
622    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                    // A type slice of a choice asks for nothing new when that type already was
704                    // mustSupport.
705                    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    /// What an element only one version constrains is in the other: its core definition,
722    /// else the same element without constraints.
723    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                // The core definition has no nested constraints of its own here.
731                base.children.clear();
732                base
733            }
734            // Not a core element (a backbone child, a logical model): compare the
735            // constraints alone, keeping the element's shape.
736            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        // A node that names no type allows its base's types.
787        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                // `Patient` → `us-core-patient`: the same type, now with a profile.
820                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        // Profiles cannot change isModifier on an element; an extension definition can
874        // (its root): compare it there only.
875        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                    // Only an extensible or required binding can make existing data invalid.
907                    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
1053/// A variant's own mustSupport means something only while its choice stays put: a choice whose
1054/// flag changed is reported itself, and one sliced by type speaks through its slices.
1055fn 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        // The same text on another element means something else.
1094        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}