rsomics-seq-grep 0.1.1

Filter FASTA/FASTQ records by ID/name/sequence — seqkit grep port
Documentation
use std::path::{Path, PathBuf};
use std::process::Command;

fn bin() -> Command {
    Command::new(env!("CARGO_BIN_EXE_rsomics-seq-grep"))
}

fn golden(name: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/golden")
        .join(name)
}

/// Every golden was frozen from a real `seqkit grep` v2.13.0 run (see the
/// crate README's `## Origin` section for exact commands) and is checked in
/// — this test never shells out to a live `seqkit`, so it stays green
/// without seqkit installed on the CI runner.
fn assert_matches_golden(args: &[&str], golden_name: &str) {
    let fa = golden("small.fa");
    let out = bin()
        .args(args)
        .arg(&fa)
        .output()
        .expect("rsomics-seq-grep failed to run");
    assert!(
        out.status.success(),
        "exit {}: {}",
        out.status,
        String::from_utf8_lossy(&out.stderr)
    );
    let expected = std::fs::read(golden(golden_name)).expect("golden file missing");
    assert_eq!(
        out.stdout,
        expected,
        "mismatch for {golden_name}.\nours:\n{}\nexpected:\n{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&expected)
    );
}

#[test]
fn id_exact_match() {
    assert_matches_golden(&["-p", "seq1,seq3"], "id_exact.out");
}

#[test]
fn by_name_full_header() {
    assert_matches_golden(&["-n", "-p", "seq1 description one"], "by_name.out");
}

#[test]
fn regex_anchored() {
    assert_matches_golden(&["-r", "-p", "seq[13]$"], "regex_anchor.out");
}

#[test]
fn ignore_case() {
    assert_matches_golden(&["-i", "-p", "SEQ1"], "ignore_case.out");
}

#[test]
fn invert_match() {
    // No short flag here: `-v` is CommonFlags' `--verbose` everywhere in
    // this campaign, so `--invert-match` stays long-only (see README).
    assert_matches_golden(&["--invert-match", "-p", "seq1"], "invert.out");
}

#[test]
fn by_seq_substring() {
    assert_matches_golden(&["-s", "-p", "TTTT"], "by_seq.out");
}

#[test]
fn by_seq_minus_strand_by_default() {
    assert_matches_golden(&["-s", "-p", "GGGGTTTT"], "by_seq_minus.out");
}

#[test]
fn by_seq_only_positive_strand() {
    assert_matches_golden(&["-s", "-P", "-p", "GGGGTTTT"], "by_seq_positive.out");
}

#[test]
fn degenerate_iupac_motif() {
    assert_matches_golden(&["-s", "-d", "-p", "RYSW"], "degenerate.out");
}

#[test]
fn region_restricts_search() {
    assert_matches_golden(&["-s", "-R", "1:4", "-p", "TTTT"], "region.out");
}

#[test]
fn line_width_10() {
    assert_matches_golden(&["-p", "seq1", "-w", "10"], "linewidth10.out");
}

#[test]
fn line_width_0_no_wrap() {
    assert_matches_golden(&["-p", "seq1", "-w", "0"], "linewidth0.out");
}

#[test]
fn pattern_file() {
    let pats = golden("pats.txt");
    let fa = golden("small.fa");
    let out = bin()
        .args(["-f"])
        .arg(&pats)
        .arg(&fa)
        .output()
        .expect("run failed");
    assert!(out.status.success());
    let expected = std::fs::read(golden("pattern_file.out")).unwrap();
    assert_eq!(out.stdout, expected);
}

#[test]
fn pattern_file_overrides_inline_pattern() {
    // Matches seqkit: when both -p and -f are given, -f wins outright —
    // the -p value is not merged in.
    let pats = golden("pats.txt");
    let fa = golden("small.fa");
    let out = bin()
        .args(["-p", "seq2", "-f"])
        .arg(&pats)
        .arg(&fa)
        .output()
        .expect("run failed");
    assert!(out.status.success());
    let expected = std::fs::read(golden("pattern_file.out")).unwrap();
    assert_eq!(out.stdout, expected);
}

#[test]
fn gz_input_autodetected() {
    let gz = golden("small.fa.gz");
    let out = bin()
        .args(["-p", "seq1"])
        .arg(&gz)
        .output()
        .expect("run failed");
    assert!(out.status.success());
    let expected = std::fs::read(golden("gz_input.out")).unwrap();
    assert_eq!(out.stdout, expected);
}

#[test]
fn stdin_input() {
    use std::io::Write;
    use std::process::Stdio;

    let fa_bytes = std::fs::read(golden("small.fa")).unwrap();
    let mut child = bin()
        .args(["-p", "seq1", "-"])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .expect("spawn failed");
    child
        .stdin
        .take()
        .unwrap()
        .write_all(&fa_bytes)
        .expect("write stdin");
    let out = child.wait_with_output().expect("wait failed");
    assert!(out.status.success());
    let expected = std::fs::read(golden("seq1_only.out")).unwrap();
    assert_eq!(out.stdout, expected);
}

#[test]
fn fastq_default_line_width_is_ignored() {
    let fq = golden("small.fq");
    let out = bin()
        .args(["-n", "-p", "read1 some desc"])
        .arg(&fq)
        .output()
        .expect("run failed");
    assert!(out.status.success());
    let expected = std::fs::read(golden("fastq_default.out")).unwrap();
    assert_eq!(out.stdout, expected);
}

#[test]
fn fastq_ignores_explicit_line_width() {
    let fq = golden("small.fq");
    let out = bin()
        .args(["-n", "-p", "read1 some desc", "-w", "5"])
        .arg(&fq)
        .output()
        .expect("run failed");
    assert!(out.status.success());
    let expected = std::fs::read(golden("fastq_ignore_width.out")).unwrap();
    assert_eq!(out.stdout, expected);
}

#[test]
fn count_only() {
    assert_matches_golden(&["-C", "-p", "seq1,seq3"], "count_only.out");
}

#[test]
fn empty_pattern_matches_empty_id_only() {
    assert_matches_golden(&["-p", ""], "empty_pattern_id.out");
}

#[test]
fn empty_pattern_by_seq_matches_empty_seq_only() {
    assert_matches_golden(&["-s", "-p", ""], "empty_pattern_seq.out");
}

#[test]
fn missing_pattern_source_is_an_error() {
    let fa = golden("small.fa");
    let out = bin().arg(&fa).output().expect("run failed");
    assert!(!out.status.success());
}

#[test]
fn regexp_and_degenerate_conflict_is_an_error() {
    let fa = golden("small.fa");
    let out = bin()
        .args(["-r", "-d", "-p", "ATG"])
        .arg(&fa)
        .output()
        .expect("run failed");
    assert!(!out.status.success());
}

#[test]
fn region_without_pattern_is_an_error() {
    let fa = golden("small.fa");
    let out = bin()
        .args(["-R", "1:4"])
        .arg(&fa)
        .output()
        .expect("run failed");
    assert!(!out.status.success());
}

// A `-s` literal pattern with a byte outside every DNA/RNA/Protein alphabet is
// rejected, matching seqkit's "illegal DNA/RNA/Protein sequence" error.
#[test]
fn illegal_by_seq_pattern_is_an_error() {
    let fa = golden("small.fa");
    let out = bin()
        .args(["-s", "-p", "T1A"])
        .arg(&fa)
        .output()
        .expect("run failed");
    assert!(!out.status.success());
}

// `-d` passes a non-IUPAC byte (here `.`) through to the regex as a literal
// rather than erroring, so the motif still runs and matches like seqkit.
#[test]
fn degenerate_non_iupac_passthrough() {
    assert_matches_golden(&["-d", "-p", "ACG."], "degenerate_passthrough.out");
}

// A degenerate motif with a literal digit runs (matching nothing here) instead
// of erroring — seqkit's Degenerate2Regexp emits the byte verbatim.
#[test]
fn degenerate_literal_digit_runs() {
    let fa = golden("small.fa");
    let out = bin()
        .args(["-d", "-p", "RY9SW"])
        .arg(&fa)
        .output()
        .expect("run failed");
    assert!(out.status.success());
    assert!(out.stdout.is_empty());
}