Skip to main content

gwseq_io/
lib.rs

1//! # gwseq-io
2//!
3//! Reading and writing bigWig, bigBed, BAM, CRAM 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//! Four 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 align;
57pub mod arrays;
58pub mod bam;
59pub mod bbi;
60pub mod bytes;
61pub mod cram;
62pub mod error;
63#[cfg(test)]
64mod fuzz;
65pub mod genomes;
66pub mod genomic;
67pub mod hic;
68#[cfg(feature = "npz")]
69pub mod npz;
70pub mod parallel;
71pub mod progress;
72pub mod source;
73
74pub use align::{AlignmentWalk, Alignments};
75pub use error::{Error, Result};
76pub use genomes::get_chr_sizes;
77
78/// What [`open`] returns. The format is sniffed from the file's magic number,
79/// not from its extension.
80///
81/// The variants differ in size — a `HiCReader` carries three memo tables a
82/// `BbiReader` does not — and boxing them is not worth the ergonomics: one of
83/// these is constructed per file opened and then held for the reader's life,
84/// so the padding is paid once against a file handle and a thread pool.
85#[allow(clippy::large_enum_variant)]
86pub enum Reader {
87    Bbi(bbi::BbiReader),
88    Bam(bam::BamReader),
89    Cram(cram::CramReader),
90    HiC(hic::HiCReader),
91}
92
93/// Everything [`open`] takes beyond the path.
94///
95/// `None` on a buffer field means "recommended", which is what `-1` spells in
96/// the Python API — and the recommendation differs by source: 32 KiB blocks for
97/// a local file, 1 MiB for a URL, 128 blocks either way.
98#[derive(Debug, Clone)]
99pub struct OpenOptions {
100    /// Worker threads. Zero or less means one per core, capped at 12.
101    pub parallel: i64,
102    /// bigWig only: scaling factor for automatic zoom selection.
103    pub zoom_correction: f64,
104    pub block_size: Option<u64>,
105    pub max_blocks: Option<usize>,
106    /// BAM and CRAM. Defaults to the file's path with `.bai` or `.crai`
107    /// appended.
108    pub index_path: Option<String>,
109    /// CRAM only: a FASTA holding the reference the file was compressed
110    /// against. `None` looks at the `UR` field of the header's `@SQ` lines and
111    /// then at `REF_CACHE`/`REF_PATH`; a CRAM that finds none still opens and
112    /// reads everything but `sequence`.
113    pub reference: Option<String>,
114}
115
116impl Default for OpenOptions {
117    fn default() -> Self {
118        Self {
119            parallel: -1,
120            zoom_correction: 1.0 / 3.0,
121            block_size: None,
122            max_blocks: None,
123            index_path: None,
124            reference: None,
125        }
126    }
127}
128
129/// What a file's first bytes say it is.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum FileKind {
132    BigWig,
133    BigBed,
134    Bam,
135    Cram,
136    HiC,
137}
138
139/// Sniff the format of an already-open source.
140///
141/// A BAM carries its magic *behind* the gzip one, being a BGZF file, so that
142/// read is only reached once the gzip magic matches. A CRAM says `CRAM` in
143/// clear at offset zero, so it is settled before any of that. A byte-swapped bbi magic
144/// is recognised and refused — these readers do not swap rather than swapping
145/// silently, and saying which it is beats "unrecognised file".
146pub fn sniff_source(source: &dyn source::ByteSource) -> Result<FileKind> {
147    let head = source.read_at(0, 4)?;
148    if head.len() < 4 {
149        return Err(Error::format(
150            source.path(),
151            "file is too short to carry a magic number",
152        ));
153    }
154    if &head[..3] == b"HIC" {
155        return Ok(FileKind::HiC);
156    }
157    if head[..4] == cram::container::MAGIC {
158        return Ok(FileKind::Cram);
159    }
160    let magic = u32::from_le_bytes([head[0], head[1], head[2], head[3]]);
161    match magic {
162        bbi::BIGWIG_MAGIC => return Ok(FileKind::BigWig),
163        bbi::BIGBED_MAGIC => return Ok(FileKind::BigBed),
164        bbi::header::BIGWIG_MAGIC_SWAPPED | bbi::header::BIGBED_MAGIC_SWAPPED => {
165            return Err(Error::format(source.path(), "incompatible endianness"))
166        }
167        _ => {}
168    }
169    if head[0] == 0x1F && head[1] == 0x8B {
170        return Ok(FileKind::Bam);
171    }
172    Err(Error::format(
173        source.path(),
174        "not a bigwig, bigbed, bam, cram or hic file",
175    ))
176}
177
178/// Sniff the format of a file from its magic number.
179pub fn sniff(path: &str) -> Result<FileKind> {
180    // A tiny cache: this reads four bytes and is thrown away.
181    let source = source::open(path, Some(4096), Some(1))?;
182    sniff_source(source.as_ref())
183}
184
185/// Open a file for reading, dispatching on its magic number.
186///
187/// The source is opened once and handed to whichever reader the magic names, so
188/// sniffing does not cost a second open — which over HTTP would be a second
189/// round trip.
190pub fn open(path: &str, options: OpenOptions) -> Result<Reader> {
191    let source = source::open(path, options.block_size, options.max_blocks)?;
192    match sniff_source(source.as_ref())? {
193        FileKind::BigWig | FileKind::BigBed => Ok(Reader::Bbi(bbi::BbiReader::from_source(
194            source,
195            path,
196            options.parallel,
197            options.zoom_correction,
198        )?)),
199        FileKind::Bam => Ok(Reader::Bam(bam::BamReader::from_source(
200            source,
201            path,
202            options.index_path.as_deref(),
203            options.parallel,
204            options.block_size,
205            options.max_blocks,
206        )?)),
207        FileKind::Cram => Ok(Reader::Cram(cram::CramReader::from_source(
208            source,
209            path,
210            options.index_path.as_deref(),
211            options.reference.as_deref(),
212            options.parallel,
213            options.block_size,
214            options.max_blocks,
215        )?)),
216        FileKind::HiC => Ok(Reader::HiC(hic::HiCReader::from_source(
217            source,
218            path,
219            options.parallel,
220        )?)),
221    }
222}