use std::collections::BTreeSet;
use std::fmt::Write as _;
use fhir_types::codec::{Json, Object, Path, Value};
use fhir_types::r4::bundle::{Bundle, BundleEntry};
use fhir_types::r4::codeable_concept::CodeableConcept;
use fhir_types::r4::coding::Coding;
use fhir_types::r4::composition::{Composition, CompositionSection};
use fhir_types::r4::diagnostic_report::DiagnosticReport;
use fhir_types::r4::encounter::Encounter;
use fhir_types::r4::extension::{Extension, ExtensionValue};
use fhir_types::r4::identifier::Identifier;
use fhir_types::r4::meta::Meta;
use fhir_types::r4::narrative::Narrative;
use fhir_types::r4::patient::Patient;
use fhir_types::r4::reference::Reference;
use fhir_types::r4::resource::Resource;
mod assembly;
#[cfg(feature = "discharge")]
pub mod discharge;
#[cfg(feature = "imaging")]
pub mod imaging;
#[cfg(feature = "laboratory")]
pub mod laboratory;
#[cfg(feature = "patient-summary")]
pub const EPS_BUNDLE: &str = "http://hl7.eu/fhir/eps/StructureDefinition/bundle-eu-eps";
#[cfg(feature = "patient-summary")]
pub const EPS_PATIENT: &str = "http://hl7.eu/fhir/eps/StructureDefinition/patient-eu-eps";
pub const LOINC: &str = "http://loinc.org";
#[cfg(feature = "patient-summary")]
pub const PATIENT_SUMMARY: &str = "60591-5";
pub const EMPTY_REASON: &str = "http://terminology.hl7.org/CodeSystem/list-empty-reason";
pub const URN_SYSTEM: &str = "urn:ietf:rfc:3986";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Slot {
pub slice: &'static str,
pub code: &'static str,
pub title: &'static str,
}
impl Slot {
#[must_use]
pub const fn new(slice: &'static str, code: &'static str, title: &'static str) -> Self {
Self { slice, code, title }
}
}
#[cfg(feature = "patient-summary")]
pub const SLOTS: [Slot; 17] = [
Slot::new("sectionProblems", "11450-4", "Problems"),
Slot::new("sectionAllergies", "48765-2", "Allergies and Intolerances"),
Slot::new("sectionMedications", "10160-0", "Medication Summary"),
Slot::new("sectionImmunizations", "11369-6", "Immunizations"),
Slot::new("sectionResults", "30954-2", "Results"),
Slot::new("sectionProceduresHx", "47519-4", "History of Procedures"),
Slot::new("sectionMedicalDevices", "46264-8", "Medical Devices"),
Slot::new("sectionAdvanceDirectives", "42348-3", "Advance Directives"),
Slot::new("sectionAlert", "104605-1", "Alerts"),
Slot::new("sectionFunctionalStatus", "47420-5", "Functional Status"),
Slot::new("sectionPregnancyHx", "10162-6", "History of Pregnancy"),
Slot::new("sectionPatientStory", "81338-6", "Patient Story"),
Slot::new("sectionPlanOfCare", "18776-5", "Plan of Care"),
Slot::new("sectionSocialHistory", "29762-2", "Social History"),
Slot::new("sectionVitalSigns", "8716-3", "Vital Signs"),
Slot::new("sectionTravelHx", "10182-4", "Travel History"),
Slot::new("sectionPatientHx", "11329-0", "Patient History"),
];
#[cfg(feature = "patient-summary")]
#[must_use]
pub fn slot(slice: &str) -> Option<&'static Slot> {
SLOTS.iter().find(|slot| slot.slice == slice)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Code {
pub system: &'static str,
pub code: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Companion {
pub profile: &'static str,
pub link: &'static str,
pub back: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Fixed(Code),
Report {
fallback: Code,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Authorship {
Gateway,
ReportPerformers,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Form {
pub bundle: &'static str,
pub composition: &'static str,
pub patient: &'static str,
pub kind: Kind,
pub categories: &'static [Code],
pub authorship: Authorship,
pub slots: &'static [Slot],
pub report: Option<Companion>,
pub encounter: Option<&'static str>,
pub language: bool,
}
#[cfg(feature = "patient-summary")]
pub const PATIENT_SUMMARY_FORM: Form = Form {
bundle: EPS_BUNDLE,
composition: "http://hl7.eu/fhir/eps/StructureDefinition/composition-eu-eps",
patient: EPS_PATIENT,
kind: Kind::Fixed(Code {
system: LOINC,
code: PATIENT_SUMMARY,
}),
categories: &[],
authorship: Authorship::Gateway,
slots: &SLOTS,
report: None,
encounter: None,
language: false,
};
#[cfg(any(feature = "laboratory", feature = "discharge", feature = "imaging"))]
pub const EU_CORE_PATIENT: &str = "http://hl7.eu/fhir/base/StructureDefinition/patient-eu-core";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum EmptyReason {
NilKnown,
Unavailable,
}
impl EmptyReason {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::NilKnown => "nilknown",
Self::Unavailable => "unavailable",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Section {
slot: &'static Slot,
paragraphs: Vec<String>,
entries: Vec<String>,
authors: Vec<String>,
empty: Option<EmptyReason>,
}
impl Section {
#[must_use]
pub fn new(slot: &'static Slot, paragraphs: Vec<String>) -> Self {
Self {
slot,
paragraphs,
entries: Vec::new(),
authors: Vec::new(),
empty: None,
}
}
#[must_use]
pub const fn slot(&self) -> &'static Slot {
self.slot
}
#[must_use]
pub fn with_entry(mut self, full_url: String) -> Self {
self.entries.push(full_url);
self
}
#[must_use]
pub fn with_author(mut self, full_url: String) -> Self {
self.authors.push(full_url);
self
}
#[must_use]
pub const fn empty_because(mut self, reason: EmptyReason) -> Self {
self.empty = Some(reason);
self
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DocumentError {
#[error("a {resource} resource carries no id")]
NoId {
resource: String,
},
#[error("a resource could not be encoded as FHIR JSON")]
Codec(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("the section {slice} references a resource the document does not hold")]
Dangling {
slice: &'static str,
},
#[error("the section {slice} is given twice")]
Repeated {
slice: &'static str,
},
#[error("the section {slice} is not a section of the document's form")]
Foreign {
slice: &'static str,
},
#[error("the document's form requires its {part}, which was not given")]
Missing {
part: &'static str,
},
#[error("the document's form and its report disagree: the form {expected} one")]
Report {
expected: &'static str,
},
}
#[derive(Debug, Clone)]
pub struct Document {
form: &'static Form,
base: String,
identifier: String,
timestamp: String,
title: String,
patient: Option<String>,
authors: Vec<String>,
entries: Vec<(String, Resource)>,
sections: Vec<Section>,
report: Option<DiagnosticReport>,
encounter: Option<String>,
language: Option<String>,
}
impl Document {
#[cfg(feature = "patient-summary")]
#[must_use]
pub fn new(base: &str, identifier: String, timestamp: String, title: String) -> Self {
Self::of(&PATIENT_SUMMARY_FORM, base, identifier, timestamp, title)
}
#[must_use]
pub fn of(
form: &'static Form,
base: &str,
identifier: String,
timestamp: String,
title: String,
) -> Self {
Self {
form,
base: base.trim_end_matches('/').to_owned(),
identifier,
timestamp,
title,
patient: None,
authors: Vec::new(),
entries: Vec::new(),
sections: Vec::new(),
report: None,
encounter: None,
language: None,
}
}
#[must_use]
pub const fn form(&self) -> &'static Form {
self.form
}
#[must_use]
pub fn full_url(&self, resource_type: &str, id: &str) -> String {
format!("{}/{resource_type}/{id}", self.base)
}
pub fn with_patient(&mut self, mut patient: Patient) -> Result<String, DocumentError> {
let meta = patient.meta.get_or_insert_with(Meta::default);
meta.profile.push(self.form.patient.into());
self.add(&[Resource::Patient(Box::new(patient))])?;
let id = match self.entries.last() {
Some((_, Resource::Patient(added))) => added.id.clone(),
_ => None,
}
.ok_or_else(|| DocumentError::NoId {
resource: String::from("Patient"),
})?;
let reference = format!("Patient/{id}");
self.patient = Some(reference.clone());
Ok(reference)
}
pub fn with_author(&mut self, authored: &Resource) -> Result<String, DocumentError> {
let full_url = self
.add(std::slice::from_ref(authored))?
.into_iter()
.next()
.ok_or_else(|| DocumentError::NoId {
resource: String::from("author"),
})?;
self.authors.push(full_url.clone());
Ok(full_url)
}
pub fn with_section(&mut self, section: Section) -> Result<(), DocumentError> {
if !self.form.slots.contains(section.slot) {
return Err(DocumentError::Foreign {
slice: section.slot.slice,
});
}
if self
.sections
.iter()
.any(|held| held.slot.slice == section.slot.slice)
{
return Err(DocumentError::Repeated {
slice: section.slot.slice,
});
}
self.sections.push(section);
Ok(())
}
pub fn with_encounter(&mut self, mut encounter: Encounter) -> Result<String, DocumentError> {
if let Some(profile) = self.form.encounter {
let meta = encounter.meta.get_or_insert_with(Meta::default);
meta.profile.push(profile.into());
}
self.add(&[Resource::Encounter(Box::new(encounter))])?;
let id = match self.entries.last() {
Some((_, Resource::Encounter(added))) => added.id.clone(),
_ => None,
}
.ok_or_else(|| DocumentError::NoId {
resource: String::from("Encounter"),
})?;
let reference = format!("Encounter/{id}");
self.encounter = Some(reference.clone());
Ok(reference)
}
pub fn with_language(&mut self, language: String) {
self.language = Some(language);
}
pub fn with_report(&mut self, report: DiagnosticReport) -> Result<(), DocumentError> {
if self.form.report.is_none() {
return Err(DocumentError::Report {
expected: "holds no",
});
}
self.report = Some(report);
Ok(())
}
pub fn add(&mut self, resources: &[Resource]) -> Result<Vec<String>, DocumentError> {
let mut objects = Vec::with_capacity(resources.len());
for resource in resources {
objects.push(Json::to_json(resource).map_err(codec)?);
}
let mut taken: BTreeSet<String> = self.entries.iter().map(|(url, _)| url.clone()).collect();
let mut renamed: Vec<(String, String)> = Vec::new();
let mut keys = Vec::with_capacity(objects.len());
for object in &mut objects {
let kind = text(object, "resourceType")
.unwrap_or("Resource")
.to_owned();
let held = text(object, "id").map(str::to_owned);
let stem = held.clone().unwrap_or_else(|| kind.to_ascii_lowercase());
let id = free(
&mut taken,
|id| self.full_url(&kind, id),
&stem,
held.is_some(),
);
if let Some(held) = held.filter(|held| *held != id) {
renamed.push((format!("{kind}/{held}"), format!("{kind}/{id}")));
}
object.insert(String::from("id"), Value::String(id.clone()));
keys.push(self.full_url(&kind, &id));
}
let mut added = Vec::with_capacity(objects.len());
for (mut object, full_url) in objects.into_iter().zip(keys) {
if !renamed.is_empty() {
for value in object.values_mut() {
rename(value, &renamed);
}
}
let resource =
Resource::from_json(&object, &mut Path::root("Resource")).map_err(codec)?;
self.entries.push((full_url.clone(), resource));
added.push(full_url);
}
Ok(added)
}
pub fn into_bundle(mut self) -> Result<Bundle, DocumentError> {
let held: BTreeSet<&str> = self.entries.iter().map(|(url, _)| url.as_str()).collect();
let mut sections = Vec::with_capacity(self.sections.len());
for section in &self.sections {
if section
.entries
.iter()
.chain(§ion.authors)
.any(|url| !held.contains(url.as_str()))
{
return Err(DocumentError::Dangling {
slice: section.slot.slice,
});
}
sections.push(composition_section(section));
}
let subject = self
.patient
.clone()
.ok_or(DocumentError::Dangling { slice: "subject" })?;
let id = self
.identifier
.rsplit(':')
.next()
.unwrap_or("composition")
.to_owned();
let composition_url = self.full_url("Composition", &id);
if self.form.encounter.is_some() && self.encounter.is_none() {
return Err(DocumentError::Missing { part: "encounter" });
}
if self.form.language && self.language.is_none() {
return Err(DocumentError::Missing { part: "language" });
}
let report = self.report_written(&subject, &composition_url)?;
let mut composition = Composition {
id: Some(id),
identifier: Some(Identifier {
system: Some(URN_SYSTEM.into()),
value: Some(self.identifier.as_str().into()),
..Identifier::default()
}),
status: "final".into(),
r#type: CodeableConcept::default(),
subject: Some(reference(&subject)),
encounter: self.encounter.as_deref().map(reference),
date: self.timestamp.as_str().into(),
author: self
.authors
.iter()
.map(|author| reference(author))
.collect(),
title: self.title.as_str().into(),
section: sections,
..Composition::default()
};
if let (Some(companion), Some((report_url, _))) = (self.form.report, &report) {
composition.extension.push(Extension {
url: companion.link.into(),
value: Some(ExtensionValue::Reference(Box::new(reference(report_url)))),
..Extension::default()
});
}
self.arrange(&mut composition, report.as_ref().map(|(_, report)| report));
let mut entry = vec![BundleEntry {
full_url: Some(composition_url.into()),
resource: Some(Resource::Composition(Box::new(composition))),
..BundleEntry::default()
}];
entry.extend(report.map(|(full_url, report)| BundleEntry {
full_url: Some(full_url.into()),
resource: Some(Resource::DiagnosticReport(Box::new(report))),
..BundleEntry::default()
}));
entry.extend(
self.entries
.into_iter()
.map(|(full_url, resource)| BundleEntry {
full_url: Some(full_url.into()),
resource: Some(resource),
..BundleEntry::default()
}),
);
Ok(Bundle {
meta: Some(Meta {
profile: vec![self.form.bundle.into()],
..Meta::default()
}),
identifier: Some(Identifier {
system: Some(URN_SYSTEM.into()),
value: Some(self.identifier.into()),
..Identifier::default()
}),
language: self.language.map(Into::into),
r#type: "document".into(),
timestamp: Some(self.timestamp.into()),
entry,
..Bundle::default()
})
}
}
fn composition_section(section: &Section) -> CompositionSection {
CompositionSection {
title: Some(section.slot.title.into()),
code: Some(CodeableConcept {
coding: vec![loinc(section.slot.code)],
..CodeableConcept::default()
}),
author: section.authors.iter().map(|url| reference(url)).collect(),
text: Some(narrative(section.slot.title, §ion.paragraphs)),
entry: section.entries.iter().map(|url| reference(url)).collect(),
empty_reason: section
.empty
.filter(|_| section.entries.is_empty())
.map(|reason| CodeableConcept {
coding: vec![Coding {
system: Some(EMPTY_REASON.into()),
code: Some(reason.code().into()),
..Coding::default()
}],
..CodeableConcept::default()
}),
..CompositionSection::default()
}
}
#[must_use]
pub fn narrative(title: &str, paragraphs: &[String]) -> Narrative {
let mut div = String::from(r#"<div xmlns="http://www.w3.org/1999/xhtml">"#);
let _written: std::fmt::Result = write!(div, "<h3>{}</h3>", escaped(title));
for paragraph in paragraphs {
let _written: std::fmt::Result = write!(div, "<p>{}</p>", escaped(paragraph));
}
div.push_str("</div>");
Narrative {
status: "generated".into(),
div: div.into(),
..Narrative::default()
}
}
fn escaped(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for character in text.chars() {
match character {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
fn loinc(code: &'static str) -> Coding {
coding(Code {
system: LOINC,
code,
})
}
fn coding(code: Code) -> Coding {
Coding {
system: Some(code.system.into()),
code: Some(code.code.into()),
..Coding::default()
}
}
fn reference(url: &str) -> Reference {
Reference {
reference: Some(url.into()),
..Reference::default()
}
}
fn text<'a>(object: &'a Object, key: &str) -> Option<&'a str> {
match object.get(key) {
Some(Value::String(text)) => Some(text),
_ => None,
}
}
fn free(
taken: &mut BTreeSet<String>,
url: impl Fn(&str) -> String,
stem: &str,
held: bool,
) -> String {
let mut candidate = if held {
stem.to_owned()
} else {
format!("{stem}-1")
};
let mut next = 1_u32;
while taken.contains(&url(&candidate)) {
next = next.saturating_add(1);
candidate = format!("{stem}-{next}");
}
taken.insert(url(&candidate));
candidate
}
fn rename(value: &mut Value, renamed: &[(String, String)]) {
match value {
Value::Object(object) => {
for (key, member) in object.iter_mut() {
if let (true, Value::String(text)) = (key == "reference", &mut *member)
&& let Some((_, to)) = renamed.iter().find(|(from, _)| from == text)
{
to.clone_into(text);
} else {
rename(member, renamed);
}
}
}
Value::Array(items) => {
for item in items {
rename(item, renamed);
}
}
_ => {}
}
}
fn codec(source: impl std::error::Error + Send + Sync + 'static) -> DocumentError {
DocumentError::Codec(Box::new(source))
}