mod helpers;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use helpers::*;
fn run_with_stats(input: &Path, prefix: &Path, output: &Path, extra: &[&str]) {
let mut cmd = dupblaster();
cmd.args(["-i"])
.arg(input)
.args(["-o"])
.arg(output)
.args(["--metrics-prefix"])
.arg(prefix)
.args(extra);
let out = cmd.output().expect("rust dupblaster ran");
assert!(
out.status.success(),
"rust dupblaster failed: {}",
String::from_utf8_lossy(&out.stderr),
);
}
fn summary(prefix: &Path) -> PathBuf {
let mut name = prefix.as_os_str().to_owned();
name.push(".duplicate-metrics.tsv");
PathBuf::from(name)
}
fn parse_stats_tsv(path: &Path) -> HashMap<String, String> {
let text = std::fs::read_to_string(path).expect("read stats");
let mut lines = text.lines();
let header = lines.next().expect("header line");
let values = lines.next().expect("value line");
let cols: Vec<&str> = header.split('\t').collect();
let vals: Vec<&str> = values.split('\t').collect();
assert_eq!(cols.len(), vals.len(), "column count mismatch in stats TSV");
cols.into_iter().map(String::from).zip(vals.into_iter().map(String::from)).collect()
}
#[test]
fn stats_tsv_reports_correct_counts_for_simple_dup_input() {
let env = TestEnv::new();
let stats = env._tmp.path().join("metrics");
let out = env._tmp.path().join("out.bam");
SamBuilder::new()
.sq("chr1", 1_000_000)
.rec_simple("r1", 99, "chr1", 100, "50M", "=", 200, 150)
.rec_simple("r1", 147, "chr1", 200, "50M", "=", 100, -150)
.rec_simple("r2", 99, "chr1", 100, "50M", "=", 200, 150)
.rec_simple("r2", 147, "chr1", 200, "50M", "=", 100, -150)
.rec_simple("r3", 99, "chr1", 500, "50M", "=", 600, 150)
.rec_simple("r3", 147, "chr1", 600, "50M", "=", 500, -150)
.write_to(&env.input);
run_with_stats(&env.input, &stats, &out, &[]);
let m = parse_stats_tsv(&summary(&stats));
assert_eq!(m["total_templates"], "3");
assert_eq!(m["mapped_pairs"], "3");
assert_eq!(m["duplicate_pairs"], "1");
assert_eq!(m["duplicate_templates"], "1");
let frac: f64 = m["frac_duplicates"].parse().unwrap();
assert!((frac - 1.0 / 3.0).abs() < 1e-6, "frac_duplicates was {frac}");
assert!(!m["estimated_library_size"].is_empty(), "library size should be set");
assert_eq!(m["dupblaster_version"], env!("CARGO_PKG_VERSION"));
assert_eq!(m["sample"], "");
}
#[test]
fn stats_tsv_uses_sample_override() {
let env = TestEnv::new();
let stats = env._tmp.path().join("metrics");
let out = env._tmp.path().join("out.bam");
SamBuilder::new()
.sq("chr1", 1_000_000)
.rec_simple("r1", 99, "chr1", 100, "50M", "=", 200, 150)
.rec_simple("r1", 147, "chr1", 200, "50M", "=", 100, -150)
.write_to(&env.input);
run_with_stats(&env.input, &stats, &out, &["--sample", "NA12878"]);
let m = parse_stats_tsv(&summary(&stats));
assert_eq!(m["sample"], "NA12878");
}
#[test]
fn stats_tsv_pulls_sample_from_read_group_sm() {
let env = TestEnv::new();
let stats = env._tmp.path().join("metrics");
let out = env._tmp.path().join("out.bam");
SamBuilder::new()
.sq("chr1", 1_000_000)
.rg("rg1", "SAMPLE_A", Some("libA"))
.rec_simple("r1", 99, "chr1", 100, "50M", "=", 200, 150)
.rec_simple("r1", 147, "chr1", 200, "50M", "=", 100, -150)
.write_to(&env.input);
run_with_stats(&env.input, &stats, &out, &[]);
let m = parse_stats_tsv(&summary(&stats));
assert_eq!(m["sample"], "SAMPLE_A");
}
#[test]
fn stats_tsv_comma_joins_multiple_sm_values() {
let env = TestEnv::new();
let stats = env._tmp.path().join("metrics");
let out = env._tmp.path().join("out.bam");
SamBuilder::new()
.sq("chr1", 1_000_000)
.rg("rg1", "SAMPLE_A", None)
.rg("rg2", "SAMPLE_B", None)
.rec_simple("r1", 99, "chr1", 100, "50M", "=", 200, 150)
.rec_simple("r1", 147, "chr1", 200, "50M", "=", 100, -150)
.write_to(&env.input);
run_with_stats(&env.input, &stats, &out, &[]);
let m = parse_stats_tsv(&summary(&stats));
assert_eq!(m["sample"], "SAMPLE_A,SAMPLE_B");
}
#[test]
fn paired_unmapped_singleton_counts_as_unmapped_orphan_not_unmated() {
let env = TestEnv::new();
let stats = env._tmp.path().join("metrics");
let out = env._tmp.path().join("out.bam");
SamBuilder::new()
.sq("chr1", 1_000_000)
.rec_simple("r1", 99, "chr1", 100, "50M", "=", 200, 150)
.rec_simple("r1", 147, "chr1", 200, "50M", "=", 100, -150)
.rec_simple("r2", 69, "*", 0, "*", "*", 0, 0)
.write_to(&env.input);
run_with_stats(&env.input, &stats, &out, &["--ignore-unmated"]);
let m = parse_stats_tsv(&summary(&stats));
assert_eq!(m["unmapped_orphans"], "1", "row: {m:?}");
assert_eq!(m["unmated_templates"], "0", "row: {m:?}");
}
#[test]
fn stats_tsv_has_all_expected_columns() {
let env = TestEnv::new();
let stats = env._tmp.path().join("metrics");
let out = env._tmp.path().join("out.bam");
SamBuilder::new()
.sq("chr1", 1_000_000)
.rec_simple("r1", 99, "chr1", 100, "50M", "=", 200, 150)
.rec_simple("r1", 147, "chr1", 200, "50M", "=", 100, -150)
.write_to(&env.input);
run_with_stats(&env.input, &stats, &out, &[]);
let text = std::fs::read_to_string(summary(&stats)).unwrap();
let header = text.lines().next().unwrap();
let cols: Vec<&str> = header.split('\t').collect();
let expected = [
"sample",
"library",
"dupblaster_version",
"total_templates",
"duplicate_templates",
"frac_duplicates",
"mapped_pairs",
"unmapped_pairs",
"duplicate_pairs",
"raw_sequencing_duplicate_pairs",
"corrected_sequencing_duplicate_pairs",
"library_duplicate_pairs",
"frac_duplicate_pairs",
"frac_sequencing_duplicate_pairs",
"estimated_library_size",
"mapped_orphans",
"duplicate_orphans",
"unmapped_orphans",
"unmated_templates",
];
assert_eq!(cols, expected);
}
#[test]
fn decomposition_columns_are_blank_when_the_split_is_disabled() {
let env = TestEnv::new();
let stats = env._tmp.path().join("metrics");
let out = env._tmp.path().join("out.bam");
SamBuilder::new()
.sq("chr1", 1_000_000)
.rec_simple("r1", 99, "chr1", 100, "50M", "=", 200, 150)
.rec_simple("r1", 147, "chr1", 200, "50M", "=", 100, -150)
.write_to(&env.input);
run_with_stats(&env.input, &stats, &out, &[]);
let text = std::fs::read_to_string(summary(&stats)).unwrap();
let mut lines = text.lines();
let header: Vec<&str> = lines.next().unwrap().split('\t').collect();
let values: Vec<&str> = lines.next().unwrap().split('\t').collect();
for column in [
"raw_sequencing_duplicate_pairs",
"corrected_sequencing_duplicate_pairs",
"library_duplicate_pairs",
"frac_sequencing_duplicate_pairs",
] {
let index = header.iter().position(|c| *c == column).expect("column present");
assert_eq!(values[index], "", "{column} should be blank");
}
}
#[test]
fn stats_counts_unaffected_by_remove_dups() {
let env = TestEnv::new();
let stats = env._tmp.path().join("metrics");
let out = env._tmp.path().join("out.bam");
SamBuilder::new()
.sq("chr1", 1_000_000)
.rec_simple("r1", 99, "chr1", 100, "50M", "=", 200, 150)
.rec_simple("r1", 147, "chr1", 200, "50M", "=", 100, -150)
.rec_simple("r2", 99, "chr1", 100, "50M", "=", 200, 150)
.rec_simple("r2", 147, "chr1", 200, "50M", "=", 100, -150)
.write_to(&env.input);
run_with_stats(&env.input, &stats, &out, &["--remove-dups"]);
let m = parse_stats_tsv(&summary(&stats));
assert_eq!(m["total_templates"], "2");
assert_eq!(m["duplicate_pairs"], "1", "the duplicate is still counted");
assert_eq!(m["duplicate_templates"], "1");
let records = read_records(&out);
assert_eq!(records.len(), 2, "duplicate pair should be removed from output");
}
#[test]
fn stats_tsv_handles_empty_input_without_crashing() {
let env = TestEnv::new();
let stats = env._tmp.path().join("metrics");
let out = env._tmp.path().join("out.bam");
SamBuilder::new().sq("chr1", 1_000_000).write_to(&env.input); run_with_stats(&env.input, &stats, &out, &[]);
let m = parse_stats_tsv(&summary(&stats));
assert_eq!(m["total_templates"], "0");
assert_eq!(m["duplicate_templates"], "0");
assert_eq!(m["mapped_pairs"], "0");
assert_eq!(m["duplicate_pairs"], "0");
let f: f64 = m["frac_duplicates"].parse().expect("frac_duplicates is a number");
assert!(f.is_finite(), "frac_duplicates was non-finite: {}", m["frac_duplicates"]);
assert_eq!(f, 0.0, "frac_duplicates should be 0 with no reads");
assert_eq!(m["estimated_library_size"], "");
}
#[test]
fn an_attached_metrics_prefix_is_not_duplicated_by_the_runner() {
let env = TestEnv::new();
let out = env._tmp.path().join("out.bam");
let prefix = env._tmp.path().join("attached");
SamBuilder::new()
.sq("chr1", 1_000_000)
.rec_simple("r1", 99, "chr1", 100, "50M", "=", 200, 150)
.rec_simple("r1", 147, "chr1", 200, "50M", "=", 100, -150)
.write_to(&env.input);
let attached = format!("--metrics-prefix={}", prefix.display());
let run = run_and_capture(&env.input, &out, &[&attached]);
assert!(run.stderr.contains("as duplicates"), "the run should have completed: {}", run.stderr);
assert!(summary(&prefix).exists(), "metrics should land under the attached prefix");
}