gtfsort 0.2.6

An optimized chr/pos/feature GTF/GFF sorter using a lexicographic-based index ordering algorithm written in Rust.
Documentation
use flate2::{read::GzDecoder, write::GzEncoder, Compression};
use std::{
    ffi::OsString,
    fs::File,
    io::{Read, Write},
    path::PathBuf,
    process::{Command, Output, Stdio},
    time::{SystemTime, UNIX_EPOCH},
};

const GTF_INPUT: &str = "\
chr2\tsrc\tgene\t20\t30\t.\t+\t.\tgene_id \"G2\";\n\
chr1\tsrc\tgene\t10\t20\t.\t+\t.\tgene_id \"G1\";\n";

const GTF_SORTED: &str = "\
chr1\tsrc\tgene\t10\t20\t.\t+\t.\tgene_id \"G1\";\n\
chr2\tsrc\tgene\t20\t30\t.\t+\t.\tgene_id \"G2\";\n";

fn run_gtfsort(args: &[OsString], input: &str) -> Output {
    let mut child = Command::new(env!("CARGO_BIN_EXE_gtfsort"))
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();

    child
        .stdin
        .take()
        .unwrap()
        .write_all(input.as_bytes())
        .unwrap();

    child.wait_with_output().unwrap()
}

fn temp_path(suffix: &str) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    std::env::temp_dir().join(format!("gtfsort_cli_{nanos}.{suffix}"))
}

#[test]
fn omitted_paths_pipe_gtf_without_polluting_stdout() {
    let output = run_gtfsort(&[], GTF_INPUT);

    assert!(output.status.success());
    assert_eq!(String::from_utf8(output.stdout).unwrap(), GTF_SORTED);
    assert!(String::from_utf8(output.stderr)
        .unwrap()
        .contains("GTF file sorted successfully"));
}

#[test]
fn dash_paths_pipe_inferred_gff3() {
    let input = "\
##gff-version 3\n\
chr2\tsrc\tgene\t20\t30\t.\t+\t.\tgene_id=G2;\n\
chr1\tsrc\tgene\t10\t20\t.\t+\t.\tgene_id=G1;\n";
    let expected = "\
##gff-version 3\n\
chr1\tsrc\tgene\t10\t20\t.\t+\t.\tgene_id=G1;\n\
chr2\tsrc\tgene\t20\t30\t.\t+\t.\tgene_id=G2;\n";
    let output = run_gtfsort(&["-i".into(), "-".into(), "-o".into(), "-".into()], input);

    assert!(output.status.success());
    assert_eq!(String::from_utf8(output.stdout).unwrap(), expected);
}

#[test]
fn streamed_side_retains_named_gzip_support() {
    let input_path = temp_path("gtf.gz");
    let output_path = temp_path("gtf.gz");

    let mut encoder = GzEncoder::new(File::create(&input_path).unwrap(), Compression::default());
    encoder.write_all(GTF_INPUT.as_bytes()).unwrap();
    encoder.finish().unwrap();

    let output = run_gtfsort(&["-i".into(), input_path.clone().into()], "");
    assert!(output.status.success());
    assert_eq!(String::from_utf8(output.stdout).unwrap(), GTF_SORTED);

    let output = run_gtfsort(&["-o".into(), output_path.clone().into()], GTF_INPUT);
    assert!(output.status.success());
    assert!(output.stdout.is_empty());

    let mut sorted = String::new();
    GzDecoder::new(File::open(&output_path).unwrap())
        .read_to_string(&mut sorted)
        .unwrap();
    assert_eq!(sorted, GTF_SORTED);

    std::fs::remove_file(input_path).unwrap();
    std::fs::remove_file(output_path).unwrap();
}

#[test]
fn empty_stdin_is_rejected() {
    let output = run_gtfsort(&[], "");

    assert!(!output.status.success());
    assert!(output.stdout.is_empty());
    assert!(String::from_utf8(output.stderr)
        .unwrap()
        .contains("stdin is empty"));
}

#[test]
fn stream_failures_do_not_leak_diagnostics_to_stdout() {
    let malformed = "chr1\tsrc\tgene\t10\t20\t.\t+\t.\ttranscript_id \"T1\";\n";
    let output = run_gtfsort(&[], malformed);

    assert!(!output.status.success());
    assert!(output.stdout.is_empty());
    assert!(String::from_utf8(output.stderr)
        .unwrap()
        .contains("Missing gene_id"));

    let output_path = temp_path("gtf");
    std::fs::create_dir(&output_path).unwrap();
    let output = run_gtfsort(&["-o".into(), output_path.clone().into()], GTF_INPUT);

    assert!(!output.status.success());
    assert!(output.stdout.is_empty());
    assert!(String::from_utf8(output.stderr)
        .unwrap()
        .contains("creating output file"));

    std::fs::remove_dir(output_path).unwrap();
}