use crate::base::iso8601;
use crate::error::ParseError;
use crate::rm::common::{LocatableAttrs, impl_locatable};
use crate::rm::data_types::{DataValue, DvCodedText, DvDateTime, DvDuration, Text};
use crate::rm::rm_type_tag;
use crate::terminology;
use serde::{Deserialize, Serialize};
rm_type_tag!(HistoryTag, "HISTORY");
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Element {
#[serde(flatten)]
locatable: LocatableAttrs,
#[serde(skip_serializing_if = "Option::is_none", default)]
value: Option<Box<DataValue>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
null_flavour: Option<DvCodedText>,
#[serde(skip_serializing_if = "Option::is_none", default)]
null_reason: Option<Text>,
}
impl_locatable!(Element, "ELEMENT");
impl Element {
#[must_use]
pub fn new(locatable: LocatableAttrs, value: DataValue) -> Self {
Self {
locatable,
value: Some(Box::new(value)),
null_flavour: None,
null_reason: None,
}
}
pub fn new_null(
locatable: LocatableAttrs,
null_flavour_code: &str,
) -> Result<Self, ParseError> {
let null_flavour = terminology::null_flavour::GROUP
.coded_text(null_flavour_code)
.ok_or_else(|| ParseError::invariant("ELEMENT", "Inv_null_flavour_valid"))?;
Ok(Self {
locatable,
value: None,
null_flavour: Some(null_flavour),
null_reason: None,
})
}
pub fn with_null_reason(mut self, reason: Text) -> Result<Self, ParseError> {
if self.value.is_some() {
return Err(ParseError::invariant("ELEMENT", "Inv_null_reason_valid"));
}
self.null_reason = Some(reason);
Ok(self)
}
#[must_use]
pub fn value(&self) -> Option<&DataValue> {
self.value.as_deref()
}
#[must_use]
pub fn null_flavour(&self) -> Option<&DvCodedText> {
self.null_flavour.as_ref()
}
#[must_use]
pub fn null_reason(&self) -> Option<&Text> {
self.null_reason.as_ref()
}
#[must_use]
pub fn is_null(&self) -> bool {
self.value.is_none()
}
#[must_use]
pub fn null_flavour_code(&self) -> Option<&str> {
self.null_flavour
.as_ref()
.map(|f| f.defining_code().code_string())
}
#[must_use]
pub fn is_masked(&self) -> bool {
self.null_flavour_code() == Some(terminology::null_flavour::MASKED)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Cluster {
#[serde(flatten)]
locatable: LocatableAttrs,
items: Vec<Item>,
}
impl_locatable!(Cluster, "CLUSTER");
impl Cluster {
pub fn new(locatable: LocatableAttrs, items: Vec<Item>) -> Result<Self, ParseError> {
if items.is_empty() {
return Err(ParseError::invariant("CLUSTER", "Items_non_empty"));
}
Ok(Self { locatable, items })
}
#[must_use]
pub fn items(&self) -> &[Item] {
&self.items
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "_type")]
#[allow(clippy::large_enum_variant)]
pub enum Item {
#[serde(rename = "CLUSTER")]
Cluster(Cluster),
#[serde(rename = "ELEMENT")]
Element(Element),
}
impl Item {
#[must_use]
pub fn type_name(&self) -> &'static str {
match self {
Self::Cluster(_) => "CLUSTER",
Self::Element(_) => "ELEMENT",
}
}
#[must_use]
pub fn archetype_node_id(&self) -> &str {
use crate::rm::common::Locatable as _;
match self {
Self::Cluster(c) => c.archetype_node_id(),
Self::Element(e) => e.archetype_node_id(),
}
}
#[must_use]
pub fn name(&self) -> &Text {
use crate::rm::common::Locatable as _;
match self {
Self::Cluster(c) => c.name(),
Self::Element(e) => e.name(),
}
}
pub fn elements(&self) -> Box<dyn Iterator<Item = &Element> + '_> {
match self {
Self::Element(e) => Box::new(core::iter::once(e)),
Self::Cluster(c) => Box::new(c.items().iter().flat_map(Item::elements)),
}
}
}
impl From<Cluster> for Item {
fn from(v: Cluster) -> Self {
Self::Cluster(v)
}
}
impl From<Element> for Item {
fn from(v: Element) -> Self {
Self::Element(v)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ItemSingle {
#[serde(flatten)]
locatable: LocatableAttrs,
item: Element,
}
impl_locatable!(ItemSingle, "ITEM_SINGLE");
impl ItemSingle {
#[must_use]
pub fn new(locatable: LocatableAttrs, item: Element) -> Self {
Self { locatable, item }
}
#[must_use]
pub fn item(&self) -> &Element {
&self.item
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ItemList {
#[serde(flatten)]
locatable: LocatableAttrs,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
items: Vec<Element>,
}
impl_locatable!(ItemList, "ITEM_LIST");
impl ItemList {
#[must_use]
pub fn new(locatable: LocatableAttrs, items: Vec<Element>) -> Self {
Self { locatable, items }
}
#[must_use]
pub fn items(&self) -> &[Element] {
&self.items
}
#[must_use]
pub fn item_count(&self) -> usize {
self.items.len()
}
#[must_use]
pub fn named_item(&self, name: &str) -> Option<&Element> {
self.items.iter().find(|e| {
use crate::rm::common::Locatable as _;
e.name().value() == name
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ItemTable {
#[serde(flatten)]
locatable: LocatableAttrs,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
rows: Vec<Cluster>,
}
impl_locatable!(ItemTable, "ITEM_TABLE");
impl ItemTable {
#[must_use]
pub fn new(locatable: LocatableAttrs, rows: Vec<Cluster>) -> Self {
Self { locatable, rows }
}
#[must_use]
pub fn rows(&self) -> &[Cluster] {
&self.rows
}
#[must_use]
pub fn row_count(&self) -> usize {
self.rows.len()
}
#[must_use]
pub fn column_count(&self) -> usize {
self.rows.first().map_or(0, |r| r.items().len())
}
#[must_use]
pub fn is_regular(&self) -> bool {
let width = self.column_count();
self.rows.iter().all(|r| r.items().len() == width)
}
#[must_use]
pub fn element_at_cell(&self, row: usize, column: usize) -> Option<&Element> {
match self.rows.get(row)?.items().get(column)? {
Item::Element(e) => Some(e),
Item::Cluster(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ItemTree {
#[serde(flatten)]
locatable: LocatableAttrs,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
items: Vec<Item>,
}
impl_locatable!(ItemTree, "ITEM_TREE");
impl ItemTree {
#[must_use]
pub fn new(locatable: LocatableAttrs, items: Vec<Item>) -> Self {
Self { locatable, items }
}
#[must_use]
pub fn items(&self) -> &[Item] {
&self.items
}
pub fn elements(&self) -> impl Iterator<Item = &Element> {
self.items.iter().flat_map(Item::elements)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "_type")]
#[allow(clippy::large_enum_variant)]
pub enum ItemStructure {
#[serde(rename = "ITEM_SINGLE")]
Single(ItemSingle),
#[serde(rename = "ITEM_LIST")]
List(ItemList),
#[serde(rename = "ITEM_TABLE")]
Table(ItemTable),
#[serde(rename = "ITEM_TREE")]
Tree(ItemTree),
}
impl ItemStructure {
#[must_use]
pub fn type_name(&self) -> &'static str {
match self {
Self::Single(_) => "ITEM_SINGLE",
Self::List(_) => "ITEM_LIST",
Self::Table(_) => "ITEM_TABLE",
Self::Tree(_) => "ITEM_TREE",
}
}
#[must_use]
pub fn locatable(&self) -> &LocatableAttrs {
use crate::rm::common::Locatable as _;
match self {
Self::Single(s) => s.locatable(),
Self::List(s) => s.locatable(),
Self::Table(s) => s.locatable(),
Self::Tree(s) => s.locatable(),
}
}
#[must_use]
pub fn elements(&self) -> Box<dyn Iterator<Item = &Element> + '_> {
match self {
Self::Single(s) => Box::new(core::iter::once(s.item())),
Self::List(s) => Box::new(s.items().iter()),
Self::Table(s) => Box::new(
s.rows()
.iter()
.flat_map(|r| r.items().iter().flat_map(Item::elements)),
),
Self::Tree(s) => Box::new(s.elements()),
}
}
}
impl From<ItemSingle> for ItemStructure {
fn from(v: ItemSingle) -> Self {
Self::Single(v)
}
}
impl From<ItemList> for ItemStructure {
fn from(v: ItemList) -> Self {
Self::List(v)
}
}
impl From<ItemTable> for ItemStructure {
fn from(v: ItemTable) -> Self {
Self::Table(v)
}
}
impl From<ItemTree> for ItemStructure {
fn from(v: ItemTree) -> Self {
Self::Tree(v)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PointEvent {
#[serde(flatten)]
locatable: LocatableAttrs,
time: DvDateTime,
#[serde(skip_serializing_if = "Option::is_none", default)]
state: Option<Box<ItemStructure>>,
data: ItemStructure,
}
impl_locatable!(PointEvent, "POINT_EVENT");
impl PointEvent {
#[must_use]
pub fn new(locatable: LocatableAttrs, time: DvDateTime, data: ItemStructure) -> Self {
Self {
locatable,
time,
state: None,
data,
}
}
#[must_use]
pub fn with_state(mut self, state: ItemStructure) -> Self {
self.state = Some(Box::new(state));
self
}
#[must_use]
pub fn time(&self) -> &DvDateTime {
&self.time
}
#[must_use]
pub fn data(&self) -> &ItemStructure {
&self.data
}
#[must_use]
pub fn state(&self) -> Option<&ItemStructure> {
self.state.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntervalEvent {
#[serde(flatten)]
locatable: LocatableAttrs,
time: DvDateTime,
#[serde(skip_serializing_if = "Option::is_none", default)]
state: Option<Box<ItemStructure>>,
data: ItemStructure,
width: DvDuration,
#[serde(skip_serializing_if = "Option::is_none", default)]
sample_count: Option<i64>,
math_function: DvCodedText,
}
impl_locatable!(IntervalEvent, "INTERVAL_EVENT");
impl IntervalEvent {
pub fn new(
locatable: LocatableAttrs,
time: DvDateTime,
data: ItemStructure,
width: DvDuration,
math_function_code: &str,
) -> Result<Self, ParseError> {
if width.value().is_negative() {
return Err(ParseError::invariant(
"INTERVAL_EVENT",
"Width_non_negative",
));
}
let math_function = terminology::event_math_function::GROUP
.coded_text(math_function_code)
.ok_or_else(|| ParseError::invariant("INTERVAL_EVENT", "Math_function_validity"))?;
Ok(Self {
locatable,
time,
state: None,
data,
width,
sample_count: None,
math_function,
})
}
#[must_use]
pub fn with_sample_count(mut self, count: i64) -> Self {
self.sample_count = Some(count);
self
}
#[must_use]
pub fn time(&self) -> &DvDateTime {
&self.time
}
#[must_use]
pub fn width(&self) -> &DvDuration {
&self.width
}
#[must_use]
pub fn math_function(&self) -> &DvCodedText {
&self.math_function
}
#[must_use]
pub fn data(&self) -> &ItemStructure {
&self.data
}
#[must_use]
pub fn state(&self) -> Option<&ItemStructure> {
self.state.as_deref()
}
#[must_use]
pub fn sample_count(&self) -> Option<i64> {
self.sample_count
}
pub fn interval_start_time(&self) -> Result<DvDateTime, crate::Error> {
let width = self.width.value();
if width.years() > 0 || width.months() > 0 {
return Err(crate::Error::Unsupported {
what: "INTERVAL_EVENT.interval_start_time with a calendar-month or -year width",
spec_ref: "spec/04-data-structures.md R4.9",
});
}
let Some(time) = self.time.value().time() else {
return Err(crate::Error::Unsupported {
what: "INTERVAL_EVENT.interval_start_time on a date with no time of day",
spec_ref: "spec/04-data-structures.md R4.9",
});
};
let seconds = width.approx_seconds();
let start = subtract_seconds(self.time.value().date(), time, seconds)?;
DvDateTime::new(&start).map_err(Into::into)
}
}
fn subtract_seconds(
date: &iso8601::Date,
time: &iso8601::Time,
seconds: f64,
) -> Result<String, crate::Error> {
let (Some(month), Some(day)) = (date.month(), date.day()) else {
return Err(crate::Error::Unsupported {
what: "arithmetic on a date without a day",
spec_ref: "spec/04-data-structures.md R4.9",
});
};
let total = i64::from(time.hour()) * 3600
+ i64::from(time.minute().unwrap_or(0)) * 60
+ i64::from(time.second().unwrap_or(0));
#[allow(clippy::cast_possible_truncation)]
let mut remaining = total - seconds.round() as i64;
let mut y = date.year();
let mut m = month;
let mut d = day;
while remaining < 0 {
remaining += 86_400;
d -= 1;
if d == 0 {
m -= 1;
if m == 0 {
m = 12;
y -= 1;
}
d = days_in_month(y, m);
}
}
let offset = time.offset().map(|o| o.to_string()).unwrap_or_default();
Ok(format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}{offset}",
remaining / 3600,
(remaining % 3600) / 60,
remaining % 60
))
}
fn days_in_month(year: i32, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
_ => 28,
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "_type")]
#[allow(clippy::large_enum_variant)]
pub enum Event {
#[serde(rename = "POINT_EVENT")]
Point(PointEvent),
#[serde(rename = "INTERVAL_EVENT")]
Interval(IntervalEvent),
}
impl Event {
#[must_use]
pub fn type_name(&self) -> &'static str {
match self {
Self::Point(_) => "POINT_EVENT",
Self::Interval(_) => "INTERVAL_EVENT",
}
}
#[must_use]
pub fn time(&self) -> &DvDateTime {
match self {
Self::Point(e) => e.time(),
Self::Interval(e) => e.time(),
}
}
#[must_use]
pub fn data(&self) -> &ItemStructure {
match self {
Self::Point(e) => e.data(),
Self::Interval(e) => e.data(),
}
}
#[must_use]
pub fn state(&self) -> Option<&ItemStructure> {
match self {
Self::Point(e) => e.state(),
Self::Interval(e) => e.state(),
}
}
#[must_use]
pub fn locatable(&self) -> &LocatableAttrs {
use crate::rm::common::Locatable as _;
match self {
Self::Point(e) => e.locatable(),
Self::Interval(e) => e.locatable(),
}
}
}
impl From<PointEvent> for Event {
fn from(v: PointEvent) -> Self {
Self::Point(v)
}
}
impl From<IntervalEvent> for Event {
fn from(v: IntervalEvent) -> Self {
Self::Interval(v)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct History {
#[serde(rename = "_type", default)]
rm_type: HistoryTag,
#[serde(flatten)]
locatable: LocatableAttrs,
origin: DvDateTime,
#[serde(skip_serializing_if = "Option::is_none", default)]
period: Option<DvDuration>,
#[serde(skip_serializing_if = "Option::is_none", default)]
duration: Option<DvDuration>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
events: Vec<Event>,
#[serde(skip_serializing_if = "Option::is_none", default)]
summary: Option<Box<ItemStructure>>,
}
impl_locatable!(History, "HISTORY");
impl History {
pub fn new(
locatable: LocatableAttrs,
origin: DvDateTime,
events: Vec<Event>,
summary: Option<ItemStructure>,
) -> Result<Self, ParseError> {
if events.is_empty() && summary.is_none() {
return Err(ParseError::invariant("HISTORY", "Events_valid"));
}
Ok(Self {
rm_type: HistoryTag,
locatable,
origin,
period: None,
duration: None,
events,
summary: summary.map(Box::new),
})
}
pub fn with_period(mut self, period: DvDuration) -> Result<Self, ParseError> {
if period.value().is_negative() || period.value().is_zero() {
return Err(ParseError::invariant("HISTORY", "Periodic_validity"));
}
self.period = Some(period);
Ok(self)
}
#[must_use]
pub fn with_duration(mut self, duration: DvDuration) -> Self {
self.duration = Some(duration);
self
}
#[must_use]
pub fn origin(&self) -> &DvDateTime {
&self.origin
}
#[must_use]
pub fn events(&self) -> &[Event] {
&self.events
}
#[must_use]
pub fn summary(&self) -> Option<&ItemStructure> {
self.summary.as_deref()
}
#[must_use]
pub fn period(&self) -> Option<&DvDuration> {
self.period.as_ref()
}
#[must_use]
pub fn duration(&self) -> Option<&DvDuration> {
self.duration.as_ref()
}
#[must_use]
pub fn offset_seconds(&self, event: &Event) -> Option<i64> {
event.time().value().diff_seconds(self.origin.value())
}
#[must_use]
pub fn is_period_consistent(&self) -> Option<bool> {
let period = self.period.as_ref()?.value().approx_seconds();
let p = self.period.as_ref()?.value();
if p.years() > 0 || p.months() > 0 {
return None;
}
#[allow(clippy::cast_possible_truncation)]
let period = period.round() as i64;
if period == 0 {
return None;
}
let mut all = true;
for event in &self.events {
let offset = self.offset_seconds(event)?;
if offset % period != 0 {
all = false;
}
}
Some(all)
}
#[must_use]
pub fn is_periodic(&self) -> bool {
self.period.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rm::data_types::{DvCount, DvQuantity};
fn attrs(name: &str, node: &str) -> LocatableAttrs {
LocatableAttrs::named(name, node).unwrap()
}
fn count_element(name: &str, n: i64) -> Element {
Element::new(attrs(name, "at0001"), DataValue::Count(DvCount::new(n)))
}
#[test]
fn an_element_is_valued_or_null_and_never_both() {
let valued = count_element("x", 1);
assert!(!valued.is_null());
assert!(valued.null_flavour().is_none());
let null =
Element::new_null(attrs("x", "at0001"), terminology::null_flavour::UNKNOWN).unwrap();
assert!(null.is_null());
assert!(null.value().is_none());
assert!(
valued
.with_null_reason(Text::plain("because").unwrap())
.is_err()
);
}
#[test]
fn the_four_null_flavours_stay_four() {
let masked =
Element::new_null(attrs("x", "at0001"), terminology::null_flavour::MASKED).unwrap();
let unknown =
Element::new_null(attrs("x", "at0001"), terminology::null_flavour::UNKNOWN).unwrap();
assert!(masked.is_masked());
assert!(!unknown.is_masked());
assert_ne!(masked.null_flavour_code(), unknown.null_flavour_code());
assert!(Element::new_null(attrs("x", "at0001"), "999").is_err());
}
#[test]
fn interval_start_time_is_derived_where_it_can_be_and_refused_where_it_cannot() {
let data = ItemSingle::new(attrs("d", "at0002"), count_element("v", 1)).into();
let event = IntervalEvent::new(
attrs("8 hour output", "at0006"),
DvDateTime::new("2026-07-31T08:00:00Z").unwrap(),
data,
DvDuration::new("PT8H").unwrap(),
terminology::event_math_function::TOTAL,
)
.unwrap();
assert_eq!(
event.interval_start_time().unwrap().as_str(),
"2026-07-31T00:00:00Z"
);
let data2 = ItemSingle::new(attrs("d", "at0002"), count_element("v", 1)).into();
let calendar = IntervalEvent::new(
attrs("monthly total", "at0006"),
DvDateTime::new("2026-03-31T08:00:00Z").unwrap(),
data2,
DvDuration::new("P1M").unwrap(),
terminology::event_math_function::TOTAL,
)
.unwrap();
assert!(calendar.interval_start_time().is_err());
}
#[test]
fn interval_start_time_crosses_a_day_boundary_correctly() {
let data = ItemSingle::new(attrs("d", "at0002"), count_element("v", 1)).into();
let event = IntervalEvent::new(
attrs("overnight", "at0006"),
DvDateTime::new("2026-03-01T06:00:00Z").unwrap(),
data,
DvDuration::new("PT12H").unwrap(),
terminology::event_math_function::MEAN,
)
.unwrap();
assert_eq!(
event.interval_start_time().unwrap().as_str(),
"2026-02-28T18:00:00Z"
);
}
#[test]
fn a_history_needs_events_or_a_summary() {
assert!(
History::new(
attrs("h", "at0001"),
DvDateTime::new("2026-07-31T09:00:00Z").unwrap(),
Vec::new(),
None,
)
.is_err()
);
}
#[test]
fn element_traversal_reaches_every_leaf_of_every_structure() {
let tree = ItemTree::new(
attrs("t", "at0001"),
vec![
count_element("a", 1).into(),
Cluster::new(attrs("c", "at0002"), vec![count_element("b", 2).into()])
.unwrap()
.into(),
],
);
assert_eq!(ItemStructure::Tree(tree).elements().count(), 2);
let table = ItemTable::new(
attrs("t", "at0001"),
vec![
Cluster::new(attrs("r", "at0002"), vec![count_element("a", 1).into()]).unwrap(),
Cluster::new(attrs("r", "at0002"), vec![count_element("b", 2).into()]).unwrap(),
],
);
assert_eq!(ItemStructure::Table(table).elements().count(), 2);
}
#[test]
fn structures_round_trip_with_their_type_tags() {
let s = ItemStructure::Single(ItemSingle::new(
attrs("s", "at0001"),
Element::new(
attrs("q", "at0002"),
DataValue::Quantity(DvQuantity::new(1.0, "mg").unwrap()),
),
));
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains(r#""_type":"ITEM_SINGLE""#), "{json}");
let back: ItemStructure = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
}