slow5lib 0.1.0

Rust re-implementation of slow5lib: read and write SLOW5/BLOW5 nanopore sequencing files
Documentation
/// BLOW5 binary format parser.
///
/// Wire layout of the file header:
///
/// ```text
/// Offset  Size  Type      Description
///      0     6  [u8; 6]   Magic: b"BLOW5\x01"
///      6     1  u8        Version major
///      7     1  u8        Version minor
///      8     1  u8        Version patch
///      9     1  u8        Record compression (0=None, 1=Zlib, 2=Zstd)
///     10     4  u32 le    Number of read groups
///     14     1  u8        Signal compression (0=None, 1=SvbZd) -- version >= 0.2.0 only
///  15-63     -  zeros     Padding
///     64     4  u32 le    ASCII header size in bytes
///     68+    N  utf-8     ASCII header text (N = header_size bytes)
/// ```
///
/// The ASCII header text contains:
/// - `@attr\tval_rg0\tval_rg1...\n` lines -- per-read-group data attributes
/// - `#type0\ttype1...\n` -- auxiliary field types (if any aux fields present)
/// - `#name0\tname1...\n` -- auxiliary field names (if any aux fields present)
///
/// References: hasindu2008/slow5lib slow5.c `slow5_hdr_init` (binary branch) and
/// `slow5_hdr_to_mem` (binary branch).
use std::io::{Read, Seek, SeekFrom};

use crate::Result;
use crate::error::SlowError;
use crate::header::{Header, ReadGroup, RecordCompression, SignalCompression};

pub(crate) const MAGIC: &[u8; 6] = b"BLOW5\x01";
pub(crate) const EOF_MARKER: &[u8; 5] = b"5WOLB";

/// Byte offset in the file at which the ASCII header size (u32) is stored.
const HDR_SIZE_OFFSET: u64 = 64;

/// Version at which the signal compression byte was introduced.
const SIGNAL_PRESS_MIN: (u8, u8) = (0, 2);

/// Parse the BLOW5 file header from `reader`, consuming bytes up to and
/// including the ASCII header section.
///
/// On return, the reader is positioned at the first record.
pub(crate) fn parse_header<R: Read + Seek>(reader: &mut R) -> Result<Header> {
    // Magic
    let mut magic = [0u8; 6];
    reader.read_exact(&mut magic)?;
    if &magic != MAGIC {
        return Err(SlowError::InvalidFormat(format!(
            "not a BLOW5 file: expected magic {:?}, got {:?}",
            MAGIC, magic
        )));
    }

    // Version
    let mut v = [0u8; 3];
    reader.read_exact(&mut v)?;
    let (major, minor, patch) = (v[0], v[1], v[2]);

    // Record compression
    let mut b = [0u8; 1];
    reader.read_exact(&mut b)?;
    let record_compression = RecordCompression::from_byte(b[0]).ok_or_else(|| {
        SlowError::InvalidFormat(format!("unknown record compression byte: {}", b[0]))
    })?;

    // Number of read groups
    let mut rg = [0u8; 4];
    reader.read_exact(&mut rg)?;
    let num_read_groups = u32::from_le_bytes(rg);
    const MAX_READ_GROUPS: u32 = 65536;
    if num_read_groups > MAX_READ_GROUPS {
        return Err(SlowError::InvalidFormat(format!(
            "num_read_groups {num_read_groups} exceeds {MAX_READ_GROUPS} limit"
        )));
    }

    // Signal compression (introduced in version 0.2.0)
    let signal_compression = if (major, minor) >= SIGNAL_PRESS_MIN {
        reader.read_exact(&mut b)?;
        SignalCompression::from_byte(b[0]).ok_or_else(|| {
            SlowError::InvalidFormat(format!("unknown signal compression byte: {}", b[0]))
        })?
    } else {
        SignalCompression::None
    };

    // Seek past padding to the ASCII header size field at offset 64
    reader.seek(SeekFrom::Start(HDR_SIZE_OFFSET))?;

    // ASCII header size
    let mut sz = [0u8; 4];
    reader.read_exact(&mut sz)?;
    let header_size_raw = u32::from_le_bytes(sz);
    // 1 MiB is generous for BLOW5 header metadata; cap prevents OOM on adversarial inputs.
    const MAX_HEADER_SIZE: u32 = 1024 * 1024;
    if header_size_raw > MAX_HEADER_SIZE {
        return Err(SlowError::InvalidFormat(format!(
            "ASCII header size {header_size_raw} exceeds {MAX_HEADER_SIZE} byte limit"
        )));
    }
    let header_size = header_size_raw as usize;

    // ASCII header text
    let mut ascii = vec![0u8; header_size];
    reader.read_exact(&mut ascii)?;

    let text = std::str::from_utf8(&ascii)
        .map_err(|e| SlowError::InvalidFormat(format!("header is not valid UTF-8: {e}")))?;

    let (read_groups, aux_meta) = parse_ascii_section(text, num_read_groups)?;

    Ok(Header {
        version: (major, minor, patch),
        num_read_groups,
        record_compression,
        signal_compression,
        read_groups,
        aux_meta,
    })
}

/// Parse the ASCII text section embedded in a BLOW5 header.
///
/// Lines starting with `@` carry per-read-group data attributes:
///   `@attr_name\tval_rg0\tval_rg1...\n`
///
/// Lines starting with `#` carry the primary+aux field type and name schema:
/// the first `#` line is the type line, the second is the column name line.
fn parse_ascii_section(
    text: &str,
    num_read_groups: u32,
) -> Result<(Vec<ReadGroup>, crate::aux::AuxMeta)> {
    let n = num_read_groups as usize;
    let mut read_groups = vec![ReadGroup::default(); n];
    let mut type_line: Option<&str> = None;
    let mut name_line: Option<&str> = None;

    for line in text.lines() {
        if let Some(rest) = line.strip_prefix('@') {
            // @attr_name\tval_rg0\tval_rg1...
            let mut parts = rest.split('\t');
            let attr = parts.next().ok_or_else(|| {
                SlowError::InvalidFormat("empty @ attribute line in header".into())
            })?;
            for (rg, value) in parts.enumerate() {
                if rg < n {
                    read_groups[rg]
                        .attributes
                        .insert(attr.to_string(), value.to_string());
                }
            }
        } else if line.starts_with('#') {
            if type_line.is_none() {
                type_line = Some(line);
            } else if name_line.is_none() {
                name_line = Some(line);
            }
        }
    }

    let aux_meta = match (type_line, name_line) {
        (Some(tl), Some(nl)) => crate::aux::parse_aux_meta_from_lines(tl, nl)?,
        _ => crate::aux::AuxMeta::default(),
    };

    Ok((read_groups, aux_meta))
}