use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
const EXE: &str = env!("CARGO_BIN_EXE_fastx");
const FASTQ: &[u8] = b"@read1 sample\nACGTACGTAC\n+\n@@++IIIIII\n\
@read2 sample\nNNNNACGTAC\n+\n!!!!IIIIII\n\
@read3\nACGT\n+\nIIII\n";
const FASTA: &[u8] = b">contig1 first\nACGTACGTAC\nGGGG\n\
>contig2\nATGGCCATTGTAATGGGCCGCTGAAAGGGTGCCCGATAG\n";
struct Scratch(PathBuf);
impl Scratch {
fn new(tag: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!("fastx-cli-{}-{tag}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
fn file(&self, name: &str, contents: &[u8]) -> PathBuf {
let path = self.0.join(name);
fs::write(&path, contents).unwrap();
path
}
fn path(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn run(args: &[&str]) -> Output {
Command::new(EXE).args(args).output().expect("run fastx")
}
fn run_stdin(args: &[&str], input: &[u8]) -> Output {
use std::io::Write;
let mut child = Command::new(EXE)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn fastx");
child.stdin.as_mut().unwrap().write_all(input).unwrap();
child.wait_with_output().expect("wait for fastx")
}
fn stdout(output: &Output) -> String {
assert!(
output.status.success(),
"command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n")
}
#[test]
fn help_and_version() {
assert!(stdout(&run(&["help"])).contains("fast FASTA/FASTQ toolkit"));
assert!(stdout(&run(&[])).contains("USAGE"));
assert!(stdout(&run(&["--version"])).starts_with("fastx 0."));
}
#[test]
fn unknown_command_fails() {
let output = run(&["frobnicate"]);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("unknown command"));
}
#[test]
fn stats_reports_counts() {
let scratch = Scratch::new("stats");
let path = scratch.file("reads.fq", FASTQ);
let text = stdout(&run(&["stats", path.to_str().unwrap()]));
assert!(text.contains("records 3"), "{text}");
assert!(text.contains("total bases 24"), "{text}");
assert!(text.contains("Q30%"), "{text}");
}
#[test]
fn convert_fastq_to_fasta() {
let scratch = Scratch::new("convert");
let input = scratch.file("reads.fq", FASTQ);
let output = scratch.path("reads.fa");
let result = run(&[
"convert",
input.to_str().unwrap(),
"-o",
output.to_str().unwrap(),
"-w",
"8",
]);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let text = fs::read_to_string(&output).unwrap();
assert_eq!(
text.replace("\r\n", "\n"),
">read1 sample\nACGTACGT\nAC\n>read2 sample\nNNNNACGT\nAC\n>read3\nACGT\n"
);
}
#[cfg(feature = "gzip")]
#[test]
fn convert_writes_gzip() {
let scratch = Scratch::new("gzip");
let input = scratch.file("reads.fq", FASTQ);
let output = scratch.path("reads.fq.gz");
assert!(run(&[
"convert",
input.to_str().unwrap(),
"-o",
output.to_str().unwrap()
])
.status
.success());
assert_eq!(&fs::read(&output).unwrap()[..2], &[0x1f, 0x8b]);
let text = stdout(&run(&["stats", output.to_str().unwrap()]));
assert!(text.contains("records 3"), "{text}");
}
#[cfg(feature = "gzip")]
#[test]
fn gzip_level_is_configurable() {
let scratch = Scratch::new("level");
let input = scratch.file("reads.fq", FASTQ);
let mut sizes = Vec::new();
for level in ["0", "1", "9"] {
let output = scratch.path(&format!("L{level}.fq.gz"));
let result = run(&[
"convert",
input.to_str().unwrap(),
"-o",
output.to_str().unwrap(),
"-l",
level,
]);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let bytes = fs::read(&output).unwrap();
assert_eq!(&bytes[..2], &[0x1f, 0x8b], "level {level} is not gzip");
sizes.push(bytes.len());
}
assert!(sizes[0] > sizes[2], "{sizes:?}");
let bad = run(&["convert", input.to_str().unwrap(), "-l", "42"]);
assert!(!bad.status.success());
assert!(String::from_utf8_lossy(&bad.stderr).contains("between 0 and 9"));
}
#[cfg(feature = "gzip")]
#[test]
fn compressed_output_is_bgzf_and_indexable() {
let scratch = Scratch::new("bgzf");
let source = scratch.file("source.fa", FASTA);
let plain = scratch.path("ref.fa");
let compressed = scratch.path("ref.fa.gz");
for target in [&plain, &compressed] {
let result = run(&[
"convert",
source.to_str().unwrap(),
"-o",
target.to_str().unwrap(),
"-w",
"12",
]);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
}
let bytes = fs::read(&compressed).unwrap();
assert!(fastx::bgzf::is_bgzf(&bytes), "output is not BGZF");
assert!(bytes.ends_with(&fastx::bgzf::EOF_BLOCK), "no EOF marker");
for target in [&plain, &compressed] {
let result = run(&["faidx", target.to_str().unwrap()]);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
}
assert!(scratch.path("ref.fa.gz.fai").exists());
assert!(scratch.path("ref.fa.gz.gzi").exists());
assert!(scratch.path("ref.fa.fai").exists());
assert!(!scratch.path("ref.fa.gzi").exists());
assert_eq!(
fs::read(scratch.path("ref.fa.gz.fai")).unwrap(),
fs::read(scratch.path("ref.fa.fai")).unwrap()
);
for region in ["contig1:1-12", "contig1:9-14", "contig2", "contig2:13-25"] {
assert_eq!(
stdout(&run(&["faidx", compressed.to_str().unwrap(), region])),
stdout(&run(&["faidx", plain.to_str().unwrap(), region])),
"{region}"
);
}
}
#[test]
fn head_limits_records() {
let scratch = Scratch::new("head");
let path = scratch.file("reads.fq", FASTQ);
let text = stdout(&run(&["head", "-n", "2", path.to_str().unwrap()]));
assert_eq!(text.matches('@').count(), 4); assert!(text.contains("@read1"));
assert!(text.contains("@read2"));
assert!(!text.contains("@read3"));
}
#[test]
fn filter_by_length_and_ambiguity() {
let scratch = Scratch::new("filter");
let path = scratch.file("reads.fq", FASTQ);
let text = stdout(&run(&["filter", "--min-len", "5", path.to_str().unwrap()]));
assert!(text.contains("read1") && text.contains("read2") && !text.contains("read3"));
let text = stdout(&run(&["filter", "--max-n", "0", path.to_str().unwrap()]));
assert!(text.contains("read1") && !text.contains("read2"));
let text = stdout(&run(&[
"filter",
"--max-n",
"0",
"-v",
path.to_str().unwrap(),
]));
assert!(!text.contains("read1") && text.contains("read2"));
}
#[test]
fn reverse_complement_through_a_pipe() {
let text = stdout(&run_stdin(&["rc", "-t", "fastq"], FASTQ));
assert!(text.contains("GTACGTACGT"), "{text}");
assert!(text.contains("IIIIII++@@"), "{text}");
}
#[test]
fn translate_uses_the_standard_code() {
let scratch = Scratch::new("translate");
let path = scratch.file("contigs.fa", FASTA);
let text = stdout(&run(&[
"translate",
"--stop-at-stop",
"-w",
"0",
path.to_str().unwrap(),
]));
assert!(text.contains("\nMAIVMGR\n"), "{text}");
}
#[test]
fn faidx_builds_and_queries() {
let scratch = Scratch::new("faidx");
let path = scratch.file("ref.fa", FASTA);
assert!(run(&["faidx", path.to_str().unwrap()]).status.success());
let fai = fs::read_to_string(scratch.path("ref.fa.fai")).unwrap();
assert!(fai.starts_with("contig1\t14\t"), "{fai}");
let text = stdout(&run(&[
"faidx",
path.to_str().unwrap(),
"contig1:1-12",
"contig2",
]));
assert!(text.contains(">contig1:1-12\nACGTACGTACGG\n"), "{text}");
assert!(text.contains(">contig2\n"), "{text}");
}
#[test]
fn faidx_rejects_bad_regions() {
let scratch = Scratch::new("faidx-bad");
let path = scratch.file("ref.fa", FASTA);
let output = run(&["faidx", path.to_str().unwrap(), "contig1:1-9999"]);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("out of bounds"));
}
const R1: &[u8] = b"@read1/1\nACGTACGTAC\n+\n@@++IIIIII\n\
@read2/1\nTTTTTTTTTT\n+\nIIIIIIIIII\n\
@read3/1\nAAAA\n+\nIIII\n";
const R2: &[u8] = b"@read1/2\nCCCCCCCCCC\n+\nIIIIIIIIII\n\
@read2/2\nGGGGGGGGGG\n+\nIIIIIIIIII\n\
@read3/2\nTTTT\n+\nIIII\n";
#[test]
fn stats_json_is_one_object_per_line() {
let scratch = Scratch::new("json");
let path = scratch.file("reads.fq", FASTQ);
let text = stdout(&run(&["stats", "--json", path.to_str().unwrap()]));
let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 1, "one input should give one line: {text}");
let line = lines[0];
assert!(line.starts_with('{') && line.ends_with('}'), "{line}");
assert!(line.contains("\"records\":3"), "{line}");
assert!(line.contains("\"quality_bases\":24"), "{line}");
let second = scratch.file("more.fq", FASTQ);
let text = stdout(&run(&[
"stats",
"--json",
path.to_str().unwrap(),
second.to_str().unwrap(),
]));
let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 3, "{text}");
assert!(lines[2].contains("\"file\":\"total\""), "{}", lines[2]);
assert!(lines[2].contains("\"records\":6"), "{}", lines[2]);
}
#[test]
fn interleave_and_deinterleave_round_trip() {
let scratch = Scratch::new("pairs");
let first = scratch.file("R1.fq", R1);
let second = scratch.file("R2.fq", R2);
let both = scratch.path("both.fq");
let result = run(&[
"interleave",
first.to_str().unwrap(),
second.to_str().unwrap(),
"-o",
both.to_str().unwrap(),
]);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let merged = fs::read_to_string(&both).unwrap().replace("\r\n", "\n");
assert!(merged.starts_with("@read1/1\n"), "{merged}");
assert_eq!(merged.matches("@read").count(), 6);
let ids: Vec<&str> = merged
.lines()
.filter(|l| l.starts_with("@read"))
.map(|l| &l[1..])
.collect();
assert_eq!(
ids,
["read1/1", "read1/2", "read2/1", "read2/2", "read3/1", "read3/2"]
);
let back1 = scratch.path("back1.fq");
let back2 = scratch.path("back2.fq");
let result = run(&[
"deinterleave",
both.to_str().unwrap(),
"--out1",
back1.to_str().unwrap(),
"--out2",
back2.to_str().unwrap(),
]);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(fs::read(&back1).unwrap(), R1);
assert_eq!(fs::read(&back2).unwrap(), R2);
}
#[test]
fn mispaired_files_are_rejected() {
let scratch = Scratch::new("mispair");
let first = scratch.file("R1.fq", R1);
let shuffled = scratch.file(
"R2.fq",
b"@read3/2\nTTTT\n+\nIIII\n\
@read2/2\nGGGGGGGGGG\n+\nIIIIIIIIII\n\
@read1/2\nCCCCCCCCCC\n+\nIIIIIIIIII\n",
);
let output = run(&[
"interleave",
first.to_str().unwrap(),
shuffled.to_str().unwrap(),
]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("mispaired"), "{stderr}");
assert!(run(&[
"interleave",
first.to_str().unwrap(),
shuffled.to_str().unwrap(),
"--no-check-names",
])
.status
.success());
}
#[test]
fn sample_is_reproducible() {
let scratch = Scratch::new("sample");
let path = scratch.file("reads.fq", R1);
let once = stdout(&run(&[
"sample",
"-n",
"2",
"--seed",
"42",
path.to_str().unwrap(),
]));
let twice = stdout(&run(&[
"sample",
"-n",
"2",
"--seed",
"42",
path.to_str().unwrap(),
]));
assert_eq!(once, twice, "same seed must give the same subset");
assert_eq!(once.matches("@read").count(), 2);
assert_eq!(
stdout(&run(&[
"sample",
"--fraction",
"1.0",
path.to_str().unwrap()
]))
.matches("@read")
.count(),
3
);
assert!(stdout(&run(&[
"sample",
"--fraction",
"0.0",
path.to_str().unwrap()
]))
.is_empty());
assert!(!run(&["sample", path.to_str().unwrap()]).status.success());
assert!(!run(&[
"sample",
"-n",
"1",
"--fraction",
"0.5",
path.to_str().unwrap()
])
.status
.success());
}
#[test]
fn dedup_drops_repeats() {
let scratch = Scratch::new("dedup");
let mut doubled = R1.to_vec();
doubled.extend_from_slice(R1);
let path = scratch.file("reads.fq", &doubled);
let text = stdout(&run(&["dedup", path.to_str().unwrap()]));
assert_eq!(text.matches("@read").count(), 3, "{text}");
let same_seq = scratch.file(
"same.fq",
b"@a\nACGT\n+\nIIII\n@b\nACGT\n+\nJJJJ\n@c\nTTTT\n+\nIIII\n",
);
let text = stdout(&run(&["dedup", "--by-seq", same_seq.to_str().unwrap()]));
assert_eq!(text.matches('@').count(), 2, "{text}");
}
#[test]
fn malformed_input_exits_nonzero() {
let scratch = Scratch::new("bad");
let path = scratch.file("bad.fa", b"this is not a sequence file\n");
let output = run(&["stats", path.to_str().unwrap()]);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("parse error"));
}