Skip to main content

openmassspec_core/
mzml.rs

1//! Canonical mzML 1.1.0 writer.
2//!
3//! Consumes any [`SpectrumSource`] and emits valid mzML, with an indexed
4//! variant that also writes the `<indexList>` + `<fileChecksum>` trailer so
5//! random-access mzML readers (pyteomics, pymzml, sciex-style indexers) work.
6//!
7//! Binary data arrays (`m/z`, intensity, and per-peak ion mobility, plus
8//! chromatogram time/intensity) are zlib-compressed (`MS:1000574`) by
9//! default, matching msconvert/OpenMS output; set
10//! [`WriteOptions::with_compression`] to write uncompressed (`MS:1000576`)
11//! arrays instead.
12//!
13//! Record errors: by default the writer stops at the first `Err` the source
14//! yields and returns it, leaving a truncated document in `out`. With
15//! [`RecordErrorPolicy::Skip`] it leaves failed records out, counts them, and
16//! reports the counts in the returned [`WriteSummary`].
17//!
18//! Every PSI-MS / UO term the writer emits on its own comes from the typed
19//! constants in [`crate::cv`], so accession and name cannot disagree.
20//! Vendor-specific terms (the source-file format, the native-ID format, the
21//! instrument, chromatogram types) come from the source's
22//! [`RunMetadata`](crate::RunMetadata) and records as [`CvTerm`] values.
23
24use 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
39// ---------- byte-counting writer that also feeds a streaming SHA-1 ----------
40
41struct 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
73// ---------- minimal SHA-1 (RFC 3174) so we don't pull in a crypto dep -------
74
75struct 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
158// ---------- base64 (RFC 4648 sec 4, no wrapping) ----------------------------
159
160const 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
190// ---------- zlib (RFC 1950) + compression-aware array encoding ------------
191
192/// Compress `data` as a zlib stream. In-memory `Vec<u8>` I/O never fails, so
193/// the `expect`s here are infallible in practice.
194fn 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
202/// The `MS:1000574`/`MS:1000576` term for a `<binaryDataArray>`, matching
203/// whatever bytes [`encode_bytes`] wrote.
204fn compression_term(compression: Compression) -> Term {
205    match compression {
206        Compression::NoCompression => cv::NO_COMPRESSION,
207        Compression::Zlib => cv::ZLIB_COMPRESSION,
208    }
209}
210
211/// Base64-encode `data`, zlib-compressing it first when `compression`
212/// requests it. Pair with [`compression_term`] so the emitted cvParam always
213/// matches the bytes actually written.
214fn 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('&', "&amp;")
233        .replace('<', "&lt;")
234        .replace('>', "&gt;")
235        .replace('"', "&quot;")
236        .replace('\'', "&apos;")
237}
238
239// ---------- cvParam emission -------------------------------------------------
240
241/// `<cvParam>` for a generated term. `value` must already be XML-escaped.
242fn 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
257/// `<cvParam>` for a generated term with a unit. `value` must already be
258/// XML-escaped.
259fn 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
278/// `<cvParam value="">` for a caller-supplied [`CvTerm`].
279fn 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
293/// PSI-MS dissociation-method terms for an [`Activation`].
294///
295/// An empty slice means no PSI-MS term is a faithful match, so the writer
296/// emits no dissociation-method cvParam rather than guessing.
297fn 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        // ETD plus the combined EThcD term, so readers that only look for
302        // ETD keep working while the supplemental HCD step is recorded.
303        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        // MS:1000435 carries the exact synonyms "multiphoton dissociation"
312        // and "MPD" in the PSI-MS CV.
313        Activation::MPID => &[cv::PHOTODISSOCIATION],
314        Activation::ECD => &[cv::ELECTRON_CAPTURE_DISSOCIATION],
315        Activation::IRMPD => &[cv::INFRARED_MULTIPHOTON_DISSOCIATION],
316        // "Proton transfer decay / activated-ion" has no matching PSI-MS
317        // dissociation term, so emit nothing.
318        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
325// ---------- spectrum spool --------------------------------------------------
326
327/// Bytes of spooled `<spectrum>` elements kept in memory before the spool
328/// moves to a temporary file.
329const SPOOL_MEMORY_LIMIT: usize = 64 * 1024 * 1024;
330
331/// Holds the written `<spectrum>` elements until the final count is known,
332/// so `<spectrumList count>` can be written before them. Small runs stay in
333/// memory; larger ones spill to a temporary file that is removed on drop.
334struct Spool {
335    len: u64,
336    /// In-memory bytes allowed before spilling to a file.
337    memory_limit: usize,
338    dir: PathBuf,
339    state: SpoolState,
340}
341
342enum SpoolState {
343    Memory(Vec<u8>),
344    File(BufWriter<File>, TempPath),
345}
346
347/// A temporary file path. [`TempPath::remove`] deletes it and reports
348/// failure; dropping it without that (a write that is already returning an
349/// error) deletes it best-effort, since that error is the one to report.
350struct 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    /// Copy everything spooled into `out`.
418    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                // The reader (and the file handle) is dropped at the end of
425                // this statement, so the file can be removed on any OS.
426                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// ---------- options and summary ----------------------------------------------
466
467/// What the writer does when the source yields an `Err` record.
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
469#[non_exhaustive]
470pub enum RecordErrorPolicy {
471    /// Stop and return the error. The document in `out` is truncated.
472    #[default]
473    Fail,
474    /// Leave the failed record out and keep writing. Each skipped record is
475    /// counted in [`WriteSummary`]. The written document stays valid: the
476    /// `<spectrumList count>` / `<chromatogramList count>` attributes give
477    /// the number of elements actually written, and their `index`
478    /// attributes are consecutive from 0 in written order.
479    Skip,
480}
481
482/// Options for [`write_mzml_with_options`] and
483/// [`write_indexed_mzml_with_options`].
484///
485/// ```
486/// use openmassspec_core::{Compression, RecordErrorPolicy, WriteOptions};
487///
488/// let opts = WriteOptions::new()
489///     .with_compression(Compression::NoCompression)
490///     .with_record_error_policy(RecordErrorPolicy::Skip);
491/// assert_eq!(opts.record_error_policy, RecordErrorPolicy::Skip);
492/// ```
493#[derive(Debug, Clone, Default, PartialEq, Eq)]
494#[non_exhaustive]
495pub struct WriteOptions {
496    /// Binary-data-array codec. Defaults to [`Compression::Zlib`].
497    pub compression: Compression,
498    /// Handling of `Err` records. Defaults to [`RecordErrorPolicy::Fail`].
499    pub record_error_policy: RecordErrorPolicy,
500    /// Directory for the temporary file that holds written spectra once
501    /// they pass 64 MiB (see [`write_mzml_with_options`]). `None`, the
502    /// default, uses [`std::env::temp_dir`].
503    pub spool_dir: Option<PathBuf>,
504}
505
506impl WriteOptions {
507    /// Default options: zlib compression, stop at the first record error.
508    pub fn new() -> Self {
509        Self::default()
510    }
511
512    /// Set the binary-data-array codec.
513    #[must_use]
514    pub fn with_compression(mut self, compression: Compression) -> Self {
515        self.compression = compression;
516        self
517    }
518
519    /// Set the record-error policy.
520    #[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    /// Set the directory for the spectrum spool file.
527    #[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/// What a successful write produced.
535#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
536#[non_exhaustive]
537pub struct WriteSummary {
538    /// `<spectrum>` elements written.
539    pub spectra_written: usize,
540    /// Spectra left out because the source yielded `Err` (only with
541    /// [`RecordErrorPolicy::Skip`]).
542    pub spectra_skipped: usize,
543    /// `<chromatogram>` elements written.
544    pub chromatograms_written: usize,
545    /// Chromatograms left out because the source yielded `Err` (only with
546    /// [`RecordErrorPolicy::Skip`]).
547    pub chromatograms_skipped: usize,
548}
549
550impl WriteSummary {
551    /// Total records skipped (spectra plus chromatograms).
552    pub fn skipped(&self) -> usize {
553        self.spectra_skipped + self.chromatograms_skipped
554    }
555}
556
557// ---------- public entry points --------------------------------------------
558
559/// Write the source's spectra as mzML 1.1.0 (un-indexed) with default
560/// [`WriteOptions`]: zlib-compressed arrays, stop at the first record error.
561///
562/// # Errors
563///
564/// Returns the first `Err` the source yields, or an I/O error from `out` or
565/// the spool file. `out` then holds an incomplete document.
566pub 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
573/// Write the source's spectra as mzML 1.1.0 (un-indexed) with explicit
574/// [`WriteOptions`].
575///
576/// `<spectrumList count>` is the number of `<spectrum>` elements written,
577/// whatever [`SpectrumSource::spectrum_count_hint`] says, and each
578/// spectrum's `index` attribute is its 0-based position in the written list
579/// (the source's [`SpectrumRecord::index`] is not used; `id` carries the
580/// native id). To know the count before the list starts, the writer holds
581/// the encoded spectra until the source is exhausted: in memory up to
582/// 64 MiB, then in a temporary file in [`WriteOptions::spool_dir`] that is
583/// removed when the write ends. A large run therefore needs free space
584/// there about the size of the output.
585///
586/// # Errors
587///
588/// Returns an I/O error from `out` or the spool file, or the first `Err` the
589/// source yields unless `opts` selects [`RecordErrorPolicy::Skip`]. `out`
590/// then holds an incomplete document.
591pub 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
612/// Write the source's spectra as indexed mzML 1.1.0 (with `<indexList>` and
613/// `<fileChecksum>` trailer) with default [`WriteOptions`]: zlib-compressed
614/// arrays, stop at the first record error.
615///
616/// # Errors
617///
618/// Returns the first `Err` the source yields, or an I/O error from `out` or
619/// the spool file. `out` then holds an incomplete document.
620pub 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
627/// Write the source's spectra as indexed mzML 1.1.0 (with `<indexList>` and
628/// `<fileChecksum>` trailer) with explicit [`WriteOptions`].
629///
630/// The `<fileChecksum>` SHA-1 is computed over the bytes actually written to
631/// `out`, from the start of the document up to and including the
632/// `<fileChecksum>` open tag, as mzML 1.1 specifies. Each `<offset>` and the
633/// `<indexListOffset>` give the byte position of the element's `<`. Skipped
634/// records have no index entry. List counts, `index` attributes, and the
635/// spectrum spool work as in [`write_mzml_with_options`].
636///
637/// # Errors
638///
639/// Returns an I/O error from `out` or the spool file, or the first `Err` the
640/// source yields unless `opts` selects [`RecordErrorPolicy::Skip`]. `out`
641/// then holds an incomplete document.
642pub 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    // mzML 1.1: the SHA-1 covers the file from the start up to and
694    // including the `<fileChecksum>` open tag (as pwiz and mzdata do).
695    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
704/// Output of [`write_body`]: counts plus, for indexed output, the byte
705/// offset of every written record.
706struct Body {
707    summary: WriteSummary,
708    spectrum_offsets: Vec<(String, u64)>,
709    chromatogram_offsets: Vec<(String, u64)>,
710}
711
712/// Write everything from the XML declaration through `</run>`.
713fn 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    // Spectra go to the spool first so `<spectrumList count>` can be the
731    // number actually written. Offsets are relative to the spool until it
732    // is copied into `cw`.
733    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        // Offsets point at the element's `<`, so write the indent first.
747        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
807// ---------- prologue / spectrum body ---------------------------------------
808
809/// Indent in front of each `<spectrum>`/`<chromatogram>` open tag. Callers
810/// write it before calling [`write_spectrum`]/[`write_chromatogram`] so the
811/// indexed writer can record offsets that point at the element's `<`.
812const RECORD_INDENT: &str = "      ";
813
814/// Fixed `xs:ID` of the acquisition-software `<software>` entry.
815const ACQUISITION_SOFTWARE_ID: &str = "acquisition_software";
816
817/// The `xs:ID` values derived from run metadata.
818struct DocumentIds {
819    /// `<software id>` of the reader software, also every
820    /// `processingMethod softwareRef`.
821    software: String,
822    /// `<run id>`.
823    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
836/// Ids the writer emits as fixed literals.
837fn 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
844/// Turn `raw` into an XML NCName (as `xs:ID` requires) that `taken` rejects.
845///
846/// Characters outside `[A-Za-z0-9._-]` become `_`. `prefix` (which starts
847/// with a letter) is prepended while the result does not start with a letter
848/// or `_`, or is taken. Names that are already valid and free pass through
849/// unchanged.
850fn 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        // Non-indexed documents have a plain <mzML> root, so they reference
902        // the base schema (matching version="1.1.0"), not the indexed
903        // wrapper schema used for <indexedmzML> output.
904        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    // The MS entry names the exact psi-ms.obo release `crate::cv` was
912    // generated from; the UO terms the writer uses come from the same file.
913    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        // A fixed id (rather than one derived from `name`): no
963        // `processingMethod` references this software, so the id only has
964        // to be unique and valid.
965        writeln!(
966            out,
967            r#"    <software id="{ACQUISITION_SOFTWARE_ID}" version="{}">"#,
968            escape(meta.acquisition_software_version.as_deref().unwrap_or(""))
969        )?;
970        // Vendor acquisition software each has its own PSI-MS term, but
971        // mapping vendor-reported names to accessions would need every
972        // string verified against the CV, so use the generic term and keep
973        // the real name as the value.
974        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    // Adapters that transform spectra before they reach the writer (e.g.
1013    // `Centroided` peak-picking) contribute additional steps here so the
1014    // file's processing history stays consistent with what was actually
1015    // done to the data (see `SpectrumSource::additional_processing_steps`).
1016    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
1052/// PSI-MS mass-analyzer-component term for each [`Analyzer`] variant.
1053///
1054/// `FTMS` covers both Orbitrap and FT-ICR instruments (see the enum's own
1055/// doc comment); this maps it to `orbitrap` since that is by far the more
1056/// common physical analyzer behind a "FTMS" scan-filter token across the
1057/// vendors this crate supports. FT-ICR instruments will be mislabeled.
1058fn 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
1068/// Assigns `instrumentConfiguration` ids (`IC2`, `IC3`, ...) to
1069/// `meta.analyzers`, in order. `IC1` is reserved for the default
1070/// configuration built from `meta.instrument`.
1071fn 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
1079/// Write one `<spectrum>`. `index` is its position in the written list.
1080fn 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    // mzML stores RT in minutes by convention.
1163    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        // Unknown activation: emit no dissociation method rather than
1299        // asserting CID, which would be wrong for ETD/UVPD/... sources.
1300        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
1386/// One `<binaryDataArray>`: the array-type term (with `unit` when given),
1387/// the precision term, the compression term, and the base64 payload.
1388fn 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
1413/// Write one `<chromatogram>`. `index` is its position in the written list.
1414fn 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        // `<activation>` is required inside `<precursor>`, but
1446        // `ChromatogramRecord` carries no activation method, so leave it
1447        // empty rather than asserting CID.
1448        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    // binaryDataArrayList is required (not optional) on ChromatogramType,
1468    // unlike SpectrumType where it's dropped for zero-peak spectra.
1469    // mzML stores time arrays in minutes by convention, matching scan start
1470    // time; `time_sec` is stored in seconds per `ChromatogramRecord`'s docs.
1471    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        /// Positions in `spectra` that are yielded as `Err` instead.
1525        failing_spectra: Vec<usize>,
1526        /// Positions in `chroms` that are yielded as `Err` instead.
1527        failing_chroms: Vec<usize>,
1528        count_hint: CountHint,
1529    }
1530
1531    /// What [`ToySource`] returns from `spectrum_count_hint`.
1532    #[derive(Clone, Copy)]
1533    enum CountHint {
1534        /// `Some(spectra.len())`, failing positions included.
1535        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        // Also on the indexed path.
1727        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 &amp; 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        // sourceFile name keeps the original, unsanitized file name.
1859        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        // SRM carries precursor/product isolation windows; TIC carries neither.
2099        assert!(s.contains("<precursor>"));
2100        assert!(s.contains("<product>"));
2101        // chromatogramList must close before </run>, after </spectrumList>.
2102        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    // ---------- record errors ------------------------------------------
2173
2174    #[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        // The document stops at the failing record: no trailer.
2188        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    /// The value of `attr="..."` in `tag`.
2233    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    /// Check that `<{list} count>` equals the number of `<{elem}>` elements,
2241    /// that their `index` attributes are 0, 1, 2, ... in document order, and
2242    /// return their ids in order.
2243    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    /// Check list counts, consecutive indexes, and, for indexed mzML, that
2263    /// every `<offset>` and `<indexListOffset>` points at its element and
2264    /// the `<fileChecksum>` matches. Returns the spectrum count.
2265    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            // Source native ids are kept in `id`; `index` is renumbered.
2343            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    /// A fresh, empty spool directory under the system temp dir.
2387    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            // A 1-byte limit spills on the first write.
2418            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        // The spool file is also removed when the write fails.
2430        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    // ---------- compression ---------------------------------------------
2478
2479    #[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        // The single toy spectrum has mz = [100.0]; with no compression the
2534        // <binary> payload is exactly the base64 of its 8 little-endian
2535        // bytes.
2536        let expected = base64_encode(&100.0f64.to_le_bytes());
2537        assert!(s.contains(&format!("<binary>{expected}</binary>")));
2538    }
2539
2540    /// Extracts the text between the first `<binary>...</binary>` pair in
2541    /// `xml`, matching the m/z array (always the first `binaryDataArray` the
2542    /// writer emits for a non-empty spectrum).
2543    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    /// Minimal RFC 4648 base64 decoder, the inverse of this module's
2553    /// `base64_encode`, scoped to tests so round-trip assertions don't need
2554    /// an extra dependency.
2555    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        // ToySource's single spectrum carries mz = [100.0] (see
2606        // `minimal_spectrum`), encoded little-endian as f64.
2607        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        // fileChecksum is hashed over whatever bytes actually got written
2617        // (see write_indexed_mzml_with_compression's doc comment), so the
2618        // trailer shape doesn't change with the codec - only the byte
2619        // count feeding the hash does.
2620        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 &amp; APCI"/>"#));
2643        assert!(xml.contains(r#"<userParam name="acquisition event id" value="42"/>"#));
2644        assert!(xml.contains(r#"<userParam name="openszraw.segment" value="A &lt; 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    // ---------- Sha1 test vectors -------------------------------------------
2670    //
2671    // These vectors come from an independent implementation (Python's
2672    // `hashlib`), not from this module's own output, so they catch a broken
2673    // implementation rather than re-confirming it.
2674
2675    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        // FIPS 180-1 / RFC 3174 sample vectors.
2684        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        // Empty input.
2699        assert_eq!(sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
2700
2701        // 56 bytes: one byte short of the 64-byte block size, so the 0x80
2702        // pad byte plus the 8-byte length field must roll over into a
2703        // second block.
2704        let msg56: Vec<u8> = (0..56u8).map(|i| b'a' + (i % 26)).collect();
2705        assert_eq!(sha1_hex(&msg56), "4ad5bb7ae3c4024768d364b77c52128ea3cffebe");
2706
2707        // 64 bytes: exactly one full block, so padding alone must fill an
2708        // entire second block.
2709        let msg64: Vec<u8> = (0..64u8).map(|i| b'a' + (i % 26)).collect();
2710        assert_eq!(sha1_hex(&msg64), "93249d4c2f8903ebf41ac358473148ae6ddd7042");
2711
2712        // 1000 bytes: spans several full blocks plus a partial final block.
2713        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        // CountingWriter feeds Sha1::update() once per `write` call, so the
2723        // chunk boundaries seen in practice rarely line up with the
2724        // internal 64-byte block size. Confirm chunking doesn't change the
2725        // digest.
2726        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}