use anyhow::{Result, anyhow};
use flate2::bufread::MultiGzDecoder;
use log::{info, warn};
use seq_io::fastq::OwnedRecord;
use seq_io::fastq::Reader as FastqReader;
use std::fs::File;
use std::io::{BufReader, Cursor, Read};
use std::path::{Path, PathBuf};
pub(crate) const BUFFER_SIZE: usize = 512 * 1024;
const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PairingRule {
CasavaOrBare,
SlashDigit,
SepDigit(u8),
}
impl PairingRule {
pub(crate) fn select(head1: &[u8], head2: &[u8]) -> Option<PairingRule> {
if matches_slash_digit(head1, head2) {
Some(PairingRule::SlashDigit)
} else if matches_sep_digit(head1, head2, b'.') {
Some(PairingRule::SepDigit(b'.'))
} else if matches_sep_digit(head1, head2, b'_') {
Some(PairingRule::SepDigit(b'_'))
} else if matches_casava_or_bare(head1, head2) {
Some(PairingRule::CasavaOrBare)
} else {
None
}
}
pub(crate) fn check_pair(&self, head1: &[u8], head2: &[u8]) -> bool {
match self {
PairingRule::CasavaOrBare => matches_casava_or_bare(head1, head2),
PairingRule::SlashDigit => matches_slash_digit(head1, head2),
PairingRule::SepDigit(sep) => matches_sep_digit(head1, head2, *sep),
}
}
}
impl std::fmt::Display for PairingRule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PairingRule::CasavaOrBare => write!(f, "identical names (Casava comment or bare)"),
PairingRule::SlashDigit => write!(f, "mate suffix '/1'/'/2'"),
PairingRule::SepDigit(sep) => {
let s = *sep as char;
write!(f, "mate suffix '{s}1'/'{s}2'")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SplitNameCheck {
Pending,
Enforced(PairingRule),
Skipped,
}
impl SplitNameCheck {
pub(crate) fn check(&mut self, head1: &[u8], head2: &[u8], record_idx: u64) -> Result<()> {
let names = || {
format!("{:?} / {:?}", String::from_utf8_lossy(head1), String::from_utf8_lossy(head2))
};
match *self {
SplitNameCheck::Enforced(rule) => anyhow::ensure!(
rule.check_pair(head1, head2),
"R1/R2 read names do not correspond at record {record_idx}: {}",
names(),
),
SplitNameCheck::Skipped => {}
SplitNameCheck::Pending => {
if let Some(rule) = PairingRule::select(head1, head2) {
*self = SplitNameCheck::Enforced(rule);
} else if PairingRule::select(head2, head1).is_some() {
anyhow::bail!(
"R1/R2 read names at record {record_idx} are in mate-2/mate-1 order \
({}); were the two --inputs given in the wrong order?",
names(),
);
} else if has_mate_marker(head1) && has_mate_marker(head2) {
anyhow::bail!(
"R1/R2 read names do not correspond at record {record_idx}: {}",
names(),
);
} else {
warn!(
"R1/R2 read names at record {record_idx} ({}) match no known \
mate-naming convention; pairing records by position only, without \
checking read names.",
names(),
);
*self = SplitNameCheck::Skipped;
}
}
}
Ok(())
}
}
fn header_token_and_comment(head: &[u8]) -> (&[u8], Option<&[u8]>) {
match memchr::memchr2(b' ', b'\t', head) {
Some(i) => (&head[..i], Some(&head[i + 1..])),
None => (head, None),
}
}
fn strip_suffix_digit(token: &[u8], sep: u8, digit: u8) -> Option<&[u8]> {
if token.len() >= 2 && token[token.len() - 2] == sep && token[token.len() - 1] == digit {
Some(&token[..token.len() - 2])
} else {
None
}
}
fn matches_slash_digit(head1: &[u8], head2: &[u8]) -> bool {
let (t1, _) = header_token_and_comment(head1);
let (t2, _) = header_token_and_comment(head2);
matches!(
(strip_suffix_digit(t1, b'/', b'1'), strip_suffix_digit(t2, b'/', b'2')),
(Some(s1), Some(s2)) if s1 == s2
)
}
fn matches_sep_digit(head1: &[u8], head2: &[u8], sep: u8) -> bool {
let (t1, _) = header_token_and_comment(head1);
let (t2, _) = header_token_and_comment(head2);
matches!(
(strip_suffix_digit(t1, sep, b'1'), strip_suffix_digit(t2, sep, b'2')),
(Some(s1), Some(s2)) if s1 == s2
)
}
fn matches_casava_or_bare(head1: &[u8], head2: &[u8]) -> bool {
let (t1, c1) = header_token_and_comment(head1);
let (t2, c2) = header_token_and_comment(head2);
if t1 != t2 {
return false;
}
match (comment_mate_number(c1), comment_mate_number(c2)) {
(Some(n1), Some(n2)) => n1 == b'1' && n2 == b'2',
_ => true,
}
}
fn comment_mate_number(comment: Option<&[u8]>) -> Option<u8> {
let comment = comment?;
if let [n @ (b'1' | b'2'), b':', ..] = comment {
return Some(*n);
}
match comment.split(|&b| b == b' ' || b == b'\t').next()? {
[.., b'/', n @ (b'1' | b'2')] => Some(*n),
_ => None,
}
}
fn has_mate_marker(head: &[u8]) -> bool {
let (token, comment) = header_token_and_comment(head);
let suffixed = [b'/', b'.', b'_'].iter().any(|&sep| {
strip_suffix_digit(token, sep, b'1').is_some()
|| strip_suffix_digit(token, sep, b'2').is_some()
});
suffixed || comment_mate_number(comment).is_some()
}
pub(crate) fn pull_pair_interleaved<I>(
iter: &mut I,
rule: PairingRule,
pair_idx: u64,
) -> Result<Option<(OwnedRecord, OwnedRecord)>>
where
I: Iterator<Item = Result<OwnedRecord>>,
{
let r1 = match iter.next() {
None => return Ok(None),
Some(Ok(r)) => r,
Some(Err(e)) => return Err(e),
};
let r2 = match iter.next() {
None => anyhow::bail!(
"interleaved input ended mid-pair (odd record count; file truncated?) at pair \
{pair_idx} (file record {})",
pair_idx * 2 - 1,
),
Some(Ok(r)) => r,
Some(Err(e)) => return Err(e),
};
anyhow::ensure!(
rule.check_pair(&r1.head, &r2.head),
"interleaved input out of sync at pair {pair_idx} (file records {}/{}): {:?} / {:?} are \
not a pair",
pair_idx * 2 - 1,
pair_idx * 2,
String::from_utf8_lossy(&r1.head),
String::from_utf8_lossy(&r2.head),
);
Ok(Some((r1, r2)))
}
pub(crate) struct OwnedRecordIter {
reader: FastqReader<Box<dyn Read + Send>>,
failed: bool,
}
impl OwnedRecordIter {
pub(crate) fn new(reader: FastqReader<Box<dyn Read + Send>>) -> Self {
Self { reader, failed: false }
}
}
impl Iterator for OwnedRecordIter {
type Item = Result<OwnedRecord>;
fn next(&mut self) -> Option<Self::Item> {
if self.failed {
return None;
}
match self.reader.next()? {
Ok(refrec) => Some(Ok(refrec.to_owned_record())),
Err(e) => {
self.failed = true;
Some(Err(anyhow!("FASTQ read error: {e}")))
}
}
}
}
fn open_one_fastq_input(path: &Path) -> Result<Box<dyn Read + Send>> {
let inner: Box<dyn Read + Send> = if path.as_os_str() == "-" {
Box::new(std::io::stdin())
} else {
Box::new(File::open(path).map_err(|e| anyhow!("Failed to open input {path:?}: {e}"))?)
};
decompress_if_gzip(inner).map_err(|e| anyhow!("Failed to read input {path:?}: {e}"))
}
fn decompress_if_gzip(mut inner: Box<dyn Read + Send>) -> std::io::Result<Box<dyn Read + Send>> {
let mut probe = [0u8; 2];
let mut probed = 0usize;
while probed < probe.len() {
match inner.read(&mut probe[probed..]) {
Ok(0) => break, Ok(n) => probed += n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
let chained = Cursor::new(probe[..probed].to_vec()).chain(inner);
if probed == probe.len() && probe == GZIP_MAGIC {
Ok(Box::new(MultiGzDecoder::new(BufReader::with_capacity(BUFFER_SIZE, chained))))
} else {
Ok(Box::new(chained))
}
}
pub(crate) fn open_fastq_inputs(
paths: &[PathBuf],
) -> Result<Vec<FastqReader<Box<dyn Read + Send>>>> {
paths
.iter()
.map(|p| open_one_fastq_input(p).map(|r| FastqReader::with_capacity(r, BUFFER_SIZE)))
.collect()
}
pub(crate) struct Sniffed<I> {
pub(crate) interleaved: bool,
pub(crate) pairing_rule: Option<PairingRule>,
pub(crate) is_empty: bool,
pub(crate) records: I,
}
pub(crate) fn sniff_single_input(
reader: FastqReader<Box<dyn Read + Send>>,
) -> Result<Sniffed<impl Iterator<Item = Result<OwnedRecord>> + Send + 'static>> {
let mut iter = OwnedRecordIter::new(reader);
let mut peeked: Vec<OwnedRecord> = Vec::with_capacity(4);
for _ in 0..4 {
match iter.next() {
Some(Ok(r)) => peeked.push(r),
Some(Err(e)) => return Err(e),
None => break,
}
}
let reversed = |first: usize| {
anyhow!(
"records {}-{} of the single input are a mate pair in mate-2/mate-1 order \
({:?} / {:?}); interleaved input must give mate 1 before mate 2",
first + 1,
first + 2,
String::from_utf8_lossy(&peeked[first].head),
String::from_utf8_lossy(&peeked[first + 1].head),
)
};
let mut interleaved = false;
let mut pairing_rule: Option<PairingRule> = None;
if peeked.len() >= 2 {
let (h1, h2) = (&peeked[0].head, &peeked[1].head);
match PairingRule::select(h1, h2) {
Some(rule) if peeked.len() < 4 || rule.check_pair(&peeked[2].head, &peeked[3].head) => {
interleaved = true;
pairing_rule = Some(rule);
}
Some(rule) if rule.check_pair(&peeked[3].head, &peeked[2].head) => {
return Err(reversed(2));
}
Some(_) => {}
None if PairingRule::select(h2, h1).is_some() => return Err(reversed(0)),
None => {}
}
}
if interleaved {
info!(
"Single input sniffed as interleaved paired-end (rule: {}; records {:?} / {:?} pair)",
pairing_rule.expect("set alongside interleaved"),
String::from_utf8_lossy(&peeked[0].head),
String::from_utf8_lossy(&peeked[1].head),
);
} else {
info!("Single input sniffed as single-end");
}
let is_empty = peeked.is_empty();
let preface: Vec<Result<OwnedRecord>> = peeked.into_iter().map(Ok).collect();
Ok(Sniffed { interleaved, pairing_rule, is_empty, records: preface.into_iter().chain(iter) })
}
pub(crate) fn default_dash(raw: &[PathBuf]) -> Vec<PathBuf> {
if raw.is_empty() { vec![PathBuf::from("-")] } else { raw.to_vec() }
}
pub(crate) fn resolve_inputs(raw: &[PathBuf], stdin_is_tty: bool) -> Result<Vec<PathBuf>> {
let resolved = default_dash(raw);
if stdin_is_tty && resolved.iter().any(|p| p.as_os_str() == "-") {
return Err(anyhow!("stdin is a terminal; pass --inputs or pipe data in"));
}
Ok(resolved)
}
pub(crate) fn check_dash_at_most_once(paths: &[PathBuf], label: &str, errors: &mut Vec<String>) {
if paths.iter().filter(|p| p.as_os_str() == "-").count() > 1 {
errors.push(format!("{label} may specify '-' (stdin/stdout) at most once."));
}
}
pub(crate) fn resolve_real_path(path: &Path) -> Option<PathBuf> {
if let Ok(real) = std::fs::canonicalize(path) {
return Some(real);
}
let name = path.file_name()?;
let parent = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
std::fs::canonicalize(parent).ok().map(|dir| dir.join(name))
}
pub(crate) fn check_distinct_inputs(paths: &[PathBuf], errors: &mut Vec<String>) {
if let [a, b] = paths
&& a.as_os_str() != "-"
&& b.as_os_str() != "-"
&& let (Some(real_a), Some(real_b)) = (resolve_real_path(a), resolve_real_path(b))
&& real_a == real_b
{
errors.push(format!(
"--inputs {a:?} and {b:?} both resolve to {real_a:?}; R1 and R2 must be different \
files."
));
}
}
pub(crate) fn check_at_most_two(paths: &[PathBuf], label: &str, errors: &mut Vec<String>) {
if paths.len() > 2 {
errors.push(format!("{label} accepts at most 2 paths; got {}.", paths.len()));
}
}
pub(crate) fn aggregate_errors(errors: Vec<String>) -> Result<()> {
if errors.is_empty() {
return Ok(());
}
use std::fmt::Write;
let detail = errors.iter().fold(String::new(), |mut s, e| {
let _ = writeln!(s, " - {e}");
s
});
Err(anyhow!("Input validation failed:\n{detail}"))
}
pub(crate) fn fmt_count(n: u64) -> String {
let s = n.to_string();
let mut result = String::with_capacity(s.len() + s.len() / 3);
for (i, ch) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
result.push(',');
}
result.push(ch);
}
result
}
#[cfg(test)]
pub(crate) fn test_fastq_bytes(records: &[(&str, &str)]) -> Vec<u8> {
let mut out = Vec::new();
for (name, seq) in records {
out.extend_from_slice(format!("@{name}\n{seq}\n+\n{}\n", "I".repeat(seq.len())).as_bytes());
}
out
}
#[cfg(test)]
pub(crate) fn test_gzip(data: &[u8]) -> Vec<u8> {
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write as _;
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
enc.write_all(data).unwrap();
enc.finish().unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn small() {
assert_eq!(fmt_count(0), "0");
assert_eq!(fmt_count(999), "999");
}
#[test]
fn with_commas() {
assert_eq!(fmt_count(1_000), "1,000");
assert_eq!(fmt_count(1_234_567), "1,234,567");
assert_eq!(fmt_count(1_000_000_000), "1,000,000,000");
}
#[test]
fn select_casava_style() {
assert_eq!(
PairingRule::select(b"read1 1:N:0:ATCG", b"read1 2:N:0:ATCG"),
Some(PairingRule::CasavaOrBare)
);
}
#[test]
fn select_bare_identical_names() {
assert_eq!(PairingRule::select(b"SRR1.1", b"SRR1.1"), Some(PairingRule::CasavaOrBare));
}
#[test]
fn select_slash_suffix_style() {
assert_eq!(PairingRule::select(b"read1/1", b"read1/2"), Some(PairingRule::SlashDigit));
}
#[test]
fn select_dot_suffix_style() {
assert_eq!(
PairingRule::select(b"SRR000001.1.1", b"SRR000001.1.2"),
Some(PairingRule::SepDigit(b'.'))
);
}
#[test]
fn select_underscore_suffix_style() {
assert_eq!(PairingRule::select(b"read1_1", b"read1_2"), Some(PairingRule::SepDigit(b'_')));
}
#[test]
fn select_none_on_mismatched_stems() {
assert_eq!(PairingRule::select(b"read1/1", b"read2/2"), None);
}
#[test]
fn select_none_on_reversed_slash_pair() {
assert_eq!(PairingRule::select(b"read1/2", b"read1/1"), None);
}
#[test]
fn select_none_on_reversed_casava_comment() {
assert_eq!(PairingRule::select(b"read1 2:N:0:AT", b"read1 1:N:0:AT"), None);
}
#[test]
fn select_ena_comment_mate_markers() {
assert_eq!(
PairingRule::select(b"ERR1.1 HWI:1:1:1/1", b"ERR1.1 HWI:1:1:1/2"),
Some(PairingRule::CasavaOrBare)
);
}
#[test]
fn select_none_on_reversed_ena_comment_mate_markers() {
assert_eq!(PairingRule::select(b"ERR1.1 HWI:1:1:1/2", b"ERR1.1 HWI:1:1:1/1"), None);
}
#[test]
fn select_sra_spot_number_comment_as_bare() {
assert_eq!(
PairingRule::select(b"SRR390728.1 1 length=72", b"SRR390728.1 1 length=72"),
Some(PairingRule::CasavaOrBare)
);
}
#[test]
fn select_ignores_non_mate_trailing_digit() {
assert_eq!(PairingRule::select(b"read1/3", b"read1/4"), None);
}
#[test]
fn check_pair_slash_digit_accepts_forward_order() {
assert!(PairingRule::SlashDigit.check_pair(b"read1/1", b"read1/2"));
}
#[test]
fn check_pair_slash_digit_rejects_reversed_order() {
assert!(!PairingRule::SlashDigit.check_pair(b"read1/2", b"read1/1"));
}
#[test]
fn check_pair_sep_digit_rejects_wrong_separator() {
assert!(!PairingRule::SepDigit(b'.').check_pair(b"read1_1", b"read1_2"));
}
#[test]
fn check_pair_casava_or_bare_rejects_mismatched_stem() {
assert!(!PairingRule::CasavaOrBare.check_pair(b"read1 1:N:0:AT", b"read2 2:N:0:AT"));
}
#[test]
fn check_pair_casava_or_bare_rejects_two_mate_1_casava_comments() {
assert!(!PairingRule::CasavaOrBare.check_pair(b"read1 1:N:0:AT", b"read1 1:N:0:AT"));
}
#[test]
fn check_pair_casava_or_bare_accepts_sra_spot_number_comment() {
assert!(
PairingRule::CasavaOrBare
.check_pair(b"SRR390728.2 2 length=72", b"SRR390728.2 2 length=72")
);
}
#[test]
fn split_name_check_enforces_rule_selected_from_first_pair() {
let mut check = SplitNameCheck::Pending;
check.check(b"read1/1", b"read1/2", 1).unwrap();
assert_eq!(check, SplitNameCheck::Enforced(PairingRule::SlashDigit));
let err = check.check(b"read2/1", b"other/2", 2).unwrap_err().to_string();
assert!(err.contains("do not correspond at record 2"), "{err}");
}
#[test]
fn split_name_check_skips_names_when_first_pair_matches_no_rule() {
let mut check = SplitNameCheck::Pending;
check.check(b"foo_a", b"bar_b", 1).unwrap();
assert_eq!(check, SplitNameCheck::Skipped);
check.check(b"anything", b"else", 2).unwrap();
}
#[test]
fn split_name_check_skips_names_when_only_one_mate_is_marked() {
let mut check = SplitNameCheck::Pending;
check.check(b"read1/1", b"read1", 1).unwrap();
assert_eq!(check, SplitNameCheck::Skipped);
}
#[test]
fn split_name_check_errors_on_swapped_first_pair() {
let mut check = SplitNameCheck::Pending;
let err = check.check(b"read1/2", b"read1/1", 1).unwrap_err().to_string();
assert!(err.contains("wrong order"), "{err}");
}
#[test]
fn split_name_check_errors_on_swapped_casava_first_pair() {
let mut check = SplitNameCheck::Pending;
let err = check.check(b"read1 2:N:0:AT", b"read1 1:N:0:AT", 1).unwrap_err().to_string();
assert!(err.contains("wrong order"), "{err}");
}
#[test]
fn split_name_check_errors_on_marked_first_pair_with_different_stems() {
let mut check = SplitNameCheck::Pending;
let err = check.check(b"read1/1", b"read2/2", 1).unwrap_err().to_string();
assert!(err.contains("do not correspond at record 1"), "{err}");
}
#[test]
fn split_name_check_errors_on_swapped_ena_first_pair() {
let mut check = SplitNameCheck::Pending;
let err =
check.check(b"ERR1.1 HWI:1:1:1/2", b"ERR1.1 HWI:1:1:1/1", 1).unwrap_err().to_string();
assert!(err.contains("wrong order"), "{err}");
}
#[test]
fn split_name_check_errors_on_two_mate_1_casava_first_pair() {
let mut check = SplitNameCheck::Pending;
let err = check.check(b"read1 1:N:0:AT", b"read1 1:N:0:AT", 1).unwrap_err().to_string();
assert!(err.contains("do not correspond at record 1"), "{err}");
}
#[test]
fn resolve_inputs_defaults_empty_to_dash() {
assert_eq!(resolve_inputs(&[], false).unwrap(), vec![PathBuf::from("-")]);
}
#[test]
fn resolve_inputs_leaves_explicit_paths_untouched() {
let raw = vec![PathBuf::from("a.fq"), PathBuf::from("b.fq")];
assert_eq!(resolve_inputs(&raw, false).unwrap(), raw);
}
#[test]
fn resolve_inputs_errors_when_stdin_is_tty_and_defaulted() {
assert!(resolve_inputs(&[], true).is_err());
}
#[test]
fn resolve_inputs_errors_when_stdin_is_tty_and_explicit_dash() {
assert!(resolve_inputs(&[PathBuf::from("-")], true).is_err());
}
#[test]
fn resolve_inputs_ok_when_stdin_is_tty_but_dash_not_used() {
let raw = vec![PathBuf::from("a.fq")];
assert!(resolve_inputs(&raw, true).is_ok());
}
#[test]
fn check_dash_at_most_once_allows_single_dash() {
let mut errors = Vec::new();
check_dash_at_most_once(&[PathBuf::from("-")], "Inputs", &mut errors);
assert!(errors.is_empty());
}
#[test]
fn check_dash_at_most_once_rejects_two_dashes() {
let mut errors = Vec::new();
check_dash_at_most_once(&[PathBuf::from("-"), PathBuf::from("-")], "Inputs", &mut errors);
assert_eq!(errors.len(), 1);
}
#[test]
fn resolve_real_path_collapses_dotdot_for_a_new_file() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("sub")).unwrap();
assert_eq!(
resolve_real_path(&tmp.path().join("sub/../out.fq")),
resolve_real_path(&tmp.path().join("out.fq"))
);
}
#[test]
fn resolve_real_path_follows_symlinked_directory_for_a_new_file() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("real")).unwrap();
std::os::unix::fs::symlink(tmp.path().join("real"), tmp.path().join("link")).unwrap();
assert_eq!(
resolve_real_path(&tmp.path().join("link/out.fq")),
resolve_real_path(&tmp.path().join("real/out.fq"))
);
}
#[test]
fn resolve_real_path_follows_symlinked_existing_file() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("target.fq"), b"").unwrap();
std::os::unix::fs::symlink(tmp.path().join("target.fq"), tmp.path().join("alias.fq"))
.unwrap();
assert_eq!(
resolve_real_path(&tmp.path().join("alias.fq")),
resolve_real_path(&tmp.path().join("target.fq"))
);
}
#[test]
fn resolve_real_path_is_none_when_parent_is_missing() {
let tmp = tempfile::TempDir::new().unwrap();
assert_eq!(resolve_real_path(&tmp.path().join("missing/out.fq")), None);
}
#[test]
fn check_distinct_inputs_rejects_symlink_to_the_other_input() {
let tmp = tempfile::TempDir::new().unwrap();
let r1 = tmp.path().join("r1.fq");
std::fs::write(&r1, b"").unwrap();
let alias = tmp.path().join("r2.fq");
std::os::unix::fs::symlink(&r1, &alias).unwrap();
let mut errors = Vec::new();
check_distinct_inputs(&[r1, alias], &mut errors);
assert_eq!(errors.len(), 1, "{errors:?}");
}
#[test]
fn check_distinct_inputs_allows_different_files() {
let tmp = tempfile::TempDir::new().unwrap();
let (r1, r2) = (tmp.path().join("r1.fq"), tmp.path().join("r2.fq"));
std::fs::write(&r1, b"").unwrap();
std::fs::write(&r2, b"").unwrap();
let mut errors = Vec::new();
check_distinct_inputs(&[r1, r2], &mut errors);
assert!(errors.is_empty(), "{errors:?}");
}
struct FailAfter(std::io::Cursor<Vec<u8>>);
impl Read for FailAfter {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self.0.read(buf)? {
0 => Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated")),
n => Ok(n),
}
}
}
#[test]
fn owned_record_iter_ends_after_first_error() {
let src = FailAfter(std::io::Cursor::new(b"@r1\nACGT\n+\nIIII\n@r2\nAC".to_vec()));
let boxed: Box<dyn Read + Send> = Box::new(src);
let mut iter = OwnedRecordIter::new(FastqReader::new(boxed));
assert!(iter.next().unwrap().is_err());
assert!(iter.next().is_none());
assert!(iter.next().is_none());
}
fn reader_from(bytes: Vec<u8>) -> FastqReader<Box<dyn Read + Send>> {
let boxed: Box<dyn Read + Send> = Box::new(std::io::Cursor::new(bytes));
FastqReader::with_capacity(boxed, BUFFER_SIZE)
}
#[test]
fn sniff_single_input_empty_is_single_end_and_empty() {
let s = sniff_single_input(reader_from(Vec::new())).unwrap();
assert!(!s.interleaved);
assert!(s.is_empty);
assert_eq!(s.pairing_rule, None);
assert!(s.records.count() == 0);
}
#[test]
fn sniff_single_input_one_record_is_single_end() {
let bytes = test_fastq_bytes(&[("read1", "ACGT")]);
let s = sniff_single_input(reader_from(bytes)).unwrap();
assert!(!s.interleaved);
assert!(!s.is_empty);
assert_eq!(s.records.count(), 1);
}
#[test]
fn sniff_single_input_two_unrelated_reads_is_single_end() {
let bytes = test_fastq_bytes(&[("read1", "ACGT"), ("read2", "ACGT")]);
let s = sniff_single_input(reader_from(bytes)).unwrap();
assert!(!s.interleaved);
assert_eq!(s.records.count(), 2);
}
#[test]
fn sniff_single_input_mate_pair_is_interleaved() {
let bytes = test_fastq_bytes(&[("read1 1:N:0:AT", "ACGT"), ("read1 2:N:0:AT", "TGCA")]);
let s = sniff_single_input(reader_from(bytes)).unwrap();
assert!(s.interleaved);
assert_eq!(s.pairing_rule, Some(PairingRule::CasavaOrBare));
assert_eq!(s.records.count(), 2);
}
#[test]
fn sniff_single_input_replays_peeked_records_in_order() {
let bytes =
test_fastq_bytes(&[("read1/1", "ACGT"), ("read1/2", "TGCA"), ("read2/1", "AAAA")]);
let s = sniff_single_input(reader_from(bytes)).unwrap();
assert!(s.interleaved);
let heads: Vec<Vec<u8>> = s.records.map(|r| r.unwrap().head).collect();
assert_eq!(heads, vec![b"read1/1".to_vec(), b"read1/2".to_vec(), b"read2/1".to_vec()]);
}
#[test]
fn sniff_single_input_sra_interleaved_sniffs_as_pe() {
let bytes = test_fastq_bytes(&[
("SRR1.1.1", "ACGT"),
("SRR1.1.2", "TGCA"),
("SRR1.2.1", "AAAA"),
("SRR1.2.2", "TTTT"),
]);
let s = sniff_single_input(reader_from(bytes)).unwrap();
assert!(s.interleaved);
assert_eq!(s.pairing_rule, Some(PairingRule::SepDigit(b'.')));
}
#[test]
fn sniff_single_input_sra_se_sniffs_as_se() {
let bytes = test_fastq_bytes(&[
("SRR1.1", "ACGT"),
("SRR1.2", "TGCA"),
("SRR1.3", "AAAA"),
("SRR1.4", "TTTT"),
]);
let s = sniff_single_input(reader_from(bytes)).unwrap();
assert!(!s.interleaved);
assert_eq!(s.pairing_rule, None);
assert_eq!(s.records.count(), 4);
}
#[test]
fn sniff_single_input_sra_split_spot_default_defline_sniffs_as_pe() {
let bytes = test_fastq_bytes(&[
("SRR1.1 1 length=4", "ACGT"),
("SRR1.1 1 length=4", "TGCA"),
("SRR1.2 2 length=4", "AAAA"),
("SRR1.2 2 length=4", "TTTT"),
]);
let s = sniff_single_input(reader_from(bytes)).unwrap();
assert!(s.interleaved);
assert_eq!(s.pairing_rule, Some(PairingRule::CasavaOrBare));
}
#[test]
fn sniff_single_input_sra_se_default_defline_sniffs_as_se() {
let bytes = test_fastq_bytes(&[
("SRR1.1 1 length=4", "ACGT"),
("SRR1.2 2 length=4", "TGCA"),
("SRR1.3 3 length=4", "AAAA"),
("SRR1.4 4 length=4", "TTTT"),
]);
let s = sniff_single_input(reader_from(bytes)).unwrap();
assert!(!s.interleaved);
}
#[test]
fn sniff_single_input_underscore_suffix_sniffs_as_pe() {
let bytes = test_fastq_bytes(&[
("read_1", "ACGT"),
("read_2", "TGCA"),
("read2_1", "AAAA"),
("read2_2", "TTTT"),
]);
let s = sniff_single_input(reader_from(bytes)).unwrap();
assert!(s.interleaved);
assert_eq!(s.pairing_rule, Some(PairingRule::SepDigit(b'_')));
}
#[test]
fn sniff_single_input_reversed_first_pair_errors() {
let bytes = test_fastq_bytes(&[("read1/2", "ACGT"), ("read1/1", "TGCA")]);
let Err(e) = sniff_single_input(reader_from(bytes)) else { panic!("expected an error") };
assert!(e.to_string().contains("records 1-2"), "{e}");
}
#[test]
fn sniff_single_input_reversed_second_pair_errors() {
let bytes = test_fastq_bytes(&[
("read1/1", "ACGT"),
("read1/2", "TGCA"),
("read2/2", "AAAA"),
("read2/1", "TTTT"),
]);
let Err(e) = sniff_single_input(reader_from(bytes)) else { panic!("expected an error") };
assert!(e.to_string().contains("records 3-4"), "{e}");
}
#[test]
fn sniff_single_input_reversed_ena_comment_pair_errors() {
let bytes =
test_fastq_bytes(&[("ERR1.1 HWI:1:1:1/2", "ACGT"), ("ERR1.1 HWI:1:1:1/1", "TGCA")]);
let Err(e) = sniff_single_input(reader_from(bytes)) else { panic!("expected an error") };
assert!(e.to_string().contains("mate-2/mate-1 order"), "{e}");
}
#[test]
fn sniff_single_input_casava_single_end_sniffs_as_se() {
let bytes = test_fastq_bytes(&[
("read1 1:N:0:AT", "ACGT"),
("read2 1:N:0:AT", "TGCA"),
("read3 1:N:0:AT", "AAAA"),
("read4 1:N:0:AT", "TTTT"),
]);
let s = sniff_single_input(reader_from(bytes)).unwrap();
assert!(!s.interleaved);
}
#[test]
fn sniff_single_input_rule_confirmation_failure_at_3_4_sniffs_as_se() {
let bytes = test_fastq_bytes(&[
("pair0/1", "ACGT"),
("pair0/2", "TGCA"),
("unrelated_a", "AAAA"),
("unrelated_b", "TTTT"),
]);
let s = sniff_single_input(reader_from(bytes)).unwrap();
assert!(!s.interleaved);
}
#[test]
fn sniff_single_input_short_pe_file_with_fewer_than_4_records_is_interleaved() {
let bytes = test_fastq_bytes(&[("pair0/1", "ACGT"), ("pair0/2", "TGCA")]);
let s = sniff_single_input(reader_from(bytes)).unwrap();
assert!(s.interleaved);
}
#[test]
fn opener_detects_gzip_content_by_magic_bytes() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("misnamed.txt"); std::fs::write(&path, test_gzip(b"@r\nACGT\n+\nIIII\n")).unwrap();
let mut reader = open_one_fastq_input(&path).unwrap();
let mut first = [0u8; 1];
reader.read_exact(&mut first).unwrap();
assert_eq!(&first, b"@"); }
#[test]
fn opener_reads_plain_content() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("plain.fq");
std::fs::write(&path, b"@r\nACGT\n+\nIIII\n").unwrap();
let mut reader = open_one_fastq_input(&path).unwrap();
let mut buf = String::new();
std::io::Read::read_to_string(&mut reader, &mut buf).unwrap();
assert_eq!(buf, "@r\nACGT\n+\nIIII\n");
}
#[test]
fn opener_handles_empty_file() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("empty.fq");
std::fs::write(&path, b"").unwrap();
let mut reader = open_one_fastq_input(&path).unwrap();
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut reader, &mut buf).unwrap();
assert!(buf.is_empty());
}
#[test]
fn opener_handles_one_byte_file() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("one_byte.fq");
std::fs::write(&path, b"@").unwrap();
let mut reader = open_one_fastq_input(&path).unwrap();
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut reader, &mut buf).unwrap();
assert_eq!(buf, b"@");
}
struct OneByteAtATime(std::io::Cursor<Vec<u8>>);
impl Read for OneByteAtATime {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
self.0.read(&mut buf[..1])
}
}
#[test]
fn gzip_detected_when_source_yields_one_byte_per_read() {
let fastq = b"@r\nACGT\n+\nIIII\n";
let src = OneByteAtATime(std::io::Cursor::new(test_gzip(fastq)));
let inner: Box<dyn Read + Send> = Box::new(src);
let mut decoded = Vec::new();
decompress_if_gzip(inner).unwrap().read_to_end(&mut decoded).unwrap();
assert_eq!(decoded, fastq);
}
}