Skip to main content

gwseq_io/
lib.rs

1//! # gwseq-io
2//!
3//! Reading and writing bigWig, bigBed, BAM and HiC files.
4//!
5//! See `ARCHITECTURE.md` at the workspace root for the module map and the
6//! reasoning behind the layering.
7//!
8//! ## Shape
9//!
10//! Three readers and one writer, each opened on a path or a URL:
11//!
12//! ```no_run
13//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
14//! use gwseq_io::bbi::{ValuesRequest, Zoom};
15//! use gwseq_io::genomic::Locs;
16//! use gwseq_io::{open, Reader};
17//!
18//! let chr_ids = vec!["chr1".to_string(), "chr2".to_string()];
19//! let starts = vec![1_000_000, 2_000_000];
20//! let ends = vec![1_010_000, 2_010_000];
21//!
22//! let Reader::Bbi(bw) = open("track.bigwig", Default::default())? else {
23//!     panic!("not a bigwig")
24//! };
25//! let values = bw.read_values(
26//!     &ValuesRequest::new(Locs::spans(&chr_ids, &starts, &ends)?)
27//!         .bin_size(10.0)
28//!         .zoom(Zoom::Auto),
29//! )?; // Array2<f32>, (loci, bins)
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! Requests are builders because the API they mirror has up to fourteen
35//! defaulted keyword arguments; a builder is where each default is written down
36//! once, for the Python layer, the CLI and Rust callers alike.
37//!
38//! ## Threads and handles
39//!
40//! A reader owns `parallel` worker threads and one file handle for its
41//! lifetime. `close()` gives both back and is idempotent; a closed reader
42//! fails every read with [`Error::Closed`], while the headers it read at open
43//! stay available. A reader that is never closed gives everything back when it
44//! is dropped.
45
46#![forbid(unsafe_code)]
47
48// The crate README is the crates.io landing page, so its examples are compiled
49// and run as doctests rather than left to rot. Not rendered into these docs —
50// it says the same things this module does, for a reader who has not arrived
51// here yet.
52#[cfg(doctest)]
53#[doc = include_str!("../README.md")]
54struct ReadmeExamples;
55
56pub mod arrays;
57pub mod bam;
58pub mod bbi;
59pub mod bytes;
60pub mod error;
61#[cfg(test)]
62mod fuzz;
63pub mod genomes;
64pub mod genomic;
65pub mod hic;
66#[cfg(feature = "npz")]
67pub mod npz;
68pub mod parallel;
69pub mod progress;
70pub mod source;
71
72pub use error::{Error, Result};
73pub use genomes::get_chr_sizes;
74
75/// What [`open`] returns. The format is sniffed from the file's magic number,
76/// not from its extension.
77///
78/// The variants differ in size — a `HiCReader` carries three memo tables a
79/// `BbiReader` does not — and boxing them is not worth the ergonomics: one of
80/// these is constructed per file opened and then held for the reader's life,
81/// so the padding is paid once against a file handle and a thread pool.
82#[allow(clippy::large_enum_variant)]
83pub enum Reader {
84    Bbi(bbi::BbiReader),
85    Bam(bam::BamReader),
86    HiC(hic::HiCReader),
87}
88
89/// Everything [`open`] takes beyond the path.
90///
91/// `None` on a buffer field means "recommended", which is what `-1` spells in
92/// the Python API — and the recommendation differs by source: 32 KiB blocks for
93/// a local file, 1 MiB for a URL, 128 blocks either way.
94#[derive(Debug, Clone)]
95pub struct OpenOptions {
96    /// Worker threads. Zero or less means one per core, capped at 12.
97    pub parallel: i64,
98    /// bigWig only: scaling factor for automatic zoom selection.
99    pub zoom_correction: f64,
100    pub block_size: Option<u64>,
101    pub max_blocks: Option<usize>,
102    /// BAM only. Defaults to the file's path with `.bai` appended.
103    pub index_path: Option<String>,
104}
105
106impl Default for OpenOptions {
107    fn default() -> Self {
108        Self {
109            parallel: -1,
110            zoom_correction: 1.0 / 3.0,
111            block_size: None,
112            max_blocks: None,
113            index_path: None,
114        }
115    }
116}
117
118/// What a file's first bytes say it is.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum FileKind {
121    BigWig,
122    BigBed,
123    Bam,
124    HiC,
125}
126
127/// Sniff the format of an already-open source.
128///
129/// A BAM carries its magic *behind* the gzip one, being a BGZF file, so that
130/// read is only reached once the gzip magic matches. A byte-swapped bbi magic
131/// is recognised and refused — these readers do not swap rather than swapping
132/// silently, and saying which it is beats "unrecognised file".
133pub fn sniff_source(source: &dyn source::ByteSource) -> Result<FileKind> {
134    let head = source.read_at(0, 4)?;
135    if head.len() < 4 {
136        return Err(Error::format(
137            source.path(),
138            "file is too short to carry a magic number",
139        ));
140    }
141    if &head[..3] == b"HIC" {
142        return Ok(FileKind::HiC);
143    }
144    let magic = u32::from_le_bytes([head[0], head[1], head[2], head[3]]);
145    match magic {
146        bbi::BIGWIG_MAGIC => return Ok(FileKind::BigWig),
147        bbi::BIGBED_MAGIC => return Ok(FileKind::BigBed),
148        bbi::header::BIGWIG_MAGIC_SWAPPED | bbi::header::BIGBED_MAGIC_SWAPPED => {
149            return Err(Error::format(source.path(), "incompatible endianness"))
150        }
151        _ => {}
152    }
153    if head[0] == 0x1F && head[1] == 0x8B {
154        return Ok(FileKind::Bam);
155    }
156    Err(Error::format(
157        source.path(),
158        "not a bigwig, bigbed, bam or hic file",
159    ))
160}
161
162/// Sniff the format of a file from its magic number.
163pub fn sniff(path: &str) -> Result<FileKind> {
164    // A tiny cache: this reads four bytes and is thrown away.
165    let source = source::open(path, Some(4096), Some(1))?;
166    sniff_source(source.as_ref())
167}
168
169/// Open a file for reading, dispatching on its magic number.
170///
171/// The source is opened once and handed to whichever reader the magic names, so
172/// sniffing does not cost a second open — which over HTTP would be a second
173/// round trip.
174pub fn open(path: &str, options: OpenOptions) -> Result<Reader> {
175    let source = source::open(path, options.block_size, options.max_blocks)?;
176    match sniff_source(source.as_ref())? {
177        FileKind::BigWig | FileKind::BigBed => Ok(Reader::Bbi(bbi::BbiReader::from_source(
178            source,
179            path,
180            options.parallel,
181            options.zoom_correction,
182        )?)),
183        FileKind::Bam => Ok(Reader::Bam(bam::BamReader::from_source(
184            source,
185            path,
186            options.index_path.as_deref(),
187            options.parallel,
188            options.block_size,
189            options.max_blocks,
190        )?)),
191        FileKind::HiC => Ok(Reader::HiC(hic::HiCReader::from_source(
192            source,
193            path,
194            options.parallel,
195        )?)),
196    }
197}