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}