rsomics-fastq-downsample 0.1.0

Deterministic random downsampling of FASTQ to a target read count or fraction
Documentation
use std::path::PathBuf;
use std::process::{Command, Stdio};

fn ours() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_rsomics-fastq-downsample"))
}

fn fixture() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden/small.fq")
}

fn seqtk_available() -> bool {
    Command::new("seqtk")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .is_ok()
}

fn count_reads(fq: &str) -> usize {
    fq.lines().filter(|l| l.starts_with('@')).count()
}

#[test]
fn fraction_1_keeps_all() {
    let out = Command::new(ours())
        .arg(fixture())
        .args(["-f", "1.0", "--seed", "42"])
        .output()
        .unwrap();
    assert!(out.status.success());
    let s = String::from_utf8_lossy(&out.stdout);
    assert_eq!(count_reads(&s), 4, "fraction=1.0 should keep all 4 reads");
}

#[test]
fn fraction_0_keeps_none() {
    let out = Command::new(ours())
        .arg(fixture())
        .args(["-f", "0.0", "--seed", "42"])
        .output()
        .unwrap();
    assert!(out.status.success());
    let s = String::from_utf8_lossy(&out.stdout);
    assert_eq!(count_reads(&s), 0, "fraction=0.0 should keep no reads");
}

#[test]
fn deterministic() {
    let run = |seed: &str| -> String {
        let out = Command::new(ours())
            .arg(fixture())
            .args(["-f", "0.5", "--seed", seed])
            .output()
            .unwrap();
        assert!(out.status.success());
        String::from_utf8(out.stdout).unwrap()
    };
    let r1 = run("99");
    let r2 = run("99");
    assert_eq!(r1, r2, "same seed must produce identical output");
}

#[test]
fn output_is_valid_fastq() {
    let out = Command::new(ours())
        .arg(fixture())
        .args(["-f", "1.0", "--seed", "1"])
        .output()
        .unwrap();
    assert!(out.status.success());
    let s = String::from_utf8_lossy(&out.stdout);
    let lines: Vec<&str> = s.lines().collect();
    assert_eq!(lines.len() % 4, 0, "FASTQ must have lines divisible by 4");
    for chunk in lines.chunks(4) {
        assert!(chunk[0].starts_with('@'), "header line must start with @");
        assert_eq!(chunk[2], "+", "separator line must be +");
        assert_eq!(
            chunk[1].len(),
            chunk[3].len(),
            "seq and qual must have equal length"
        );
    }
}

#[test]
fn count_comparable_to_seqtk() {
    if !seqtk_available() {
        eprintln!("skipping: seqtk not found");
        return;
    }
    let ours_out = Command::new(ours())
        .arg(fixture())
        .args(["-f", "0.5", "--seed", "42"])
        .output()
        .unwrap();
    assert!(ours_out.status.success());
    let our_count = count_reads(&String::from_utf8_lossy(&ours_out.stdout));

    let seqtk_out = Command::new("seqtk")
        .args(["sample", "-s", "42", fixture().to_str().unwrap(), "2"])
        .output()
        .unwrap();
    let seqtk_count = count_reads(&String::from_utf8_lossy(&seqtk_out.stdout));

    assert!(our_count <= 4 && our_count > 0);
    assert!(seqtk_count <= 4 && seqtk_count > 0);
}