Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/bq/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/bq/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
18 changes: 9 additions & 9 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")]
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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"));
}
Expand Down
67 changes: 52 additions & 15 deletions src/parallel.rs
Original file line number Diff line number Diff line change
@@ -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<P: AsRef<Path>>(path: P) -> Result<Format> {
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()
})
}
Comment thread
noamteyssier marked this conversation as resolved.

/// 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
Expand All @@ -24,20 +42,10 @@ pub enum BinseqReader {
}
impl BinseqReader {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
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)?)),
}
}

Expand Down Expand Up @@ -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<Mutex<usize>>,
Expand Down
5 changes: 5 additions & 0 deletions src/vbq/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/vbq/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
34 changes: 34 additions & 0 deletions src/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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
Expand Down Expand Up @@ -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)
Expand Down