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`, `openszraw`) behind a uniform
4//! vendor-detection + mzML-conversion API.
5//!
6//! Each vendor parser is gated behind a Cargo feature
7//! (`thermo`, `bruker`, `waters`, `agilent`, `sciex`, `shimadzu`) and
8//! re-exported under [`vendor`]. The `all` meta-feature pulls in every
9//! supported 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 = "shimadzu")]
39    pub use openszraw;
40    #[cfg(feature = "thermo")]
41    pub use opentfraw;
42    #[cfg(feature = "bruker")]
43    pub use opentimstdf;
44    #[cfg(feature = "waters")]
45    pub use openwraw;
46}
47
48/// Detected on-disk vendor / format family.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum VendorFormat {
51    /// Thermo Fisher Finnigan `.raw` (file).
52    ThermoRaw,
53    /// Bruker timsTOF TDF (directory ending in `.d/` containing
54    /// `analysis.tdf` + `analysis.tdf_bin`).
55    BrukerTdf,
56    /// Waters MassLynx bundle (directory ending in `.raw/` containing
57    /// `_HEADER.TXT`).
58    WatersRaw,
59    /// Agilent MassHunter bundle (directory containing an `AcqData/`
60    /// subdirectory with `MSScan.bin`).
61    AgilentMassHunter,
62    /// SCIEX legacy `.wiff` file (paired with a sibling `.wiff.scan`).
63    SciexWiff,
64    /// Shimadzu LabSolutions `.qgd` (GC-MS) or `.lcd` (LC-MS, IT-TOF or
65    /// QTOF) file. `openszraw::reader::Reader` auto-detects which of
66    /// the three on-disk variants it is from the file's own CFBF
67    /// stream layout, so a single `VendorFormat` variant covers all of
68    /// them here too.
69    ShimadzuLabSolutions,
70}
71
72impl VendorFormat {
73    /// Vendor-name string suitable for logs and the CLI.
74    pub fn name(self) -> &'static str {
75        match self {
76            Self::ThermoRaw => "thermo",
77            Self::BrukerTdf => "bruker",
78            Self::WatersRaw => "waters",
79            Self::AgilentMassHunter => "agilent",
80            Self::SciexWiff => "sciex",
81            Self::ShimadzuLabSolutions => "shimadzu",
82        }
83    }
84}
85
86/// Result of probing a filesystem path for a supported vendor format.
87#[derive(Debug, Clone)]
88pub struct Detected {
89    /// Canonical path to feed back into the matching vendor reader.
90    /// For directory-based formats this is the bundle directory; for
91    /// Thermo, the `.raw` file itself.
92    pub path: PathBuf,
93    /// Identified format.
94    pub format: VendorFormat,
95}
96
97/// Inspect `path` (file or directory) and return the matching vendor
98/// format, or `None` when none of the supported signatures match.
99///
100/// This function is always available, even with no features enabled,
101/// so a host application can decide which feature to enable at compile
102/// time based on a runtime probe.
103pub fn detect_format(path: &Path) -> Option<Detected> {
104    if path.is_dir() {
105        // Bruker .d/ first, then Agilent .d/, then Waters .raw/.
106        // Bruker and Agilent both use a `.d` extension, so they are
107        // disambiguated by contents (analysis.tdf vs AcqData/MSScan.bin),
108        // not by the directory name.
109        if path.join("analysis.tdf").is_file() && path.join("analysis.tdf_bin").is_file() {
110            return Some(Detected {
111                path: path.to_path_buf(),
112                format: VendorFormat::BrukerTdf,
113            });
114        }
115        if path.join("AcqData").join("MSScan.bin").is_file() {
116            return Some(Detected {
117                path: path.to_path_buf(),
118                format: VendorFormat::AgilentMassHunter,
119            });
120        }
121        if path.join("_HEADER.TXT").is_file() {
122            return Some(Detected {
123                path: path.to_path_buf(),
124                format: VendorFormat::WatersRaw,
125            });
126        }
127        return None;
128    }
129    if path.is_file() {
130        if is_thermo_raw(path) {
131            return Some(Detected {
132                path: path.to_path_buf(),
133                format: VendorFormat::ThermoRaw,
134            });
135        }
136        if is_sciex_wiff(path) {
137            return Some(Detected {
138                path: path.to_path_buf(),
139                format: VendorFormat::SciexWiff,
140            });
141        }
142        if is_shimadzu_labsolutions(path) {
143            return Some(Detected {
144                path: path.to_path_buf(),
145                format: VendorFormat::ShimadzuLabSolutions,
146            });
147        }
148        return None;
149    }
150    None
151}
152
153/// Returns `true` if `path` looks like a SCIEX legacy `.wiff` file: a
154/// `.wiff` extension with a sibling `.wiff.scan` file alongside it (the
155/// scan data the reader needs). The extension check is case-insensitive.
156fn is_sciex_wiff(path: &Path) -> bool {
157    let is_wiff_ext = path
158        .extension()
159        .and_then(|e| e.to_str())
160        .is_some_and(|e| e.eq_ignore_ascii_case("wiff"));
161    if !is_wiff_ext {
162        return false;
163    }
164    let mut scan = path.as_os_str().to_os_string();
165    scan.push(".scan");
166    Path::new(&scan).is_file()
167}
168
169/// Returns `true` if `path` looks like a Shimadzu LabSolutions `.qgd` or
170/// `.lcd` file: one of those two extensions (case-insensitive) whose
171/// first 8 bytes are the CFBF/OLE2 container signature. Unlike the
172/// SCIEX `.wiff` check, there is no sibling file to corroborate against
173/// (Shimadzu raw files are self-contained), so the magic-byte check is
174/// the only content-level signal available - still strictly more
175/// verification than a bare extension check.
176fn is_shimadzu_labsolutions(path: &Path) -> bool {
177    let is_shimadzu_ext = path
178        .extension()
179        .and_then(|e| e.to_str())
180        .is_some_and(|e| e.eq_ignore_ascii_case("qgd") || e.eq_ignore_ascii_case("lcd"));
181    if !is_shimadzu_ext {
182        return false;
183    }
184    use std::fs::File;
185    use std::io::Read;
186    let Ok(mut f) = File::open(path) else {
187        return false;
188    };
189    let mut buf = [0u8; 8];
190    if f.read_exact(&mut buf).is_err() {
191        return false;
192    }
193    const CFBF_MAGIC: [u8; 8] = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
194    buf == CFBF_MAGIC
195}
196
197/// Returns `true` if the file looks like a Thermo Finnigan `.raw`.
198///
199/// The Finnigan signature is the UTF-16LE string `Finnigan` starting at
200/// offset 2 (the first two bytes are a small header version word).
201fn is_thermo_raw(path: &Path) -> bool {
202    use std::fs::File;
203    use std::io::Read;
204    let Ok(mut f) = File::open(path) else {
205        return false;
206    };
207    let mut buf = [0u8; 18];
208    if f.read_exact(&mut buf).is_err() {
209        return false;
210    }
211    // "Finnigan" in UTF-16LE: F.i.n.n.i.g.a.n. (16 bytes) at offset 2.
212    const FINNIGAN_UTF16LE: [u8; 16] = [
213        0x46, 0x00, 0x69, 0x00, 0x6e, 0x00, 0x6e, 0x00, 0x69, 0x00, 0x67, 0x00, 0x61, 0x00, 0x6e,
214        0x00,
215    ];
216    buf[2..18] == FINNIGAN_UTF16LE
217}
218
219/// Convert a detected vendor file to mzML at `output`. Picks the
220/// correct vendor crate's `write_mzml` (or `write_indexed_mzml`) based
221/// on `indexed`.
222#[allow(clippy::needless_pass_by_value)] // for symmetry with detect_format
223pub fn convert_to_mzml(detected: Detected, output: &Path, indexed: bool) -> Result<()> {
224    use std::fs::File;
225    use std::io::BufWriter;
226    let f = File::create(output)?;
227    let mut w = BufWriter::new(f);
228    write_to(detected.format, &detected.path, &mut w, indexed)
229}
230
231/// Like [`convert_to_mzml`] but writes to an arbitrary writer instead
232/// of a path. Useful for streaming output to gzip, stdout, or any other
233/// sink.
234#[allow(clippy::needless_pass_by_value)]
235pub fn convert_to_mzml_writer<W: std::io::Write>(
236    detected: Detected,
237    writer: &mut W,
238    indexed: bool,
239) -> Result<()> {
240    write_to(detected.format, &detected.path, writer, indexed)
241}
242
243fn write_to(
244    format: VendorFormat,
245    path: &Path,
246    w: &mut impl std::io::Write,
247    indexed: bool,
248) -> Result<()> {
249    match format {
250        VendorFormat::ThermoRaw => {
251            #[cfg(feature = "thermo")]
252            {
253                thermo_convert(path, w, indexed)
254            }
255            #[cfg(not(feature = "thermo"))]
256            {
257                let _ = (path, w, indexed);
258                Err(Error::FeatureDisabled { vendor: "thermo" })
259            }
260        }
261        VendorFormat::BrukerTdf => {
262            #[cfg(feature = "bruker")]
263            {
264                if indexed {
265                    opentimstdf::mzml::write_indexed_mzml(path, w)?;
266                } else {
267                    opentimstdf::mzml::write_mzml(path, w)?;
268                }
269                Ok(())
270            }
271            #[cfg(not(feature = "bruker"))]
272            {
273                let _ = (path, w, indexed);
274                Err(Error::FeatureDisabled { vendor: "bruker" })
275            }
276        }
277        VendorFormat::WatersRaw => {
278            #[cfg(feature = "waters")]
279            {
280                if indexed {
281                    openwraw::mzml::write_indexed_mzml(path, w)?;
282                } else {
283                    openwraw::mzml::write_mzml(path, w)?;
284                }
285                Ok(())
286            }
287            #[cfg(not(feature = "waters"))]
288            {
289                let _ = (path, w, indexed);
290                Err(Error::FeatureDisabled { vendor: "waters" })
291            }
292        }
293        VendorFormat::AgilentMassHunter => {
294            #[cfg(feature = "agilent")]
295            {
296                // openaraw has no mzml module of its own; its Reader
297                // implements openmassspec_core::SpectrumSource, so drive
298                // the core writer directly.
299                let mut reader = openaraw::reader::Reader::open(path)?;
300                if indexed {
301                    openmassspec_core::write_indexed_mzml(&mut reader, w)?;
302                } else {
303                    openmassspec_core::write_mzml(&mut reader, w)?;
304                }
305                Ok(())
306            }
307            #[cfg(not(feature = "agilent"))]
308            {
309                let _ = (path, w, indexed);
310                Err(Error::FeatureDisabled { vendor: "agilent" })
311            }
312        }
313        VendorFormat::SciexWiff => {
314            #[cfg(feature = "sciex")]
315            {
316                let mut reader = opensxraw::reader::Reader::open(path)?;
317                if indexed {
318                    openmassspec_core::write_indexed_mzml(&mut reader, w)?;
319                } else {
320                    openmassspec_core::write_mzml(&mut reader, w)?;
321                }
322                Ok(())
323            }
324            #[cfg(not(feature = "sciex"))]
325            {
326                let _ = (path, w, indexed);
327                Err(Error::FeatureDisabled { vendor: "sciex" })
328            }
329        }
330        VendorFormat::ShimadzuLabSolutions => {
331            #[cfg(feature = "shimadzu")]
332            {
333                // openszraw has no mzml module of its own; its Reader
334                // implements openmassspec_core::SpectrumSource, so drive
335                // the core writer directly (same pattern as Agilent/SCIEX).
336                let mut reader = openszraw::reader::Reader::open(path)?;
337                if indexed {
338                    openmassspec_core::write_indexed_mzml(&mut reader, w)?;
339                } else {
340                    openmassspec_core::write_mzml(&mut reader, w)?;
341                }
342                Ok(())
343            }
344            #[cfg(not(feature = "shimadzu"))]
345            {
346                let _ = (path, w, indexed);
347                Err(Error::FeatureDisabled { vendor: "shimadzu" })
348            }
349        }
350    }
351}
352
353#[cfg(feature = "thermo")]
354fn thermo_convert(path: &Path, out: &mut impl std::io::Write, indexed: bool) -> Result<()> {
355    use std::fs::File;
356    use std::io::BufReader;
357    let raw = opentfraw::RawFileReader::open_path(path)?;
358    let mut source = BufReader::with_capacity(2 << 20, File::open(path)?);
359    let filename = path
360        .file_name()
361        .and_then(|n| n.to_str())
362        .unwrap_or("unknown.raw");
363    if indexed {
364        opentfraw::mzml::write_indexed_mzml(&raw, &mut source, out, filename, false)?;
365    } else {
366        opentfraw::mzml::write_mzml(&raw, &mut source, out, filename, false)?;
367    }
368    Ok(())
369}
370
371/// Like [`convert_to_mzml`], but every profile-mode spectrum is centroided
372/// first via [`openmassspec_core::Centroided`]. `min_intensity`, when
373/// `Some`, discards picked peaks below that noise floor.
374#[allow(clippy::needless_pass_by_value)]
375pub fn convert_to_mzml_centroided(
376    detected: Detected,
377    output: &Path,
378    indexed: bool,
379    min_intensity: Option<f32>,
380) -> Result<()> {
381    use std::fs::File;
382    use std::io::BufWriter;
383    let f = File::create(output)?;
384    let mut w = BufWriter::new(f);
385    write_to_centroided(
386        detected.format,
387        &detected.path,
388        &mut w,
389        indexed,
390        min_intensity,
391    )
392}
393
394/// Like [`convert_to_mzml_writer`], but every profile-mode spectrum is
395/// centroided first via [`openmassspec_core::Centroided`]. `min_intensity`,
396/// when `Some`, discards picked peaks below that noise floor.
397#[allow(clippy::needless_pass_by_value)]
398pub fn convert_to_mzml_writer_centroided<W: std::io::Write>(
399    detected: Detected,
400    writer: &mut W,
401    indexed: bool,
402    min_intensity: Option<f32>,
403) -> Result<()> {
404    write_to_centroided(
405        detected.format,
406        &detected.path,
407        writer,
408        indexed,
409        min_intensity,
410    )
411}
412
413/// Unlike [`write_to`], every vendor arm here drives
414/// `openmassspec_core::write_mzml`/`write_indexed_mzml` directly over a
415/// [`openmassspec_core::Centroided`]-wrapped source, rather than each
416/// vendor crate's own `write_mzml` convenience wrapper - centroiding has
417/// to happen between "open the source" and "hand it to the writer", so
418/// the shortcut those wrappers take (path in, mzML out, no source object
419/// exposed) doesn't compose here.
420fn write_to_centroided(
421    format: VendorFormat,
422    path: &Path,
423    w: &mut impl std::io::Write,
424    indexed: bool,
425    min_intensity: Option<f32>,
426) -> Result<()> {
427    #[allow(unused_imports)]
428    use openmassspec_core::SpectrumSource;
429    match format {
430        VendorFormat::ThermoRaw => {
431            #[cfg(feature = "thermo")]
432            {
433                use std::fs::File;
434                use std::io::BufReader;
435                let raw = opentfraw::RawFileReader::open_path(path)?;
436                let mut source = BufReader::with_capacity(2 << 20, File::open(path)?);
437                let filename = path
438                    .file_name()
439                    .and_then(|n| n.to_str())
440                    .unwrap_or("unknown.raw");
441                let src = opentfraw::mzml::OpenTfRawSource::new(&raw, &mut source, filename, false);
442                let mut src = with_min_intensity_opt(src, min_intensity);
443                if indexed {
444                    openmassspec_core::write_indexed_mzml(&mut src, w)?;
445                } else {
446                    openmassspec_core::write_mzml(&mut src, w)?;
447                }
448                Ok(())
449            }
450            #[cfg(not(feature = "thermo"))]
451            {
452                let _ = (path, w, indexed, min_intensity);
453                Err(Error::FeatureDisabled { vendor: "thermo" })
454            }
455        }
456        VendorFormat::BrukerTdf => {
457            #[cfg(feature = "bruker")]
458            {
459                let src = opentimstdf::mzml::TdfSource::open(path)?;
460                let mut src = with_min_intensity_opt(src, min_intensity);
461                if indexed {
462                    openmassspec_core::write_indexed_mzml(&mut src, w)?;
463                } else {
464                    openmassspec_core::write_mzml(&mut src, w)?;
465                }
466                Ok(())
467            }
468            #[cfg(not(feature = "bruker"))]
469            {
470                let _ = (path, w, indexed, min_intensity);
471                Err(Error::FeatureDisabled { vendor: "bruker" })
472            }
473        }
474        VendorFormat::WatersRaw => {
475            #[cfg(feature = "waters")]
476            {
477                let src = openwraw::mzml::WatersSource::open(path)?;
478                let mut src = with_min_intensity_opt(src, min_intensity);
479                if indexed {
480                    openmassspec_core::write_indexed_mzml(&mut src, w)?;
481                } else {
482                    openmassspec_core::write_mzml(&mut src, w)?;
483                }
484                Ok(())
485            }
486            #[cfg(not(feature = "waters"))]
487            {
488                let _ = (path, w, indexed, min_intensity);
489                Err(Error::FeatureDisabled { vendor: "waters" })
490            }
491        }
492        VendorFormat::AgilentMassHunter => {
493            #[cfg(feature = "agilent")]
494            {
495                let reader = openaraw::reader::Reader::open(path)?;
496                let mut src = with_min_intensity_opt(reader, min_intensity);
497                if indexed {
498                    openmassspec_core::write_indexed_mzml(&mut src, w)?;
499                } else {
500                    openmassspec_core::write_mzml(&mut src, w)?;
501                }
502                Ok(())
503            }
504            #[cfg(not(feature = "agilent"))]
505            {
506                let _ = (path, w, indexed, min_intensity);
507                Err(Error::FeatureDisabled { vendor: "agilent" })
508            }
509        }
510        VendorFormat::SciexWiff => {
511            #[cfg(feature = "sciex")]
512            {
513                let reader = opensxraw::reader::Reader::open(path)?;
514                let mut src = with_min_intensity_opt(reader, min_intensity);
515                if indexed {
516                    openmassspec_core::write_indexed_mzml(&mut src, w)?;
517                } else {
518                    openmassspec_core::write_mzml(&mut src, w)?;
519                }
520                Ok(())
521            }
522            #[cfg(not(feature = "sciex"))]
523            {
524                let _ = (path, w, indexed, min_intensity);
525                Err(Error::FeatureDisabled { vendor: "sciex" })
526            }
527        }
528        VendorFormat::ShimadzuLabSolutions => {
529            #[cfg(feature = "shimadzu")]
530            {
531                let reader = openszraw::reader::Reader::open(path)?;
532                let mut src = with_min_intensity_opt(reader, min_intensity);
533                if indexed {
534                    openmassspec_core::write_indexed_mzml(&mut src, w)?;
535                } else {
536                    openmassspec_core::write_mzml(&mut src, w)?;
537                }
538                Ok(())
539            }
540            #[cfg(not(feature = "shimadzu"))]
541            {
542                let _ = (path, w, indexed, min_intensity);
543                Err(Error::FeatureDisabled { vendor: "shimadzu" })
544            }
545        }
546    }
547}
548
549/// Apply an optional noise floor to a freshly wrapped [`Centroided`]
550/// source. Shared by every `collect_centroided` / `write_to_centroided`
551/// vendor arm.
552///
553/// [`Centroided`]: openmassspec_core::Centroided
554#[allow(dead_code)]
555fn with_min_intensity_opt<S: openmassspec_core::SpectrumSource>(
556    src: S,
557    min_intensity: Option<f32>,
558) -> openmassspec_core::Centroided<S> {
559    let centroided = openmassspec_core::Centroided::new(src);
560    match min_intensity {
561        Some(v) => centroided.with_min_intensity(v),
562        None => centroided,
563    }
564}
565
566/// Open the appropriate vendor source for `detected`, collect every
567/// spectrum into a `Vec`, and return both the records and the
568/// run-level metadata. Used by tools that need a second pass over the
569/// data (conformance validation, `info` summaries, Arrow batching).
570///
571/// This dispatches to the same vendor code paths as
572/// [`convert_to_mzml`], so a feature-gated build that excludes a
573/// vendor will return an error here for that vendor.
574#[allow(clippy::needless_pass_by_value)]
575pub fn collect(
576    detected: Detected,
577) -> Result<(
578    Vec<openmassspec_core::SpectrumRecord>,
579    openmassspec_core::RunMetadata,
580)> {
581    #[allow(unused_imports)]
582    use openmassspec_core::SpectrumSource;
583    match detected.format {
584        VendorFormat::ThermoRaw => {
585            #[cfg(feature = "thermo")]
586            {
587                use std::fs::File;
588                use std::io::BufReader;
589                let raw = opentfraw::RawFileReader::open_path(&detected.path)?;
590                let mut source = BufReader::with_capacity(2 << 20, File::open(&detected.path)?);
591                let filename = detected
592                    .path
593                    .file_name()
594                    .and_then(|n| n.to_str())
595                    .unwrap_or("unknown.raw");
596                let mut src =
597                    opentfraw::mzml::OpenTfRawSource::new(&raw, &mut source, filename, false);
598                let meta = src.run_metadata();
599                let recs: Vec<_> = src.iter_spectra().collect();
600                Ok((recs, meta))
601            }
602            #[cfg(not(feature = "thermo"))]
603            Err(Error::FeatureDisabled { vendor: "thermo" })
604        }
605        VendorFormat::BrukerTdf => {
606            #[cfg(feature = "bruker")]
607            {
608                let mut src = opentimstdf::mzml::TdfSource::open(&detected.path)?;
609                let meta = src.run_metadata();
610                let recs: Vec<_> = src.iter_spectra().collect();
611                Ok((recs, meta))
612            }
613            #[cfg(not(feature = "bruker"))]
614            Err(Error::FeatureDisabled { vendor: "bruker" })
615        }
616        VendorFormat::WatersRaw => {
617            #[cfg(feature = "waters")]
618            {
619                let mut src = openwraw::mzml::WatersSource::open(&detected.path)?;
620                let meta = src.run_metadata();
621                let recs: Vec<_> = src.iter_spectra().collect();
622                Ok((recs, meta))
623            }
624            #[cfg(not(feature = "waters"))]
625            Err(Error::FeatureDisabled { vendor: "waters" })
626        }
627        VendorFormat::AgilentMassHunter => {
628            #[cfg(feature = "agilent")]
629            {
630                let mut src = openaraw::reader::Reader::open(&detected.path)?;
631                let meta = src.run_metadata();
632                let recs: Vec<_> = src.iter_spectra().collect();
633                Ok((recs, meta))
634            }
635            #[cfg(not(feature = "agilent"))]
636            Err(Error::FeatureDisabled { vendor: "agilent" })
637        }
638        VendorFormat::SciexWiff => {
639            #[cfg(feature = "sciex")]
640            {
641                let mut src = opensxraw::reader::Reader::open(&detected.path)?;
642                let meta = src.run_metadata();
643                let recs: Vec<_> = src.iter_spectra().collect();
644                Ok((recs, meta))
645            }
646            #[cfg(not(feature = "sciex"))]
647            Err(Error::FeatureDisabled { vendor: "sciex" })
648        }
649        VendorFormat::ShimadzuLabSolutions => {
650            #[cfg(feature = "shimadzu")]
651            {
652                let mut src = openszraw::reader::Reader::open(&detected.path)?;
653                let meta = src.run_metadata();
654                let recs: Vec<_> = src.iter_spectra().collect();
655                Ok((recs, meta))
656            }
657            #[cfg(not(feature = "shimadzu"))]
658            Err(Error::FeatureDisabled { vendor: "shimadzu" })
659        }
660    }
661}
662
663/// Like [`collect`], but every profile-mode spectrum is centroided first
664/// via [`openmassspec_core::Centroided`]. Already-centroided spectra pass
665/// through unchanged. `min_intensity`, when `Some`, discards picked peaks
666/// below that noise floor.
667#[allow(clippy::needless_pass_by_value, unused_variables)]
668pub fn collect_centroided(
669    detected: Detected,
670    min_intensity: Option<f32>,
671) -> Result<(
672    Vec<openmassspec_core::SpectrumRecord>,
673    openmassspec_core::RunMetadata,
674)> {
675    #[allow(unused_imports)]
676    use openmassspec_core::SpectrumSource;
677    match detected.format {
678        VendorFormat::ThermoRaw => {
679            #[cfg(feature = "thermo")]
680            {
681                use std::fs::File;
682                use std::io::BufReader;
683                let raw = opentfraw::RawFileReader::open_path(&detected.path)?;
684                let mut source = BufReader::with_capacity(2 << 20, File::open(&detected.path)?);
685                let filename = detected
686                    .path
687                    .file_name()
688                    .and_then(|n| n.to_str())
689                    .unwrap_or("unknown.raw");
690                let src = opentfraw::mzml::OpenTfRawSource::new(&raw, &mut source, filename, false);
691                let mut src = with_min_intensity_opt(src, min_intensity);
692                let meta = src.run_metadata();
693                let recs: Vec<_> = src.iter_spectra().collect();
694                Ok((recs, meta))
695            }
696            #[cfg(not(feature = "thermo"))]
697            Err(Error::FeatureDisabled { vendor: "thermo" })
698        }
699        VendorFormat::BrukerTdf => {
700            #[cfg(feature = "bruker")]
701            {
702                let src = opentimstdf::mzml::TdfSource::open(&detected.path)?;
703                let mut src = with_min_intensity_opt(src, min_intensity);
704                let meta = src.run_metadata();
705                let recs: Vec<_> = src.iter_spectra().collect();
706                Ok((recs, meta))
707            }
708            #[cfg(not(feature = "bruker"))]
709            Err(Error::FeatureDisabled { vendor: "bruker" })
710        }
711        VendorFormat::WatersRaw => {
712            #[cfg(feature = "waters")]
713            {
714                let src = openwraw::mzml::WatersSource::open(&detected.path)?;
715                let mut src = with_min_intensity_opt(src, min_intensity);
716                let meta = src.run_metadata();
717                let recs: Vec<_> = src.iter_spectra().collect();
718                Ok((recs, meta))
719            }
720            #[cfg(not(feature = "waters"))]
721            Err(Error::FeatureDisabled { vendor: "waters" })
722        }
723        VendorFormat::AgilentMassHunter => {
724            #[cfg(feature = "agilent")]
725            {
726                let src = openaraw::reader::Reader::open(&detected.path)?;
727                let mut src = with_min_intensity_opt(src, min_intensity);
728                let meta = src.run_metadata();
729                let recs: Vec<_> = src.iter_spectra().collect();
730                Ok((recs, meta))
731            }
732            #[cfg(not(feature = "agilent"))]
733            Err(Error::FeatureDisabled { vendor: "agilent" })
734        }
735        VendorFormat::SciexWiff => {
736            #[cfg(feature = "sciex")]
737            {
738                let src = opensxraw::reader::Reader::open(&detected.path)?;
739                let mut src = with_min_intensity_opt(src, min_intensity);
740                let meta = src.run_metadata();
741                let recs: Vec<_> = src.iter_spectra().collect();
742                Ok((recs, meta))
743            }
744            #[cfg(not(feature = "sciex"))]
745            Err(Error::FeatureDisabled { vendor: "sciex" })
746        }
747        VendorFormat::ShimadzuLabSolutions => {
748            #[cfg(feature = "shimadzu")]
749            {
750                let src = openszraw::reader::Reader::open(&detected.path)?;
751                let mut src = with_min_intensity_opt(src, min_intensity);
752                let meta = src.run_metadata();
753                let recs: Vec<_> = src.iter_spectra().collect();
754                Ok((recs, meta))
755            }
756            #[cfg(not(feature = "shimadzu"))]
757            Err(Error::FeatureDisabled { vendor: "shimadzu" })
758        }
759    }
760}
761
762/// Like [`collect`], but visits each spectrum through `on_spectrum` as soon
763/// as it is decoded instead of buffering the whole run into a `Vec` first.
764/// Memory use is bounded by whatever `on_spectrum` itself retains, not by
765/// the acquisition size - the mzML/Arrow writers already stream this way
766/// internally; this gives callers that need their own second pass (Arrow
767/// batching, conformance checks, summaries) the same property instead of
768/// going through [`collect`].
769///
770/// If `on_spectrum` returns an error, iteration stops immediately and that
771/// error is returned.
772#[allow(clippy::needless_pass_by_value)]
773pub fn stream(
774    detected: Detected,
775    mut on_spectrum: impl FnMut(openmassspec_core::SpectrumRecord) -> Result<()>,
776) -> Result<openmassspec_core::RunMetadata> {
777    #[allow(unused_imports)]
778    use openmassspec_core::SpectrumSource;
779    match detected.format {
780        VendorFormat::ThermoRaw => {
781            #[cfg(feature = "thermo")]
782            {
783                use std::fs::File;
784                use std::io::BufReader;
785                let raw = opentfraw::RawFileReader::open_path(&detected.path)?;
786                let mut source = BufReader::with_capacity(2 << 20, File::open(&detected.path)?);
787                let filename = detected
788                    .path
789                    .file_name()
790                    .and_then(|n| n.to_str())
791                    .unwrap_or("unknown.raw");
792                let mut src =
793                    opentfraw::mzml::OpenTfRawSource::new(&raw, &mut source, filename, false);
794                let meta = src.run_metadata();
795                for rec in src.iter_spectra() {
796                    on_spectrum(rec)?;
797                }
798                Ok(meta)
799            }
800            #[cfg(not(feature = "thermo"))]
801            Err(Error::FeatureDisabled { vendor: "thermo" })
802        }
803        VendorFormat::BrukerTdf => {
804            #[cfg(feature = "bruker")]
805            {
806                let mut src = opentimstdf::mzml::TdfSource::open(&detected.path)?;
807                let meta = src.run_metadata();
808                for rec in src.iter_spectra() {
809                    on_spectrum(rec)?;
810                }
811                Ok(meta)
812            }
813            #[cfg(not(feature = "bruker"))]
814            Err(Error::FeatureDisabled { vendor: "bruker" })
815        }
816        VendorFormat::WatersRaw => {
817            #[cfg(feature = "waters")]
818            {
819                let mut src = openwraw::mzml::WatersSource::open(&detected.path)?;
820                let meta = src.run_metadata();
821                for rec in src.iter_spectra() {
822                    on_spectrum(rec)?;
823                }
824                Ok(meta)
825            }
826            #[cfg(not(feature = "waters"))]
827            Err(Error::FeatureDisabled { vendor: "waters" })
828        }
829        VendorFormat::AgilentMassHunter => {
830            #[cfg(feature = "agilent")]
831            {
832                let mut src = openaraw::reader::Reader::open(&detected.path)?;
833                let meta = src.run_metadata();
834                for rec in src.iter_spectra() {
835                    on_spectrum(rec)?;
836                }
837                Ok(meta)
838            }
839            #[cfg(not(feature = "agilent"))]
840            Err(Error::FeatureDisabled { vendor: "agilent" })
841        }
842        VendorFormat::SciexWiff => {
843            #[cfg(feature = "sciex")]
844            {
845                let mut src = opensxraw::reader::Reader::open(&detected.path)?;
846                let meta = src.run_metadata();
847                for rec in src.iter_spectra() {
848                    on_spectrum(rec)?;
849                }
850                Ok(meta)
851            }
852            #[cfg(not(feature = "sciex"))]
853            Err(Error::FeatureDisabled { vendor: "sciex" })
854        }
855        VendorFormat::ShimadzuLabSolutions => {
856            #[cfg(feature = "shimadzu")]
857            {
858                let mut src = openszraw::reader::Reader::open(&detected.path)?;
859                let meta = src.run_metadata();
860                for rec in src.iter_spectra() {
861                    on_spectrum(rec)?;
862                }
863                Ok(meta)
864            }
865            #[cfg(not(feature = "shimadzu"))]
866            Err(Error::FeatureDisabled { vendor: "shimadzu" })
867        }
868    }
869}
870
871/// Like [`stream`], but every profile-mode spectrum is centroided first via
872/// [`openmassspec_core::Centroided`]. Already-centroided spectra pass
873/// through unchanged. `min_intensity`, when `Some`, discards picked peaks
874/// below that noise floor.
875#[allow(clippy::needless_pass_by_value, unused_variables)]
876pub fn stream_centroided(
877    detected: Detected,
878    min_intensity: Option<f32>,
879    mut on_spectrum: impl FnMut(openmassspec_core::SpectrumRecord) -> Result<()>,
880) -> Result<openmassspec_core::RunMetadata> {
881    #[allow(unused_imports)]
882    use openmassspec_core::SpectrumSource;
883    match detected.format {
884        VendorFormat::ThermoRaw => {
885            #[cfg(feature = "thermo")]
886            {
887                use std::fs::File;
888                use std::io::BufReader;
889                let raw = opentfraw::RawFileReader::open_path(&detected.path)?;
890                let mut source = BufReader::with_capacity(2 << 20, File::open(&detected.path)?);
891                let filename = detected
892                    .path
893                    .file_name()
894                    .and_then(|n| n.to_str())
895                    .unwrap_or("unknown.raw");
896                let src = opentfraw::mzml::OpenTfRawSource::new(&raw, &mut source, filename, false);
897                let mut src = with_min_intensity_opt(src, min_intensity);
898                let meta = src.run_metadata();
899                for rec in src.iter_spectra() {
900                    on_spectrum(rec)?;
901                }
902                Ok(meta)
903            }
904            #[cfg(not(feature = "thermo"))]
905            Err(Error::FeatureDisabled { vendor: "thermo" })
906        }
907        VendorFormat::BrukerTdf => {
908            #[cfg(feature = "bruker")]
909            {
910                let src = opentimstdf::mzml::TdfSource::open(&detected.path)?;
911                let mut src = with_min_intensity_opt(src, min_intensity);
912                let meta = src.run_metadata();
913                for rec in src.iter_spectra() {
914                    on_spectrum(rec)?;
915                }
916                Ok(meta)
917            }
918            #[cfg(not(feature = "bruker"))]
919            Err(Error::FeatureDisabled { vendor: "bruker" })
920        }
921        VendorFormat::WatersRaw => {
922            #[cfg(feature = "waters")]
923            {
924                let src = openwraw::mzml::WatersSource::open(&detected.path)?;
925                let mut src = with_min_intensity_opt(src, min_intensity);
926                let meta = src.run_metadata();
927                for rec in src.iter_spectra() {
928                    on_spectrum(rec)?;
929                }
930                Ok(meta)
931            }
932            #[cfg(not(feature = "waters"))]
933            Err(Error::FeatureDisabled { vendor: "waters" })
934        }
935        VendorFormat::AgilentMassHunter => {
936            #[cfg(feature = "agilent")]
937            {
938                let src = openaraw::reader::Reader::open(&detected.path)?;
939                let mut src = with_min_intensity_opt(src, min_intensity);
940                let meta = src.run_metadata();
941                for rec in src.iter_spectra() {
942                    on_spectrum(rec)?;
943                }
944                Ok(meta)
945            }
946            #[cfg(not(feature = "agilent"))]
947            Err(Error::FeatureDisabled { vendor: "agilent" })
948        }
949        VendorFormat::SciexWiff => {
950            #[cfg(feature = "sciex")]
951            {
952                let src = opensxraw::reader::Reader::open(&detected.path)?;
953                let mut src = with_min_intensity_opt(src, min_intensity);
954                let meta = src.run_metadata();
955                for rec in src.iter_spectra() {
956                    on_spectrum(rec)?;
957                }
958                Ok(meta)
959            }
960            #[cfg(not(feature = "sciex"))]
961            Err(Error::FeatureDisabled { vendor: "sciex" })
962        }
963        VendorFormat::ShimadzuLabSolutions => {
964            #[cfg(feature = "shimadzu")]
965            {
966                let src = openszraw::reader::Reader::open(&detected.path)?;
967                let mut src = with_min_intensity_opt(src, min_intensity);
968                let meta = src.run_metadata();
969                for rec in src.iter_spectra() {
970                    on_spectrum(rec)?;
971                }
972                Ok(meta)
973            }
974            #[cfg(not(feature = "shimadzu"))]
975            Err(Error::FeatureDisabled { vendor: "shimadzu" })
976        }
977    }
978}
979
980/// Return only the run-level metadata for `detected`, without decoding any
981/// spectra. Metadata (instrument, source file format, native ID format,
982/// software name/version) is already available as soon as the vendor
983/// source is opened, so callers that only need it - like the Python
984/// binding's `run_info` - can skip the decode pass entirely instead of
985/// going through [`collect`] and discarding the records.
986#[allow(clippy::needless_pass_by_value)]
987pub fn metadata_only(detected: Detected) -> Result<openmassspec_core::RunMetadata> {
988    #[allow(unused_imports)]
989    use openmassspec_core::SpectrumSource;
990    match detected.format {
991        VendorFormat::ThermoRaw => {
992            #[cfg(feature = "thermo")]
993            {
994                use std::fs::File;
995                use std::io::BufReader;
996                let raw = opentfraw::RawFileReader::open_path(&detected.path)?;
997                let mut source = BufReader::with_capacity(2 << 20, File::open(&detected.path)?);
998                let filename = detected
999                    .path
1000                    .file_name()
1001                    .and_then(|n| n.to_str())
1002                    .unwrap_or("unknown.raw");
1003                let src = opentfraw::mzml::OpenTfRawSource::new(&raw, &mut source, filename, false);
1004                Ok(src.run_metadata())
1005            }
1006            #[cfg(not(feature = "thermo"))]
1007            Err(Error::FeatureDisabled { vendor: "thermo" })
1008        }
1009        VendorFormat::BrukerTdf => {
1010            #[cfg(feature = "bruker")]
1011            {
1012                let src = opentimstdf::mzml::TdfSource::open(&detected.path)?;
1013                Ok(src.run_metadata())
1014            }
1015            #[cfg(not(feature = "bruker"))]
1016            Err(Error::FeatureDisabled { vendor: "bruker" })
1017        }
1018        VendorFormat::WatersRaw => {
1019            #[cfg(feature = "waters")]
1020            {
1021                let src = openwraw::mzml::WatersSource::open(&detected.path)?;
1022                Ok(src.run_metadata())
1023            }
1024            #[cfg(not(feature = "waters"))]
1025            Err(Error::FeatureDisabled { vendor: "waters" })
1026        }
1027        VendorFormat::AgilentMassHunter => {
1028            #[cfg(feature = "agilent")]
1029            {
1030                let src = openaraw::reader::Reader::open(&detected.path)?;
1031                Ok(src.run_metadata())
1032            }
1033            #[cfg(not(feature = "agilent"))]
1034            Err(Error::FeatureDisabled { vendor: "agilent" })
1035        }
1036        VendorFormat::SciexWiff => {
1037            #[cfg(feature = "sciex")]
1038            {
1039                let src = opensxraw::reader::Reader::open(&detected.path)?;
1040                Ok(src.run_metadata())
1041            }
1042            #[cfg(not(feature = "sciex"))]
1043            Err(Error::FeatureDisabled { vendor: "sciex" })
1044        }
1045        VendorFormat::ShimadzuLabSolutions => {
1046            #[cfg(feature = "shimadzu")]
1047            {
1048                let src = openszraw::reader::Reader::open(&detected.path)?;
1049                Ok(src.run_metadata())
1050            }
1051            #[cfg(not(feature = "shimadzu"))]
1052            Err(Error::FeatureDisabled { vendor: "shimadzu" })
1053        }
1054    }
1055}
1056
1057/// A trivial in-memory [`openmassspec_core::SpectrumSource`] backed by a
1058/// `Vec<SpectrumRecord>` + a [`openmassspec_core::RunMetadata`]. Hand it
1059/// to `openmassspec_core::write_mzml` when you already have the records
1060/// in hand and just want to emit mzML.
1061pub struct VecSource {
1062    pub metadata: openmassspec_core::RunMetadata,
1063    pub records: Vec<openmassspec_core::SpectrumRecord>,
1064}
1065
1066impl VecSource {
1067    pub fn new(
1068        metadata: openmassspec_core::RunMetadata,
1069        records: Vec<openmassspec_core::SpectrumRecord>,
1070    ) -> Self {
1071        Self { metadata, records }
1072    }
1073}
1074
1075impl openmassspec_core::SpectrumSource for VecSource {
1076    fn run_metadata(&self) -> openmassspec_core::RunMetadata {
1077        self.metadata.clone()
1078    }
1079    fn iter_spectra<'s>(
1080        &'s mut self,
1081    ) -> Box<dyn Iterator<Item = openmassspec_core::SpectrumRecord> + 's> {
1082        Box::new(self.records.drain(..))
1083    }
1084    fn spectrum_count_hint(&self) -> Option<usize> {
1085        Some(self.records.len())
1086    }
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091    use super::*;
1092    use std::io::Write;
1093
1094    #[test]
1095    fn detect_returns_none_for_garbage_file() {
1096        let tmp = tempfile_path();
1097        std::fs::write(&tmp, b"hello").unwrap();
1098        assert!(detect_format(&tmp).is_none());
1099        let _ = std::fs::remove_file(&tmp);
1100    }
1101
1102    #[test]
1103    fn detect_returns_thermo_for_finnigan_magic() {
1104        let tmp = tempfile_path();
1105        let mut f = std::fs::File::create(&tmp).unwrap();
1106        // 2-byte version word + "Finnigan" in UTF-16LE + trailing garbage.
1107        f.write_all(&[
1108            0x01, 0xa1, 0x46, 0x00, 0x69, 0x00, 0x6e, 0x00, 0x6e, 0x00, 0x69, 0x00, 0x67, 0x00,
1109            0x61, 0x00, 0x6e, 0x00, 0xff, 0xff,
1110        ])
1111        .unwrap();
1112        let det = detect_format(&tmp).expect("detect");
1113        assert_eq!(det.format, VendorFormat::ThermoRaw);
1114        let _ = std::fs::remove_file(&tmp);
1115    }
1116
1117    #[test]
1118    fn detect_returns_bruker_for_tdf_layout() {
1119        let tmp = tempfile_dir();
1120        std::fs::write(tmp.join("analysis.tdf"), b"").unwrap();
1121        std::fs::write(tmp.join("analysis.tdf_bin"), b"").unwrap();
1122        let det = detect_format(&tmp).expect("detect");
1123        assert_eq!(det.format, VendorFormat::BrukerTdf);
1124        let _ = std::fs::remove_dir_all(&tmp);
1125    }
1126
1127    #[test]
1128    fn detect_returns_waters_for_header_layout() {
1129        let tmp = tempfile_dir();
1130        std::fs::write(tmp.join("_HEADER.TXT"), b"$$ FAKE\n").unwrap();
1131        let det = detect_format(&tmp).expect("detect");
1132        assert_eq!(det.format, VendorFormat::WatersRaw);
1133        let _ = std::fs::remove_dir_all(&tmp);
1134    }
1135
1136    #[test]
1137    fn detect_returns_agilent_for_acqdata_layout() {
1138        let tmp = tempfile_dir();
1139        let acq = tmp.join("AcqData");
1140        std::fs::create_dir_all(&acq).unwrap();
1141        std::fs::write(acq.join("MSScan.bin"), b"").unwrap();
1142        let det = detect_format(&tmp).expect("detect");
1143        assert_eq!(det.format, VendorFormat::AgilentMassHunter);
1144        let _ = std::fs::remove_dir_all(&tmp);
1145    }
1146
1147    #[test]
1148    fn detect_returns_sciex_for_wiff_with_scan_sibling() {
1149        let dir = tempfile_dir();
1150        let wiff = dir.join("run.wiff");
1151        std::fs::write(&wiff, b"\xd0\xcf\x11\xe0").unwrap();
1152        std::fs::write(dir.join("run.wiff.scan"), b"").unwrap();
1153        let det = detect_format(&wiff).expect("detect");
1154        assert_eq!(det.format, VendorFormat::SciexWiff);
1155        let _ = std::fs::remove_dir_all(&dir);
1156    }
1157
1158    #[test]
1159    fn detect_returns_none_for_wiff_without_scan_sibling() {
1160        let dir = tempfile_dir();
1161        let wiff = dir.join("lonely.wiff");
1162        std::fs::write(&wiff, b"\xd0\xcf\x11\xe0").unwrap();
1163        // No .wiff.scan alongside -> not a usable SCIEX pair.
1164        assert!(detect_format(&wiff).is_none());
1165        let _ = std::fs::remove_dir_all(&dir);
1166    }
1167
1168    const CFBF_MAGIC_8: [u8; 8] = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
1169
1170    #[test]
1171    fn detect_returns_shimadzu_for_lcd_with_cfbf_magic() {
1172        let dir = tempfile_dir();
1173        let lcd = dir.join("run.lcd");
1174        std::fs::write(&lcd, CFBF_MAGIC_8).unwrap();
1175        let det = detect_format(&lcd).expect("detect");
1176        assert_eq!(det.format, VendorFormat::ShimadzuLabSolutions);
1177        let _ = std::fs::remove_dir_all(&dir);
1178    }
1179
1180    #[test]
1181    fn detect_returns_shimadzu_for_qgd_with_cfbf_magic() {
1182        let dir = tempfile_dir();
1183        let qgd = dir.join("run.qgd");
1184        std::fs::write(&qgd, CFBF_MAGIC_8).unwrap();
1185        let det = detect_format(&qgd).expect("detect");
1186        assert_eq!(det.format, VendorFormat::ShimadzuLabSolutions);
1187        let _ = std::fs::remove_dir_all(&dir);
1188    }
1189
1190    #[test]
1191    fn detect_returns_none_for_lcd_without_cfbf_magic() {
1192        let dir = tempfile_dir();
1193        let lcd = dir.join("not_really.lcd");
1194        std::fs::write(&lcd, b"not a real container").unwrap();
1195        assert!(detect_format(&lcd).is_none());
1196        let _ = std::fs::remove_dir_all(&dir);
1197    }
1198
1199    #[test]
1200    fn detect_returns_none_for_unrelated_extension_with_cfbf_magic() {
1201        // The CFBF/OLE2 signature alone is not Shimadzu-specific (SCIEX's
1202        // legacy .wiff also uses it) - the extension must match too.
1203        let dir = tempfile_dir();
1204        let other = dir.join("run.xyz");
1205        std::fs::write(&other, CFBF_MAGIC_8).unwrap();
1206        assert!(detect_format(&other).is_none());
1207        let _ = std::fs::remove_dir_all(&dir);
1208    }
1209
1210    fn tempfile_path() -> PathBuf {
1211        let pid = std::process::id();
1212        let mut p = std::env::temp_dir();
1213        p.push(format!("msio-test-{pid}-{:p}", &pid));
1214        p
1215    }
1216
1217    fn tempfile_dir() -> PathBuf {
1218        let p = tempfile_path();
1219        let _ = std::fs::create_dir_all(&p);
1220        p
1221    }
1222
1223    #[test]
1224    fn convert_unsupported_format_returns_typed_error() {
1225        // `detect_format` returns None here, so callers can't reach
1226        // `convert_to_mzml`. Exercise the FeatureDisabled / Mzml paths
1227        // through the public `Error` variants directly to keep this
1228        // test feature-agnostic.
1229        let e: Error = std::io::Error::other("boom").into();
1230        assert!(matches!(e, Error::Io(_)));
1231        let e = Error::FeatureDisabled { vendor: "thermo" };
1232        assert_eq!(
1233            e.to_string(),
1234            "openmassspec-io was built without the 'thermo' feature"
1235        );
1236        let e = Error::UnsupportedFormat(PathBuf::from("/tmp/nope"));
1237        assert!(matches!(e, Error::UnsupportedFormat(_)));
1238    }
1239}