rdml-qpcr 0.1.1

Read, write, and validate RDML (Real-time PCR Data Markup Language) qPCR data files
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! The `.rdml` file container: a pkzip archive whose member
//! `rdml_data.xml` is the document, alongside optional digital-PCR
//! partition tables (`partitions/*.tsv`) and vendor-specific extras.
//!
//! [`RdmlFile`] is the primary way to read and write whole files. It
//! preserves every archive member it does not understand byte-for-byte,
//! so a read–modify–write cycle never drops a vendor's sidecar data, and
//! it reads bare uncompressed `.xml` files as well (the format notes
//! require software to accept both).

use std::collections::BTreeMap;
use std::io::{Cursor, Read, Seek, Write};
use std::path::Path;

use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};

use crate::error::{Error, Result};
use crate::model::Rdml;
use crate::partition_table::PartitionTable;
use crate::validate::ValidationReport;
use crate::version::{RdmlVersion, ReadNote, WriteReport};

/// The required name of the document member inside the archive.
pub const DOCUMENT_MEMBER: &str = "rdml_data.xml";

/// The archive folder that holds digital-PCR partition tables.
pub const PARTITIONS_FOLDER: &str = "partitions/";

/// A complete RDML file: the parsed document plus every other archive
/// member, preserved byte-for-byte.
///
/// Reading accepts a zip archive (`.rdml` / `.rdm`) or a bare
/// uncompressed XML document, in any RDML version; the in-memory
/// document is always the modern shape. Writing produces a zip archive
/// of a caller-chosen version (1.3, the latest recommendation, by
/// default), after validating.
///
/// ```no_run
/// use rdml_qpcr::RdmlFile;
///
/// let mut file = RdmlFile::open("run42.rdml")?;
/// println!("was RDML {}", file.source_version().unwrap());
/// file.document.date_updated = Some(rdml_qpcr::DateTime::new("2026-08-14T12:00:00Z")?);
/// let report = file.save("run42-updated.rdml")?; // validates, writes 1.3
/// assert!(report.is_lossless());
/// # Ok::<(), rdml_qpcr::Error>(())
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct RdmlFile {
    /// The RDML document itself. Mutate freely; it is validated when the
    /// file is saved.
    pub document: Rdml,
    /// Version of the source file, if this value was read from one.
    source_version: Option<RdmlVersion>,
    /// Notes from reading (legacy-version migrations, skipped elements).
    read_notes: Vec<ReadNote>,
    /// Every archive member except the document, keyed by full member
    /// name (e.g. `partitions/well96.tsv`, `acme_calibration.bin`).
    members: BTreeMap<String, Vec<u8>>,
}

impl RdmlFile {
    /// Creates a fresh file around a document, with no extra members.
    #[must_use]
    pub fn new(document: Rdml) -> Self {
        Self {
            document,
            source_version: None,
            read_notes: Vec::new(),
            members: BTreeMap::new(),
        }
    }

    /// Opens an RDML file from disk — a `.rdml`/`.rdm` zip archive or a
    /// bare `.xml` document, decided by content, not extension.
    ///
    /// # Errors
    ///
    /// Fails on I/O errors and everything listed for
    /// [`from_slice`](Self::from_slice).
    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
        let bytes = std::fs::read(path)?;
        Self::from_slice(&bytes)
    }

    /// Reads an RDML file from a byte slice (zip archive or bare XML,
    /// decided by content).
    ///
    /// # Errors
    ///
    /// Fails if the archive is corrupt, contains no document member, or
    /// the document is not parseable RDML (malformed XML, unsupported
    /// version, invalid values, non-UTF-8 content).
    pub fn from_slice(bytes: &[u8]) -> Result<Self> {
        if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") {
            Self::from_zip(Cursor::new(bytes))
        } else {
            let text = std::str::from_utf8(bytes).map_err(Error::NotUtf8)?;
            let parsed = Rdml::from_xml(text)?;
            Ok(Self {
                document: parsed.document,
                source_version: Some(parsed.version),
                read_notes: parsed.notes,
                members: BTreeMap::new(),
            })
        }
    }

    /// Reads an RDML zip archive from any seekable reader.
    ///
    /// # Errors
    ///
    /// Fails on I/O and zip errors, if the archive contains no document
    /// member (no `rdml_data.xml` and no single root `.xml` fallback),
    /// or if the document is not parseable RDML.
    pub fn from_zip<R: Read + Seek>(reader: R) -> Result<Self> {
        let mut archive = ZipArchive::new(reader)?;
        let mut members = BTreeMap::new();
        let mut document_xml: Option<Vec<u8>> = None;
        let mut notes = Vec::new();
        for i in 0..archive.len() {
            let mut entry = archive.by_index(i)?;
            if entry.is_dir() {
                continue;
            }
            let name = entry.name().to_string();
            // Capacity is a hint; saturate rather than trust a hostile size.
            let mut bytes = Vec::with_capacity(usize::try_from(entry.size()).unwrap_or(0));
            entry.read_to_end(&mut bytes)?;
            if name == DOCUMENT_MEMBER {
                document_xml = Some(bytes);
            } else {
                members.insert(name, bytes);
            }
        }
        let document_xml = if let Some(bytes) = document_xml {
            bytes
        } else {
            // Lenient fallback: exactly one XML member at the archive
            // root gets treated as the document, with a note.
            let no_document = || Error::Invalid {
                path: DOCUMENT_MEMBER.into(),
                message: format!(
                    "archive contains no `{DOCUMENT_MEMBER}` member \
                     (and no unambiguous root .xml fallback)"
                ),
            };
            let mut root_xml = members
                .keys()
                .filter(|n| !n.contains('/') && has_xml_extension(n))
                .cloned();
            match (root_xml.next(), root_xml.next()) {
                (Some(only), None) => {
                    drop(root_xml);
                    notes.push(ReadNote::new(
                        only.clone(),
                        format!(
                            "archive has no `{DOCUMENT_MEMBER}`; using `{only}` as the \
                             document (non-standard member name)"
                        ),
                    ));
                    members.remove(&only).ok_or_else(no_document)?
                }
                _ => return Err(no_document()),
            }
        };
        let text = std::str::from_utf8(&document_xml).map_err(Error::NotUtf8)?;
        let parsed = Rdml::from_xml(text)?;
        notes.extend(parsed.notes);
        Ok(Self {
            document: parsed.document,
            source_version: Some(parsed.version),
            read_notes: notes,
            members,
        })
    }

    /// The RDML version of the source file, or `None` for a file built
    /// in memory.
    #[must_use]
    pub fn source_version(&self) -> Option<RdmlVersion> {
        self.source_version
    }

    /// Notes produced while reading the source file: legacy-version
    /// migrations, skipped unknown elements, non-standard member names.
    #[must_use]
    pub fn read_notes(&self) -> &[ReadNote] {
        &self.read_notes
    }

    // ---- members -----------------------------------------------------

    /// Iterates over all archive members except the document itself, in
    /// name order. Includes partition tables and vendor extras.
    pub fn members(&self) -> impl Iterator<Item = (&str, &[u8])> {
        self.members.iter().map(|(n, b)| (n.as_str(), b.as_slice()))
    }

    /// The raw bytes of a member, if present.
    pub fn member(&self, name: &str) -> Option<&[u8]> {
        self.members.get(name).map(Vec::as_slice)
    }

    /// Adds or replaces a member. By convention, third-party members are
    /// prefixed with the company name (e.g. `acme_rawtraces.bin`);
    /// partition tables belong under `partitions/`.
    pub fn insert_member(&mut self, name: impl Into<String>, bytes: impl Into<Vec<u8>>) {
        self.members.insert(name.into(), bytes.into());
    }

    /// Removes a member, returning its bytes if it existed.
    pub fn remove_member(&mut self, name: &str) -> Option<Vec<u8>> {
        self.members.remove(name)
    }

    // ---- partition tables --------------------------------------------

    /// Parses the digital-PCR partition table with the given file name
    /// (as referenced by
    /// [`Partitions::end_pt_table`](crate::Partitions::end_pt_table) —
    /// the bare name; the `partitions/` folder prefix is applied here).
    ///
    /// # Errors
    ///
    /// Fails if no such member exists or the table is malformed
    /// ([`Error::PartitionTable`] with a line number).
    pub fn partition_table(&self, name: &str) -> Result<PartitionTable> {
        let member_name = Self::partition_member_name(name);
        let bytes = self
            .member(&member_name)
            .or_else(|| self.member(name))
            .ok_or_else(|| Error::NoSuchMember(member_name.clone()))?;
        PartitionTable::parse(bytes, &member_name)
    }

    /// Serialises a partition table into the archive under
    /// `partitions/{name}`. Set the same `name` in the reaction's
    /// [`Partitions::end_pt_table`](crate::Partitions::end_pt_table).
    pub fn insert_partition_table(&mut self, name: &str, table: &PartitionTable) {
        self.members.insert(
            Self::partition_member_name(name),
            table.to_tsv().into_bytes(),
        );
    }

    fn partition_member_name(name: &str) -> String {
        if name.starts_with(PARTITIONS_FOLDER) {
            name.to_string()
        } else {
            format!("{PARTITIONS_FOLDER}{name}")
        }
    }

    // ---- validation --------------------------------------------------

    /// Validates the document ([`Rdml::validate`]) plus the container
    /// itself: every `endPtTable` reference must name an existing
    /// `partitions/` member.
    ///
    /// # Errors
    ///
    /// Returns `Err` with the same report when any error-severity finding
    /// exists; `Ok` reports may still carry warnings.
    pub fn validate(&self) -> std::result::Result<ValidationReport, ValidationReport> {
        let mut report = match self.document.validate() {
            Ok(r) | Err(r) => r,
        };
        for ctx in self.document.reactions() {
            if let Some(partitions) = &ctx.react.partitions
                && let Some(table) = &partitions.end_pt_table
            {
                let member_name = Self::partition_member_name(table);
                if !self.members.contains_key(&member_name)
                    && !self.members.contains_key(table.as_str())
                {
                    report.findings.push(crate::validate::Finding {
                        severity: crate::validate::Severity::Error,
                        path: format!(
                            "rdml/experiment[{}]/run[{}]/react[{}]/partitions/endPtTable",
                            ctx.experiment.id, ctx.run.id, ctx.react.id
                        ),
                        message: format!(
                            "references `{table}` but the archive has no member \
                             `{member_name}`"
                        ),
                    });
                }
            }
        }
        if report.is_ok() {
            Ok(report)
        } else {
            Err(report)
        }
    }

    // ---- writing -----------------------------------------------------

    /// Saves as a `.rdml` zip archive in RDML 1.3 (the latest
    /// recommendation), validating first. Returns the
    /// [`WriteReport`]; with a 1.3 target it only carries losses if the
    /// document uses 1.4 fields.
    ///
    /// # Errors
    ///
    /// Fails with [`Error::Validation`] if validation finds errors, and
    /// on I/O or zip errors.
    pub fn save(&self, path: impl AsRef<Path>) -> Result<WriteReport> {
        self.save_as(path, RdmlVersion::LATEST_REC)
    }

    /// Saves as a `.rdml` zip archive of the chosen version, validating
    /// first.
    ///
    /// # Errors
    ///
    /// Fails with [`Error::Validation`] if validation finds errors, with
    /// [`Error::UnsupportedWriteVersion`] for RDML 1.0, and on I/O or
    /// zip errors.
    pub fn save_as(&self, path: impl AsRef<Path>, version: RdmlVersion) -> Result<WriteReport> {
        self.check_valid()?;
        self.save_as_unchecked(path, version)
    }

    /// Saves without validating. The output of an invalid document may
    /// violate the RDML schema; prefer [`save_as`](Self::save_as).
    ///
    /// # Errors
    ///
    /// Fails with [`Error::UnsupportedWriteVersion`] for RDML 1.0, and
    /// on I/O or zip errors.
    pub fn save_as_unchecked(
        &self,
        path: impl AsRef<Path>,
        version: RdmlVersion,
    ) -> Result<WriteReport> {
        let file = std::fs::File::create(path)?;
        self.write_unchecked(std::io::BufWriter::new(file), version)
    }

    /// Writes the zip archive of the chosen version to any writer,
    /// validating first.
    ///
    /// # Errors
    ///
    /// As for [`save_as`](Self::save_as).
    pub fn write<W: Write + Seek>(&self, writer: W, version: RdmlVersion) -> Result<WriteReport> {
        self.check_valid()?;
        self.write_unchecked(writer, version)
    }

    /// Writes without validating; prefer [`write`](Self::write).
    ///
    /// # Errors
    ///
    /// As for [`save_as_unchecked`](Self::save_as_unchecked).
    pub fn write_unchecked<W: Write + Seek>(
        &self,
        writer: W,
        version: RdmlVersion,
    ) -> Result<WriteReport> {
        let (xml, report) = self.document.to_xml_unchecked(version)?;
        let mut zip = ZipWriter::new(writer);
        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
        zip.start_file(DOCUMENT_MEMBER, options)?;
        zip.write_all(xml.as_bytes())?;
        for (name, bytes) in &self.members {
            zip.start_file(name.as_str(), options)?;
            zip.write_all(bytes)?;
        }
        zip.finish()?.flush()?;
        Ok(report)
    }

    /// The zip archive of the chosen version as bytes, validating first.
    ///
    /// # Errors
    ///
    /// As for [`save_as`](Self::save_as).
    pub fn to_bytes(&self, version: RdmlVersion) -> Result<(Vec<u8>, WriteReport)> {
        self.check_valid()?;
        let mut cursor = Cursor::new(Vec::new());
        let report = self.write_unchecked(&mut cursor, version)?;
        Ok((cursor.into_inner(), report))
    }

    fn check_valid(&self) -> Result<()> {
        if let Err(report) = self.validate() {
            return Err(Error::Validation(report));
        }
        Ok(())
    }
}

impl From<Rdml> for RdmlFile {
    fn from(document: Rdml) -> Self {
        Self::new(document)
    }
}

/// Case-insensitive `.xml` extension check on an archive member name.
fn has_xml_extension(name: &str) -> bool {
    Path::new(name)
        .extension()
        .is_some_and(|ext| ext.eq_ignore_ascii_case("xml"))
}