use std::collections::{HashMap, HashSet};
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::model::{Data, Rdml};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Severity {
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Finding {
pub severity: Severity,
pub path: String,
pub message: String,
}
impl fmt::Display for Finding {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let sev = match self.severity {
Severity::Warning => "warning",
Severity::Error => "error",
};
write!(f, "{sev}: {}: {}", self.path, self.message)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ValidationReport {
pub(crate) findings: Vec<Finding>,
}
impl ValidationReport {
#[must_use]
pub fn findings(&self) -> &[Finding] {
&self.findings
}
#[must_use]
pub fn is_ok(&self) -> bool {
self.errors().next().is_none()
}
pub fn errors(&self) -> impl Iterator<Item = &Finding> {
self.findings
.iter()
.filter(|f| f.severity == Severity::Error)
}
pub fn warnings(&self) -> impl Iterator<Item = &Finding> {
self.findings
.iter()
.filter(|f| f.severity == Severity::Warning)
}
fn error(&mut self, path: impl Into<String>, message: impl Into<String>) {
self.findings.push(Finding {
severity: Severity::Error,
path: path.into(),
message: message.into(),
});
}
fn warning(&mut self, path: impl Into<String>, message: impl Into<String>) {
self.findings.push(Finding {
severity: Severity::Warning,
path: path.into(),
message: message.into(),
});
}
}
impl fmt::Display for ValidationReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.findings.is_empty() {
return f.write_str("valid: no findings");
}
for (i, finding) in self.findings.iter().enumerate() {
if i > 0 {
writeln!(f)?;
}
write!(f, "{finding}")?;
}
Ok(())
}
}
impl Rdml {
#[allow(clippy::too_many_lines)]
pub fn validate(&self) -> Result<ValidationReport, ValidationReport> {
let mut r = ValidationReport::default();
let experimenters = unique_ids(
&mut r,
"rdml/experimenter",
self.experimenters.iter().map(|e| e.id.as_str()),
);
let documentations = unique_ids(
&mut r,
"rdml/documentation",
self.documentations.iter().map(|d| d.id.as_str()),
);
let dyes = unique_ids(&mut r, "rdml/dye", self.dyes.iter().map(|d| d.id.as_str()));
let samples = unique_ids(
&mut r,
"rdml/sample",
self.samples.iter().map(|s| s.id.as_str()),
);
let targets = unique_ids(
&mut r,
"rdml/target",
self.targets.iter().map(|t| t.id.as_str()),
);
let tccs = unique_ids(
&mut r,
"rdml/thermalCyclingConditions",
self.thermal_cycling_conditions
.iter()
.map(|t| t.id.as_str()),
);
unique_ids(
&mut r,
"rdml/experiment",
self.experiments.iter().map(|e| e.id.as_str()),
);
for sample in &self.samples {
let path = format!("rdml/sample[{}]", sample.id);
check_doc_refs(&mut r, &path, &sample.documentation, &documentations);
check_xrefs(&mut r, &path, &sample.x_refs);
let mut untargeted_types = 0usize;
for (i, entry) in sample.types.iter().enumerate() {
match &entry.target_id {
Some(t) if !targets.contains(t.as_str()) => r.error(
format!("{path}/type[{}]", i + 1),
format!("targetId `{t}` does not reference any target"),
),
Some(_) => {}
None => untargeted_types += 1,
}
}
if untargeted_types > 1 {
r.warning(
format!("{path}/type"),
format!(
"{untargeted_types} type entries without a targetId; \
only one all-target entry should be present"
),
);
}
for (i, q) in sample.quantities.iter().enumerate() {
if let Some(t) = &q.target_id
&& !targets.contains(t.as_str())
{
r.error(
format!("{path}/quantity[{}]", i + 1),
format!("targetId `{t}` does not reference any target"),
);
}
}
if let Some(m) = &sample.cdna_synthesis_method
&& let Some(t) = &m.thermal_cycling_conditions
&& !tccs.contains(t.as_str())
{
r.error(
format!("{path}/cdnaSynthesisMethod/thermalCyclingConditions"),
format!("`{t}` does not reference any thermalCyclingConditions"),
);
}
}
for target in &self.targets {
let path = format!("rdml/target[{}]", target.id);
check_doc_refs(&mut r, &path, &target.documentation, &documentations);
check_xrefs(&mut r, &path, &target.x_refs);
if !dyes.contains(target.dye_id.as_str()) {
r.error(
format!("{path}/dyeId"),
format!("`{}` does not reference any dye", target.dye_id),
);
}
}
for tcc in &self.thermal_cycling_conditions {
let path = format!("rdml/thermalCyclingConditions[{}]", tcc.id);
check_doc_refs(&mut r, &path, &tcc.documentation, &documentations);
check_experimenter_refs(&mut r, &path, &tcc.experimenters, &experimenters);
if tcc.steps.is_empty() {
r.error(&path, "a protocol must contain at least one step");
}
let mut nrs = HashSet::new();
let step_numbers: HashSet<u32> = tcc.steps.iter().map(|s| s.nr.get()).collect();
for step in &tcc.steps {
let spath = format!("{path}/step[{}]", step.nr);
if !nrs.insert(step.nr) {
r.error(&spath, format!("duplicate step number {}", step.nr));
}
if let crate::model::StepKind::Loop(l) = &step.kind {
if !step_numbers.contains(&l.goto.get()) {
r.warning(
format!("{spath}/loop/goto"),
format!("goto step {} does not exist in this protocol", l.goto),
);
}
if l.goto > step.nr && l.repeat != 0 {
r.warning(
format!("{spath}/loop"),
format!(
"forward goto (step {} from step {}) requires repeat = 0, \
found {}",
l.goto, step.nr, l.repeat
),
);
}
}
}
}
for experiment in &self.experiments {
let epath = format!("rdml/experiment[{}]", experiment.id);
check_doc_refs(&mut r, &epath, &experiment.documentation, &documentations);
let mut run_ids = HashSet::new();
for run in &experiment.runs {
let rpath = format!("{epath}/run[{}]", run.id);
if !run_ids.insert(run.id.as_str()) {
r.error(&rpath, format!("duplicate run id `{}`", run.id));
}
check_doc_refs(&mut r, &rpath, &run.documentation, &documentations);
check_experimenter_refs(&mut r, &rpath, &run.experimenters, &experimenters);
if let Some(t) = &run.thermal_cycling_conditions
&& !tccs.contains(t.as_str())
{
r.error(
format!("{rpath}/thermalCyclingConditions"),
format!("`{t}` does not reference any thermalCyclingConditions"),
);
}
let mut react_ids = HashSet::new();
for react in &run.reacts {
let wpath = format!("{rpath}/react[{}]", react.id);
if !react_ids.insert(react.id) {
r.error(&wpath, format!("duplicate react id {}", react.id));
}
if !samples.contains(react.sample.as_str()) {
r.error(
format!("{wpath}/sample"),
format!("`{}` does not reference any sample", react.sample),
);
}
let mut data_targets = HashSet::new();
for data in &react.data {
let dpath = format!("{wpath}/data[{}]", data.tar);
if !data_targets.insert(data.tar.as_str()) {
r.error(
&dpath,
format!("duplicate data entry for target `{}`", data.tar),
);
}
if !targets.contains(data.tar.as_str()) {
r.error(
format!("{dpath}/tar"),
format!("`{}` does not reference any target", data.tar),
);
}
check_data(&mut r, &dpath, data);
}
if let Some(partitions) = &react.partitions {
let ppath = format!("{wpath}/partitions");
if partitions.data.is_empty() {
r.error(&ppath, "partitions must contain at least one data entry");
}
let mut ptargets = HashSet::new();
for pdata in &partitions.data {
let pdpath = format!("{ppath}/data[{}]", pdata.tar);
if !ptargets.insert(pdata.tar.as_str()) {
r.error(
&pdpath,
format!(
"duplicate partition data entry for target `{}`",
pdata.tar
),
);
}
if !targets.contains(pdata.tar.as_str()) {
r.error(
format!("{pdpath}/tar"),
format!("`{}` does not reference any target", pdata.tar),
);
}
check_suspicious_reasons(
&mut r,
&pdpath,
"excluded",
pdata.excluded.as_ref(),
);
check_suspicious_reasons(&mut r, &pdpath, "note", pdata.note.as_ref());
}
}
}
}
}
if r.is_ok() { Ok(r) } else { Err(r) }
}
}
fn unique_ids<'a>(
r: &mut ValidationReport,
path: &str,
ids: impl Iterator<Item = &'a str>,
) -> HashSet<&'a str> {
let mut seen: HashMap<&str, u32> = HashMap::new();
for id in ids {
*seen.entry(id).or_default() += 1;
}
for (id, count) in seen.iter().filter(|&(_, &c)| c > 1) {
r.error(
format!("{path}[{id}]"),
format!("id `{id}` is defined {count} times; master-element ids must be unique"),
);
}
seen.into_keys().collect()
}
fn check_doc_refs(
r: &mut ValidationReport,
path: &str,
refs: &[crate::types::DocumentationRef],
known: &HashSet<&str>,
) {
let mut seen = HashSet::new();
for dref in refs {
if !known.contains(dref.as_str()) {
r.error(
format!("{path}/documentation"),
format!("`{dref}` does not reference any documentation"),
);
}
if !seen.insert(dref.as_str()) {
r.error(
format!("{path}/documentation"),
format!("duplicate documentation reference `{dref}`"),
);
}
}
}
fn check_experimenter_refs(
r: &mut ValidationReport,
path: &str,
refs: &[crate::types::ExperimenterRef],
known: &HashSet<&str>,
) {
let mut seen = HashSet::new();
for eref in refs {
if !known.contains(eref.as_str()) {
r.error(
format!("{path}/experimenter"),
format!("`{eref}` does not reference any experimenter"),
);
}
if !seen.insert(eref.as_str()) {
r.error(
format!("{path}/experimenter"),
format!("duplicate experimenter reference `{eref}`"),
);
}
}
}
fn check_xrefs(r: &mut ValidationReport, path: &str, xrefs: &[crate::model::XRef]) {
let mut seen = HashSet::new();
for x in xrefs {
if !seen.insert((x.name.as_deref(), x.id.as_deref())) {
r.error(
format!("{path}/xRef"),
format!(
"duplicate xRef (name `{}`, id `{}`)",
x.name.as_deref().unwrap_or(""),
x.id.as_deref().unwrap_or("")
),
);
}
}
}
fn check_data(r: &mut ValidationReport, path: &str, data: &Data) {
let mut cycles = HashSet::new();
for adp in &data.adps {
if !cycles.insert(adp.cyc.to_bits()) {
r.error(
format!("{path}/adp"),
format!("duplicate amplification data point for cycle {}", adp.cyc),
);
}
}
let mut temps = HashSet::new();
for mdp in &data.mdps {
if !temps.insert(mdp.tmp.to_bits()) {
r.error(
format!("{path}/mdp"),
format!("duplicate melting data point for temperature {}", mdp.tmp),
);
}
}
check_suspicious_reasons(r, path, "excl", data.excl.as_ref());
check_suspicious_reasons(r, path, "note", data.note.as_ref());
}
fn check_suspicious_reasons(
r: &mut ValidationReport,
path: &str,
element: &str,
reasons: Option<&crate::types::Reasons>,
) {
if let Some(reasons) = reasons {
for reason in reasons.iter() {
if reason.eq_ignore_ascii_case("false") || reason.eq_ignore_ascii_case("true") {
r.warning(
format!("{path}/{element}"),
format!(
"literal `{reason}` as text; `{element}` semantics are carried by \
presence — this element should be absent when it does not apply"
),
);
}
}
}
}
#[cfg(test)]
mod tests {
use crate::enums::{SampleType, TargetType};
use crate::model::*;
use crate::types::*;
use std::num::NonZeroU32;
fn id(s: &str) -> Id {
Id::new(s).unwrap()
}
fn nz(n: u32) -> NonZeroU32 {
NonZeroU32::new(n).unwrap()
}
fn valid_doc() -> Rdml {
let mut doc = Rdml::default();
doc.dyes.push(Dye::new(id("FAM")));
doc.experimenters
.push(Experimenter::new(id("AB"), "Ada", "Byron"));
doc.documentations.push(Documentation::new(id("proto-doc")));
doc.samples.push(Sample::new(id("s1")));
doc.targets.push(Target::new(
id("GAPDH"),
TargetType::Reference,
DyeRef::new("FAM").unwrap(),
));
let mut tcc = ThermalCyclingConditions::new(id("std-3step"));
tcc.push_step(StepKind::Temperature(TemperatureStep::new(95.0, nz(30))));
tcc.push_step(StepKind::Temperature(TemperatureStep::new(60.0, nz(60))));
tcc.push_step(StepKind::Loop(LoopStep::new(nz(1), 39)));
doc.thermal_cycling_conditions.push(tcc);
let mut react = React::new(nz(1), SampleRef::new("s1").unwrap());
let mut data = Data::new(TargetRef::new("GAPDH").unwrap());
data.adps.push(AmpPoint::new(1.0, 0.5));
data.adps.push(AmpPoint::new(2.0, 0.6));
react.data.push(data);
let mut run = Run::new(id("plate 1"), PcrFormat::plate96());
run.thermal_cycling_conditions = Some(TccRef::new("std-3step").unwrap());
run.experimenters.push(ExperimenterRef::new("AB").unwrap());
run.reacts.push(react);
let mut experiment = Experiment::new(id("exp1"));
experiment.runs.push(run);
doc.experiments.push(experiment);
doc
}
#[test]
fn valid_document_passes() {
let report = valid_doc().validate().unwrap();
assert!(report.is_ok());
assert_eq!(report.findings().len(), 0, "{report}");
}
#[test]
fn dangling_references_are_each_reported() {
let mut doc = valid_doc();
doc.dyes.clear(); doc.samples.clear(); let report = doc.validate().unwrap_err();
assert_eq!(report.errors().count(), 2, "{report}");
assert!(report.findings().iter().any(|f| f.path.contains("dyeId")));
assert!(
report
.findings()
.iter()
.any(|f| f.path.contains("react[1]/sample"))
);
}
#[test]
fn duplicate_master_ids() {
let mut doc = valid_doc();
doc.samples.push(Sample::new(id("s1")));
let report = doc.validate().unwrap_err();
assert!(
report
.errors()
.any(|f| f.message.contains("defined 2 times")),
"{report}"
);
}
#[test]
fn duplicate_react_and_run_ids() {
let mut doc = valid_doc();
let run_copy = doc.experiments[0].runs[0].clone();
doc.experiments[0].runs.push(run_copy);
let react_copy = doc.experiments[0].runs[0].reacts[0].clone();
doc.experiments[0].runs[0].reacts.push(react_copy);
let report = doc.validate().unwrap_err();
assert!(
report
.errors()
.any(|f| f.message.contains("duplicate run id"))
);
assert!(
report
.errors()
.any(|f| f.message.contains("duplicate react id")),
"{report}"
);
}
#[test]
fn duplicate_adp_cycle_and_data_target() {
let mut doc = valid_doc();
let react = &mut doc.experiments[0].runs[0].reacts[0];
react.data[0].adps.push(AmpPoint::new(1.0, 0.9)); let dup = react.data[0].clone();
react.data.push(dup); let report = doc.validate().unwrap_err();
assert!(
report
.errors()
.any(|f| f.message.contains("duplicate amplification data point")),
"{report}"
);
assert!(
report
.errors()
.any(|f| f.message.contains("duplicate data entry for target")),
"{report}"
);
}
#[test]
fn duplicate_step_nr_and_empty_protocol() {
let mut doc = valid_doc();
doc.thermal_cycling_conditions[0].steps[1].nr = nz(1);
doc.thermal_cycling_conditions
.push(ThermalCyclingConditions::new(id("empty")));
let report = doc.validate().unwrap_err();
assert!(
report
.errors()
.any(|f| f.message.contains("duplicate step number"))
);
assert!(
report
.errors()
.any(|f| f.message.contains("at least one step"))
);
}
#[test]
fn forward_goto_with_nonzero_repeat_warns() {
let mut doc = valid_doc();
doc.thermal_cycling_conditions[0].steps[2].kind = StepKind::Loop(LoopStep::new(nz(5), 10));
let report = doc.validate().unwrap();
assert!(report.is_ok()); assert_eq!(report.warnings().count(), 2, "{report}"); }
#[test]
fn excl_false_warns() {
let mut doc = valid_doc();
doc.experiments[0].runs[0].reacts[0].data[0].excl = Some(Reasons::one("false"));
let report = doc.validate().unwrap();
assert!(report.is_ok());
assert_eq!(report.warnings().count(), 1);
assert!(report.findings()[0].message.contains("presence"));
}
#[test]
fn sample_type_target_ref_checked() {
let mut doc = valid_doc();
doc.samples[0].types.push(SampleTypeEntry::for_target(
SampleType::PositiveControl,
TargetRef::new("nonexistent").unwrap(),
));
let report = doc.validate().unwrap_err();
assert!(
report
.errors()
.any(|f| f.message.contains("does not reference any target")),
"{report}"
);
}
#[test]
fn partitions_checked() {
let mut doc = valid_doc();
let mut partitions = Partitions::new(0.85);
partitions.data.push(PartitionData::new(
TargetRef::new("GAPDH").unwrap(),
1000,
15000,
));
partitions
.data
.push(PartitionData::new(TargetRef::new("GAPDH").unwrap(), 2, 3));
doc.experiments[0].runs[0].reacts[0].partitions = Some(partitions);
let report = doc.validate().unwrap_err();
assert!(
report
.errors()
.any(|f| f.message.contains("duplicate partition data entry")),
"{report}"
);
}
}