mod cigar;
mod complexity;
mod counts;
mod countset;
mod dedup;
mod metrics;
mod plots;
mod raw_reader;
mod raw_writer;
mod readname;
mod sam_reader;
mod sig;
mod tiles;
use std::fs::File;
use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Instant;
use anyhow::{Context, Result, bail};
use bgzf::CompressionLevel;
use clap::{Parser, ValueEnum};
use fgumi_raw_bam::RawRecord;
use noodles_sam::Header;
use noodles_sam::header::record::value::Map;
use noodles_sam::header::record::value::map::Program;
use noodles_sam::header::record::value::map::program::tag as program_tag;
use rawb_io::ReadAhead;
use crate::complexity::{LadderRecorder, ladder_path, write_ladder_rows};
use crate::counts::{histogram_path, histogram_rows, write_histogram_rows};
use crate::dedup::{LibraryIndex, ProcessorOptions, RecordProcessor, Stats};
use crate::metrics::{
Metrics, SequencingUnitMetrics, duplicate_metrics_path, resolve_sample, sequencing_units_path,
write_rows_to_path, write_unit_rows_to_path,
};
use crate::raw_reader::RawBamReader;
use crate::raw_writer::RawBamWriter;
use crate::readname::ReadNameFormat;
use crate::sam_reader::SamReader;
use crate::sig::{MethylationMode, SingleEndStrategy};
use crate::tiles::{DEFAULT_SPILL_BUCKETS, TileSpiller};
const DUPBLASTER_BUILD: &str = env!("CARGO_PKG_VERSION");
macro_rules! repo_url {
() => {
"https://github.com/fulcrumgenomics/dupblaster"
};
}
macro_rules! help_banner {
() => {
concat!("dupblaster by Fulcrum Genomics\n", repo_url!(), "\n\n")
};
}
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum SingleEndStrategyCli {
#[value(name = "strand-aware")]
StrandAware,
#[value(name = "picard-exact")]
PicardExact,
#[value(name = "picard-approx")]
PicardApprox,
#[value(name = "samblaster-legacy")]
SamblasterLegacy,
}
impl SingleEndStrategyCli {
fn to_strategy(self) -> SingleEndStrategy {
match self {
Self::StrandAware => SingleEndStrategy::StrandAware,
Self::PicardExact => SingleEndStrategy::PicardExact,
Self::PicardApprox => SingleEndStrategy::PicardApprox,
Self::SamblasterLegacy => SingleEndStrategy::SamblasterLegacy,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum MethylationModeCli {
#[value(name = "directional")]
Directional,
}
impl MethylationModeCli {
fn to_mode(self) -> MethylationMode {
match self {
Self::Directional => MethylationMode::Directional,
}
}
}
fn parse_compression_level(s: &str) -> Result<CompressionLevel, String> {
let n: u8 = s.parse().map_err(|e| format!("not a u8: {e}"))?;
CompressionLevel::new(n).map_err(|e| format!("{e}"))
}
const MIN_TMP_COMPRESSION_LEVEL: i32 = -7;
const MAX_TMP_COMPRESSION_LEVEL: i32 = 9;
fn parse_tmp_compression_level(s: &str) -> Result<i32, String> {
let level: i32 = s.parse().map_err(|_| format!("not an integer: {s}"))?;
let supported = zstd::compression_level_range();
let (min, max) = (
MIN_TMP_COMPRESSION_LEVEL.max(*supported.start()),
MAX_TMP_COMPRESSION_LEVEL.min(*supported.end()),
);
if level == 0 {
return Err("0 is ambiguous here: omit --tmp-compression-level entirely to leave \
temporary files uncompressed, or pass 3 for zstd's default level"
.to_string());
}
if !(min..=max).contains(&level) {
return Err(format!("must be between {min} and {max}"));
}
Ok(level)
}
const TUNING_HEADING: &str = "Advanced tuning (rarely needed)";
const SHORT_ABOUT: &str =
concat!(help_banner!(), "Mark or remove duplicate reads in a query-grouped SAM/BAM file.");
const LONG_ABOUT: &str = concat!(
help_banner!(),
"Mark or remove duplicates in a query-grouped SAM/BAM file.\n\
Input must be query-grouped (same-QNAME alignments adjacent, as straight from \
an aligner); output is always BAM, uncompressed unless -l says otherwise.",
);
#[derive(Parser, Debug, Clone)]
#[command(name = "dupblaster", version = DUPBLASTER_BUILD, disable_version_flag = true,
about = SHORT_ABOUT, long_about = LONG_ABOUT)]
pub struct Args {
#[arg(short = 'i', long = "input")]
pub input: Option<PathBuf>,
#[arg(short = 'o', long = "output")]
pub output: Option<PathBuf>,
#[arg(long = "metrics-prefix", value_name = "PREFIX")]
pub metrics_prefix: PathBuf,
#[arg(short = 'l',
long = "compression-level",
default_value = "0",
value_parser = parse_compression_level)]
pub compression_level: CompressionLevel,
#[arg(short = 'r', long = "remove-dups")]
pub remove_dups: bool,
#[arg(short = 'm', long = "add-mate-tags")]
pub add_mate_tags: bool,
#[arg(long = "ignore-unmated")]
pub ignore_unmated: bool,
#[arg(long = "single-end-strategy",
value_enum,
default_value_t = SingleEndStrategyCli::StrandAware)]
pub single_end_strategy: SingleEndStrategyCli,
#[arg(long = "library-aware", value_name = "on|off",
num_args = 0..=1, default_value = "on", default_missing_value = "on",
hide_possible_values = true,
value_parser = clap::builder::BoolishValueParser::new())]
pub library_aware: bool,
#[arg(long = "methylation-mode", value_enum)]
pub methylation_mode: Option<MethylationModeCli>,
#[arg(long = "tmp-dir")]
pub tmp_dir: Option<PathBuf>,
#[arg(long = "sample")]
pub sample: Option<String>,
#[arg(long = "duplication-spectrum", value_name = "on|off",
num_args = 0..=1, default_value = "off", default_missing_value = "on",
hide_possible_values = true,
value_parser = clap::builder::BoolishValueParser::new())]
pub duplication_spectrum: bool,
#[arg(long = "sampling-interval",
default_value_t = 1_000_000,
value_parser = clap::value_parser!(u64).range(1..))]
pub sampling_interval: u64,
#[arg(long = "sequencing-duplicate-detection", value_name = "on|off",
num_args = 0..=1, default_value = "on", default_missing_value = "on",
hide_possible_values = true,
value_parser = clap::builder::BoolishValueParser::new())]
pub sequencing_duplicate_detection: bool,
#[arg(long = "read-name-format", value_name = "FORMAT")]
pub read_name_format: Option<ReadNameFormat>,
#[arg(short = 'q', long = "quiet")]
pub quiet: bool,
#[arg(short = 'V', long = "version", action = clap::ArgAction::Version)]
pub show_version: Option<bool>,
#[arg(long = "min-bins",
default_value_t = 32,
hide = true,
value_parser = clap::value_parser!(u32).range(1..=8192))]
pub min_bins: u32,
#[arg(long = "check-crc", conflicts_with = "no_check_crc", help_heading = TUNING_HEADING)]
pub check_crc: bool,
#[arg(long = "no-check-crc", conflicts_with = "check_crc", help_heading = TUNING_HEADING)]
pub no_check_crc: bool,
#[arg(long = "read-buffer-mb", default_value_t = 16, help_heading = TUNING_HEADING,
value_parser = clap::value_parser!(u32).range(1..=4096))]
pub read_buffer_mb: u32,
#[arg(long = "write-buffer-mb", default_value_t = 64, help_heading = TUNING_HEADING,
value_parser = clap::value_parser!(u32).range(1..=4096))]
pub write_buffer_mb: u32,
#[arg(long = "max-read-length", default_value_t = 1000, help_heading = TUNING_HEADING,
value_parser = clap::value_parser!(i32).range(1..=10_000_000))]
pub max_read_length: i32,
#[arg(long = "tmp-compression-level", value_name = "LEVEL",
help_heading = TUNING_HEADING, allow_negative_numbers = true,
value_parser = parse_tmp_compression_level)]
pub tmp_compression_level: Option<i32>,
}
impl Args {
pub fn effective_check_crc(&self) -> bool {
if self.check_crc {
return true;
}
if self.no_check_crc {
return false;
}
matches!(self.input.as_deref(), Some(p) if p.to_string_lossy() != "-")
}
pub fn validate(&self) -> Result<()> {
if let Some(p) = &self.output {
let s = p.to_string_lossy();
if s != "-" && !s.ends_with(".bam") {
bail!(
"output path {} must end in `.bam` (dupblaster only writes BAM); \
use `-` to send BAM to stdout",
p.display()
);
}
}
Ok(())
}
}
fn main() -> ExitCode {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp(None)
.init();
let args = Args::parse();
match run(args) {
Ok(code) => code,
Err(err) => {
eprintln!("dupblaster: {err:#}");
eprintln!("dupblaster: Premature exit (return code 1).");
ExitCode::FAILURE
}
}
}
fn ensure_dir_writable(path: &Path, flag: &str) -> Result<()> {
let dir = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
};
if !dir.is_dir() {
bail!("{flag}: output directory {} does not exist", dir.display());
}
let probe = dir.join(format!(".dupblaster-write-probe.{}", std::process::id()));
std::fs::File::create(&probe)
.and_then(|_| std::fs::remove_file(&probe))
.with_context(|| format!("{flag}: cannot write to output directory {}", dir.display()))?;
Ok(())
}
fn run(args: Args) -> Result<ExitCode> {
args.validate()?;
ensure_dir_writable(&args.metrics_prefix, "--metrics-prefix")?;
let started = StartedRun::now();
if !args.quiet {
eprintln!("dupblaster: Version {DUPBLASTER_BUILD} by Fulcrum Genomics");
eprintln!("dupblaster: {}", repo_url!());
}
let raw_source: Box<dyn std::io::Read + Send> = match args.input.as_deref() {
Some(p) if p.to_string_lossy() != "-" => {
let f = File::open(p).with_context(|| format!("opening {} for read", p.display()))?;
Box::new(f)
}
_ => Box::new(std::io::stdin()),
};
let read_buf_bytes = (args.read_buffer_mb as usize).saturating_mul(1024 * 1024);
let mut reader_box: Box<dyn BufRead> =
Box::new(ReadAhead::with_thread_name(raw_source, read_buf_bytes, "dupblaster"));
let input_name =
args.input.as_deref().map(|p| p.display().to_string()).unwrap_or_else(|| "stdin".into());
let input_format = detect_format(&mut reader_box)?;
let check_crc = args.effective_check_crc();
let mut reader: Reader = match input_format {
Format::Bam => {
if !args.quiet {
eprintln!(
"dupblaster: Reading BAM from {input_name} (CRC verify: {}).",
if check_crc { "on" } else { "off" }
);
}
Reader::Bam(RawBamReader::new(reader_box, check_crc))
}
Format::Sam => {
if !args.quiet {
eprintln!("dupblaster: Reading SAM from {input_name}.");
}
Reader::Sam(SamReader::new(reader_box))
}
};
let mut header = reader.read_header()?;
if header.reference_sequences().is_empty() {
bail!("Input has no @SQ reference sequences. Exiting.");
}
if !args.quiet {
eprintln!(
"dupblaster: Loaded {} header sequence entries.",
header.reference_sequences().len()
);
}
let ref_lengths: Vec<i32> = header
.reference_sequences()
.iter()
.map(|(name, m)| {
i32::try_from(usize::from(m.length())).map_err(|_| {
anyhow::anyhow!(
"Contig {} length {} exceeds i32::MAX",
String::from_utf8_lossy(name),
usize::from(m.length()),
)
})
})
.collect::<Result<Vec<i32>>>()?;
append_dupblaster_pg(&mut header)?;
let write_buf_bytes = (args.write_buffer_mb as usize).saturating_mul(1024 * 1024);
let mut out = RawBamWriter::open(
args.output.as_deref(),
&header,
write_buf_bytes,
args.compression_level,
)?;
let opts = processor_options(&args);
let library_index = LibraryIndex::from_header(&header, !args.library_aware);
let num_libs = library_index.num_libs();
let mut stats = Stats::new(&library_index);
let mut processor =
RecordProcessor::from_ref_lengths(&ref_lengths, opts, args.min_bins, library_index);
if args.sequencing_duplicate_detection {
let spiller = TileSpiller::new(
args.read_name_format.clone().unwrap_or_default(),
processor.bin_count(),
DEFAULT_SPILL_BUCKETS,
args.tmp_dir.as_deref(),
args.tmp_compression_level,
)
.context("preparing the sequencing-vs-library duplicate spill")?;
processor.attach_tile_spiller(spiller);
}
if !args.quiet {
eprintln!(
"dupblaster: bin_shift={} bin_count={} (min-bins={}), partition cells ≈ {}",
processor.bin_shift(),
processor.bin_count(),
args.min_bins,
((processor.bin_count() + 1) * 2).pow(2),
);
if num_libs > 1 {
eprintln!(
"dupblaster: library-aware mode — {num_libs} library buckets (incl. 'Unknown \
Library'); duplicates are called within a library and reported per library."
);
}
if args.methylation_mode.is_some() {
eprintln!(
"dupblaster: methylation mode = directional — pairs keyed in template order so \
opposite-strand (OT/OB) fragments at one locus are kept distinct."
);
}
}
let mut ladder = LadderRecorder::new(
num_libs as usize,
args.sampling_interval,
resolve_sample(&header, args.sample.as_deref()),
);
let processed = if args.single_end_strategy.to_strategy() == SingleEndStrategy::PicardExact {
run_picard_exact(
&mut reader,
&mut processor,
&mut stats,
&mut out,
&header,
&args,
&mut ladder,
)
} else {
let mut pool: Vec<RawRecord> = Vec::with_capacity(8);
for_each_block(&mut reader, &mut pool, |block| {
let lib = processor.process_block(block, &mut stats, &mut out)?;
ladder.observe(lib, &stats.libraries[lib as usize]);
Ok(())
})
.context("processing record block")
};
if let Err(error) = processed {
out.abandon();
return Err(error);
}
print_run_stats(&stats, &args);
out.finish().context("finishing main output")?;
let decomposition = match processor.take_tile_spiller() {
Some(mut spiller) => {
let measure = !args.quiet && args.tmp_compression_level.is_some();
let on_disk =
spiller.finish_spill(measure).context("closing the duplicate-split spill")?;
if !args.quiet {
let logical = spiller.spilled_bytes();
let mib = |bytes: u64| bytes as f64 / (1024.0 * 1024.0);
let compressed = match (on_disk, logical) {
(Some(on_disk), logical) if logical > 0 => format!(
" ({:.1} MiB on disk, {:.2}x)",
mib(on_disk),
logical as f64 / on_disk.max(1) as f64
),
_ => String::new(),
};
eprintln!(
"dupblaster: decomposing duplicates from {:.1} MiB of spilled tile \
records{compressed}",
mib(logical),
);
}
Some(
spiller
.decompose(num_libs)
.context("decomposing sequencing vs library duplicates")?,
)
}
None => None,
};
let prefix = args.metrics_prefix.as_path();
let rows = Metrics::rows_from_stats(
&stats,
&header,
args.sample.as_deref(),
decomposition.as_ref().map(|result| result.libraries.as_slice()),
);
write_rows_to_path(&rows, &duplicate_metrics_path(prefix))
.context("writing duplicate-metrics TSV")?;
if let Some(result) = decomposition.as_ref() {
let sample = resolve_sample(&header, args.sample.as_deref());
let rows = SequencingUnitMetrics::rows(&result.units, &stats, &sample);
write_unit_rows_to_path(&rows, &sequencing_units_path(prefix))
.context("writing sequencing-unit TSV")?;
}
{
let mut rec = ladder;
rec.finalize(&stats);
let path = ladder_path(prefix);
write_ladder_rows(rec.rows(), &path).context("writing duplication-sampled TSV")?;
plots::write_duplicate_ladder_pdf(rec.rows(), &plots::ladder_plot_path(prefix))
.context("writing duplication-sampled plot")?;
}
if let Some(counts) = processor.counts() {
let sample = resolve_sample(&header, args.sample.as_deref());
let rows = histogram_rows(counts, &stats, &sample);
write_histogram_rows(&rows, &histogram_path(prefix))
.context("writing duplication-spectrum TSV")?;
plots::write_count_histogram_pdfs(&rows, prefix)
.context("writing duplication-spectrum plot")?;
}
report(&started, stats.totals().id_count, args.quiet);
if stats.clamped_template_count > 0 {
return Ok(ExitCode::FAILURE);
}
Ok(ExitCode::SUCCESS)
}
fn processor_options(args: &Args) -> ProcessorOptions {
ProcessorOptions {
remove_dups: args.remove_dups,
add_mate_tags: args.add_mate_tags,
ignore_unmated: args.ignore_unmated,
max_read_length: args.max_read_length,
single_end_strategy: args.single_end_strategy.to_strategy(),
methylation_mode: args.methylation_mode.map(MethylationModeCli::to_mode),
collect_counts: args.duplication_spectrum,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Format {
Sam,
Bam,
}
fn detect_format<R: BufRead>(reader: &mut R) -> Result<Format> {
let head = reader.fill_buf().context("reading input to detect format")?;
if head.is_empty() {
bail!("Empty input: no SAM/BAM header detected");
}
match head[0] {
0x1f => Ok(Format::Bam),
b'@' => Ok(Format::Sam),
b => bail!(
"Input doesn't look like SAM or BAM (first byte 0x{:02x}); expected '@' or 0x1f",
b
),
}
}
enum Reader {
Bam(RawBamReader<Box<dyn BufRead>>),
Sam(SamReader<Box<dyn BufRead>>),
}
impl Reader {
fn read_header(&mut self) -> Result<Header> {
match self {
Reader::Bam(r) => r.read_header().context("reading BAM header"),
Reader::Sam(r) => r.read_header().context("reading SAM header"),
}
}
fn read_record(&mut self, rec: &mut RawRecord) -> std::io::Result<bool> {
match self {
Reader::Bam(r) => r.read_record(rec),
Reader::Sam(r) => r.read_record(rec),
}
}
}
fn for_each_block(
reader: &mut Reader,
pool: &mut Vec<RawRecord>,
mut on_block: impl FnMut(&mut [RawRecord]) -> Result<()>,
) -> Result<()> {
if pool.is_empty() {
pool.push(RawRecord::new());
}
let mut block_len: usize = 0;
loop {
if pool.len() == block_len {
pool.push(RawRecord::new());
}
let read_idx = block_len;
let got = reader.read_record(&mut pool[read_idx]).context("reading record")?;
if !got {
break;
}
if block_len > 0 && pool[read_idx].read_name() != pool[0].read_name() {
on_block(&mut pool[..block_len])?;
pool.swap(0, read_idx);
block_len = 1;
} else {
block_len += 1;
}
}
if block_len > 0 {
on_block(&mut pool[..block_len])?;
}
Ok(())
}
fn run_picard_exact(
reader: &mut Reader,
processor: &mut RecordProcessor,
stats: &mut Stats,
out: &mut RawBamWriter,
header: &Header,
args: &Args,
ladder: &mut LadderRecorder,
) -> Result<()> {
const TEMP_RING_BYTES: usize = 8 * 1024 * 1024;
let temp = create_temp_bam(args.tmp_dir.as_deref())?;
let temp_path = temp.path().to_path_buf();
if !args.quiet {
eprintln!(
"dupblaster: picard-exact mode — buffering orphan/single-end reads to {}",
temp_path.display()
);
}
let deferred;
{
let mut temp_writer =
RawBamWriter::open_temp(&temp, header, TEMP_RING_BYTES, args.tmp_compression_level)
.context("opening temp orphan BAM")?;
let mut pool: Vec<RawRecord> = Vec::with_capacity(8);
for_each_block(reader, &mut pool, |block| {
let lib = processor.process_block_phase1(block, stats, out, &mut temp_writer)?;
ladder.observe(lib, &stats.libraries[lib as usize]);
Ok(())
})
.context("processing record block (picard-exact pass 1)")?;
deferred = temp_writer.records_written();
temp_writer.finish().context("finishing temp orphan BAM")?;
}
processor.finalize_fragment_table();
let file = temp.reopen().context("reopening temp orphan BAM for pass 2")?;
let buffered: Box<dyn BufRead> = match args.tmp_compression_level {
None => Box::new(std::io::BufReader::new(file)),
Some(_) => Box::new(std::io::BufReader::new(
zstd::stream::read::Decoder::new(file).context("starting zstd decode of temp BAM")?,
)),
};
let mut temp_reader = Reader::Bam(RawBamReader::new(buffered, false));
temp_reader.read_header().context("reading temp orphan BAM header")?;
let mut pool: Vec<RawRecord> = Vec::with_capacity(8);
let mut recovered: u64 = 0;
for_each_block(&mut temp_reader, &mut pool, |block| {
recovered += block.len() as u64;
let lib = processor.process_fragment_block(block, stats, out)?;
ladder.observe(lib, &stats.libraries[lib as usize]);
Ok(())
})
.context("processing fragment block (picard-exact pass 2)")?;
if recovered != deferred {
bail!(
"temp orphan BAM held {deferred} records but only {recovered} could be read back; \
the temporary file under --tmp-dir was truncated or corrupted"
);
}
Ok(())
}
fn create_temp_bam(dir: Option<&std::path::Path>) -> Result<tempfile::NamedTempFile> {
let mut builder = tempfile::Builder::new();
builder.prefix("dupblaster-orphans-").suffix(".bam");
let file = match dir {
Some(d) => builder.tempfile_in(d),
None => builder.tempfile(),
}
.context("creating temp file for picard-exact orphan buffering")?;
Ok(file)
}
fn append_dupblaster_pg(header: &mut Header) -> Result<()> {
let programs = header.programs().as_ref();
let known_ids: std::collections::HashSet<&[u8]> =
programs.keys().map(|k| k.as_slice()).collect();
for (id, map) in programs.iter() {
if let Some(pp) = map.other_fields().get(&program_tag::PREVIOUS_PROGRAM_ID) {
let pp_bytes = pp.as_ref();
if !known_ids.contains(pp_bytes) {
bail!(
"input header @PG ID:{} has PP:{} but no @PG with that ID exists. \
This is a malformed SAM header. Strip the broken PP tag or rewrite \
the @PG chain (e.g. via `samtools reheader`) before re-running.",
String::from_utf8_lossy(id.as_slice()),
String::from_utf8_lossy(pp_bytes),
);
}
}
}
let cl = command_line_for_pg();
let mut map = Map::<Program>::default();
map.other_fields_mut().insert(program_tag::VERSION, DUPBLASTER_BUILD.into());
map.other_fields_mut().insert(program_tag::COMMAND_LINE, cl.into());
header.programs_mut().add("DUPBLASTER", map).context("appending @PG DUPBLASTER record")?;
Ok(())
}
fn command_line_for_pg() -> String {
let mut args = std::env::args();
let prog = args
.next()
.map(|a| {
std::path::Path::new(&a)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or(a)
})
.unwrap_or_else(|| "dupblaster".to_string());
let rest: Vec<String> = args.collect();
if rest.is_empty() { prog } else { format!("{prog} {}", rest.join(" ")) }
}
fn print_run_stats(stats: &Stats, args: &Args) {
let totals = stats.totals();
if totals.id_count == 0 {
eprintln!("dupblaster: No reads processed.");
return;
}
if stats.clamped_template_count > 0 {
let pct = 100.0 * stats.clamped_template_count as f64 / totals.id_count as f64;
eprintln!(
"dupblaster: WARNING: {} of {} ({:.3}%) templates had a read whose 5' coordinate \
was clamped to its contig because its clipping extends more than \
--max-read-length({}) bases past a contig edge.",
stats.clamped_template_count, totals.id_count, pct, args.max_read_length
);
eprintln!(
"dupblaster: Duplicate marking may be imprecise for those templates; re-run with a \
larger --max-read-length to eliminate the clamping. (Exiting non-zero.)"
);
}
if args.ignore_unmated {
let pct = 100.0 * totals.unmated_count as f64 / totals.id_count as f64;
eprintln!(
"dupblaster: Found {:>10} of {:>10} ({:5.3}%) total read ids are marked paired yet are unmated.",
totals.unmated_count, totals.id_count, pct
);
if totals.unmated_count > 0 {
eprintln!(
"dupblaster: Please double check that input file is query-grouped (QNAME grouped)."
);
}
}
let verb = if args.remove_dups { "Removed" } else { "Marked " };
let pct = 100.0 * totals.dup_count as f64 / totals.id_count as f64;
eprintln!(
"dupblaster: {} {:>10} of {:>10} ({:5.3}%) total read ids as duplicates.",
verb, totals.dup_count, totals.id_count, pct
);
}
struct StartedRun {
wall_start: Instant,
}
impl StartedRun {
fn now() -> Self {
Self { wall_start: Instant::now() }
}
}
fn report(started: &StartedRun, n_templates: u64, quiet: bool) {
if quiet {
return;
}
let wall = started.wall_start.elapsed().as_secs_f64();
let stderr = std::io::stderr();
let mut stderr = stderr.lock();
#[cfg(unix)]
if let Some(ru) = read_rusage() {
let user = ru.user_secs;
let sys = ru.sys_secs;
let rss_mb = ru.max_rss_bytes as f64 / (1024.0 * 1024.0);
let _ = writeln!(
stderr,
"dupblaster: Processed {n_templates} templates in {wall:.2}s wall, \
{user:.2}s user CPU, {sys:.2}s system CPU, max RSS {rss_mb:.1} MB.",
);
return;
}
let _ = writeln!(stderr, "dupblaster: Processed {n_templates} templates in {wall:.2}s wall.");
}
#[cfg(unix)]
struct Rusage {
user_secs: f64,
sys_secs: f64,
max_rss_bytes: u64,
}
#[cfg(unix)]
fn read_rusage() -> Option<Rusage> {
let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) };
if rc != 0 {
return None;
}
let user_secs = ru.ru_utime.tv_sec as f64 + ru.ru_utime.tv_usec as f64 * 1e-6;
let sys_secs = ru.ru_stime.tv_sec as f64 + ru.ru_stime.tv_usec as f64 * 1e-6;
let max_rss = ru.ru_maxrss as u64;
#[cfg(target_os = "macos")]
let max_rss_bytes = max_rss;
#[cfg(not(target_os = "macos"))]
let max_rss_bytes = max_rss.saturating_mul(1024);
Some(Rusage { user_secs, sys_secs, max_rss_bytes })
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(extra: &[&str]) -> Result<Args, clap::Error> {
let mut argv = vec!["dupblaster", "--metrics-prefix", "metrics"];
argv.extend_from_slice(extra);
Args::try_parse_from(argv)
}
fn args_with_output(out: Option<&str>) -> Args {
Args {
input: None,
output: out.map(PathBuf::from),
metrics_prefix: PathBuf::from("metrics"),
remove_dups: false,
add_mate_tags: false,
ignore_unmated: false,
max_read_length: 1000,
library_aware: true,
single_end_strategy: SingleEndStrategyCli::StrandAware,
methylation_mode: None,
tmp_dir: None,
sample: None,
duplication_spectrum: false,
sampling_interval: 1_000_000,
sequencing_duplicate_detection: false,
read_name_format: None,
tmp_compression_level: None,
quiet: true,
min_bins: 32,
check_crc: false,
no_check_crc: false,
read_buffer_mb: 16,
write_buffer_mb: 64,
compression_level: CompressionLevel::new(0).expect("level 0 is valid"),
show_version: None,
}
}
#[test]
fn validate_accepts_bam_extension() {
assert!(args_with_output(Some("out.bam")).validate().is_ok());
assert!(args_with_output(Some("/tmp/sample-A.bam")).validate().is_ok());
}
#[test]
fn validate_accepts_stdout_dash() {
assert!(args_with_output(Some("-")).validate().is_ok());
}
#[test]
fn validate_accepts_no_output_specified() {
assert!(args_with_output(None).validate().is_ok());
}
#[test]
fn validate_rejects_non_bam_extension() {
let err = args_with_output(Some("out.sam")).validate().unwrap_err();
assert!(err.to_string().contains("must end in `.bam`"));
}
#[test]
fn validate_rejects_missing_extension() {
let err = args_with_output(Some("out")).validate().unwrap_err();
assert!(err.to_string().contains("must end in `.bam`"));
}
#[test]
fn max_read_length_rejects_non_positive() {
assert!(parse(&["--max-read-length", "0"]).is_err());
assert!(parse(&["--max-read-length", "-5"]).is_err());
}
#[test]
fn max_read_length_rejects_overflowing_value() {
assert!(parse(&["--max-read-length", "20000000"]).is_err());
}
#[test]
fn max_read_length_accepts_in_range() {
let args = parse(&["--max-read-length", "150000"]).unwrap();
assert_eq!(args.max_read_length, 150_000);
}
#[test]
fn min_bins_rejects_zero_and_oversized() {
assert!(parse(&["--min-bins", "0"]).is_err());
assert!(parse(&["--min-bins", "100000"]).is_err());
}
#[test]
fn min_bins_accepts_in_range() {
let args = parse(&["--min-bins", "8192"]).unwrap();
assert_eq!(args.min_bins, 8192);
}
#[test]
fn effective_check_crc_true_when_flag_set() {
let mut a = args_with_output(None);
a.check_crc = true;
assert!(a.effective_check_crc());
}
#[test]
fn effective_check_crc_false_when_no_check_flag_set() {
let mut a = args_with_output(None);
a.no_check_crc = true;
assert!(!a.effective_check_crc());
}
#[test]
fn effective_check_crc_defaults_on_for_file_input() {
let mut a = args_with_output(None);
a.input = Some(PathBuf::from("reads.bam"));
assert!(a.effective_check_crc());
}
#[test]
fn effective_check_crc_defaults_off_for_stdin() {
assert!(!args_with_output(None).effective_check_crc());
}
#[test]
fn effective_check_crc_defaults_off_for_stdin_dash() {
let mut a = args_with_output(None);
a.input = Some(PathBuf::from("-"));
assert!(!a.effective_check_crc());
}
#[test]
fn check_crc_and_no_check_crc_are_mutually_exclusive() {
assert!(parse(&["--check-crc", "--no-check-crc"]).is_err());
}
#[test]
fn methylation_mode_defaults_to_none() {
let args = parse(&[]).unwrap();
assert_eq!(args.methylation_mode, None);
}
#[test]
fn methylation_mode_parses_directional() {
let args = parse(&["--methylation-mode", "directional"]).unwrap();
assert_eq!(args.methylation_mode, Some(MethylationModeCli::Directional));
}
#[test]
fn methylation_mode_rejects_unknown_value() {
assert!(parse(&["--methylation-mode", "pbat"]).is_err());
}
#[test]
fn short_l_sets_compression_level() {
let args = parse(&["-l", "6"]).unwrap();
assert_eq!(u8::from(args.compression_level), 6);
}
#[test]
fn short_m_sets_add_mate_tags() {
let args = parse(&["-m"]).unwrap();
assert!(args.add_mate_tags);
}
#[test]
fn metrics_prefix_is_required() {
assert!(Args::try_parse_from(["dupblaster"]).is_err());
}
#[test]
fn toggle_defaults() {
let args = parse(&[]).unwrap();
assert!(args.sequencing_duplicate_detection);
assert!(args.library_aware);
assert!(
!args.duplication_spectrum,
"the spectrum costs ~1 GB peak RSS, so it is the one opt-in"
);
}
#[test]
fn a_bare_toggle_means_on() {
assert!(parse(&["--duplication-spectrum"]).unwrap().duplication_spectrum);
assert!(
parse(&["--sequencing-duplicate-detection"]).unwrap().sequencing_duplicate_detection
);
assert!(parse(&["--library-aware"]).unwrap().library_aware);
}
#[test]
fn off_turns_a_toggle_off() {
assert!(
!parse(&["--sequencing-duplicate-detection", "off"])
.unwrap()
.sequencing_duplicate_detection
);
assert!(!parse(&["--library-aware", "off"]).unwrap().library_aware);
}
#[test]
fn toggles_accept_the_usual_boolean_spellings() {
for off in ["off", "false", "no", "0", "OFF", "False"] {
assert!(
!parse(&["--library-aware", off]).unwrap().library_aware,
"{off} should read as off"
);
}
for on in ["on", "true", "yes", "1", "ON"] {
assert!(
parse(&["--duplication-spectrum", on]).unwrap().duplication_spectrum,
"{on} should read as on"
);
}
}
#[test]
fn a_toggle_rejects_a_non_boolean_value() {
assert!(parse(&["--duplication-spectrum", "maybe"]).is_err());
}
#[test]
fn a_toggle_cannot_be_given_twice() {
assert!(
parse(&[
"--sequencing-duplicate-detection",
"on",
"--sequencing-duplicate-detection",
"off",
])
.is_err()
);
}
}