use std::io::Write;
use quick_xml::Writer;
use quick_xml::events::{BytesDecl, BytesStart, BytesText, Event};
use crate::error::{Error, Result};
use crate::model::{
Data, Dye, Experiment, Experimenter, Oligo, Partitions, Rdml, React, Run, Sample, Step,
StepKind, Target, ThermalCyclingConditions, XRef,
};
use crate::types::Reasons;
use crate::version::{RdmlVersion, WriteLoss, WriteReport};
pub(crate) fn write_xml<W: Write>(doc: &Rdml, out: W, version: RdmlVersion) -> Result<WriteReport> {
if !version.is_writable() {
return Err(Error::UnsupportedWriteVersion(version));
}
let mut w = Emitter {
x: Writer::new_with_indent(out, b' ', 2),
version,
report: WriteReport::new(version),
};
w.x.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
w.rdml(doc)?;
w.x.get_mut().write_all(b"\n").map_err(Error::Io)?;
Ok(w.report)
}
struct Emitter<W: Write> {
x: Writer<W>,
version: RdmlVersion,
report: WriteReport,
}
impl<W: Write> Emitter<W> {
fn start(&mut self, name: &str) -> Result<()> {
self.x.write_event(Event::Start(BytesStart::new(name)))?;
Ok(())
}
fn end(&mut self, name: &str) -> Result<()> {
self.x
.write_event(Event::End(BytesStart::new(name).to_end()))?;
Ok(())
}
fn empty(&mut self, name: &str) -> Result<()> {
self.x.write_event(Event::Empty(BytesStart::new(name)))?;
Ok(())
}
fn text_el(&mut self, name: &str, text: &str) -> Result<()> {
if text.is_empty() {
return self.empty(name);
}
self.start(name)?;
self.x.write_event(Event::Text(BytesText::new(text)))?;
self.end(name)
}
fn opt_text(&mut self, name: &str, value: Option<&str>) -> Result<()> {
match value {
Some(v) => self.text_el(name, v),
None => Ok(()),
}
}
fn f64_el(&mut self, name: &str, value: f64) -> Result<()> {
self.text_el(name, &format_float(value))
}
fn opt_f64(&mut self, name: &str, value: Option<f64>) -> Result<()> {
match value {
Some(v) => self.f64_el(name, v),
None => Ok(()),
}
}
fn opt_i32(&mut self, name: &str, value: Option<i32>) -> Result<()> {
match value {
Some(v) => self.text_el(name, &v.to_string()),
None => Ok(()),
}
}
fn opt_bool(&mut self, name: &str, value: Option<bool>) -> Result<()> {
match value {
Some(v) => self.text_el(name, if v { "true" } else { "false" }),
None => Ok(()),
}
}
fn ref_el(&mut self, name: &str, id: &str) -> Result<()> {
let mut el = BytesStart::new(name);
el.push_attribute(("id", id));
self.x.write_event(Event::Empty(el))?;
Ok(())
}
fn opt_reasons(&mut self, name: &str, value: Option<&Reasons>) -> Result<()> {
match value {
Some(r) => self.text_el(name, &r.to_joined()),
None => Ok(()),
}
}
fn gate(&mut self, min: RdmlVersion, path: impl FnOnce() -> String, dropped: &str) -> bool {
if self.version >= min {
true
} else {
self.report.losses.push(WriteLoss {
path: path(),
dropped: dropped.to_string(),
required_version: min,
});
false
}
}
fn rdml(&mut self, doc: &Rdml) -> Result<()> {
let mut root = BytesStart::new("rdml");
root.push_attribute(("xmlns", "http://www.rdml.org"));
root.push_attribute((
"xmlns:rdml",
"http://www.rdml.org",
));
root.push_attribute(("version", self.version.as_str()));
self.x.write_event(Event::Start(root))?;
if let Some(d) = &doc.date_made {
self.text_el("dateMade", d.as_str())?;
}
if let Some(d) = &doc.date_updated {
self.text_el("dateUpdated", d.as_str())?;
}
for id in &doc.ids {
self.start("id")?;
self.text_el("publisher", &id.publisher)?;
self.text_el("serialNumber", &id.serial_number)?;
self.opt_text("MD5Hash", id.md5_hash.as_deref())?;
self.end("id")?;
}
for e in &doc.experimenters {
self.experimenter(e)?;
}
for d in &doc.documentations {
let mut el = BytesStart::new("documentation");
el.push_attribute(("id", d.id.as_str()));
match &d.text {
Some(text) => {
self.x.write_event(Event::Start(el))?;
self.text_el("text", text)?;
self.end("documentation")?;
}
None => self.x.write_event(Event::Empty(el))?,
}
}
for d in &doc.dyes {
self.dye(d)?;
}
for s in &doc.samples {
self.sample(s)?;
}
for t in &doc.targets {
self.target(t)?;
}
for t in &doc.thermal_cycling_conditions {
self.tcc(t)?;
}
for e in &doc.experiments {
self.experiment(e)?;
}
self.end("rdml")
}
fn experimenter(&mut self, e: &Experimenter) -> Result<()> {
let mut el = BytesStart::new("experimenter");
el.push_attribute(("id", e.id.as_str()));
self.x.write_event(Event::Start(el))?;
self.text_el("firstName", &e.first_name)?;
self.text_el("lastName", &e.last_name)?;
self.opt_text("email", e.email.as_deref())?;
self.opt_text("labName", e.lab_name.as_deref())?;
self.opt_text("labAddress", e.lab_address.as_deref())?;
self.end("experimenter")
}
fn dye(&mut self, d: &Dye) -> Result<()> {
let mut el = BytesStart::new("dye");
el.push_attribute(("id", d.id.as_str()));
self.x.write_event(Event::Start(el))?;
self.opt_text("description", d.description.as_deref())?;
if let Some(c) = d.chemistry
&& self.gate(
RdmlVersion::V1_3,
|| format!("rdml/dye[{}]/dyeChemistry", d.id),
c.as_str(),
)
{
self.text_el("dyeChemistry", c.as_str())?;
}
if let Some(v) = d.n_copy_fact
&& self.gate(
RdmlVersion::V1_4,
|| format!("rdml/dye[{}]/nCopyFact", d.id),
&format_float(v),
)
{
self.f64_el("nCopyFact", v)?;
}
if let Some(v) = d.dye_conc
&& self.gate(
RdmlVersion::V1_4,
|| format!("rdml/dye[{}]/dyeConc", d.id),
&format_float(v),
)
{
self.f64_el("dyeConc", v)?;
}
self.end("dye")
}
fn sample(&mut self, s: &Sample) -> Result<()> {
let path = |suffix: &str| format!("rdml/sample[{}]/{suffix}", s.id);
let mut el = BytesStart::new("sample");
el.push_attribute(("id", s.id.as_str()));
self.x.write_event(Event::Start(el))?;
self.opt_text("description", s.description.as_deref())?;
for d in &s.documentation {
self.ref_el("documentation", d.as_str())?;
}
for x in &s.x_refs {
self.xref(x)?;
}
for (i, a) in s.annotations.iter().enumerate() {
if self.gate(
RdmlVersion::V1_2,
|| path(&format!("annotation[{}]", i + 1)),
&format!("annotation {} = {}", a.property, a.value),
) {
self.start("annotation")?;
self.text_el("property", &a.property)?;
self.text_el("value", &a.value)?;
self.end("annotation")?;
}
}
self.sample_types(s)?;
self.opt_bool("interRunCalibrator", s.inter_run_calibrator)?;
if let Some(v) = s.double_stranded
&& self.gate(RdmlVersion::V1_4, || path("doubleStranded"), &v.to_string())
{
self.opt_bool("doubleStranded", Some(v))?;
}
self.sample_quantities(s)?;
self.opt_bool("calibratorSample", s.calibrator_sample)?;
if let Some(m) = &s.cdna_synthesis_method {
self.start("cdnaSynthesisMethod")?;
self.opt_text("enzyme", m.enzyme.as_deref())?;
if let Some(p) = m.priming_method {
self.text_el("primingMethod", p.as_str())?;
}
self.opt_bool("dnaseTreatment", m.dnase_treatment)?;
if let Some(t) = &m.thermal_cycling_conditions {
self.ref_el("thermalCyclingConditions", t.as_str())?;
}
self.end("cdnaSynthesisMethod")?;
}
if let Some(tq) = &s.template_quantity {
if self.version >= RdmlVersion::V1_2 {
self.start("templateQuantity")?;
self.f64_el("conc", tq.conc)?;
self.text_el("nucleotide", tq.nucleotide.as_str())?;
self.end("templateQuantity")?;
} else {
use crate::enums::Nucleotide;
let element = match tq.nucleotide {
Nucleotide::Rna => "templateRNAQuantity",
Nucleotide::Dna | Nucleotide::GenomicDna | Nucleotide::Cdna => {
self.report.losses.push(WriteLoss {
path: path("templateQuantity/nucleotide"),
dropped: format!(
"nucleotide detail `{}` (collapsed into templateDNAQuantity)",
tq.nucleotide
),
required_version: RdmlVersion::V1_2,
});
"templateDNAQuantity"
}
};
self.start(element)?;
self.f64_el("value", tq.conc)?;
self.text_el("unit", "ng")?;
self.end(element)?;
}
}
self.end("sample")
}
fn sample_types(&mut self, s: &Sample) -> Result<()> {
if self.version >= RdmlVersion::V1_3 {
for t in &s.types {
let mut el = BytesStart::new("type");
if let Some(target) = &t.target_id {
el.push_attribute(("targetId", target.as_str()));
}
self.x.write_event(Event::Start(el))?;
self.x
.write_event(Event::Text(BytesText::new(t.value.as_str())))?;
self.end("type")?;
}
return Ok(());
}
let Some(keep) = s
.types
.iter()
.find(|t| t.target_id.is_none())
.or(s.types.first())
else {
return self.text_el("type", crate::enums::SampleType::Unknown.as_str());
};
if let Some(target) = &keep.target_id {
self.report.losses.push(WriteLoss {
path: format!("rdml/sample[{}]/type/@targetId", s.id),
dropped: format!("target scope `{target}`"),
required_version: RdmlVersion::V1_3,
});
}
for t in s.types.iter().filter(|t| !std::ptr::eq(*t, keep)) {
self.report.losses.push(WriteLoss {
path: format!("rdml/sample[{}]/type", s.id),
dropped: format!(
"additional type entry `{}`{}",
t.value,
t.target_id
.as_ref()
.map(|target| format!(" for target `{target}`"))
.unwrap_or_default()
),
required_version: RdmlVersion::V1_3,
});
}
self.text_el("type", keep.value.as_str())
}
fn sample_quantities(&mut self, s: &Sample) -> Result<()> {
if self.version >= RdmlVersion::V1_3 {
for q in &s.quantities {
let mut el = BytesStart::new("quantity");
if let Some(target) = &q.target_id {
el.push_attribute(("targetId", target.as_str()));
}
self.x.write_event(Event::Start(el))?;
self.f64_el("value", q.value)?;
self.text_el("unit", q.unit.as_str())?;
self.end("quantity")?;
}
return Ok(());
}
let Some(keep) = s
.quantities
.iter()
.find(|q| q.target_id.is_none())
.or(s.quantities.first())
else {
return Ok(());
};
if let Some(target) = &keep.target_id {
self.report.losses.push(WriteLoss {
path: format!("rdml/sample[{}]/quantity/@targetId", s.id),
dropped: format!("target scope `{target}`"),
required_version: RdmlVersion::V1_3,
});
}
for q in s.quantities.iter().filter(|q| !std::ptr::eq(*q, keep)) {
self.report.losses.push(WriteLoss {
path: format!("rdml/sample[{}]/quantity", s.id),
dropped: format!(
"additional quantity entry {} {}",
format_float(q.value),
q.unit
),
required_version: RdmlVersion::V1_3,
});
}
self.start("quantity")?;
self.f64_el("value", keep.value)?;
self.text_el("unit", keep.unit.as_str())?;
self.end("quantity")
}
fn xref(&mut self, x: &XRef) -> Result<()> {
if x.name.is_none() && x.id.is_none() {
return self.empty("xRef");
}
self.start("xRef")?;
self.opt_text("name", x.name.as_deref())?;
self.opt_text("id", x.id.as_deref())?;
self.end("xRef")
}
fn target(&mut self, t: &Target) -> Result<()> {
let path = |suffix: &str| format!("rdml/target[{}]/{suffix}", t.id);
let mut el = BytesStart::new("target");
el.push_attribute(("id", t.id.as_str()));
self.x.write_event(Event::Start(el))?;
self.opt_text("description", t.description.as_deref())?;
for d in &t.documentation {
self.ref_el("documentation", d.as_str())?;
}
for x in &t.x_refs {
self.xref(x)?;
}
self.text_el("type", t.target_type.as_str())?;
self.opt_text(
"amplificationEfficiencyMethod",
t.amplification_efficiency_method.as_deref(),
)?;
self.opt_f64("amplificationEfficiency", t.amplification_efficiency)?;
if let Some(v) = t.amplification_efficiency_se
&& self.gate(
RdmlVersion::V1_2,
|| path("amplificationEfficiencySE"),
&format_float(v),
)
{
self.f64_el("amplificationEfficiencySE", v)?;
}
if let Some(v) = t.melting_temperature
&& self.gate(
RdmlVersion::V1_3,
|| path("meltingTemperature"),
&format_float(v),
)
{
self.f64_el("meltingTemperature", v)?;
}
self.opt_f64("detectionLimit", t.detection_limit)?;
self.ref_el("dyeId", t.dye_id.as_str())?;
if let Some(seq) = &t.sequences {
self.start("sequences")?;
self.oligo("forwardPrimer", seq.forward_primer.as_ref(), &path)?;
self.oligo("reversePrimer", seq.reverse_primer.as_ref(), &path)?;
self.oligo("probe1", seq.probe1.as_ref(), &path)?;
self.oligo("probe2", seq.probe2.as_ref(), &path)?;
self.oligo("amplicon", seq.amplicon.as_ref(), &path)?;
self.end("sequences")?;
}
if let Some(c) = &t.commercial_assay {
self.start("commercialAssay")?;
self.text_el("company", &c.company)?;
self.text_el("orderNumber", &c.order_number)?;
self.end("commercialAssay")?;
}
self.end("target")
}
fn oligo(
&mut self,
name: &str,
oligo: Option<&Oligo>,
parent_path: &impl Fn(&str) -> String,
) -> Result<()> {
let Some(o) = oligo else { return Ok(()) };
self.start(name)?;
self.opt_text("threePrimeTag", o.three_prime_tag.as_deref())?;
self.opt_text("fivePrimeTag", o.five_prime_tag.as_deref())?;
self.text_el("sequence", o.sequence.as_str())?;
if let Some(v) = o.oligo_conc
&& self.gate(
RdmlVersion::V1_4,
|| parent_path(&format!("sequences/{name}/oligoConc")),
&format_float(v),
)
{
self.f64_el("oligoConc", v)?;
}
self.end(name)
}
fn tcc(&mut self, t: &ThermalCyclingConditions) -> Result<()> {
let mut el = BytesStart::new("thermalCyclingConditions");
el.push_attribute(("id", t.id.as_str()));
self.x.write_event(Event::Start(el))?;
self.opt_text("description", t.description.as_deref())?;
for d in &t.documentation {
self.ref_el("documentation", d.as_str())?;
}
self.opt_f64("lidTemperature", t.lid_temperature)?;
for e in &t.experimenters {
self.ref_el("experimenter", e.as_str())?;
}
for step in &t.steps {
self.step(step)?;
}
self.end("thermalCyclingConditions")
}
fn step(&mut self, s: &Step) -> Result<()> {
self.start("step")?;
self.text_el("nr", &s.nr.to_string())?;
self.opt_text("description", s.description.as_deref())?;
match &s.kind {
StepKind::Temperature(t) => {
self.start("temperature")?;
self.f64_el("temperature", t.temperature)?;
self.text_el("duration", &t.duration.to_string())?;
self.opt_f64("temperatureChange", t.temperature_change)?;
self.opt_i32("durationChange", t.duration_change)?;
if let Some(m) = t.measure {
self.text_el("measure", m.as_str())?;
}
self.opt_f64("ramp", t.ramp)?;
self.end("temperature")?;
}
StepKind::Gradient(g) => {
self.start("gradient")?;
self.f64_el("highTemperature", g.high_temperature)?;
self.f64_el("lowTemperature", g.low_temperature)?;
self.text_el("duration", &g.duration.to_string())?;
self.opt_f64("temperatureChange", g.temperature_change)?;
self.opt_i32("durationChange", g.duration_change)?;
if let Some(m) = g.measure {
self.text_el("measure", m.as_str())?;
}
self.opt_f64("ramp", g.ramp)?;
self.end("gradient")?;
}
StepKind::Loop(l) => {
self.start("loop")?;
self.text_el("goto", &l.goto.to_string())?;
self.text_el("repeat", &l.repeat.to_string())?;
self.end("loop")?;
}
StepKind::Pause(p) => {
self.start("pause")?;
self.f64_el("temperature", p.temperature)?;
self.end("pause")?;
}
StepKind::LidOpen => self.empty("lidOpen")?,
}
self.end("step")
}
fn experiment(&mut self, e: &Experiment) -> Result<()> {
let mut el = BytesStart::new("experiment");
el.push_attribute(("id", e.id.as_str()));
self.x.write_event(Event::Start(el))?;
self.opt_text("description", e.description.as_deref())?;
for d in &e.documentation {
self.ref_el("documentation", d.as_str())?;
}
for run in &e.runs {
self.run(e, run)?;
}
self.end("experiment")
}
fn run(&mut self, e: &Experiment, r: &Run) -> Result<()> {
let mut el = BytesStart::new("run");
el.push_attribute(("id", r.id.as_str()));
self.x.write_event(Event::Start(el))?;
self.opt_text("description", r.description.as_deref())?;
for d in &r.documentation {
self.ref_el("documentation", d.as_str())?;
}
for ex in &r.experimenters {
self.ref_el("experimenter", ex.as_str())?;
}
self.opt_text("instrument", r.instrument.as_deref())?;
if let Some(s) = &r.data_collection_software {
self.start("dataCollectionSoftware")?;
self.text_el("name", &s.name)?;
self.text_el("version", &s.version)?;
self.end("dataCollectionSoftware")?;
}
self.opt_text(
"backgroundDeterminationMethod",
r.background_determination_method.as_deref(),
)?;
if let Some(m) = r.cq_detection_method {
self.text_el("cqDetectionMethod", m.as_str())?;
}
if let Some(t) = &r.thermal_cycling_conditions {
self.ref_el("thermalCyclingConditions", t.as_str())?;
}
self.start("pcrFormat")?;
self.text_el("rows", &r.pcr_format.rows.to_string())?;
self.text_el("columns", &r.pcr_format.columns.to_string())?;
self.text_el("rowLabel", r.pcr_format.row_label.as_str())?;
self.text_el("columnLabel", r.pcr_format.column_label.as_str())?;
self.end("pcrFormat")?;
if let Some(d) = &r.run_date {
self.text_el("runDate", d.as_str())?;
}
for react in &r.reacts {
self.react(e, r, react)?;
}
self.end("run")
}
fn react(&mut self, e: &Experiment, r: &Run, react: &React) -> Result<()> {
let path = |suffix: &str| {
format!(
"rdml/experiment[{}]/run[{}]/react[{}]/{suffix}",
e.id, r.id, react.id
)
};
if self.version < RdmlVersion::V1_3 && react.data.is_empty() {
self.report.losses.push(WriteLoss {
path: path("").trim_end_matches('/').to_string(),
dropped: format!(
"entire react (sample `{}`{}) — RDML {} requires at least one data \
element per react and this react has none",
react.sample,
if react.partitions.is_some() {
", including its digital-PCR partition data"
} else {
""
},
self.version,
),
required_version: RdmlVersion::V1_3,
});
return Ok(());
}
let mut el = BytesStart::new("react");
el.push_attribute(("id", react.id.to_string().as_str()));
self.x.write_event(Event::Start(el))?;
self.ref_el("sample", react.sample.as_str())?;
if let Some(v) = react.vol
&& self.gate(RdmlVersion::V1_4, || path("vol"), &format_float(v))
{
self.f64_el("vol", v)?;
}
for data in &react.data {
self.data(&path, data)?;
}
if let Some(p) = &react.partitions
&& self.gate(
RdmlVersion::V1_3,
|| path("partitions"),
&format!("digital-PCR partition data ({} targets)", p.data.len()),
)
{
self.partitions(p)?;
}
self.end("react")
}
fn data(&mut self, react_path: &impl Fn(&str) -> String, d: &Data) -> Result<()> {
let path = |suffix: &str| react_path(&format!("data[{}]/{suffix}", d.tar));
self.start("data")?;
self.ref_el("tar", d.tar.as_str())?;
self.opt_f64("cq", d.cq)?;
macro_rules! gated {
($field:expr, $name:literal, $min:expr, $emit:expr) => {
if let Some(v) = $field
&& self.gate($min, || path($name), &v.to_string())
{
#[allow(clippy::redundant_closure_call)]
$emit(self, v)?;
}
};
}
gated!(d.n0, "N0", RdmlVersion::V1_3, |s: &mut Self, v| s
.f64_el("N0", v));
gated!(d.n_copy, "Ncopy", RdmlVersion::V1_4, |s: &mut Self, v| s
.f64_el("Ncopy", v));
if let Some(v) = &d.amp_eff_met
&& self.gate(RdmlVersion::V1_3, || path("ampEffMet"), v)
{
self.text_el("ampEffMet", v)?;
}
gated!(d.amp_eff, "ampEff", RdmlVersion::V1_3, |s: &mut Self, v| s
.f64_el("ampEff", v));
gated!(
d.amp_eff_se,
"ampEffSE",
RdmlVersion::V1_3,
|s: &mut Self, v| s.f64_el("ampEffSE", v)
);
gated!(d.corr_f, "corrF", RdmlVersion::V1_3, |s: &mut Self, v| s
.f64_el("corrF", v));
gated!(d.corr_p, "corrP", RdmlVersion::V1_3, |s: &mut Self, v| s
.f64_el("corrP", v));
gated!(d.corr_cq, "corrCq", RdmlVersion::V1_3, |s: &mut Self, v| s
.f64_el("corrCq", v));
gated!(
d.melt_temp,
"meltTemp",
RdmlVersion::V1_3,
|s: &mut Self, v| s.f64_el("meltTemp", v)
);
self.opt_reasons("excl", d.excl.as_ref())?;
if let Some(n) = &d.note
&& self.gate(RdmlVersion::V1_3, || path("note"), &n.to_joined())
{
self.opt_reasons("note", Some(n))?;
}
for adp in &d.adps {
self.start("adp")?;
self.f64_el("cyc", adp.cyc)?;
self.opt_f64("tmp", adp.tmp)?;
self.f64_el("fluor", adp.fluor)?;
self.end("adp")?;
}
for mdp in &d.mdps {
self.start("mdp")?;
self.f64_el("tmp", mdp.tmp)?;
self.f64_el("fluor", mdp.fluor)?;
self.end("mdp")?;
}
self.opt_f64("endPt", d.end_pt)?;
self.opt_f64("bgFluor", d.bg_fluor)?;
if let Some(v) = d.bg_fluor_slp
&& self.gate(RdmlVersion::V1_2, || path("bgFluorSlp"), &format_float(v))
{
self.f64_el("bgFluorSlp", v)?;
}
self.opt_f64("quantFluor", d.quant_fluor)?;
self.end("data")
}
fn partitions(&mut self, p: &Partitions) -> Result<()> {
self.start("partitions")?;
self.f64_el("volume", p.volume)?;
self.opt_text("endPtTable", p.end_pt_table.as_deref())?;
for d in &p.data {
self.start("data")?;
self.ref_el("tar", d.tar.as_str())?;
self.opt_reasons("excluded", d.excluded.as_ref())?;
self.opt_reasons("note", d.note.as_ref())?;
self.text_el("pos", &d.pos.to_string())?;
self.text_el("neg", &d.neg.to_string())?;
self.opt_i32("undef", d.undef)?;
self.opt_i32("excl", d.excl)?;
self.opt_f64("conc", d.conc)?;
self.end("data")?;
}
self.end("partitions")
}
}
pub(crate) fn format_float(v: f64) -> String {
if v.is_nan() {
"NaN".to_string()
} else if v == f64::INFINITY {
"INF".to_string()
} else if v == f64::NEG_INFINITY {
"-INF".to_string()
} else {
format!("{v}")
}
}