slow5lib 0.1.0

Rust re-implementation of slow5lib: read and write SLOW5/BLOW5 nanopore sequencing files
Documentation
/// Wire-compatibility parity tests.
///
/// Reference files in tests/data/parity/ were generated once by slow5tools v1.3.0
/// from tests/data/parity_input.slow5 and committed to the repo. Each test writes
/// the same records using the Rust writer and compares byte-for-byte.
///
/// Only compression combinations whose output is deterministic regardless of
/// library version are byte-compared:
///   - RecordCompression::None + SignalCompression::None
///   - RecordCompression::None + SignalCompression::SvbZd
///   - RecordCompression::None + SignalCompression::ExZd
///
/// Zstd and zlib compressed variants are verified by reading back with the Rust
/// reader and confirming the values are correct (not byte-for-byte, since
/// compressed output can vary across library versions).
///
/// `ref_none_exzd.blow5` and `ref_zstd_exzd.blow5` were generated with
/// slow5tools v1.4.0 (the first release with ex-zd support; v1.3.0 predates it),
/// unlike the other reference files above which are v1.3.0.
use slow5lib::aux::AuxMeta;
use slow5lib::header::{Header, ReadGroup, RecordCompression, SignalCompression};
use slow5lib::{Slow5Reader, Slow5Writer};

const PARITY_INPUT: &str = "tests/data/parity_input.slow5";
const REF_NONE_NONE: &str = "tests/data/parity/ref_none_none.blow5";
const REF_NONE_SVBZD: &str = "tests/data/parity/ref_none_svbzd.blow5";
const REF_ZSTD_SVBZD: &str = "tests/data/parity/ref_zstd_svbzd.blow5";
const REF_ZLIB_NONE: &str = "tests/data/parity/ref_zlib_none.blow5";
const REF_NONE_EXZD: &str = "tests/data/parity/ref_none_exzd.blow5";
const REF_ZSTD_EXZD: &str = "tests/data/parity/ref_zstd_exzd.blow5";

fn parity_header(rec: RecordCompression, sig: SignalCompression) -> Header {
    Header {
        version: (0, 2, 0),
        num_read_groups: 1,
        record_compression: rec,
        signal_compression: sig,
        read_groups: vec![ReadGroup::default()],
        aux_meta: AuxMeta::default(),
    }
}

fn read_all_records(path: &str) -> Vec<slow5lib::record::Record> {
    let mut reader = Slow5Reader::open(path).unwrap_or_else(|e| panic!("open {path}: {e}"));
    reader
        .records()
        .map(|r| r.unwrap_or_else(|e| panic!("record from {path}: {e}")))
        .collect()
}

fn write_records(
    path: &str,
    records: &[slow5lib::record::Record],
    rec: RecordCompression,
    sig: SignalCompression,
) {
    let header = parity_header(rec, sig);
    let mut writer =
        Slow5Writer::create(path, header).unwrap_or_else(|e| panic!("create {path}: {e}"));
    for r in records {
        writer
            .write(r)
            .unwrap_or_else(|e| panic!("write to {path}: {e}"));
    }
    writer
        .finish()
        .unwrap_or_else(|e| panic!("finish {path}: {e}"));
}

// ── Byte-for-byte parity (deterministic combinations) ────────────────────────

#[test]
fn parity_none_none() {
    let tmp = "/tmp/slow5lib_parity_none_none.blow5";
    let records = read_all_records(PARITY_INPUT);
    write_records(
        tmp,
        &records,
        RecordCompression::None,
        SignalCompression::None,
    );

    let got = std::fs::read(tmp).expect("read rust output");
    let want = std::fs::read(REF_NONE_NONE).expect("read reference");
    assert_eq!(
        got,
        want,
        "none+none output differs from slow5tools reference ({} bytes vs {} bytes)",
        got.len(),
        want.len()
    );
}

#[test]
fn parity_none_svbzd() {
    let tmp = "/tmp/slow5lib_parity_none_svbzd.blow5";
    let records = read_all_records(PARITY_INPUT);
    write_records(
        tmp,
        &records,
        RecordCompression::None,
        SignalCompression::SvbZd,
    );

    let got = std::fs::read(tmp).expect("read rust output");
    let want = std::fs::read(REF_NONE_SVBZD).expect("read reference");
    assert_eq!(
        got,
        want,
        "none+svbzd output differs from slow5tools reference ({} bytes vs {} bytes)",
        got.len(),
        want.len()
    );
}

#[test]
fn parity_none_exzd() {
    let tmp = "/tmp/slow5lib_parity_none_exzd.blow5";
    let records = read_all_records(PARITY_INPUT);
    write_records(
        tmp,
        &records,
        RecordCompression::None,
        SignalCompression::ExZd,
    );

    let got = std::fs::read(tmp).expect("read rust output");
    let want = std::fs::read(REF_NONE_EXZD).expect("read reference");
    assert_eq!(
        got,
        want,
        "none+exzd output differs from slow5tools reference ({} bytes vs {} bytes)",
        got.len(),
        want.len()
    );
}

// ── Value-level parity (compressed combinations) ─────────────────────────────
//
// Compressed bytes may differ across library versions so we verify that:
//   (a) Our Rust writer produces output that our Rust reader can read back
//       with correct values.
//   (b) The reference file produced by slow5tools round-trips through our
//       Rust reader with the same values.

fn check_parity_values(rust_path: &str, ref_path: &str) {
    let source = read_all_records(PARITY_INPUT);
    let rust_out = read_all_records(rust_path);
    let ref_out = read_all_records(ref_path);

    assert_eq!(
        rust_out.len(),
        source.len(),
        "rust output record count mismatch"
    );
    assert_eq!(
        ref_out.len(),
        source.len(),
        "reference record count mismatch"
    );

    for (i, ((src, got), want)) in source
        .iter()
        .zip(rust_out.iter())
        .zip(ref_out.iter())
        .enumerate()
    {
        assert_eq!(got.read_id, src.read_id, "record {i}: read_id mismatch");
        assert_eq!(
            got.raw_signal, src.raw_signal,
            "record {i}: signal mismatch (rust output)"
        );
        assert_eq!(
            want.read_id, src.read_id,
            "record {i}: read_id mismatch (ref)"
        );
        assert_eq!(
            want.raw_signal, src.raw_signal,
            "record {i}: signal mismatch (slow5tools reference)"
        );
    }
}

#[test]
fn parity_zstd_svbzd() {
    let tmp = "/tmp/slow5lib_parity_zstd_svbzd.blow5";
    let records = read_all_records(PARITY_INPUT);
    write_records(
        tmp,
        &records,
        RecordCompression::Zstd,
        SignalCompression::SvbZd,
    );
    check_parity_values(tmp, REF_ZSTD_SVBZD);
}

#[test]
fn parity_zstd_exzd() {
    let tmp = "/tmp/slow5lib_parity_zstd_exzd.blow5";
    let records = read_all_records(PARITY_INPUT);
    write_records(
        tmp,
        &records,
        RecordCompression::Zstd,
        SignalCompression::ExZd,
    );
    check_parity_values(tmp, REF_ZSTD_EXZD);
}

#[test]
fn parity_zlib_none() {
    let tmp = "/tmp/slow5lib_parity_zlib_none.blow5";
    let records = read_all_records(PARITY_INPUT);
    write_records(
        tmp,
        &records,
        RecordCompression::Zlib,
        SignalCompression::None,
    );
    check_parity_values(tmp, REF_ZLIB_NONE);
}

// ── Cross-format parity ───────────────────────────────────────────────────────
//
// Read from each reference BLOW5 and confirm values match the SLOW5 text source.

#[test]
fn parity_ref_none_none_readable() {
    let source = read_all_records(PARITY_INPUT);
    let from_ref = read_all_records(REF_NONE_NONE);
    assert_eq!(from_ref.len(), source.len());
    for (i, (got, want)) in from_ref.iter().zip(source.iter()).enumerate() {
        assert_eq!(got.read_id, want.read_id, "record {i}");
        assert_eq!(got.raw_signal, want.raw_signal, "record {i}");
    }
}

#[test]
fn parity_ref_none_svbzd_readable() {
    let source = read_all_records(PARITY_INPUT);
    let from_ref = read_all_records(REF_NONE_SVBZD);
    assert_eq!(from_ref.len(), source.len());
    for (i, (got, want)) in from_ref.iter().zip(source.iter()).enumerate() {
        assert_eq!(got.read_id, want.read_id, "record {i}");
        assert_eq!(got.raw_signal, want.raw_signal, "record {i}");
    }
}

#[test]
fn parity_ref_zstd_svbzd_readable() {
    let source = read_all_records(PARITY_INPUT);
    let from_ref = read_all_records(REF_ZSTD_SVBZD);
    assert_eq!(from_ref.len(), source.len());
    for (i, (got, want)) in from_ref.iter().zip(source.iter()).enumerate() {
        assert_eq!(got.read_id, want.read_id, "record {i}");
        assert_eq!(got.raw_signal, want.raw_signal, "record {i}");
    }
}

#[test]
fn parity_ref_zlib_none_readable() {
    let source = read_all_records(PARITY_INPUT);
    let from_ref = read_all_records(REF_ZLIB_NONE);
    assert_eq!(from_ref.len(), source.len());
    for (i, (got, want)) in from_ref.iter().zip(source.iter()).enumerate() {
        assert_eq!(got.read_id, want.read_id, "record {i}");
        assert_eq!(got.raw_signal, want.raw_signal, "record {i}");
    }
}

#[test]
fn parity_ref_none_exzd_readable() {
    let source = read_all_records(PARITY_INPUT);
    let from_ref = read_all_records(REF_NONE_EXZD);
    assert_eq!(from_ref.len(), source.len());
    for (i, (got, want)) in from_ref.iter().zip(source.iter()).enumerate() {
        assert_eq!(got.read_id, want.read_id, "record {i}");
        assert_eq!(got.raw_signal, want.raw_signal, "record {i}");
    }
}

#[test]
fn parity_ref_zstd_exzd_readable() {
    let source = read_all_records(PARITY_INPUT);
    let from_ref = read_all_records(REF_ZSTD_EXZD);
    assert_eq!(from_ref.len(), source.len());
    for (i, (got, want)) in from_ref.iter().zip(source.iter()).enumerate() {
        assert_eq!(got.read_id, want.read_id, "record {i}");
        assert_eq!(got.raw_signal, want.raw_signal, "record {i}");
    }
}