#![expect(
clippy::disallowed_types,
reason = "the wire-boundary validation reads the canonical JSON node before the typed decode \
(#1694 boundary class)"
)]
use openehr_base::validate::InvariantViolation;
use serde_json::Value;
mod fast;
pub(crate) mod generated;
pub mod incomplete;
pub mod terminology;
pub mod typed_dispatch;
#[must_use]
pub fn try_fast_validate(ty: &str, value: &Value, out: &mut Vec<InvariantViolation>) -> bool {
fast::try_validate(ty, value, out)
}
#[must_use]
pub fn locatable_node_id_violation(ty: &str, value: &Value) -> Option<InvariantViolation> {
if value.get("archetype_node_id").and_then(Value::as_str) != Some("") {
return None;
}
if !crate::v1_1::model::descendants("LOCATABLE").contains(&ty) {
return None;
}
let mut out = Vec::with_capacity(1);
generated::archetype_node_id_core(ty, "", &mut out);
out.pop()
}
pub fn check_mandatory_containers(ty: &str, value: &Value, out: &mut Vec<InvariantViolation>) {
for attr in crate::v1_1::model::attributes(ty) {
if !attr.is_mandatory
|| matches!(attr.container, crate::v1_1::model::Container::None)
|| attr.declared_type == "Octet"
{
continue;
}
match value.get(attr.name) {
None | Some(Value::Null) => {
out.push(InvariantViolation::here(format!(
"does not conform to RM type {ty}: mandatory container `{}` is absent",
attr.name
)));
}
Some(Value::Array(a))
if a.is_empty() && attr.cardinality.is_some_and(|c| c.lower >= 1) =>
{
out.push(InvariantViolation::here(format!(
"does not conform to RM type {ty}: mandatory container `{}` is empty \
(cardinality lower bound 1)",
attr.name
)));
}
_ => {}
}
}
}
#[must_use]
pub fn check_declared_slot_type(
parent_type: &str,
field: &str,
wire_type: &str,
) -> Option<InvariantViolation> {
let attr = crate::v1_1::model::attribute(parent_type, field)?;
if matches!(attr.container, crate::v1_1::model::Container::Hash) {
return None;
}
crate::v1_1::model::class(attr.declared_type)?;
if crate::v1_1::model::is_a(wire_type, attr.declared_type) {
return None;
}
Some(InvariantViolation::here(format!(
"does not conform to RM type {parent_type}: `{field}` is declared \
{declared} and this member claims `_type` {wire_type}, which is not \
a {declared}",
declared = attr.declared_type,
)))
}
#[must_use]
pub fn check_slot_member_is_object(parent_type: &str, field: &str) -> Option<InvariantViolation> {
let attr = crate::v1_1::model::attribute(parent_type, field)?;
if matches!(attr.container, crate::v1_1::model::Container::Hash) {
return None;
}
crate::v1_1::model::class(attr.declared_type)?;
Some(InvariantViolation::here(format!(
"does not conform to RM type {parent_type}: `{field}` is declared \
{declared} and this member is not a JSON object",
declared = attr.declared_type,
)))
}
#[must_use]
pub fn check_archetyped_valid(
node_id: Option<&str>,
has_archetype_details: bool,
details_archetype_id: Option<&str>,
) -> Vec<InvariantViolation> {
let mut out = Vec::new();
let Some(node_id) = node_id else {
return out;
};
if crate::v1_1::paths::archetype_node_id_is_term_code(node_id) && has_archetype_details {
out.push(InvariantViolation::here(format!(
"node {node_id:?} is not an archetype root (at/id term code) and must \
not carry archetype_details (LOCATABLE.Archetyped_valid)"
)));
}
if let Some(archetype_id) = details_archetype_id
&& archetype_id != node_id
{
out.push(InvariantViolation::here(format!(
"archetype root archetype_node_id {node_id:?} is not the stringified \
archetype_details.archetype_id {archetype_id:?} — at an archetype root \
the two are always the same value (LOCATABLE.archetype_node_id)"
)));
}
out
}
#[must_use]
pub fn check_cluster_items_present(
obj: &serde_json::Map<String, Value>,
ty: Option<&str>,
) -> Option<InvariantViolation> {
(ty == Some("CLUSTER") && obj.get("items").and_then(Value::as_array).is_none()).then(|| {
InvariantViolation::here("CLUSTER.items is mandatory (1..1 List<ITEM>, cluster.adoc)")
})
}
#[must_use]
pub fn check_data_structure_shapes(
obj: &serde_json::Map<String, Value>,
ty: Option<&str>,
) -> Vec<InvariantViolation> {
let mut out = Vec::new();
let Some(ty) = ty else {
return out;
};
if ty == "HISTORY"
&& let Some(events) = obj.get("events").and_then(Value::as_array)
{
let mut first: Option<&str> = None;
for (i, event) in events.iter().enumerate() {
let Some(data_ty) = event.pointer("/data/_type").and_then(Value::as_str) else {
continue;
};
match first {
None => first = Some(data_ty),
Some(locked) if locked != data_ty => {
out.push(InvariantViolation::at(
format!("events[{i}]"),
format!(
"HISTORY events must all carry the same ITEM_STRUCTURE \
subtype in data — the history is HISTORY<{locked}> but \
this event carries {data_ty} (RM data_structures \
master06 §History)"
),
));
}
Some(_) => {}
}
}
}
out
}
#[must_use]
pub(crate) fn invariant_failed(name: &str, rm_type: &str) -> InvariantViolation {
InvariantViolation::here(format!("Invariant {name} failed on type {rm_type}"))
}
#[must_use]
#[expect(
clippy::float_cmp,
reason = "an exact-integrality test is precisely a bit-equality question (`x.floor() == x`), not a tolerance comparison"
)]
pub(crate) fn is_integral(v: f64) -> bool {
v.is_finite() && v.floor() == v
}
#[must_use]
pub(crate) fn valid_magnitude_status(s: &str) -> bool {
matches!(s, "=" | "<" | ">" | "<=" | ">=" | "~")
}
#[must_use]
pub(crate) fn valid_percentage(v: f64) -> bool {
(0.0..=100.0).contains(&v)
}
#[must_use]
pub(crate) fn valid_proportion_kind(k: i32) -> bool {
!matches!(
crate::v1_1::data_types::quantity::proportion_kind::ProportionKind::from_value(k),
crate::v1_1::data_types::quantity::proportion_kind::ProportionKind::Other(_)
)
}
pub(crate) use openehr_base::v1_2::foundation_types::time::time_definitions::{
valid_iso8601_date, valid_iso8601_date_time, valid_iso8601_duration, valid_iso8601_time,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iso_date_forms() {
assert!(valid_iso8601_date("2021"));
assert!(valid_iso8601_date("2021-05"));
assert!(valid_iso8601_date("2021-05-17"));
assert!(valid_iso8601_date("20210517"));
assert!(!valid_iso8601_date("2021-13"));
assert!(!valid_iso8601_date("2021-05-32"));
assert!(!valid_iso8601_date("not-a-date"));
assert!(!valid_iso8601_date(""));
}
#[test]
fn iso_date_day_is_calendar_exact() {
assert!(valid_iso8601_date("2021-01-31"));
assert!(valid_iso8601_date("2021-12-31"));
assert!(!valid_iso8601_date("2021-04-31")); assert!(!valid_iso8601_date("2021-06-31"));
assert!(!valid_iso8601_date("2021-09-31"));
assert!(!valid_iso8601_date("2021-11-31"));
assert!(valid_iso8601_date("2021-04-30"));
assert!(!valid_iso8601_date("2021-02-31"));
assert!(!valid_iso8601_date("2021-02-30"));
assert!(!valid_iso8601_date("2021-02-29")); assert!(valid_iso8601_date("2021-02-28"));
assert!(valid_iso8601_date("2020-02-29")); assert!(valid_iso8601_date("2000-02-29")); assert!(!valid_iso8601_date("1900-02-29"));
assert!(!valid_iso8601_date("2021-05-00"));
assert!(!valid_iso8601_date("20210431"));
assert!(!valid_iso8601_date("20210229"));
assert!(valid_iso8601_date("20200229"));
assert!(valid_iso8601_date("20210131"));
}
#[test]
fn iso_time_forms() {
assert!(valid_iso8601_time("10"));
assert!(valid_iso8601_time("10:30"));
assert!(valid_iso8601_time("10:30:59"));
assert!(valid_iso8601_time("10:30:59.250"));
assert!(valid_iso8601_time("10:30:59Z"));
assert!(valid_iso8601_time("10:30:59+01:00"));
assert!(!valid_iso8601_time("25:00"));
assert!(!valid_iso8601_time("10:61"));
assert!(!valid_iso8601_time("abc"));
}
#[test]
fn iso_time_fraction_only_on_seconds() {
assert!(valid_iso8601_time("10:30:59.250"));
assert!(valid_iso8601_time("10:30:59,5"));
assert!(valid_iso8601_time("103059.250")); assert!(valid_iso8601_time("10:30:59.5+01:00")); assert!(!valid_iso8601_time("10.5")); assert!(!valid_iso8601_time("10:05.5")); assert!(!valid_iso8601_time("1005.5")); assert!(!valid_iso8601_time("10,5")); }
#[test]
fn iso_date_time_forms() {
assert!(valid_iso8601_date_time("2021-05-17T10:30:00"));
assert!(valid_iso8601_date_time("2021-05-17T10:30:00+02:00"));
assert!(valid_iso8601_date_time("2021-05-17"));
assert!(!valid_iso8601_date_time("2021-05-17T99:00"));
assert!(!valid_iso8601_date_time("nope"));
}
#[test]
fn iso_duration_forms() {
assert!(valid_iso8601_duration("P1Y"));
assert!(valid_iso8601_duration("P1Y2M10D"));
assert!(valid_iso8601_duration("PT2H30M"));
assert!(valid_iso8601_duration("P1Y2M10DT2H30M"));
assert!(valid_iso8601_duration("P2W"));
assert!(valid_iso8601_duration("-P1D"));
assert!(valid_iso8601_duration("PT0.5S"));
assert!(!valid_iso8601_duration("P"));
assert!(!valid_iso8601_duration("1Y"));
assert!(!valid_iso8601_duration("P1X"));
assert!(!valid_iso8601_duration("PT"));
}
#[test]
fn time_validity_agrees_with_the_base_reader() {
for value in [
"10:30:00", "10:30:59", "23:59:59", "10:30:60", "103060", "25:00:00",
] {
let base = openehr_base::v1_2::foundation_types::time::iso8601_time::Iso8601Time {
value: value.to_owned(),
}
.hour()
.is_some();
assert_eq!(
valid_iso8601_time(value),
base,
"{value:?}: the RM validator and the BASE reader disagree",
);
}
}
#[test]
fn duration_validity_agrees_with_the_base_reader() {
for value in [
"P1Y",
"P1Y2M10DT2H30M",
"P2W",
"PT0.5S",
"P1Y1Y",
"P2Y1Y",
"P1D1M",
"PT1S1H",
"P1YT",
"PT1H2H",
"P1X",
"PT",
] {
let base =
openehr_base::v1_2::foundation_types::time::iso8601_duration::Iso8601Duration {
value: value.to_owned(),
}
.to_seconds()
.is_some();
assert_eq!(
valid_iso8601_duration(value),
base,
"{value:?}: the RM validator and the BASE reader disagree",
);
}
}
#[test]
fn iso_duration_fraction_only_on_seconds() {
assert!(valid_iso8601_duration("PT2H30M0.5S"));
assert!(valid_iso8601_duration("PT0,5S"));
assert!(!valid_iso8601_duration("P1Y3M4DT2.5H"));
assert!(!valid_iso8601_duration("PT2H14.5M"));
assert!(!valid_iso8601_duration("P1.5Y"));
assert!(!valid_iso8601_duration("P1.5M"));
assert!(!valid_iso8601_duration("P1.5W"));
assert!(!valid_iso8601_duration("P1.5D"));
assert!(!valid_iso8601_duration("PT1.5H"));
assert!(!valid_iso8601_duration("PT2H14,5M"));
}
#[test]
fn timezone_bounds_are_asymmetric() {
assert!(valid_iso8601_time("10:00:00+14:00"));
assert!(valid_iso8601_time("10:00:00-12:00"));
assert!(valid_iso8601_time("10:00:00+00:00"));
assert!(valid_iso8601_time("10:00:00-00:00"));
assert!(
!valid_iso8601_time("10:00:00+15:00"),
"+15 exceeds Max_timezone_hour"
);
assert!(
!valid_iso8601_time("10:00:00-13:00"),
"-13 exceeds Min_timezone_hour"
);
}
}