use std::fs::File;
use std::io::{self, BufRead, BufReader, Read, Seek, Stdin};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use bzip2_rs::DecoderReader;
use super::{Sequence, SequenceFile};
use crate::config::FastQCConfig;
enum ReaderKind {
Plain(BufReader<File>),
Gzip(Box<BufReader<rapidgzip_core::DecoderReader>>),
Bzip2(Box<BufReader<DecoderReader<File>>>),
Stdin(BufReader<Stdin>),
}
const READ_BUFFER: usize = 1 << 20;
impl BufRead for ReaderKind {
fn fill_buf(&mut self) -> io::Result<&[u8]> {
match self {
ReaderKind::Plain(r) => r.fill_buf(),
ReaderKind::Gzip(r) => r.fill_buf(),
ReaderKind::Bzip2(r) => r.fill_buf(),
ReaderKind::Stdin(r) => r.fill_buf(),
}
}
fn consume(&mut self, amt: usize) {
match self {
ReaderKind::Plain(r) => r.consume(amt),
ReaderKind::Gzip(r) => r.consume(amt),
ReaderKind::Bzip2(r) => r.consume(amt),
ReaderKind::Stdin(r) => r.consume(amt),
}
}
}
impl Read for ReaderKind {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self {
ReaderKind::Plain(r) => r.read(buf),
ReaderKind::Gzip(r) => r.read(buf),
ReaderKind::Bzip2(r) => r.read(buf),
ReaderKind::Stdin(r) => r.read(buf),
}
}
}
enum Progress {
Stdin,
FilePosition(File),
Gzip(GzipProgress),
}
const MAX_PLAUSIBLE_RATIO: u64 = 1000;
const ISIZE_MODULUS: u64 = 1 << 32;
struct GzipProgress {
compressed: Arc<AtomicU64>,
handle: rapidgzip_core::DecoderHandle,
estimate: std::sync::Mutex<GzipEstimate>,
}
#[derive(Default)]
struct GzipEstimate {
ratio_total: u64,
trailer: Option<u64>,
}
impl GzipProgress {
fn percent(&self, file_size: u64, buffered: u64) -> Option<f64> {
if file_size == 0 {
return None;
}
let stats = self.handle.stats();
let parsed = stats.consumed_bytes.saturating_sub(buffered);
let produced = stats.decompressed_bytes.max(stats.consumed_bytes);
let compressed = self.compressed.load(Ordering::Relaxed);
if compressed == 0 || produced == 0 {
return None;
}
let mut estimate = self
.estimate
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let ratio = produced as f64 / compressed as f64;
estimate.ratio_total = estimate
.ratio_total
.max((file_size as f64 * ratio) as u64)
.max(produced);
if let Some(trailer) = estimate.trailer {
let past_first_member = match stats.member_count {
0 => false,
1 => produced % ISIZE_MODULUS != trailer,
_ => true,
};
let wrapped = unwrap_isize(trailer, 0, estimate.ratio_total);
if past_first_member || estimate.ratio_total > wrapped + wrapped / 4 {
estimate.trailer = None;
}
}
let total = match estimate.trailer {
Some(trailer) => unwrap_isize(trailer, parsed, estimate.ratio_total),
None => estimate.ratio_total,
};
if total == 0 {
return None;
}
Some((parsed as f64 / total as f64 * 100.0).clamp(0.0, 100.0))
}
}
fn unwrap_isize(trailer: u64, parsed: u64, hint: u64) -> u64 {
let nearest = (hint.saturating_sub(trailer) + ISIZE_MODULUS / 2) / ISIZE_MODULUS;
let at_least = parsed.saturating_sub(trailer).div_ceil(ISIZE_MODULUS);
trailer + nearest.max(at_least) * ISIZE_MODULUS
}
fn gzip_trailer_size(path: &Path, file_size: u64) -> Option<u64> {
if file_size < 20 {
return None;
}
let mut file = File::open(path).ok()?;
file.seek(io::SeekFrom::End(-4)).ok()?;
let mut trailer = [0u8; 4];
file.read_exact(&mut trailer).ok()?;
let total = u32::from_le_bytes(trailer) as u64;
(total >= file_size && total <= file_size.saturating_mul(MAX_PLAUSIBLE_RATIO)).then_some(total)
}
fn detect_compression_from_magic(path: &Path) -> io::Result<&'static str> {
let mut f = File::open(path)?;
if !f.metadata()?.is_file() {
return Ok("none");
}
let mut magic = [0u8; 2];
let n = f.read(&mut magic)?;
if n >= 2 {
if magic[0] == 0x1f && magic[1] == 0x8b {
return Ok("gz");
}
if magic[0] == 0x42 && magic[1] == 0x5a {
return Ok("bz2");
}
}
Ok("none")
}
struct CountingSource {
file: File,
counter: Arc<AtomicU64>,
}
impl rapidgzip_core::ReadAt for CountingSource {
fn len(&self) -> io::Result<u64> {
self.file.metadata().map(|metadata| metadata.len())
}
fn read_at(&self, offset: u64, buffer: &mut [u8]) -> io::Result<usize> {
let read = rapidgzip_core::ReadAt::read_at(&self.file, offset, buffer)?;
self.counter
.fetch_max(offset + read as u64, Ordering::Relaxed);
Ok(read)
}
}
impl Read for CountingSource {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
let read = self.file.read(buffer)?;
self.counter.fetch_add(read as u64, Ordering::Relaxed);
Ok(read)
}
}
struct OpenedGzip {
reader: rapidgzip_core::DecoderReader,
progress: GzipProgress,
background_threads: usize,
}
fn open_rapidgzip(
file: File,
threads: usize,
trailer_total: Option<u64>,
) -> io::Result<OpenedGzip> {
let positional = threads > 0
&& file
.metadata()
.is_ok_and(|metadata| metadata.file_type().is_file());
let compressed = Arc::new(AtomicU64::new(0));
let source = CountingSource {
file,
counter: Arc::clone(&compressed),
};
let decoder = rapidgzip_core::Decoder::builder()
.decoder_threads(threads.max(1))
.build()?;
let reader = if positional {
decoder.reader(source)
} else {
decoder.stream_reader(source)
}
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let progress = GzipProgress {
compressed,
handle: reader.handle(),
estimate: std::sync::Mutex::new(GzipEstimate {
ratio_total: 0,
trailer: trailer_total,
}),
};
Ok(OpenedGzip {
reader,
progress,
background_threads: if positional { threads } else { 0 },
})
}
pub struct FastQFile {
reader: ReaderKind,
name: String,
file_size: u64,
progress: Progress,
background_threads: usize,
next_sequence: Option<Sequence>,
line_number: u64,
is_colorspace: bool,
colorspace_checked: bool,
casava_mode: bool,
nofilter: bool,
pub lowest_char: u8,
}
impl FastQFile {
pub fn open<P: AsRef<Path>>(config: &FastQCConfig, path: P) -> io::Result<Self> {
let path = path.as_ref();
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
let is_stdin = name.starts_with("stdin");
let file_size = if is_stdin {
u64::MAX
} else {
std::fs::metadata(path)?.len()
};
let (reader, progress, background_threads) = if is_stdin {
(
ReaderKind::Stdin(BufReader::new(io::stdin())),
Progress::Stdin,
0,
)
} else {
let lower_name = name.to_lowercase();
let compression = if lower_name.ends_with(".gz") {
"gz"
} else if lower_name.ends_with(".bz2") {
"bz2"
} else {
detect_compression_from_magic(path)?
};
let file = File::open(path)?;
match compression {
"gz" => {
let trailer = gzip_trailer_size(path, file_size);
let gz = open_rapidgzip(file, config.decompress_threads, trailer)?;
(
ReaderKind::Gzip(Box::new(BufReader::with_capacity(
READ_BUFFER,
gz.reader,
))),
Progress::Gzip(gz.progress),
gz.background_threads,
)
}
"bz2" => {
let pos_handle = file.try_clone()?;
(
ReaderKind::Bzip2(Box::new(BufReader::new(DecoderReader::new(file)))),
Progress::FilePosition(pos_handle),
0,
)
}
_ => {
let pos_handle = file.try_clone()?;
(
ReaderKind::Plain(BufReader::with_capacity(READ_BUFFER, file)),
Progress::FilePosition(pos_handle),
0,
)
}
}
};
let casava_mode = config.casava;
let nofilter = config.nofilter;
let mut fq = FastQFile {
reader,
name,
file_size,
progress,
background_threads,
next_sequence: None,
line_number: 0,
is_colorspace: false,
colorspace_checked: false,
casava_mode,
nofilter,
lowest_char: 255,
};
fq.read_next()?;
Ok(fq)
}
fn read_line(&mut self, out: &mut Vec<u8>) -> io::Result<bool> {
out.clear();
let mut read_any = false;
loop {
let available = match self.reader.fill_buf() {
Ok(available) => available,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
};
if available.is_empty() {
break;
}
read_any = true;
if let Some(newline) = memchr::memchr(b'\n', available) {
out.extend_from_slice(&available[..newline]);
self.reader.consume(newline + 1);
break;
}
let len = available.len();
out.extend_from_slice(available);
self.reader.consume(len);
}
self.line_number += 1;
if !read_any {
return Ok(false);
}
while out.last() == Some(&b'\r') {
out.pop();
}
Ok(true)
}
fn read_next(&mut self) -> io::Result<()> {
let mut id_bytes = Vec::new();
loop {
if !self.read_line(&mut id_bytes)? {
self.next_sequence = None;
return Ok(());
}
if !id_bytes.is_empty() {
break;
}
}
if !id_bytes.starts_with(b"@") {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("ID line didn't start with '@' at line {}", self.line_number),
));
}
let id = String::from_utf8(id_bytes).map_err(|_| invalid_utf8())?;
let mut seq_bytes = Vec::new();
if !self.read_line(&mut seq_bytes)? {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"Ran out of data in the middle of a fastq entry. Your file is probably truncated",
));
}
let mut quality_bytes = Vec::with_capacity(seq_bytes.len());
if !self.read_line(&mut quality_bytes)? {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"Ran out of data in the middle of a fastq entry. Your file is probably truncated",
));
}
check_utf8(&quality_bytes)?;
if !quality_bytes.starts_with(b"+") {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Midline '{}' didn't start with '+' at {}",
String::from_utf8_lossy(&quality_bytes),
self.line_number
),
));
}
if !self.read_line(&mut quality_bytes)? {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"Ran out of data in the middle of a fastq entry. Your file is probably truncated",
));
}
check_utf8(&quality_bytes)?;
if let Some(lowest) = quality_bytes.iter().copied().min() {
self.lowest_char = self.lowest_char.min(lowest);
}
let seq_str = std::str::from_utf8(&seq_bytes).map_err(|_| invalid_utf8())?;
if !self.colorspace_checked {
self.colorspace_checked = true;
self.is_colorspace = check_colorspace(seq_str);
}
let is_filtered =
self.casava_mode && !self.nofilter && id.find(":Y:").is_some_and(|pos| pos > 0);
let mut sequence = if self.is_colorspace {
let upper = seq_str.to_ascii_uppercase();
let bases = convert_colorspace_to_bases(&upper)?;
let mut s = Sequence::new(id, bases.into_bytes(), quality_bytes);
s.colorspace = Some(upper.into_bytes());
s
} else {
Sequence::new(id, seq_bytes, quality_bytes)
};
sequence.is_filtered = is_filtered;
self.next_sequence = Some(sequence);
Ok(())
}
}
impl SequenceFile for FastQFile {
fn next(&mut self) -> Option<io::Result<Sequence>> {
let current = self.next_sequence.take()?;
if let Err(e) = self.read_next() {
return Some(Err(e));
}
Some(Ok(current))
}
fn name(&self) -> &str {
&self.name
}
fn is_colorspace(&self) -> bool {
self.is_colorspace
}
fn background_threads(&self) -> usize {
self.background_threads
}
fn percent_complete(&self) -> f64 {
if self.next_sequence.is_none() {
return 100.0;
}
match &self.progress {
Progress::Stdin => 0.0,
Progress::Gzip(progress) => {
let buffered = match &self.reader {
ReaderKind::Gzip(r) => r.buffer().len() as u64,
_ => 0,
};
progress.percent(self.file_size, buffered).unwrap_or(0.0)
}
Progress::FilePosition(handle) => {
let buffered = match &self.reader {
ReaderKind::Plain(r) => r.buffer().len() as u64,
_ => 0,
};
let mut handle: &File = handle;
handle
.stream_position()
.map(|pos| {
(pos.saturating_sub(buffered) as f64 / self.file_size as f64) * 100.0
})
.unwrap_or(0.0)
}
}
}
}
fn invalid_utf8() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
"stream did not contain valid UTF-8",
)
}
fn check_utf8(bytes: &[u8]) -> io::Result<()> {
std::str::from_utf8(bytes)
.map(|_| ())
.map_err(|_| invalid_utf8())
}
fn check_colorspace(seq: &str) -> bool {
let bytes = seq.as_bytes();
if bytes.len() < 2 {
return false;
}
if !matches!(
bytes[0],
b'G' | b'A' | b'T' | b'C' | b'N' | b'g' | b'a' | b't' | b'c' | b'n'
) {
return false;
}
for &b in &bytes[1..] {
if !matches!(b, b'.' | b'0'..=b'6') {
return false;
}
}
true
}
fn convert_colorspace_to_bases(s: &str) -> io::Result<String> {
let cs = s.as_bytes();
if cs.is_empty() {
return Ok(String::new());
}
let mut bp = vec![0u8; cs.len() - 1];
for i in 1..cs.len() {
let ref_base = if i == 1 {
cs[0]
} else {
bp[i - 2]
};
debug_assert!(
matches!(ref_base, b'G' | b'A' | b'T' | b'C'),
"Colorspace sequence data should always start with a real DNA letter, got '{}'",
ref_base as char,
);
bp[i - 1] = match cs[i] {
b'0' => ref_base, b'1' => match ref_base {
b'A' => b'C',
b'C' => b'A',
b'G' => b'T',
b'T' => b'G',
_ => b'N',
},
b'2' => match ref_base {
b'A' => b'G',
b'G' => b'A',
b'C' => b'T',
b'T' => b'C',
_ => b'N',
},
b'3' => match ref_base {
b'A' => b'T',
b'T' => b'A',
b'G' => b'C',
b'C' => b'G',
_ => b'N',
},
b'.' | b'4' | b'5' | b'6' => {
for b in &mut bp[(i - 1)..] {
*b = b'N';
}
break;
}
other => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Unexpected colorspace char '{}'", other as char),
));
}
};
}
Ok(String::from_utf8(bp).expect("colorspace output should be valid UTF-8"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_check_colorspace_positive() {
assert!(check_colorspace("G0123456"));
assert!(check_colorspace("A.012"));
assert!(check_colorspace("t00"));
}
#[test]
fn test_check_colorspace_negative() {
assert!(!check_colorspace("ACGTACGT"));
assert!(!check_colorspace("A")); assert!(!check_colorspace(""));
assert!(!check_colorspace("X012")); }
fn colorspace(s: &str) -> String {
convert_colorspace_to_bases(s).unwrap()
}
#[test]
fn test_convert_colorspace_basic() {
assert_eq!(colorspace("A0"), "A");
assert_eq!(colorspace("A1"), "C");
assert_eq!(colorspace("A2"), "G");
assert_eq!(colorspace("A3"), "T");
}
#[test]
fn test_convert_colorspace_chained() {
assert_eq!(colorspace("A00"), "AA");
assert_eq!(colorspace("A01"), "AC");
assert_eq!(colorspace("G10"), "TT");
}
#[test]
fn test_convert_colorspace_unknown_fills_n() {
assert_eq!(colorspace("A.12"), "NNN");
assert_eq!(colorspace("A04"), "AN");
}
#[test]
fn test_convert_colorspace_empty() {
assert_eq!(colorspace(""), "");
}
#[test]
fn test_convert_colorspace_rejects_bases() {
let err = convert_colorspace_to_bases("TACGT").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn test_read_minimal_fastq() {
let config = FastQCConfig::default();
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/minimal.fastq");
let mut reader = FastQFile::open(&config, path).unwrap();
let seq = reader.next().unwrap().unwrap();
assert_eq!(seq.id, "@READ0001");
assert_eq!(seq.sequence, b"AAAAAAAAAAAAAAAA");
assert_eq!(seq.quality, b"IIIIIIIIIIIIIIII");
assert!(!seq.is_filtered);
assert!(!reader.is_colorspace());
assert!(reader.next().is_none());
}
#[test]
fn test_read_complex_fastq() {
let config = FastQCConfig::default();
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/complex.fastq");
let mut reader = FastQFile::open(&config, path).unwrap();
let mut count = 0;
while let Some(result) = reader.next() {
let seq = result.unwrap();
count += 1;
assert_eq!(seq.sequence, b"ACGTACGTACGTACGT");
assert_eq!(seq.quality, b"IIIIIIIIIIIIIIII");
assert_eq!(seq.id, format!("@READ{:04}", count));
}
assert_eq!(count, 5);
}
#[test]
fn test_lowest_char_tracking() {
let config = FastQCConfig::default();
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/minimal.fastq");
let mut reader = FastQFile::open(&config, path).unwrap();
while reader.next().is_some() {}
assert_eq!(reader.lowest_char, b'I');
}
#[test]
fn test_casava_filter_detection() {
let config = FastQCConfig {
casava: true,
nofilter: false,
..FastQCConfig::default()
};
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/minimal.fastq");
let mut reader = FastQFile::open(&config, path).unwrap();
let seq = reader.next().unwrap().unwrap();
assert!(!seq.is_filtered);
}
#[test]
fn test_sequence_uppercase() {
let seq = Sequence::new(
"@test".to_string(),
b"acgtACGT".to_vec(),
b"IIIIIIII".to_vec(),
);
assert_eq!(seq.sequence, b"ACGTACGT");
}
#[test]
fn test_gzip_matches_plaintext() {
let config = FastQCConfig::default();
let base = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/");
let mut plain = FastQFile::open(&config, format!("{base}minimal.fastq")).unwrap();
let mut gz = FastQFile::open(&config, format!("{base}minimal.fastq.gz")).unwrap();
assert!(
matches!(gz.progress, Progress::Gzip(_)),
"expected the gzip reader for .gz input"
);
loop {
match (plain.next(), gz.next()) {
(Some(a), Some(b)) => {
let a = a.unwrap();
let b = b.unwrap();
assert_eq!(a.id, b.id);
assert_eq!(a.sequence, b.sequence);
assert_eq!(a.quality, b.quality);
}
(None, None) => break,
_ => panic!("record count mismatch between plaintext and gzip"),
}
}
}
#[test]
fn test_gzip_trailer_size() {
let base = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/");
let gz = Path::new(base).join("realistic.fastq.gz");
let gz_len = std::fs::metadata(&gz).unwrap().len();
let trailer = gzip_trailer_size(&gz, gz_len).expect("a plausible trailer");
let mut decoded = 0u64;
let mut reader = open_rapidgzip(File::open(&gz).unwrap(), 1, None)
.unwrap()
.reader;
let mut buffer = [0u8; 8192];
loop {
match reader.read(&mut buffer).unwrap() {
0 => break,
n => decoded += n as u64,
}
}
assert_eq!(trailer, decoded, "trailer is not the uncompressed size");
assert_eq!(gzip_trailer_size(&gz, 19), None);
assert_eq!(gzip_trailer_size(&gz, trailer + 1), None);
assert_eq!(gzip_trailer_size(&gz, 1), None);
}
#[test]
fn test_unwrap_isize() {
const GIB: u64 = 1 << 30;
assert_eq!(unwrap_isize(GIB, 0, GIB), GIB);
assert_eq!(unwrap_isize(GIB, GIB / 2, 2 * GIB), GIB);
assert_eq!(unwrap_isize(2 * GIB, GIB / 10, 5 * GIB), 6 * GIB);
assert_eq!(unwrap_isize(2 * GIB, GIB / 10, 7 * GIB), 6 * GIB);
assert_eq!(unwrap_isize(2 * GIB, 3 * GIB, GIB), 6 * GIB);
assert_eq!(unwrap_isize(GIB, 0, 13 * GIB), 13 * GIB);
}
#[test]
fn test_gzip_inline_decoding() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/realistic.fastq.gz");
let background = FastQFile::open(&FastQCConfig::default(), path).unwrap();
assert_eq!(background.background_threads(), 1);
let config = FastQCConfig {
decompress_threads: 0,
..FastQCConfig::default()
};
let mut inline = FastQFile::open(&config, path).unwrap();
assert_eq!(inline.background_threads(), 0);
let mut count = 0u64;
let mut seen_partial = false;
while let Some(result) = inline.next() {
result.unwrap();
count += 1;
seen_partial |= inline.percent_complete() < 100.0;
}
assert_eq!(count, 1009);
assert!(seen_partial, "progress was saturated for the whole file");
}
#[cfg(unix)]
#[test]
fn test_gzip_from_a_fifo() {
let dir = std::env::temp_dir().join(format!("fastqc_fifo_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fifo = dir.join("reads.fastq.gz");
let _ = std::fs::remove_file(&fifo);
let status = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.expect("mkfifo");
assert!(status.success());
let source = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/realistic.fastq.gz");
let writer = {
let fifo = fifo.clone();
std::thread::spawn(move || {
let data = std::fs::read(source).unwrap();
std::fs::write(&fifo, data).unwrap();
})
};
let mut reader = FastQFile::open(&FastQCConfig::default(), &fifo).unwrap();
assert_eq!(reader.background_threads(), 0);
let mut count = 0u64;
while let Some(result) = reader.next() {
result.unwrap();
count += 1;
}
writer.join().unwrap();
std::fs::remove_dir_all(&dir).ok();
assert_eq!(count, 1009);
}
#[test]
fn test_gzip_progress_tracks_consumed_bytes() {
let config = FastQCConfig::default();
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/realistic.fastq.gz");
let mut reader = FastQFile::open(&config, path).unwrap();
let mut last = 0.0f64;
let mut seen_partial = false;
while let Some(result) = reader.next() {
result.unwrap();
let percent = reader.percent_complete();
assert!(
(0.0..=100.0).contains(&percent),
"percent out of range: {percent}"
);
assert!(
percent >= last,
"progress went backwards: {last} -> {percent}"
);
if percent < 100.0 {
seen_partial = true;
}
last = percent;
}
assert!(
seen_partial,
"progress was saturated for the whole file, which is the bug this guards"
);
assert_eq!(reader.percent_complete(), 100.0, "did not finish at 100%");
}
#[test]
fn test_gzip_progress_concatenated_members() {
let source = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/realistic.fastq.gz");
let member = std::fs::read(source).unwrap();
let dir = std::env::temp_dir().join(format!("fastqc_concat_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("reads.fastq.gz");
std::fs::write(&path, [member.as_slice(), member.as_slice()].concat()).unwrap();
let mut reader = FastQFile::open(&FastQCConfig::default(), &path).unwrap();
let mut count = 0u64;
let mut at_halfway = None;
while let Some(result) = reader.next() {
result.unwrap();
count += 1;
let percent = reader.percent_complete();
if count == 1009 {
at_halfway = Some(percent);
}
}
std::fs::remove_dir_all(&dir).ok();
assert_eq!(count, 2018);
let at_halfway = at_halfway.unwrap();
assert!(
(35.0..=65.0).contains(&at_halfway),
"halfway through the reads but progress says {at_halfway}%"
);
assert_eq!(reader.percent_complete(), 100.0);
}
#[test]
fn test_plain_progress_tracks_parsed_bytes() {
let config = FastQCConfig::default();
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/varied.fastq");
let mut reader = FastQFile::open(&config, path).unwrap();
let mut last = 0.0f64;
let mut seen_partial = false;
while let Some(result) = reader.next() {
result.unwrap();
let percent = reader.percent_complete();
assert!(
percent >= last,
"progress went backwards: {last} -> {percent}"
);
seen_partial |= percent < 100.0;
last = percent;
}
assert!(seen_partial, "progress was saturated for the whole file");
assert_eq!(reader.percent_complete(), 100.0);
}
#[test]
fn test_gzip_reads_realistic() {
let config = FastQCConfig::default();
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/realistic.fastq.gz");
let mut reader = FastQFile::open(&config, path).unwrap();
assert!(matches!(reader.progress, Progress::Gzip(_)));
let mut count = 0u64;
while let Some(result) = reader.next() {
let seq = result.unwrap();
assert!(!seq.is_empty());
count += 1;
}
assert_eq!(count, 1009);
}
}