Skip to main content

fhir_core/
convert.rs

1//! Converting a resource from one FHIR release to another, saying what was
2//! lost.
3//!
4//! The releases do not share model types, and they never will: an R3, R4 and R5
5//! `Patient` disagree about enough that a common type would either accept data
6//! no release permits or silently drop data a release requires (spec 12,
7//! R12.4). So this module does not convert *types*. It converts the **wire
8//! form** — a `serde_json::Value` — from the shape one release's model accepts
9//! into the shape another's does, and it returns a [`LossReport`] naming
10//! everything it had to change or discard.
11//!
12//! That report is the point. "Serialize to JSON and see what the target
13//! refuses" already worked; what it could not tell you is *what* it refused, or
14//! that it refused anything at all — serde reports the first error and stops,
15//! and a field the target simply does not have is not an error, it is silence.
16//! Cross-version exchange is routine in national deployments, and a conversion
17//! whose losses are invisible is worse than one that fails.
18//!
19//! # What it is driven by
20//!
21//! Both releases' [`ElementMeta`] tables, which are generated from the official
22//! `ElementDefinition`s. Nothing here is a hand-written rule about a particular
23//! resource, so the layer does not rot as releases are added: `fhir-r6`
24//! became convertible by existing, not by anyone editing this file.
25//!
26//! The consequence worth stating plainly: this is a **structural** conversion.
27//! It knows that R4 `Observation` has no `bodyStructure`, that R3
28//! `Observation.value[x]` admits `Attachment` where R4 does not, and that
29//! `Bundle.entry` repeats in both. It does *not* know that R3's
30//! `MedicationRequest.requester.agent` became R4's `MedicationRequest.requester`
31//! — that is a semantic remapping, and inventing one here would be exactly the
32//! silent data-mangling the type split exists to prevent. Such elements are
33//! reported as [`LossKind::ElementRemoved`], which is honest: this layer did not
34//! carry them over.
35
36use ::serde_json::{Map, Value};
37
38use crate::meta::{self, ElementMeta};
39
40/// Why a piece of the source did not survive the conversion unchanged.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum LossKind {
44    /// The target release has no element at this path. The value was dropped.
45    ElementRemoved,
46    /// The target release has no such resource type. The resource was dropped.
47    ResourceRemoved,
48    /// The document is not a resource: it has no `resourceType` to convert by.
49    /// Serializing a bare resource struct rather than the release's `Resource`
50    /// enum produces exactly this, because the tag lives on the enum.
51    NotAResource,
52    /// A `value[x]` variant whose type the target's choice does not allow. The
53    /// value was dropped.
54    ChoiceVariantUnsupported,
55    /// The element repeats in the source and does not in the target. Everything
56    /// after the first entry was dropped.
57    CardinalityNarrowed,
58    /// The element's JSON kind differs between the releases — a string became a
59    /// number, or a primitive became a complex type — so the value cannot be
60    /// carried across as it stands. It was dropped.
61    TypeChanged,
62    /// The target requires this element (`min >= 1`) and the converted resource
63    /// does not have it. Nothing was dropped; the result will not validate.
64    RequiredMissing,
65    /// The target binds this element to a *different* value set with `required`
66    /// strength, so the code carried over may not be a legal value there. The
67    /// value was kept.
68    BindingChanged,
69}
70
71impl LossKind {
72    /// Whether the loss discarded data, as opposed to reporting a problem with
73    /// data that was kept.
74    ///
75    /// [`RequiredMissing`](Self::RequiredMissing) and
76    /// [`BindingChanged`](Self::BindingChanged) are warnings about the result;
77    /// every other kind means something is gone.
78    #[must_use]
79    pub fn discards_data(self) -> bool {
80        !matches!(self, Self::RequiredMissing | Self::BindingChanged)
81    }
82}
83
84impl ::std::fmt::Display for LossKind {
85    fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
86        let s = match self {
87            Self::ElementRemoved => "element not in target",
88            Self::ResourceRemoved => "resource type not in target",
89            Self::NotAResource => "not a resource",
90            Self::ChoiceVariantUnsupported => "choice variant not in target",
91            Self::CardinalityNarrowed => "does not repeat in target",
92            Self::TypeChanged => "incompatible type in target",
93            Self::RequiredMissing => "required by target but absent",
94            Self::BindingChanged => "different required binding in target",
95        };
96        f.write_str(s)
97    }
98}
99
100/// One thing the conversion changed or discarded.
101#[derive(Debug, Clone, PartialEq, Eq)]
102#[non_exhaustive]
103pub struct Loss {
104    /// Where it happened, as a JSON-ish path into the source document, e.g.
105    /// `"Observation.component[1].valueAttachment"`.
106    pub path: String,
107    /// What happened.
108    pub kind: LossKind,
109    /// The specifics — the offending type name, the two value-set URLs, the
110    /// number of entries dropped.
111    pub detail: String,
112}
113
114impl ::std::fmt::Display for Loss {
115    fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
116        write!(f, "{}: {} ({})", self.path, self.kind, self.detail)
117    }
118}
119
120/// Everything the conversion changed or discarded, in document order.
121#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub struct LossReport {
123    losses: Vec<Loss>,
124}
125
126impl LossReport {
127    /// Whether the conversion carried the whole document across untouched.
128    #[must_use]
129    pub fn is_lossless(&self) -> bool {
130        self.losses.is_empty()
131    }
132
133    /// Whether any loss actually discarded data, as opposed to warning about
134    /// data that was kept (see [`LossKind::discards_data`]).
135    #[must_use]
136    pub fn discarded_data(&self) -> bool {
137        self.losses.iter().any(|l| l.kind.discards_data())
138    }
139
140    /// How many losses were recorded.
141    #[must_use]
142    pub fn len(&self) -> usize {
143        self.losses.len()
144    }
145
146    /// Whether no losses were recorded; the same question as
147    /// [`is_lossless`](Self::is_lossless).
148    #[must_use]
149    pub fn is_empty(&self) -> bool {
150        self.losses.is_empty()
151    }
152
153    /// The losses, in document order.
154    pub fn iter(&self) -> impl Iterator<Item = &Loss> {
155        self.losses.iter()
156    }
157
158    /// Only the losses of a given kind.
159    pub fn of_kind(&self, kind: LossKind) -> impl Iterator<Item = &Loss> {
160        self.losses.iter().filter(move |l| l.kind == kind)
161    }
162}
163
164impl<'a> IntoIterator for &'a LossReport {
165    type Item = &'a Loss;
166    type IntoIter = ::std::slice::Iter<'a, Loss>;
167
168    fn into_iter(self) -> Self::IntoIter {
169        self.losses.iter()
170    }
171}
172
173impl ::std::fmt::Display for LossReport {
174    fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
175        if self.losses.is_empty() {
176            return f.write_str("lossless");
177        }
178        for (i, loss) in self.losses.iter().enumerate() {
179            if i > 0 {
180                f.write_str("\n")?;
181            }
182            write!(f, "{loss}")?;
183        }
184        Ok(())
185    }
186}
187
188/// The result of a conversion: what the target release will accept, and what
189/// that cost.
190#[derive(Debug, Clone, PartialEq, Eq)]
191#[non_exhaustive]
192pub struct Converted {
193    /// The converted document, ready to deserialize into the target release's
194    /// model. [`Value::Null`] if the resource type does not exist in the target.
195    pub value: Value,
196    /// What was changed or discarded getting there.
197    pub report: LossReport,
198}
199
200impl Converted {
201    /// The converted document, or the report if anything was changed at all.
202    ///
203    /// For callers who would rather refuse a document than transmit a lossy
204    /// version of it — a reasonable default when the receiver is a clinical
205    /// system and a dropped element is a dropped fact.
206    ///
207    /// The bar is [`LossReport::is_lossless`], not
208    /// [`LossReport::discarded_data`]: a `RequiredMissing` means the result
209    /// will not validate in the target, and a `BindingChanged` means a code
210    /// that was legal may not be, so neither is something a strict caller
211    /// should be handed silently. Both are rare in practice — across the
212    /// committed corpora they account for one document each per release pair,
213    /// against roughly half that convert cleanly — so this does not reject
214    /// everything.
215    ///
216    /// # Errors
217    ///
218    /// The [`LossReport`], whenever the conversion was not lossless.
219    pub fn strict(self) -> Result<Value, LossReport> {
220        if self.report.is_lossless() {
221            Ok(self.value)
222        } else {
223            Err(self.report)
224        }
225    }
226}
227
228/// Convert one resource's JSON from the `source` release to the `target`
229/// release, driven by the two element tables.
230///
231/// Pass each release's `meta::elements()`. The `fhir` crate's
232/// `convert::between` wraps this so the tables come from the release marker
233/// types rather than by hand.
234///
235/// The returned [`Converted::value`] contains only what the target's model
236/// accepts, so deserializing it into the target's `Resource` succeeds where
237/// deserializing the source's JSON directly would have failed or silently
238/// dropped fields.
239#[must_use]
240pub fn resource(
241    source: &'static [ElementMeta],
242    target: &'static [ElementMeta],
243    value: &Value,
244) -> Converted {
245    let mut losses = Vec::new();
246    let converted = convert_resource(source, target, value, "", &mut losses);
247    Converted {
248        value: converted.unwrap_or(Value::Null),
249        report: LossReport { losses },
250    }
251}
252
253/// Convert a resource object, using its own `resourceType` as the context.
254///
255/// `Returns` `None` when the target release has no such resource type, which is
256/// how a dropped `contained` entry and a dropped root resource share a path.
257fn convert_resource(
258    source: &'static [ElementMeta],
259    target: &'static [ElementMeta],
260    value: &Value,
261    path: &str,
262    losses: &mut Vec<Loss>,
263) -> Option<Value> {
264    // A document with no `resourceType` cannot be converted, and must not fail
265    // quietly: an empty report beside a null result would read as "nothing to
266    // do" when the truth is "nothing was done".
267    let type_name = value
268        .as_object()
269        .and_then(|o| o.get("resourceType"))
270        .and_then(Value::as_str);
271    let Some(type_name) = type_name else {
272        losses.push(Loss {
273            path: if path.is_empty() {
274                "(root)".to_string()
275            } else {
276                path.to_string()
277            },
278            kind: LossKind::NotAResource,
279            detail: "no resourceType; serialize the release's Resource enum, \
280                     which carries the tag, rather than the resource struct"
281                .to_string(),
282        });
283        return None;
284    };
285    let obj = value.as_object()?;
286    let here = if path.is_empty() {
287        type_name.to_string()
288    } else {
289        path.to_string()
290    };
291
292    if !has_type(target, type_name) {
293        losses.push(Loss {
294            path: here,
295            kind: LossKind::ResourceRemoved,
296            detail: format!("no {type_name} in the target release"),
297        });
298        return None;
299    }
300
301    let mut ctx = Ctx {
302        source,
303        target,
304        losses,
305    };
306    Some(Value::Object(ctx.object(obj, type_name, type_name, &here)))
307}
308
309/// Whether a release's table knows a resource or datatype by name.
310fn has_type(table: &'static [ElementMeta], name: &str) -> bool {
311    let prefix = format!("{name}.");
312    table.iter().any(|e| e.path.starts_with(&prefix))
313}
314
315/// The walk's fixed state: the two tables and the accumulating report.
316struct Ctx<'a> {
317    source: &'static [ElementMeta],
318    target: &'static [ElementMeta],
319    losses: &'a mut Vec<Loss>,
320}
321
322impl Ctx<'_> {
323    /// Convert every member of one object, in document order.
324    ///
325    /// `src_context` and `tgt_context` are the FHIR paths (or datatype names)
326    /// this object sits at in each release; they differ whenever an element's
327    /// type was renamed between the two.
328    fn object(
329        &mut self,
330        obj: &Map<String, Value>,
331        src_context: &str,
332        tgt_context: &str,
333        path: &str,
334    ) -> Map<String, Value> {
335        // A recursive backbone re-enters an ancestor rather than nesting for
336        // ever, so `QuestionnaireResponse.item.answer.item` has no children of
337        // its own and must be read as `QuestionnaireResponse.item`.
338        let src_context = resolve_recursion(self.source, src_context);
339        let tgt_context = resolve_recursion(self.target, tgt_context);
340        let mut out = Map::new();
341
342        for (key, value) in obj {
343            if key == "resourceType" {
344                out.insert(key.clone(), value.clone());
345                continue;
346            }
347            // `_field` carries the primitive extensions of `field`; it stands or
348            // falls with the element it annotates.
349            let sibling = key.starts_with('_');
350            let base = key.strip_prefix('_').unwrap_or(key);
351            let here = format!("{path}.{key}");
352
353            let src_meta = meta::resolve(
354                self.source,
355                &format!("{src_context}.{base}"),
356                src_context,
357                base,
358            );
359            let Some(tgt_meta) = meta::resolve(
360                self.target,
361                &format!("{tgt_context}.{base}"),
362                tgt_context,
363                base,
364            ) else {
365                // Report a dropped `_field` only when its element is not present
366                // to be reported in its own right, so one removal is one loss.
367                if !sibling || !obj.contains_key(base) {
368                    self.losses.push(Loss {
369                        path: here,
370                        kind: LossKind::ElementRemoved,
371                        detail: format!("{tgt_context} has no {base}"),
372                    });
373                }
374                continue;
375            };
376
377            let src_type = src_meta.and_then(|m| chosen_type(m, base));
378            let tgt_type = chosen_type(tgt_meta, base);
379
380            // A choice whose variant the target does not offer.
381            if tgt_meta.is_choice() && tgt_type.is_none() {
382                if !sibling || !obj.contains_key(base) {
383                    let allowed = tgt_meta.type_codes().collect::<Vec<_>>().join(", ");
384                    self.losses.push(Loss {
385                        path: here,
386                        kind: LossKind::ChoiceVariantUnsupported,
387                        detail: format!("{} allows only: {allowed}", tgt_meta.path),
388                    });
389                }
390                continue;
391            }
392
393            // A primitive that became a number, or a complex type that replaced
394            // a primitive: the value cannot cross as it stands. `_field`
395            // siblings are always `Element`s, so this does not apply to them.
396            if !sibling
397                && let (Some(s), Some(t)) = (src_type, tgt_type)
398                && meta::json_kind(s) != meta::json_kind(t)
399            {
400                self.losses.push(Loss {
401                    path: here,
402                    kind: LossKind::TypeChanged,
403                    detail: format!("{s} in the source, {t} in the target"),
404                });
405                continue;
406            }
407
408            if !sibling {
409                self.check_binding(src_meta, tgt_meta, &here);
410            }
411
412            let value = self.fit_cardinality(value, tgt_meta, &here);
413            // A `_field` sibling holds an `Element` (id and extensions),
414            // whatever the element it annotates is typed as.
415            let (child_src, child_tgt) = if sibling {
416                ("Element", "Element")
417            } else {
418                (
419                    src_meta.map_or(src_context, |m| child_context(m, src_type)),
420                    child_context(tgt_meta, tgt_type),
421                )
422            };
423            let converted = self.value(&value, child_src, child_tgt, tgt_type, &here);
424            out.insert(key.clone(), converted);
425        }
426
427        self.check_required(&out, tgt_context, path);
428        out
429    }
430
431    /// Convert one value: recurse into objects and arrays, pass scalars through.
432    fn value(
433        &mut self,
434        value: &Value,
435        src_context: &str,
436        tgt_context: &str,
437        type_code: Option<&str>,
438        path: &str,
439    ) -> Value {
440        match value {
441            Value::Array(items) => Value::Array(
442                items
443                    .iter()
444                    .enumerate()
445                    .map(|(i, item)| {
446                        let at = format!("{path}[{i}]");
447                        self.value(item, src_context, tgt_context, type_code, &at)
448                    })
449                    .collect(),
450            ),
451            Value::Object(obj) => {
452                // `contained`, and `Bundle.entry.resource`, hold whole resources
453                // whose context is their own `resourceType`, not this path.
454                if type_code == Some("Resource") || obj.contains_key("resourceType") {
455                    return convert_resource(self.source, self.target, value, path, self.losses)
456                        .unwrap_or(Value::Null);
457                }
458                Value::Object(self.object(obj, src_context, tgt_context, path))
459            }
460            other => other.clone(),
461        }
462    }
463
464    /// Match the value's JSON shape to the target's cardinality.
465    ///
466    /// FHIR JSON writes a repeating element as an array and a singular one as a
467    /// bare value, so an element that repeats in only one of the two releases
468    /// has to be wrapped or unwrapped. Wrapping loses nothing and is silent;
469    /// unwrapping past the first entry does, and is reported.
470    fn fit_cardinality(
471        &mut self,
472        value: &Value,
473        tgt_meta: &'static ElementMeta,
474        path: &str,
475    ) -> Value {
476        let Some(items) = value.as_array() else {
477            // Singular in the source, repeating in the target: wrap it. `null`
478            // is left alone — it is a placeholder in a `_field` array, not a
479            // value to promote.
480            if tgt_meta.is_multiple() && !value.is_null() {
481                return Value::Array(vec![value.clone()]);
482            }
483            return value.clone();
484        };
485        if tgt_meta.is_multiple() || items.len() <= 1 {
486            // A one-entry array for a singular target still has to be unwrapped,
487            // but nothing is lost by doing it.
488            if !tgt_meta.is_multiple() && items.len() == 1 {
489                return items[0].clone();
490            }
491            return value.clone();
492        }
493        self.losses.push(Loss {
494            path: path.to_string(),
495            kind: LossKind::CardinalityNarrowed,
496            detail: format!(
497                "{} entries, but {} is {}..{}",
498                items.len(),
499                tgt_meta.path,
500                tgt_meta.min,
501                tgt_meta.max
502            ),
503        });
504        items[0].clone()
505    }
506
507    /// Warn when the target binds the element to a different value set with
508    /// `required` strength, so a code that was legal may no longer be.
509    fn check_binding(
510        &mut self,
511        src_meta: Option<&'static ElementMeta>,
512        tgt_meta: &'static ElementMeta,
513        path: &str,
514    ) {
515        let Some(tgt) = tgt_meta.binding else { return };
516        if tgt.strength != meta::BindingStrength::Required {
517            return;
518        }
519        let src = src_meta.and_then(|m| m.binding);
520        let same = src.is_some_and(|s| {
521            s.strength == meta::BindingStrength::Required
522                && canonical_vs(s.value_set) == canonical_vs(tgt.value_set)
523        });
524        if same {
525            return;
526        }
527        self.losses.push(Loss {
528            path: path.to_string(),
529            kind: LossKind::BindingChanged,
530            detail: match src.and_then(|s| s.value_set) {
531                Some(from) => format!("{from} → {}", tgt.value_set.unwrap_or("(none)")),
532                None => format!("now required: {}", tgt.value_set.unwrap_or("(none)")),
533            },
534        });
535    }
536
537    /// Report the target's mandatory elements that the converted object lacks.
538    ///
539    /// This does not repair anything — there is nothing honest to put in a
540    /// missing required field — but it is the difference between a document that
541    /// will fail validation and one you know will.
542    fn check_required(&mut self, out: &Map<String, Value>, tgt_context: &str, path: &str) {
543        let prefix = format!("{tgt_context}.");
544        for el in self.target.iter().filter(|e| e.path.starts_with(&prefix)) {
545            let Some(name) = el.path.strip_prefix(&prefix) else {
546                continue;
547            };
548            // Direct children only; grandchildren are checked when built.
549            if !el.is_required() || name.contains('.') {
550                continue;
551            }
552            let present = if el.is_choice() {
553                let base = name.trim_end_matches("[x]");
554                out.keys()
555                    .any(|k| meta::choice_suffix(el, k).is_some() && k.starts_with(base))
556            } else {
557                out.contains_key(name)
558            };
559            if !present {
560                self.losses.push(Loss {
561                    path: format!("{path}.{name}"),
562                    kind: LossKind::RequiredMissing,
563                    detail: format!("{} is {}..{}", el.path, el.min, el.max),
564                });
565            }
566        }
567    }
568}
569
570/// Follow a recursive backbone back to the path it re-enters.
571///
572/// FHIR expresses recursion with `contentReference`: `Questionnaire.item.item`
573/// does not restate the item's elements, it points at `Questionnaire.item`. The
574/// generated table therefore has no children under the deeper path, and a walk
575/// that took that at face value would report every element of every nested item
576/// as missing from the target — which is what it did before this existed.
577///
578/// The element's own `contentReference` says where, so this is a lookup, not a
579/// guess. Guessing was tried: matching on the final path segment resolves
580/// `Questionnaire.item.item` correctly but sends
581/// `TestScript.test.action.operation` to whichever `…operation` it finds first,
582/// and `QuestionnaireResponse.item.item` to `Claim.item`.
583///
584/// References can chain, so this follows them, with a bound in case a future
585/// specification ever ships a cycle.
586fn resolve_recursion<'a>(table: &'static [ElementMeta], context: &'a str) -> &'a str {
587    let mut at = context;
588    for _ in 0..8 {
589        if has_type(table, at) {
590            return at;
591        }
592        match meta::find(table, at).and_then(|e| e.content_reference) {
593            Some(target) => at = target,
594            None => return at,
595        }
596    }
597    at
598}
599
600/// The context a child of this element sits in: a named datatype switches to
601/// that type, a backbone keeps the element's own path.
602/// A `contentReference` element (a recursive backbone) declares no type at all;
603/// its own path is the right answer there too, because [`resolve_recursion`]
604/// maps that path back to the one it re-enters.
605fn child_context(el: &'static ElementMeta, type_code: Option<&'static str>) -> &'static str {
606    match type_code {
607        Some(code) if meta::is_datatype(code) => code,
608        _ => el.path,
609    }
610}
611
612/// The single type this key selects: for a choice, the one its suffix names;
613/// otherwise the element's only type.
614fn chosen_type(el: &'static ElementMeta, key: &str) -> Option<&'static str> {
615    if el.is_choice() {
616        let suffix = meta::choice_suffix(el, key)?;
617        return el.type_codes().find(|c| c.eq_ignore_ascii_case(suffix));
618    }
619    el.types.first().map(|t| t.code)
620}
621
622/// A value-set URL without its `|version` suffix, so R4's
623/// `…/observation-status` and R5's `…/observation-status|5.0.0` compare equal.
624fn canonical_vs(url: Option<&'static str>) -> Option<&'static str> {
625    url.map(|u| u.split('|').next().unwrap_or(u))
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    // The engine is release-agnostic, so its unit tests build small tables by
633    // hand rather than depending on a release crate — `fhir-core` cannot see
634    // one. The conversions against the real R3/R4/R5 tables live in the `fhir`
635    // crate's tests, where the models are in scope.
636
637    const EMPTY: &[&str] = &[];
638
639    macro_rules! el {
640        ($path:expr, $min:expr, $max:expr, $ty:expr) => {
641            ElementMeta {
642                path: $path,
643                min: $min,
644                max: $max,
645                is_summary: false,
646                binding: None,
647                types: &[TypeRef {
648                    code: $ty,
649                    target_profiles: EMPTY,
650                }],
651                content_reference: None,
652            }
653        };
654    }
655
656    use crate::meta::TypeRef;
657
658    static SRC: &[ElementMeta] = &[
659        el!("Thing.gone", 0, "1", "string"),
660        el!("Thing.kept", 0, "1", "string"),
661        el!("Thing.many", 0, "*", "string"),
662        el!("Thing.num", 0, "1", "string"),
663    ];
664
665    // Sorted by path, as the generated tables are: `meta::find` binary-searches.
666    static TGT: &[ElementMeta] = &[
667        el!("Thing.kept", 0, "1", "string"),
668        el!("Thing.many", 0, "1", "string"),
669        el!("Thing.needed", 1, "1", "string"),
670        el!("Thing.num", 0, "1", "integer"),
671    ];
672
673    #[test]
674    fn the_fixtures_are_sorted() {
675        for table in [SRC, TGT] {
676            assert!(
677                table.windows(2).all(|w| w[0].path < w[1].path),
678                "an unsorted table silently breaks the binary search in meta::find"
679            );
680        }
681    }
682
683    fn convert(json: &str) -> Converted {
684        resource(SRC, TGT, &::serde_json::from_str(json).unwrap())
685    }
686
687    #[test]
688    fn drops_an_element_the_target_lacks() {
689        let out = convert(r#"{"resourceType":"Thing","gone":"x","kept":"y"}"#);
690        assert_eq!(out.value["kept"], "y");
691        assert!(out.value.get("gone").is_none());
692        let loss = out.report.of_kind(LossKind::ElementRemoved).next().unwrap();
693        assert_eq!(loss.path, "Thing.gone");
694    }
695
696    #[test]
697    fn narrows_a_repeating_element_and_says_how_much() {
698        let out = convert(r#"{"resourceType":"Thing","many":["a","b","c"]}"#);
699        assert_eq!(out.value["many"], "a");
700        let loss = out
701            .report
702            .of_kind(LossKind::CardinalityNarrowed)
703            .next()
704            .unwrap();
705        assert!(loss.detail.contains("3 entries"));
706    }
707
708    #[test]
709    fn drops_a_value_whose_json_kind_changed() {
710        let out = convert(r#"{"resourceType":"Thing","num":"12"}"#);
711        assert!(out.value.get("num").is_none());
712        assert_eq!(
713            out.report.of_kind(LossKind::TypeChanged).count(),
714            1,
715            "a string cannot be carried into an integer element"
716        );
717    }
718
719    #[test]
720    fn reports_a_required_element_it_cannot_invent() {
721        let out = convert(r#"{"resourceType":"Thing","kept":"y"}"#);
722        let loss = out
723            .report
724            .of_kind(LossKind::RequiredMissing)
725            .next()
726            .unwrap();
727        assert_eq!(loss.path, "Thing.needed");
728        assert!(
729            !loss.kind.discards_data(),
730            "nothing was dropped; the result merely will not validate"
731        );
732    }
733
734    #[test]
735    fn a_document_with_no_resource_type_is_reported_not_silently_nulled() {
736        // Serializing a bare resource struct lands here, because `resourceType`
737        // comes from the release's `Resource` enum tag. Returning null with an
738        // empty report would be a silent failure.
739        let out = resource(SRC, TGT, &::serde_json::json!({"kept": "y"}));
740        assert_eq!(out.value, Value::Null);
741        assert!(
742            !out.report.is_lossless(),
743            "a null result needs an explanation"
744        );
745        assert_eq!(out.report.of_kind(LossKind::NotAResource).count(), 1);
746    }
747
748    #[test]
749    fn an_unknown_resource_type_yields_null_not_an_empty_object() {
750        let out = resource(SRC, TGT, &::serde_json::json!({"resourceType": "Other"}));
751        assert_eq!(out.value, Value::Null);
752        assert_eq!(out.report.of_kind(LossKind::ResourceRemoved).count(), 1);
753    }
754
755    #[test]
756    fn a_lossless_conversion_says_so() {
757        let out = convert(r#"{"resourceType":"Thing","kept":"y","needed":"z"}"#);
758        assert!(out.report.is_lossless(), "{}", out.report);
759        assert!(!out.report.discarded_data());
760    }
761
762    #[test]
763    fn strict_passes_a_clean_conversion_through() {
764        let out = convert(r#"{"resourceType":"Thing","kept":"y","needed":"z"}"#);
765        let value = out.strict().expect("nothing was lost");
766        assert_eq!(value["kept"], "y");
767    }
768
769    #[test]
770    fn strict_refuses_a_lossy_one_and_hands_back_the_reason() {
771        let out = convert(r#"{"resourceType":"Thing","gone":"x","needed":"z"}"#);
772        let report = out.strict().expect_err("an element was dropped");
773        assert_eq!(report.of_kind(LossKind::ElementRemoved).count(), 1);
774    }
775
776    #[test]
777    fn strict_refuses_a_warning_too_even_though_nothing_was_dropped() {
778        // `needed` is absent, so nothing was discarded — but the result will
779        // not validate in the target, which a strict caller must not be handed
780        // without being told.
781        let out = convert(r#"{"resourceType":"Thing","kept":"y"}"#);
782        assert!(!out.report.discarded_data(), "nothing was dropped");
783        assert!(out.strict().is_err(), "and yet it must not pass strict");
784    }
785
786    #[test]
787    fn a_primitive_extension_sibling_follows_its_element() {
788        // `_gone` annotates an element the target does not have, so it goes too
789        // — and the pair is reported once, not twice.
790        let out = convert(r#"{"resourceType":"Thing","gone":"x","_gone":{"id":"a"}}"#);
791        assert!(out.value.get("_gone").is_none());
792        assert_eq!(out.report.of_kind(LossKind::ElementRemoved).count(), 1);
793    }
794}