Skip to main content

openmassspec_io/
lib.rs

1//! `openmassspec-io` is the umbrella crate that ties together the open
2//! Rust mass-spec parsers (`opentfraw`, `opentimstdf`, `openwraw`,
3//! `openaraw`, `opensxraw`) behind a uniform vendor-detection +
4//! mzML-conversion API.
5//!
6//! Each vendor parser is gated behind a Cargo feature
7//! (`thermo`, `bruker`, `waters`, `agilent`, `sciex`) and re-exported
8//! under [`vendor`]. The `all` meta-feature pulls in every supported
9//! vendor.
10//!
11//! Even with no features enabled, [`detect_format`] is available so
12//! callers can probe a path without paying the compile-time cost of a
13//! parser they will not use.
14//!
15//! [`collect`], [`convert_to_mzml`], and [`convert_to_mzml_writer`] each
16//! have a `_centroided` sibling that centroids every profile-mode
17//! spectrum first via [`openmassspec_core::Centroided`]; this is always
18//! opt-in, never the default.
19
20#![forbid(unsafe_code)]
21
22use std::path::{Path, PathBuf};
23
24mod error;
25pub use error::{Error, Result};
26
27pub use openmassspec_core as core;
28
29#[cfg(feature = "arrow")]
30pub use openmassspec_core::arrow;
31
32/// Re-exports of each vendor parser, gated by feature.
33pub mod vendor {
34    #[cfg(feature = "agilent")]
35    pub use openaraw;
36    #[cfg(feature = "sciex")]
37    pub use opensxraw;
38    #[cfg(feature = "thermo")]
39    pub use opentfraw;
40    #[cfg(feature = "bruker")]
41    pub use opentimstdf;
42    #[cfg(feature = "waters")]
43    pub use openwraw;
44}
45
46/// Detected on-disk vendor / format family.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum VendorFormat {
49    /// Thermo Fisher Finnigan `.raw` (file).
50    ThermoRaw,
51    /// Bruker timsTOF TDF (directory ending in `.d/` containing
52    /// `analysis.tdf` + `analysis.tdf_bin`).
53    BrukerTdf,
54    /// Waters MassLynx bundle (directory ending in `.raw/` containing
55    /// `_HEADER.TXT`).
56    WatersRaw,
57    /// Agilent MassHunter bundle (directory containing an `AcqData/`
58    /// subdirectory with `MSScan.bin`).
59    AgilentMassHunter,
60    /// SCIEX legacy `.wiff` file (paired with a sibling `.wiff.scan`).
61    SciexWiff,
62}
63
64impl VendorFormat {
65    /// Vendor-name string suitable for logs and the CLI.
66    pub fn name(self) -> &'static str {
67        match self {
68            Self::ThermoRaw => "thermo",
69            Self::BrukerTdf => "bruker",
70            Self::WatersRaw => "waters",
71            Self::AgilentMassHunter => "agilent",
72            Self::SciexWiff => "sciex",
73        }
74    }
75}
76
77/// Result of probing a filesystem path for a supported vendor format.
78#[derive(Debug, Clone)]
79pub struct Detected {
80    /// Canonical path to feed back into the matching vendor reader.
81    /// For directory-based formats this is the bundle directory; for
82    /// Thermo, the `.raw` file itself.
83    pub path: PathBuf,
84    /// Identified format.
85    pub format: VendorFormat,
86}
87
88/// Inspect `path` (file or directory) and return the matching vendor
89/// format, or `None` when none of the supported signatures match.
90///
91/// This function is always available, even with no features enabled,
92/// so a host application can decide which feature to enable at compile
93/// time based on a runtime probe.
94pub fn detect_format(path: &Path) -> Option<Detected> {
95    if path.is_dir() {
96        // Bruker .d/ first, then Agilent .d/, then Waters .raw/.
97        // Bruker and Agilent both use a `.d` extension, so they are
98        // disambiguated by contents (analysis.tdf vs AcqData/MSScan.bin),
99        // not by the directory name.
100        if path.join("analysis.tdf").is_file() && path.join("analysis.tdf_bin").is_file() {
101            return Some(Detected {
102                path: path.to_path_buf(),
103                format: VendorFormat::BrukerTdf,
104            });
105        }
106        if path.join("AcqData").join("MSScan.bin").is_file() {
107            return Some(Detected {
108                path: path.to_path_buf(),
109                format: VendorFormat::AgilentMassHunter,
110            });
111        }
112        if path.join("_HEADER.TXT").is_file() {
113            return Some(Detected {
114                path: path.to_path_buf(),
115                format: VendorFormat::WatersRaw,
116            });
117        }
118        return None;
119    }
120    if path.is_file() {
121        if is_thermo_raw(path) {
122            return Some(Detected {
123                path: path.to_path_buf(),
124                format: VendorFormat::ThermoRaw,
125            });
126        }
127        if is_sciex_wiff(path) {
128            return Some(Detected {
129                path: path.to_path_buf(),
130                format: VendorFormat::SciexWiff,
131            });
132        }
133        return None;
134    }
135    None
136}
137
138/// Returns `true` if `path` looks like a SCIEX legacy `.wiff` file: a
139/// `.wiff` extension with a sibling `.wiff.scan` file alongside it (the
140/// scan data the reader needs). The extension check is case-insensitive.
141fn is_sciex_wiff(path: &Path) -> bool {
142    let is_wiff_ext = path
143        .extension()
144        .and_then(|e| e.to_str())
145        .is_some_and(|e| e.eq_ignore_ascii_case("wiff"));
146    if !is_wiff_ext {
147        return false;
148    }
149    let mut scan = path.as_os_str().to_os_string();
150    scan.push(".scan");
151    Path::new(&scan).is_file()
152}
153
154/// Returns `true` if the file looks like a Thermo Finnigan `.raw`.
155///
156/// The Finnigan signature is the UTF-16LE string `Finnigan` starting at
157/// offset 2 (the first two bytes are a small header version word).
158fn is_thermo_raw(path: &Path) -> bool {
159    use std::fs::File;
160    use std::io::Read;
161    let Ok(mut f) = File::open(path) else {
162        return false;
163    };
164    let mut buf = [0u8; 18];
165    if f.read_exact(&mut buf).is_err() {
166        return false;
167    }
168    // "Finnigan" in UTF-16LE: F.i.n.n.i.g.a.n. (16 bytes) at offset 2.
169    const FINNIGAN_UTF16LE: [u8; 16] = [
170        0x46, 0x00, 0x69, 0x00, 0x6e, 0x00, 0x6e, 0x00, 0x69, 0x00, 0x67, 0x00, 0x61, 0x00, 0x6e,
171        0x00,
172    ];
173    buf[2..18] == FINNIGAN_UTF16LE
174}
175
176/// Convert a detected vendor file to mzML at `output`. Picks the
177/// correct vendor crate's `write_mzml` (or `write_indexed_mzml`) based
178/// on `indexed`.
179#[allow(clippy::needless_pass_by_value)] // for symmetry with detect_format
180pub fn convert_to_mzml(detected: Detected, output: &Path, indexed: bool) -> Result<()> {
181    use std::fs::File;
182    use std::io::BufWriter;
183    let f = File::create(output)?;
184    let mut w = BufWriter::new(f);
185    write_to(detected.format, &detected.path, &mut w, indexed)
186}
187
188/// Like [`convert_to_mzml`] but writes to an arbitrary writer instead
189/// of a path. Useful for streaming output to gzip, stdout, or any other
190/// sink.
191#[allow(clippy::needless_pass_by_value)]
192pub fn convert_to_mzml_writer<W: std::io::Write>(
193    detected: Detected,
194    writer: &mut W,
195    indexed: bool,
196) -> Result<()> {
197    write_to(detected.format, &detected.path, writer, indexed)
198}
199
200fn write_to(
201    format: VendorFormat,
202    path: &Path,
203    w: &mut impl std::io::Write,
204    indexed: bool,
205) -> Result<()> {
206    match format {
207        VendorFormat::ThermoRaw => {
208            #[cfg(feature = "thermo")]
209            {
210                thermo_convert(path, w, indexed)
211            }
212            #[cfg(not(feature = "thermo"))]
213            {
214                let _ = (path, w, indexed);
215                Err(Error::FeatureDisabled { vendor: "thermo" })
216            }
217        }
218        VendorFormat::BrukerTdf => {
219            #[cfg(feature = "bruker")]
220            {
221                if indexed {
222                    opentimstdf::mzml::write_indexed_mzml(path, w)?;
223                } else {
224                    opentimstdf::mzml::write_mzml(path, w)?;
225                }
226                Ok(())
227            }
228            #[cfg(not(feature = "bruker"))]
229            {
230                let _ = (path, w, indexed);
231                Err(Error::FeatureDisabled { vendor: "bruker" })
232            }
233        }
234        VendorFormat::WatersRaw => {
235            #[cfg(feature = "waters")]
236            {
237                if indexed {
238                    openwraw::mzml::write_indexed_mzml(path, w)?;
239                } else {
240                    openwraw::mzml::write_mzml(path, w)?;
241                }
242                Ok(())
243            }
244            #[cfg(not(feature = "waters"))]
245            {
246                let _ = (path, w, indexed);
247                Err(Error::FeatureDisabled { vendor: "waters" })
248            }
249        }
250        VendorFormat::AgilentMassHunter => {
251            #[cfg(feature = "agilent")]
252            {
253                // openaraw has no mzml module of its own; its Reader
254                // implements openmassspec_core::SpectrumSource, so drive
255                // the core writer directly.
256                let mut reader = openaraw::reader::Reader::open(path)?;
257                if indexed {
258                    openmassspec_core::write_indexed_mzml(&mut reader, w)?;
259                } else {
260                    openmassspec_core::write_mzml(&mut reader, w)?;
261                }
262                Ok(())
263            }
264            #[cfg(not(feature = "agilent"))]
265            {
266                let _ = (path, w, indexed);
267                Err(Error::FeatureDisabled { vendor: "agilent" })
268            }
269        }
270        VendorFormat::SciexWiff => {
271            #[cfg(feature = "sciex")]
272            {
273                let mut reader = opensxraw::reader::Reader::open(path)?;
274                if indexed {
275                    openmassspec_core::write_indexed_mzml(&mut reader, w)?;
276                } else {
277                    openmassspec_core::write_mzml(&mut reader, w)?;
278                }
279                Ok(())
280            }
281            #[cfg(not(feature = "sciex"))]
282            {
283                let _ = (path, w, indexed);
284                Err(Error::FeatureDisabled { vendor: "sciex" })
285            }
286        }
287    }
288}
289
290#[cfg(feature = "thermo")]
291fn thermo_convert(path: &Path, out: &mut impl std::io::Write, indexed: bool) -> Result<()> {
292    use std::fs::File;
293    use std::io::BufReader;
294    let raw = opentfraw::RawFileReader::open_path(path)?;
295    let mut source = BufReader::with_capacity(2 << 20, File::open(path)?);
296    let filename = path
297        .file_name()
298        .and_then(|n| n.to_str())
299        .unwrap_or("unknown.raw");
300    if indexed {
301        opentfraw::mzml::write_indexed_mzml(&raw, &mut source, out, filename, false)?;
302    } else {
303        opentfraw::mzml::write_mzml(&raw, &mut source, out, filename, false)?;
304    }
305    Ok(())
306}
307
308/// Like [`convert_to_mzml`], but every profile-mode spectrum is centroided
309/// first via [`openmassspec_core::Centroided`]. `min_intensity`, when
310/// `Some`, discards picked peaks below that noise floor.
311#[allow(clippy::needless_pass_by_value)]
312pub fn convert_to_mzml_centroided(
313    detected: Detected,
314    output: &Path,
315    indexed: bool,
316    min_intensity: Option<f32>,
317) -> Result<()> {
318    use std::fs::File;
319    use std::io::BufWriter;
320    let f = File::create(output)?;
321    let mut w = BufWriter::new(f);
322    write_to_centroided(
323        detected.format,
324        &detected.path,
325        &mut w,
326        indexed,
327        min_intensity,
328    )
329}
330
331/// Like [`convert_to_mzml_writer`], but every profile-mode spectrum is
332/// centroided first via [`openmassspec_core::Centroided`]. `min_intensity`,
333/// when `Some`, discards picked peaks below that noise floor.
334#[allow(clippy::needless_pass_by_value)]
335pub fn convert_to_mzml_writer_centroided<W: std::io::Write>(
336    detected: Detected,
337    writer: &mut W,
338    indexed: bool,
339    min_intensity: Option<f32>,
340) -> Result<()> {
341    write_to_centroided(
342        detected.format,
343        &detected.path,
344        writer,
345        indexed,
346        min_intensity,
347    )
348}
349
350/// Unlike [`write_to`], every vendor arm here drives
351/// `openmassspec_core::write_mzml`/`write_indexed_mzml` directly over a
352/// [`openmassspec_core::Centroided`]-wrapped source, rather than each
353/// vendor crate's own `write_mzml` convenience wrapper - centroiding has
354/// to happen between "open the source" and "hand it to the writer", so
355/// the shortcut those wrappers take (path in, mzML out, no source object
356/// exposed) doesn't compose here.
357fn write_to_centroided(
358    format: VendorFormat,
359    path: &Path,
360    w: &mut impl std::io::Write,
361    indexed: bool,
362    min_intensity: Option<f32>,
363) -> Result<()> {
364    #[allow(unused_imports)]
365    use openmassspec_core::SpectrumSource;
366    match format {
367        VendorFormat::ThermoRaw => {
368            #[cfg(feature = "thermo")]
369            {
370                use std::fs::File;
371                use std::io::BufReader;
372                let raw = opentfraw::RawFileReader::open_path(path)?;
373                let mut source = BufReader::with_capacity(2 << 20, File::open(path)?);
374                let filename = path
375                    .file_name()
376                    .and_then(|n| n.to_str())
377                    .unwrap_or("unknown.raw");
378                let src = opentfraw::mzml::OpenTfRawSource::new(&raw, &mut source, filename, false);
379                let mut src = with_min_intensity_opt(src, min_intensity);
380                if indexed {
381                    openmassspec_core::write_indexed_mzml(&mut src, w)?;
382                } else {
383                    openmassspec_core::write_mzml(&mut src, w)?;
384                }
385                Ok(())
386            }
387            #[cfg(not(feature = "thermo"))]
388            {
389                let _ = (path, w, indexed, min_intensity);
390                Err(Error::FeatureDisabled { vendor: "thermo" })
391            }
392        }
393        VendorFormat::BrukerTdf => {
394            #[cfg(feature = "bruker")]
395            {
396                let src = opentimstdf::mzml::TdfSource::open(path)?;
397                let mut src = with_min_intensity_opt(src, min_intensity);
398                if indexed {
399                    openmassspec_core::write_indexed_mzml(&mut src, w)?;
400                } else {
401                    openmassspec_core::write_mzml(&mut src, w)?;
402                }
403                Ok(())
404            }
405            #[cfg(not(feature = "bruker"))]
406            {
407                let _ = (path, w, indexed, min_intensity);
408                Err(Error::FeatureDisabled { vendor: "bruker" })
409            }
410        }
411        VendorFormat::WatersRaw => {
412            #[cfg(feature = "waters")]
413            {
414                let src = openwraw::mzml::WatersSource::open(path)?;
415                let mut src = with_min_intensity_opt(src, min_intensity);
416                if indexed {
417                    openmassspec_core::write_indexed_mzml(&mut src, w)?;
418                } else {
419                    openmassspec_core::write_mzml(&mut src, w)?;
420                }
421                Ok(())
422            }
423            #[cfg(not(feature = "waters"))]
424            {
425                let _ = (path, w, indexed, min_intensity);
426                Err(Error::FeatureDisabled { vendor: "waters" })
427            }
428        }
429        VendorFormat::AgilentMassHunter => {
430            #[cfg(feature = "agilent")]
431            {
432                let reader = openaraw::reader::Reader::open(path)?;
433                let mut src = with_min_intensity_opt(reader, min_intensity);
434                if indexed {
435                    openmassspec_core::write_indexed_mzml(&mut src, w)?;
436                } else {
437                    openmassspec_core::write_mzml(&mut src, w)?;
438                }
439                Ok(())
440            }
441            #[cfg(not(feature = "agilent"))]
442            {
443                let _ = (path, w, indexed, min_intensity);
444                Err(Error::FeatureDisabled { vendor: "agilent" })
445            }
446        }
447        VendorFormat::SciexWiff => {
448            #[cfg(feature = "sciex")]
449            {
450                let reader = opensxraw::reader::Reader::open(path)?;
451                let mut src = with_min_intensity_opt(reader, min_intensity);
452                if indexed {
453                    openmassspec_core::write_indexed_mzml(&mut src, w)?;
454                } else {
455                    openmassspec_core::write_mzml(&mut src, w)?;
456                }
457                Ok(())
458            }
459            #[cfg(not(feature = "sciex"))]
460            {
461                let _ = (path, w, indexed, min_intensity);
462                Err(Error::FeatureDisabled { vendor: "sciex" })
463            }
464        }
465    }
466}
467
468/// Apply an optional noise floor to a freshly wrapped [`Centroided`]
469/// source. Shared by every `collect_centroided` / `write_to_centroided`
470/// vendor arm.
471///
472/// [`Centroided`]: openmassspec_core::Centroided
473#[allow(dead_code)]
474fn with_min_intensity_opt<S: openmassspec_core::SpectrumSource>(
475    src: S,
476    min_intensity: Option<f32>,
477) -> openmassspec_core::Centroided<S> {
478    let centroided = openmassspec_core::Centroided::new(src);
479    match min_intensity {
480        Some(v) => centroided.with_min_intensity(v),
481        None => centroided,
482    }
483}
484
485/// Open the appropriate vendor source for `detected`, collect every
486/// spectrum into a `Vec`, and return both the records and the
487/// run-level metadata. Used by tools that need a second pass over the
488/// data (conformance validation, `info` summaries, Arrow batching).
489///
490/// This dispatches to the same vendor code paths as
491/// [`convert_to_mzml`], so a feature-gated build that excludes a
492/// vendor will return an error here for that vendor.
493#[allow(clippy::needless_pass_by_value)]
494pub fn collect(
495    detected: Detected,
496) -> Result<(
497    Vec<openmassspec_core::SpectrumRecord>,
498    openmassspec_core::RunMetadata,
499)> {
500    #[allow(unused_imports)]
501    use openmassspec_core::SpectrumSource;
502    match detected.format {
503        VendorFormat::ThermoRaw => {
504            #[cfg(feature = "thermo")]
505            {
506                use std::fs::File;
507                use std::io::BufReader;
508                let raw = opentfraw::RawFileReader::open_path(&detected.path)?;
509                let mut source = BufReader::with_capacity(2 << 20, File::open(&detected.path)?);
510                let filename = detected
511                    .path
512                    .file_name()
513                    .and_then(|n| n.to_str())
514                    .unwrap_or("unknown.raw");
515                let mut src =
516                    opentfraw::mzml::OpenTfRawSource::new(&raw, &mut source, filename, false);
517                let meta = src.run_metadata();
518                let recs: Vec<_> = src.iter_spectra().collect();
519                Ok((recs, meta))
520            }
521            #[cfg(not(feature = "thermo"))]
522            Err(Error::FeatureDisabled { vendor: "thermo" })
523        }
524        VendorFormat::BrukerTdf => {
525            #[cfg(feature = "bruker")]
526            {
527                let mut src = opentimstdf::mzml::TdfSource::open(&detected.path)?;
528                let meta = src.run_metadata();
529                let recs: Vec<_> = src.iter_spectra().collect();
530                Ok((recs, meta))
531            }
532            #[cfg(not(feature = "bruker"))]
533            Err(Error::FeatureDisabled { vendor: "bruker" })
534        }
535        VendorFormat::WatersRaw => {
536            #[cfg(feature = "waters")]
537            {
538                let mut src = openwraw::mzml::WatersSource::open(&detected.path)?;
539                let meta = src.run_metadata();
540                let recs: Vec<_> = src.iter_spectra().collect();
541                Ok((recs, meta))
542            }
543            #[cfg(not(feature = "waters"))]
544            Err(Error::FeatureDisabled { vendor: "waters" })
545        }
546        VendorFormat::AgilentMassHunter => {
547            #[cfg(feature = "agilent")]
548            {
549                let mut src = openaraw::reader::Reader::open(&detected.path)?;
550                let meta = src.run_metadata();
551                let recs: Vec<_> = src.iter_spectra().collect();
552                Ok((recs, meta))
553            }
554            #[cfg(not(feature = "agilent"))]
555            Err(Error::FeatureDisabled { vendor: "agilent" })
556        }
557        VendorFormat::SciexWiff => {
558            #[cfg(feature = "sciex")]
559            {
560                let mut src = opensxraw::reader::Reader::open(&detected.path)?;
561                let meta = src.run_metadata();
562                let recs: Vec<_> = src.iter_spectra().collect();
563                Ok((recs, meta))
564            }
565            #[cfg(not(feature = "sciex"))]
566            Err(Error::FeatureDisabled { vendor: "sciex" })
567        }
568    }
569}
570
571/// Like [`collect`], but every profile-mode spectrum is centroided first
572/// via [`openmassspec_core::Centroided`]. Already-centroided spectra pass
573/// through unchanged. `min_intensity`, when `Some`, discards picked peaks
574/// below that noise floor.
575#[allow(clippy::needless_pass_by_value, unused_variables)]
576pub fn collect_centroided(
577    detected: Detected,
578    min_intensity: Option<f32>,
579) -> Result<(
580    Vec<openmassspec_core::SpectrumRecord>,
581    openmassspec_core::RunMetadata,
582)> {
583    #[allow(unused_imports)]
584    use openmassspec_core::SpectrumSource;
585    match detected.format {
586        VendorFormat::ThermoRaw => {
587            #[cfg(feature = "thermo")]
588            {
589                use std::fs::File;
590                use std::io::BufReader;
591                let raw = opentfraw::RawFileReader::open_path(&detected.path)?;
592                let mut source = BufReader::with_capacity(2 << 20, File::open(&detected.path)?);
593                let filename = detected
594                    .path
595                    .file_name()
596                    .and_then(|n| n.to_str())
597                    .unwrap_or("unknown.raw");
598                let src = opentfraw::mzml::OpenTfRawSource::new(&raw, &mut source, filename, false);
599                let mut src = with_min_intensity_opt(src, min_intensity);
600                let meta = src.run_metadata();
601                let recs: Vec<_> = src.iter_spectra().collect();
602                Ok((recs, meta))
603            }
604            #[cfg(not(feature = "thermo"))]
605            Err(Error::FeatureDisabled { vendor: "thermo" })
606        }
607        VendorFormat::BrukerTdf => {
608            #[cfg(feature = "bruker")]
609            {
610                let src = opentimstdf::mzml::TdfSource::open(&detected.path)?;
611                let mut src = with_min_intensity_opt(src, min_intensity);
612                let meta = src.run_metadata();
613                let recs: Vec<_> = src.iter_spectra().collect();
614                Ok((recs, meta))
615            }
616            #[cfg(not(feature = "bruker"))]
617            Err(Error::FeatureDisabled { vendor: "bruker" })
618        }
619        VendorFormat::WatersRaw => {
620            #[cfg(feature = "waters")]
621            {
622                let src = openwraw::mzml::WatersSource::open(&detected.path)?;
623                let mut src = with_min_intensity_opt(src, min_intensity);
624                let meta = src.run_metadata();
625                let recs: Vec<_> = src.iter_spectra().collect();
626                Ok((recs, meta))
627            }
628            #[cfg(not(feature = "waters"))]
629            Err(Error::FeatureDisabled { vendor: "waters" })
630        }
631        VendorFormat::AgilentMassHunter => {
632            #[cfg(feature = "agilent")]
633            {
634                let src = openaraw::reader::Reader::open(&detected.path)?;
635                let mut src = with_min_intensity_opt(src, min_intensity);
636                let meta = src.run_metadata();
637                let recs: Vec<_> = src.iter_spectra().collect();
638                Ok((recs, meta))
639            }
640            #[cfg(not(feature = "agilent"))]
641            Err(Error::FeatureDisabled { vendor: "agilent" })
642        }
643        VendorFormat::SciexWiff => {
644            #[cfg(feature = "sciex")]
645            {
646                let src = opensxraw::reader::Reader::open(&detected.path)?;
647                let mut src = with_min_intensity_opt(src, min_intensity);
648                let meta = src.run_metadata();
649                let recs: Vec<_> = src.iter_spectra().collect();
650                Ok((recs, meta))
651            }
652            #[cfg(not(feature = "sciex"))]
653            Err(Error::FeatureDisabled { vendor: "sciex" })
654        }
655    }
656}
657
658/// A trivial in-memory [`openmassspec_core::SpectrumSource`] backed by a
659/// `Vec<SpectrumRecord>` + a [`openmassspec_core::RunMetadata`]. Hand it
660/// to `openmassspec_core::write_mzml` when you already have the records
661/// in hand and just want to emit mzML.
662pub struct VecSource {
663    pub metadata: openmassspec_core::RunMetadata,
664    pub records: Vec<openmassspec_core::SpectrumRecord>,
665}
666
667impl VecSource {
668    pub fn new(
669        metadata: openmassspec_core::RunMetadata,
670        records: Vec<openmassspec_core::SpectrumRecord>,
671    ) -> Self {
672        Self { metadata, records }
673    }
674}
675
676impl openmassspec_core::SpectrumSource for VecSource {
677    fn run_metadata(&self) -> openmassspec_core::RunMetadata {
678        self.metadata.clone()
679    }
680    fn iter_spectra<'s>(
681        &'s mut self,
682    ) -> Box<dyn Iterator<Item = openmassspec_core::SpectrumRecord> + 's> {
683        Box::new(self.records.drain(..))
684    }
685    fn spectrum_count_hint(&self) -> Option<usize> {
686        Some(self.records.len())
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use std::io::Write;
694
695    #[test]
696    fn detect_returns_none_for_garbage_file() {
697        let tmp = tempfile_path();
698        std::fs::write(&tmp, b"hello").unwrap();
699        assert!(detect_format(&tmp).is_none());
700        let _ = std::fs::remove_file(&tmp);
701    }
702
703    #[test]
704    fn detect_returns_thermo_for_finnigan_magic() {
705        let tmp = tempfile_path();
706        let mut f = std::fs::File::create(&tmp).unwrap();
707        // 2-byte version word + "Finnigan" in UTF-16LE + trailing garbage.
708        f.write_all(&[
709            0x01, 0xa1, 0x46, 0x00, 0x69, 0x00, 0x6e, 0x00, 0x6e, 0x00, 0x69, 0x00, 0x67, 0x00,
710            0x61, 0x00, 0x6e, 0x00, 0xff, 0xff,
711        ])
712        .unwrap();
713        let det = detect_format(&tmp).expect("detect");
714        assert_eq!(det.format, VendorFormat::ThermoRaw);
715        let _ = std::fs::remove_file(&tmp);
716    }
717
718    #[test]
719    fn detect_returns_bruker_for_tdf_layout() {
720        let tmp = tempfile_dir();
721        std::fs::write(tmp.join("analysis.tdf"), b"").unwrap();
722        std::fs::write(tmp.join("analysis.tdf_bin"), b"").unwrap();
723        let det = detect_format(&tmp).expect("detect");
724        assert_eq!(det.format, VendorFormat::BrukerTdf);
725        let _ = std::fs::remove_dir_all(&tmp);
726    }
727
728    #[test]
729    fn detect_returns_waters_for_header_layout() {
730        let tmp = tempfile_dir();
731        std::fs::write(tmp.join("_HEADER.TXT"), b"$$ FAKE\n").unwrap();
732        let det = detect_format(&tmp).expect("detect");
733        assert_eq!(det.format, VendorFormat::WatersRaw);
734        let _ = std::fs::remove_dir_all(&tmp);
735    }
736
737    #[test]
738    fn detect_returns_agilent_for_acqdata_layout() {
739        let tmp = tempfile_dir();
740        let acq = tmp.join("AcqData");
741        std::fs::create_dir_all(&acq).unwrap();
742        std::fs::write(acq.join("MSScan.bin"), b"").unwrap();
743        let det = detect_format(&tmp).expect("detect");
744        assert_eq!(det.format, VendorFormat::AgilentMassHunter);
745        let _ = std::fs::remove_dir_all(&tmp);
746    }
747
748    #[test]
749    fn detect_returns_sciex_for_wiff_with_scan_sibling() {
750        let dir = tempfile_dir();
751        let wiff = dir.join("run.wiff");
752        std::fs::write(&wiff, b"\xd0\xcf\x11\xe0").unwrap();
753        std::fs::write(dir.join("run.wiff.scan"), b"").unwrap();
754        let det = detect_format(&wiff).expect("detect");
755        assert_eq!(det.format, VendorFormat::SciexWiff);
756        let _ = std::fs::remove_dir_all(&dir);
757    }
758
759    #[test]
760    fn detect_returns_none_for_wiff_without_scan_sibling() {
761        let dir = tempfile_dir();
762        let wiff = dir.join("lonely.wiff");
763        std::fs::write(&wiff, b"\xd0\xcf\x11\xe0").unwrap();
764        // No .wiff.scan alongside -> not a usable SCIEX pair.
765        assert!(detect_format(&wiff).is_none());
766        let _ = std::fs::remove_dir_all(&dir);
767    }
768
769    fn tempfile_path() -> PathBuf {
770        let pid = std::process::id();
771        let mut p = std::env::temp_dir();
772        p.push(format!("msio-test-{pid}-{:p}", &pid));
773        p
774    }
775
776    fn tempfile_dir() -> PathBuf {
777        let p = tempfile_path();
778        let _ = std::fs::create_dir_all(&p);
779        p
780    }
781
782    #[test]
783    fn convert_unsupported_format_returns_typed_error() {
784        // `detect_format` returns None here, so callers can't reach
785        // `convert_to_mzml`. Exercise the FeatureDisabled / Mzml paths
786        // through the public `Error` variants directly to keep this
787        // test feature-agnostic.
788        let e: Error = std::io::Error::other("boom").into();
789        assert!(matches!(e, Error::Io(_)));
790        let e = Error::FeatureDisabled { vendor: "thermo" };
791        assert_eq!(
792            e.to_string(),
793            "openmassspec-io was built without the 'thermo' feature"
794        );
795        let e = Error::UnsupportedFormat(PathBuf::from("/tmp/nope"));
796        assert!(matches!(e, Error::UnsupportedFormat(_)));
797    }
798}