Skip to main content

gwseq_io/source/
gzip.rs

1//! Transparent gzip, for the text converters.
2//!
3//! A codec is sequential, and sequential is [`std::io::Read`] — a different
4//! trait from [`ByteSource`], not a crippled implementation of it. There is no
5//! forward-only "file" here that has to refuse `len()` and override every
6//! method that would move its cursor: nothing needing random access ever sees a
7//! gzip stream, and nothing reading a gzip stream ever seeks.
8//!
9//! [`ByteSource`]: crate::source::ByteSource
10
11use std::io::{BufReader, Read};
12use std::path::Path;
13
14use crate::error::{Error, Result};
15
16/// `MultiGzDecoder`, never `GzDecoder`: concatenated members are what a gzip
17/// file is here — BGZF is nothing else, and `bgzip` writes one member per
18/// block — and only the multi variant walks past the first.
19pub type GzipReader<R> = flate2::read::MultiGzDecoder<R>;
20
21/// The 0x1F 0x8B magic.
22pub fn is_gzipped(first_two: &[u8]) -> bool {
23    first_two.len() >= 2 && first_two[0] == 0x1F && first_two[1] == 0x8B
24}
25
26/// Open a path for sequential reading, inflating when it is gzipped.
27///
28/// This is what the converters read their bedGraph / WIG / BED through, and the
29/// only place in the crate that reads sequentially rather than by offset.
30///
31/// The magic decides, not the name: a `.bedgraph` holding gzip is read, and a
32/// `.gz` holding text is too. Returns the reader and the file's size on disk,
33/// which is what the converters report progress against — the *compressed*
34/// size, since that is what "how far through the file are we" means to a caller
35/// watching it.
36pub fn open_text(path: impl AsRef<Path>) -> Result<(TextInput, u64)> {
37    let path = path.as_ref();
38    let display = path.to_string_lossy().into_owned();
39    let file = std::fs::File::open(path).map_err(|e| Error::io(&display, e))?;
40    let size = file.metadata().map_err(|e| Error::io(&display, e))?.len();
41
42    let mut head = [0u8; 2];
43    let read = read_full(&file, &mut head).map_err(|e| Error::io(&display, e))?;
44    // Rewound rather than kept: the two bytes belong to whichever decoder gets
45    // the file, and threading them past it would mean wrapping every reader in
46    // a chain for the sake of one peek.
47    use std::io::Seek as _;
48    let mut file = file;
49    file.rewind().map_err(|e| Error::io(&display, e))?;
50
51    let inner: Box<dyn Read + Send> = if is_gzipped(&head[..read]) {
52        Box::new(GzipReader::new(BufReader::with_capacity(1 << 16, file)))
53    } else {
54        Box::new(file)
55    };
56    Ok((BufReader::with_capacity(1 << 20, inner), size))
57}
58
59fn read_full(mut file: &std::fs::File, buf: &mut [u8]) -> std::io::Result<usize> {
60    let mut filled = 0;
61    while filled < buf.len() {
62        match file.read(&mut buf[filled..]) {
63            Ok(0) => break,
64            Ok(n) => filled += n,
65            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
66            Err(e) => return Err(e),
67        }
68    }
69    Ok(filled)
70}
71
72/// Sequential reads over anything, buffered. Named so the converters do not
73/// spell the generic out.
74pub type TextInput = BufReader<Box<dyn Read + Send>>;
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use std::io::{BufRead as _, Write as _};
80
81    fn temp(name: &str) -> std::path::PathBuf {
82        let dir = std::env::temp_dir().join("gwseq_gzip_tests");
83        std::fs::create_dir_all(&dir).unwrap();
84        dir.join(name)
85    }
86
87    /// One gzip member per slice, concatenated — which is what a BGZF file is,
88    /// and what `MultiGzDecoder` is here to walk. Built by the test rather than
89    /// by a writer this crate exports: nothing in the library writes gzip, and
90    /// a public type kept alive only by the tests that use it is not a public
91    /// type worth having.
92    fn gzip_members(bodies: &[&[u8]]) -> Vec<u8> {
93        let mut out = Vec::new();
94        for body in bodies {
95            let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::new(6));
96            e.write_all(body).unwrap();
97            out.extend_from_slice(&e.finish().unwrap());
98        }
99        out
100    }
101
102    fn lines_of(path: &std::path::Path) -> Vec<String> {
103        let (reader, _) = open_text(path).unwrap();
104        reader.lines().map(|l| l.unwrap()).collect()
105    }
106
107    #[test]
108    fn plain_text_is_read_as_it_stands() {
109        let path = temp("plain.txt");
110        std::fs::write(&path, "one\ntwo\nthree\n").unwrap();
111        assert_eq!(lines_of(&path), ["one", "two", "three"]);
112        std::fs::remove_file(&path).ok();
113    }
114
115    #[test]
116    fn a_gzipped_file_is_inflated_whatever_it_is_called() {
117        let path = temp("nogz.bedgraph");
118        std::fs::write(&path, gzip_members(&[b"one\ntwo\nthree\n"])).unwrap();
119        assert_eq!(lines_of(&path), ["one", "two", "three"]);
120        std::fs::remove_file(&path).ok();
121    }
122
123    #[test]
124    fn every_member_of_a_multi_member_file_is_read() {
125        // What `MultiGzDecoder` buys: `GzDecoder` stops after the first, which
126        // on a BGZF file means losing all but its first block.
127        let path = temp("members.gz");
128        let bodies: Vec<Vec<u8>> = (0..50).map(|i| format!("line{i}\n").into_bytes()).collect();
129        let members = gzip_members(&bodies.iter().map(|b| &b[..]).collect::<Vec<_>>());
130        std::fs::write(&path, members).unwrap();
131        let lines = lines_of(&path);
132        assert_eq!(lines.len(), 50);
133        assert_eq!(lines[49], "line49");
134        std::fs::remove_file(&path).ok();
135    }
136
137    #[test]
138    fn the_reported_size_is_the_size_on_disk() {
139        let path = temp("size.txt");
140        std::fs::write(&path, vec![b'x'; 1234]).unwrap();
141        let (_, size) = open_text(&path).unwrap();
142        assert_eq!(size, 1234);
143        std::fs::remove_file(&path).ok();
144    }
145
146    #[test]
147    fn an_empty_file_reads_as_no_lines_rather_than_failing() {
148        let path = temp("empty.txt");
149        std::fs::write(&path, b"").unwrap();
150        assert!(lines_of(&path).is_empty());
151        std::fs::remove_file(&path).ok();
152    }
153}