1#![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#[cfg(feature = "arrow")]
73pub use openmassspec_arrow as arrow;
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80#[non_exhaustive]
81pub enum VendorFormat {
82 ThermoRaw,
84 BrukerTdf,
87 WatersRaw,
90 AgilentMassHunter,
93 SciexWiff,
95 ShimadzuLabSolutions,
101}
102
103impl VendorFormat {
104 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#[derive(Debug, Clone, PartialEq, Eq)]
120#[non_exhaustive]
121pub struct Detected {
122 pub path: PathBuf,
126 pub format: VendorFormat,
128}
129
130impl Detected {
131 pub fn new(path: impl Into<PathBuf>, format: VendorFormat) -> Self {
135 Self {
136 path: path.into(),
137 format,
138 }
139 }
140}
141
142pub fn detect_format(path: &Path) -> Option<Detected> {
149 let format = if path.is_dir() {
150 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
179fn 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
195fn 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
214fn is_thermo_raw(path: &Path) -> bool {
219 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
227fn 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
237pub 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#[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
351fn 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
367struct 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#[derive(Debug, Clone, Default, PartialEq)]
409#[non_exhaustive]
410pub struct ConvertOptions {
411 pub indexed: bool,
413 pub centroid: bool,
416 pub centroid_min_intensity: Option<f32>,
419 pub write: WriteOptions,
422}
423
424impl ConvertOptions {
425 pub fn new() -> Self {
427 Self::default()
428 }
429
430 #[must_use]
432 pub fn with_indexed(mut self, indexed: bool) -> Self {
433 self.indexed = indexed;
434 self
435 }
436
437 #[must_use]
439 pub fn with_centroid(mut self, centroid: bool) -> Self {
440 self.centroid = centroid;
441 self
442 }
443
444 #[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 #[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 #[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 #[must_use]
470 pub fn with_write_options(mut self, write: WriteOptions) -> Self {
471 self.write = write;
472 self
473 }
474}
475
476pub 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
498pub 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
524fn 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
547pub fn collect(detected: &Detected) -> Result<(Vec<SpectrumRecord>, RunMetadata)> {
557 with_source(detected, collect_from)
558}
559
560pub 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
575pub 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
592pub 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
610pub fn metadata_only(detected: &Detected) -> Result<RunMetadata> {
618 with_source(detected, |src| Ok(src.run_metadata()))
619}
620
621pub 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#[derive(Debug, Clone)]
644#[non_exhaustive]
645pub struct VecSource {
646 pub metadata: RunMetadata,
648 pub records: Vec<SpectrumRecord>,
650}
651
652impl VecSource {
653 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 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 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 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 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 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}