use ::serde_json::{Map, Value};
use crate::meta::{self, ElementMeta};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum LossKind {
ElementRemoved,
ResourceRemoved,
NotAResource,
ChoiceVariantUnsupported,
CardinalityNarrowed,
TypeChanged,
RequiredMissing,
BindingChanged,
}
impl LossKind {
#[must_use]
pub fn discards_data(self) -> bool {
!matches!(self, Self::RequiredMissing | Self::BindingChanged)
}
}
impl ::std::fmt::Display for LossKind {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
let s = match self {
Self::ElementRemoved => "element not in target",
Self::ResourceRemoved => "resource type not in target",
Self::NotAResource => "not a resource",
Self::ChoiceVariantUnsupported => "choice variant not in target",
Self::CardinalityNarrowed => "does not repeat in target",
Self::TypeChanged => "incompatible type in target",
Self::RequiredMissing => "required by target but absent",
Self::BindingChanged => "different required binding in target",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Loss {
pub path: String,
pub kind: LossKind,
pub detail: String,
}
impl ::std::fmt::Display for Loss {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
write!(f, "{}: {} ({})", self.path, self.kind, self.detail)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LossReport {
losses: Vec<Loss>,
}
impl LossReport {
#[must_use]
pub fn is_lossless(&self) -> bool {
self.losses.is_empty()
}
#[must_use]
pub fn discarded_data(&self) -> bool {
self.losses.iter().any(|l| l.kind.discards_data())
}
#[must_use]
pub fn len(&self) -> usize {
self.losses.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.losses.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &Loss> {
self.losses.iter()
}
pub fn of_kind(&self, kind: LossKind) -> impl Iterator<Item = &Loss> {
self.losses.iter().filter(move |l| l.kind == kind)
}
}
impl<'a> IntoIterator for &'a LossReport {
type Item = &'a Loss;
type IntoIter = ::std::slice::Iter<'a, Loss>;
fn into_iter(self) -> Self::IntoIter {
self.losses.iter()
}
}
impl ::std::fmt::Display for LossReport {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
if self.losses.is_empty() {
return f.write_str("lossless");
}
for (i, loss) in self.losses.iter().enumerate() {
if i > 0 {
f.write_str("\n")?;
}
write!(f, "{loss}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Converted {
pub value: Value,
pub report: LossReport,
}
impl Converted {
pub fn strict(self) -> Result<Value, LossReport> {
if self.report.is_lossless() {
Ok(self.value)
} else {
Err(self.report)
}
}
}
#[must_use]
pub fn resource(
source: &'static [ElementMeta],
target: &'static [ElementMeta],
value: &Value,
) -> Converted {
let mut losses = Vec::new();
let converted = convert_resource(source, target, value, "", &mut losses);
Converted {
value: converted.unwrap_or(Value::Null),
report: LossReport { losses },
}
}
fn convert_resource(
source: &'static [ElementMeta],
target: &'static [ElementMeta],
value: &Value,
path: &str,
losses: &mut Vec<Loss>,
) -> Option<Value> {
let type_name = value
.as_object()
.and_then(|o| o.get("resourceType"))
.and_then(Value::as_str);
let Some(type_name) = type_name else {
losses.push(Loss {
path: if path.is_empty() {
"(root)".to_string()
} else {
path.to_string()
},
kind: LossKind::NotAResource,
detail: "no resourceType; serialize the release's Resource enum, \
which carries the tag, rather than the resource struct"
.to_string(),
});
return None;
};
let obj = value.as_object()?;
let here = if path.is_empty() {
type_name.to_string()
} else {
path.to_string()
};
if !has_type(target, type_name) {
losses.push(Loss {
path: here,
kind: LossKind::ResourceRemoved,
detail: format!("no {type_name} in the target release"),
});
return None;
}
let mut ctx = Ctx {
source,
target,
losses,
};
Some(Value::Object(ctx.object(obj, type_name, type_name, &here)))
}
fn has_type(table: &'static [ElementMeta], name: &str) -> bool {
let prefix = format!("{name}.");
table.iter().any(|e| e.path.starts_with(&prefix))
}
struct Ctx<'a> {
source: &'static [ElementMeta],
target: &'static [ElementMeta],
losses: &'a mut Vec<Loss>,
}
impl Ctx<'_> {
fn object(
&mut self,
obj: &Map<String, Value>,
src_context: &str,
tgt_context: &str,
path: &str,
) -> Map<String, Value> {
let src_context = resolve_recursion(self.source, src_context);
let tgt_context = resolve_recursion(self.target, tgt_context);
let mut out = Map::new();
for (key, value) in obj {
if key == "resourceType" {
out.insert(key.clone(), value.clone());
continue;
}
let sibling = key.starts_with('_');
let base = key.strip_prefix('_').unwrap_or(key);
let here = format!("{path}.{key}");
let src_meta = meta::resolve(
self.source,
&format!("{src_context}.{base}"),
src_context,
base,
);
let Some(tgt_meta) = meta::resolve(
self.target,
&format!("{tgt_context}.{base}"),
tgt_context,
base,
) else {
if !sibling || !obj.contains_key(base) {
self.losses.push(Loss {
path: here,
kind: LossKind::ElementRemoved,
detail: format!("{tgt_context} has no {base}"),
});
}
continue;
};
let src_type = src_meta.and_then(|m| chosen_type(m, base));
let tgt_type = chosen_type(tgt_meta, base);
if tgt_meta.is_choice() && tgt_type.is_none() {
if !sibling || !obj.contains_key(base) {
let allowed = tgt_meta.type_codes().collect::<Vec<_>>().join(", ");
self.losses.push(Loss {
path: here,
kind: LossKind::ChoiceVariantUnsupported,
detail: format!("{} allows only: {allowed}", tgt_meta.path),
});
}
continue;
}
if !sibling
&& let (Some(s), Some(t)) = (src_type, tgt_type)
&& meta::json_kind(s) != meta::json_kind(t)
{
self.losses.push(Loss {
path: here,
kind: LossKind::TypeChanged,
detail: format!("{s} in the source, {t} in the target"),
});
continue;
}
if !sibling {
self.check_binding(src_meta, tgt_meta, &here);
}
let value = self.fit_cardinality(value, tgt_meta, &here);
let (child_src, child_tgt) = if sibling {
("Element", "Element")
} else {
(
src_meta.map_or(src_context, |m| child_context(m, src_type)),
child_context(tgt_meta, tgt_type),
)
};
let converted = self.value(&value, child_src, child_tgt, tgt_type, &here);
out.insert(key.clone(), converted);
}
self.check_required(&out, tgt_context, path);
out
}
fn value(
&mut self,
value: &Value,
src_context: &str,
tgt_context: &str,
type_code: Option<&str>,
path: &str,
) -> Value {
match value {
Value::Array(items) => Value::Array(
items
.iter()
.enumerate()
.map(|(i, item)| {
let at = format!("{path}[{i}]");
self.value(item, src_context, tgt_context, type_code, &at)
})
.collect(),
),
Value::Object(obj) => {
if type_code == Some("Resource") || obj.contains_key("resourceType") {
return convert_resource(self.source, self.target, value, path, self.losses)
.unwrap_or(Value::Null);
}
Value::Object(self.object(obj, src_context, tgt_context, path))
}
other => other.clone(),
}
}
fn fit_cardinality(
&mut self,
value: &Value,
tgt_meta: &'static ElementMeta,
path: &str,
) -> Value {
let Some(items) = value.as_array() else {
if tgt_meta.is_multiple() && !value.is_null() {
return Value::Array(vec![value.clone()]);
}
return value.clone();
};
if tgt_meta.is_multiple() || items.len() <= 1 {
if !tgt_meta.is_multiple() && items.len() == 1 {
return items[0].clone();
}
return value.clone();
}
self.losses.push(Loss {
path: path.to_string(),
kind: LossKind::CardinalityNarrowed,
detail: format!(
"{} entries, but {} is {}..{}",
items.len(),
tgt_meta.path,
tgt_meta.min,
tgt_meta.max
),
});
items[0].clone()
}
fn check_binding(
&mut self,
src_meta: Option<&'static ElementMeta>,
tgt_meta: &'static ElementMeta,
path: &str,
) {
let Some(tgt) = tgt_meta.binding else { return };
if tgt.strength != meta::BindingStrength::Required {
return;
}
let src = src_meta.and_then(|m| m.binding);
let same = src.is_some_and(|s| {
s.strength == meta::BindingStrength::Required
&& canonical_vs(s.value_set) == canonical_vs(tgt.value_set)
});
if same {
return;
}
self.losses.push(Loss {
path: path.to_string(),
kind: LossKind::BindingChanged,
detail: match src.and_then(|s| s.value_set) {
Some(from) => format!("{from} → {}", tgt.value_set.unwrap_or("(none)")),
None => format!("now required: {}", tgt.value_set.unwrap_or("(none)")),
},
});
}
fn check_required(&mut self, out: &Map<String, Value>, tgt_context: &str, path: &str) {
let prefix = format!("{tgt_context}.");
for el in self.target.iter().filter(|e| e.path.starts_with(&prefix)) {
let Some(name) = el.path.strip_prefix(&prefix) else {
continue;
};
if !el.is_required() || name.contains('.') {
continue;
}
let present = if el.is_choice() {
let base = name.trim_end_matches("[x]");
out.keys()
.any(|k| meta::choice_suffix(el, k).is_some() && k.starts_with(base))
} else {
out.contains_key(name)
};
if !present {
self.losses.push(Loss {
path: format!("{path}.{name}"),
kind: LossKind::RequiredMissing,
detail: format!("{} is {}..{}", el.path, el.min, el.max),
});
}
}
}
}
fn resolve_recursion<'a>(table: &'static [ElementMeta], context: &'a str) -> &'a str {
let mut at = context;
for _ in 0..8 {
if has_type(table, at) {
return at;
}
match meta::find(table, at).and_then(|e| e.content_reference) {
Some(target) => at = target,
None => return at,
}
}
at
}
fn child_context(el: &'static ElementMeta, type_code: Option<&'static str>) -> &'static str {
match type_code {
Some(code) if meta::is_datatype(code) => code,
_ => el.path,
}
}
fn chosen_type(el: &'static ElementMeta, key: &str) -> Option<&'static str> {
if el.is_choice() {
let suffix = meta::choice_suffix(el, key)?;
return el.type_codes().find(|c| c.eq_ignore_ascii_case(suffix));
}
el.types.first().map(|t| t.code)
}
fn canonical_vs(url: Option<&'static str>) -> Option<&'static str> {
url.map(|u| u.split('|').next().unwrap_or(u))
}
#[cfg(test)]
mod tests {
use super::*;
const EMPTY: &[&str] = &[];
macro_rules! el {
($path:expr, $min:expr, $max:expr, $ty:expr) => {
ElementMeta {
path: $path,
min: $min,
max: $max,
is_summary: false,
binding: None,
types: &[TypeRef {
code: $ty,
target_profiles: EMPTY,
}],
content_reference: None,
}
};
}
use crate::meta::TypeRef;
static SRC: &[ElementMeta] = &[
el!("Thing.gone", 0, "1", "string"),
el!("Thing.kept", 0, "1", "string"),
el!("Thing.many", 0, "*", "string"),
el!("Thing.num", 0, "1", "string"),
];
static TGT: &[ElementMeta] = &[
el!("Thing.kept", 0, "1", "string"),
el!("Thing.many", 0, "1", "string"),
el!("Thing.needed", 1, "1", "string"),
el!("Thing.num", 0, "1", "integer"),
];
#[test]
fn the_fixtures_are_sorted() {
for table in [SRC, TGT] {
assert!(
table.windows(2).all(|w| w[0].path < w[1].path),
"an unsorted table silently breaks the binary search in meta::find"
);
}
}
fn convert(json: &str) -> Converted {
resource(SRC, TGT, &::serde_json::from_str(json).unwrap())
}
#[test]
fn drops_an_element_the_target_lacks() {
let out = convert(r#"{"resourceType":"Thing","gone":"x","kept":"y"}"#);
assert_eq!(out.value["kept"], "y");
assert!(out.value.get("gone").is_none());
let loss = out.report.of_kind(LossKind::ElementRemoved).next().unwrap();
assert_eq!(loss.path, "Thing.gone");
}
#[test]
fn narrows_a_repeating_element_and_says_how_much() {
let out = convert(r#"{"resourceType":"Thing","many":["a","b","c"]}"#);
assert_eq!(out.value["many"], "a");
let loss = out
.report
.of_kind(LossKind::CardinalityNarrowed)
.next()
.unwrap();
assert!(loss.detail.contains("3 entries"));
}
#[test]
fn drops_a_value_whose_json_kind_changed() {
let out = convert(r#"{"resourceType":"Thing","num":"12"}"#);
assert!(out.value.get("num").is_none());
assert_eq!(
out.report.of_kind(LossKind::TypeChanged).count(),
1,
"a string cannot be carried into an integer element"
);
}
#[test]
fn reports_a_required_element_it_cannot_invent() {
let out = convert(r#"{"resourceType":"Thing","kept":"y"}"#);
let loss = out
.report
.of_kind(LossKind::RequiredMissing)
.next()
.unwrap();
assert_eq!(loss.path, "Thing.needed");
assert!(
!loss.kind.discards_data(),
"nothing was dropped; the result merely will not validate"
);
}
#[test]
fn a_document_with_no_resource_type_is_reported_not_silently_nulled() {
let out = resource(SRC, TGT, &::serde_json::json!({"kept": "y"}));
assert_eq!(out.value, Value::Null);
assert!(
!out.report.is_lossless(),
"a null result needs an explanation"
);
assert_eq!(out.report.of_kind(LossKind::NotAResource).count(), 1);
}
#[test]
fn an_unknown_resource_type_yields_null_not_an_empty_object() {
let out = resource(SRC, TGT, &::serde_json::json!({"resourceType": "Other"}));
assert_eq!(out.value, Value::Null);
assert_eq!(out.report.of_kind(LossKind::ResourceRemoved).count(), 1);
}
#[test]
fn a_lossless_conversion_says_so() {
let out = convert(r#"{"resourceType":"Thing","kept":"y","needed":"z"}"#);
assert!(out.report.is_lossless(), "{}", out.report);
assert!(!out.report.discarded_data());
}
#[test]
fn strict_passes_a_clean_conversion_through() {
let out = convert(r#"{"resourceType":"Thing","kept":"y","needed":"z"}"#);
let value = out.strict().expect("nothing was lost");
assert_eq!(value["kept"], "y");
}
#[test]
fn strict_refuses_a_lossy_one_and_hands_back_the_reason() {
let out = convert(r#"{"resourceType":"Thing","gone":"x","needed":"z"}"#);
let report = out.strict().expect_err("an element was dropped");
assert_eq!(report.of_kind(LossKind::ElementRemoved).count(), 1);
}
#[test]
fn strict_refuses_a_warning_too_even_though_nothing_was_dropped() {
let out = convert(r#"{"resourceType":"Thing","kept":"y"}"#);
assert!(!out.report.discarded_data(), "nothing was dropped");
assert!(out.strict().is_err(), "and yet it must not pass strict");
}
#[test]
fn a_primitive_extension_sibling_follows_its_element() {
let out = convert(r#"{"resourceType":"Thing","gone":"x","_gone":{"id":"a"}}"#);
assert!(out.value.get("_gone").is_none());
assert_eq!(out.report.of_kind(LossKind::ElementRemoved).count(), 1);
}
}