diff --git a/CHANGELOG.md b/CHANGELOG.md index fd3e66e..0782fe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bug in the `bq` stream reader that used an incorrect record id for small buffers. +### Added + +- `BinseqReader::new` now determines a file's BINSEQ format (BQ, VBQ, or CBQ) by sniffing its + magic bytes instead of relying on the file extension, so it works regardless of how the file + is named. +- `Format::sniff` for identifying a BINSEQ format from a byte buffer, plus public `FILE_MAGIC` + constants on `bq`, `vbq`, and `cbq`. + ### Changed - Added clippy checks and additional lint allowances to CI, plus general style fixes. - Improved test coverage throughout the library. +- Renamed `ExtensionError` to `FormatError` (`UnrecognizedMagicBytes` variant) to reflect + detection now being based on file content rather than the file extension. ## [0.9.3] - 2026-07-01 diff --git a/src/bq/header.rs b/src/bq/header.rs index 61adf24..eed8c2f 100644 --- a/src/bq/header.rs +++ b/src/bq/header.rs @@ -16,6 +16,11 @@ use crate::error::{BuilderError, HeaderError, Result}; #[allow(clippy::unreadable_literal)] const MAGIC: u32 = 0x51455342; +/// The magic bytes as they appear at the start of a BQ file on disk. +/// +/// Used to identify BQ files by content rather than by file extension. +pub const FILE_MAGIC: [u8; 4] = MAGIC.to_le_bytes(); + /// Current format version of the binary sequence file format /// /// This version number allows for future format changes while maintaining backward compatibility. diff --git a/src/bq/mod.rs b/src/bq/mod.rs index fd194f6..47fa622 100644 --- a/src/bq/mod.rs +++ b/src/bq/mod.rs @@ -241,6 +241,6 @@ mod header; mod reader; mod writer; -pub use header::{FileHeader, FileHeaderBuilder, SIZE_HEADER}; +pub use header::{FILE_MAGIC, FileHeader, FileHeaderBuilder, SIZE_HEADER}; pub use reader::{MmapReader, RefRecord, StreamReader}; pub use writer::{Encoder, StreamWriter, StreamWriterBuilder, Writer, WriterBuilder}; diff --git a/src/error.rs b/src/error.rs index ddbde48..1c76ed9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -40,8 +40,8 @@ pub enum Error { #[error("Error with UTF8: {0}")] Utf8Error(#[from] std::str::Utf8Error), - /// Errors related to missing extensions - ExtensionError(#[from] ExtensionError), + /// Errors related to determining the BINSEQ format of a file + FormatError(#[from] FormatError), /// Errors from the bitnuc dependency for nucleotide encoding/decoding #[error("Bitnuc error: {0}")] @@ -327,10 +327,10 @@ pub enum FastxEncodingError { } #[derive(thiserror::Error, Debug)] -pub enum ExtensionError { - /// When the extension is not supported - #[error("Unsupported extension in path: {0}")] - UnsupportedExtension(String), +pub enum FormatError { + /// When the BINSEQ format could not be determined from a file's magic bytes + #[error("Unable to determine BINSEQ format from magic bytes in file: {0}")] + UnrecognizedMagicBytes(String), } /// Trait for converting arbitrary errors into `Error` @@ -532,11 +532,11 @@ mod testing { assert!(error_str.contains("Missing sequence length")); } - // ==================== ExtensionError Tests ==================== + // ==================== FormatError Tests ==================== #[test] - fn test_extension_error_unsupported() { - let error = ExtensionError::UnsupportedExtension("test.xyz".to_string()); + fn test_format_error_unrecognized_magic_bytes() { + let error = FormatError::UnrecognizedMagicBytes("test.xyz".to_string()); let error_str = format!("{error}"); assert!(error_str.contains("test.xyz")); } diff --git a/src/parallel.rs b/src/parallel.rs index 8d7c190..eedd96c 100644 --- a/src/parallel.rs +++ b/src/parallel.rs @@ -1,12 +1,30 @@ +use std::fs::File; +use std::io::Read as _; use std::ops::Range; use std::path::Path; use crate::{ BinseqRecord, Result, bq, cbq, - error::{ExtensionError, ReadError}, + error::{FormatError, ReadError}, vbq, + write::Format, }; +/// Number of leading bytes read from a file to identify its BINSEQ format. +/// +/// This must be at least as long as the longest format magic sequence (CBQ's, at 7 bytes). +const MAGIC_PEEK_LEN: usize = 7; + +/// Determines the BINSEQ format of a file by inspecting its leading magic bytes. +fn sniff_format>(path: P) -> Result { + let file = File::open(path.as_ref())?; + let mut buffer = [0u8; MAGIC_PEEK_LEN]; + file.take(MAGIC_PEEK_LEN as u64).read_exact(&mut buffer)?; + Format::sniff(&buffer).ok_or_else(|| { + FormatError::UnrecognizedMagicBytes(path.as_ref().to_string_lossy().to_string()).into() + }) +} + /// An enum abstraction for BINSEQ readers that can process records in parallel /// /// This is a convenience enum that can be used for general workflows where the @@ -24,20 +42,10 @@ pub enum BinseqReader { } impl BinseqReader { pub fn new>(path: P) -> Result { - match path.as_ref().extension() { - Some(ext) => match ext.to_str() { - Some("bq") => Ok(Self::Bq(bq::MmapReader::new(path)?)), - Some("vbq") => Ok(Self::Vbq(vbq::MmapReader::new(path)?)), - Some("cbq") => Ok(Self::Cbq(cbq::MmapReader::new(path)?)), - _ => Err(ExtensionError::UnsupportedExtension( - path.as_ref().to_string_lossy().to_string(), - ) - .into()), - }, - None => Err(ExtensionError::UnsupportedExtension( - path.as_ref().to_string_lossy().to_string(), - ) - .into()), + match sniff_format(&path)? { + Format::Bq => Ok(Self::Bq(bq::MmapReader::new(path)?)), + Format::Vbq => Ok(Self::Vbq(vbq::MmapReader::new(path)?)), + Format::Cbq => Ok(Self::Cbq(cbq::MmapReader::new(path)?)), } } @@ -250,6 +258,35 @@ mod testing { use super::*; + #[test] + fn test_new_ignores_extension_uses_magic_bytes() { + let dir = std::env::temp_dir(); + + // A CBQ file saved with a .bq extension should still be read as CBQ. + let wrong_ext = dir.join("binseq_sniff_wrong_ext.bq"); + std::fs::copy("./data/subset.cbq", &wrong_ext).unwrap(); + let reader = BinseqReader::new(&wrong_ext).unwrap(); + assert!(matches!(reader, BinseqReader::Cbq(_))); + + // A BQ file with no extension at all should still be detected. + let no_ext = dir.join("binseq_sniff_no_ext"); + std::fs::copy("./data/subset.bq", &no_ext).unwrap(); + let reader = BinseqReader::new(&no_ext).unwrap(); + assert!(matches!(reader, BinseqReader::Bq(_))); + + std::fs::remove_file(&wrong_ext).unwrap(); + std::fs::remove_file(&no_ext).unwrap(); + } + + #[test] + fn test_new_unrecognized_file_errors() { + let dir = std::env::temp_dir(); + let junk = dir.join("binseq_sniff_junk.cbq"); + std::fs::write(&junk, b"not a binseq file at all").unwrap(); + assert!(BinseqReader::new(&junk).is_err()); + std::fs::remove_file(&junk).unwrap(); + } + #[derive(Clone, Default)] struct TestProcessor { pub n_records: Arc>, diff --git a/src/vbq/header.rs b/src/vbq/header.rs index 29a88b3..6aa8ded 100644 --- a/src/vbq/header.rs +++ b/src/vbq/header.rs @@ -25,6 +25,11 @@ use crate::error::{HeaderError, ReadError, Result}; #[allow(clippy::unreadable_literal)] const MAGIC: u32 = 0x51455356; +/// The magic bytes as they appear at the start of a VBQ file on disk. +/// +/// Used to identify VBQ files by content rather than by file extension. +pub const FILE_MAGIC: [u8; 4] = MAGIC.to_le_bytes(); + /// Magic number for block identification: "BLOCKSEQ" in ASCII (0x5145534B434F4C42) /// /// This constant is used in block headers to validate block integrity. diff --git a/src/vbq/mod.rs b/src/vbq/mod.rs index 230cca0..12cc4bf 100644 --- a/src/vbq/mod.rs +++ b/src/vbq/mod.rs @@ -150,7 +150,7 @@ mod index; mod reader; mod writer; -pub use header::{BlockHeader, FileHeader, FileHeaderBuilder}; +pub use header::{BlockHeader, FILE_MAGIC, FileHeader, FileHeaderBuilder}; pub use index::{BlockIndex, BlockRange}; pub use reader::{MmapReader, RecordBlock, RecordBlockIter, RefRecord}; pub use writer::{Writer, WriterBuilder}; diff --git a/src/write.rs b/src/write.rs index fe23240..9d0ab17 100644 --- a/src/write.rs +++ b/src/write.rs @@ -96,6 +96,24 @@ impl Format { Self::Cbq => ".cbq", } } + + /// Determines the BINSEQ format by inspecting the magic bytes at the start of a buffer. + /// + /// This identifies the format from file content rather than a file extension, so it + /// works regardless of how the file is named. Returns `None` if `bytes` is too short + /// or does not start with a recognized magic sequence. + #[must_use] + pub fn sniff(bytes: &[u8]) -> Option { + if bytes.starts_with(cbq::FILE_MAGIC) { + Some(Self::Cbq) + } else if bytes.starts_with(&bq::FILE_MAGIC) { + Some(Self::Bq) + } else if bytes.starts_with(&vbq::FILE_MAGIC) { + Some(Self::Vbq) + } else { + None + } + } } /// Builder for creating [`BinseqWriter`] instances @@ -646,6 +664,22 @@ mod tests { assert_eq!(Format::Cbq.extension(), ".cbq"); } + #[test] + fn test_format_sniff() { + assert_eq!(Format::sniff(&bq::FILE_MAGIC), Some(Format::Bq)); + assert_eq!(Format::sniff(&vbq::FILE_MAGIC), Some(Format::Vbq)); + assert_eq!(Format::sniff(cbq::FILE_MAGIC), Some(Format::Cbq)); + + // Trailing bytes after the magic sequence are ignored. + let mut cbq_like = cbq::FILE_MAGIC.to_vec(); + cbq_like.extend_from_slice(b"trailing data"); + assert_eq!(Format::sniff(&cbq_like), Some(Format::Cbq)); + + assert_eq!(Format::sniff(b"not a binseq file"), None); + assert_eq!(Format::sniff(&[]), None); + assert_eq!(Format::sniff(b"BS"), None); + } + #[test] fn test_build_bq_writer() -> Result<()> { let writer = BinseqWriterBuilder::new(Format::Bq)