use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::{Read, Write};
use std::process::{Command, Output, Stdio};
use tempfile::TempDir;
fn chelae_bin() -> &'static str {
env!("CARGO_BIN_EXE_chelae")
}
fn run_chelae_ok(args: &[&str], stdin_bytes: &[u8]) -> Vec<u8> {
let output = spawn_chelae(args, stdin_bytes);
assert!(
output.status.success(),
"chelae {args:?} failed (status {:?}): {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
output.stdout
}
fn spawn_chelae(args: &[&str], stdin_bytes: &[u8]) -> Output {
let mut child = Command::new(chelae_bin())
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn chelae");
let mut child_stdin = child.stdin.take().unwrap();
std::thread::scope(|s| {
s.spawn(move || {
let _ = child_stdin.write_all(stdin_bytes);
});
child.wait_with_output().expect("failed to wait on chelae")
})
}
fn fq_record(name: &str, seq: &str) -> String {
format!("@{name}\n{seq}\n+\n{}\n", "I".repeat(seq.len()))
}
fn se_fastq_text(n: usize, seq: &str) -> String {
(0..n).map(|i| fq_record(&format!("read{i}"), seq)).collect()
}
fn interleaved_fastq_text(n: usize, r1_seq: &str, r2_seq: &str) -> String {
let mut s = String::new();
for i in 0..n {
s += &fq_record(&format!("pair{i}/1"), r1_seq);
s += &fq_record(&format!("pair{i}/2"), r2_seq);
}
s
}
fn gzip(data: &[u8]) -> Vec<u8> {
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
enc.write_all(data).unwrap();
enc.finish().unwrap()
}
#[test]
fn stdin_plain_to_file_out() {
let tmp = TempDir::new().unwrap();
let out = tmp.path().join("out.fq");
let input = se_fastq_text(5, "ACGTACGTACGTACGTACGT");
run_chelae_ok(
&["trim", "-i", "-", "-o", out.to_str().unwrap(), "--output-compression", "none"],
input.as_bytes(),
);
let written = std::fs::read_to_string(&out).unwrap();
assert_eq!(written.matches("@read").count(), 5);
}
#[test]
fn stdin_gzip_to_file_out() {
let tmp = TempDir::new().unwrap();
let out = tmp.path().join("out.fq");
let input = gzip(se_fastq_text(5, "ACGTACGTACGTACGTACGT").as_bytes());
run_chelae_ok(
&["trim", "-i", "-", "-o", out.to_str().unwrap(), "--output-compression", "none"],
&input,
);
let written = std::fs::read_to_string(&out).unwrap();
assert_eq!(written.matches("@read").count(), 5);
}
#[test]
fn no_input_flag_defaults_to_stdin() {
let tmp = TempDir::new().unwrap();
let out = tmp.path().join("out.fq");
let input = se_fastq_text(3, "ACGTACGTACGTACGTACGT");
run_chelae_ok(
&["trim", "-o", out.to_str().unwrap(), "--output-compression", "none"],
input.as_bytes(),
);
let written = std::fs::read_to_string(&out).unwrap();
assert_eq!(written.matches("@read").count(), 3);
}
#[test]
fn file_in_to_stdout_is_plain_fastq() {
let tmp = TempDir::new().unwrap();
let in_path = tmp.path().join("in.fq");
std::fs::write(&in_path, se_fastq_text(4, "ACGTACGTACGTACGTACGT")).unwrap();
let stdout = run_chelae_ok(&["trim", "-i", in_path.to_str().unwrap()], &[]);
assert!(!stdout.starts_with(&[0x1f, 0x8b]), "expected plain text on stdout by default");
let text = String::from_utf8(stdout).unwrap();
assert_eq!(text.matches("@read").count(), 4);
}
#[test]
fn file_in_to_stdout_is_bgzf_with_override() {
let tmp = TempDir::new().unwrap();
let in_path = tmp.path().join("in.fq");
std::fs::write(&in_path, se_fastq_text(4, "ACGTACGTACGTACGTACGT")).unwrap();
let stdout = run_chelae_ok(
&["trim", "-i", in_path.to_str().unwrap(), "-o", "-", "--output-compression", "bgzf"],
&[],
);
assert!(stdout.starts_with(&[0x1f, 0x8b]), "expected gzip/BGZF magic bytes on stdout");
}
#[test]
fn interleaved_gz_stdin_to_interleaved_plain_stdout() {
let input =
gzip(interleaved_fastq_text(3, "AAAACCCCTTAAAACCCCTT", "GGGGTTTTAAGGGGTTTTAA").as_bytes());
let stdout =
run_chelae_ok(&["trim", "-i", "-", "-o", "-", "--output-compression", "none"], &input);
assert!(!stdout.starts_with(&[0x1f, 0x8b]), "expected plain text on stdout");
let text = String::from_utf8(stdout).unwrap();
let heads: Vec<&str> = text.lines().filter(|l| l.starts_with('@')).collect();
assert_eq!(heads, vec!["@pair0/1", "@pair0/2", "@pair1/1", "@pair1/2", "@pair2/1", "@pair2/2"]);
}
#[test]
fn detect_output_fasta_dash_writes_stdout() {
let template = "ACGTGACCTGATTGCAACGATCGTAGCTAGCATCGATCGATTAGCGATCGA";
let adapter_tail = "AGATCGGAAGAGCACACGTCTGA";
let seq = format!("{template}{adapter_tail}");
let input = se_fastq_text(200, &seq);
let stdout = run_chelae_ok(&["detect", "-i", "-", "-o", "-"], input.as_bytes());
let text = String::from_utf8(stdout).unwrap();
assert!(text.trim_start().starts_with('>'), "expected FASTA on stdout, got:\n{text}");
assert!(text.contains("truseq"), "expected the truseq kit name in the FASTA, got:\n{text}");
}
#[test]
fn detect_output_fasta_to_closed_stdout_exits_successfully() {
let template = "ACGTGACCTGATTGCAACGATCGTAGCTAGCATCGATCGATTAGCGATCGA";
let adapter_tail = "AGATCGGAAGAGCACACGTCTGA";
let input = se_fastq_text(200, &format!("{template}{adapter_tail}"));
let mut child = Command::new(chelae_bin())
.args(["detect", "-i", "-", "-o", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn chelae");
drop(child.stdout.take());
child.stdin.take().unwrap().write_all(input.as_bytes()).unwrap();
let output = child.wait_with_output().expect("failed to wait on chelae");
assert!(
output.status.success(),
"expected exit 0 when stdout is closed early, got {:?}: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn detect_stdin_input() {
let tmp = TempDir::new().unwrap();
let out = tmp.path().join("adapters.fa");
let template = "ACGTGACCTGATTGCAACGATCGTAGCTAGCATCGATCGATTAGCGATCGA";
let adapter_tail = "AGATCGGAAGAGCACACGTCTGA";
let seq = format!("{template}{adapter_tail}");
let input = se_fastq_text(200, &seq);
run_chelae_ok(&["detect", "-i", "-", "-o", out.to_str().unwrap()], input.as_bytes());
let fasta = std::fs::read_to_string(&out).unwrap();
assert!(fasta.contains("truseq"), "expected the truseq kit name in the FASTA, got:\n{fasta}");
}
fn run_until_stdout_closed(
args: &[&str],
stdin_bytes: Vec<u8>,
) -> (std::process::ExitStatus, String) {
let mut child = Command::new(chelae_bin())
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn chelae");
let mut child_stdin = child.stdin.take().unwrap();
let writer = std::thread::spawn(move || {
let _ = child_stdin.write_all(&stdin_bytes);
});
let mut child_stdout = child.stdout.take().unwrap();
let mut buf = [0u8; 4096];
std::io::Read::read(&mut child_stdout, &mut buf).expect("failed to read initial stdout bytes");
drop(child_stdout);
let start = std::time::Instant::now();
let status = loop {
if let Some(status) = child.try_wait().expect("failed to poll chelae") {
break status;
}
assert!(
start.elapsed() < std::time::Duration::from_secs(10),
"chelae did not exit promptly after its stdout was closed"
);
std::thread::sleep(std::time::Duration::from_millis(20));
};
writer.join().unwrap();
let mut stderr = String::new();
child.stderr.take().unwrap().read_to_string(&mut stderr).unwrap();
(status, stderr)
}
#[test]
fn stdout_closed_early_exits_promptly() {
let input = se_fastq_text(250_000, "ACGTACGTACGTACGTACGT");
let (status, stderr) = run_until_stdout_closed(
&["trim", "-i", "-", "-o", "-", "--output-compression", "none"],
input.into_bytes(),
);
assert!(status.success(), "expected exit 0 after stdout closed early, got {status:?}");
assert!(!stderr.contains("--metrics/--json"), "no report caveat expected:\n{stderr}");
}
#[test]
fn stdout_closed_early_warns_that_reports_may_overcount() {
let tmp = TempDir::new().unwrap();
let metrics = tmp.path().join("metrics.tsv");
let input = se_fastq_text(250_000, "ACGTACGTACGTACGTACGT");
let (status, stderr) = run_until_stdout_closed(
&["trim", "-i", "-", "-o", "-", "--metrics", metrics.to_str().unwrap()],
input.into_bytes(),
);
assert!(status.success(), "expected exit 0 after stdout closed early, got {status:?}");
assert!(stderr.contains("--metrics/--json may include reads"), "{stderr}");
assert!(metrics.exists());
}
#[test]
fn stdout_closed_early_leaves_split_file_output_valid() {
let tmp = TempDir::new().unwrap();
let out2 = tmp.path().join("out2.fq.gz");
let input = interleaved_fastq_text(250_000, "ACGTACGTACGTACGTACGT", "TGCATGCATGCATGCATGCA");
let (status, _) = run_until_stdout_closed(
&["trim", "-i", "-", "-o", "-", "-o", out2.to_str().unwrap()],
input.into_bytes(),
);
assert!(status.success(), "expected exit 0, got {status:?}");
let bytes = std::fs::read(&out2).unwrap();
assert!(!bytes.is_empty(), "expected non-empty partial output on the file sink");
let mut decoded = Vec::new();
flate2::bufread::MultiGzDecoder::new(bytes.as_slice())
.read_to_end(&mut decoded)
.expect("out2.fq.gz should be a valid, non-truncated BGZF stream");
assert!(decoded.starts_with(b"@"), "expected valid FASTQ content, got:\n{decoded:?}");
}