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};
pub const DOCUMENT_MEMBER: &str = "rdml_data.xml";
pub const PARTITIONS_FOLDER: &str = "partitions/";
#[derive(Debug, Clone, PartialEq)]
pub struct RdmlFile {
pub document: Rdml,
source_version: Option<RdmlVersion>,
read_notes: Vec<ReadNote>,
members: BTreeMap<String, Vec<u8>>,
}
impl RdmlFile {
#[must_use]
pub fn new(document: Rdml) -> Self {
Self {
document,
source_version: None,
read_notes: Vec::new(),
members: BTreeMap::new(),
}
}
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let bytes = std::fs::read(path)?;
Self::from_slice(&bytes)
}
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(),
})
}
}
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();
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 {
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,
})
}
#[must_use]
pub fn source_version(&self) -> Option<RdmlVersion> {
self.source_version
}
#[must_use]
pub fn read_notes(&self) -> &[ReadNote] {
&self.read_notes
}
pub fn members(&self) -> impl Iterator<Item = (&str, &[u8])> {
self.members.iter().map(|(n, b)| (n.as_str(), b.as_slice()))
}
pub fn member(&self, name: &str) -> Option<&[u8]> {
self.members.get(name).map(Vec::as_slice)
}
pub fn insert_member(&mut self, name: impl Into<String>, bytes: impl Into<Vec<u8>>) {
self.members.insert(name.into(), bytes.into());
}
pub fn remove_member(&mut self, name: &str) -> Option<Vec<u8>> {
self.members.remove(name)
}
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)
}
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}")
}
}
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)
}
}
pub fn save(&self, path: impl AsRef<Path>) -> Result<WriteReport> {
self.save_as(path, RdmlVersion::LATEST_REC)
}
pub fn save_as(&self, path: impl AsRef<Path>, version: RdmlVersion) -> Result<WriteReport> {
self.check_valid()?;
self.save_as_unchecked(path, version)
}
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)
}
pub fn write<W: Write + Seek>(&self, writer: W, version: RdmlVersion) -> Result<WriteReport> {
self.check_valid()?;
self.write_unchecked(writer, version)
}
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)
}
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)
}
}
fn has_xml_extension(name: &str) -> bool {
Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("xml"))
}