use std::path::{Path, PathBuf};
use quick_xml::Reader;
use quick_xml::events::{BytesStart, Event};
pub const DUTCH: &str = "nl-NL";
pub const SNOMED_OID: &str = "2.16.840.1.113883.6.96";
#[derive(Debug, thiserror::Error)]
pub enum LabcodesetError {
#[error("cannot read {path}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("no `labconcepts-*.xml` document under {root}")]
NoDocument {
root: PathBuf,
},
#[error("{path} is not well-formed XML")]
Xml {
path: PathBuf,
#[source]
source: quick_xml::Error,
},
#[error("{path}: an attribute of `{element}` does not parse")]
Attribute {
path: PathBuf,
element: String,
#[source]
source: quick_xml::events::attributes::AttrError,
},
#[error("{path}: unexpected element `{element}` inside `{parent}`")]
Unexpected {
path: PathBuf,
parent: String,
element: String,
},
#[error("{path}: `{element}` has no `{attribute}` attribute")]
MissingAttribute {
path: PathBuf,
element: String,
attribute: &'static str,
},
#[error("{path}: `{parent}` has no `{element}` element")]
MissingElement {
path: PathBuf,
parent: String,
element: &'static str,
},
#[error("{path}: the document ends inside `{element}`")]
Truncated {
path: PathBuf,
element: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConceptStatus {
Active,
Retired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoincStatus {
Active,
Deprecated,
Discouraged,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Axes {
pub component: String,
pub property: Option<String>,
pub timing: Option<String>,
pub system: Option<String>,
pub scale: Option<String>,
pub method: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Translation {
pub language: String,
pub axes: Axes,
pub class: Option<String>,
pub long_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Replacement {
pub from: String,
pub to: String,
pub comment: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoincConcept {
pub code: String,
pub status: LoincStatus,
pub axes: Axes,
pub class: String,
pub order_observation: Option<String>,
pub panel_type: Option<String>,
pub long_name: String,
pub replacement: Option<Replacement>,
pub translation: Option<Translation>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MaterialRef {
pub code: String,
pub display_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refset {
pub concept_id: String,
pub preferred_term: String,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Refset(Refset),
ValueSet(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LabConcept {
pub status: ConceptStatus,
pub loinc: LoincConcept,
pub materials: Vec<MaterialRef>,
pub outcome: Option<Outcome>,
pub units: Vec<String>,
pub retired_reason: Option<String>,
pub retired_replacement: Option<String>,
pub release_note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Material {
pub code: String,
pub display_name: String,
pub system: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unit {
pub id: String,
pub status: Option<String>,
pub ucum: String,
pub name: Option<String>,
pub dutch_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrdinalConcept {
pub code: String,
pub code_system: String,
pub code_system_name: Option<String>,
pub display_name: String,
pub level: Option<String>,
pub kind: Option<String>,
pub descriptions: Vec<(Option<String>, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrdinalValueSet {
pub id: String,
pub effective_date: Option<String>,
pub name: Option<String>,
pub display_name: String,
pub status: Option<String>,
pub version_label: Option<String>,
pub concepts: Vec<OrdinalConcept>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Publication {
pub effective_date: String,
pub description: String,
pub concepts: Vec<LabConcept>,
pub materials: Vec<Material>,
pub units: Vec<Unit>,
pub ordinals: Vec<OrdinalValueSet>,
pub nominals: Vec<Refset>,
}
impl Publication {
#[must_use]
pub fn release(&self) -> String {
self.effective_date
.chars()
.take_while(char::is_ascii_digit)
.take(8)
.collect()
}
#[must_use]
pub fn unit(&self, id: &str) -> Option<&Unit> {
self.units.iter().find(|u| u.id == id)
}
}
pub fn read(path: &Path) -> Result<Publication, LabcodesetError> {
let document = if path.is_dir() {
find_document(path)?
} else {
path.to_path_buf()
};
let text = std::fs::read_to_string(&document).map_err(|source| LabcodesetError::Io {
path: document.clone(),
source,
})?;
parse(&text, &document)
}
fn find_document(root: &Path) -> Result<PathBuf, LabcodesetError> {
let mut found: Vec<PathBuf> = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(dir) = pending.pop() {
let entries = std::fs::read_dir(&dir).map_err(|source| LabcodesetError::Io {
path: dir.clone(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| LabcodesetError::Io {
path: dir.clone(),
source,
})?;
let path = entry.path();
if path.is_dir() {
pending.push(path);
} else if is_document(&path) {
found.push(path);
}
}
}
found.sort();
found.pop().ok_or_else(|| LabcodesetError::NoDocument {
root: root.to_path_buf(),
})
}
fn is_document(path: &Path) -> bool {
path.file_stem()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|n| n.starts_with("labconcepts"))
&& path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("xml"))
}
pub fn parse(text: &str, path: &Path) -> Result<Publication, LabcodesetError> {
let mut reader = Reader::from_str(text);
reader.config_mut().trim_text(true);
let mut parser = Parser {
reader,
path: path.to_path_buf(),
};
loop {
match parser.next()? {
Event::Start(start) if start.name().as_ref() == "publication" => {
let start = start.into_owned();
return parser.publication(&start);
}
Event::Eof => {
return Err(LabcodesetError::MissingElement {
path: path.to_path_buf(),
parent: String::from("(document)"),
element: "publication",
});
}
Event::Start(other) | Event::Empty(other) => {
return Err(LabcodesetError::Unexpected {
path: path.to_path_buf(),
parent: String::from("(document)"),
element: other.name().as_ref().to_owned(),
});
}
_ => {}
}
}
}
struct Parser<'a> {
reader: Reader<&'a [u8]>,
path: PathBuf,
}
struct Child<'e> {
start: BytesStart<'e>,
empty: bool,
}
impl Child<'_> {
fn name(&self) -> String {
self.start.name().as_ref().to_owned()
}
}
impl Parser<'_> {
fn next(&mut self) -> Result<Event<'static>, LabcodesetError> {
self.reader
.read_event()
.map(Event::into_owned)
.map_err(|source| LabcodesetError::Xml {
path: self.path.clone(),
source,
})
}
fn attribute(
&self,
start: &BytesStart<'_>,
name: &str,
) -> Result<Option<String>, LabcodesetError> {
for attribute in start.attributes() {
let attribute = attribute.map_err(|source| LabcodesetError::Attribute {
path: self.path.clone(),
element: start.name().as_ref().to_owned(),
source,
})?;
if attribute.key.as_ref() == name {
let value = attribute
.normalized_value(quick_xml::XmlVersion::Explicit1_0)
.map_err(|source| LabcodesetError::Xml {
path: self.path.clone(),
source,
})?;
return Ok(Some(value.into_owned()));
}
}
Ok(None)
}
fn required(
&self,
start: &BytesStart<'_>,
name: &'static str,
) -> Result<String, LabcodesetError> {
self.attribute(start, name)?
.ok_or_else(|| LabcodesetError::MissingAttribute {
path: self.path.clone(),
element: start.name().as_ref().to_owned(),
attribute: name,
})
}
fn text(&mut self, child: &Child<'_>) -> Result<String, LabcodesetError> {
if child.empty {
return Ok(String::new());
}
let raw = self
.reader
.read_text(child.start.name())
.map_err(|source| LabcodesetError::Xml {
path: self.path.clone(),
source,
})?;
Ok(raw.xml10_content().trim().to_owned())
}
fn skip(&mut self, child: &Child<'_>) -> Result<(), LabcodesetError> {
if child.empty {
return Ok(());
}
self.reader
.read_to_end(child.start.name())
.map(|_| ())
.map_err(|source| LabcodesetError::Xml {
path: self.path.clone(),
source,
})
}
fn children(
&mut self,
parent: &BytesStart<'_>,
empty: bool,
mut handle: impl FnMut(&mut Self, &Child<'_>) -> Result<(), LabcodesetError>,
) -> Result<(), LabcodesetError> {
if empty {
return Ok(());
}
let parent_name = parent.name().as_ref().to_owned();
loop {
match self.next()? {
Event::End(_) => return Ok(()),
Event::Start(start) => handle(
self,
&Child {
start,
empty: false,
},
)?,
Event::Empty(start) => handle(self, &Child { start, empty: true })?,
Event::Comment(_) | Event::PI(_) => {}
Event::Eof => {
return Err(LabcodesetError::Truncated {
path: self.path.clone(),
element: parent_name,
});
}
_ => {
return Err(LabcodesetError::Unexpected {
path: self.path.clone(),
parent: parent_name,
element: String::from("(text)"),
});
}
}
}
}
fn unexpected(&self, parent: &BytesStart<'_>, child: &Child<'_>) -> LabcodesetError {
LabcodesetError::Unexpected {
path: self.path.clone(),
parent: parent.name().as_ref().to_owned(),
element: child.name(),
}
}
fn missing(&self, parent: &BytesStart<'_>, element: &'static str) -> LabcodesetError {
LabcodesetError::MissingElement {
path: self.path.clone(),
parent: parent.name().as_ref().to_owned(),
element,
}
}
fn publication(&mut self, start: &BytesStart<'_>) -> Result<Publication, LabcodesetError> {
let effective_date = self.required(start, "effectiveDate")?;
let mut description = String::new();
let mut concepts = Vec::new();
let mut materials = Vec::new();
let mut units = Vec::new();
let mut ordinals = Vec::new();
let mut nominals = Vec::new();
self.children(start, false, |parser, child| {
match child.name().as_str() {
"desc" => description = parser.text(child)?,
"lab_concepts" => {
let parent = child.start.clone();
parser.children(&parent, child.empty, |parser, item| {
if item.name() == "lab_concept" {
concepts.push(parser.lab_concept(item)?);
Ok(())
} else {
Err(parser.unexpected(&parent, item))
}
})?;
}
"map" => {
let parent = child.start.clone();
parser.children(&parent, child.empty, |parser, item| {
if item.name() == "material" {
materials.push(Material {
code: parser.required(&item.start, "code")?,
display_name: parser.required(&item.start, "displayName")?,
system: parser.attribute(&item.start, "system")?,
});
parser.skip(item)
} else {
Err(parser.unexpected(&parent, item))
}
})?;
}
"units" => {
let parent = child.start.clone();
parser.children(&parent, child.empty, |parser, item| {
if item.name() == "unit" {
units.push(parser.unit(item)?);
Ok(())
} else {
Err(parser.unexpected(&parent, item))
}
})?;
}
"ordinals" => {
let parent = child.start.clone();
parser.children(&parent, child.empty, |parser, item| {
if item.name() == "valueSet" {
ordinals.push(parser.ordinal(item)?);
Ok(())
} else {
Err(parser.unexpected(&parent, item))
}
})?;
}
"nominals" => {
let parent = child.start.clone();
parser.children(&parent, child.empty, |parser, item| {
if item.name() == "refset" {
nominals.push(parser.refset(item)?);
Ok(())
} else {
Err(parser.unexpected(&parent, item))
}
})?;
}
"panels" => parser.skip(child)?,
_ => return Err(parser.unexpected(start, child)),
}
Ok(())
})?;
Ok(Publication {
effective_date,
description,
concepts,
materials,
units,
ordinals,
nominals,
})
}
fn lab_concept(&mut self, node: &Child<'_>) -> Result<LabConcept, LabcodesetError> {
let status = match self.required(&node.start, "status")?.as_str() {
"active" => ConceptStatus::Active,
"retired" => ConceptStatus::Retired,
_ => {
return Err(LabcodesetError::MissingAttribute {
path: self.path.clone(),
element: node.name(),
attribute: "status (active or retired)",
});
}
};
let mut loinc = None;
let mut materials = Vec::new();
let mut outcome = None;
let mut units = Vec::new();
let mut retired_reason = None;
let mut retired_replacement = None;
let mut release_note = None;
let start = node.start.clone();
self.children(&start, node.empty, |parser, child| {
match child.name().as_str() {
"loincConcept" => loinc = Some(parser.loinc_concept(child)?),
"materials" => {
let parent = child.start.clone();
parser.children(&parent, child.empty, |parser, item| {
if item.name() == "material" {
materials.push(MaterialRef {
code: parser.required(&item.start, "code")?,
display_name: parser.required(&item.start, "displayName")?,
});
parser.skip(item)
} else {
Err(parser.unexpected(&parent, item))
}
})?;
}
"outcomes" => {
let parent = child.start.clone();
parser.children(&parent, child.empty, |parser, item| {
match item.name().as_str() {
"refset" => outcome = Some(Outcome::Refset(parser.refset(item)?)),
"valueSet" => {
outcome =
Some(Outcome::ValueSet(parser.required(&item.start, "ref")?));
parser.skip(item)?;
}
_ => return Err(parser.unexpected(&parent, item)),
}
Ok(())
})?;
}
"units" => {
let parent = child.start.clone();
parser.children(&parent, child.empty, |parser, item| {
if item.name() == "unit" {
units.push(parser.required(&item.start, "ref")?);
parser.skip(item)
} else {
Err(parser.unexpected(&parent, item))
}
})?;
}
"retired-reason" => retired_reason = Some(parser.text(child)?),
"retired-replacement" => retired_replacement = Some(parser.text(child)?),
"releasenote" => release_note = Some(parser.text(child)?),
_ => return Err(parser.unexpected(&start, child)),
}
Ok(())
})?;
Ok(LabConcept {
status,
loinc: loinc.ok_or_else(|| self.missing(&start, "loincConcept"))?,
materials,
outcome,
units,
retired_reason,
retired_replacement,
release_note,
})
}
fn loinc_concept(&mut self, node: &Child<'_>) -> Result<LoincConcept, LabcodesetError> {
let code = self.required(&node.start, "loinc_num")?;
let status = match self.required(&node.start, "status")?.as_str() {
"ACTIVE" => LoincStatus::Active,
"DEPRECATED" => LoincStatus::Deprecated,
"DISCOURAGED" => LoincStatus::Discouraged,
_ => {
return Err(LabcodesetError::MissingAttribute {
path: self.path.clone(),
element: node.name(),
attribute: "status (ACTIVE, DEPRECATED, or DISCOURAGED)",
});
}
};
let mut axes = Axes::default();
let mut class = None;
let mut order_observation = None;
let mut panel_type = None;
let mut long_name = None;
let mut replacement = None;
let mut translation = None;
let start = node.start.clone();
self.children(&start, node.empty, |parser, child| {
let name = child.name();
if axis(&mut axes, &name, parser, child)? {
return Ok(());
}
match name.as_str() {
"class" => class = Some(parser.text(child)?),
"orderObs" => order_observation = Some(parser.text(child)?),
"panelType" => panel_type = Some(parser.text(child)?),
"longName" => long_name = Some(parser.text(child)?),
"map" => {
replacement = Some(Replacement {
from: parser.required(&child.start, "from")?,
to: parser.required(&child.start, "to")?,
comment: parser
.attribute(&child.start, "comment")?
.unwrap_or_default(),
});
parser.skip(child)?;
}
"translation" => translation = Some(parser.translation(child)?),
"references" => parser.skip(child)?,
_ => return Err(parser.unexpected(&start, child)),
}
Ok(())
})?;
Ok(LoincConcept {
code,
status,
axes,
class: class.ok_or_else(|| self.missing(&start, "class"))?,
order_observation,
panel_type,
long_name: long_name.ok_or_else(|| self.missing(&start, "longName"))?,
replacement,
translation,
})
}
fn translation(&mut self, node: &Child<'_>) -> Result<Translation, LabcodesetError> {
let language = self.required(&node.start, "language")?;
let mut axes = Axes::default();
let mut class = None;
let mut long_name = None;
let start = node.start.clone();
self.children(&start, node.empty, |parser, child| {
let name = child.name();
if axis(&mut axes, &name, parser, child)? {
return Ok(());
}
match name.as_str() {
"class" => class = Some(parser.text(child)?),
"longName" => long_name = Some(parser.text(child)?),
_ => return Err(parser.unexpected(&start, child)),
}
Ok(())
})?;
Ok(Translation {
language,
axes,
class,
long_name,
})
}
fn refset(&mut self, node: &Child<'_>) -> Result<Refset, LabcodesetError> {
let refset = Refset {
concept_id: self.required(&node.start, "conceptId")?,
preferred_term: self.required(&node.start, "preferredTerm")?,
source: self.required(&node.start, "src")?,
};
self.skip(node)?;
Ok(refset)
}
fn unit(&mut self, node: &Child<'_>) -> Result<Unit, LabcodesetError> {
let id = self.required(&node.start, "id")?;
let status = self.attribute(&node.start, "status")?;
let mut ucum = None;
let mut name = None;
let mut dutch_name = None;
let start = node.start.clone();
self.children(&start, node.empty, |parser, child| {
match child.name().as_str() {
"rm" => ucum = Some(parser.text(child)?),
"name" => name = Some(parser.text(child)?),
"nlname" => dutch_name = Some(parser.text(child)?),
_ => return Err(parser.unexpected(&start, child)),
}
Ok(())
})?;
Ok(Unit {
id,
status,
ucum: ucum.ok_or_else(|| self.missing(&start, "rm"))?,
name,
dutch_name: dutch_name.ok_or_else(|| self.missing(&start, "nlname"))?,
})
}
fn ordinal(&mut self, node: &Child<'_>) -> Result<OrdinalValueSet, LabcodesetError> {
let id = self.required(&node.start, "id")?;
let display_name = self.required(&node.start, "displayName")?;
let effective_date = self.attribute(&node.start, "effectiveDate")?;
let name = self.attribute(&node.start, "name")?;
let status = self.attribute(&node.start, "statusCode")?;
let version_label = self.attribute(&node.start, "versionLabel")?;
let mut concepts = Vec::new();
let start = node.start.clone();
self.children(&start, node.empty, |parser, child| {
if child.name() != "conceptList" {
return Err(parser.unexpected(&start, child));
}
let list = child.start.clone();
parser.children(&list, child.empty, |parser, item| {
if item.name() != "concept" {
return Err(parser.unexpected(&list, item));
}
concepts.push(parser.ordinal_concept(item)?);
Ok(())
})
})?;
Ok(OrdinalValueSet {
id,
effective_date,
name,
display_name,
status,
version_label,
concepts,
})
}
fn ordinal_concept(&mut self, node: &Child<'_>) -> Result<OrdinalConcept, LabcodesetError> {
let mut concept = OrdinalConcept {
code: self.required(&node.start, "code")?,
code_system: self.required(&node.start, "codeSystem")?,
code_system_name: self.attribute(&node.start, "codeSystemName")?,
display_name: self.required(&node.start, "displayName")?,
level: self.attribute(&node.start, "level")?,
kind: self.attribute(&node.start, "type")?,
descriptions: Vec::new(),
};
let start = node.start.clone();
self.children(&start, node.empty, |parser, child| {
if child.name() != "desc" {
return Err(parser.unexpected(&start, child));
}
let language = parser.attribute(&child.start, "language")?;
let text = parser.text(child)?;
concept.descriptions.push((language, text));
Ok(())
})?;
Ok(concept)
}
}
fn axis(
axes: &mut Axes,
name: &str,
parser: &mut Parser<'_>,
child: &Child<'_>,
) -> Result<bool, LabcodesetError> {
let slot = match name {
"component" => {
axes.component = parser.text(child)?;
return Ok(true);
}
"property" => &mut axes.property,
"timing" => &mut axes.timing,
"system" => &mut axes.system,
"scale" => &mut axes.scale,
"method" => &mut axes.method,
_ => return Ok(false),
};
*slot = Some(parser.text(child)?);
Ok(true)
}