use std::io::{Result, Write};
use flate2::write::ZlibEncoder;
use flate2::Compression as ZlibLevel;
use crate::enums::{Activation, Analyzer, Compression, MobilityArrayKind, Polarity, ScanMode};
use crate::source::SpectrumSource;
use crate::types::{ChromatogramRecord, CvTerm, RunMetadata, SpectrumRecord};
struct CountingWriter<'a, W: Write> {
inner: &'a mut W,
pos: u64,
sha1: Sha1,
hashing: bool,
}
impl<'a, W: Write> CountingWriter<'a, W> {
fn new(inner: &'a mut W) -> Self {
Self {
inner,
pos: 0,
sha1: Sha1::new(),
hashing: true,
}
}
}
impl<W: Write> Write for CountingWriter<'_, W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
self.pos += n as u64;
if self.hashing {
self.sha1.update(&buf[..n]);
}
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
struct Sha1 {
state: [u32; 5],
count: u64,
buf: [u8; 64],
buf_len: usize,
}
impl Sha1 {
fn new() -> Self {
Self {
state: [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0],
count: 0,
buf: [0u8; 64],
buf_len: 0,
}
}
fn update(&mut self, data: &[u8]) {
let mut off = 0;
while off < data.len() {
let space = 64 - self.buf_len;
let take = space.min(data.len() - off);
self.buf[self.buf_len..self.buf_len + take].copy_from_slice(&data[off..off + take]);
self.buf_len += take;
self.count += take as u64;
off += take;
if self.buf_len == 64 {
self.compress();
self.buf_len = 0;
}
}
}
fn compress(&mut self) {
let mut w = [0u32; 80];
for (i, word) in w.iter_mut().enumerate().take(16) {
*word = u32::from_be_bytes(self.buf[i * 4..i * 4 + 4].try_into().unwrap());
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let [mut a, mut b, mut c, mut d, mut e] = self.state;
for (i, &wi) in w.iter().enumerate() {
let (f, k) = match i {
0..=19 => ((b & c) | (!b & d), 0x5A827999u32),
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
_ => (b ^ c ^ d, 0xCA62C1D6),
};
let temp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(wi);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = temp;
}
self.state[0] = self.state[0].wrapping_add(a);
self.state[1] = self.state[1].wrapping_add(b);
self.state[2] = self.state[2].wrapping_add(c);
self.state[3] = self.state[3].wrapping_add(d);
self.state[4] = self.state[4].wrapping_add(e);
}
fn finalize(mut self) -> [u8; 20] {
let bit_count = self.count * 8;
self.update(&[0x80]);
while self.buf_len != 56 {
self.update(&[0u8]);
}
self.update(&bit_count.to_be_bytes());
let mut digest = [0u8; 20];
for (i, &word) in self.state.iter().enumerate() {
digest[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
}
digest
}
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn base64_encode(data: &[u8]) -> String {
let n = data.len();
let mut out = Vec::with_capacity(n.div_ceil(3) * 4);
let mut i = 0;
while i + 2 < n {
let b = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | (data[i + 2] as u32);
out.push(B64[((b >> 18) & 0x3f) as usize]);
out.push(B64[((b >> 12) & 0x3f) as usize]);
out.push(B64[((b >> 6) & 0x3f) as usize]);
out.push(B64[(b & 0x3f) as usize]);
i += 3;
}
if n - i == 2 {
let b = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8);
out.push(B64[((b >> 18) & 0x3f) as usize]);
out.push(B64[((b >> 12) & 0x3f) as usize]);
out.push(B64[((b >> 6) & 0x3f) as usize]);
out.push(b'=');
} else if n - i == 1 {
let b = (data[i] as u32) << 16;
out.push(B64[((b >> 18) & 0x3f) as usize]);
out.push(B64[((b >> 12) & 0x3f) as usize]);
out.push(b'=');
out.push(b'=');
}
String::from_utf8(out).expect("base64 output is ASCII")
}
fn zlib_compress(data: &[u8]) -> Vec<u8> {
let mut enc = ZlibEncoder::new(Vec::new(), ZlibLevel::default());
enc.write_all(data)
.expect("in-memory zlib compression cannot fail");
enc.finish()
.expect("in-memory zlib compression cannot fail")
}
fn compression_cv(compression: Compression) -> (&'static str, &'static str) {
match compression {
Compression::NoCompression => ("MS:1000576", "no compression"),
Compression::Zlib => ("MS:1000574", "zlib compression"),
}
}
fn encode_bytes(data: &[u8], compression: Compression) -> String {
match compression {
Compression::NoCompression => base64_encode(data),
Compression::Zlib => base64_encode(&zlib_compress(data)),
}
}
fn encode_f64_array(vals: &[f64], compression: Compression) -> String {
let bytes: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
encode_bytes(&bytes, compression)
}
fn encode_f32_array(vals: &[f32], compression: Compression) -> String {
let bytes: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
encode_bytes(&bytes, compression)
}
fn escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn activation_cv(act: Activation, analyzer: Option<Analyzer>) -> (&'static str, &'static str) {
match act {
Activation::HCD => ("MS:1000422", "beam-type collision-induced dissociation"),
Activation::ETD | Activation::EThcD => ("MS:1000598", "electron transfer dissociation"),
Activation::CID => match analyzer {
Some(Analyzer::FTMS) => ("MS:1000422", "beam-type collision-induced dissociation"),
_ => ("MS:1000133", "collision-induced dissociation"),
},
Activation::MPID => (
"MS:1002481",
"supplemental beam-type collision-induced dissociation",
),
Activation::ECD => ("MS:1000250", "electron capture dissociation"),
Activation::IRMPD => ("MS:1000262", "infrared multiphoton dissociation"),
Activation::PD => ("MS:1001880", "in-source collision-induced dissociation"),
Activation::PQD => ("MS:1000599", "pulsed q dissociation"),
Activation::UVPD => ("MS:1003246", "ultraviolet photodissociation"),
Activation::SID => ("MS:1000422", "beam-type collision-induced dissociation"),
}
}
pub fn write_mzml<S: SpectrumSource + ?Sized, W: Write>(src: &mut S, out: &mut W) -> Result<()> {
write_mzml_with_compression(src, out, Compression::default())
}
pub fn write_mzml_with_compression<S: SpectrumSource + ?Sized, W: Write>(
src: &mut S,
out: &mut W,
compression: Compression,
) -> Result<()> {
let meta = src.run_metadata();
let count = src.spectrum_count_hint().unwrap_or(0);
let mobility_kind = meta.mobility_array_kind;
let analyzer_ic_ids = analyzer_ic_table(&meta.analyzers);
let extra_processing = src.additional_processing_steps();
write_prologue(out, &meta, count, false, &extra_processing)?;
for rec in src.iter_spectra() {
write_spectrum(out, &rec, mobility_kind, &analyzer_ic_ids, compression)?;
}
writeln!(out, r#" </spectrumList>"#)?;
let chroms: Vec<ChromatogramRecord> = src.iter_chromatograms().collect();
if !chroms.is_empty() {
writeln!(
out,
r#" <chromatogramList count="{}" defaultDataProcessingRef="dp1">"#,
chroms.len()
)?;
for rec in &chroms {
write_chromatogram(out, rec, compression)?;
}
writeln!(out, r#" </chromatogramList>"#)?;
}
writeln!(out, r#" </run>"#)?;
writeln!(out, r#"</mzML>"#)?;
Ok(())
}
pub fn write_indexed_mzml<S: SpectrumSource + ?Sized, W: Write>(
src: &mut S,
out: &mut W,
) -> Result<()> {
write_indexed_mzml_with_compression(src, out, Compression::default())
}
pub fn write_indexed_mzml_with_compression<S: SpectrumSource + ?Sized, W: Write>(
src: &mut S,
out: &mut W,
compression: Compression,
) -> Result<()> {
let meta = src.run_metadata();
let count = src.spectrum_count_hint().unwrap_or(0);
let mobility_kind = meta.mobility_array_kind;
let analyzer_ic_ids = analyzer_ic_table(&meta.analyzers);
let extra_processing = src.additional_processing_steps();
let mut cw = CountingWriter::new(out);
write_prologue(&mut cw, &meta, count, true, &extra_processing)?;
let mut offsets: Vec<(String, u64)> = Vec::with_capacity(count);
for rec in src.iter_spectra() {
offsets.push((rec.native_id.clone(), cw.pos));
write_spectrum(&mut cw, &rec, mobility_kind, &analyzer_ic_ids, compression)?;
}
writeln!(cw, r#" </spectrumList>"#)?;
let chroms: Vec<ChromatogramRecord> = src.iter_chromatograms().collect();
let mut chrom_offsets: Vec<(String, u64)> = Vec::with_capacity(chroms.len());
if !chroms.is_empty() {
writeln!(
cw,
r#" <chromatogramList count="{}" defaultDataProcessingRef="dp1">"#,
chroms.len()
)?;
for rec in &chroms {
chrom_offsets.push((rec.id.clone(), cw.pos));
write_chromatogram(&mut cw, rec, compression)?;
}
writeln!(cw, r#" </chromatogramList>"#)?;
}
writeln!(cw, r#" </run>"#)?;
writeln!(cw, r#" </mzML>"#)?;
let index_list_offset = cw.pos;
let n_indexes = 1 + usize::from(!chrom_offsets.is_empty());
writeln!(cw, r#" <indexList count="{n_indexes}">"#)?;
writeln!(cw, r#" <index name="spectrum">"#)?;
for (id, offset) in &offsets {
writeln!(
cw,
r#" <offset idRef="{}">{}</offset>"#,
escape(id),
offset
)?;
}
writeln!(cw, r#" </index>"#)?;
if !chrom_offsets.is_empty() {
writeln!(cw, r#" <index name="chromatogram">"#)?;
for (id, offset) in &chrom_offsets {
writeln!(
cw,
r#" <offset idRef="{}">{}</offset>"#,
escape(id),
offset
)?;
}
writeln!(cw, r#" </index>"#)?;
}
writeln!(cw, r#" </indexList>"#)?;
cw.hashing = false;
let digest = std::mem::replace(&mut cw.sha1, Sha1::new()).finalize();
let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
writeln!(
cw,
r#" <indexListOffset>{}</indexListOffset>"#,
index_list_offset
)?;
writeln!(cw, r#" <fileChecksum>{}</fileChecksum>"#, hex)?;
writeln!(cw, r#"</indexedmzML>"#)?;
Ok(())
}
fn write_prologue<W: Write>(
out: &mut W,
meta: &RunMetadata,
n_spectra: usize,
indexed: bool,
extra_processing: &[(&'static str, &'static str)],
) -> Result<()> {
writeln!(out, r#"<?xml version="1.0" encoding="utf-8"?>"#)?;
if indexed {
writeln!(out, r#"<indexedmzML xmlns="http://psi.hupo.org/ms/mzml""#)?;
writeln!(
out,
r#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance""#
)?;
writeln!(
out,
r#" xsi:schemaLocation="http://psi.hupo.org/ms/mzml http://psidev.info/files/ms/mzML/xsd/mzML1.1.2_idx.xsd">"#
)?;
writeln!(
out,
r#" <mzML xmlns="http://psi.hupo.org/ms/mzml" version="1.1.0">"#
)?;
} else {
writeln!(out, r#"<mzML xmlns="http://psi.hupo.org/ms/mzml""#)?;
writeln!(
out,
r#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance""#
)?;
writeln!(
out,
r#" xsi:schemaLocation="http://psi.hupo.org/ms/mzml http://psidev.info/files/ms/mzML/xsd/mzML1.1.0.xsd""#
)?;
writeln!(out, r#" version="1.1.0">"#)?;
}
writeln!(out, r#" <cvList count="2">"#)?;
writeln!(
out,
r#" <cv id="MS" fullName="Proteomics Standards Initiative Mass Spectrometry Ontology" version="4.1.100" URI="https://raw.githubusercontent.com/HUPO-PSI/psi-ms-CV/master/psi-ms.obo"/>"#
)?;
writeln!(
out,
r#" <cv id="UO" fullName="Unit Ontology" version="09:04:2014" URI="https://raw.githubusercontent.com/bio-ontology-research-group/unit-ontology/master/unit.obo"/>"#
)?;
writeln!(out, r#" </cvList>"#)?;
writeln!(out, r#" <fileDescription>"#)?;
writeln!(out, r#" <fileContent>"#)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000579" name="MS1 spectrum" value=""/>"#
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000580" name="MSn spectrum" value=""/>"#
)?;
writeln!(out, r#" </fileContent>"#)?;
writeln!(out, r#" <sourceFileList count="1">"#)?;
writeln!(
out,
r#" <sourceFile id="sf1" name="{}" location="">"#,
escape(&meta.source_file_name)
)?;
write_cv(out, " ", &meta.source_file_format)?;
write_cv(out, " ", &meta.native_id_format)?;
writeln!(out, r#" </sourceFile>"#)?;
writeln!(out, r#" </sourceFileList>"#)?;
writeln!(out, r#" </fileDescription>"#)?;
writeln!(out, r#" <softwareList count="1">"#)?;
writeln!(
out,
r#" <software id="{}" version="{}">"#,
escape(&meta.software_name),
escape(&meta.software_version)
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000799" name="custom unreleased software tool" value="{}"/>"#,
escape(&meta.software_name)
)?;
writeln!(out, r#" </software>"#)?;
writeln!(out, r#" </softwareList>"#)?;
writeln!(
out,
r#" <instrumentConfigurationList count="{}">"#,
1 + meta.analyzers.len()
)?;
writeln!(out, r#" <instrumentConfiguration id="IC1">"#)?;
write_cv(out, " ", &meta.instrument)?;
if let Some(serial) = &meta.instrument_serial_number {
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000529" name="instrument serial number" value="{}"/>"#,
escape(serial)
)?;
}
writeln!(out, r#" </instrumentConfiguration>"#)?;
for (analyzer, id) in analyzer_ic_table(&meta.analyzers) {
let (acc, name) = analyzer_cv(analyzer);
writeln!(out, r#" <instrumentConfiguration id="{id}">"#)?;
write_cv(out, " ", &meta.instrument)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="{acc}" name="{name}" value=""/>"#
)?;
writeln!(out, r#" </instrumentConfiguration>"#)?;
}
writeln!(out, r#" </instrumentConfigurationList>"#)?;
writeln!(out, r#" <dataProcessingList count="1">"#)?;
writeln!(out, r#" <dataProcessing id="dp1">"#)?;
writeln!(
out,
r#" <processingMethod order="0" softwareRef="{}">"#,
escape(&meta.software_name)
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000544" name="Conversion to mzML" value=""/>"#
)?;
writeln!(out, r#" </processingMethod>"#)?;
for (order, (accession, name)) in extra_processing.iter().enumerate() {
writeln!(
out,
r#" <processingMethod order="{}" softwareRef="{}">"#,
order + 1,
escape(&meta.software_name)
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="{accession}" name="{name}" value=""/>"#
)?;
writeln!(out, r#" </processingMethod>"#)?;
}
writeln!(out, r#" </dataProcessing>"#)?;
writeln!(out, r#" </dataProcessingList>"#)?;
match &meta.start_timestamp {
Some(ts) => writeln!(
out,
r#" <run id="{}" defaultInstrumentConfigurationRef="IC1" defaultSourceFileRef="sf1" startTimeStamp="{}">"#,
escape(&meta.source_file_name),
escape(ts)
)?,
None => writeln!(
out,
r#" <run id="{}" defaultInstrumentConfigurationRef="IC1" defaultSourceFileRef="sf1">"#,
escape(&meta.source_file_name)
)?,
}
writeln!(
out,
r#" <spectrumList count="{}" defaultDataProcessingRef="dp1">"#,
n_spectra
)?;
Ok(())
}
fn analyzer_cv(analyzer: Analyzer) -> (&'static str, &'static str) {
match analyzer {
Analyzer::ITMS => ("MS:1000264", "ion trap"),
Analyzer::TQMS | Analyzer::SQMS => ("MS:1000081", "quadrupole"),
Analyzer::TOFMS => ("MS:1000084", "time-of-flight"),
Analyzer::FTMS => ("MS:1000484", "orbitrap"),
Analyzer::Sector => ("MS:1000080", "magnetic sector"),
}
}
fn analyzer_ic_table(analyzers: &[Analyzer]) -> Vec<(Analyzer, String)> {
analyzers
.iter()
.enumerate()
.map(|(i, a)| (*a, format!("IC{}", i + 2)))
.collect()
}
fn write_cv<W: Write>(out: &mut W, indent: &str, cv: &CvTerm) -> Result<()> {
writeln!(
out,
r#"{indent}<cvParam cvRef="MS" accession="{}" name="{}" value=""/>"#,
cv.accession,
escape(&cv.name)
)
}
fn write_spectrum<W: Write>(
out: &mut W,
rec: &SpectrumRecord,
mobility_kind: Option<MobilityArrayKind>,
analyzer_ic_ids: &[(Analyzer, String)],
compression: Compression,
) -> Result<()> {
let spectrum_type = if rec.ms_level <= 1 {
("MS:1000579", "MS1 spectrum")
} else {
("MS:1000580", "MSn spectrum")
};
let n_peaks = rec.mz.len();
let ic_ref = rec.analyzer.and_then(|a| {
analyzer_ic_ids
.iter()
.find(|(candidate, _)| *candidate == a)
.map(|(_, id)| id.as_str())
});
writeln!(
out,
r#" <spectrum id="{sid}" index="{idx}" defaultArrayLength="{n}">"#,
sid = escape(&rec.native_id),
idx = rec.index,
n = n_peaks
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000511" name="ms level" value="{}"/>"#,
rec.ms_level
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="{}" name="{}" value=""/>"#,
spectrum_type.0, spectrum_type.1
)?;
match rec.scan_mode {
Some(ScanMode::Centroid) => writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000127" name="centroid spectrum" value=""/>"#
)?,
_ => writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000128" name="profile spectrum" value=""/>"#
)?,
}
match rec.polarity {
Some(Polarity::Positive) => writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000130" name="positive scan" value=""/>"#
)?,
Some(Polarity::Negative) => writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000129" name="negative scan" value=""/>"#
)?,
None => {}
}
let tic = rec.effective_tic();
let (bp_mz, bp_int) = rec.effective_base_peak().unwrap_or((0.0, 0.0));
let (lo_mz, hi_mz) = rec.effective_mz_range().unwrap_or((0.0, 0.0));
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000285" name="total ion current" value="{:.6}"/>"#,
tic
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000504" name="base peak m/z" value="{:.6}"/>"#,
bp_mz
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000505" name="base peak intensity" value="{:.6}"/>"#,
bp_int
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000528" name="lowest observed m/z" value="{:.6}"/>"#,
lo_mz
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000527" name="highest observed m/z" value="{:.6}"/>"#,
hi_mz
)?;
writeln!(out, r#" <scanList count="1">"#)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000795" name="no combination" value=""/>"#
)?;
match ic_ref {
Some(id) => writeln!(out, r#" <scan instrumentConfigurationRef="{id}">"#)?,
None => writeln!(out, r#" <scan>"#)?,
}
if let Some(f) = rec.filter.as_deref() {
if !f.is_empty() {
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000512" name="filter string" value="{}"/>"#,
escape(f)
)?;
}
}
let rt_min = rec.retention_time_sec / 60.0;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000016" name="scan start time" value="{:.6}" unitCvRef="UO" unitAccession="UO:0000031" unitName="minute"/>"#,
rt_min
)?;
if let Some(it) = rec.ion_injection_time_ms {
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000927" name="ion injection time" value="{:.6}" unitCvRef="UO" unitAccession="UO:0000028" unitName="millisecond"/>"#,
it
)?;
}
if let Some(mob) = rec.inv_mobility {
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1002815" name="inverse reduced ion mobility" value="{:.6}" unitCvRef="MS" unitAccession="MS:1002814" unitName="volt-second per square centimeter"/>"#,
mob
)?;
}
if let Some(cv) = rec.faims_cv {
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1001581" name="FAIMS compensation voltage" value="{:.4}" unitCvRef="UO" unitAccession="UO:0000218" unitName="volt"/>"#,
cv
)?;
}
writeln!(out, r#" <scanWindowList count="1">"#)?;
writeln!(out, r#" <scanWindow>"#)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000501" name="scan window lower limit" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
lo_mz
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000500" name="scan window upper limit" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
hi_mz
)?;
writeln!(out, r#" </scanWindow>"#)?;
writeln!(out, r#" </scanWindowList>"#)?;
writeln!(out, r#" </scan>"#)?;
writeln!(out, r#" </scanList>"#)?;
if let Some(pre) = rec.precursor.as_ref() {
writeln!(out, r#" <precursorList count="1">"#)?;
if let Some(ref nid) = pre.precursor_native_id {
writeln!(
out,
r#" <precursor spectrumRef="{}">"#,
escape(nid)
)?;
} else {
writeln!(out, r#" <precursor>"#)?;
}
if pre.target_mz.is_some() || pre.isolation_width.is_some() {
writeln!(out, r#" <isolationWindow>"#)?;
if let Some(mz) = pre.target_mz {
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000827" name="isolation window target m/z" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
mz
)?;
}
if let Some(w) = pre.isolation_width {
let half = w / 2.0;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000828" name="isolation window lower offset" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
half
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000829" name="isolation window upper offset" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
half
)?;
}
writeln!(out, r#" </isolationWindow>"#)?;
}
if let Some(mz) = pre.selected_mz {
writeln!(out, r#" <selectedIonList count="1">"#)?;
writeln!(out, r#" <selectedIon>"#)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000744" name="selected ion m/z" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
mz
)?;
if let Some(z) = pre.charge {
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000041" name="charge state" value="{z}"/>"#
)?;
}
if let Some(i) = pre.intensity {
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000042" name="peak intensity" value="{:.6}"/>"#,
i
)?;
}
if let Some(ccs) = pre.ccs {
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1002954" name="collisional cross sectional area" value="{:.6}" unitCvRef="UO" unitAccession="UO:0000324" unitName="square angstrom"/>"#,
ccs
)?;
}
writeln!(out, r#" </selectedIon>"#)?;
writeln!(out, r#" </selectedIonList>"#)?;
}
writeln!(out, r#" <activation>"#)?;
if let Some(act) = pre.activation {
let (acc, name) = activation_cv(act, pre.analyzer);
writeln!(
out,
r#" <cvParam cvRef="MS" accession="{acc}" name="{name}" value=""/>"#
)?;
} else {
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000133" name="collision-induced dissociation" value=""/>"#
)?;
}
if let Some(e) = pre.collision_energy {
if pre.ce_is_nce {
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1002013" name="normalized collision energy" value="{:.2}"/>"#,
e
)?;
} else {
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000045" name="collision energy" value="{:.2}" unitCvRef="UO" unitAccession="UO:0000266" unitName="electronvolt"/>"#,
e
)?;
}
}
writeln!(out, r#" </activation>"#)?;
writeln!(out, r#" </precursor>"#)?;
writeln!(out, r#" </precursorList>"#)?;
}
if n_peaks > 0 {
let (compression_acc, compression_name) = compression_cv(compression);
let mz_b64 = encode_f64_array(&rec.mz, compression);
let int_b64 = encode_f32_array(&rec.intensity, compression);
let mobility_b64_opt = rec
.inv_mobility_per_peak
.as_ref()
.filter(|v| v.len() == n_peaks)
.map(|v| encode_f32_array(v, compression));
let array_count = 2 + usize::from(mobility_b64_opt.is_some());
writeln!(
out,
r#" <binaryDataArrayList count="{array_count}">"#
)?;
writeln!(
out,
r#" <binaryDataArray encodedLength="{}">"#,
mz_b64.len()
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000514" name="m/z array" value=""/>"#
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000523" name="64-bit float" value=""/>"#
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="{compression_acc}" name="{compression_name}" value=""/>"#
)?;
writeln!(out, r#" <binary>{mz_b64}</binary>"#)?;
writeln!(out, r#" </binaryDataArray>"#)?;
writeln!(
out,
r#" <binaryDataArray encodedLength="{}">"#,
int_b64.len()
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000515" name="intensity array" value=""/>"#
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000521" name="32-bit float" value=""/>"#
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="{compression_acc}" name="{compression_name}" value=""/>"#
)?;
writeln!(out, r#" <binary>{int_b64}</binary>"#)?;
writeln!(out, r#" </binaryDataArray>"#)?;
if let Some(mobility_b64) = mobility_b64_opt {
let (cv_acc, cv_name, unit_acc, unit_ref, unit_name) = match mobility_kind {
Some(MobilityArrayKind::DriftTimeMilliseconds) => (
"MS:1003007",
"raw ion mobility array",
"UO:0000028",
"UO",
"millisecond",
),
Some(MobilityArrayKind::InverseReducedVsPerCm2) | None => (
"MS:1003008",
"raw inverse reduced ion mobility array",
"MS:1002814",
"MS",
"volt-second per square centimeter",
),
};
writeln!(
out,
r#" <binaryDataArray encodedLength="{}">"#,
mobility_b64.len()
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="{cv_acc}" name="{cv_name}" value="" unitCvRef="{unit_ref}" unitAccession="{unit_acc}" unitName="{unit_name}"/>"#
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000521" name="32-bit float" value=""/>"#
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="{compression_acc}" name="{compression_name}" value=""/>"#
)?;
writeln!(out, r#" <binary>{mobility_b64}</binary>"#)?;
writeln!(out, r#" </binaryDataArray>"#)?;
}
writeln!(out, r#" </binaryDataArrayList>"#)?;
}
writeln!(out, r#" </spectrum>"#)?;
Ok(())
}
fn write_chromatogram<W: Write>(
out: &mut W,
rec: &ChromatogramRecord,
compression: Compression,
) -> Result<()> {
let n = rec.time_sec.len();
writeln!(
out,
r#" <chromatogram id="{id}" index="{idx}" defaultArrayLength="{n}">"#,
id = escape(&rec.id),
idx = rec.index,
)?;
match &rec.chromatogram_type {
Some(cv) => write_cv(out, " ", cv)?,
None => writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000235" name="total ion current chromatogram" value=""/>"#
)?,
}
if let Some(mz) = rec.precursor_mz {
writeln!(out, r#" <precursor>"#)?;
writeln!(out, r#" <isolationWindow>"#)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000827" name="isolation window target m/z" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
mz
)?;
writeln!(out, r#" </isolationWindow>"#)?;
writeln!(out, r#" <activation>"#)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000133" name="collision-induced dissociation" value=""/>"#
)?;
writeln!(out, r#" </activation>"#)?;
writeln!(out, r#" </precursor>"#)?;
}
if let Some(mz) = rec.product_mz {
writeln!(out, r#" <product>"#)?;
writeln!(out, r#" <isolationWindow>"#)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000827" name="isolation window target m/z" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
mz
)?;
writeln!(out, r#" </isolationWindow>"#)?;
writeln!(out, r#" </product>"#)?;
}
let time_min: Vec<f32> = rec.time_sec.iter().map(|&t| t / 60.0).collect();
let (compression_acc, compression_name) = compression_cv(compression);
let time_b64 = encode_f32_array(&time_min, compression);
let int_b64 = encode_f32_array(&rec.intensity, compression);
writeln!(out, r#" <binaryDataArrayList count="2">"#)?;
writeln!(
out,
r#" <binaryDataArray encodedLength="{}">"#,
time_b64.len()
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000595" name="time array" value="" unitCvRef="UO" unitAccession="UO:0000031" unitName="minute"/>"#
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000521" name="32-bit float" value=""/>"#
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="{compression_acc}" name="{compression_name}" value=""/>"#
)?;
writeln!(out, r#" <binary>{time_b64}</binary>"#)?;
writeln!(out, r#" </binaryDataArray>"#)?;
writeln!(
out,
r#" <binaryDataArray encodedLength="{}">"#,
int_b64.len()
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000515" name="intensity array" value=""/>"#
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="MS:1000521" name="32-bit float" value=""/>"#
)?;
writeln!(
out,
r#" <cvParam cvRef="MS" accession="{compression_acc}" name="{compression_name}" value=""/>"#
)?;
writeln!(out, r#" <binary>{int_b64}</binary>"#)?;
writeln!(out, r#" </binaryDataArray>"#)?;
writeln!(out, r#" </binaryDataArrayList>"#)?;
writeln!(out, r#" </chromatogram>"#)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::enums::Polarity;
#[test]
fn base64_rfc_vectors() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
assert_eq!(base64_encode(b"Man"), "TWFu");
}
struct ToySource {
start_timestamp: Option<String>,
instrument_serial_number: Option<String>,
analyzers: Vec<Analyzer>,
spectra: Vec<SpectrumRecord>,
chroms: Vec<ChromatogramRecord>,
extra_processing: Vec<(&'static str, &'static str)>,
}
impl ToySource {
fn new() -> Self {
Self {
start_timestamp: None,
instrument_serial_number: None,
analyzers: Vec::new(),
spectra: vec![minimal_spectrum(0, None)],
chroms: Vec::new(),
extra_processing: Vec::new(),
}
}
}
impl SpectrumSource for ToySource {
fn run_metadata(&self) -> RunMetadata {
RunMetadata {
source_file_name: "toy.raw".into(),
source_file_format: CvTerm::new("MS:1000563", "Thermo RAW format"),
native_id_format: CvTerm::new("MS:1000768", "Thermo nativeID format"),
instrument: CvTerm::new("MS:1001911", "Q Exactive"),
instrument_serial_number: self.instrument_serial_number.clone(),
acquisition_software_name: None,
acquisition_software_version: None,
software_name: "toy".into(),
software_version: "0.0.0".into(),
start_timestamp: self.start_timestamp.clone(),
mobility_array_kind: None,
analyzers: self.analyzers.clone(),
}
}
fn iter_spectra<'a>(&'a mut self) -> Box<dyn Iterator<Item = SpectrumRecord> + 'a> {
Box::new(self.spectra.clone().into_iter())
}
fn iter_chromatograms<'a>(
&'a mut self,
) -> Box<dyn Iterator<Item = ChromatogramRecord> + 'a> {
Box::new(self.chroms.clone().into_iter())
}
fn spectrum_count_hint(&self) -> Option<usize> {
Some(self.spectra.len())
}
fn additional_processing_steps(&self) -> Vec<(&'static str, &'static str)> {
self.extra_processing.clone()
}
}
fn minimal_spectrum(index: usize, faims_cv: Option<f64>) -> SpectrumRecord {
SpectrumRecord {
index,
scan_number: (index + 1) as u32,
native_id: format!("controllerType=0 controllerNumber=1 scan={}", index + 1),
ms_level: 1,
polarity: Some(Polarity::Positive),
scan_mode: Some(ScanMode::Centroid),
analyzer: None,
filter: None,
retention_time_sec: index as f64,
total_ion_current: None,
base_peak_mz: None,
base_peak_intensity: None,
low_mz: None,
high_mz: None,
ion_injection_time_ms: None,
inv_mobility: None,
faims_cv,
precursor: None,
mz: vec![100.0],
intensity: vec![1.0],
inv_mobility_per_peak: None,
}
}
fn tic_chromatogram(index: usize) -> ChromatogramRecord {
ChromatogramRecord {
index,
id: "TIC".into(),
chromatogram_type: Some(CvTerm::new("MS:1000235", "total ion current chromatogram")),
precursor_mz: None,
product_mz: None,
time_sec: vec![0.0, 60.0, 120.0],
intensity: vec![10.0, 20.0, 15.0],
}
}
fn srm_chromatogram(index: usize) -> ChromatogramRecord {
ChromatogramRecord {
index,
id: "SRM SIC Q1=524.3 Q3=136.1".into(),
chromatogram_type: Some(CvTerm::new(
"MS:1001473",
"selected reaction monitoring chromatogram",
)),
precursor_mz: Some(524.3),
product_mz: Some(136.1),
time_sec: vec![0.0, 30.0],
intensity: vec![5.0, 8.0],
}
}
#[test]
fn start_timestamp_emitted_when_present() {
let mut src = ToySource::new();
src.start_timestamp = Some("2026-01-01T12:00:00Z".into());
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(r#"startTimeStamp="2026-01-01T12:00:00Z""#));
}
#[test]
fn start_timestamp_omitted_when_absent() {
let mut src = ToySource::new();
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(!s.contains("startTimeStamp"));
}
#[test]
fn instrument_serial_number_emitted_when_present() {
let mut src = ToySource::new();
src.instrument_serial_number = Some("SN12345".into());
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(
r#"<cvParam cvRef="MS" accession="MS:1000529" name="instrument serial number" value="SN12345"/>"#
));
}
#[test]
fn instrument_serial_number_omitted_when_absent() {
let mut src = ToySource::new();
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(!s.contains("MS:1000529"));
}
#[test]
fn no_extra_instrument_configurations_when_analyzers_empty() {
let mut src = ToySource::new();
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(r#"<instrumentConfigurationList count="1">"#));
assert!(!s.contains("instrumentConfigurationRef"));
}
#[test]
fn no_additional_processing_steps_leaves_data_processing_list_untouched() {
let mut src = ToySource::new();
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(r#"<dataProcessingList count="1">"#));
assert!(s.contains(r#"<processingMethod order="0" softwareRef="toy">"#));
assert!(!s.contains(r#"<processingMethod order="1""#));
}
#[test]
fn additional_processing_steps_appended_in_order_after_conversion_step() {
let mut src = ToySource::new();
src.extra_processing = vec![("MS:1000035", "peak picking"), ("MS:1001485", "smoothing")];
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(r#"<dataProcessingList count="1">"#));
let conversion = s.find(r#"<processingMethod order="0""#).unwrap();
let peak_picking = s.find(r#"<processingMethod order="1""#).unwrap();
let smoothing = s.find(r#"<processingMethod order="2""#).unwrap();
assert!(conversion < peak_picking);
assert!(peak_picking < smoothing);
assert!(s.contains(
r#"<cvParam cvRef="MS" accession="MS:1000035" name="peak picking" value=""/>"#
));
assert!(
s.contains(r#"<cvParam cvRef="MS" accession="MS:1001485" name="smoothing" value=""/>"#)
);
}
#[test]
fn per_spectrum_instrument_configuration_ref_matches_analyzer() {
let mut src = ToySource::new();
src.analyzers = vec![Analyzer::FTMS, Analyzer::ITMS];
src.spectra = vec![
SpectrumRecord {
analyzer: Some(Analyzer::FTMS),
..minimal_spectrum(0, None)
},
SpectrumRecord {
index: 1,
native_id: "controllerType=0 controllerNumber=1 scan=2".into(),
analyzer: Some(Analyzer::ITMS),
..minimal_spectrum(1, None)
},
];
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(r#"<instrumentConfigurationList count="3">"#));
assert!(s.contains(r#"<instrumentConfiguration id="IC2">"#));
assert!(
s.contains(r#"<cvParam cvRef="MS" accession="MS:1000484" name="orbitrap" value=""/>"#)
);
assert!(s.contains(r#"<instrumentConfiguration id="IC3">"#));
assert!(
s.contains(r#"<cvParam cvRef="MS" accession="MS:1000264" name="ion trap" value=""/>"#)
);
assert!(s.contains(r#"instrumentConfigurationRef="IC2""#));
assert!(s.contains(r#"instrumentConfigurationRef="IC3""#));
}
#[test]
fn spectrum_with_unmatched_analyzer_gets_no_ref() {
let mut src = ToySource::new();
src.analyzers = vec![Analyzer::FTMS];
src.spectra = vec![SpectrumRecord {
analyzer: Some(Analyzer::TOFMS),
..minimal_spectrum(0, None)
}];
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(!s.contains("instrumentConfigurationRef"));
}
#[test]
fn faims_cv_emitted_when_present() {
let mut src = ToySource::new();
src.spectra = vec![minimal_spectrum(0, Some(-45.0))];
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(
r#"accession="MS:1001581" name="FAIMS compensation voltage" value="-45.0000""#
));
}
#[test]
fn faims_cv_omitted_when_absent() {
let mut src = ToySource::new();
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(!s.contains("MS:1001581"));
}
#[test]
fn ccs_emitted_on_selected_ion_when_present() {
let mut src = ToySource::new();
src.spectra = vec![SpectrumRecord {
ms_level: 2,
precursor: Some(crate::types::PrecursorInfo {
selected_mz: Some(500.5),
ccs: Some(153.4),
..Default::default()
}),
..minimal_spectrum(0, None)
}];
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(
r#"<cvParam cvRef="MS" accession="MS:1002954" name="collisional cross sectional area" value="153.400000" unitCvRef="UO" unitAccession="UO:0000324" unitName="square angstrom"/>"#
));
}
#[test]
fn ccs_omitted_when_absent() {
let mut src = ToySource::new();
src.spectra = vec![SpectrumRecord {
ms_level: 2,
precursor: Some(crate::types::PrecursorInfo {
selected_mz: Some(500.5),
..Default::default()
}),
..minimal_spectrum(0, None)
}];
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(!s.contains("MS:1002954"));
}
#[test]
fn chromatogram_list_omitted_when_source_yields_none() {
let mut src = ToySource::new();
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(!s.contains("chromatogramList"));
assert!(!s.contains("<chromatogram "));
}
#[test]
fn chromatogram_list_emitted_with_tic_and_srm() {
let mut src = ToySource::new();
src.chroms = vec![tic_chromatogram(0), srm_chromatogram(1)];
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(r#"<chromatogramList count="2" defaultDataProcessingRef="dp1">"#));
assert!(s.contains(r#"<chromatogram id="TIC" index="0" defaultArrayLength="3">"#));
assert!(s.contains(
r#"<chromatogram id="SRM SIC Q1=524.3 Q3=136.1" index="1" defaultArrayLength="2">"#
));
assert!(s.contains(r#"accession="MS:1000235" name="total ion current chromatogram""#));
assert!(s.contains(
r#"accession="MS:1001473" name="selected reaction monitoring chromatogram""#
));
assert!(s.contains("<precursor>"));
assert!(s.contains("<product>"));
let spectrum_list_end = s.find("</spectrumList>").unwrap();
let chrom_list_start = s.find("<chromatogramList").unwrap();
let run_end = s.find("</run>").unwrap();
assert!(spectrum_list_end < chrom_list_start);
assert!(chrom_list_start < run_end);
}
#[test]
fn indexed_mzml_adds_chromatogram_index_block() {
let mut src = ToySource::new();
src.chroms = vec![tic_chromatogram(0)];
let mut buf = Vec::new();
write_indexed_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(r#"<indexList count="2">"#));
assert!(s.contains(r#"<index name="spectrum">"#));
assert!(s.contains(r#"<index name="chromatogram">"#));
assert!(s.contains(r#"idRef="TIC""#));
}
#[test]
fn indexed_mzml_has_single_index_block_without_chromatograms() {
let mut src = ToySource::new();
let mut buf = Vec::new();
write_indexed_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(r#"<indexList count="1">"#));
assert!(!s.contains(r#"<index name="chromatogram">"#));
}
#[test]
fn compression_cv_selects_matching_accession() {
assert_eq!(
compression_cv(Compression::NoCompression),
("MS:1000576", "no compression")
);
assert_eq!(
compression_cv(Compression::Zlib),
("MS:1000574", "zlib compression")
);
}
#[test]
fn zlib_compress_round_trips() {
use std::io::Read;
let original = b"the quick brown fox jumps over the lazy dog, repeated: \
the quick brown fox jumps over the lazy dog";
let compressed = zlib_compress(original);
assert!(
compressed.len() < original.len(),
"zlib should shrink a repetitive payload"
);
let mut decoder = flate2::read::ZlibDecoder::new(&compressed[..]);
let mut round_tripped = Vec::new();
decoder.read_to_end(&mut round_tripped).unwrap();
assert_eq!(round_tripped, original);
}
#[test]
fn default_writer_emits_zlib_compression_not_no_compression() {
let mut src = ToySource::new();
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(r#"accession="MS:1000574" name="zlib compression""#));
assert!(!s.contains("MS:1000576"));
}
#[test]
fn indexed_default_writer_also_emits_zlib_compression() {
let mut src = ToySource::new();
let mut buf = Vec::new();
write_indexed_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(r#"accession="MS:1000574" name="zlib compression""#));
assert!(!s.contains("MS:1000576"));
}
#[test]
fn explicit_no_compression_matches_pre_1_4_layout() {
let mut src = ToySource::new();
let mut buf = Vec::new();
write_mzml_with_compression(&mut src, &mut buf, Compression::NoCompression).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains(r#"accession="MS:1000576" name="no compression""#));
assert!(!s.contains("MS:1000574"));
let expected = base64_encode(&100.0f64.to_le_bytes());
assert!(s.contains(&format!("<binary>{expected}</binary>")));
}
fn first_binary_payload(xml: &str) -> &str {
let start = xml.find("<binary>").expect("no <binary> element") + "<binary>".len();
let end = xml[start..]
.find("</binary>")
.expect("unterminated <binary>")
+ start;
&xml[start..end]
}
fn base64_decode(s: &str) -> Vec<u8> {
fn val(c: u8) -> u8 {
match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => 0,
}
}
let bytes: Vec<u8> = s.bytes().filter(|&b| b != b'=').collect();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
let n = (bytes.len() - i).min(4);
let mut chunk = [0u8; 4];
chunk[..n].copy_from_slice(&bytes[i..i + n]);
let b = ((val(chunk[0]) as u32) << 18)
| ((val(chunk[1]) as u32) << 12)
| ((val(chunk[2]) as u32) << 6)
| (val(chunk[3]) as u32);
if n > 1 {
out.push((b >> 16) as u8);
}
if n > 2 {
out.push((b >> 8) as u8);
}
if n > 3 {
out.push(b as u8);
}
i += 4;
}
out
}
#[test]
fn zlib_binary_payload_decodes_back_to_original_mz_array() {
use std::io::Read;
let mut src = ToySource::new();
let mut buf = Vec::new();
write_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
let payload = first_binary_payload(&s);
let compressed = base64_decode(payload);
let mut decoder = flate2::read::ZlibDecoder::new(&compressed[..]);
let mut raw = Vec::new();
decoder.read_to_end(&mut raw).unwrap();
assert_eq!(raw, 100.0f64.to_le_bytes());
}
#[test]
fn indexed_zlib_checksum_still_present_and_well_formed() {
let mut src = ToySource::new();
let mut buf = Vec::new();
write_indexed_mzml(&mut src, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("<indexListOffset>"));
let checksum_start = s.find("<fileChecksum>").unwrap() + "<fileChecksum>".len();
let checksum_end = s.find("</fileChecksum>").unwrap();
let hex = &s[checksum_start..checksum_end];
assert_eq!(hex.len(), 40, "SHA-1 hex digest must be 40 chars");
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
fn sha1_hex(data: &[u8]) -> String {
let mut h = Sha1::new();
h.update(data);
h.finalize().iter().map(|b| format!("{:02x}", b)).collect()
}
#[test]
fn sha1_matches_nist_fips_180_1_vectors() {
assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
assert_eq!(
sha1_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
"84983e441c3bd26ebaae4aa1f95129e5e54670f1"
);
let million_a = vec![b'a'; 1_000_000];
assert_eq!(
sha1_hex(&million_a),
"34aa973cd4c4daa4f61eeb2bdbad27316534016f"
);
}
#[test]
fn sha1_handles_padding_block_boundaries() {
assert_eq!(sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
let msg56: Vec<u8> = (0..56u8).map(|i| b'a' + (i % 26)).collect();
assert_eq!(sha1_hex(&msg56), "4ad5bb7ae3c4024768d364b77c52128ea3cffebe");
let msg64: Vec<u8> = (0..64u8).map(|i| b'a' + (i % 26)).collect();
assert_eq!(sha1_hex(&msg64), "93249d4c2f8903ebf41ac358473148ae6ddd7042");
let msg1000: Vec<u8> = (0..1000u32).map(|i| (i % 256) as u8).collect();
assert_eq!(
sha1_hex(&msg1000),
"af0b191c2de46fe13fe0908f5a6a4e90e0cafc46"
);
}
#[test]
fn sha1_streaming_updates_match_single_shot() {
let data: Vec<u8> = (0..200u32).map(|i| (i % 256) as u8).collect();
let mut whole = Sha1::new();
whole.update(&data);
let whole_digest = whole.finalize();
let mut chunked = Sha1::new();
for chunk in data.chunks(7) {
chunked.update(chunk);
}
let chunked_digest = chunked.finalize();
assert_eq!(whole_digest, chunked_digest);
}
}