#![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 openehr_term::bundle::{OpenehrTerminology, openehr};
use serde_json::Value;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Group {
CompositionCategory,
Setting,
NullFlavour,
InstructionState,
InstructionTransition,
ParticipationFunction,
ParticipationMode,
EventMathFunction,
TermMappingPurpose,
AuditChangeType,
AttestationReason,
SubjectRelationship,
VersionLifecycleState,
}
impl Group {
#[must_use]
pub fn is_valid(self, t: &OpenehrTerminology, code: &str) -> bool {
match self {
Group::CompositionCategory => t.is_valid_code("composition_category", code),
Group::Setting => t.is_valid_code("setting", code),
Group::NullFlavour => t.is_valid_code("null_flavours", code),
Group::InstructionState => t.is_valid_code("instruction_states", code),
Group::InstructionTransition => t.is_valid_code("instruction_transitions", code),
Group::ParticipationFunction => t.is_valid_code("participation_function", code),
Group::ParticipationMode => t.is_valid_code("participation_mode", code),
Group::EventMathFunction => t.is_valid_code("event_math_function", code),
Group::TermMappingPurpose => t.is_valid_code("term_mapping_purpose", code),
Group::AuditChangeType => t.is_valid_code("audit_change_type", code),
Group::AttestationReason => t.is_valid_code("attestation_reason", code),
Group::SubjectRelationship => t.is_valid_code("subject_relationship", code),
Group::VersionLifecycleState => t.is_valid_code("version_lifecycle_state", code),
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Group::CompositionCategory => "composition category",
Group::Setting => "setting",
Group::NullFlavour => "null flavour",
Group::InstructionState => "instruction state",
Group::InstructionTransition => "instruction transition",
Group::ParticipationFunction => "participation function",
Group::ParticipationMode => "participation mode",
Group::EventMathFunction => "event math function",
Group::TermMappingPurpose => "term mapping purpose",
Group::AuditChangeType => "audit change type",
Group::AttestationReason => "attestation reason",
Group::SubjectRelationship => "subject relationship",
Group::VersionLifecycleState => "version lifecycle state",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CodeSet {
Languages,
Countries,
CharacterSets,
MediaTypes,
NormalStatuses,
CompressionAlgorithms,
IntegrityCheckAlgorithms,
}
impl CodeSet {
#[must_use]
pub fn is_valid(self, t: &OpenehrTerminology, code: &str) -> bool {
match self {
CodeSet::Languages => t.is_valid_external_code("languages", code),
CodeSet::Countries => t.is_valid_external_code("countries", code),
CodeSet::CharacterSets => t.is_valid_external_code("character_sets", code),
CodeSet::MediaTypes => t.is_valid_external_code("media_types", code),
CodeSet::NormalStatuses => t.is_valid_normal_status(code),
CodeSet::CompressionAlgorithms => t
.code_set("compression_algorithms")
.is_some_and(|cs| cs.codes.iter().flatten().any(|c| c.value == code)),
CodeSet::IntegrityCheckAlgorithms => t
.code_set("integrity_check_algorithms")
.is_some_and(|cs| cs.codes.iter().flatten().any(|c| c.value == code)),
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
CodeSet::Languages => "language (ISO 639-1)",
CodeSet::Countries => "country (ISO 3166-1)",
CodeSet::CharacterSets => "character set (IANA)",
CodeSet::MediaTypes => "media type (IANA)",
CodeSet::NormalStatuses => "normal status",
CodeSet::CompressionAlgorithms => "compression algorithm",
CodeSet::IntegrityCheckAlgorithms => "integrity check algorithm",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Binding {
Group(Group),
CodeSet(CodeSet),
}
impl Binding {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Binding::Group(g) => g.label(),
Binding::CodeSet(cs) => cs.label(),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Slot {
pub field: &'static str,
pub invariant: &'static str,
pub binding: Binding,
}
#[must_use]
#[expect(
clippy::too_many_lines,
reason = "a flat `_type` -> `&[Slot]` terminology-binding table; the length is the size of the RM type set, not logic"
)]
pub fn slots_for(rm_type: &str) -> &'static [Slot] {
const LANG_ENCODING: &[Slot] = &[
Slot {
field: "language",
invariant: "Language_valid",
binding: Binding::CodeSet(CodeSet::Languages),
},
Slot {
field: "encoding",
invariant: "Encoding_valid",
binding: Binding::CodeSet(CodeSet::CharacterSets),
},
];
match rm_type {
"COMPOSITION" => &[
Slot {
field: "category",
invariant: "Category_validity",
binding: Binding::Group(Group::CompositionCategory),
},
Slot {
field: "language",
invariant: "Language_valid",
binding: Binding::CodeSet(CodeSet::Languages),
},
Slot {
field: "territory",
invariant: "Territory_valid",
binding: Binding::CodeSet(CodeSet::Countries),
},
],
"EVENT_CONTEXT" => &[Slot {
field: "setting",
invariant: "Setting_valid",
binding: Binding::Group(Group::Setting),
}],
"ELEMENT" => &[Slot {
field: "null_flavour",
invariant: "Inv_null_flavour_valid",
binding: Binding::Group(Group::NullFlavour),
}],
"ISM_TRANSITION" => &[
Slot {
field: "current_state",
invariant: "Current_state_valid",
binding: Binding::Group(Group::InstructionState),
},
Slot {
field: "transition",
invariant: "Transition_valid",
binding: Binding::Group(Group::InstructionTransition),
},
],
"PARTICIPATION" | "EXTRACT_PARTICIPATION" => &[
Slot {
field: "function",
invariant: "Function_valid",
binding: Binding::Group(Group::ParticipationFunction),
},
Slot {
field: "mode",
invariant: "Mode_valid",
binding: Binding::Group(Group::ParticipationMode),
},
],
"EVENT" | "POINT_EVENT" | "INTERVAL_EVENT" => &[Slot {
field: "math_function",
invariant: "Math_function_validity",
binding: Binding::Group(Group::EventMathFunction),
}],
"OBSERVATION" | "EVALUATION" | "INSTRUCTION" | "ACTION" | "ADMIN_ENTRY"
| "GENERIC_ENTRY" | "DV_TEXT" | "DV_CODED_TEXT" => LANG_ENCODING,
"TERM_MAPPING" => &[Slot {
field: "purpose",
invariant: "Purpose_valid",
binding: Binding::Group(Group::TermMappingPurpose),
}],
"DV_MULTIMEDIA" => &[
Slot {
field: "media_type",
invariant: "Media_type_valid",
binding: Binding::CodeSet(CodeSet::MediaTypes),
},
Slot {
field: "charset",
invariant: "Charset_valid",
binding: Binding::CodeSet(CodeSet::CharacterSets),
},
Slot {
field: "language",
invariant: "Language_valid",
binding: Binding::CodeSet(CodeSet::Languages),
},
Slot {
field: "compression_algorithm",
invariant: "Compression_algorithm_validity",
binding: Binding::CodeSet(CodeSet::CompressionAlgorithms),
},
Slot {
field: "integrity_check_algorithm",
invariant: "Integrity_check_algorithm_validity",
binding: Binding::CodeSet(CodeSet::IntegrityCheckAlgorithms),
},
],
"DV_PARSABLE" => &[
Slot {
field: "charset",
invariant: "Charset_valid",
binding: Binding::CodeSet(CodeSet::CharacterSets),
},
Slot {
field: "language",
invariant: "Language_valid",
binding: Binding::CodeSet(CodeSet::Languages),
},
],
"AUDIT_DETAILS" => &[Slot {
field: "change_type",
invariant: "Change_type_valid",
binding: Binding::Group(Group::AuditChangeType),
}],
"ATTESTATION" => &[
Slot {
field: "reason",
invariant: "Reason_valid",
binding: Binding::Group(Group::AttestationReason),
},
Slot {
field: "change_type",
invariant: "Change_type_valid",
binding: Binding::Group(Group::AuditChangeType),
},
],
"PARTY_RELATED" => &[Slot {
field: "relationship",
invariant: "Relationship_valid",
binding: Binding::Group(Group::SubjectRelationship),
}],
"DV_QUANTITY" | "DV_COUNT" | "DV_PROPORTION" | "DV_ORDINAL" | "DV_SCALE" | "DV_DATE"
| "DV_TIME" | "DV_DATE_TIME" | "DV_DURATION" => &[Slot {
field: "normal_status",
invariant: "Normal_status_validity",
binding: Binding::CodeSet(CodeSet::NormalStatuses),
}],
"ORIGINAL_VERSION" | "IMPORTED_VERSION" => &[Slot {
field: "lifecycle_state",
invariant: "Lifecycle_state_valid",
binding: Binding::Group(Group::VersionLifecycleState),
}],
"AUTHORED_RESOURCE" => &[Slot {
field: "original_language",
invariant: "Original_language_valid",
binding: Binding::CodeSet(CodeSet::Languages),
}],
"RESOURCE_DESCRIPTION_ITEM" | "TRANSLATION_DETAILS" => &[Slot {
field: "language",
invariant: "Language_valid",
binding: Binding::CodeSet(CodeSet::Languages),
}],
_ => &[],
}
}
#[must_use]
pub fn openehr_code(node: &Value) -> Option<(&str, &str)> {
let code_phrase = node.get("defining_code").unwrap_or(node);
let code = code_phrase.get("code_string").and_then(Value::as_str)?;
let terminology = code_phrase
.get("terminology_id")
.and_then(|t| t.get("value"))
.and_then(Value::as_str)
.unwrap_or("");
Some((code, terminology))
}
#[must_use]
pub fn slot_is_violated(slot: &Slot, node: &Value) -> bool {
let Some((code, terminology)) = openehr_code(node) else {
return false;
};
match slot.binding {
Binding::Group(group) => {
if terminology != "openehr" {
return false;
}
!group.is_valid(openehr(), code)
}
Binding::CodeSet(cs) => !cs.is_valid(openehr(), code),
}
}
pub fn validate_rm_terminology(ty: &str, value: &Value, out: &mut Vec<InvariantViolation>) {
for slot in slots_for(ty) {
let Some(node) = value.get(slot.field) else {
continue;
};
if slot_is_violated(slot, node) {
out.push(InvariantViolation::at(
slot.field,
format!("Invariant {} failed on type {ty}", slot.invariant),
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use openehr_term::codes::AuditChangeType;
#[test]
fn an_inherited_coded_invariant_reaches_the_descendant() {
let node = serde_json::json!({
"change_type": {
"value": "creation",
"defining_code": {
"terminology_id": { "value": "openehr" },
"code_string": "9999"
}
}
});
for ty in ["AUDIT_DETAILS", "ATTESTATION"] {
let mut out = Vec::new();
validate_rm_terminology(ty, &node, &mut out);
assert!(
out.iter().any(
|v| v.message == format!("Invariant Change_type_valid failed on type {ty}")
),
"{ty}: an out-of-group change_type must be refused, got {out:?}",
);
}
let valid = serde_json::json!({
"change_type": {
"value": AuditChangeType::Creation.rubric(),
"defining_code": {
"terminology_id": { "value": "openehr" },
"code_string": AuditChangeType::Creation.code()
}
}
});
for ty in ["AUDIT_DETAILS", "ATTESTATION"] {
let mut out = Vec::new();
validate_rm_terminology(ty, &valid, &mut out);
assert!(
out.is_empty(),
"{ty}: a valid change_type raises nothing, got {out:?}"
);
}
}
}