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`); the
8//! `all` meta-feature pulls in every supported vendor. The reader crates
9//! are private dependencies: no reader type appears in this crate's API,
10//! so a reader's major release does not change it. Reader errors arrive
11//! as [`Error::Reader`]; [`Error::reader_error`] gives the reader's own
12//! error for downcasting. The `arrow` feature re-exports
13//! [`openmassspec-arrow`](https://docs.rs/openmassspec-arrow) as
14//! [`arrow`]; no Arrow type appears anywhere else in this crate's API.
15//!
16//! Even with no features enabled, [`detect_format`] is available so
17//! callers can probe a path without paying the compile-time cost of a
18//! parser they will not use.
19//!
20//! # Errors in the record stream
21//!
22//! Every vendor source yields `Result` records: a spectrum or chromatogram
23//! the parser cannot decode is reported as an error, never dropped.
24//! [`collect`], [`stream`], and [`collect_chromatograms`] stop at the first
25//! such error and return it. [`convert_to_mzml`] does the same by default;
26//! [`ConvertOptions::with_record_error_policy`] with
27//! [`RecordErrorPolicy::Skip`]
28//! leaves unreadable records out instead and reports how many in the
29//! returned [`WriteSummary`].
30//!
31//! # Centroiding
32//!
33//! [`ConvertOptions::with_centroid`], [`collect_centroided`], and
34//! [`stream_centroided`] centroid every profile-mode spectrum first via
35//! [`openmassspec_core::Centroided`]. Centroiding is always opt-in.
36//!
37//! # Example
38//!
39//! ```no_run
40//! use std::path::Path;
41//! use openmassspec_io::core::RecordErrorPolicy;
42//! use openmassspec_io::{convert_to_mzml, detect_format, ConvertOptions};
43//!
44//! let detected = detect_format(Path::new("run.raw")).expect("supported format");
45//! let opts = ConvertOptions::new()
46//!     .with_indexed(true)
47//!     .with_record_error_policy(RecordErrorPolicy::Skip);
48//! let summary = convert_to_mzml(&detected, Path::new("run.mzML"), &opts)?;
49//! if summary.skipped() > 0 {
50//!     eprintln!("warning: {} unreadable records skipped", summary.skipped());
51//! }
52//! # Ok::<(), openmassspec_io::Error>(())
53//! ```
54
55#![forbid(unsafe_code)]
56
57use std::io::Write;
58use std::path::{Path, PathBuf};
59
60use openmassspec_core::{
61    ChromatogramIter, ChromatogramRecord, Compression, CvTerm, RecordErrorPolicy, RunMetadata,
62    SpectrumIter, SpectrumRecord, SpectrumSource, WriteOptions, WriteSummary,
63};
64
65mod error;
66pub use error::{BoxError, Error, Result};
67
68pub use openmassspec_core as core;
69
70/// The [`openmassspec-arrow`](https://docs.rs/openmassspec-arrow) crate:
71/// the Arrow schema and `RecordBatch` builder for spectrum records.
72#[cfg(feature = "arrow")]
73pub use openmassspec_arrow as arrow;
74
75/// Detected on-disk vendor / format family.
76///
77/// New vendors are added as new variants in minor releases; match with a
78/// wildcard arm.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80#[non_exhaustive]
81pub enum VendorFormat {
82    /// Thermo Fisher Finnigan `.raw` (file).
83    ThermoRaw,
84    /// Bruker timsTOF TDF (directory ending in `.d/` containing
85    /// `analysis.tdf` + `analysis.tdf_bin`).
86    BrukerTdf,
87    /// Waters MassLynx bundle (directory ending in `.raw/` containing
88    /// `_HEADER.TXT`).
89    WatersRaw,
90    /// Agilent MassHunter bundle (directory containing an `AcqData/`
91    /// subdirectory with `MSScan.bin`).
92    AgilentMassHunter,
93    /// SCIEX legacy `.wiff` file (paired with a sibling `.wiff.scan`).
94    SciexWiff,
95    /// Shimadzu LabSolutions `.qgd` (GC-MS) or `.lcd` (LC-MS, IT-TOF or
96    /// QTOF) file. `openszraw::reader::Reader` auto-detects which of
97    /// the three on-disk variants it is from the file's own CFBF
98    /// stream layout, so a single `VendorFormat` variant covers all of
99    /// them here too.
100    ShimadzuLabSolutions,
101}
102
103impl VendorFormat {
104    /// Vendor-name string suitable for logs and the CLI: `"thermo"`,
105    /// `"bruker"`, `"waters"`, `"agilent"`, `"sciex"`, or `"shimadzu"`.
106    pub fn name(self) -> &'static str {
107        match self {
108            Self::ThermoRaw => "thermo",
109            Self::BrukerTdf => "bruker",
110            Self::WatersRaw => "waters",
111            Self::AgilentMassHunter => "agilent",
112            Self::SciexWiff => "sciex",
113            Self::ShimadzuLabSolutions => "shimadzu",
114        }
115    }
116}
117
118/// Result of probing a filesystem path for a supported vendor format.
119#[derive(Debug, Clone, PartialEq, Eq)]
120#[non_exhaustive]
121pub struct Detected {
122    /// Canonical path to feed back into the matching vendor reader.
123    /// For directory-based formats this is the bundle directory; for
124    /// Thermo, the `.raw` file itself.
125    pub path: PathBuf,
126    /// Identified format.
127    pub format: VendorFormat,
128}
129
130impl Detected {
131    /// Pair a path with a format without probing the filesystem. Use this
132    /// to force a format that [`detect_format`] does not recognize; the
133    /// vendor reader still validates the input when it is opened.
134    pub fn new(path: impl Into<PathBuf>, format: VendorFormat) -> Self {
135        Self {
136            path: path.into(),
137            format,
138        }
139    }
140}
141
142/// Inspect `path` (file or directory) and return the matching vendor
143/// format, or `None` when none of the supported signatures match.
144///
145/// This function is always available, even with no features enabled,
146/// so a host application can decide which feature to enable at compile
147/// time based on a runtime probe.
148pub fn detect_format(path: &Path) -> Option<Detected> {
149    let format = if path.is_dir() {
150        // Bruker .d/ first, then Agilent .d/, then Waters .raw/.
151        // Bruker and Agilent both use a `.d` extension, so they are
152        // disambiguated by contents (analysis.tdf vs AcqData/MSScan.bin),
153        // not by the directory name.
154        if path.join("analysis.tdf").is_file() && path.join("analysis.tdf_bin").is_file() {
155            VendorFormat::BrukerTdf
156        } else if path.join("AcqData").join("MSScan.bin").is_file() {
157            VendorFormat::AgilentMassHunter
158        } else if path.join("_HEADER.TXT").is_file() {
159            VendorFormat::WatersRaw
160        } else {
161            return None;
162        }
163    } else if path.is_file() {
164        if is_thermo_raw(path) {
165            VendorFormat::ThermoRaw
166        } else if is_sciex_wiff(path) {
167            VendorFormat::SciexWiff
168        } else if is_shimadzu_labsolutions(path) {
169            VendorFormat::ShimadzuLabSolutions
170        } else {
171            return None;
172        }
173    } else {
174        return None;
175    };
176    Some(Detected::new(path, format))
177}
178
179/// Returns `true` if `path` looks like a SCIEX legacy `.wiff` file: a
180/// `.wiff` extension with a sibling `.wiff.scan` file alongside it (the
181/// scan data the reader needs). The extension check is case-insensitive.
182fn is_sciex_wiff(path: &Path) -> bool {
183    let is_wiff_ext = path
184        .extension()
185        .and_then(|e| e.to_str())
186        .is_some_and(|e| e.eq_ignore_ascii_case("wiff"));
187    if !is_wiff_ext {
188        return false;
189    }
190    let mut scan = path.as_os_str().to_os_string();
191    scan.push(".scan");
192    Path::new(&scan).is_file()
193}
194
195/// Returns `true` if `path` looks like a Shimadzu LabSolutions `.qgd` or
196/// `.lcd` file: one of those two extensions (case-insensitive) whose
197/// first 8 bytes are the CFBF/OLE2 container signature. Unlike the
198/// SCIEX `.wiff` check, there is no sibling file to corroborate against
199/// (Shimadzu raw files are self-contained), so the magic-byte check is
200/// the only content-level signal available - still strictly more
201/// verification than a bare extension check.
202fn is_shimadzu_labsolutions(path: &Path) -> bool {
203    let is_shimadzu_ext = path
204        .extension()
205        .and_then(|e| e.to_str())
206        .is_some_and(|e| e.eq_ignore_ascii_case("qgd") || e.eq_ignore_ascii_case("lcd"));
207    if !is_shimadzu_ext {
208        return false;
209    }
210    const CFBF_MAGIC: [u8; 8] = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
211    read_prefix::<8>(path).is_some_and(|buf| buf == CFBF_MAGIC)
212}
213
214/// Returns `true` if the file looks like a Thermo Finnigan `.raw`.
215///
216/// The Finnigan signature is the UTF-16LE string `Finnigan` starting at
217/// offset 2 (the first two bytes are a small header version word).
218fn is_thermo_raw(path: &Path) -> bool {
219    // "Finnigan" in UTF-16LE: F.i.n.n.i.g.a.n. (16 bytes) at offset 2.
220    const FINNIGAN_UTF16LE: [u8; 16] = [
221        0x46, 0x00, 0x69, 0x00, 0x6e, 0x00, 0x6e, 0x00, 0x69, 0x00, 0x67, 0x00, 0x61, 0x00, 0x6e,
222        0x00,
223    ];
224    read_prefix::<18>(path).is_some_and(|buf| buf[2..18] == FINNIGAN_UTF16LE)
225}
226
227/// First `N` bytes of `path`, or `None` when the file cannot be opened or
228/// is shorter than `N` bytes. Detection treats both as "not this format".
229fn read_prefix<const N: usize>(path: &Path) -> Option<[u8; N]> {
230    use std::io::Read;
231    let mut f = std::fs::File::open(path).ok()?;
232    let mut buf = [0u8; N];
233    f.read_exact(&mut buf).ok()?;
234    Some(buf)
235}
236
237// ---------------------------------------------------------------------
238// Source dispatch
239// ---------------------------------------------------------------------
240
241/// Open the vendor source for `detected` and run `f` on it.
242///
243/// This is the single dispatch point every other function in this crate
244/// goes through. Use it to drive a source in a way the convenience
245/// functions do not cover, for example a custom
246/// [`WriteOptions`] or a consumer that
247/// handles `Err` records itself.
248///
249/// The source is closed when `f` returns.
250///
251/// # Errors
252///
253/// [`Error::FeatureDisabled`] when the vendor's feature is off,
254/// [`Error::Reader`] when the input cannot be opened, or whatever `f`
255/// returns. An `openmassspec_core::Error::Vendor` that `f` returns (an
256/// unreadable record) is reported as [`Error::Reader`] for this vendor.
257pub fn with_source<T>(
258    detected: &Detected,
259    f: impl FnOnce(&mut dyn SpectrumSource) -> Result<T>,
260) -> Result<T> {
261    let vendor = detected.format.name();
262    with_source_inner(detected, f).map_err(|e| e.attribute_to(vendor))
263}
264
265/// Error mapper for a vendor reader's own error type.
266#[allow(dead_code)]
267fn reader_err<E>(vendor: &'static str) -> impl FnOnce(E) -> Error
268where
269    E: std::error::Error + Send + Sync + 'static,
270{
271    move |e| Error::reader(vendor, e)
272}
273
274#[cfg_attr(
275    not(all(
276        feature = "thermo",
277        feature = "bruker",
278        feature = "waters",
279        feature = "agilent",
280        feature = "sciex",
281        feature = "shimadzu"
282    )),
283    allow(unused_variables)
284)]
285fn with_source_inner<T>(
286    detected: &Detected,
287    f: impl FnOnce(&mut dyn SpectrumSource) -> Result<T>,
288) -> Result<T> {
289    let path = detected.path.as_path();
290    match detected.format {
291        VendorFormat::ThermoRaw => {
292            #[cfg(feature = "thermo")]
293            {
294                let raw =
295                    opentfraw::RawFileReader::open_path(path).map_err(reader_err("thermo"))?;
296                let mut file =
297                    std::io::BufReader::with_capacity(2 << 20, std::fs::File::open(path)?);
298                let filename = path
299                    .file_name()
300                    .map_or_else(|| "unknown.raw".into(), |n| n.to_string_lossy());
301                let mut src =
302                    opentfraw::mzml::OpenTfRawSource::new(&raw, &mut file, &filename, false);
303                f(&mut src)
304            }
305            #[cfg(not(feature = "thermo"))]
306            Err(Error::FeatureDisabled { vendor: "thermo" })
307        }
308        VendorFormat::BrukerTdf => {
309            #[cfg(feature = "bruker")]
310            {
311                f(&mut opentimstdf::mzml::TdfSource::open(path).map_err(reader_err("bruker"))?)
312            }
313            #[cfg(not(feature = "bruker"))]
314            Err(Error::FeatureDisabled { vendor: "bruker" })
315        }
316        VendorFormat::WatersRaw => {
317            #[cfg(feature = "waters")]
318            {
319                f(&mut openwraw::mzml::WatersSource::open(path).map_err(reader_err("waters"))?)
320            }
321            #[cfg(not(feature = "waters"))]
322            Err(Error::FeatureDisabled { vendor: "waters" })
323        }
324        VendorFormat::AgilentMassHunter => {
325            #[cfg(feature = "agilent")]
326            {
327                f(&mut openaraw::reader::Reader::open(path).map_err(reader_err("agilent"))?)
328            }
329            #[cfg(not(feature = "agilent"))]
330            Err(Error::FeatureDisabled { vendor: "agilent" })
331        }
332        VendorFormat::SciexWiff => {
333            #[cfg(feature = "sciex")]
334            {
335                f(&mut opensxraw::reader::Reader::open(path).map_err(reader_err("sciex"))?)
336            }
337            #[cfg(not(feature = "sciex"))]
338            Err(Error::FeatureDisabled { vendor: "sciex" })
339        }
340        VendorFormat::ShimadzuLabSolutions => {
341            #[cfg(feature = "shimadzu")]
342            {
343                f(&mut openszraw::reader::Reader::open(path).map_err(reader_err("shimadzu"))?)
344            }
345            #[cfg(not(feature = "shimadzu"))]
346            Err(Error::FeatureDisabled { vendor: "shimadzu" })
347        }
348    }
349}
350
351/// Like [`with_source`], but every profile-mode spectrum is centroided
352/// via [`openmassspec_core::Centroided`] before `f` sees it.
353fn with_centroided_source<T>(
354    detected: &Detected,
355    min_intensity: Option<f32>,
356    f: impl FnOnce(&mut dyn SpectrumSource) -> Result<T>,
357) -> Result<T> {
358    with_source(detected, |src| {
359        let mut centroided = openmassspec_core::Centroided::new(DynSource(src));
360        if let Some(v) = min_intensity {
361            centroided = centroided.with_min_intensity(v);
362        }
363        f(&mut centroided)
364    })
365}
366
367/// Sized wrapper so a `&mut dyn SpectrumSource` can go into adapters that
368/// take an owned `S: SpectrumSource`.
369struct DynSource<'a>(&'a mut dyn SpectrumSource);
370
371impl SpectrumSource for DynSource<'_> {
372    fn run_metadata(&self) -> RunMetadata {
373        self.0.run_metadata()
374    }
375    fn iter_spectra<'s>(&'s mut self) -> SpectrumIter<'s> {
376        self.0.iter_spectra()
377    }
378    fn iter_chromatograms<'s>(&'s mut self) -> ChromatogramIter<'s> {
379        self.0.iter_chromatograms()
380    }
381    fn spectrum_count_hint(&self) -> Option<usize> {
382        self.0.spectrum_count_hint()
383    }
384    fn additional_processing_steps(&self) -> Vec<CvTerm> {
385        self.0.additional_processing_steps()
386    }
387}
388
389// ---------------------------------------------------------------------
390// mzML conversion
391// ---------------------------------------------------------------------
392
393/// Options for [`convert_to_mzml`] and [`convert_to_mzml_writer`].
394///
395/// The defaults write un-indexed mzML with zlib-compressed arrays, no
396/// centroiding, and stop at the first unreadable record.
397///
398/// ```
399/// use openmassspec_io::core::RecordErrorPolicy;
400/// use openmassspec_io::ConvertOptions;
401///
402/// let opts = ConvertOptions::new()
403///     .with_indexed(true)
404///     .with_centroid(true)
405///     .with_record_error_policy(RecordErrorPolicy::Skip);
406/// assert!(opts.indexed);
407/// ```
408#[derive(Debug, Clone, Default, PartialEq)]
409#[non_exhaustive]
410pub struct ConvertOptions {
411    /// Emit indexed mzML (`<indexList>` and `<fileChecksum>`).
412    pub indexed: bool,
413    /// Centroid every profile-mode spectrum before writing (local-maxima
414    /// peak picking; already-centroided spectra pass through unchanged).
415    pub centroid: bool,
416    /// When centroiding, discard picked peaks below this intensity.
417    /// Ignored unless [`centroid`](Self::centroid) is set.
418    pub centroid_min_intensity: Option<f32>,
419    /// Writer options from `openmassspec-core`: binary array compression
420    /// and the record-error policy.
421    pub write: WriteOptions,
422}
423
424impl ConvertOptions {
425    /// Default options (see the type-level docs).
426    pub fn new() -> Self {
427        Self::default()
428    }
429
430    /// Emit indexed mzML.
431    #[must_use]
432    pub fn with_indexed(mut self, indexed: bool) -> Self {
433        self.indexed = indexed;
434        self
435    }
436
437    /// Centroid profile-mode spectra before writing.
438    #[must_use]
439    pub fn with_centroid(mut self, centroid: bool) -> Self {
440        self.centroid = centroid;
441        self
442    }
443
444    /// Noise floor for centroiding.
445    #[must_use]
446    pub fn with_centroid_min_intensity(mut self, min_intensity: impl Into<Option<f32>>) -> Self {
447        self.centroid_min_intensity = min_intensity.into();
448        self
449    }
450
451    /// Binary data array compression.
452    #[must_use]
453    pub fn with_compression(mut self, compression: Compression) -> Self {
454        self.write = self.write.with_compression(compression);
455        self
456    }
457
458    /// What to do with a spectrum or chromatogram the reader cannot
459    /// decode. [`RecordErrorPolicy::Fail`] (the default) stops and returns
460    /// the error; [`RecordErrorPolicy::Skip`] leaves it out and counts it
461    /// in the returned [`WriteSummary`].
462    #[must_use]
463    pub fn with_record_error_policy(mut self, policy: RecordErrorPolicy) -> Self {
464        self.write = self.write.with_record_error_policy(policy);
465        self
466    }
467
468    /// Replace the writer options wholesale.
469    #[must_use]
470    pub fn with_write_options(mut self, write: WriteOptions) -> Self {
471        self.write = write;
472        self
473    }
474}
475
476/// Convert a detected vendor acquisition to an mzML file at `output`.
477///
478/// Returns what was written, including how many records were skipped when
479/// `opts` selects [`RecordErrorPolicy::Skip`]. Callers that skip must
480/// surface [`WriteSummary::skipped`] to their users.
481///
482/// # Errors
483///
484/// The vendor reader's error when the input cannot be opened, the first
485/// unreadable record under [`RecordErrorPolicy::Fail`], or an I/O error.
486/// On error `output` may hold a truncated document.
487pub fn convert_to_mzml(
488    detected: &Detected,
489    output: &Path,
490    opts: &ConvertOptions,
491) -> Result<WriteSummary> {
492    let mut w = std::io::BufWriter::new(std::fs::File::create(output)?);
493    let summary = convert_to_mzml_writer(detected, &mut w, opts)?;
494    w.flush()?;
495    Ok(summary)
496}
497
498/// Like [`convert_to_mzml`] but writes to an arbitrary writer (gzip,
499/// stdout, an in-memory buffer). The writer is not flushed.
500///
501/// # Errors
502///
503/// As for [`convert_to_mzml`].
504pub fn convert_to_mzml_writer<W: Write>(
505    detected: &Detected,
506    writer: &mut W,
507    opts: &ConvertOptions,
508) -> Result<WriteSummary> {
509    let write = |src: &mut dyn SpectrumSource| -> Result<WriteSummary> {
510        let summary = if opts.indexed {
511            openmassspec_core::write_indexed_mzml_with_options(src, writer, &opts.write)?
512        } else {
513            openmassspec_core::write_mzml_with_options(src, writer, &opts.write)?
514        };
515        Ok(summary)
516    };
517    if opts.centroid {
518        with_centroided_source(detected, opts.centroid_min_intensity, write)
519    } else {
520        with_source(detected, write)
521    }
522}
523
524// ---------------------------------------------------------------------
525// Reading
526// ---------------------------------------------------------------------
527
528fn collect_from(src: &mut dyn SpectrumSource) -> Result<(Vec<SpectrumRecord>, RunMetadata)> {
529    let meta = src.run_metadata();
530    let recs = src
531        .iter_spectra()
532        .collect::<openmassspec_core::Result<Vec<_>>>()?;
533    Ok((recs, meta))
534}
535
536fn stream_from(
537    src: &mut dyn SpectrumSource,
538    on_spectrum: &mut dyn FnMut(SpectrumRecord) -> Result<()>,
539) -> Result<RunMetadata> {
540    let meta = src.run_metadata();
541    for rec in src.iter_spectra() {
542        on_spectrum(rec?)?;
543    }
544    Ok(meta)
545}
546
547/// Decode every spectrum of `detected` into a `Vec`, together with the
548/// run-level metadata. Used by tools that need a second pass over the
549/// data (conformance validation, `info` summaries).
550///
551/// # Errors
552///
553/// [`Error::FeatureDisabled`] when the vendor's feature is off, the vendor
554/// reader's error when the input cannot be opened, and the first spectrum
555/// the reader cannot decode.
556pub fn collect(detected: &Detected) -> Result<(Vec<SpectrumRecord>, RunMetadata)> {
557    with_source(detected, collect_from)
558}
559
560/// Like [`collect`], but every profile-mode spectrum is centroided first
561/// via [`openmassspec_core::Centroided`]. Already-centroided spectra pass
562/// through unchanged. `min_intensity`, when `Some`, discards picked peaks
563/// below that noise floor.
564///
565/// # Errors
566///
567/// As for [`collect`].
568pub fn collect_centroided(
569    detected: &Detected,
570    min_intensity: Option<f32>,
571) -> Result<(Vec<SpectrumRecord>, RunMetadata)> {
572    with_centroided_source(detected, min_intensity, collect_from)
573}
574
575/// Like [`collect`], but visits each spectrum through `on_spectrum` as soon
576/// as it is decoded instead of buffering the whole run into a `Vec` first.
577/// Memory use is bounded by whatever `on_spectrum` itself retains, not by
578/// the acquisition size.
579///
580/// # Errors
581///
582/// As for [`collect`]. If `on_spectrum` returns an error, iteration stops
583/// immediately and that error is returned; [`Error::Cancelled`] is the
584/// conventional value for "stop, nothing went wrong".
585pub fn stream(
586    detected: &Detected,
587    mut on_spectrum: impl FnMut(SpectrumRecord) -> Result<()>,
588) -> Result<RunMetadata> {
589    with_source(detected, |src| stream_from(src, &mut on_spectrum))
590}
591
592/// Like [`stream`], but every profile-mode spectrum is centroided first via
593/// [`openmassspec_core::Centroided`]. Already-centroided spectra pass
594/// through unchanged. `min_intensity`, when `Some`, discards picked peaks
595/// below that noise floor.
596///
597/// # Errors
598///
599/// As for [`stream`].
600pub fn stream_centroided(
601    detected: &Detected,
602    min_intensity: Option<f32>,
603    mut on_spectrum: impl FnMut(SpectrumRecord) -> Result<()>,
604) -> Result<RunMetadata> {
605    with_centroided_source(detected, min_intensity, |src| {
606        stream_from(src, &mut on_spectrum)
607    })
608}
609
610/// Return only the run-level metadata for `detected`, without decoding any
611/// spectra.
612///
613/// # Errors
614///
615/// [`Error::FeatureDisabled`] when the vendor's feature is off, or the
616/// vendor reader's error when the input cannot be opened.
617pub fn metadata_only(detected: &Detected) -> Result<RunMetadata> {
618    with_source(detected, |src| Ok(src.run_metadata()))
619}
620
621/// Decode chromatogram traces from a vendor acquisition without decoding
622/// its spectra. The returned records retain their native channel IDs and
623/// time/intensity arrays; vendors without decoded traces return an empty
624/// vec.
625///
626/// # Errors
627///
628/// As for [`collect`], with the first chromatogram the reader cannot
629/// decode in place of the first spectrum.
630pub fn collect_chromatograms(detected: &Detected) -> Result<Vec<ChromatogramRecord>> {
631    with_source(detected, |src| {
632        Ok(src
633            .iter_chromatograms()
634            .collect::<openmassspec_core::Result<Vec<_>>>()?)
635    })
636}
637
638/// A trivial in-memory [`SpectrumSource`] backed by a `Vec<SpectrumRecord>`
639/// and a [`RunMetadata`]. Hand it to `openmassspec_core::write_mzml` when
640/// you already have the records in hand and just want to emit mzML.
641///
642/// `iter_spectra` drains `records`, so the source yields its records once.
643#[derive(Debug, Clone)]
644#[non_exhaustive]
645pub struct VecSource {
646    /// Run-level metadata returned by `run_metadata`.
647    pub metadata: RunMetadata,
648    /// Records yielded, in order, by `iter_spectra`.
649    pub records: Vec<SpectrumRecord>,
650}
651
652impl VecSource {
653    /// Wrap `records` with `metadata`.
654    pub fn new(metadata: RunMetadata, records: Vec<SpectrumRecord>) -> Self {
655        Self { metadata, records }
656    }
657}
658
659impl SpectrumSource for VecSource {
660    fn run_metadata(&self) -> RunMetadata {
661        self.metadata.clone()
662    }
663    fn iter_spectra<'s>(&'s mut self) -> SpectrumIter<'s> {
664        Box::new(self.records.drain(..).map(Ok))
665    }
666    fn spectrum_count_hint(&self) -> Option<usize> {
667        Some(self.records.len())
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use std::io::Write;
675
676    #[test]
677    fn detect_returns_none_for_garbage_file() {
678        let tmp = tempfile_path();
679        std::fs::write(&tmp, b"hello").unwrap();
680        assert!(detect_format(&tmp).is_none());
681        let _ = std::fs::remove_file(&tmp);
682    }
683
684    #[test]
685    fn detect_returns_thermo_for_finnigan_magic() {
686        let tmp = tempfile_path();
687        let mut f = std::fs::File::create(&tmp).unwrap();
688        // 2-byte version word + "Finnigan" in UTF-16LE + trailing garbage.
689        f.write_all(&[
690            0x01, 0xa1, 0x46, 0x00, 0x69, 0x00, 0x6e, 0x00, 0x6e, 0x00, 0x69, 0x00, 0x67, 0x00,
691            0x61, 0x00, 0x6e, 0x00, 0xff, 0xff,
692        ])
693        .unwrap();
694        let det = detect_format(&tmp).expect("detect");
695        assert_eq!(det.format, VendorFormat::ThermoRaw);
696        let _ = std::fs::remove_file(&tmp);
697    }
698
699    #[test]
700    fn detect_returns_bruker_for_tdf_layout() {
701        let tmp = tempfile_dir();
702        std::fs::write(tmp.join("analysis.tdf"), b"").unwrap();
703        std::fs::write(tmp.join("analysis.tdf_bin"), b"").unwrap();
704        let det = detect_format(&tmp).expect("detect");
705        assert_eq!(det.format, VendorFormat::BrukerTdf);
706        let _ = std::fs::remove_dir_all(&tmp);
707    }
708
709    #[test]
710    fn detect_returns_waters_for_header_layout() {
711        let tmp = tempfile_dir();
712        std::fs::write(tmp.join("_HEADER.TXT"), b"$$ FAKE\n").unwrap();
713        let det = detect_format(&tmp).expect("detect");
714        assert_eq!(det.format, VendorFormat::WatersRaw);
715        let _ = std::fs::remove_dir_all(&tmp);
716    }
717
718    #[test]
719    fn detect_returns_agilent_for_acqdata_layout() {
720        let tmp = tempfile_dir();
721        let acq = tmp.join("AcqData");
722        std::fs::create_dir_all(&acq).unwrap();
723        std::fs::write(acq.join("MSScan.bin"), b"").unwrap();
724        let det = detect_format(&tmp).expect("detect");
725        assert_eq!(det.format, VendorFormat::AgilentMassHunter);
726        let _ = std::fs::remove_dir_all(&tmp);
727    }
728
729    #[test]
730    fn detect_returns_sciex_for_wiff_with_scan_sibling() {
731        let dir = tempfile_dir();
732        let wiff = dir.join("run.wiff");
733        std::fs::write(&wiff, b"\xd0\xcf\x11\xe0").unwrap();
734        std::fs::write(dir.join("run.wiff.scan"), b"").unwrap();
735        let det = detect_format(&wiff).expect("detect");
736        assert_eq!(det.format, VendorFormat::SciexWiff);
737        let _ = std::fs::remove_dir_all(&dir);
738    }
739
740    #[test]
741    fn detect_returns_none_for_wiff_without_scan_sibling() {
742        let dir = tempfile_dir();
743        let wiff = dir.join("lonely.wiff");
744        std::fs::write(&wiff, b"\xd0\xcf\x11\xe0").unwrap();
745        // No .wiff.scan alongside -> not a usable SCIEX pair.
746        assert!(detect_format(&wiff).is_none());
747        let _ = std::fs::remove_dir_all(&dir);
748    }
749
750    const CFBF_MAGIC_8: [u8; 8] = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
751
752    #[test]
753    fn detect_returns_shimadzu_for_lcd_with_cfbf_magic() {
754        let dir = tempfile_dir();
755        let lcd = dir.join("run.lcd");
756        std::fs::write(&lcd, CFBF_MAGIC_8).unwrap();
757        let det = detect_format(&lcd).expect("detect");
758        assert_eq!(det.format, VendorFormat::ShimadzuLabSolutions);
759        let _ = std::fs::remove_dir_all(&dir);
760    }
761
762    #[test]
763    fn detect_returns_shimadzu_for_qgd_with_cfbf_magic() {
764        let dir = tempfile_dir();
765        let qgd = dir.join("run.qgd");
766        std::fs::write(&qgd, CFBF_MAGIC_8).unwrap();
767        let det = detect_format(&qgd).expect("detect");
768        assert_eq!(det.format, VendorFormat::ShimadzuLabSolutions);
769        let _ = std::fs::remove_dir_all(&dir);
770    }
771
772    #[test]
773    fn detect_returns_none_for_lcd_without_cfbf_magic() {
774        let dir = tempfile_dir();
775        let lcd = dir.join("not_really.lcd");
776        std::fs::write(&lcd, b"not a real container").unwrap();
777        assert!(detect_format(&lcd).is_none());
778        let _ = std::fs::remove_dir_all(&dir);
779    }
780
781    #[test]
782    fn detect_returns_none_for_unrelated_extension_with_cfbf_magic() {
783        // The CFBF/OLE2 signature alone is not Shimadzu-specific (SCIEX's
784        // legacy .wiff also uses it) - the extension must match too.
785        let dir = tempfile_dir();
786        let other = dir.join("run.xyz");
787        std::fs::write(&other, CFBF_MAGIC_8).unwrap();
788        assert!(detect_format(&other).is_none());
789        let _ = std::fs::remove_dir_all(&dir);
790    }
791
792    fn tempfile_path() -> PathBuf {
793        let pid = std::process::id();
794        let mut p = std::env::temp_dir();
795        p.push(format!("msio-test-{pid}-{:p}", &pid));
796        p
797    }
798
799    fn tempfile_dir() -> PathBuf {
800        let p = tempfile_path();
801        let _ = std::fs::create_dir_all(&p);
802        p
803    }
804
805    #[test]
806    fn convert_unsupported_format_returns_typed_error() {
807        // `detect_format` returns None here, so callers can't reach
808        // `convert_to_mzml`. Exercise the FeatureDisabled / Mzml paths
809        // through the public `Error` variants directly to keep this
810        // test feature-agnostic.
811        let e: Error = std::io::Error::other("boom").into();
812        assert!(matches!(e, Error::Io(_)));
813        let e = Error::FeatureDisabled { vendor: "thermo" };
814        assert_eq!(
815            e.to_string(),
816            "openmassspec-io was built without the 'thermo' feature"
817        );
818        let e = Error::UnsupportedFormat(PathBuf::from("/tmp/nope"));
819        assert!(matches!(e, Error::UnsupportedFormat(_)));
820    }
821
822    #[test]
823    fn convert_options_default_is_strict_unindexed_uncentroided() {
824        let o = ConvertOptions::new();
825        assert!(!o.indexed);
826        assert!(!o.centroid);
827        assert_eq!(o.centroid_min_intensity, None);
828        assert_eq!(o.write.record_error_policy, RecordErrorPolicy::Fail);
829        let o = o
830            .with_record_error_policy(RecordErrorPolicy::Skip)
831            .with_centroid_min_intensity(5.0);
832        assert_eq!(o.write.record_error_policy, RecordErrorPolicy::Skip);
833        assert_eq!(o.centroid_min_intensity, Some(5.0));
834    }
835
836    fn meta() -> RunMetadata {
837        RunMetadata::new(
838            "t.raw",
839            CvTerm::new("MS:1000563", "Thermo RAW format"),
840            CvTerm::new("MS:1000768", "Thermo nativeID format"),
841            CvTerm::new("MS:1000483", "Thermo Fisher Scientific instrument model"),
842            "test",
843            "0",
844        )
845    }
846
847    #[test]
848    fn vec_source_yields_every_record_once() {
849        let recs = vec![
850            SpectrumRecord::new(0, 1, "scan=1"),
851            SpectrumRecord::new(1, 2, "scan=2"),
852        ];
853        let mut src = VecSource::new(meta(), recs);
854        assert_eq!(src.spectrum_count_hint(), Some(2));
855        let got = src
856            .iter_spectra()
857            .collect::<openmassspec_core::Result<Vec<_>>>()
858            .unwrap();
859        assert_eq!(got.len(), 2);
860        assert_eq!(src.iter_spectra().count(), 0);
861    }
862
863    /// Source whose second spectrum is unreadable.
864    struct OneBad;
865
866    impl SpectrumSource for OneBad {
867        fn run_metadata(&self) -> RunMetadata {
868            meta()
869        }
870        fn iter_spectra<'s>(&'s mut self) -> SpectrumIter<'s> {
871            Box::new(
872                vec![
873                    Ok(SpectrumRecord::new(0, 1, "scan=1").with_peaks(vec![1.0], vec![1.0])),
874                    Err(openmassspec_core::Error::vendor(std::io::Error::other(
875                        "scan 2 truncated",
876                    ))),
877                    Ok(SpectrumRecord::new(1, 3, "scan=3").with_peaks(vec![1.0], vec![1.0])),
878                ]
879                .into_iter(),
880            )
881        }
882    }
883
884    #[test]
885    fn collect_and_stream_surface_unreadable_records() {
886        let err = collect_from(&mut OneBad).unwrap_err();
887        assert!(matches!(err, Error::Core(_)), "{err:?}");
888        let source = err.reader_error().expect("reader error");
889        assert!(source.downcast_ref::<std::io::Error>().is_some());
890        let err = err.attribute_to("thermo");
891        assert_eq!(err.reader_vendor(), Some("thermo"));
892        assert!(err.to_string().starts_with("thermo reader: "), "{err}");
893        assert!(err.to_string().contains("scan 2 truncated"));
894
895        let mut seen = 0;
896        let err = stream_from(&mut OneBad, &mut |_| {
897            seen += 1;
898            Ok(())
899        })
900        .unwrap_err();
901        assert!(matches!(err, Error::Core(_)));
902        assert_eq!(seen, 1);
903    }
904
905    #[test]
906    fn centroided_dyn_source_forwards_processing_steps_and_errors() {
907        let mut inner = OneBad;
908        let mut src = openmassspec_core::Centroided::new(DynSource(&mut inner));
909        assert_eq!(src.additional_processing_steps().len(), 1);
910        assert!(collect_from(&mut src).is_err());
911    }
912}