mod cigar;
mod complexity;
mod counts;
mod countset;
mod dedup;
mod metrics;
mod plots;
mod raw_reader;
mod raw_writer;
mod sam_reader;
mod sig;
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, resolve_sample, write_rows_to_path};
use crate::raw_reader::RawBamReader;
use crate::raw_writer::RawBamWriter;
use crate::sam_reader::SamReader;
use crate::sig::{MethylationMode, SingleEndStrategy};
const DUPBLASTER_BUILD: &str = env!("CARGO_PKG_VERSION");
#[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-approx")]
PicardApprox,
#[value(name = "picard-exact")]
PicardExact,
#[value(name = "samblaster-legacy")]
SamblasterLegacy,
}
impl SingleEndStrategyCli {
fn to_strategy(self) -> SingleEndStrategy {
match self {
Self::StrandAware => SingleEndStrategy::StrandAware,
Self::PicardApprox => SingleEndStrategy::PicardApprox,
Self::PicardExact => SingleEndStrategy::PicardExact,
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 SHORT_ABOUT: &str = "Mark or remove duplicate reads in a query-grouped SAM/BAM file.";
const LONG_ABOUT: &str = "Mark or remove duplicates in a query-grouped SAM/BAM file.\n\
Input must be query-grouped (all alignments for the same QNAME adjacent, \
typically straight from the aligner); output is always BAM (uncompressed by \
default — see --compression-level).";
#[derive(Parser, Debug, Clone)]
#[command(name = "dupblaster", 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 = "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(long = "add-mate-tags")]
pub add_mate_tags: bool,
#[arg(long = "ignore-unmated")]
pub ignore_unmated: bool,
#[arg(long = "max-read-length",
default_value_t = 1000,
value_parser = clap::value_parser!(i32).range(1..=10_000_000))]
pub max_read_length: i32,
#[arg(long = "single-end-strategy",
value_enum,
default_value_t = SingleEndStrategyCli::StrandAware)]
pub single_end_strategy: SingleEndStrategyCli,
#[arg(long = "library-unaware")]
pub library_unaware: bool,
#[arg(long = "methylation-mode", value_enum)]
pub methylation_mode: Option<MethylationModeCli>,
#[arg(long = "tmp-dir")]
pub tmp_dir: Option<PathBuf>,
#[arg(long = "stats")]
pub stats: Option<PathBuf>,
#[arg(long = "sample")]
pub sample: Option<String>,
#[arg(long = "complexity-metrics")]
pub complexity_metrics: Option<PathBuf>,
#[arg(long = "complexity-interval",
default_value_t = 1_000_000,
value_parser = clap::value_parser!(u64).range(1..))]
pub complexity_interval: u64,
#[arg(short = 'q', long = "quiet")]
pub quiet: 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")]
pub check_crc: bool,
#[arg(long = "no-check-crc", conflicts_with = "check_crc")]
pub no_check_crc: bool,
#[arg(long = "read-buffer-mb", default_value_t = 16, value_parser = clap::value_parser!(u32).range(1..=4096))]
pub read_buffer_mb: u32,
#[arg(long = "write-buffer-mb", default_value_t = 64, value_parser = clap::value_parser!(u32).range(1..=4096))]
pub write_buffer_mb: u32,
#[arg(short = 'V', long = "version")]
pub show_version: bool,
}
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> {
if args.show_version {
println!("dupblaster {DUPBLASTER_BUILD}");
return Ok(ExitCode::SUCCESS);
}
args.validate()?;
if let Some(stats) = args.stats.as_deref() {
ensure_dir_writable(stats, "--stats")?;
}
if let Some(prefix) = args.complexity_metrics.as_deref() {
ensure_dir_writable(prefix, "--complexity-metrics")?;
}
let started = StartedRun::now();
if !args.quiet {
eprintln!("dupblaster: Version {DUPBLASTER_BUILD}");
}
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_unaware);
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.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 = args.complexity_metrics.as_ref().map(|_| {
LadderRecorder::new(
num_libs as usize,
args.complexity_interval,
resolve_sample(&header, args.sample.as_deref()),
)
});
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)?;
if let Some(rec) = ladder.as_mut() {
rec.observe(lib, &stats.libraries[lib as usize]);
}
Ok(())
})
.context("processing record block")?;
}
print_run_stats(&stats, &args);
out.finish().context("finishing main output")?;
if let Some(stats_path) = args.stats.as_deref() {
let rows = Metrics::rows_from_stats(&stats, &header, args.sample.as_deref());
write_rows_to_path(&rows, stats_path).context("writing --stats TSV")?;
}
let prefix = args.complexity_metrics.as_deref();
if let (Some(mut rec), Some(prefix)) = (ladder, prefix) {
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(prefix), Some(counts)) = (prefix, 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.complexity_metrics.is_some(),
}
}
#[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;
let mut current_qname: Vec<u8> = Vec::new();
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;
}
let new_qname_differs =
block_len > 0 && pool[read_idx].read_name() != current_qname.as_slice();
if new_qname_differs {
on_block(&mut pool[..block_len])?;
if read_idx != 0 {
pool.swap(0, read_idx);
}
block_len = 1;
current_qname.clear();
current_qname.extend_from_slice(pool[0].read_name());
} else {
if block_len == 0 {
current_qname.clear();
current_qname.extend_from_slice(pool[0].read_name());
}
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 Option<LadderRecorder>,
) -> Result<()> {
const TEMP_RING_BYTES: usize = 8 * 1024 * 1024;
let stored = CompressionLevel::new(0).expect("compression level 0 is valid");
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 mut temp_writer = RawBamWriter::open(Some(&temp_path), header, TEMP_RING_BYTES, stored)
.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)?;
if let Some(rec) = ladder.as_mut() {
rec.observe(lib, &stats.libraries[lib as usize]);
}
Ok(())
})
.context("processing record block (picard-exact pass 1)")?;
temp_writer.finish().context("finishing temp orphan BAM")?;
}
processor.finalize_fragment_table();
let file = File::open(&temp_path).context("reopening temp orphan BAM for pass 2")?;
let buffered: Box<dyn BufRead> = Box::new(std::io::BufReader::new(file));
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);
for_each_block(&mut temp_reader, &mut pool, |block| {
let lib = processor.process_fragment_block(block, stats, out)?;
if let Some(rec) = ladder.as_mut() {
rec.observe(lib, &stats.libraries[lib as usize]);
}
Ok(())
})
.context("processing fragment block (picard-exact pass 2)")?;
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 args_with_output(out: Option<&str>) -> Args {
Args {
input: None,
output: out.map(PathBuf::from),
remove_dups: false,
add_mate_tags: false,
ignore_unmated: false,
max_read_length: 1000,
library_unaware: false,
single_end_strategy: SingleEndStrategyCli::StrandAware,
methylation_mode: None,
tmp_dir: None,
stats: None,
sample: None,
complexity_metrics: None,
complexity_interval: 1_000_000,
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: false,
}
}
#[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!(Args::try_parse_from(["dupblaster", "--max-read-length", "0"]).is_err());
assert!(Args::try_parse_from(["dupblaster", "--max-read-length", "-5"]).is_err());
}
#[test]
fn max_read_length_rejects_overflowing_value() {
assert!(Args::try_parse_from(["dupblaster", "--max-read-length", "20000000"]).is_err());
}
#[test]
fn max_read_length_accepts_in_range() {
let args = Args::try_parse_from(["dupblaster", "--max-read-length", "150000"]).unwrap();
assert_eq!(args.max_read_length, 150_000);
}
#[test]
fn min_bins_rejects_zero_and_oversized() {
assert!(Args::try_parse_from(["dupblaster", "--min-bins", "0"]).is_err());
assert!(Args::try_parse_from(["dupblaster", "--min-bins", "100000"]).is_err());
}
#[test]
fn min_bins_accepts_in_range() {
let args = Args::try_parse_from(["dupblaster", "--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!(Args::try_parse_from(["dupblaster", "--check-crc", "--no-check-crc"]).is_err());
}
#[test]
fn methylation_mode_defaults_to_none() {
let args = Args::try_parse_from(["dupblaster"]).unwrap();
assert_eq!(args.methylation_mode, None);
}
#[test]
fn methylation_mode_parses_directional() {
let args =
Args::try_parse_from(["dupblaster", "--methylation-mode", "directional"]).unwrap();
assert_eq!(args.methylation_mode, Some(MethylationModeCli::Directional));
}
#[test]
fn methylation_mode_rejects_unknown_value() {
assert!(Args::try_parse_from(["dupblaster", "--methylation-mode", "pbat"]).is_err());
}
}