use crate::error::PathError;
use crate::rm::common::Locatable as _;
use crate::rm::data_structures::{Cluster, Element, Event, History, Item, ItemStructure};
use crate::rm::data_types::{
DataValue, DvCodedText, DvInterval, DvOrdered as _, DvText, OrderedAttrs, ReferenceRange, Text,
};
use crate::rm::ehr::{Composition, ContentItem, Entry, Section};
use core::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Condition {
NodeId(String),
Name(String),
Index(usize),
}
impl fmt::Display for Condition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NodeId(v) => write!(f, "archetype_node_id='{v}'"),
Self::Name(v) => write!(f, "name/value='{v}'"),
Self::Index(i) => write!(f, "{i}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Segment {
pub attribute: String,
pub conditions: Vec<Condition>,
}
impl Segment {
fn matches(&self, node_id: &str, name: &str, index: usize) -> bool {
self.conditions.iter().all(|c| match c {
Condition::NodeId(v) => node_id == v,
Condition::Name(v) => name == v,
Condition::Index(i) => index == *i,
})
}
}
impl fmt::Display for Segment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.attribute)?;
if self.conditions.is_empty() {
return Ok(());
}
write!(f, "[")?;
for (i, c) in self.conditions.iter().enumerate() {
if i > 0 {
write!(f, " and ")?;
}
write!(f, "{c}")?;
}
write!(f, "]")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Path {
segments: Vec<Segment>,
}
impl Path {
#[must_use]
pub fn segments(&self) -> &[Segment] {
&self.segments
}
#[must_use]
pub fn is_root(&self) -> bool {
self.segments.is_empty()
}
}
impl fmt::Display for Path {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.segments.is_empty() {
return f.write_str("/");
}
for s in &self.segments {
write!(f, "/{s}")?;
}
Ok(())
}
}
impl core::str::FromStr for Path {
type Err = PathError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_path(s)
}
}
fn parse_path(input: &str) -> Result<Path, PathError> {
let trimmed = input.trim();
if trimmed.is_empty() || trimmed == "/" {
return Ok(Path {
segments: Vec::new(),
});
}
let body = trimmed.strip_prefix('/').unwrap_or(trimmed);
let base = trimmed.len() - body.len();
let mut segments = Vec::new();
let bytes = body.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
let start = i;
while i < bytes.len() && bytes[i] != b'[' && bytes[i] != b'/' {
i += 1;
}
let attribute = &body[start..i];
if attribute.is_empty() {
return Err(PathError::Malformed {
offset: base + i,
reason: "empty attribute name",
});
}
let mut conditions = Vec::new();
if i < bytes.len() && bytes[i] == b'[' {
let open = i;
i += 1;
let mut quote: Option<u8> = None;
loop {
if i >= bytes.len() {
return Err(PathError::Malformed {
offset: base + open,
reason: "unclosed `[`",
});
}
match (quote, bytes[i]) {
(None, b'\'' | b'"') => quote = Some(bytes[i]),
(Some(q), c) if c == q => quote = None,
(None, b']') => break,
_ => {}
}
i += 1;
}
conditions = parse_predicate(&body[open + 1..i], base + open + 1)?;
i += 1;
}
segments.push(Segment {
attribute: attribute.to_owned(),
conditions,
});
if i < bytes.len() {
if bytes[i] != b'/' {
return Err(PathError::Malformed {
offset: base + i,
reason: "expected `/` after a segment",
});
}
i += 1;
if i == bytes.len() {
return Err(PathError::Malformed {
offset: base + i,
reason: "trailing `/`",
});
}
}
}
Ok(Path { segments })
}
fn parse_predicate(text: &str, offset: usize) -> Result<Vec<Condition>, PathError> {
let mut conditions = Vec::new();
for part in split_conjunctions(text) {
let part = part.trim();
if part.is_empty() {
return Err(PathError::Malformed {
offset,
reason: "empty predicate term",
});
}
conditions.push(parse_condition(part, offset)?);
}
if conditions.is_empty() {
return Err(PathError::Malformed {
offset,
reason: "empty predicate",
});
}
Ok(conditions)
}
fn split_conjunctions(text: &str) -> Vec<&str> {
let mut parts = Vec::new();
let bytes = text.as_bytes();
let mut quote: Option<u8> = None;
let mut start = 0usize;
let mut i = 0usize;
while i < bytes.len() {
match (quote, bytes[i]) {
(None, b'\'' | b'"') => quote = Some(bytes[i]),
(Some(q), c) if c == q => quote = None,
(None, b',') => {
parts.push(&text[start..i]);
start = i + 1;
}
(None, b'a') if text[i..].starts_with("and") && is_word_boundary(text, i, 3) => {
parts.push(&text[start..i]);
i += 3;
start = i;
continue;
}
_ => {}
}
i += 1;
}
parts.push(&text[start..]);
parts
}
fn is_word_boundary(text: &str, at: usize, len: usize) -> bool {
let before = at == 0 || text.as_bytes()[at - 1].is_ascii_whitespace();
let after = at + len >= text.len() || text.as_bytes()[at + len].is_ascii_whitespace();
before && after
}
fn parse_condition(text: &str, offset: usize) -> Result<Condition, PathError> {
if let Some((lhs, rhs)) = text.split_once('=') {
let value = unquote(rhs.trim());
return match lhs.trim() {
"archetype_node_id" => Ok(Condition::NodeId(value)),
"name/value" | "name" => Ok(Condition::Name(value)),
_ => Err(PathError::Malformed {
offset,
reason: "predicate attribute is not archetype_node_id or name/value",
}),
};
}
if text.starts_with('\'') || text.starts_with('"') {
return Ok(Condition::Name(unquote(text)));
}
if let Ok(index) = text.parse::<usize>() {
return Ok(Condition::Index(index));
}
Ok(Condition::NodeId(text.to_owned()))
}
fn unquote(text: &str) -> String {
let trimmed = text.trim();
for q in ['\'', '"'] {
if trimmed.starts_with(q) && trimmed.ends_with(q) && trimmed.len() >= 2 {
return trimmed[1..trimmed.len() - 1].to_owned();
}
}
trimmed.to_owned()
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum Node<'a> {
Composition(&'a Composition),
Section(&'a Section),
Entry(&'a Entry),
ItemStructure(&'a ItemStructure),
Cluster(&'a Cluster),
Element(&'a Element),
History(&'a History),
Event(&'a Event),
DataValue(&'a DataValue),
Text(&'a Text),
CodedText(&'a DvCodedText),
PlainText(&'a DvText),
Interval(&'a DvInterval),
ReferenceRange(&'a ReferenceRange),
Scalar(Scalar<'a>),
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum Scalar<'a> {
Str(&'a str),
Number(f64),
Integer(i64),
Boolean(bool),
}
impl Scalar<'_> {
#[must_use]
pub fn to_display_string(&self) -> String {
match self {
Self::Str(v) => (*v).to_owned(),
Self::Number(v) => v.to_string(),
Self::Integer(v) => v.to_string(),
Self::Boolean(v) => v.to_string(),
}
}
}
impl<'a> Node<'a> {
#[must_use]
pub fn type_name(&self) -> &'static str {
match self {
Self::Composition(_) => "COMPOSITION",
Self::Section(_) => "SECTION",
Self::Entry(e) => e.type_name(),
Self::ItemStructure(s) => s.type_name(),
Self::Cluster(_) => "CLUSTER",
Self::Element(_) => "ELEMENT",
Self::History(_) => "HISTORY",
Self::Event(e) => e.type_name(),
Self::DataValue(v) => v.type_name(),
Self::Text(t) => t.type_name(),
Self::CodedText(_) => "DV_CODED_TEXT",
Self::PlainText(_) => "DV_TEXT",
Self::Interval(_) => "DV_INTERVAL",
Self::ReferenceRange(_) => "REFERENCE_RANGE",
Self::Scalar(_) => "primitive",
}
}
#[must_use]
pub fn archetype_node_id(&self) -> Option<&'a str> {
Some(match self {
Self::Composition(c) => c.archetype_node_id(),
Self::Section(s) => s.archetype_node_id(),
Self::Entry(e) => e.locatable().archetype_node_id(),
Self::ItemStructure(s) => s.locatable().archetype_node_id(),
Self::Cluster(c) => c.archetype_node_id(),
Self::Element(e) => e.archetype_node_id(),
Self::History(h) => h.archetype_node_id(),
Self::Event(e) => e.locatable().archetype_node_id(),
Self::DataValue(_)
| Self::Text(_)
| Self::CodedText(_)
| Self::PlainText(_)
| Self::Interval(_)
| Self::ReferenceRange(_)
| Self::Scalar(_) => return None,
})
}
#[must_use]
pub fn name(&self) -> Option<&'a str> {
Some(match self {
Self::Composition(c) => c.name().value(),
Self::Section(s) => s.name().value(),
Self::Entry(e) => e.locatable().name().value(),
Self::ItemStructure(s) => s.locatable().name().value(),
Self::Cluster(c) => c.name().value(),
Self::Element(e) => e.name().value(),
Self::History(h) => h.name().value(),
Self::Event(e) => e.locatable().name().value(),
Self::DataValue(_)
| Self::Text(_)
| Self::CodedText(_)
| Self::PlainText(_)
| Self::Interval(_)
| Self::ReferenceRange(_)
| Self::Scalar(_) => return None,
})
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn children(&self, attribute: &str) -> Vec<Node<'a>> {
match self {
Self::Composition(c) => match attribute {
"content" => c.content().iter().map(content_item_node).collect(),
"name" => vec![Node::Text(c.name())],
"category" => vec![Node::CodedText(c.category())],
_ => Vec::new(),
},
Self::Section(s) => match attribute {
"items" => s.items().iter().map(content_item_node).collect(),
"name" => vec![Node::Text(s.name())],
_ => Vec::new(),
},
Self::Entry(e) => entry_children(e, attribute),
Self::ItemStructure(s) => item_structure_children(s, attribute),
Self::Cluster(c) => match attribute {
"items" => c.items().iter().map(item_node).collect(),
"name" => vec![Node::Text(c.name())],
_ => Vec::new(),
},
Self::Element(e) => match attribute {
"value" => e.value().map(Node::DataValue).into_iter().collect(),
"null_flavour" => e
.null_flavour()
.map(|f| Node::Scalar(Scalar::Str(f.defining_code().code_string())))
.into_iter()
.collect(),
"name" => vec![Node::Text(e.name())],
_ => Vec::new(),
},
Self::History(h) => match attribute {
"events" => h.events().iter().map(Node::Event).collect(),
"summary" => h.summary().map(Node::ItemStructure).into_iter().collect(),
"origin" => vec![Node::Scalar(Scalar::Str(h.origin().as_str()))],
"name" => vec![Node::Text(h.name())],
_ => Vec::new(),
},
Self::Event(e) => match attribute {
"data" => vec![Node::ItemStructure(e.data())],
"state" => e.state().map(Node::ItemStructure).into_iter().collect(),
"time" => vec![Node::Scalar(Scalar::Str(e.time().as_str()))],
"name" => vec![Node::Text(e.locatable().name())],
_ => Vec::new(),
},
Self::DataValue(v) => data_value_children(v, attribute),
Self::Text(t) => match attribute {
"value" => vec![Node::Scalar(Scalar::Str(t.value()))],
"defining_code" => t
.defining_code()
.map(|c| Node::Scalar(Scalar::Str(c.code_string())))
.into_iter()
.collect(),
_ => Vec::new(),
},
Self::CodedText(t) => match attribute {
"value" => vec![Node::Scalar(Scalar::Str(t.value()))],
"defining_code" => {
vec![Node::Scalar(Scalar::Str(t.defining_code().code_string()))]
}
_ => Vec::new(),
},
Self::PlainText(t) => match attribute {
"value" => vec![Node::Scalar(Scalar::Str(t.value()))],
_ => Vec::new(),
},
Self::Interval(i) => interval_children(i, attribute),
Self::ReferenceRange(r) => match attribute {
"meaning" => vec![Node::PlainText(r.meaning())],
"range" => vec![Node::Interval(r.range())],
_ => Vec::new(),
},
Self::Scalar(_) => Vec::new(),
}
}
}
fn content_item_node(item: &ContentItem) -> Node<'_> {
match item {
ContentItem::Section(s) => Node::Section(s),
ContentItem::Entry(e) => Node::Entry(e),
}
}
fn item_node(item: &Item) -> Node<'_> {
match item {
Item::Cluster(c) => Node::Cluster(c),
Item::Element(e) => Node::Element(e),
}
}
fn entry_children<'a>(entry: &'a Entry, attribute: &str) -> Vec<Node<'a>> {
match (entry, attribute) {
(Entry::Observation(o), "data") => vec![Node::History(o.data())],
(Entry::Observation(o), "state") => o.state().map(Node::History).into_iter().collect(),
(Entry::Observation(o), "protocol") => o
.care_entry()
.protocol()
.map(Node::ItemStructure)
.into_iter()
.collect(),
(Entry::Evaluation(e), "data") => vec![Node::ItemStructure(e.data())],
(Entry::AdminEntry(a), "data") => vec![Node::ItemStructure(a.data())],
(Entry::Instruction(i), "narrative") => vec![Node::Text(i.narrative())],
(Entry::Instruction(i), "activities") => i
.activities()
.iter()
.map(|a| Node::ItemStructure(a.description()))
.collect(),
(Entry::Action(a), "description") => vec![Node::ItemStructure(a.description())],
(Entry::Action(a), "time") => vec![Node::Scalar(Scalar::Str(a.time().as_str()))],
(_, "name") => vec![Node::Text(entry.locatable().name())],
_ => Vec::new(),
}
}
fn item_structure_children<'a>(structure: &'a ItemStructure, attribute: &str) -> Vec<Node<'a>> {
match (structure, attribute) {
(ItemStructure::Single(s), "item" | "items") => vec![Node::Element(s.item())],
(ItemStructure::List(l), "items") => l.items().iter().map(Node::Element).collect(),
(ItemStructure::Table(t), "rows") => t.rows().iter().map(Node::Cluster).collect(),
(ItemStructure::Tree(t), "items") => t.items().iter().map(item_node).collect(),
(_, "name") => vec![Node::Text(structure.locatable().name())],
_ => Vec::new(),
}
}
fn interval_children<'a>(interval: &'a DvInterval, attribute: &str) -> Vec<Node<'a>> {
match attribute {
"lower" => interval.lower().map(Node::DataValue).into_iter().collect(),
"upper" => interval.upper().map(Node::DataValue).into_iter().collect(),
"lower_unbounded" => vec![Node::Scalar(Scalar::Boolean(interval.lower_unbounded()))],
"upper_unbounded" => vec![Node::Scalar(Scalar::Boolean(interval.upper_unbounded()))],
"lower_included" => interval
.lower_included()
.map(|v| Node::Scalar(Scalar::Boolean(v)))
.into_iter()
.collect(),
"upper_included" => interval
.upper_included()
.map(|v| Node::Scalar(Scalar::Boolean(v)))
.into_iter()
.collect(),
_ => Vec::new(),
}
}
fn ordered_children<'a>(attrs: &'a OrderedAttrs, attribute: &str) -> Vec<Node<'a>> {
match attribute {
"normal_range" => attrs
.normal_range()
.map(Node::Interval)
.into_iter()
.collect(),
"other_reference_ranges" => attrs
.other_reference_ranges()
.iter()
.map(Node::ReferenceRange)
.collect(),
"normal_status" => attrs
.normal_status()
.map(|c| Node::Scalar(Scalar::Str(c.code_string())))
.into_iter()
.collect(),
_ => Vec::new(),
}
}
fn data_value_children<'a>(value: &'a DataValue, attribute: &str) -> Vec<Node<'a>> {
if let Some(attrs) = ordered_attrs_of(value) {
let ordered = ordered_children(attrs, attribute);
if !ordered.is_empty() {
return ordered;
}
}
if let DataValue::Interval(i) = value {
return interval_children(i, attribute);
}
let scalar = match (value, attribute) {
(DataValue::Quantity(q), "magnitude") => Scalar::Number(q.magnitude()),
(DataValue::Quantity(q), "units") => Scalar::Str(q.units()),
(DataValue::Quantity(q), "precision") => match q.precision() {
Some(p) => Scalar::Integer(i64::from(p)),
None => return Vec::new(),
},
(DataValue::Count(c), "magnitude") => Scalar::Integer(c.magnitude()),
(DataValue::Ordinal(o), "value") => Scalar::Integer(o.value()),
(DataValue::Ordinal(o), "symbol") => return vec![Node::CodedText(o.symbol())],
(DataValue::Scale(s), "symbol") => return vec![Node::CodedText(s.symbol())],
(DataValue::Scale(s), "value") => Scalar::Number(s.value()),
(DataValue::Proportion(p), "numerator") => Scalar::Number(p.numerator()),
(DataValue::Proportion(p), "denominator") => Scalar::Number(p.denominator()),
(DataValue::Boolean(b), "value") => Scalar::Boolean(b.value()),
(DataValue::Text(t), "value") => Scalar::Str(t.value()),
(DataValue::CodedText(t), "value") => Scalar::Str(t.value()),
(DataValue::CodedText(t), "defining_code") => Scalar::Str(t.defining_code().code_string()),
(DataValue::Date(d), "value") => Scalar::Str(d.as_str()),
(DataValue::Time(t), "value") => Scalar::Str(t.as_str()),
(DataValue::DateTime(t), "value") => Scalar::Str(t.as_str()),
(DataValue::Duration(d), "value") => Scalar::Str(d.as_str()),
(DataValue::Uri(u), "value") => Scalar::Str(u.value()),
(DataValue::EhrUri(u), "value") => Scalar::Str(u.value()),
(DataValue::Identifier(i), "id") => Scalar::Str(i.id()),
_ => return Vec::new(),
};
vec![Node::Scalar(scalar)]
}
fn ordered_attrs_of(value: &DataValue) -> Option<&OrderedAttrs> {
Some(match value {
DataValue::Ordinal(v) => v.ordered_attrs(),
DataValue::Scale(v) => v.ordered_attrs(),
DataValue::Quantity(v) => v.ordered_attrs(),
DataValue::Count(v) => v.ordered_attrs(),
DataValue::Proportion(v) => v.ordered_attrs(),
DataValue::Date(v) => v.ordered_attrs(),
DataValue::Time(v) => v.ordered_attrs(),
DataValue::DateTime(v) => v.ordered_attrs(),
DataValue::Duration(v) => v.ordered_attrs(),
_ => return None,
})
}
pub trait Pathable {
fn as_node(&self) -> Node<'_>;
fn items_at_path(&self, path: &str) -> Result<Vec<Node<'_>>, PathError> {
let parsed: Path = path.parse()?;
Ok(resolve(self.as_node(), &parsed))
}
fn item_at_path(&self, path: &str) -> Result<Node<'_>, PathError> {
let matches = self.items_at_path(path)?;
match matches.len() {
0 => Err(PathError::NoMatch {
path: path.to_owned(),
}),
1 => Ok(matches[0]),
n => Err(PathError::NotUnique {
path: path.to_owned(),
count: n,
}),
}
}
fn path_exists(&self, path: &str) -> Result<bool, PathError> {
Ok(!self.items_at_path(path)?.is_empty())
}
fn path_unique(&self, path: &str) -> Result<bool, PathError> {
Ok(self.items_at_path(path)?.len() == 1)
}
}
fn resolve<'a>(root: Node<'a>, path: &Path) -> Vec<Node<'a>> {
let mut current = vec![root];
for segment in path.segments() {
let mut next = Vec::new();
for node in current {
let children = node.children(&segment.attribute);
for (index, child) in children.into_iter().enumerate() {
let node_id = child.archetype_node_id().unwrap_or_default();
let name = child.name().unwrap_or_default();
if segment.matches(node_id, name, index) {
next.push(child);
}
}
}
if next.is_empty() {
return Vec::new();
}
current = next;
}
current
}
impl Pathable for Composition {
fn as_node(&self) -> Node<'_> {
Node::Composition(self)
}
}
impl Pathable for Section {
fn as_node(&self) -> Node<'_> {
Node::Section(self)
}
}
impl Pathable for Entry {
fn as_node(&self) -> Node<'_> {
Node::Entry(self)
}
}
impl Pathable for ItemStructure {
fn as_node(&self) -> Node<'_> {
Node::ItemStructure(self)
}
}
impl Pathable for Cluster {
fn as_node(&self) -> Node<'_> {
Node::Cluster(self)
}
}
impl Pathable for Element {
fn as_node(&self) -> Node<'_> {
Node::Element(self)
}
}
impl Pathable for History {
fn as_node(&self) -> Node<'_> {
Node::History(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rm::common::{LocatableAttrs, PartyIdentified};
use crate::rm::data_structures::{ItemTree, PointEvent};
use crate::rm::data_types::{CodePhrase, DvDateTime, DvQuantity};
use crate::rm::ehr::{EntryAttrs, Observation};
use crate::terminology;
fn attrs(name: &str, node: &str) -> LocatableAttrs {
LocatableAttrs::named(name, node).unwrap()
}
fn blood_pressure() -> Composition {
let element = |name: &str, node: &str, v: f64| {
Item::Element(Element::new(
attrs(name, node),
DataValue::Quantity(DvQuantity::new(v, "mm[Hg]").unwrap()),
))
};
let data = ItemTree::new(
attrs("blood pressure", "at0003"),
vec![
element("Systolic", "at0004", 184.0),
element("Diastolic", "at0005", 96.0),
],
);
let event = PointEvent::new(
attrs("any event", "at0006"),
DvDateTime::new("2026-07-31T09:15:00Z").unwrap(),
data.into(),
);
let history = History::new(
attrs("Event Series", "at0001"),
DvDateTime::new("2026-07-31T09:00:00Z").unwrap(),
vec![event.into()],
None,
)
.unwrap();
let observation = Observation::new(
attrs(
"Blood pressure",
"openEHR-EHR-OBSERVATION.blood_pressure.v2",
),
EntryAttrs::about_subject(
CodePhrase::new("ISO_639-1", "en").unwrap(),
CodePhrase::new("IANA_character-sets", "UTF-8").unwrap(),
),
history,
);
Composition::new(
attrs("Encounter", "openEHR-EHR-COMPOSITION.encounter.v1"),
terminology::composition_category::EVENT,
PartyIdentified::named("Dr A Nurse").unwrap().into(),
CodePhrase::new("ISO_639-1", "en").unwrap(),
CodePhrase::new("ISO_3166-1", "GB").unwrap(),
)
.unwrap()
.with_content(observation.into())
}
#[test]
fn a_full_path_reaches_a_magnitude() {
let c = blood_pressure();
let node = c
.item_at_path(
"/content[openEHR-EHR-OBSERVATION.blood_pressure.v2]/data/events[at0006]/data/items[at0004]/value/magnitude",
)
.unwrap();
assert_eq!(node, Node::Scalar(Scalar::Number(184.0)));
}
#[test]
fn an_ambiguous_path_fails_instead_of_taking_the_first() {
let c = blood_pressure();
let err = c
.item_at_path("/content/data/events/data/items/value/magnitude")
.unwrap_err();
assert!(
matches!(err, PathError::NotUnique { count: 2, .. }),
"{err:?}"
);
assert_eq!(
c.items_at_path("/content/data/events/data/items/value/magnitude")
.unwrap()
.len(),
2
);
}
#[test]
fn name_predicates_select_the_right_repeat() {
let c = blood_pressure();
let diastolic = c
.item_at_path("/content/data/events/data/items['Diastolic']/value/magnitude")
.unwrap();
assert_eq!(diastolic, Node::Scalar(Scalar::Number(96.0)));
let combined = c
.item_at_path("/content/data/events/data/items[at0004, 'Systolic']/value/magnitude")
.unwrap();
assert_eq!(combined, Node::Scalar(Scalar::Number(184.0)));
}
#[test]
fn index_predicates_are_zero_based_and_quoted_digits_are_names() {
let c = blood_pressure();
let first = c
.item_at_path("/content/data/events/data/items[0]/value/magnitude")
.unwrap();
assert_eq!(first, Node::Scalar(Scalar::Number(184.0)));
assert!(
!c.path_exists("/content/data/events/data/items['0']")
.unwrap()
);
}
#[test]
fn a_wrong_attribute_is_no_match_not_an_error() {
let c = blood_pressure();
assert!(
!c.path_exists("/content/data/events/data/nonexistent")
.unwrap()
);
assert!(matches!(
c.item_at_path("/content/data/events/data/nonexistent"),
Err(PathError::NoMatch { .. })
));
}
#[test]
fn reference_ranges_are_navigable() {
use crate::base::Interval;
use crate::rm::data_types::{DvText, ReferenceRange};
let bound = |v: f64| DataValue::Quantity(DvQuantity::new(v, "mmol/l").unwrap());
let quantity = DvQuantity::new(9.9, "mmol/l")
.unwrap()
.with_normal_range(Interval::closed(bound(3.5), bound(5.5)).unwrap())
.with_other_reference_range(ReferenceRange::new(
DvText::new("therapeutic").unwrap(),
Interval::closed(bound(4.0), bound(5.0)).unwrap(),
))
.with_normal_status(
crate::rm::data_types::CodePhrase::openehr(
crate::terminology::normal_status::VERY_HIGH,
)
.unwrap(),
);
let element: ItemStructure = crate::rm::data_structures::ItemSingle::new(
attrs("s", "at0001"),
Element::new(attrs("K", "at0004"), DataValue::Quantity(quantity)),
)
.into();
assert_eq!(
element
.item_at_path("/item/value/normal_range/lower/magnitude")
.unwrap(),
Node::Scalar(Scalar::Number(3.5))
);
assert_eq!(
element
.item_at_path("/item/value/normal_range/upper_included")
.unwrap(),
Node::Scalar(Scalar::Boolean(true))
);
assert_eq!(
element
.item_at_path("/item/value/other_reference_ranges/meaning/value")
.unwrap(),
Node::Scalar(Scalar::Str("therapeutic"))
);
assert_eq!(
element
.item_at_path("/item/value/other_reference_ranges/range/upper/magnitude")
.unwrap(),
Node::Scalar(Scalar::Number(5.0))
);
assert_eq!(
element.item_at_path("/item/value/normal_status").unwrap(),
Node::Scalar(Scalar::Str("HHH"))
);
}
#[test]
fn an_interval_valued_element_is_navigable() {
use crate::base::Interval;
let bound = |v: f64| DataValue::Quantity(DvQuantity::new(v, "mm[Hg]").unwrap());
let target = DataValue::Interval(Box::new(
Interval::closed(bound(130.0), bound(140.0)).unwrap(),
));
let structure: ItemStructure = crate::rm::data_structures::ItemSingle::new(
attrs("s", "at0001"),
Element::new(attrs("Target", "at0004"), target),
)
.into();
assert_eq!(
structure
.item_at_path("/item/value/lower/magnitude")
.unwrap(),
Node::Scalar(Scalar::Number(130.0))
);
assert_eq!(
structure.item_at_path("/item/value/upper/units").unwrap(),
Node::Scalar(Scalar::Str("mm[Hg]"))
);
assert_eq!(
structure
.item_at_path("/item/value/lower_unbounded")
.unwrap(),
Node::Scalar(Scalar::Boolean(false))
);
assert!(!structure.path_exists("/item/value/middle").unwrap());
}
#[test]
fn malformed_paths_report_where_they_broke() {
for text in ["/content[at0001", "/content//data", "/content/", "//"] {
let err = text.parse::<Path>();
assert!(err.is_err(), "accepted {text}");
}
assert!("".parse::<Path>().unwrap().is_root());
assert!("/".parse::<Path>().unwrap().is_root());
}
#[test]
fn predicates_round_trip_through_display_in_long_form() {
let p: Path = "/data[at0001]/events[at0006, 'any event']".parse().unwrap();
let printed = p.to_string();
let reparsed: Path = printed.parse().unwrap();
assert_eq!(reparsed, p);
}
fn rendered(nodes: &[Node<'_>]) -> String {
nodes
.iter()
.map(|n| match n {
Node::Scalar(s) => s.to_display_string(),
other => other.type_name().to_owned(),
})
.collect::<Vec<_>>()
.join(", ")
}
#[test]
#[allow(clippy::too_many_lines)]
fn every_navigable_attribute_of_a_data_value_reaches_its_value() {
const QUANTITY: &str = r#"{"_type":"DV_QUANTITY","magnitude":140.0,"units":"mm[Hg]","precision":1,
"normal_range":{"lower":{"_type":"DV_QUANTITY","magnitude":90.0,"units":"mm[Hg]"},
"upper":{"_type":"DV_QUANTITY","magnitude":120.0,"units":"mm[Hg]"},
"lower_unbounded":false,"upper_unbounded":false},
"normal_status":{"terminology_id":{"value":"openehr"},"code_string":"H"},
"other_reference_ranges":[{"meaning":{"value":"therapeutic"},
"range":{"lower":{"_type":"DV_QUANTITY","magnitude":80.0,"units":"mm[Hg]"},
"lower_unbounded":false,"upper_unbounded":true}}]}"#;
const ORDINAL: &str = r#"{"_type":"DV_ORDINAL","value":2,
"symbol":{"value":"moderate","defining_code":{"terminology_id":{"value":"local"},"code_string":"at0003"}}}"#;
const SCALE: &str = r#"{"_type":"DV_SCALE","value":2.5,
"symbol":{"value":"often","defining_code":{"terminology_id":{"value":"local"},"code_string":"at0004"}}}"#;
const CODED: &str = r#"{"_type":"DV_CODED_TEXT","value":"female",
"defining_code":{"terminology_id":{"value":"local"},"code_string":"at0022"}}"#;
const INTERVAL: &str = r#"{"_type":"DV_INTERVAL",
"lower":{"_type":"DV_COUNT","magnitude":1},"lower_included":true,
"lower_unbounded":false,"upper_unbounded":true}"#;
let ranged = |body: &str| {
format!(
r#"{{{body},"normal_status":{{"terminology_id":{{"value":"openehr"}},"code_string":"L"}}}}"#
)
};
let ordinal_ranged = ranged(
r#""_type":"DV_ORDINAL","value":1,"symbol":{"value":"mild","defining_code":{"terminology_id":{"value":"local"},"code_string":"at0002"}}"#,
);
let scale_ranged = ranged(
r#""_type":"DV_SCALE","value":1.0,"symbol":{"value":"rarely","defining_code":{"terminology_id":{"value":"local"},"code_string":"at0005"}}"#,
);
let count_ranged = ranged(r#""_type":"DV_COUNT","magnitude":3"#);
let proportion_ranged =
ranged(r#""_type":"DV_PROPORTION","numerator":1.0,"denominator":4.0,"type":0"#);
let (rd, rt, rdt, rdur) = (
ranged(r#""_type":"DV_DATE","value":"2026-08-03""#),
ranged(r#""_type":"DV_TIME","value":"09:30:00""#),
ranged(r#""_type":"DV_DATE_TIME","value":"2026-08-03T09:30:00Z""#),
ranged(r#""_type":"DV_DURATION","value":"P1D""#),
);
let (ranged_date, ranged_time, ranged_date_time, ranged_duration) =
(rd.as_str(), rt.as_str(), rdt.as_str(), rdur.as_str());
let (ranged_ordinal, ranged_scale, ranged_count, ranged_proportion) = (
ordinal_ranged.as_str(),
scale_ranged.as_str(),
count_ranged.as_str(),
proportion_ranged.as_str(),
);
let cases: &[(&str, &str, &str)] = &[
(QUANTITY, "magnitude", "140"),
(QUANTITY, "units", "mm[Hg]"),
(QUANTITY, "precision", "1"),
(QUANTITY, "normal_range", "DV_INTERVAL"),
(QUANTITY, "normal_status", "H"),
(QUANTITY, "other_reference_ranges", "REFERENCE_RANGE"),
(QUANTITY, "nonesuch", ""),
(ranged_ordinal, "normal_status", "L"),
(ranged_scale, "normal_status", "L"),
(ranged_count, "normal_status", "L"),
(ranged_proportion, "normal_status", "L"),
(ranged_date, "normal_status", "L"),
(ranged_time, "normal_status", "L"),
(ranged_date_time, "normal_status", "L"),
(ranged_duration, "normal_status", "L"),
(r#"{"_type":"DV_COUNT","magnitude":7}"#, "magnitude", "7"),
(ORDINAL, "value", "2"),
(ORDINAL, "symbol", "DV_CODED_TEXT"),
(SCALE, "value", "2.5"),
(SCALE, "symbol", "DV_CODED_TEXT"),
(
r#"{"_type":"DV_PROPORTION","numerator":1.0,"denominator":4.0,"type":0}"#,
"numerator",
"1",
),
(
r#"{"_type":"DV_PROPORTION","numerator":1.0,"denominator":4.0,"type":0}"#,
"denominator",
"4",
),
(r#"{"_type":"DV_BOOLEAN","value":true}"#, "value", "true"),
(r#"{"_type":"DV_TEXT","value":"free text"}"#, "value", "free text"),
(CODED, "value", "female"),
(CODED, "defining_code", "at0022"),
(r#"{"_type":"DV_DATE","value":"2026-08-03"}"#, "value", "2026-08-03"),
(r#"{"_type":"DV_TIME","value":"09:30:00"}"#, "value", "09:30:00"),
(
r#"{"_type":"DV_DATE_TIME","value":"2026-08-03T09:30:00Z"}"#,
"value",
"2026-08-03T09:30:00Z",
),
(r#"{"_type":"DV_DURATION","value":"P1D"}"#, "value", "P1D"),
(
r#"{"_type":"DV_URI","value":"https://example.org/x"}"#,
"value",
"https://example.org/x",
),
(
r#"{"_type":"DV_EHR_URI","value":"ehr://x/y"}"#,
"value",
"ehr://x/y",
),
(
r#"{"_type":"DV_IDENTIFIER","id":"NHS-12345","issuer":"","assigner":"","type":""}"#,
"id",
"NHS-12345",
),
(INTERVAL, "lower", "DV_COUNT"),
(INTERVAL, "upper", ""),
(INTERVAL, "lower_included", "true"),
(INTERVAL, "upper_included", ""),
(INTERVAL, "lower_unbounded", "false"),
(INTERVAL, "upper_unbounded", "true"),
];
for (json, attribute, want) in cases {
let value: DataValue = serde_json::from_str(json)
.unwrap_or_else(|e| panic!("fixture does not deserialize: {e}\n{json}"));
let got = rendered(&Node::DataValue(&value).children(attribute));
assert_eq!(
got, *want,
"navigating `{attribute}` reached {got:?}, wanted {want:?}"
);
}
}
#[test]
#[allow(clippy::too_many_lines)]
fn every_navigable_attribute_of_a_structural_node_reaches_its_value() {
use crate::rm::data_structures::{Cluster, ItemList, ItemSingle, ItemTable};
use crate::rm::data_structures::Event;
use crate::rm::ehr::{
Action, AdminEntry, CareEntryAttrs, Evaluation, Instruction, IsmTransition, Section,
};
let entry_attrs = || {
EntryAttrs::about_subject(
CodePhrase::new("ISO_639-1", "en").unwrap(),
CodePhrase::new("IANA_character-sets", "UTF-8").unwrap(),
)
};
let element = |name: &str, node: &str, v: f64| {
Element::new(
attrs(name, node),
DataValue::Quantity(DvQuantity::new(v, "mm[Hg]").unwrap()),
)
};
let single = |name: &str| ItemSingle::new(attrs(name, "at0100"), element("s", "at0101", 1.0));
let composition = blood_pressure();
let coded_name = Text::Coded(
DvCodedText::new(
"Systolic",
CodePhrase::new("local", "at0004").unwrap(),
)
.unwrap(),
);
let section = Section::new(
attrs("Findings", "at0200"),
vec![crate::rm::ehr::ContentItem::Section(Section::new(
attrs("Nested", "at0201"),
vec![],
))],
);
let state = History::new(
attrs("State Series", "at0300"),
DvDateTime::new("2026-07-31T09:00:00Z").unwrap(),
vec![PointEvent::new(
attrs("state event", "at0301"),
DvDateTime::new("2026-07-31T09:05:00Z").unwrap(),
ItemTree::new(attrs("position", "at0302"), vec![]).into(),
)
.into()],
None,
)
.unwrap();
let observation = Observation::new(
attrs("Blood pressure", "openEHR-EHR-OBSERVATION.blood_pressure.v2"),
entry_attrs(),
History::new(
attrs("Event Series", "at0001"),
DvDateTime::new("2026-07-31T09:00:00Z").unwrap(),
vec![PointEvent::new(
attrs("any event", "at0006"),
DvDateTime::new("2026-07-31T09:15:00Z").unwrap(),
ItemTree::new(attrs("bp", "at0003"), vec![]).into(),
)
.into()],
None,
)
.unwrap(),
)
.with_state(state)
.with_care_entry(CareEntryAttrs::default().with_protocol(single("protocol").into()));
let evaluation = Evaluation::new(
attrs("Problem", "openEHR-EHR-EVALUATION.problem_diagnosis.v1"),
entry_attrs(),
single("evaluation data").into(),
);
let admin = AdminEntry::new(
attrs("Admission", "openEHR-EHR-ADMIN_ENTRY.admission.v0"),
entry_attrs(),
single("admin data").into(),
);
let instruction = Instruction::new(
attrs("Order", "openEHR-EHR-INSTRUCTION.medication_order.v3"),
entry_attrs(),
Text::Plain(DvText::new("Give 5mg at once").unwrap()),
vec![crate::rm::ehr::Activity::new(
attrs("activity", "at0400"),
single("activity description").into(),
"openEHR-EHR-ACTION.medication.v1",
)
.unwrap()],
)
.unwrap();
let action = Action::new(
attrs("Given", "openEHR-EHR-ACTION.medication.v1"),
entry_attrs(),
DvDateTime::new("2026-07-31T10:00:00Z").unwrap(),
single("action description").into(),
IsmTransition::new("532").unwrap(),
);
let item_single = single("single");
let item_list = ItemList::new(attrs("list", "at0500"), vec![element("l", "at0501", 2.0)]);
let item_table = ItemTable::new(
attrs("table", "at0600"),
vec![Cluster::new(
attrs("row", "at0601"),
vec![Item::Element(element("cell", "at0602", 6.0))],
)
.unwrap()],
);
let item_tree = ItemTree::new(
attrs("tree", "at0700"),
vec![Item::Element(element("t", "at0701", 3.0))],
);
let cluster = Cluster::new(
attrs("cluster", "at0800"),
vec![Item::Element(element("c", "at0801", 4.0))],
)
.unwrap();
let valued = element("valued", "at0900", 5.0);
let absent = Element::new_null(attrs("absent", "at0901"), "253").unwrap();
let history = History::new(
attrs("Series", "at1000"),
DvDateTime::new("2026-07-31T08:00:00Z").unwrap(),
vec![PointEvent::new(
attrs("point", "at1001"),
DvDateTime::new("2026-07-31T08:30:00Z").unwrap(),
item_single.clone().into(),
)
.into()],
Some(single("summary").into()),
)
.unwrap();
let event: Event = PointEvent::new(
attrs("point", "at1100"),
DvDateTime::new("2026-07-31T08:30:00Z").unwrap(),
item_single.clone().into(),
)
.into();
let event_with_state = PointEvent::new(
attrs("point", "at1101"),
DvDateTime::new("2026-07-31T08:35:00Z").unwrap(),
item_single.clone().into(),
)
.with_state(single("event state").into());
let observation_entry = Entry::Observation(observation);
let evaluation_entry = Entry::Evaluation(evaluation);
let admin_entry = Entry::AdminEntry(admin);
let instruction_entry = Entry::Instruction(instruction);
let action_entry = Entry::Action(action);
let single_structure: ItemStructure = item_single.clone().into();
let list_structure: ItemStructure = item_list.into();
let table_structure: ItemStructure = item_table.into();
let tree_structure: ItemStructure = item_tree.into();
let event_state: Event = event_with_state.into();
let cases: &[(Node<'_>, &str, &str)] = &[
(Node::Composition(&composition), "content", "OBSERVATION"),
(Node::Composition(&composition), "name", "DV_TEXT"),
(Node::Composition(&composition), "category", "DV_CODED_TEXT"),
(Node::Composition(&composition), "nonesuch", ""),
(Node::Text(composition.name()), "value", "Encounter"),
(Node::Text(composition.name()), "defining_code", ""),
(Node::Text(&coded_name), "value", "Systolic"),
(Node::Text(&coded_name), "defining_code", "at0004"),
(Node::CodedText(composition.category()), "value", "event"),
(Node::CodedText(composition.category()), "defining_code", "433"),
(Node::Section(§ion), "items", "SECTION"),
(Node::Section(§ion), "name", "DV_TEXT"),
(Node::Entry(&observation_entry), "data", "HISTORY"),
(Node::Entry(&observation_entry), "state", "HISTORY"),
(Node::Entry(&observation_entry), "protocol", "ITEM_SINGLE"),
(Node::Entry(&observation_entry), "name", "DV_TEXT"),
(Node::Entry(&evaluation_entry), "data", "ITEM_SINGLE"),
(Node::Entry(&evaluation_entry), "name", "DV_TEXT"),
(Node::Entry(&admin_entry), "data", "ITEM_SINGLE"),
(Node::Entry(&admin_entry), "name", "DV_TEXT"),
(Node::Entry(&instruction_entry), "narrative", "DV_TEXT"),
(Node::Entry(&instruction_entry), "activities", "ITEM_SINGLE"),
(Node::Entry(&instruction_entry), "name", "DV_TEXT"),
(Node::Entry(&action_entry), "description", "ITEM_SINGLE"),
(Node::Entry(&action_entry), "time", "2026-07-31T10:00:00Z"),
(Node::Entry(&action_entry), "name", "DV_TEXT"),
(Node::Entry(&observation_entry), "narrative", ""),
(Node::Entry(&instruction_entry), "data", ""),
(Node::ItemStructure(&single_structure), "item", "ELEMENT"),
(Node::ItemStructure(&single_structure), "items", "ELEMENT"),
(Node::ItemStructure(&single_structure), "name", "DV_TEXT"),
(Node::ItemStructure(&list_structure), "items", "ELEMENT"),
(Node::ItemStructure(&list_structure), "name", "DV_TEXT"),
(Node::ItemStructure(&table_structure), "rows", "CLUSTER"),
(Node::ItemStructure(&table_structure), "name", "DV_TEXT"),
(Node::ItemStructure(&tree_structure), "items", "ELEMENT"),
(Node::ItemStructure(&tree_structure), "name", "DV_TEXT"),
(Node::ItemStructure(&list_structure), "rows", ""),
(Node::ItemStructure(&table_structure), "items", ""),
(Node::Cluster(&cluster), "items", "ELEMENT"),
(Node::Cluster(&cluster), "name", "DV_TEXT"),
(Node::Element(&valued), "value", "DV_QUANTITY"),
(Node::Element(&valued), "name", "DV_TEXT"),
(Node::Element(&valued), "null_flavour", ""),
(Node::Element(&absent), "value", ""),
(Node::Element(&absent), "null_flavour", "253"),
(Node::History(&history), "events", "POINT_EVENT"),
(Node::History(&history), "summary", "ITEM_SINGLE"),
(Node::History(&history), "origin", "2026-07-31T08:00:00Z"),
(Node::History(&history), "name", "DV_TEXT"),
(Node::Event(&event), "data", "ITEM_SINGLE"),
(Node::Event(&event), "time", "2026-07-31T08:30:00Z"),
(Node::Event(&event), "name", "DV_TEXT"),
(Node::Event(&event), "state", ""),
(Node::Event(&event_state), "state", "ITEM_SINGLE"),
];
for (node, attribute, want) in cases {
let got = rendered(&node.children(attribute));
assert_eq!(
got,
*want,
"{}/{attribute} reached {got:?}, wanted {want:?}",
node.type_name()
);
}
}
#[test]
fn a_predicate_is_scanned_through_quotes_and_errors_report_where() {
let p: Path = "/items['a]b']/value".parse().unwrap();
assert_eq!(p.segments().len(), 2);
assert_eq!(p.segments()[0].conditions, vec![Condition::Name("a]b".into())]);
let p: Path = r#"/items["a]b"]/value"#.parse().unwrap();
assert_eq!(p.segments()[0].conditions, vec![Condition::Name("a]b".into())]);
let offset_of = |text: &str| match text.parse::<Path>() {
Err(PathError::Malformed { offset, .. }) => offset,
other => panic!("{text} did not fail as malformed: {other:?}"),
};
assert_eq!(offset_of("/items[at0001"), 6, "unclosed `[` points at the `[`");
assert_eq!(offset_of("items[at0001"), 5, "without the leading slash");
assert_eq!(offset_of("/items/"), 7, "trailing `/` points past the end");
assert_eq!(offset_of("/items//value"), 7, "an empty attribute name");
assert_eq!(
offset_of("/items[at0001]x/value"),
14,
"a segment must be followed by `/`"
);
assert_eq!(
offset_of("/items[bad/attr='x']"),
7,
"a bad predicate points just past the `[`"
);
assert_eq!(offset_of("/content/items[bad/attr='x']"), 15);
}
#[test]
fn conditions_are_split_on_and_and_comma_but_not_inside_a_name() {
let conditions = |text: &str| text.parse::<Path>().unwrap().segments()[0].conditions.clone();
assert_eq!(
conditions("/items[archetype_node_id='at0004' and name/value='Systolic']"),
vec![
Condition::NodeId("at0004".into()),
Condition::Name("Systolic".into()),
]
);
assert_eq!(
conditions("/items[archetype_node_id='at0004', name/value='Systolic']"),
vec![
Condition::NodeId("at0004".into()),
Condition::Name("Systolic".into()),
]
);
assert_eq!(
conditions("/items[name/value='Weight, standing']"),
vec![Condition::Name("Weight, standing".into())]
);
assert_eq!(
conditions("/items[name/value='Signs and symptoms']"),
vec![Condition::Name("Signs and symptoms".into())]
);
assert_eq!(
conditions("/items[name/value=band]"),
vec![Condition::Name("band".into())],
"`band` was split at its `and`"
);
assert_eq!(
conditions("/items[name/value=andy]"),
vec![Condition::Name("andy".into())],
"`andy` was split at its `and`"
);
assert!(
"/items[ and]".parse::<Path>().is_err(),
"a predicate of only a separator was accepted as a node id"
);
let err = "/items[archetype_node_id='at0004' and]"
.parse::<Path>()
.expect_err("a dangling `and` leaves an empty term");
assert!(
matches!(err, PathError::Malformed { reason, .. } if reason.contains("empty")),
"{err:?}"
);
}
#[test]
fn a_value_is_unquoted_only_when_both_quotes_are_there() {
let name = |text: &str| match text.parse::<Path>().unwrap().segments()[0]
.conditions
.first()
.cloned()
{
Some(Condition::Name(v)) => v,
other => panic!("{text} did not yield a name: {other:?}"),
};
assert_eq!(name("/items[name/value='Systolic']"), "Systolic");
assert_eq!(name(r#"/items[name/value="Systolic"]"#), "Systolic");
assert_eq!(name("/items[name/value='Sys'tolic]"), "'Sys'tolic");
assert_eq!(name("/items[name/value=Sys'tolic']"), "Sys'tolic'");
}
#[test]
fn the_root_path_is_the_only_root_and_uniqueness_is_counted() {
for text in ["", "/", " "] {
assert!(text.parse::<Path>().unwrap().is_root(), "{text:?}");
}
for text in ["/content", "/content/name/value"] {
assert!(!text.parse::<Path>().unwrap().is_root(), "{text:?}");
}
let c = blood_pressure();
assert!(c.path_unique("/content").unwrap());
let many = "/content/data/events/data/items";
assert_eq!(c.items_at_path(many).unwrap().len(), 2);
assert!(!c.path_unique(many).unwrap());
assert!(!c.path_unique("/content/nonesuch").unwrap());
}
}