#![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::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::model::attributes(ty) {
if !attr.is_mandatory
|| matches!(attr.container, crate::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
)));
}
_ => {}
}
}
}
pub fn nonempty_list_violations(ty: &str, value: &Value, out: &mut Vec<InvariantViolation>) {
for (class, attribute, invariant) in generated::NONEMPTY_LIST_RULES {
if *class != ty && !crate::model::descendants(class).contains(&ty) {
continue;
}
let present_and_empty = value
.get(*attribute)
.and_then(Value::as_array)
.is_some_and(Vec::is_empty);
generated::nonempty_list_core(ty, attribute, invariant, present_and_empty, out);
}
}
#[must_use]
pub fn check_declared_slot_type(
parent_type: &str,
field: &str,
wire_type: &str,
) -> Option<InvariantViolation> {
let attr = crate::model::attribute(parent_type, field)?;
if matches!(attr.container, crate::model::Container::Hash) {
return None;
}
crate::model::class(attr.declared_type)?;
if crate::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::model::attribute(parent_type, field)?;
if matches!(attr.container, crate::model::Container::Hash) {
return None;
}
crate::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::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 {
(0..=4).contains(&k)
}
fn all_digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
fn part(s: &str, range: std::ops::Range<usize>) -> &str {
s.get(range).unwrap_or_default()
}
fn digits_n(s: &str, n: usize) -> bool {
s.len() == n && all_digits(s)
}
fn in_range(s: &str, lo: u32, hi: u32) -> bool {
s.len() == 2 && all_digits(s) && s.parse::<u32>().is_ok_and(|v| (lo..=hi).contains(&v))
}
fn is_leap_year(year: u32) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
fn days_in_month(year: u32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
fn valid_day(y: &str, m: &str, d: &str) -> bool {
if d.len() != 2 || !all_digits(d) {
return false;
}
let (Ok(year), Ok(month), Ok(day)) = (y.parse::<u32>(), m.parse::<u32>(), d.parse::<u32>())
else {
return false;
};
(1..=days_in_month(year, month)).contains(&day)
}
#[must_use]
pub(crate) fn is_valid_iso_date(s: &str) -> bool {
if s.contains('-') {
match s.split('-').collect::<Vec<_>>().as_slice() {
[y] => digits_n(y, 4),
[y, m] => digits_n(y, 4) && in_range(m, 1, 12),
[y, m, d] => digits_n(y, 4) && in_range(m, 1, 12) && valid_day(y, m, d),
_ => false,
}
} else {
match s.len() {
4 => all_digits(s),
6 => all_digits(s) && in_range(part(s, 4..6), 1, 12),
8 => {
all_digits(s)
&& in_range(part(s, 4..6), 1, 12)
&& valid_day(part(s, 0..4), part(s, 4..6), part(s, 6..8))
}
_ => false,
}
}
}
fn is_valid_tz(tz: &str) -> bool {
if tz.is_empty() || tz == "Z" {
return true;
}
let Some(rest) = tz.strip_prefix(['+', '-']) else {
return false;
};
let max_hour = if tz.starts_with('+') { 14 } else { 12 };
if rest.contains(':') {
matches!(rest.split(':').collect::<Vec<_>>().as_slice(),
[h, m] if in_range(h, 0, max_hour) && in_range(m, 0, 59))
} else {
match rest.len() {
2 => in_range(rest, 0, max_hour),
4 => in_range(part(rest, 0..2), 0, max_hour) && in_range(part(rest, 2..4), 0, 59),
_ => false,
}
}
}
fn is_valid_time_core(s: &str) -> bool {
let (base, frac) = match s.split_once(['.', ',']) {
Some((b, f)) => (b, Some(f)),
None => (s, None),
};
if let Some(f) = frac
&& !all_digits(f)
{
return false;
}
let has_frac = frac.is_some();
if base.contains(':') {
match base.split(':').collect::<Vec<_>>().as_slice() {
[h] => !has_frac && in_range(h, 0, 23),
[h, m] => !has_frac && in_range(h, 0, 23) && in_range(m, 0, 59),
[h, m, sec] => in_range(h, 0, 23) && in_range(m, 0, 59) && in_range(sec, 0, 60),
_ => false,
}
} else {
match base.len() {
2 => !has_frac && in_range(base, 0, 23),
4 => {
!has_frac && in_range(part(base, 0..2), 0, 23) && in_range(part(base, 2..4), 0, 59)
}
6 => {
in_range(part(base, 0..2), 0, 23)
&& in_range(part(base, 2..4), 0, 59)
&& in_range(part(base, 4..6), 0, 60)
}
_ => false,
}
}
}
#[must_use]
pub(crate) fn is_valid_iso_time(s: &str) -> bool {
if let Some(stripped) = s.strip_suffix('Z') {
return is_valid_time_core(stripped);
}
if let Some((core, tz)) = s.rfind(['+', '-']).and_then(|pos| s.split_at_checked(pos)) {
return is_valid_time_core(core) && is_valid_tz(tz);
}
is_valid_time_core(s)
}
#[must_use]
pub(crate) fn is_valid_iso_date_time(s: &str) -> bool {
match s.split_once('T') {
Some((date, time)) => is_valid_iso_date(date) && is_valid_iso_time(time),
None => is_valid_iso_date(s),
}
}
fn parse_duration_components(s: &str, allowed: &[u8], any: &mut bool) -> bool {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let start = i;
while bytes.get(i).is_some_and(u8::is_ascii_digit) {
i += 1;
}
let mut has_fraction = false;
if matches!(bytes.get(i), Some(b'.' | b',')) {
has_fraction = true;
i += 1;
while bytes.get(i).is_some_and(u8::is_ascii_digit) {
i += 1;
}
}
let Some(&designator) = bytes.get(i) else {
return false;
};
if i == start {
return false;
}
if !allowed.contains(&designator) {
return false;
}
if has_fraction && designator != b'S' {
return false;
}
i += 1;
*any = true;
}
true
}
#[must_use]
pub(crate) fn is_valid_iso_duration(s: &str) -> bool {
let s = s.strip_prefix(['+', '-']).unwrap_or(s);
let Some(rest) = s.strip_prefix('P') else {
return false;
};
let (date_part, time_part) = match rest.split_once('T') {
Some((d, t)) => (d, Some(t)),
None => (rest, None),
};
let mut any = false;
if !parse_duration_components(date_part, b"YMWD", &mut any) {
return false;
}
if let Some(t) = time_part
&& (t.is_empty() || !parse_duration_components(t, b"HMS", &mut any))
{
return false;
}
any
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iso_date_forms() {
assert!(is_valid_iso_date("2021"));
assert!(is_valid_iso_date("2021-05"));
assert!(is_valid_iso_date("2021-05-17"));
assert!(is_valid_iso_date("20210517"));
assert!(!is_valid_iso_date("2021-13"));
assert!(!is_valid_iso_date("2021-05-32"));
assert!(!is_valid_iso_date("not-a-date"));
assert!(!is_valid_iso_date(""));
}
#[test]
fn iso_date_day_is_calendar_exact() {
assert!(is_valid_iso_date("2021-01-31"));
assert!(is_valid_iso_date("2021-12-31"));
assert!(!is_valid_iso_date("2021-04-31")); assert!(!is_valid_iso_date("2021-06-31"));
assert!(!is_valid_iso_date("2021-09-31"));
assert!(!is_valid_iso_date("2021-11-31"));
assert!(is_valid_iso_date("2021-04-30"));
assert!(!is_valid_iso_date("2021-02-31"));
assert!(!is_valid_iso_date("2021-02-30"));
assert!(!is_valid_iso_date("2021-02-29")); assert!(is_valid_iso_date("2021-02-28"));
assert!(is_valid_iso_date("2020-02-29")); assert!(is_valid_iso_date("2000-02-29")); assert!(!is_valid_iso_date("1900-02-29"));
assert!(!is_valid_iso_date("2021-05-00"));
assert!(!is_valid_iso_date("20210431"));
assert!(!is_valid_iso_date("20210229"));
assert!(is_valid_iso_date("20200229"));
assert!(is_valid_iso_date("20210131"));
}
#[test]
fn iso_time_forms() {
assert!(is_valid_iso_time("10"));
assert!(is_valid_iso_time("10:30"));
assert!(is_valid_iso_time("10:30:59"));
assert!(is_valid_iso_time("10:30:59.250"));
assert!(is_valid_iso_time("10:30:59Z"));
assert!(is_valid_iso_time("10:30:59+01:00"));
assert!(!is_valid_iso_time("25:00"));
assert!(!is_valid_iso_time("10:61"));
assert!(!is_valid_iso_time("abc"));
}
#[test]
fn iso_time_fraction_only_on_seconds() {
assert!(is_valid_iso_time("10:30:59.250"));
assert!(is_valid_iso_time("10:30:59,5"));
assert!(is_valid_iso_time("103059.250")); assert!(is_valid_iso_time("10:30:59.5+01:00")); assert!(!is_valid_iso_time("10.5")); assert!(!is_valid_iso_time("10:05.5")); assert!(!is_valid_iso_time("1005.5")); assert!(!is_valid_iso_time("10,5")); }
#[test]
fn iso_date_time_forms() {
assert!(is_valid_iso_date_time("2021-05-17T10:30:00"));
assert!(is_valid_iso_date_time("2021-05-17T10:30:00+02:00"));
assert!(is_valid_iso_date_time("2021-05-17"));
assert!(!is_valid_iso_date_time("2021-05-17T99:00"));
assert!(!is_valid_iso_date_time("nope"));
}
#[test]
fn iso_duration_forms() {
assert!(is_valid_iso_duration("P1Y"));
assert!(is_valid_iso_duration("P1Y2M10D"));
assert!(is_valid_iso_duration("PT2H30M"));
assert!(is_valid_iso_duration("P1Y2M10DT2H30M"));
assert!(is_valid_iso_duration("P2W"));
assert!(is_valid_iso_duration("-P1D"));
assert!(is_valid_iso_duration("PT0.5S"));
assert!(!is_valid_iso_duration("P"));
assert!(!is_valid_iso_duration("1Y"));
assert!(!is_valid_iso_duration("P1X"));
assert!(!is_valid_iso_duration("PT"));
}
#[test]
fn iso_duration_fraction_only_on_seconds() {
assert!(is_valid_iso_duration("PT2H30M0.5S"));
assert!(is_valid_iso_duration("PT0,5S"));
assert!(!is_valid_iso_duration("P1Y3M4DT2.5H"));
assert!(!is_valid_iso_duration("PT2H14.5M"));
assert!(!is_valid_iso_duration("P1.5Y"));
assert!(!is_valid_iso_duration("P1.5M"));
assert!(!is_valid_iso_duration("P1.5W"));
assert!(!is_valid_iso_duration("P1.5D"));
assert!(!is_valid_iso_duration("PT1.5H"));
assert!(!is_valid_iso_duration("PT2H14,5M"));
}
#[test]
fn timezone_bounds_are_asymmetric() {
assert!(is_valid_iso_time("10:00:00+14:00"));
assert!(is_valid_iso_time("10:00:00-12:00"));
assert!(is_valid_iso_time("10:00:00+00:00"));
assert!(is_valid_iso_time("10:00:00-00:00"));
assert!(
!is_valid_iso_time("10:00:00+15:00"),
"+15 exceeds Max_timezone_hour"
);
assert!(
!is_valid_iso_time("10:00:00-13:00"),
"-13 exceeds Min_timezone_hour"
);
}
}