1#![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
32pub mod vendor {
34 #[cfg(feature = "agilent")]
35 pub use openaraw;
36 #[cfg(feature = "sciex")]
37 pub use opensxraw;
38 #[cfg(feature = "thermo")]
39 pub use opentfraw;
40 #[cfg(feature = "bruker")]
41 pub use opentimstdf;
42 #[cfg(feature = "waters")]
43 pub use openwraw;
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum VendorFormat {
49 ThermoRaw,
51 BrukerTdf,
54 WatersRaw,
57 AgilentMassHunter,
60 SciexWiff,
62}
63
64impl VendorFormat {
65 pub fn name(self) -> &'static str {
67 match self {
68 Self::ThermoRaw => "thermo",
69 Self::BrukerTdf => "bruker",
70 Self::WatersRaw => "waters",
71 Self::AgilentMassHunter => "agilent",
72 Self::SciexWiff => "sciex",
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct Detected {
80 pub path: PathBuf,
84 pub format: VendorFormat,
86}
87
88pub fn detect_format(path: &Path) -> Option<Detected> {
95 if path.is_dir() {
96 if path.join("analysis.tdf").is_file() && path.join("analysis.tdf_bin").is_file() {
101 return Some(Detected {
102 path: path.to_path_buf(),
103 format: VendorFormat::BrukerTdf,
104 });
105 }
106 if path.join("AcqData").join("MSScan.bin").is_file() {
107 return Some(Detected {
108 path: path.to_path_buf(),
109 format: VendorFormat::AgilentMassHunter,
110 });
111 }
112 if path.join("_HEADER.TXT").is_file() {
113 return Some(Detected {
114 path: path.to_path_buf(),
115 format: VendorFormat::WatersRaw,
116 });
117 }
118 return None;
119 }
120 if path.is_file() {
121 if is_thermo_raw(path) {
122 return Some(Detected {
123 path: path.to_path_buf(),
124 format: VendorFormat::ThermoRaw,
125 });
126 }
127 if is_sciex_wiff(path) {
128 return Some(Detected {
129 path: path.to_path_buf(),
130 format: VendorFormat::SciexWiff,
131 });
132 }
133 return None;
134 }
135 None
136}
137
138fn is_sciex_wiff(path: &Path) -> bool {
142 let is_wiff_ext = path
143 .extension()
144 .and_then(|e| e.to_str())
145 .is_some_and(|e| e.eq_ignore_ascii_case("wiff"));
146 if !is_wiff_ext {
147 return false;
148 }
149 let mut scan = path.as_os_str().to_os_string();
150 scan.push(".scan");
151 Path::new(&scan).is_file()
152}
153
154fn is_thermo_raw(path: &Path) -> bool {
159 use std::fs::File;
160 use std::io::Read;
161 let Ok(mut f) = File::open(path) else {
162 return false;
163 };
164 let mut buf = [0u8; 18];
165 if f.read_exact(&mut buf).is_err() {
166 return false;
167 }
168 const FINNIGAN_UTF16LE: [u8; 16] = [
170 0x46, 0x00, 0x69, 0x00, 0x6e, 0x00, 0x6e, 0x00, 0x69, 0x00, 0x67, 0x00, 0x61, 0x00, 0x6e,
171 0x00,
172 ];
173 buf[2..18] == FINNIGAN_UTF16LE
174}
175
176#[allow(clippy::needless_pass_by_value)] pub fn convert_to_mzml(detected: Detected, output: &Path, indexed: bool) -> Result<()> {
181 use std::fs::File;
182 use std::io::BufWriter;
183 let f = File::create(output)?;
184 let mut w = BufWriter::new(f);
185 write_to(detected.format, &detected.path, &mut w, indexed)
186}
187
188#[allow(clippy::needless_pass_by_value)]
192pub fn convert_to_mzml_writer<W: std::io::Write>(
193 detected: Detected,
194 writer: &mut W,
195 indexed: bool,
196) -> Result<()> {
197 write_to(detected.format, &detected.path, writer, indexed)
198}
199
200fn write_to(
201 format: VendorFormat,
202 path: &Path,
203 w: &mut impl std::io::Write,
204 indexed: bool,
205) -> Result<()> {
206 match format {
207 VendorFormat::ThermoRaw => {
208 #[cfg(feature = "thermo")]
209 {
210 thermo_convert(path, w, indexed)
211 }
212 #[cfg(not(feature = "thermo"))]
213 {
214 let _ = (path, w, indexed);
215 Err(Error::FeatureDisabled { vendor: "thermo" })
216 }
217 }
218 VendorFormat::BrukerTdf => {
219 #[cfg(feature = "bruker")]
220 {
221 if indexed {
222 opentimstdf::mzml::write_indexed_mzml(path, w)?;
223 } else {
224 opentimstdf::mzml::write_mzml(path, w)?;
225 }
226 Ok(())
227 }
228 #[cfg(not(feature = "bruker"))]
229 {
230 let _ = (path, w, indexed);
231 Err(Error::FeatureDisabled { vendor: "bruker" })
232 }
233 }
234 VendorFormat::WatersRaw => {
235 #[cfg(feature = "waters")]
236 {
237 if indexed {
238 openwraw::mzml::write_indexed_mzml(path, w)?;
239 } else {
240 openwraw::mzml::write_mzml(path, w)?;
241 }
242 Ok(())
243 }
244 #[cfg(not(feature = "waters"))]
245 {
246 let _ = (path, w, indexed);
247 Err(Error::FeatureDisabled { vendor: "waters" })
248 }
249 }
250 VendorFormat::AgilentMassHunter => {
251 #[cfg(feature = "agilent")]
252 {
253 let mut reader = openaraw::reader::Reader::open(path)?;
257 if indexed {
258 openmassspec_core::write_indexed_mzml(&mut reader, w)?;
259 } else {
260 openmassspec_core::write_mzml(&mut reader, w)?;
261 }
262 Ok(())
263 }
264 #[cfg(not(feature = "agilent"))]
265 {
266 let _ = (path, w, indexed);
267 Err(Error::FeatureDisabled { vendor: "agilent" })
268 }
269 }
270 VendorFormat::SciexWiff => {
271 #[cfg(feature = "sciex")]
272 {
273 let mut reader = opensxraw::reader::Reader::open(path)?;
274 if indexed {
275 openmassspec_core::write_indexed_mzml(&mut reader, w)?;
276 } else {
277 openmassspec_core::write_mzml(&mut reader, w)?;
278 }
279 Ok(())
280 }
281 #[cfg(not(feature = "sciex"))]
282 {
283 let _ = (path, w, indexed);
284 Err(Error::FeatureDisabled { vendor: "sciex" })
285 }
286 }
287 }
288}
289
290#[cfg(feature = "thermo")]
291fn thermo_convert(path: &Path, out: &mut impl std::io::Write, indexed: bool) -> Result<()> {
292 use std::fs::File;
293 use std::io::BufReader;
294 let raw = opentfraw::RawFileReader::open_path(path)?;
295 let mut source = BufReader::with_capacity(2 << 20, File::open(path)?);
296 let filename = path
297 .file_name()
298 .and_then(|n| n.to_str())
299 .unwrap_or("unknown.raw");
300 if indexed {
301 opentfraw::mzml::write_indexed_mzml(&raw, &mut source, out, filename, false)?;
302 } else {
303 opentfraw::mzml::write_mzml(&raw, &mut source, out, filename, false)?;
304 }
305 Ok(())
306}
307
308#[allow(clippy::needless_pass_by_value)]
312pub fn convert_to_mzml_centroided(
313 detected: Detected,
314 output: &Path,
315 indexed: bool,
316 min_intensity: Option<f32>,
317) -> Result<()> {
318 use std::fs::File;
319 use std::io::BufWriter;
320 let f = File::create(output)?;
321 let mut w = BufWriter::new(f);
322 write_to_centroided(
323 detected.format,
324 &detected.path,
325 &mut w,
326 indexed,
327 min_intensity,
328 )
329}
330
331#[allow(clippy::needless_pass_by_value)]
335pub fn convert_to_mzml_writer_centroided<W: std::io::Write>(
336 detected: Detected,
337 writer: &mut W,
338 indexed: bool,
339 min_intensity: Option<f32>,
340) -> Result<()> {
341 write_to_centroided(
342 detected.format,
343 &detected.path,
344 writer,
345 indexed,
346 min_intensity,
347 )
348}
349
350fn write_to_centroided(
358 format: VendorFormat,
359 path: &Path,
360 w: &mut impl std::io::Write,
361 indexed: bool,
362 min_intensity: Option<f32>,
363) -> Result<()> {
364 #[allow(unused_imports)]
365 use openmassspec_core::SpectrumSource;
366 match format {
367 VendorFormat::ThermoRaw => {
368 #[cfg(feature = "thermo")]
369 {
370 use std::fs::File;
371 use std::io::BufReader;
372 let raw = opentfraw::RawFileReader::open_path(path)?;
373 let mut source = BufReader::with_capacity(2 << 20, File::open(path)?);
374 let filename = path
375 .file_name()
376 .and_then(|n| n.to_str())
377 .unwrap_or("unknown.raw");
378 let src = opentfraw::mzml::OpenTfRawSource::new(&raw, &mut source, filename, false);
379 let mut src = with_min_intensity_opt(src, min_intensity);
380 if indexed {
381 openmassspec_core::write_indexed_mzml(&mut src, w)?;
382 } else {
383 openmassspec_core::write_mzml(&mut src, w)?;
384 }
385 Ok(())
386 }
387 #[cfg(not(feature = "thermo"))]
388 {
389 let _ = (path, w, indexed, min_intensity);
390 Err(Error::FeatureDisabled { vendor: "thermo" })
391 }
392 }
393 VendorFormat::BrukerTdf => {
394 #[cfg(feature = "bruker")]
395 {
396 let src = opentimstdf::mzml::TdfSource::open(path)?;
397 let mut src = with_min_intensity_opt(src, min_intensity);
398 if indexed {
399 openmassspec_core::write_indexed_mzml(&mut src, w)?;
400 } else {
401 openmassspec_core::write_mzml(&mut src, w)?;
402 }
403 Ok(())
404 }
405 #[cfg(not(feature = "bruker"))]
406 {
407 let _ = (path, w, indexed, min_intensity);
408 Err(Error::FeatureDisabled { vendor: "bruker" })
409 }
410 }
411 VendorFormat::WatersRaw => {
412 #[cfg(feature = "waters")]
413 {
414 let src = openwraw::mzml::WatersSource::open(path)?;
415 let mut src = with_min_intensity_opt(src, min_intensity);
416 if indexed {
417 openmassspec_core::write_indexed_mzml(&mut src, w)?;
418 } else {
419 openmassspec_core::write_mzml(&mut src, w)?;
420 }
421 Ok(())
422 }
423 #[cfg(not(feature = "waters"))]
424 {
425 let _ = (path, w, indexed, min_intensity);
426 Err(Error::FeatureDisabled { vendor: "waters" })
427 }
428 }
429 VendorFormat::AgilentMassHunter => {
430 #[cfg(feature = "agilent")]
431 {
432 let reader = openaraw::reader::Reader::open(path)?;
433 let mut src = with_min_intensity_opt(reader, min_intensity);
434 if indexed {
435 openmassspec_core::write_indexed_mzml(&mut src, w)?;
436 } else {
437 openmassspec_core::write_mzml(&mut src, w)?;
438 }
439 Ok(())
440 }
441 #[cfg(not(feature = "agilent"))]
442 {
443 let _ = (path, w, indexed, min_intensity);
444 Err(Error::FeatureDisabled { vendor: "agilent" })
445 }
446 }
447 VendorFormat::SciexWiff => {
448 #[cfg(feature = "sciex")]
449 {
450 let reader = opensxraw::reader::Reader::open(path)?;
451 let mut src = with_min_intensity_opt(reader, min_intensity);
452 if indexed {
453 openmassspec_core::write_indexed_mzml(&mut src, w)?;
454 } else {
455 openmassspec_core::write_mzml(&mut src, w)?;
456 }
457 Ok(())
458 }
459 #[cfg(not(feature = "sciex"))]
460 {
461 let _ = (path, w, indexed, min_intensity);
462 Err(Error::FeatureDisabled { vendor: "sciex" })
463 }
464 }
465 }
466}
467
468#[allow(dead_code)]
474fn with_min_intensity_opt<S: openmassspec_core::SpectrumSource>(
475 src: S,
476 min_intensity: Option<f32>,
477) -> openmassspec_core::Centroided<S> {
478 let centroided = openmassspec_core::Centroided::new(src);
479 match min_intensity {
480 Some(v) => centroided.with_min_intensity(v),
481 None => centroided,
482 }
483}
484
485#[allow(clippy::needless_pass_by_value)]
494pub fn collect(
495 detected: Detected,
496) -> Result<(
497 Vec<openmassspec_core::SpectrumRecord>,
498 openmassspec_core::RunMetadata,
499)> {
500 #[allow(unused_imports)]
501 use openmassspec_core::SpectrumSource;
502 match detected.format {
503 VendorFormat::ThermoRaw => {
504 #[cfg(feature = "thermo")]
505 {
506 use std::fs::File;
507 use std::io::BufReader;
508 let raw = opentfraw::RawFileReader::open_path(&detected.path)?;
509 let mut source = BufReader::with_capacity(2 << 20, File::open(&detected.path)?);
510 let filename = detected
511 .path
512 .file_name()
513 .and_then(|n| n.to_str())
514 .unwrap_or("unknown.raw");
515 let mut src =
516 opentfraw::mzml::OpenTfRawSource::new(&raw, &mut source, filename, false);
517 let meta = src.run_metadata();
518 let recs: Vec<_> = src.iter_spectra().collect();
519 Ok((recs, meta))
520 }
521 #[cfg(not(feature = "thermo"))]
522 Err(Error::FeatureDisabled { vendor: "thermo" })
523 }
524 VendorFormat::BrukerTdf => {
525 #[cfg(feature = "bruker")]
526 {
527 let mut src = opentimstdf::mzml::TdfSource::open(&detected.path)?;
528 let meta = src.run_metadata();
529 let recs: Vec<_> = src.iter_spectra().collect();
530 Ok((recs, meta))
531 }
532 #[cfg(not(feature = "bruker"))]
533 Err(Error::FeatureDisabled { vendor: "bruker" })
534 }
535 VendorFormat::WatersRaw => {
536 #[cfg(feature = "waters")]
537 {
538 let mut src = openwraw::mzml::WatersSource::open(&detected.path)?;
539 let meta = src.run_metadata();
540 let recs: Vec<_> = src.iter_spectra().collect();
541 Ok((recs, meta))
542 }
543 #[cfg(not(feature = "waters"))]
544 Err(Error::FeatureDisabled { vendor: "waters" })
545 }
546 VendorFormat::AgilentMassHunter => {
547 #[cfg(feature = "agilent")]
548 {
549 let mut src = openaraw::reader::Reader::open(&detected.path)?;
550 let meta = src.run_metadata();
551 let recs: Vec<_> = src.iter_spectra().collect();
552 Ok((recs, meta))
553 }
554 #[cfg(not(feature = "agilent"))]
555 Err(Error::FeatureDisabled { vendor: "agilent" })
556 }
557 VendorFormat::SciexWiff => {
558 #[cfg(feature = "sciex")]
559 {
560 let mut src = opensxraw::reader::Reader::open(&detected.path)?;
561 let meta = src.run_metadata();
562 let recs: Vec<_> = src.iter_spectra().collect();
563 Ok((recs, meta))
564 }
565 #[cfg(not(feature = "sciex"))]
566 Err(Error::FeatureDisabled { vendor: "sciex" })
567 }
568 }
569}
570
571#[allow(clippy::needless_pass_by_value, unused_variables)]
576pub fn collect_centroided(
577 detected: Detected,
578 min_intensity: Option<f32>,
579) -> Result<(
580 Vec<openmassspec_core::SpectrumRecord>,
581 openmassspec_core::RunMetadata,
582)> {
583 #[allow(unused_imports)]
584 use openmassspec_core::SpectrumSource;
585 match detected.format {
586 VendorFormat::ThermoRaw => {
587 #[cfg(feature = "thermo")]
588 {
589 use std::fs::File;
590 use std::io::BufReader;
591 let raw = opentfraw::RawFileReader::open_path(&detected.path)?;
592 let mut source = BufReader::with_capacity(2 << 20, File::open(&detected.path)?);
593 let filename = detected
594 .path
595 .file_name()
596 .and_then(|n| n.to_str())
597 .unwrap_or("unknown.raw");
598 let src = opentfraw::mzml::OpenTfRawSource::new(&raw, &mut source, filename, false);
599 let mut src = with_min_intensity_opt(src, min_intensity);
600 let meta = src.run_metadata();
601 let recs: Vec<_> = src.iter_spectra().collect();
602 Ok((recs, meta))
603 }
604 #[cfg(not(feature = "thermo"))]
605 Err(Error::FeatureDisabled { vendor: "thermo" })
606 }
607 VendorFormat::BrukerTdf => {
608 #[cfg(feature = "bruker")]
609 {
610 let src = opentimstdf::mzml::TdfSource::open(&detected.path)?;
611 let mut src = with_min_intensity_opt(src, min_intensity);
612 let meta = src.run_metadata();
613 let recs: Vec<_> = src.iter_spectra().collect();
614 Ok((recs, meta))
615 }
616 #[cfg(not(feature = "bruker"))]
617 Err(Error::FeatureDisabled { vendor: "bruker" })
618 }
619 VendorFormat::WatersRaw => {
620 #[cfg(feature = "waters")]
621 {
622 let src = openwraw::mzml::WatersSource::open(&detected.path)?;
623 let mut src = with_min_intensity_opt(src, min_intensity);
624 let meta = src.run_metadata();
625 let recs: Vec<_> = src.iter_spectra().collect();
626 Ok((recs, meta))
627 }
628 #[cfg(not(feature = "waters"))]
629 Err(Error::FeatureDisabled { vendor: "waters" })
630 }
631 VendorFormat::AgilentMassHunter => {
632 #[cfg(feature = "agilent")]
633 {
634 let src = openaraw::reader::Reader::open(&detected.path)?;
635 let mut src = with_min_intensity_opt(src, min_intensity);
636 let meta = src.run_metadata();
637 let recs: Vec<_> = src.iter_spectra().collect();
638 Ok((recs, meta))
639 }
640 #[cfg(not(feature = "agilent"))]
641 Err(Error::FeatureDisabled { vendor: "agilent" })
642 }
643 VendorFormat::SciexWiff => {
644 #[cfg(feature = "sciex")]
645 {
646 let src = opensxraw::reader::Reader::open(&detected.path)?;
647 let mut src = with_min_intensity_opt(src, min_intensity);
648 let meta = src.run_metadata();
649 let recs: Vec<_> = src.iter_spectra().collect();
650 Ok((recs, meta))
651 }
652 #[cfg(not(feature = "sciex"))]
653 Err(Error::FeatureDisabled { vendor: "sciex" })
654 }
655 }
656}
657
658pub struct VecSource {
663 pub metadata: openmassspec_core::RunMetadata,
664 pub records: Vec<openmassspec_core::SpectrumRecord>,
665}
666
667impl VecSource {
668 pub fn new(
669 metadata: openmassspec_core::RunMetadata,
670 records: Vec<openmassspec_core::SpectrumRecord>,
671 ) -> Self {
672 Self { metadata, records }
673 }
674}
675
676impl openmassspec_core::SpectrumSource for VecSource {
677 fn run_metadata(&self) -> openmassspec_core::RunMetadata {
678 self.metadata.clone()
679 }
680 fn iter_spectra<'s>(
681 &'s mut self,
682 ) -> Box<dyn Iterator<Item = openmassspec_core::SpectrumRecord> + 's> {
683 Box::new(self.records.drain(..))
684 }
685 fn spectrum_count_hint(&self) -> Option<usize> {
686 Some(self.records.len())
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use std::io::Write;
694
695 #[test]
696 fn detect_returns_none_for_garbage_file() {
697 let tmp = tempfile_path();
698 std::fs::write(&tmp, b"hello").unwrap();
699 assert!(detect_format(&tmp).is_none());
700 let _ = std::fs::remove_file(&tmp);
701 }
702
703 #[test]
704 fn detect_returns_thermo_for_finnigan_magic() {
705 let tmp = tempfile_path();
706 let mut f = std::fs::File::create(&tmp).unwrap();
707 f.write_all(&[
709 0x01, 0xa1, 0x46, 0x00, 0x69, 0x00, 0x6e, 0x00, 0x6e, 0x00, 0x69, 0x00, 0x67, 0x00,
710 0x61, 0x00, 0x6e, 0x00, 0xff, 0xff,
711 ])
712 .unwrap();
713 let det = detect_format(&tmp).expect("detect");
714 assert_eq!(det.format, VendorFormat::ThermoRaw);
715 let _ = std::fs::remove_file(&tmp);
716 }
717
718 #[test]
719 fn detect_returns_bruker_for_tdf_layout() {
720 let tmp = tempfile_dir();
721 std::fs::write(tmp.join("analysis.tdf"), b"").unwrap();
722 std::fs::write(tmp.join("analysis.tdf_bin"), b"").unwrap();
723 let det = detect_format(&tmp).expect("detect");
724 assert_eq!(det.format, VendorFormat::BrukerTdf);
725 let _ = std::fs::remove_dir_all(&tmp);
726 }
727
728 #[test]
729 fn detect_returns_waters_for_header_layout() {
730 let tmp = tempfile_dir();
731 std::fs::write(tmp.join("_HEADER.TXT"), b"$$ FAKE\n").unwrap();
732 let det = detect_format(&tmp).expect("detect");
733 assert_eq!(det.format, VendorFormat::WatersRaw);
734 let _ = std::fs::remove_dir_all(&tmp);
735 }
736
737 #[test]
738 fn detect_returns_agilent_for_acqdata_layout() {
739 let tmp = tempfile_dir();
740 let acq = tmp.join("AcqData");
741 std::fs::create_dir_all(&acq).unwrap();
742 std::fs::write(acq.join("MSScan.bin"), b"").unwrap();
743 let det = detect_format(&tmp).expect("detect");
744 assert_eq!(det.format, VendorFormat::AgilentMassHunter);
745 let _ = std::fs::remove_dir_all(&tmp);
746 }
747
748 #[test]
749 fn detect_returns_sciex_for_wiff_with_scan_sibling() {
750 let dir = tempfile_dir();
751 let wiff = dir.join("run.wiff");
752 std::fs::write(&wiff, b"\xd0\xcf\x11\xe0").unwrap();
753 std::fs::write(dir.join("run.wiff.scan"), b"").unwrap();
754 let det = detect_format(&wiff).expect("detect");
755 assert_eq!(det.format, VendorFormat::SciexWiff);
756 let _ = std::fs::remove_dir_all(&dir);
757 }
758
759 #[test]
760 fn detect_returns_none_for_wiff_without_scan_sibling() {
761 let dir = tempfile_dir();
762 let wiff = dir.join("lonely.wiff");
763 std::fs::write(&wiff, b"\xd0\xcf\x11\xe0").unwrap();
764 assert!(detect_format(&wiff).is_none());
766 let _ = std::fs::remove_dir_all(&dir);
767 }
768
769 fn tempfile_path() -> PathBuf {
770 let pid = std::process::id();
771 let mut p = std::env::temp_dir();
772 p.push(format!("msio-test-{pid}-{:p}", &pid));
773 p
774 }
775
776 fn tempfile_dir() -> PathBuf {
777 let p = tempfile_path();
778 let _ = std::fs::create_dir_all(&p);
779 p
780 }
781
782 #[test]
783 fn convert_unsupported_format_returns_typed_error() {
784 let e: Error = std::io::Error::other("boom").into();
789 assert!(matches!(e, Error::Io(_)));
790 let e = Error::FeatureDisabled { vendor: "thermo" };
791 assert_eq!(
792 e.to_string(),
793 "openmassspec-io was built without the 'thermo' feature"
794 );
795 let e = Error::UnsupportedFormat(PathBuf::from("/tmp/nope"));
796 assert!(matches!(e, Error::UnsupportedFormat(_)));
797 }
798}