rdml-qpcr 0.1.1

Read, write, and validate RDML (Real-time PCR Data Markup Language) qPCR data files
Documentation
//! RDML format versions and the reports produced when reading or writing
//! across them.
//!
//! This crate reads any RDML version (1.0–1.4) into one in-memory
//! model shaped like 1.4, and writes a caller-chosen version. Both
//! directions are explicit about anything non-identity:
//!
//! - Reading an old file returns [`ReadNote`]s describing every mapping
//!   that was not a plain rename (see the [1.0 migration](#rdml-10) below).
//! - Writing an older version returns a [`WriteReport`] listing every
//!   element that had to be dropped because the target version cannot
//!   express it. Nothing is dropped silently.
//!
//! # RDML 1.0
//!
//! 1.0 is read-only, migrated to the modern shape as follows (each step
//! generates a [`ReadNote`]):
//!
//! - `pcrFormat` was an enumerated string ("96-well plate; A1-H12", …); it
//!   is mapped to the equivalent rows/columns pair of RDML ≥ 1.1.
//! - `react/@id` was a well label ("B3"); it is mapped to the positional
//!   integer id of RDML ≥ 1.1 using the run's format. Unparseable labels
//!   fall back to document order.
//! - `target/dyeId` was an optional free string; each distinct value
//!   becomes a synthesized [`Dye`](crate::Dye) master element. Targets
//!   without a dye get one synthesized dye named `conversion_dye_missing`
//!   (dyes are mandatory for targets from 1.1 on; the placeholder name
//!   matches the one `RDMLpython` uses for the same migration).
//! - `data/quantity` (removed in 1.1 with no successor) is dropped.
//! - `sample/templateRNAQuantity` / `templateDNAQuantity` become
//!   [`TemplateQuantity`](crate::TemplateQuantity) (per the consortium's
//!   1.1→1.2 changelog); if both are present only one can be kept.
//! - `sample/templateRNAQuality` / `templateDNAQuality` become
//!   [`Annotation`](crate::Annotation)s with properties
//!   `templateRNAQuality/method`, `templateRNAQuality/result` (dito DNA),
//!   the destination the 1.1→1.2 changelog names for quality information.
//! - `rdml/thirdPartyExtensions` (arbitrary XML, removed in 1.1) is
//!   dropped; its raw XML is preserved in the note so no data is lost
//!   without a trace.
//!
//! Writing 1.0 is not supported: it would require re-inventing well
//! labels and the enumerated format strings the consortium itself
//! abandoned.

use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Serialize};

use crate::error::Error;

/// A version of the RDML specification.
///
/// Versions 1.0–1.3 are consortium *recommendations* (REC); 1.4 is a
/// *candidate recommendation* (CR) — supported, but the format may still
/// change before it becomes final.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum RdmlVersion {
    /// RDML 1.0 (REC). Read-only; see the [module docs](self).
    #[serde(rename = "1.0")]
    V1_0,
    /// RDML 1.1 (REC).
    #[serde(rename = "1.1")]
    V1_1,
    /// RDML 1.2 (REC).
    #[serde(rename = "1.2")]
    V1_2,
    /// RDML 1.3 (REC) — the latest recommendation, and this crate's
    /// default write version.
    #[serde(rename = "1.3")]
    V1_3,
    /// RDML 1.4 (CR) — candidate recommendation; may still change.
    #[serde(rename = "1.4")]
    V1_4,
}

impl RdmlVersion {
    /// The latest consortium recommendation (currently 1.3) — the default
    /// version written by this crate.
    pub const LATEST_REC: Self = Self::V1_3;

    /// The latest version this crate understands (currently the 1.4
    /// candidate recommendation).
    pub const LATEST: Self = Self::V1_4;

    /// The version string as it appears in the document's `version`
    /// attribute.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::V1_0 => "1.0",
            Self::V1_1 => "1.1",
            Self::V1_2 => "1.2",
            Self::V1_3 => "1.3",
            Self::V1_4 => "1.4",
        }
    }

    /// Whether this crate can write documents of this version (everything
    /// but 1.0).
    #[must_use]
    pub fn is_writable(self) -> bool {
        self != Self::V1_0
    }
}

impl fmt::Display for RdmlVersion {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for RdmlVersion {
    type Err = Error;
    fn from_str(s: &str) -> Result<Self, Error> {
        match s {
            "1.0" => Ok(Self::V1_0),
            "1.1" => Ok(Self::V1_1),
            "1.2" => Ok(Self::V1_2),
            "1.3" => Ok(Self::V1_3),
            "1.4" => Ok(Self::V1_4),
            other => Err(Error::UnsupportedVersion(other.to_string())),
        }
    }
}

/// A note produced while reading: something in the source document could
/// not be carried over verbatim and was mapped, dropped, or skipped as
/// described. Produced mainly when reading legacy versions (see the
/// [module docs](self)) and for unknown elements in any version.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReadNote {
    /// Where in the source document, e.g.
    /// `rdml/experiment[myexp]/run[plate1]/react[B3]`.
    pub path: String,
    /// What happened, in prose. Where content was dropped (1.0
    /// `data/quantity`, `thirdPartyExtensions`) the dropped content is
    /// quoted here in full.
    pub message: String,
}

impl ReadNote {
    pub(crate) fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            path: path.into(),
            message: message.into(),
        }
    }
}

impl fmt::Display for ReadNote {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {}", self.path, self.message)
    }
}

/// The report returned by every write: which version was written and what
/// (if anything) the target version could not express.
///
/// Writing the default 1.3 only loses data if the document uses 1.4
/// fields; writing 1.4 is always lossless.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WriteReport {
    pub(crate) version: RdmlVersion,
    pub(crate) losses: Vec<WriteLoss>,
}

impl WriteReport {
    /// The version that was written.
    #[must_use]
    pub fn version(&self) -> RdmlVersion {
        self.version
    }

    /// Everything the target version could not express, each entry naming
    /// the element and the version that would have kept it. Empty for a
    /// lossless write.
    #[must_use]
    pub fn losses(&self) -> &[WriteLoss] {
        &self.losses
    }

    pub(crate) fn new(version: RdmlVersion) -> Self {
        Self {
            version,
            losses: Vec::new(),
        }
    }

    /// True if nothing was dropped.
    #[must_use]
    pub fn is_lossless(&self) -> bool {
        self.losses.is_empty()
    }
}

/// One element dropped by a version downgrade during writing.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WriteLoss {
    /// Where in the document, e.g.
    /// `rdml/sample[liver 1]/doubleStranded`.
    pub path: String,
    /// A description of the dropped value.
    pub dropped: String,
    /// The lowest RDML version that can express the element.
    pub required_version: RdmlVersion,
}

impl fmt::Display for WriteLoss {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{}: dropped {} (requires RDML ≥ {})",
            self.path, self.dropped, self.required_version
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn version_strings_round_trip() {
        for v in [
            RdmlVersion::V1_0,
            RdmlVersion::V1_1,
            RdmlVersion::V1_2,
            RdmlVersion::V1_3,
            RdmlVersion::V1_4,
        ] {
            assert_eq!(v.as_str().parse::<RdmlVersion>().unwrap(), v);
        }
        assert!("2.0".parse::<RdmlVersion>().is_err());
        assert!("1".parse::<RdmlVersion>().is_err());
    }

    #[test]
    fn version_ordering() {
        assert!(RdmlVersion::V1_0 < RdmlVersion::V1_1);
        assert!(RdmlVersion::V1_3 < RdmlVersion::V1_4);
        assert!(!RdmlVersion::V1_0.is_writable());
        assert!(RdmlVersion::V1_1.is_writable());
    }
}