1use std::fmt::Display;
25use std::fs::{self, File, OpenOptions};
26use std::io::{self, BufWriter, Seek, SeekFrom, Write};
27use std::path::PathBuf;
28use std::sync::atomic::{AtomicU64, Ordering};
29
30use flate2::write::ZlibEncoder;
31use flate2::Compression as ZlibLevel;
32
33use crate::cv::{self, Term};
34use crate::enums::{Activation, Analyzer, Compression, MobilityArrayKind, Polarity, ScanMode};
35use crate::error::Result;
36use crate::source::SpectrumSource;
37use crate::types::{ChromatogramRecord, CvTerm, RunMetadata, SpectrumRecord};
38
39struct CountingWriter<'a, W: Write> {
42 inner: &'a mut W,
43 pos: u64,
44 sha1: Sha1,
45 hashing: bool,
46}
47
48impl<'a, W: Write> CountingWriter<'a, W> {
49 fn new(inner: &'a mut W) -> Self {
50 Self {
51 inner,
52 pos: 0,
53 sha1: Sha1::new(),
54 hashing: true,
55 }
56 }
57}
58
59impl<W: Write> Write for CountingWriter<'_, W> {
60 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
61 let n = self.inner.write(buf)?;
62 self.pos += n as u64;
63 if self.hashing {
64 self.sha1.update(&buf[..n]);
65 }
66 Ok(n)
67 }
68 fn flush(&mut self) -> std::io::Result<()> {
69 self.inner.flush()
70 }
71}
72
73struct Sha1 {
76 state: [u32; 5],
77 count: u64,
78 buf: [u8; 64],
79 buf_len: usize,
80}
81
82impl Sha1 {
83 fn new() -> Self {
84 Self {
85 state: [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0],
86 count: 0,
87 buf: [0u8; 64],
88 buf_len: 0,
89 }
90 }
91
92 fn update(&mut self, data: &[u8]) {
93 let mut off = 0;
94 while off < data.len() {
95 let space = 64 - self.buf_len;
96 let take = space.min(data.len() - off);
97 self.buf[self.buf_len..self.buf_len + take].copy_from_slice(&data[off..off + take]);
98 self.buf_len += take;
99 self.count += take as u64;
100 off += take;
101 if self.buf_len == 64 {
102 self.compress();
103 self.buf_len = 0;
104 }
105 }
106 }
107
108 fn compress(&mut self) {
109 let mut w = [0u32; 80];
110 for (i, word) in w.iter_mut().enumerate().take(16) {
111 *word = u32::from_be_bytes(self.buf[i * 4..i * 4 + 4].try_into().unwrap());
112 }
113 for i in 16..80 {
114 w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
115 }
116 let [mut a, mut b, mut c, mut d, mut e] = self.state;
117 for (i, &wi) in w.iter().enumerate() {
118 let (f, k) = match i {
119 0..=19 => ((b & c) | (!b & d), 0x5A827999u32),
120 20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
121 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
122 _ => (b ^ c ^ d, 0xCA62C1D6),
123 };
124 let temp = a
125 .rotate_left(5)
126 .wrapping_add(f)
127 .wrapping_add(e)
128 .wrapping_add(k)
129 .wrapping_add(wi);
130 e = d;
131 d = c;
132 c = b.rotate_left(30);
133 b = a;
134 a = temp;
135 }
136 self.state[0] = self.state[0].wrapping_add(a);
137 self.state[1] = self.state[1].wrapping_add(b);
138 self.state[2] = self.state[2].wrapping_add(c);
139 self.state[3] = self.state[3].wrapping_add(d);
140 self.state[4] = self.state[4].wrapping_add(e);
141 }
142
143 fn finalize(mut self) -> [u8; 20] {
144 let bit_count = self.count * 8;
145 self.update(&[0x80]);
146 while self.buf_len != 56 {
147 self.update(&[0u8]);
148 }
149 self.update(&bit_count.to_be_bytes());
150 let mut digest = [0u8; 20];
151 for (i, &word) in self.state.iter().enumerate() {
152 digest[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
153 }
154 digest
155 }
156}
157
158const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
161
162fn base64_encode(data: &[u8]) -> String {
163 let n = data.len();
164 let mut out = Vec::with_capacity(n.div_ceil(3) * 4);
165 let mut i = 0;
166 while i + 2 < n {
167 let b = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | (data[i + 2] as u32);
168 out.push(B64[((b >> 18) & 0x3f) as usize]);
169 out.push(B64[((b >> 12) & 0x3f) as usize]);
170 out.push(B64[((b >> 6) & 0x3f) as usize]);
171 out.push(B64[(b & 0x3f) as usize]);
172 i += 3;
173 }
174 if n - i == 2 {
175 let b = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8);
176 out.push(B64[((b >> 18) & 0x3f) as usize]);
177 out.push(B64[((b >> 12) & 0x3f) as usize]);
178 out.push(B64[((b >> 6) & 0x3f) as usize]);
179 out.push(b'=');
180 } else if n - i == 1 {
181 let b = (data[i] as u32) << 16;
182 out.push(B64[((b >> 18) & 0x3f) as usize]);
183 out.push(B64[((b >> 12) & 0x3f) as usize]);
184 out.push(b'=');
185 out.push(b'=');
186 }
187 String::from_utf8(out).expect("base64 output is ASCII")
188}
189
190fn zlib_compress(data: &[u8]) -> Vec<u8> {
195 let mut enc = ZlibEncoder::new(Vec::new(), ZlibLevel::default());
196 enc.write_all(data)
197 .expect("in-memory zlib compression cannot fail");
198 enc.finish()
199 .expect("in-memory zlib compression cannot fail")
200}
201
202fn compression_term(compression: Compression) -> Term {
205 match compression {
206 Compression::NoCompression => cv::NO_COMPRESSION,
207 Compression::Zlib => cv::ZLIB_COMPRESSION,
208 }
209}
210
211fn encode_bytes(data: &[u8], compression: Compression) -> String {
215 match compression {
216 Compression::NoCompression => base64_encode(data),
217 Compression::Zlib => base64_encode(&zlib_compress(data)),
218 }
219}
220
221fn encode_f64_array(vals: &[f64], compression: Compression) -> String {
222 let bytes: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
223 encode_bytes(&bytes, compression)
224}
225
226fn encode_f32_array(vals: &[f32], compression: Compression) -> String {
227 let bytes: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
228 encode_bytes(&bytes, compression)
229}
230
231fn escape(s: &str) -> String {
232 s.replace('&', "&")
233 .replace('<', "<")
234 .replace('>', ">")
235 .replace('"', """)
236 .replace('\'', "'")
237}
238
239fn cv_param<W: Write>(
243 out: &mut W,
244 indent: &str,
245 term: Term,
246 value: impl Display,
247) -> io::Result<()> {
248 writeln!(
249 out,
250 r#"{indent}<cvParam cvRef="{}" accession="{}" name="{}" value="{value}"/>"#,
251 term.cv_ref(),
252 term.accession(),
253 term.name()
254 )
255}
256
257fn cv_param_with_unit<W: Write>(
260 out: &mut W,
261 indent: &str,
262 term: Term,
263 value: impl Display,
264 unit: Term,
265) -> io::Result<()> {
266 writeln!(
267 out,
268 r#"{indent}<cvParam cvRef="{}" accession="{}" name="{}" value="{value}" unitCvRef="{}" unitAccession="{}" unitName="{}"/>"#,
269 term.cv_ref(),
270 term.accession(),
271 term.name(),
272 unit.cv_ref(),
273 unit.accession(),
274 unit.name()
275 )
276}
277
278fn cv_term_param<W: Write>(out: &mut W, indent: &str, term: &CvTerm) -> io::Result<()> {
280 let cv_ref = if term.accession.starts_with("UO:") {
281 "UO"
282 } else {
283 "MS"
284 };
285 writeln!(
286 out,
287 r#"{indent}<cvParam cvRef="{cv_ref}" accession="{}" name="{}" value=""/>"#,
288 escape(&term.accession),
289 escape(&term.name)
290 )
291}
292
293fn activation_terms(act: Activation, analyzer: Option<Analyzer>) -> &'static [Term] {
298 match act {
299 Activation::HCD => &[cv::BEAM_TYPE_COLLISION_INDUCED_DISSOCIATION],
300 Activation::ETD => &[cv::ELECTRON_TRANSFER_DISSOCIATION],
301 Activation::EThcD => &[
304 cv::ELECTRON_TRANSFER_DISSOCIATION,
305 cv::ELECTRON_TRANSFER_HIGHER_ENERGY_COLLISION_DISSOCIATION,
306 ],
307 Activation::CID => match analyzer {
308 Some(Analyzer::FTMS) => &[cv::BEAM_TYPE_COLLISION_INDUCED_DISSOCIATION],
309 _ => &[cv::COLLISION_INDUCED_DISSOCIATION],
310 },
311 Activation::MPID => &[cv::PHOTODISSOCIATION],
314 Activation::ECD => &[cv::ELECTRON_CAPTURE_DISSOCIATION],
315 Activation::IRMPD => &[cv::INFRARED_MULTIPHOTON_DISSOCIATION],
316 Activation::PD => &[],
319 Activation::PQD => &[cv::PULSED_Q_DISSOCIATION],
320 Activation::UVPD => &[cv::ULTRAVIOLET_PHOTODISSOCIATION],
321 Activation::SID => &[cv::SURFACE_INDUCED_DISSOCIATION],
322 }
323}
324
325const SPOOL_MEMORY_LIMIT: usize = 64 * 1024 * 1024;
330
331struct Spool {
335 len: u64,
336 memory_limit: usize,
338 dir: PathBuf,
339 state: SpoolState,
340}
341
342enum SpoolState {
343 Memory(Vec<u8>),
344 File(BufWriter<File>, TempPath),
345}
346
347struct TempPath(Option<PathBuf>);
351
352impl TempPath {
353 fn path(&self) -> &std::path::Path {
354 self.0.as_deref().expect("spool path present until removed")
355 }
356
357 fn remove(mut self) -> io::Result<()> {
358 let path = self.0.take().expect("spool path present until removed");
359 fs::remove_file(&path).map_err(|e| {
360 io::Error::new(
361 e.kind(),
362 format!("cannot remove spool file {}: {e}", path.display()),
363 )
364 })
365 }
366}
367
368impl Drop for TempPath {
369 fn drop(&mut self) {
370 if let Some(path) = self.0.take() {
371 let _ = fs::remove_file(path);
372 }
373 }
374}
375
376impl Spool {
377 fn new(dir: PathBuf, memory_limit: usize) -> Self {
378 Self {
379 len: 0,
380 memory_limit,
381 dir,
382 state: SpoolState::Memory(Vec::new()),
383 }
384 }
385
386 fn spill(&mut self) -> io::Result<()> {
387 static NEXT: AtomicU64 = AtomicU64::new(0);
388 let SpoolState::Memory(buf) = &mut self.state else {
389 return Ok(());
390 };
391 let nanos = std::time::SystemTime::now()
392 .duration_since(std::time::UNIX_EPOCH)
393 .map_or(0, |d| d.as_nanos());
394 let path = self.dir.join(format!(
395 "openmassspec-spool-{}-{nanos}-{}.tmp",
396 std::process::id(),
397 NEXT.fetch_add(1, Ordering::Relaxed)
398 ));
399 let file = OpenOptions::new()
400 .read(true)
401 .write(true)
402 .create_new(true)
403 .open(&path)
404 .map_err(|e| {
405 io::Error::new(
406 e.kind(),
407 format!("cannot create spool file {}: {e}", path.display()),
408 )
409 })?;
410 let path = TempPath(Some(path));
411 let mut file = BufWriter::with_capacity(1 << 20, file);
412 file.write_all(buf)?;
413 self.state = SpoolState::File(file, path);
414 Ok(())
415 }
416
417 fn copy_into<W: Write>(self, out: &mut W) -> io::Result<()> {
419 match self.state {
420 SpoolState::Memory(buf) => out.write_all(&buf),
421 SpoolState::File(file, path) => {
422 let mut file = file.into_inner().map_err(|e| e.into_error())?;
423 file.seek(SeekFrom::Start(0))?;
424 let copied = io::copy(&mut io::BufReader::with_capacity(1 << 20, file), out)?;
427 if copied != self.len {
428 return Err(io::Error::other(format!(
429 "spool file {} returned {copied} of {} bytes",
430 path.path().display(),
431 self.len
432 )));
433 }
434 path.remove()
435 }
436 }
437 }
438}
439
440impl Write for Spool {
441 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
442 if let SpoolState::Memory(mem) = &self.state {
443 if mem.len() + buf.len() > self.memory_limit {
444 self.spill()?;
445 }
446 }
447 let n = match &mut self.state {
448 SpoolState::Memory(mem) => {
449 mem.extend_from_slice(buf);
450 buf.len()
451 }
452 SpoolState::File(file, _) => file.write(buf)?,
453 };
454 self.len += n as u64;
455 Ok(n)
456 }
457 fn flush(&mut self) -> io::Result<()> {
458 match &mut self.state {
459 SpoolState::Memory(_) => Ok(()),
460 SpoolState::File(file, _) => file.flush(),
461 }
462 }
463}
464
465#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
469#[non_exhaustive]
470pub enum RecordErrorPolicy {
471 #[default]
473 Fail,
474 Skip,
480}
481
482#[derive(Debug, Clone, Default, PartialEq, Eq)]
494#[non_exhaustive]
495pub struct WriteOptions {
496 pub compression: Compression,
498 pub record_error_policy: RecordErrorPolicy,
500 pub spool_dir: Option<PathBuf>,
504}
505
506impl WriteOptions {
507 pub fn new() -> Self {
509 Self::default()
510 }
511
512 #[must_use]
514 pub fn with_compression(mut self, compression: Compression) -> Self {
515 self.compression = compression;
516 self
517 }
518
519 #[must_use]
521 pub fn with_record_error_policy(mut self, policy: RecordErrorPolicy) -> Self {
522 self.record_error_policy = policy;
523 self
524 }
525
526 #[must_use]
528 pub fn with_spool_dir(mut self, dir: impl Into<PathBuf>) -> Self {
529 self.spool_dir = Some(dir.into());
530 self
531 }
532}
533
534#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
536#[non_exhaustive]
537pub struct WriteSummary {
538 pub spectra_written: usize,
540 pub spectra_skipped: usize,
543 pub chromatograms_written: usize,
545 pub chromatograms_skipped: usize,
548}
549
550impl WriteSummary {
551 pub fn skipped(&self) -> usize {
553 self.spectra_skipped + self.chromatograms_skipped
554 }
555}
556
557pub fn write_mzml<S: SpectrumSource + ?Sized, W: Write>(
567 src: &mut S,
568 out: &mut W,
569) -> Result<WriteSummary> {
570 write_mzml_with_options(src, out, &WriteOptions::default())
571}
572
573pub fn write_mzml_with_options<S: SpectrumSource + ?Sized, W: Write>(
592 src: &mut S,
593 out: &mut W,
594 opts: &WriteOptions,
595) -> Result<WriteSummary> {
596 write_plain(src, out, opts, SPOOL_MEMORY_LIMIT)
597}
598
599fn write_plain<S: SpectrumSource + ?Sized, W: Write>(
600 src: &mut S,
601 out: &mut W,
602 opts: &WriteOptions,
603 spool_memory_limit: usize,
604) -> Result<WriteSummary> {
605 let mut cw = CountingWriter::new(out);
606 cw.hashing = false;
607 let body = write_body(src, &mut cw, opts, false, spool_memory_limit)?;
608 writeln!(cw, r#"</mzML>"#)?;
609 Ok(body.summary)
610}
611
612pub fn write_indexed_mzml<S: SpectrumSource + ?Sized, W: Write>(
621 src: &mut S,
622 out: &mut W,
623) -> Result<WriteSummary> {
624 write_indexed_mzml_with_options(src, out, &WriteOptions::default())
625}
626
627pub fn write_indexed_mzml_with_options<S: SpectrumSource + ?Sized, W: Write>(
643 src: &mut S,
644 out: &mut W,
645 opts: &WriteOptions,
646) -> Result<WriteSummary> {
647 write_indexed(src, out, opts, SPOOL_MEMORY_LIMIT)
648}
649
650fn write_indexed<S: SpectrumSource + ?Sized, W: Write>(
651 src: &mut S,
652 out: &mut W,
653 opts: &WriteOptions,
654 spool_memory_limit: usize,
655) -> Result<WriteSummary> {
656 let mut cw = CountingWriter::new(out);
657 let body = write_body(src, &mut cw, opts, true, spool_memory_limit)?;
658 writeln!(cw, r#" </mzML>"#)?;
659
660 cw.write_all(b" ")?;
661 let index_list_offset = cw.pos;
662 let n_indexes = 1 + usize::from(!body.chromatogram_offsets.is_empty());
663 writeln!(cw, r#"<indexList count="{n_indexes}">"#)?;
664 writeln!(cw, r#" <index name="spectrum">"#)?;
665 for (id, offset) in &body.spectrum_offsets {
666 writeln!(
667 cw,
668 r#" <offset idRef="{}">{}</offset>"#,
669 escape(id),
670 offset
671 )?;
672 }
673 writeln!(cw, r#" </index>"#)?;
674 if !body.chromatogram_offsets.is_empty() {
675 writeln!(cw, r#" <index name="chromatogram">"#)?;
676 for (id, offset) in &body.chromatogram_offsets {
677 writeln!(
678 cw,
679 r#" <offset idRef="{}">{}</offset>"#,
680 escape(id),
681 offset
682 )?;
683 }
684 writeln!(cw, r#" </index>"#)?;
685 }
686 writeln!(cw, r#" </indexList>"#)?;
687
688 writeln!(
689 cw,
690 r#" <indexListOffset>{}</indexListOffset>"#,
691 index_list_offset
692 )?;
693 write!(cw, r#" <fileChecksum>"#)?;
696 cw.hashing = false;
697 let digest = std::mem::replace(&mut cw.sha1, Sha1::new()).finalize();
698 let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
699 writeln!(cw, r#"{}</fileChecksum>"#, hex)?;
700 writeln!(cw, r#"</indexedmzML>"#)?;
701 Ok(body.summary)
702}
703
704struct Body {
707 summary: WriteSummary,
708 spectrum_offsets: Vec<(String, u64)>,
709 chromatogram_offsets: Vec<(String, u64)>,
710}
711
712fn write_body<S: SpectrumSource + ?Sized, W: Write>(
714 src: &mut S,
715 cw: &mut CountingWriter<'_, W>,
716 opts: &WriteOptions,
717 indexed: bool,
718 spool_memory_limit: usize,
719) -> Result<Body> {
720 let meta = src.run_metadata();
721 let ids = DocumentIds::new(&meta);
722 let mobility_kind = meta.mobility_array_kind;
723 let analyzer_ic_ids = analyzer_ic_table(&meta.analyzers);
724 let extra_processing = src.additional_processing_steps();
725 let compression = opts.compression;
726 let skip = opts.record_error_policy == RecordErrorPolicy::Skip;
727
728 write_prologue(cw, &meta, &ids, indexed, &extra_processing)?;
729
730 let spool_dir = opts.spool_dir.clone().unwrap_or_else(std::env::temp_dir);
734 let mut spool = Spool::new(spool_dir, spool_memory_limit);
735 let mut summary = WriteSummary::default();
736 let mut spectrum_offsets = Vec::new();
737 for item in src.iter_spectra() {
738 let rec = match item {
739 Ok(rec) => rec,
740 Err(_) if skip => {
741 summary.spectra_skipped += 1;
742 continue;
743 }
744 Err(e) => return Err(e),
745 };
746 spool.write_all(RECORD_INDENT.as_bytes())?;
748 if indexed {
749 spectrum_offsets.push((rec.native_id.clone(), spool.len));
750 }
751 write_spectrum(
752 &mut spool,
753 &rec,
754 summary.spectra_written,
755 mobility_kind,
756 &analyzer_ic_ids,
757 compression,
758 )?;
759 summary.spectra_written += 1;
760 }
761 writeln!(
762 cw,
763 r#" <spectrumList count="{}" defaultDataProcessingRef="dp1">"#,
764 summary.spectra_written
765 )?;
766 let spool_start = cw.pos;
767 spool.copy_into(cw)?;
768 for (_, offset) in &mut spectrum_offsets {
769 *offset += spool_start;
770 }
771 writeln!(cw, r#" </spectrumList>"#)?;
772
773 let mut chroms = Vec::new();
774 for item in src.iter_chromatograms() {
775 match item {
776 Ok(rec) => chroms.push(rec),
777 Err(_) if skip => summary.chromatograms_skipped += 1,
778 Err(e) => return Err(e),
779 }
780 }
781 let mut chromatogram_offsets = Vec::new();
782 if !chroms.is_empty() {
783 writeln!(
784 cw,
785 r#" <chromatogramList count="{}" defaultDataProcessingRef="dp1">"#,
786 chroms.len()
787 )?;
788 for rec in &chroms {
789 cw.write_all(RECORD_INDENT.as_bytes())?;
790 if indexed {
791 chromatogram_offsets.push((rec.id.clone(), cw.pos));
792 }
793 write_chromatogram(cw, rec, summary.chromatograms_written, compression)?;
794 summary.chromatograms_written += 1;
795 }
796 writeln!(cw, r#" </chromatogramList>"#)?;
797 }
798
799 writeln!(cw, r#" </run>"#)?;
800 Ok(Body {
801 summary,
802 spectrum_offsets,
803 chromatogram_offsets,
804 })
805}
806
807const RECORD_INDENT: &str = " ";
813
814const ACQUISITION_SOFTWARE_ID: &str = "acquisition_software";
816
817struct DocumentIds {
819 software: String,
822 run: String,
824}
825
826impl DocumentIds {
827 fn new(meta: &RunMetadata) -> Self {
828 let software = unique_ncname(&meta.software_name, "software_", is_fixed_id);
829 let run = unique_ncname(&meta.source_file_name, "run_", |id| {
830 is_fixed_id(id) || id == software
831 });
832 Self { software, run }
833 }
834}
835
836fn is_fixed_id(id: &str) -> bool {
838 id == "sf1"
839 || id == "dp1"
840 || id == ACQUISITION_SOFTWARE_ID
841 || (id.starts_with("IC") && id[2..].parse::<usize>().is_ok())
842}
843
844fn unique_ncname(raw: &str, prefix: &str, taken: impl Fn(&str) -> bool) -> String {
851 let mut id: String = raw
852 .chars()
853 .map(|c| {
854 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
855 c
856 } else {
857 '_'
858 }
859 })
860 .collect();
861 loop {
862 let starts_ok = id
863 .chars()
864 .next()
865 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
866 if starts_ok && !taken(&id) {
867 return id;
868 }
869 id.insert_str(0, prefix);
870 }
871}
872
873fn write_prologue<W: Write>(
874 out: &mut W,
875 meta: &RunMetadata,
876 ids: &DocumentIds,
877 indexed: bool,
878 extra_processing: &[CvTerm],
879) -> io::Result<()> {
880 writeln!(out, r#"<?xml version="1.0" encoding="utf-8"?>"#)?;
881 if indexed {
882 writeln!(out, r#"<indexedmzML xmlns="http://psi.hupo.org/ms/mzml""#)?;
883 writeln!(
884 out,
885 r#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance""#
886 )?;
887 writeln!(
888 out,
889 r#" xsi:schemaLocation="http://psi.hupo.org/ms/mzml http://psidev.info/files/ms/mzML/xsd/mzML1.1.2_idx.xsd">"#
890 )?;
891 writeln!(
892 out,
893 r#" <mzML xmlns="http://psi.hupo.org/ms/mzml" version="1.1.0">"#
894 )?;
895 } else {
896 writeln!(out, r#"<mzML xmlns="http://psi.hupo.org/ms/mzml""#)?;
897 writeln!(
898 out,
899 r#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance""#
900 )?;
901 writeln!(
905 out,
906 r#" xsi:schemaLocation="http://psi.hupo.org/ms/mzml http://psidev.info/files/ms/mzML/xsd/mzML1.1.0.xsd""#
907 )?;
908 writeln!(out, r#" version="1.1.0">"#)?;
909 }
910
911 writeln!(out, r#" <cvList count="2">"#)?;
914 writeln!(
915 out,
916 r#" <cv id="MS" fullName="Proteomics Standards Initiative Mass Spectrometry Ontology" version="{}" URI="{}"/>"#,
917 cv::PSI_MS_VERSION,
918 cv::PSI_MS_SOURCE_URL
919 )?;
920 writeln!(
921 out,
922 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"/>"#
923 )?;
924 writeln!(out, r#" </cvList>"#)?;
925
926 writeln!(out, r#" <fileDescription>"#)?;
927 writeln!(out, r#" <fileContent>"#)?;
928 cv_param(out, " ", cv::MS1_SPECTRUM, "")?;
929 cv_param(out, " ", cv::MSN_SPECTRUM, "")?;
930 writeln!(out, r#" </fileContent>"#)?;
931 writeln!(out, r#" <sourceFileList count="1">"#)?;
932 writeln!(
933 out,
934 r#" <sourceFile id="sf1" name="{}" location="">"#,
935 escape(&meta.source_file_name)
936 )?;
937 cv_term_param(out, " ", &meta.source_file_format)?;
938 cv_term_param(out, " ", &meta.native_id_format)?;
939 writeln!(out, r#" </sourceFile>"#)?;
940 writeln!(out, r#" </sourceFileList>"#)?;
941 writeln!(out, r#" </fileDescription>"#)?;
942
943 writeln!(
944 out,
945 r#" <softwareList count="{}">"#,
946 1 + usize::from(meta.acquisition_software_name.is_some())
947 )?;
948 writeln!(
949 out,
950 r#" <software id="{}" version="{}">"#,
951 ids.software,
952 escape(&meta.software_version)
953 )?;
954 cv_param(
955 out,
956 " ",
957 cv::CUSTOM_UNRELEASED_SOFTWARE_TOOL,
958 escape(&meta.software_name),
959 )?;
960 writeln!(out, r#" </software>"#)?;
961 if let Some(name) = &meta.acquisition_software_name {
962 writeln!(
966 out,
967 r#" <software id="{ACQUISITION_SOFTWARE_ID}" version="{}">"#,
968 escape(meta.acquisition_software_version.as_deref().unwrap_or(""))
969 )?;
970 cv_param(
975 out,
976 " ",
977 cv::CUSTOM_UNRELEASED_SOFTWARE_TOOL,
978 escape(name),
979 )?;
980 writeln!(out, r#" </software>"#)?;
981 }
982 writeln!(out, r#" </softwareList>"#)?;
983
984 writeln!(
985 out,
986 r#" <instrumentConfigurationList count="{}">"#,
987 1 + meta.analyzers.len()
988 )?;
989 writeln!(out, r#" <instrumentConfiguration id="IC1">"#)?;
990 cv_term_param(out, " ", &meta.instrument)?;
991 if let Some(serial) = &meta.instrument_serial_number {
992 cv_param(out, " ", cv::INSTRUMENT_SERIAL_NUMBER, escape(serial))?;
993 }
994 writeln!(out, r#" </instrumentConfiguration>"#)?;
995 for (analyzer, id) in analyzer_ic_table(&meta.analyzers) {
996 writeln!(out, r#" <instrumentConfiguration id="{id}">"#)?;
997 cv_term_param(out, " ", &meta.instrument)?;
998 cv_param(out, " ", analyzer_term(analyzer), "")?;
999 writeln!(out, r#" </instrumentConfiguration>"#)?;
1000 }
1001 writeln!(out, r#" </instrumentConfigurationList>"#)?;
1002
1003 writeln!(out, r#" <dataProcessingList count="1">"#)?;
1004 writeln!(out, r#" <dataProcessing id="dp1">"#)?;
1005 writeln!(
1006 out,
1007 r#" <processingMethod order="0" softwareRef="{}">"#,
1008 ids.software
1009 )?;
1010 cv_param(out, " ", cv::CONVERSION_TO_MZML, "")?;
1011 writeln!(out, r#" </processingMethod>"#)?;
1012 for (order, term) in extra_processing.iter().enumerate() {
1017 writeln!(
1018 out,
1019 r#" <processingMethod order="{}" softwareRef="{}">"#,
1020 order + 1,
1021 ids.software
1022 )?;
1023 cv_term_param(out, " ", term)?;
1024 writeln!(out, r#" </processingMethod>"#)?;
1025 }
1026 writeln!(out, r#" </dataProcessing>"#)?;
1027 writeln!(out, r#" </dataProcessingList>"#)?;
1028
1029 let run_id = &ids.run;
1030 match &meta.start_timestamp {
1031 Some(ts) => writeln!(
1032 out,
1033 r#" <run id="{run_id}" defaultInstrumentConfigurationRef="IC1" defaultSourceFileRef="sf1" startTimeStamp="{}">"#,
1034 escape(ts)
1035 )?,
1036 None => writeln!(
1037 out,
1038 r#" <run id="{run_id}" defaultInstrumentConfigurationRef="IC1" defaultSourceFileRef="sf1">"#
1039 )?,
1040 }
1041 for (name, value) in &meta.extra {
1042 writeln!(
1043 out,
1044 r#" <userParam name="{}" value="{}"/>"#,
1045 escape(name),
1046 escape(value)
1047 )?;
1048 }
1049 Ok(())
1050}
1051
1052fn analyzer_term(analyzer: Analyzer) -> Term {
1059 match analyzer {
1060 Analyzer::ITMS => cv::ION_TRAP,
1061 Analyzer::TQMS | Analyzer::SQMS => cv::QUADRUPOLE,
1062 Analyzer::TOFMS => cv::TIME_OF_FLIGHT,
1063 Analyzer::FTMS => cv::ORBITRAP,
1064 Analyzer::Sector => cv::MAGNETIC_SECTOR,
1065 }
1066}
1067
1068fn analyzer_ic_table(analyzers: &[Analyzer]) -> Vec<(Analyzer, String)> {
1072 analyzers
1073 .iter()
1074 .enumerate()
1075 .map(|(i, a)| (*a, format!("IC{}", i + 2)))
1076 .collect()
1077}
1078
1079fn write_spectrum<W: Write>(
1081 out: &mut W,
1082 rec: &SpectrumRecord,
1083 index: usize,
1084 mobility_kind: Option<MobilityArrayKind>,
1085 analyzer_ic_ids: &[(Analyzer, String)],
1086 compression: Compression,
1087) -> io::Result<()> {
1088 const I: &str = " ";
1089 let spectrum_type = if rec.ms_level <= 1 {
1090 cv::MS1_SPECTRUM
1091 } else {
1092 cv::MSN_SPECTRUM
1093 };
1094 let n_peaks = rec.mz.len();
1095
1096 let ic_ref = rec.analyzer.and_then(|a| {
1097 analyzer_ic_ids
1098 .iter()
1099 .find(|(candidate, _)| *candidate == a)
1100 .map(|(_, id)| id.as_str())
1101 });
1102 writeln!(
1103 out,
1104 r#"<spectrum id="{sid}" index="{idx}" defaultArrayLength="{n}">"#,
1105 sid = escape(&rec.native_id),
1106 idx = index,
1107 n = n_peaks
1108 )?;
1109 cv_param(out, I, cv::MS_LEVEL, rec.ms_level)?;
1110 cv_param(out, I, spectrum_type, "")?;
1111
1112 match rec.scan_mode {
1113 Some(ScanMode::Centroid) => cv_param(out, I, cv::CENTROID_SPECTRUM, "")?,
1114 _ => cv_param(out, I, cv::PROFILE_SPECTRUM, "")?,
1115 }
1116
1117 match rec.polarity {
1118 Some(Polarity::Positive) => cv_param(out, I, cv::POSITIVE_SCAN, "")?,
1119 Some(Polarity::Negative) => cv_param(out, I, cv::NEGATIVE_SCAN, "")?,
1120 None => {}
1121 }
1122
1123 let tic = rec.effective_tic();
1124 let (bp_mz, bp_int) = rec.effective_base_peak().unwrap_or((0.0, 0.0));
1125 let (lo_mz, hi_mz) = rec.effective_mz_range().unwrap_or((0.0, 0.0));
1126
1127 cv_param(out, I, cv::TOTAL_ION_CURRENT, format_args!("{tic:.6}"))?;
1128 cv_param(out, I, cv::BASE_PEAK_MZ, format_args!("{bp_mz:.6}"))?;
1129 cv_param(out, I, cv::BASE_PEAK_INTENSITY, format_args!("{bp_int:.6}"))?;
1130 cv_param(out, I, cv::LOWEST_OBSERVED_MZ, format_args!("{lo_mz:.6}"))?;
1131 cv_param(out, I, cv::HIGHEST_OBSERVED_MZ, format_args!("{hi_mz:.6}"))?;
1132
1133 if let Some(event_id) = rec.acquisition_event_id {
1134 writeln!(
1135 out,
1136 r#" <userParam name="acquisition event id" value="{event_id}"/>"#
1137 )?;
1138 }
1139 for (name, value) in &rec.extra {
1140 writeln!(
1141 out,
1142 r#" <userParam name="{}" value="{}"/>"#,
1143 escape(name),
1144 escape(value)
1145 )?;
1146 }
1147
1148 writeln!(out, r#" <scanList count="1">"#)?;
1149 cv_param(out, " ", cv::NO_COMBINATION, "")?;
1150 match ic_ref {
1151 Some(id) => writeln!(out, r#" <scan instrumentConfigurationRef="{id}">"#)?,
1152 None => writeln!(out, r#" <scan>"#)?,
1153 }
1154
1155 const S: &str = " ";
1156 if let Some(f) = rec.filter.as_deref() {
1157 if !f.is_empty() {
1158 cv_param(out, S, cv::FILTER_STRING, escape(f))?;
1159 }
1160 }
1161
1162 let rt_min = rec.retention_time_sec / 60.0;
1164 cv_param_with_unit(
1165 out,
1166 S,
1167 cv::SCAN_START_TIME,
1168 format_args!("{rt_min:.6}"),
1169 cv::MINUTE,
1170 )?;
1171
1172 if let Some(it) = rec.ion_injection_time_ms {
1173 cv_param_with_unit(
1174 out,
1175 S,
1176 cv::ION_INJECTION_TIME,
1177 format_args!("{it:.6}"),
1178 cv::MILLISECOND,
1179 )?;
1180 }
1181
1182 if let Some(mob) = rec.inv_mobility {
1183 cv_param_with_unit(
1184 out,
1185 S,
1186 cv::INVERSE_REDUCED_ION_MOBILITY,
1187 format_args!("{mob:.6}"),
1188 cv::VOLT_SECOND_PER_SQUARE_CENTIMETER,
1189 )?;
1190 }
1191
1192 if let Some(faims) = rec.faims_cv {
1193 cv_param_with_unit(
1194 out,
1195 S,
1196 cv::FAIMS_COMPENSATION_VOLTAGE,
1197 format_args!("{faims:.4}"),
1198 cv::VOLT,
1199 )?;
1200 }
1201
1202 writeln!(out, r#" <scanWindowList count="1">"#)?;
1203 writeln!(out, r#" <scanWindow>"#)?;
1204 const W16: &str = " ";
1205 cv_param_with_unit(
1206 out,
1207 W16,
1208 cv::SCAN_WINDOW_LOWER_LIMIT,
1209 format_args!("{lo_mz:.6}"),
1210 cv::MZ,
1211 )?;
1212 cv_param_with_unit(
1213 out,
1214 W16,
1215 cv::SCAN_WINDOW_UPPER_LIMIT,
1216 format_args!("{hi_mz:.6}"),
1217 cv::MZ,
1218 )?;
1219 writeln!(out, r#" </scanWindow>"#)?;
1220 writeln!(out, r#" </scanWindowList>"#)?;
1221 writeln!(out, r#" </scan>"#)?;
1222 writeln!(out, r#" </scanList>"#)?;
1223
1224 if let Some(pre) = rec.precursor.as_ref() {
1225 writeln!(out, r#" <precursorList count="1">"#)?;
1226 if let Some(ref nid) = pre.precursor_native_id {
1227 writeln!(
1228 out,
1229 r#" <precursor spectrumRef="{}">"#,
1230 escape(nid)
1231 )?;
1232 } else {
1233 writeln!(out, r#" <precursor>"#)?;
1234 }
1235
1236 const P: &str = " ";
1237 if pre.target_mz.is_some() || pre.isolation_width.is_some() {
1238 writeln!(out, r#" <isolationWindow>"#)?;
1239 if let Some(mz) = pre.target_mz {
1240 cv_param_with_unit(
1241 out,
1242 P,
1243 cv::ISOLATION_WINDOW_TARGET_MZ,
1244 format_args!("{mz:.6}"),
1245 cv::MZ,
1246 )?;
1247 }
1248 if let Some(w) = pre.isolation_width {
1249 let half = w / 2.0;
1250 cv_param_with_unit(
1251 out,
1252 P,
1253 cv::ISOLATION_WINDOW_LOWER_OFFSET,
1254 format_args!("{half:.6}"),
1255 cv::MZ,
1256 )?;
1257 cv_param_with_unit(
1258 out,
1259 P,
1260 cv::ISOLATION_WINDOW_UPPER_OFFSET,
1261 format_args!("{half:.6}"),
1262 cv::MZ,
1263 )?;
1264 }
1265 writeln!(out, r#" </isolationWindow>"#)?;
1266 }
1267
1268 if let Some(mz) = pre.selected_mz {
1269 writeln!(out, r#" <selectedIonList count="1">"#)?;
1270 writeln!(out, r#" <selectedIon>"#)?;
1271 cv_param_with_unit(
1272 out,
1273 W16,
1274 cv::SELECTED_ION_MZ,
1275 format_args!("{mz:.6}"),
1276 cv::MZ,
1277 )?;
1278 if let Some(z) = pre.charge {
1279 cv_param(out, W16, cv::CHARGE_STATE, z)?;
1280 }
1281 if let Some(i) = pre.intensity {
1282 cv_param(out, W16, cv::PEAK_INTENSITY, format_args!("{i:.6}"))?;
1283 }
1284 if let Some(ccs) = pre.ccs {
1285 cv_param_with_unit(
1286 out,
1287 W16,
1288 cv::COLLISIONAL_CROSS_SECTIONAL_AREA,
1289 format_args!("{ccs:.6}"),
1290 cv::SQUARE_ANGSTROM,
1291 )?;
1292 }
1293 writeln!(out, r#" </selectedIon>"#)?;
1294 writeln!(out, r#" </selectedIonList>"#)?;
1295 }
1296
1297 writeln!(out, r#" <activation>"#)?;
1298 if let Some(act) = pre.activation {
1301 for term in activation_terms(act, pre.analyzer) {
1302 cv_param(out, P, *term, "")?;
1303 }
1304 }
1305 if let Some(e) = pre.collision_energy {
1306 if pre.ce_is_nce {
1307 cv_param_with_unit(
1308 out,
1309 P,
1310 cv::NORMALIZED_COLLISION_ENERGY,
1311 format_args!("{e:.2}"),
1312 cv::PERCENT,
1313 )?;
1314 } else {
1315 cv_param_with_unit(
1316 out,
1317 P,
1318 cv::COLLISION_ENERGY,
1319 format_args!("{e:.2}"),
1320 cv::ELECTRONVOLT,
1321 )?;
1322 }
1323 }
1324 writeln!(out, r#" </activation>"#)?;
1325 writeln!(out, r#" </precursor>"#)?;
1326 writeln!(out, r#" </precursorList>"#)?;
1327 }
1328
1329 if n_peaks > 0 {
1330 let compression_cv = compression_term(compression);
1331 let mz_b64 = encode_f64_array(&rec.mz, compression);
1332 let int_b64 = encode_f32_array(&rec.intensity, compression);
1333 let mobility_b64_opt = rec
1334 .inv_mobility_per_peak
1335 .as_ref()
1336 .filter(|v| v.len() == n_peaks)
1337 .map(|v| encode_f32_array(v, compression));
1338 let array_count = 2 + usize::from(mobility_b64_opt.is_some());
1339
1340 writeln!(
1341 out,
1342 r#" <binaryDataArrayList count="{array_count}">"#
1343 )?;
1344 write_binary_array(
1345 out,
1346 &mz_b64,
1347 cv::MZ_ARRAY,
1348 None,
1349 cv::FLOAT_64_BIT,
1350 compression_cv,
1351 )?;
1352 write_binary_array(
1353 out,
1354 &int_b64,
1355 cv::INTENSITY_ARRAY,
1356 None,
1357 cv::FLOAT_32_BIT,
1358 compression_cv,
1359 )?;
1360 if let Some(mobility_b64) = mobility_b64_opt {
1361 let (array, unit) = match mobility_kind {
1362 Some(MobilityArrayKind::DriftTimeMilliseconds) => {
1363 (cv::RAW_ION_MOBILITY_ARRAY, cv::MILLISECOND)
1364 }
1365 Some(MobilityArrayKind::InverseReducedVsPerCm2) | None => (
1366 cv::RAW_INVERSE_REDUCED_ION_MOBILITY_ARRAY,
1367 cv::VOLT_SECOND_PER_SQUARE_CENTIMETER,
1368 ),
1369 };
1370 write_binary_array(
1371 out,
1372 &mobility_b64,
1373 array,
1374 Some(unit),
1375 cv::FLOAT_32_BIT,
1376 compression_cv,
1377 )?;
1378 }
1379 writeln!(out, r#" </binaryDataArrayList>"#)?;
1380 }
1381
1382 writeln!(out, r#" </spectrum>"#)?;
1383 Ok(())
1384}
1385
1386fn write_binary_array<W: Write>(
1389 out: &mut W,
1390 b64: &str,
1391 array: Term,
1392 unit: Option<Term>,
1393 precision: Term,
1394 compression: Term,
1395) -> io::Result<()> {
1396 const B: &str = " ";
1397 writeln!(
1398 out,
1399 r#" <binaryDataArray encodedLength="{}">"#,
1400 b64.len()
1401 )?;
1402 match unit {
1403 Some(unit) => cv_param_with_unit(out, B, array, "", unit)?,
1404 None => cv_param(out, B, array, "")?,
1405 }
1406 cv_param(out, B, precision, "")?;
1407 cv_param(out, B, compression, "")?;
1408 writeln!(out, r#" <binary>{b64}</binary>"#)?;
1409 writeln!(out, r#" </binaryDataArray>"#)?;
1410 Ok(())
1411}
1412
1413fn write_chromatogram<W: Write>(
1415 out: &mut W,
1416 rec: &ChromatogramRecord,
1417 index: usize,
1418 compression: Compression,
1419) -> io::Result<()> {
1420 let n = rec.time_sec.len();
1421
1422 writeln!(
1423 out,
1424 r#"<chromatogram id="{id}" index="{idx}" defaultArrayLength="{n}">"#,
1425 id = escape(&rec.id),
1426 idx = index,
1427 )?;
1428
1429 match &rec.chromatogram_type {
1430 Some(term) => cv_term_param(out, " ", term)?,
1431 None => cv_param(out, " ", cv::TOTAL_ION_CURRENT_CHROMATOGRAM, "")?,
1432 }
1433
1434 if let Some(mz) = rec.precursor_mz {
1435 writeln!(out, r#" <precursor>"#)?;
1436 writeln!(out, r#" <isolationWindow>"#)?;
1437 cv_param_with_unit(
1438 out,
1439 " ",
1440 cv::ISOLATION_WINDOW_TARGET_MZ,
1441 format_args!("{mz:.6}"),
1442 cv::MZ,
1443 )?;
1444 writeln!(out, r#" </isolationWindow>"#)?;
1445 writeln!(out, r#" <activation>"#)?;
1449 writeln!(out, r#" </activation>"#)?;
1450 writeln!(out, r#" </precursor>"#)?;
1451 }
1452
1453 if let Some(mz) = rec.product_mz {
1454 writeln!(out, r#" <product>"#)?;
1455 writeln!(out, r#" <isolationWindow>"#)?;
1456 cv_param_with_unit(
1457 out,
1458 " ",
1459 cv::ISOLATION_WINDOW_TARGET_MZ,
1460 format_args!("{mz:.6}"),
1461 cv::MZ,
1462 )?;
1463 writeln!(out, r#" </isolationWindow>"#)?;
1464 writeln!(out, r#" </product>"#)?;
1465 }
1466
1467 let time_min: Vec<f32> = rec.time_sec.iter().map(|&t| t / 60.0).collect();
1472 let compression_cv = compression_term(compression);
1473 let time_b64 = encode_f32_array(&time_min, compression);
1474 let int_b64 = encode_f32_array(&rec.intensity, compression);
1475
1476 writeln!(out, r#" <binaryDataArrayList count="2">"#)?;
1477 write_binary_array(
1478 out,
1479 &time_b64,
1480 cv::TIME_ARRAY,
1481 Some(cv::MINUTE),
1482 cv::FLOAT_32_BIT,
1483 compression_cv,
1484 )?;
1485 write_binary_array(
1486 out,
1487 &int_b64,
1488 cv::INTENSITY_ARRAY,
1489 None,
1490 cv::FLOAT_32_BIT,
1491 compression_cv,
1492 )?;
1493 writeln!(out, r#" </binaryDataArrayList>"#)?;
1494 writeln!(out, r#" </chromatogram>"#)?;
1495 Ok(())
1496}
1497
1498#[cfg(test)]
1499mod tests {
1500 use super::*;
1501 use crate::enums::Polarity;
1502
1503 #[test]
1504 fn base64_rfc_vectors() {
1505 assert_eq!(base64_encode(b""), "");
1506 assert_eq!(base64_encode(b"f"), "Zg==");
1507 assert_eq!(base64_encode(b"fo"), "Zm8=");
1508 assert_eq!(base64_encode(b"foo"), "Zm9v");
1509 assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
1510 assert_eq!(base64_encode(b"Man"), "TWFu");
1511 }
1512
1513 struct ToySource {
1514 source_file_name: String,
1515 start_timestamp: Option<String>,
1516 instrument_serial_number: Option<String>,
1517 acquisition_software_name: Option<String>,
1518 acquisition_software_version: Option<String>,
1519 analyzers: Vec<Analyzer>,
1520 extra: std::collections::BTreeMap<String, String>,
1521 spectra: Vec<SpectrumRecord>,
1522 chroms: Vec<ChromatogramRecord>,
1523 extra_processing: Vec<CvTerm>,
1524 failing_spectra: Vec<usize>,
1526 failing_chroms: Vec<usize>,
1528 count_hint: CountHint,
1529 }
1530
1531 #[derive(Clone, Copy)]
1533 enum CountHint {
1534 Len,
1536 None,
1537 Fixed(usize),
1538 }
1539
1540 fn decode_error(what: &str, i: usize) -> crate::Error {
1541 crate::Error::vendor(std::io::Error::other(format!("{what} {i} is corrupt")))
1542 }
1543
1544 impl ToySource {
1545 fn new() -> Self {
1546 Self {
1547 source_file_name: "toy.raw".into(),
1548 start_timestamp: None,
1549 instrument_serial_number: None,
1550 acquisition_software_name: None,
1551 acquisition_software_version: None,
1552 analyzers: Vec::new(),
1553 extra: Default::default(),
1554 spectra: vec![minimal_spectrum(0, None)],
1555 chroms: Vec::new(),
1556 extra_processing: Vec::new(),
1557 failing_spectra: Vec::new(),
1558 failing_chroms: Vec::new(),
1559 count_hint: CountHint::Len,
1560 }
1561 }
1562 }
1563
1564 impl SpectrumSource for ToySource {
1565 fn run_metadata(&self) -> RunMetadata {
1566 RunMetadata {
1567 source_file_name: self.source_file_name.clone(),
1568 source_file_format: CvTerm::new("MS:1000563", "Thermo RAW format"),
1569 native_id_format: CvTerm::new("MS:1000768", "Thermo nativeID format"),
1570 instrument: CvTerm::new("MS:1001911", "Q Exactive"),
1571 instrument_serial_number: self.instrument_serial_number.clone(),
1572 acquisition_software_name: self.acquisition_software_name.clone(),
1573 acquisition_software_version: self.acquisition_software_version.clone(),
1574 software_name: "toy".into(),
1575 software_version: "0.0.0".into(),
1576 start_timestamp: self.start_timestamp.clone(),
1577 mobility_array_kind: None,
1578 analyzers: self.analyzers.clone(),
1579 extra: self.extra.clone(),
1580 }
1581 }
1582
1583 fn iter_spectra<'a>(&'a mut self) -> crate::SpectrumIter<'a> {
1584 let failing = &self.failing_spectra;
1585 Box::new(self.spectra.iter().enumerate().map(move |(i, rec)| {
1586 if failing.contains(&i) {
1587 Err(decode_error("spectrum", i))
1588 } else {
1589 Ok(rec.clone())
1590 }
1591 }))
1592 }
1593
1594 fn iter_chromatograms<'a>(&'a mut self) -> crate::ChromatogramIter<'a> {
1595 let failing = &self.failing_chroms;
1596 Box::new(self.chroms.iter().enumerate().map(move |(i, rec)| {
1597 if failing.contains(&i) {
1598 Err(decode_error("chromatogram", i))
1599 } else {
1600 Ok(rec.clone())
1601 }
1602 }))
1603 }
1604
1605 fn spectrum_count_hint(&self) -> Option<usize> {
1606 match self.count_hint {
1607 CountHint::Len => Some(self.spectra.len()),
1608 CountHint::None => None,
1609 CountHint::Fixed(n) => Some(n),
1610 }
1611 }
1612
1613 fn additional_processing_steps(&self) -> Vec<CvTerm> {
1614 self.extra_processing.clone()
1615 }
1616 }
1617
1618 fn minimal_spectrum(index: usize, faims_cv: Option<f64>) -> SpectrumRecord {
1619 SpectrumRecord {
1620 index,
1621 scan_number: (index + 1) as u32,
1622 native_id: format!("controllerType=0 controllerNumber=1 scan={}", index + 1),
1623 ms_level: 1,
1624 polarity: Some(Polarity::Positive),
1625 scan_mode: Some(ScanMode::Centroid),
1626 analyzer: None,
1627 acquisition_event_id: None,
1628 filter: None,
1629 retention_time_sec: index as f64,
1630 total_ion_current: None,
1631 base_peak_mz: None,
1632 base_peak_intensity: None,
1633 low_mz: None,
1634 high_mz: None,
1635 ion_injection_time_ms: None,
1636 inv_mobility: None,
1637 faims_cv,
1638 precursor: None,
1639 mz: vec![100.0],
1640 intensity: vec![1.0],
1641 inv_mobility_per_peak: None,
1642 extra: Default::default(),
1643 }
1644 }
1645
1646 fn tic_chromatogram(index: usize) -> ChromatogramRecord {
1647 ChromatogramRecord {
1648 index,
1649 id: "TIC".into(),
1650 chromatogram_type: Some(CvTerm::new("MS:1000235", "total ion current chromatogram")),
1651 precursor_mz: None,
1652 product_mz: None,
1653 time_sec: vec![0.0, 60.0, 120.0],
1654 intensity: vec![10.0, 20.0, 15.0],
1655 }
1656 }
1657
1658 fn srm_chromatogram(index: usize) -> ChromatogramRecord {
1659 ChromatogramRecord {
1660 index,
1661 id: "SRM SIC Q1=524.3 Q3=136.1".into(),
1662 chromatogram_type: Some(CvTerm::new(
1663 "MS:1001473",
1664 "selected reaction monitoring chromatogram",
1665 )),
1666 precursor_mz: Some(524.3),
1667 product_mz: Some(136.1),
1668 time_sec: vec![0.0, 30.0],
1669 intensity: vec![5.0, 8.0],
1670 }
1671 }
1672
1673 #[test]
1674 fn start_timestamp_emitted_when_present() {
1675 let mut src = ToySource::new();
1676 src.start_timestamp = Some("2026-01-01T12:00:00Z".into());
1677 let mut buf = Vec::new();
1678 write_mzml(&mut src, &mut buf).unwrap();
1679 let s = String::from_utf8(buf).unwrap();
1680 assert!(s.contains(r#"startTimeStamp="2026-01-01T12:00:00Z""#));
1681 }
1682
1683 #[test]
1684 fn start_timestamp_omitted_when_absent() {
1685 let mut src = ToySource::new();
1686 let mut buf = Vec::new();
1687 write_mzml(&mut src, &mut buf).unwrap();
1688 let s = String::from_utf8(buf).unwrap();
1689 assert!(!s.contains("startTimeStamp"));
1690 }
1691
1692 #[test]
1693 fn instrument_serial_number_emitted_when_present() {
1694 let mut src = ToySource::new();
1695 src.instrument_serial_number = Some("SN12345".into());
1696 let mut buf = Vec::new();
1697 write_mzml(&mut src, &mut buf).unwrap();
1698 let s = String::from_utf8(buf).unwrap();
1699 assert!(s.contains(
1700 r#"<cvParam cvRef="MS" accession="MS:1000529" name="instrument serial number" value="SN12345"/>"#
1701 ));
1702 }
1703
1704 #[test]
1705 fn instrument_serial_number_omitted_when_absent() {
1706 let mut src = ToySource::new();
1707 let mut buf = Vec::new();
1708 write_mzml(&mut src, &mut buf).unwrap();
1709 let s = String::from_utf8(buf).unwrap();
1710 assert!(!s.contains("MS:1000529"));
1711 }
1712
1713 #[test]
1714 fn acquisition_software_emitted_as_second_software_entry_when_present() {
1715 let mut src = ToySource::new();
1716 src.acquisition_software_name = Some("Xcalibur".into());
1717 src.acquisition_software_version = Some("4.4.16.14".into());
1718 let mut buf = Vec::new();
1719 write_mzml(&mut src, &mut buf).unwrap();
1720 let s = String::from_utf8(buf).unwrap();
1721 assert!(s.contains(r#"<softwareList count="2">"#));
1722 assert!(s.contains(r#"<software id="acquisition_software" version="4.4.16.14">"#));
1723 assert!(s.contains(
1724 r#"<cvParam cvRef="MS" accession="MS:1000799" name="custom unreleased software tool" value="Xcalibur"/>"#
1725 ));
1726 let mut buf = Vec::new();
1728 write_indexed_mzml(&mut src, &mut buf).unwrap();
1729 let s = String::from_utf8(buf).unwrap();
1730 assert!(s.contains(r#"<software id="acquisition_software" version="4.4.16.14">"#));
1731 }
1732
1733 #[test]
1734 fn acquisition_software_omitted_when_absent() {
1735 let mut src = ToySource::new();
1736 let mut buf = Vec::new();
1737 write_mzml(&mut src, &mut buf).unwrap();
1738 let s = String::from_utf8(buf).unwrap();
1739 assert!(s.contains(r#"<softwareList count="1">"#));
1740 assert!(!s.contains("acquisition_software"));
1741 }
1742
1743 #[test]
1744 fn acquisition_software_version_defaults_to_empty_string_when_absent() {
1745 let mut src = ToySource::new();
1746 src.acquisition_software_name = Some("MassHunter".into());
1747 let mut buf = Vec::new();
1748 write_mzml(&mut src, &mut buf).unwrap();
1749 let s = String::from_utf8(buf).unwrap();
1750 assert!(s.contains(r#"<software id="acquisition_software" version="">"#));
1751 }
1752
1753 fn is_ncname(s: &str) -> bool {
1754 let mut chars = s.chars();
1755 chars
1756 .next()
1757 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1758 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
1759 }
1760
1761 fn run_id(meta: &RunMetadata) -> String {
1762 DocumentIds::new(meta).run
1763 }
1764
1765 fn software_id(name: &str) -> String {
1766 let meta = RunMetadata {
1767 software_name: name.into(),
1768 ..ToySource::new().run_metadata()
1769 };
1770 DocumentIds::new(&meta).software
1771 }
1772
1773 #[test]
1774 fn software_id_is_a_valid_ncname() {
1775 assert_eq!(software_id("opentfraw"), "opentfraw");
1776 assert_eq!(software_id("open tf raw"), "open_tf_raw");
1777 assert_eq!(software_id("2raw"), "software_2raw");
1778 assert_eq!(software_id(""), "software_");
1779 assert_eq!(software_id("sf1"), "software_sf1");
1780 assert_eq!(software_id("IC1"), "software_IC1");
1781 assert_eq!(
1782 software_id("acquisition_software"),
1783 "software_acquisition_software"
1784 );
1785 for name in ["0", "a b", "r\u{e9}ader", "a:b", "x&y", ""] {
1786 let id = software_id(name);
1787 assert!(is_ncname(&id), "{name:?} -> {id:?}");
1788 }
1789 }
1790
1791 #[test]
1792 fn software_id_and_refs_use_sanitized_name_and_value_keeps_original() {
1793 struct Named(ToySource);
1794 impl SpectrumSource for Named {
1795 fn run_metadata(&self) -> RunMetadata {
1796 RunMetadata {
1797 software_name: "1 reader & co".into(),
1798 ..self.0.run_metadata()
1799 }
1800 }
1801 fn iter_spectra<'a>(&'a mut self) -> crate::SpectrumIter<'a> {
1802 self.0.iter_spectra()
1803 }
1804 }
1805 let mut src = Named(ToySource::new());
1806 let mut buf = Vec::new();
1807 write_mzml(&mut src, &mut buf).unwrap();
1808 let s = String::from_utf8(buf).unwrap();
1809 assert!(s.contains(r#"<software id="software_1_reader___co" version="0.0.0">"#));
1810 assert!(s.contains(r#"softwareRef="software_1_reader___co""#));
1811 assert!(s.contains(r#"name="custom unreleased software tool" value="1 reader & co""#));
1812 }
1813
1814 #[test]
1815 fn run_id_does_not_collide_with_software_id() {
1816 let meta = RunMetadata {
1817 source_file_name: "reader".into(),
1818 software_name: "reader".into(),
1819 ..ToySource::new().run_metadata()
1820 };
1821 let ids = DocumentIds::new(&meta);
1822 assert_eq!(ids.software, "reader");
1823 assert_eq!(ids.run, "run_reader");
1824 }
1825
1826 #[test]
1827 fn run_id_is_a_valid_ncname() {
1828 let meta = |name: &str| RunMetadata {
1829 source_file_name: name.into(),
1830 ..ToySource::new().run_metadata()
1831 };
1832 assert_eq!(run_id(&meta("toy.raw")), "toy.raw");
1833 assert_eq!(run_id(&meta("_x.d")), "_x.d");
1834 assert_eq!(
1835 run_id(&meta("20260101_sample.raw")),
1836 "run_20260101_sample.raw"
1837 );
1838 assert_eq!(run_id(&meta("my run (1).wiff")), "my_run__1_.wiff");
1839 assert_eq!(run_id(&meta("-a")), "run_-a");
1840 assert_eq!(run_id(&meta("")), "run_");
1841 assert_eq!(run_id(&meta("dp1")), "run_dp1");
1842 assert_eq!(run_id(&meta("IC2")), "run_IC2");
1843 assert_eq!(run_id(&meta("toy")), "run_toy");
1844 for name in ["0.raw", "a b", "\u{e9}t\u{e9}.raw", "a:b", ""] {
1845 let id = run_id(&meta(name));
1846 assert!(is_ncname(&id), "{name:?} -> {id:?}");
1847 }
1848 }
1849
1850 #[test]
1851 fn run_id_uses_sanitized_name_for_digit_leading_file() {
1852 let mut src = ToySource::new();
1853 src.source_file_name = "2026 sample.raw".into();
1854 let mut buf = Vec::new();
1855 write_mzml(&mut src, &mut buf).unwrap();
1856 let s = String::from_utf8(buf).unwrap();
1857 assert!(s.contains(r#"<run id="run_2026_sample.raw" "#));
1858 assert!(s.contains(r#"<sourceFile id="sf1" name="2026 sample.raw""#));
1860 }
1861
1862 #[test]
1863 fn no_extra_instrument_configurations_when_analyzers_empty() {
1864 let mut src = ToySource::new();
1865 let mut buf = Vec::new();
1866 write_mzml(&mut src, &mut buf).unwrap();
1867 let s = String::from_utf8(buf).unwrap();
1868 assert!(s.contains(r#"<instrumentConfigurationList count="1">"#));
1869 assert!(!s.contains("instrumentConfigurationRef"));
1870 }
1871
1872 #[test]
1873 fn no_additional_processing_steps_leaves_data_processing_list_untouched() {
1874 let mut src = ToySource::new();
1875 let mut buf = Vec::new();
1876 write_mzml(&mut src, &mut buf).unwrap();
1877 let s = String::from_utf8(buf).unwrap();
1878 assert!(s.contains(r#"<dataProcessingList count="1">"#));
1879 assert!(s.contains(r#"<processingMethod order="0" softwareRef="toy">"#));
1880 assert!(!s.contains(r#"<processingMethod order="1""#));
1881 }
1882
1883 #[test]
1884 fn additional_processing_steps_appended_in_order_after_conversion_step() {
1885 let mut src = ToySource::new();
1886 src.extra_processing = vec![
1887 cv::PEAK_PICKING.into(),
1888 CvTerm::new("MS:1000592", "smoothing"),
1889 ];
1890 let mut buf = Vec::new();
1891 write_mzml(&mut src, &mut buf).unwrap();
1892 let s = String::from_utf8(buf).unwrap();
1893
1894 assert!(s.contains(r#"<dataProcessingList count="1">"#));
1895 let conversion = s.find(r#"<processingMethod order="0""#).unwrap();
1896 let peak_picking = s.find(r#"<processingMethod order="1""#).unwrap();
1897 let smoothing = s.find(r#"<processingMethod order="2""#).unwrap();
1898 assert!(conversion < peak_picking);
1899 assert!(peak_picking < smoothing);
1900 assert!(s.contains(
1901 r#"<cvParam cvRef="MS" accession="MS:1000035" name="peak picking" value=""/>"#
1902 ));
1903 assert!(
1904 s.contains(r#"<cvParam cvRef="MS" accession="MS:1000592" name="smoothing" value=""/>"#)
1905 );
1906 }
1907
1908 #[test]
1909 fn per_spectrum_instrument_configuration_ref_matches_analyzer() {
1910 let mut src = ToySource::new();
1911 src.analyzers = vec![Analyzer::FTMS, Analyzer::ITMS];
1912 src.spectra = vec![
1913 SpectrumRecord {
1914 analyzer: Some(Analyzer::FTMS),
1915 ..minimal_spectrum(0, None)
1916 },
1917 SpectrumRecord {
1918 index: 1,
1919 native_id: "controllerType=0 controllerNumber=1 scan=2".into(),
1920 analyzer: Some(Analyzer::ITMS),
1921 ..minimal_spectrum(1, None)
1922 },
1923 ];
1924 let mut buf = Vec::new();
1925 write_mzml(&mut src, &mut buf).unwrap();
1926 let s = String::from_utf8(buf).unwrap();
1927
1928 assert!(s.contains(r#"<instrumentConfigurationList count="3">"#));
1929 assert!(s.contains(r#"<instrumentConfiguration id="IC2">"#));
1930 assert!(
1931 s.contains(r#"<cvParam cvRef="MS" accession="MS:1000484" name="orbitrap" value=""/>"#)
1932 );
1933 assert!(s.contains(r#"<instrumentConfiguration id="IC3">"#));
1934 assert!(
1935 s.contains(r#"<cvParam cvRef="MS" accession="MS:1000264" name="ion trap" value=""/>"#)
1936 );
1937
1938 assert!(s.contains(r#"instrumentConfigurationRef="IC2""#));
1939 assert!(s.contains(r#"instrumentConfigurationRef="IC3""#));
1940 }
1941
1942 #[test]
1943 fn spectrum_with_unmatched_analyzer_gets_no_ref() {
1944 let mut src = ToySource::new();
1945 src.analyzers = vec![Analyzer::FTMS];
1946 src.spectra = vec![SpectrumRecord {
1947 analyzer: Some(Analyzer::TOFMS),
1948 ..minimal_spectrum(0, None)
1949 }];
1950 let mut buf = Vec::new();
1951 write_mzml(&mut src, &mut buf).unwrap();
1952 let s = String::from_utf8(buf).unwrap();
1953 assert!(!s.contains("instrumentConfigurationRef"));
1954 }
1955
1956 #[test]
1957 fn faims_cv_emitted_when_present() {
1958 let mut src = ToySource::new();
1959 src.spectra = vec![minimal_spectrum(0, Some(-45.0))];
1960 let mut buf = Vec::new();
1961 write_mzml(&mut src, &mut buf).unwrap();
1962 let s = String::from_utf8(buf).unwrap();
1963 assert!(s.contains(
1964 r#"accession="MS:1001581" name="FAIMS compensation voltage" value="-45.0000""#
1965 ));
1966 }
1967
1968 #[test]
1969 fn faims_cv_omitted_when_absent() {
1970 let mut src = ToySource::new();
1971 let mut buf = Vec::new();
1972 write_mzml(&mut src, &mut buf).unwrap();
1973 let s = String::from_utf8(buf).unwrap();
1974 assert!(!s.contains("MS:1001581"));
1975 }
1976
1977 #[test]
1978 fn ccs_emitted_on_selected_ion_when_present() {
1979 let mut src = ToySource::new();
1980 src.spectra = vec![SpectrumRecord {
1981 ms_level: 2,
1982 precursor: Some(crate::types::PrecursorInfo {
1983 selected_mz: Some(500.5),
1984 ccs: Some(153.4),
1985 ..Default::default()
1986 }),
1987 ..minimal_spectrum(0, None)
1988 }];
1989 let mut buf = Vec::new();
1990 write_mzml(&mut src, &mut buf).unwrap();
1991 let s = String::from_utf8(buf).unwrap();
1992 assert!(s.contains(
1993 r#"<cvParam cvRef="MS" accession="MS:1002954" name="collisional cross sectional area" value="153.400000" unitCvRef="UO" unitAccession="UO:0000324" unitName="square angstrom"/>"#
1994 ));
1995 }
1996
1997 #[test]
1998 fn ccs_omitted_when_absent() {
1999 let mut src = ToySource::new();
2000 src.spectra = vec![SpectrumRecord {
2001 ms_level: 2,
2002 precursor: Some(crate::types::PrecursorInfo {
2003 selected_mz: Some(500.5),
2004 ..Default::default()
2005 }),
2006 ..minimal_spectrum(0, None)
2007 }];
2008 let mut buf = Vec::new();
2009 write_mzml(&mut src, &mut buf).unwrap();
2010 let s = String::from_utf8(buf).unwrap();
2011 assert!(!s.contains("MS:1002954"));
2012 }
2013
2014 fn ms2_with(activation: Option<Activation>, nce: Option<f64>) -> String {
2015 let mut src = ToySource::new();
2016 src.spectra = vec![SpectrumRecord {
2017 ms_level: 2,
2018 precursor: Some(crate::types::PrecursorInfo {
2019 selected_mz: Some(500.5),
2020 activation,
2021 collision_energy: nce,
2022 ce_is_nce: nce.is_some(),
2023 ..Default::default()
2024 }),
2025 ..minimal_spectrum(0, None)
2026 }];
2027 let mut buf = Vec::new();
2028 write_mzml(&mut src, &mut buf).unwrap();
2029 String::from_utf8(buf).unwrap()
2030 }
2031
2032 fn activation_block(xml: &str) -> &str {
2033 let start = xml.find("<activation>").unwrap();
2034 let end = xml.find("</activation>").unwrap();
2035 &xml[start..end]
2036 }
2037
2038 #[test]
2039 fn normalized_collision_energy_uses_ms_1000138_with_percent_unit() {
2040 let s = ms2_with(Some(Activation::HCD), Some(30.0));
2041 assert!(s.contains(
2042 r#"<cvParam cvRef="MS" accession="MS:1000138" name="normalized collision energy" value="30.00" unitCvRef="UO" unitAccession="UO:0000187" unitName="percent"/>"#
2043 ));
2044 assert!(!s.contains("MS:1002013"));
2045 }
2046
2047 #[test]
2048 fn unknown_activation_emits_no_dissociation_method() {
2049 let s = ms2_with(None, None);
2050 let block = activation_block(&s);
2051 assert!(!block.contains("cvParam"), "{block}");
2052 }
2053
2054 #[test]
2055 fn activation_terms_match_psi_ms() {
2056 let cases: &[(Activation, &[&str])] = &[
2057 (Activation::SID, &["MS:1000136"]),
2058 (Activation::MPID, &["MS:1000435"]),
2059 (Activation::EThcD, &["MS:1000598", "MS:1002631"]),
2060 (Activation::PD, &[]),
2061 ];
2062 for (act, expected) in cases {
2063 let s = ms2_with(Some(*act), None);
2064 let block = activation_block(&s);
2065 assert_eq!(block.matches("<cvParam").count(), expected.len(), "{act:?}");
2066 for acc in *expected {
2067 assert!(block.contains(acc), "{act:?} missing {acc}");
2068 }
2069 }
2070 }
2071
2072 #[test]
2073 fn chromatogram_list_omitted_when_source_yields_none() {
2074 let mut src = ToySource::new();
2075 let mut buf = Vec::new();
2076 write_mzml(&mut src, &mut buf).unwrap();
2077 let s = String::from_utf8(buf).unwrap();
2078 assert!(!s.contains("chromatogramList"));
2079 assert!(!s.contains("<chromatogram "));
2080 }
2081
2082 #[test]
2083 fn chromatogram_list_emitted_with_tic_and_srm() {
2084 let mut src = ToySource::new();
2085 src.chroms = vec![tic_chromatogram(0), srm_chromatogram(1)];
2086 let mut buf = Vec::new();
2087 write_mzml(&mut src, &mut buf).unwrap();
2088 let s = String::from_utf8(buf).unwrap();
2089 assert!(s.contains(r#"<chromatogramList count="2" defaultDataProcessingRef="dp1">"#));
2090 assert!(s.contains(r#"<chromatogram id="TIC" index="0" defaultArrayLength="3">"#));
2091 assert!(s.contains(
2092 r#"<chromatogram id="SRM SIC Q1=524.3 Q3=136.1" index="1" defaultArrayLength="2">"#
2093 ));
2094 assert!(s.contains(r#"accession="MS:1000235" name="total ion current chromatogram""#));
2095 assert!(s.contains(
2096 r#"accession="MS:1001473" name="selected reaction monitoring chromatogram""#
2097 ));
2098 assert!(s.contains("<precursor>"));
2100 assert!(s.contains("<product>"));
2101 let spectrum_list_end = s.find("</spectrumList>").unwrap();
2103 let chrom_list_start = s.find("<chromatogramList").unwrap();
2104 let run_end = s.find("</run>").unwrap();
2105 assert!(spectrum_list_end < chrom_list_start);
2106 assert!(chrom_list_start < run_end);
2107 }
2108
2109 #[test]
2110 fn indexed_mzml_adds_chromatogram_index_block() {
2111 let mut src = ToySource::new();
2112 src.chroms = vec![tic_chromatogram(0)];
2113 let mut buf = Vec::new();
2114 write_indexed_mzml(&mut src, &mut buf).unwrap();
2115 let s = String::from_utf8(buf).unwrap();
2116 assert!(s.contains(r#"<indexList count="2">"#));
2117 assert!(s.contains(r#"<index name="spectrum">"#));
2118 assert!(s.contains(r#"<index name="chromatogram">"#));
2119 assert!(s.contains(r#"idRef="TIC""#));
2120 }
2121
2122 #[test]
2123 fn indexed_mzml_has_single_index_block_without_chromatograms() {
2124 let mut src = ToySource::new();
2125 let mut buf = Vec::new();
2126 write_indexed_mzml(&mut src, &mut buf).unwrap();
2127 let s = String::from_utf8(buf).unwrap();
2128 assert!(s.contains(r#"<indexList count="1">"#));
2129 assert!(!s.contains(r#"<index name="chromatogram">"#));
2130 }
2131
2132 #[test]
2133 fn index_offsets_point_at_element_open_tags() {
2134 let mut src = ToySource::new();
2135 src.spectra.push(SpectrumRecord {
2136 native_id: "controllerType=0 controllerNumber=1 scan=2".into(),
2137 ..minimal_spectrum(1, None)
2138 });
2139 src.chroms = vec![tic_chromatogram(0), srm_chromatogram(1)];
2140 let mut buf = Vec::new();
2141 write_indexed_mzml(&mut src, &mut buf).unwrap();
2142 let s = String::from_utf8(buf).unwrap();
2143
2144 let mut checked = 0;
2145 for line in s.lines() {
2146 let Some(rest) = line.trim().strip_prefix("<offset idRef=\"") else {
2147 continue;
2148 };
2149 let (id, rest) = rest.split_once('"').unwrap();
2150 let offset: usize = rest
2151 .trim_start_matches('>')
2152 .trim_end_matches("</offset>")
2153 .parse()
2154 .unwrap();
2155 let tail = &s[offset..];
2156 assert!(
2157 tail.starts_with(&format!("<spectrum id=\"{id}\""))
2158 || tail.starts_with(&format!("<chromatogram id=\"{id}\"")),
2159 "offset {offset} for {id:?} points at {:?}",
2160 &tail[..20.min(tail.len())]
2161 );
2162 checked += 1;
2163 }
2164 assert_eq!(checked, 4);
2165
2166 let start = s.find("<indexListOffset>").unwrap() + "<indexListOffset>".len();
2167 let end = s.find("</indexListOffset>").unwrap();
2168 let offset: usize = s[start..end].parse().unwrap();
2169 assert!(s[offset..].starts_with("<indexList "));
2170 }
2171
2172 #[test]
2175 fn record_error_fails_the_write_by_default() {
2176 let mut src = ToySource::new();
2177 src.spectra.push(minimal_spectrum(1, None));
2178 src.failing_spectra = vec![1];
2179 let mut buf = Vec::new();
2180 let err = write_mzml(&mut src, &mut buf).unwrap_err();
2181 assert!(matches!(err, crate::Error::Vendor(_)), "{err:?}");
2182 assert!(err.to_string().contains("spectrum 1 is corrupt"));
2183
2184 let mut buf = Vec::new();
2185 let err = write_indexed_mzml(&mut src, &mut buf).unwrap_err();
2186 assert!(err.to_string().contains("spectrum 1 is corrupt"));
2187 assert!(!String::from_utf8(buf).unwrap().contains("<indexList"));
2189 }
2190
2191 #[test]
2192 fn chromatogram_error_fails_the_write_by_default() {
2193 let mut src = ToySource::new();
2194 src.chroms = vec![tic_chromatogram(0)];
2195 src.failing_chroms = vec![0];
2196 let err = write_mzml(&mut src, &mut Vec::new()).unwrap_err();
2197 assert!(err.to_string().contains("chromatogram 0 is corrupt"));
2198 }
2199
2200 #[test]
2201 fn skip_policy_counts_and_reports_skipped_records() {
2202 let mut src = ToySource::new();
2203 src.spectra = (0..4).map(|i| minimal_spectrum(i, None)).collect();
2204 src.failing_spectra = vec![1, 3];
2205 src.chroms = vec![tic_chromatogram(0), srm_chromatogram(1)];
2206 src.failing_chroms = vec![0];
2207 let opts = WriteOptions::new().with_record_error_policy(RecordErrorPolicy::Skip);
2208
2209 for indexed in [false, true] {
2210 let mut buf = Vec::new();
2211 let summary = if indexed {
2212 write_indexed_mzml_with_options(&mut src, &mut buf, &opts).unwrap()
2213 } else {
2214 write_mzml_with_options(&mut src, &mut buf, &opts).unwrap()
2215 };
2216 assert_eq!(summary.spectra_written, 2);
2217 assert_eq!(summary.spectra_skipped, 2);
2218 assert_eq!(summary.chromatograms_written, 1);
2219 assert_eq!(summary.chromatograms_skipped, 1);
2220 assert_eq!(summary.skipped(), 3);
2221
2222 let s = String::from_utf8(buf).unwrap();
2223 assert_eq!(s.matches("<spectrum ").count(), 2);
2224 assert!(s.contains(r#"<chromatogramList count="1""#));
2225 assert!(!s.contains(r#"id="TIC""#));
2226 if indexed {
2227 assert_eq!(s.matches("<offset ").count(), 3);
2228 }
2229 }
2230 }
2231
2232 fn attr<'a>(tag: &'a str, attr: &str) -> &'a str {
2234 let key = format!(" {attr}=\"");
2235 let start = tag.find(&key).unwrap() + key.len();
2236 let len = tag[start..].find('"').unwrap();
2237 &tag[start..start + len]
2238 }
2239
2240 fn check_list(s: &str, list: &str, elem: &str) -> Vec<String> {
2244 let open = format!("<{elem} ");
2245 let tags: Vec<&str> = s
2246 .match_indices(&open)
2247 .map(|(i, _)| &s[i..i + s[i..].find('>').unwrap()])
2248 .collect();
2249 for (i, tag) in tags.iter().enumerate() {
2250 assert_eq!(attr(tag, "index"), i.to_string(), "{tag}");
2251 }
2252 match s.find(&format!("<{list} ")) {
2253 Some(i) => {
2254 let list_tag = &s[i..i + s[i..].find('>').unwrap()];
2255 assert_eq!(attr(list_tag, "count"), tags.len().to_string());
2256 }
2257 None => assert!(tags.is_empty()),
2258 }
2259 tags.iter().map(|t| attr(t, "id").to_string()).collect()
2260 }
2261
2262 fn check_document(buf: &[u8], indexed: bool) -> usize {
2266 let s = std::str::from_utf8(buf).unwrap();
2267 let spectra = check_list(s, "spectrumList", "spectrum");
2268 let chroms = check_list(s, "chromatogramList", "chromatogram");
2269 if !indexed {
2270 assert!(!s.contains("<indexList"));
2271 return spectra.len();
2272 }
2273 let mut offsets = Vec::new();
2274 for line in s.lines() {
2275 let Some(rest) = line.trim().strip_prefix("<offset idRef=\"") else {
2276 continue;
2277 };
2278 let (id, rest) = rest.split_once('"').unwrap();
2279 let offset: usize = rest
2280 .trim_start_matches('>')
2281 .trim_end_matches("</offset>")
2282 .parse()
2283 .unwrap();
2284 offsets.push((id.to_string(), offset));
2285 }
2286 assert_eq!(offsets.len(), spectra.len() + chroms.len());
2287 for ((id, offset), (elem, expected)) in offsets.iter().zip(
2288 spectra
2289 .iter()
2290 .map(|id| ("spectrum", id))
2291 .chain(chroms.iter().map(|id| ("chromatogram", id))),
2292 ) {
2293 assert_eq!(id, expected);
2294 assert!(
2295 s[*offset..].starts_with(&format!("<{elem} id=\"{id}\"")),
2296 "offset {offset} for {id:?} points at {:?}",
2297 &s[*offset..(*offset + 30).min(s.len())]
2298 );
2299 }
2300 let start = s.find("<indexListOffset>").unwrap() + "<indexListOffset>".len();
2301 let end = s.find("</indexListOffset>").unwrap();
2302 let offset: usize = s[start..end].parse().unwrap();
2303 assert!(s[offset..].starts_with("<indexList "));
2304
2305 let tag = "<fileChecksum>";
2306 let digest_start = s.find(tag).unwrap() + tag.len();
2307 let digest_end = s.find("</fileChecksum>").unwrap();
2308 assert_eq!(&s[digest_start..digest_end], sha1_hex(&buf[..digest_start]));
2309 spectra.len()
2310 }
2311
2312 fn write_with(src: &mut ToySource, opts: &WriteOptions, indexed: bool) -> Vec<u8> {
2313 let mut buf = Vec::new();
2314 if indexed {
2315 write_indexed_mzml_with_options(src, &mut buf, opts).unwrap();
2316 } else {
2317 write_mzml_with_options(src, &mut buf, opts).unwrap();
2318 }
2319 buf
2320 }
2321
2322 #[test]
2323 fn skip_policy_writes_real_counts_and_consecutive_indexes() {
2324 let mut src = ToySource::new();
2325 src.spectra = (0..6)
2326 .map(|i| {
2327 let mut r = minimal_spectrum(i, None);
2328 r.native_id = format!("controllerType=0 controllerNumber=1 scan={}", i + 1);
2329 r
2330 })
2331 .collect();
2332 src.failing_spectra = vec![0, 2, 3];
2333 src.chroms = vec![tic_chromatogram(0), srm_chromatogram(1)];
2334 src.failing_chroms = vec![0];
2335 let opts = WriteOptions::new().with_record_error_policy(RecordErrorPolicy::Skip);
2336
2337 for indexed in [false, true] {
2338 let buf = write_with(&mut src, &opts, indexed);
2339 assert_eq!(check_document(&buf, indexed), 3);
2340 let s = String::from_utf8(buf).unwrap();
2341 assert!(s.contains(r#"<spectrumList count="3" "#));
2342 assert!(s.contains(
2344 r#"<spectrum id="controllerType=0 controllerNumber=1 scan=2" index="0" "#
2345 ));
2346 assert!(s.contains(
2347 r#"<spectrum id="controllerType=0 controllerNumber=1 scan=6" index="2" "#
2348 ));
2349 assert!(s.contains(r#"<chromatogramList count="1" "#));
2350 assert!(s.contains(r#"<chromatogram id="SRM SIC Q1=524.3 Q3=136.1" index="0" "#));
2351 }
2352 }
2353
2354 #[test]
2355 fn spectrum_count_ignores_missing_or_wrong_hint() {
2356 for hint in [CountHint::None, CountHint::Fixed(1), CountHint::Fixed(99)] {
2357 let mut src = ToySource::new();
2358 src.spectra = (0..3)
2359 .map(|i| {
2360 let mut r = minimal_spectrum(i, None);
2361 r.native_id = format!("scan={}", i + 1);
2362 r
2363 })
2364 .collect();
2365 src.count_hint = hint;
2366 for indexed in [false, true] {
2367 let buf = write_with(&mut src, &WriteOptions::new(), indexed);
2368 assert_eq!(check_document(&buf, indexed), 3);
2369 }
2370 }
2371 }
2372
2373 #[test]
2374 fn empty_source_writes_zero_count() {
2375 let mut src = ToySource::new();
2376 src.spectra.clear();
2377 src.count_hint = CountHint::Fixed(5);
2378 for indexed in [false, true] {
2379 let buf = write_with(&mut src, &WriteOptions::new(), indexed);
2380 assert_eq!(check_document(&buf, indexed), 0);
2381 let s = String::from_utf8(buf).unwrap();
2382 assert!(s.contains(r#"<spectrumList count="0" "#));
2383 }
2384 }
2385
2386 fn spool_test_dir(name: &str) -> PathBuf {
2388 let dir = std::env::temp_dir().join(format!(
2389 "openmassspec-spool-test-{name}-{}",
2390 std::process::id()
2391 ));
2392 let _ = fs::remove_dir_all(&dir);
2393 fs::create_dir_all(&dir).unwrap();
2394 dir
2395 }
2396
2397 #[test]
2398 fn spilled_spool_matches_in_memory_output_and_is_removed() {
2399 let dir = spool_test_dir("spill");
2400 let mut src = ToySource::new();
2401 src.spectra = (0..5)
2402 .map(|i| {
2403 let mut r = minimal_spectrum(i, None);
2404 r.native_id = format!("scan={}", i + 1);
2405 r
2406 })
2407 .collect();
2408 src.failing_spectra = vec![1];
2409 src.chroms = vec![tic_chromatogram(0)];
2410 let opts = WriteOptions::new()
2411 .with_record_error_policy(RecordErrorPolicy::Skip)
2412 .with_spool_dir(&dir);
2413
2414 for indexed in [false, true] {
2415 let in_memory = write_with(&mut src, &opts, indexed);
2416 let mut spilled = Vec::new();
2417 let summary = if indexed {
2419 write_indexed(&mut src, &mut spilled, &opts, 1).unwrap()
2420 } else {
2421 write_plain(&mut src, &mut spilled, &opts, 1).unwrap()
2422 };
2423 assert_eq!(summary.spectra_written, 4);
2424 assert_eq!(spilled, in_memory);
2425 assert_eq!(check_document(&spilled, indexed), 4);
2426 assert_eq!(fs::read_dir(&dir).unwrap().count(), 0, "spool file left");
2427 }
2428
2429 let fail = WriteOptions::new().with_spool_dir(&dir);
2431 assert!(write_plain(&mut src, &mut Vec::new(), &fail, 1).is_err());
2432 assert_eq!(fs::read_dir(&dir).unwrap().count(), 0, "spool file left");
2433 fs::remove_dir_all(&dir).unwrap();
2434 }
2435
2436 #[test]
2437 fn unusable_spool_dir_is_an_io_error() {
2438 let dir = spool_test_dir("missing").join("does-not-exist");
2439 let mut src = ToySource::new();
2440 let opts = WriteOptions::new().with_spool_dir(&dir);
2441 let err = write_plain(&mut src, &mut Vec::new(), &opts, 1).unwrap_err();
2442 assert!(matches!(err, crate::Error::Io(_)), "{err:?}");
2443 assert!(err.to_string().contains("spool file"), "{err}");
2444 fs::remove_dir_all(dir.parent().unwrap()).unwrap();
2445 }
2446
2447 #[test]
2448 fn summary_counts_everything_when_nothing_fails() {
2449 let mut src = ToySource::new();
2450 src.chroms = vec![tic_chromatogram(0)];
2451 let summary = write_indexed_mzml(&mut src, &mut Vec::new()).unwrap();
2452 assert_eq!(summary.spectra_written, 1);
2453 assert_eq!(summary.chromatograms_written, 1);
2454 assert_eq!(summary.skipped(), 0);
2455 }
2456
2457 #[test]
2458 fn generated_term_names_need_no_xml_escaping() {
2459 for t in cv::ALL {
2460 assert_eq!(escape(t.name()), t.name(), "{}", t.accession());
2461 }
2462 }
2463
2464 #[test]
2465 fn cv_list_names_the_generated_obo_release() {
2466 let mut src = ToySource::new();
2467 let mut buf = Vec::new();
2468 write_mzml(&mut src, &mut buf).unwrap();
2469 let s = String::from_utf8(buf).unwrap();
2470 assert!(s.contains(&format!(
2471 r#"version="{}" URI="{}""#,
2472 cv::PSI_MS_VERSION,
2473 cv::PSI_MS_SOURCE_URL
2474 )));
2475 }
2476
2477 #[test]
2480 fn compression_term_selects_matching_accession() {
2481 assert_eq!(
2482 compression_term(Compression::NoCompression),
2483 cv::NO_COMPRESSION
2484 );
2485 assert_eq!(compression_term(Compression::Zlib), cv::ZLIB_COMPRESSION);
2486 }
2487
2488 #[test]
2489 fn zlib_compress_round_trips() {
2490 use std::io::Read;
2491 let original = b"the quick brown fox jumps over the lazy dog, repeated: \
2492 the quick brown fox jumps over the lazy dog";
2493 let compressed = zlib_compress(original);
2494 assert!(
2495 compressed.len() < original.len(),
2496 "zlib should shrink a repetitive payload"
2497 );
2498 let mut decoder = flate2::read::ZlibDecoder::new(&compressed[..]);
2499 let mut round_tripped = Vec::new();
2500 decoder.read_to_end(&mut round_tripped).unwrap();
2501 assert_eq!(round_tripped, original);
2502 }
2503
2504 #[test]
2505 fn default_writer_emits_zlib_compression_not_no_compression() {
2506 let mut src = ToySource::new();
2507 let mut buf = Vec::new();
2508 write_mzml(&mut src, &mut buf).unwrap();
2509 let s = String::from_utf8(buf).unwrap();
2510 assert!(s.contains(r#"accession="MS:1000574" name="zlib compression""#));
2511 assert!(!s.contains("MS:1000576"));
2512 }
2513
2514 #[test]
2515 fn indexed_default_writer_also_emits_zlib_compression() {
2516 let mut src = ToySource::new();
2517 let mut buf = Vec::new();
2518 write_indexed_mzml(&mut src, &mut buf).unwrap();
2519 let s = String::from_utf8(buf).unwrap();
2520 assert!(s.contains(r#"accession="MS:1000574" name="zlib compression""#));
2521 assert!(!s.contains("MS:1000576"));
2522 }
2523
2524 #[test]
2525 fn explicit_no_compression_writes_raw_little_endian_arrays() {
2526 let mut src = ToySource::new();
2527 let mut buf = Vec::new();
2528 let opts = WriteOptions::new().with_compression(Compression::NoCompression);
2529 write_mzml_with_options(&mut src, &mut buf, &opts).unwrap();
2530 let s = String::from_utf8(buf).unwrap();
2531 assert!(s.contains(r#"accession="MS:1000576" name="no compression""#));
2532 assert!(!s.contains("MS:1000574"));
2533 let expected = base64_encode(&100.0f64.to_le_bytes());
2537 assert!(s.contains(&format!("<binary>{expected}</binary>")));
2538 }
2539
2540 fn first_binary_payload(xml: &str) -> &str {
2544 let start = xml.find("<binary>").expect("no <binary> element") + "<binary>".len();
2545 let end = xml[start..]
2546 .find("</binary>")
2547 .expect("unterminated <binary>")
2548 + start;
2549 &xml[start..end]
2550 }
2551
2552 fn base64_decode(s: &str) -> Vec<u8> {
2556 fn val(c: u8) -> u8 {
2557 match c {
2558 b'A'..=b'Z' => c - b'A',
2559 b'a'..=b'z' => c - b'a' + 26,
2560 b'0'..=b'9' => c - b'0' + 52,
2561 b'+' => 62,
2562 b'/' => 63,
2563 _ => 0,
2564 }
2565 }
2566 let bytes: Vec<u8> = s.bytes().filter(|&b| b != b'=').collect();
2567 let mut out = Vec::new();
2568 let mut i = 0;
2569 while i < bytes.len() {
2570 let n = (bytes.len() - i).min(4);
2571 let mut chunk = [0u8; 4];
2572 chunk[..n].copy_from_slice(&bytes[i..i + n]);
2573 let b = ((val(chunk[0]) as u32) << 18)
2574 | ((val(chunk[1]) as u32) << 12)
2575 | ((val(chunk[2]) as u32) << 6)
2576 | (val(chunk[3]) as u32);
2577 if n > 1 {
2578 out.push((b >> 16) as u8);
2579 }
2580 if n > 2 {
2581 out.push((b >> 8) as u8);
2582 }
2583 if n > 3 {
2584 out.push(b as u8);
2585 }
2586 i += 4;
2587 }
2588 out
2589 }
2590
2591 #[test]
2592 fn zlib_binary_payload_decodes_back_to_original_mz_array() {
2593 use std::io::Read;
2594 let mut src = ToySource::new();
2595 let mut buf = Vec::new();
2596 write_mzml(&mut src, &mut buf).unwrap();
2597 let s = String::from_utf8(buf).unwrap();
2598
2599 let payload = first_binary_payload(&s);
2600 let compressed = base64_decode(payload);
2601 let mut decoder = flate2::read::ZlibDecoder::new(&compressed[..]);
2602 let mut raw = Vec::new();
2603 decoder.read_to_end(&mut raw).unwrap();
2604
2605 assert_eq!(raw, 100.0f64.to_le_bytes());
2608 }
2609
2610 #[test]
2611 fn indexed_zlib_checksum_still_present_and_well_formed() {
2612 let mut src = ToySource::new();
2613 let mut buf = Vec::new();
2614 write_indexed_mzml(&mut src, &mut buf).unwrap();
2615 let s = String::from_utf8(buf).unwrap();
2616 assert!(s.contains("<indexListOffset>"));
2621 let checksum_start = s.find("<fileChecksum>").unwrap() + "<fileChecksum>".len();
2622 let checksum_end = s.find("</fileChecksum>").unwrap();
2623 let hex = &s[checksum_start..checksum_end];
2624 assert_eq!(hex.len(), 40, "SHA-1 hex digest must be 40 chars");
2625 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
2626 }
2627
2628 #[test]
2629 fn projects_vendor_extras_and_acquisition_event_id_as_user_params() {
2630 let mut src = ToySource::new();
2631 src.extra
2632 .insert("openwraw.source_type".into(), "ESI & APCI".into());
2633 src.spectra[0].acquisition_event_id = Some(42);
2634 src.spectra[0]
2635 .extra
2636 .insert("openszraw.segment".into(), "A < B".into());
2637
2638 let mut buf = Vec::new();
2639 write_mzml(&mut src, &mut buf).unwrap();
2640 let xml = String::from_utf8(buf).unwrap();
2641
2642 assert!(xml.contains(r#"<userParam name="openwraw.source_type" value="ESI & APCI"/>"#));
2643 assert!(xml.contains(r#"<userParam name="acquisition event id" value="42"/>"#));
2644 assert!(xml.contains(r#"<userParam name="openszraw.segment" value="A < B"/>"#));
2645
2646 let run_extra = xml
2647 .find(r#"<userParam name="openwraw.source_type""#)
2648 .unwrap();
2649 let spectrum_list = xml.find("<spectrumList ").unwrap();
2650 assert!(run_extra < spectrum_list);
2651
2652 let spectrum_start = xml.find("<spectrum ").unwrap();
2653 let spectrum_xml = &xml[spectrum_start..];
2654 let last_spectrum_cv = spectrum_xml
2655 .find(r#"<cvParam cvRef="MS" accession="MS:1000527""#)
2656 .unwrap();
2657 let event_id = spectrum_xml
2658 .find(r#"<userParam name="acquisition event id""#)
2659 .unwrap();
2660 let spectrum_extra = spectrum_xml
2661 .find(r#"<userParam name="openszraw.segment""#)
2662 .unwrap();
2663 let scan_list = spectrum_xml.find("<scanList ").unwrap();
2664 assert!(last_spectrum_cv < event_id);
2665 assert!(event_id < spectrum_extra);
2666 assert!(spectrum_extra < scan_list);
2667 }
2668
2669 fn sha1_hex(data: &[u8]) -> String {
2676 let mut h = Sha1::new();
2677 h.update(data);
2678 h.finalize().iter().map(|b| format!("{:02x}", b)).collect()
2679 }
2680
2681 #[test]
2682 fn sha1_matches_nist_fips_180_1_vectors() {
2683 assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
2685 assert_eq!(
2686 sha1_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
2687 "84983e441c3bd26ebaae4aa1f95129e5e54670f1"
2688 );
2689 let million_a = vec![b'a'; 1_000_000];
2690 assert_eq!(
2691 sha1_hex(&million_a),
2692 "34aa973cd4c4daa4f61eeb2bdbad27316534016f"
2693 );
2694 }
2695
2696 #[test]
2697 fn sha1_handles_padding_block_boundaries() {
2698 assert_eq!(sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
2700
2701 let msg56: Vec<u8> = (0..56u8).map(|i| b'a' + (i % 26)).collect();
2705 assert_eq!(sha1_hex(&msg56), "4ad5bb7ae3c4024768d364b77c52128ea3cffebe");
2706
2707 let msg64: Vec<u8> = (0..64u8).map(|i| b'a' + (i % 26)).collect();
2710 assert_eq!(sha1_hex(&msg64), "93249d4c2f8903ebf41ac358473148ae6ddd7042");
2711
2712 let msg1000: Vec<u8> = (0..1000u32).map(|i| (i % 256) as u8).collect();
2714 assert_eq!(
2715 sha1_hex(&msg1000),
2716 "af0b191c2de46fe13fe0908f5a6a4e90e0cafc46"
2717 );
2718 }
2719
2720 #[test]
2721 fn sha1_streaming_updates_match_single_shot() {
2722 let data: Vec<u8> = (0..200u32).map(|i| (i % 256) as u8).collect();
2727
2728 let mut whole = Sha1::new();
2729 whole.update(&data);
2730 let whole_digest = whole.finalize();
2731
2732 let mut chunked = Sha1::new();
2733 for chunk in data.chunks(7) {
2734 chunked.update(chunk);
2735 }
2736 let chunked_digest = chunked.finalize();
2737
2738 assert_eq!(whole_digest, chunked_digest);
2739 }
2740}