` / `-t`: global worker count; defaults to available CPUs.
+
+`.gz` chain and annotation paths are supported when chaintools is built with
+the `gzip` feature. `cargo install --all-features chaintools` enables it.
+
+## Annotation semantics
+
+Features are derived through `genepred`, then overlapping and book-ended
+intervals are merged per chromosome before totals are calculated. Isoforms
+therefore cannot double-count shared bases.
+
+| Input | exon | cds / utr | intron |
+|:---|:---:|:---:|:---:|
+| BED3–6 | full interval | unavailable | unavailable |
+| BED8/9 | full interval | thick bounds | unavailable |
+| BED12 | blocks | blocks intersected with thick bounds | gaps between blocks |
+| GTF/GFF | derived from transcript structure | derived from coding bounds | derived from exon structure |
+
+A non-empty annotation that yields no requested intervals fails instead of
+silently reporting zero coverage. Chromosomes are not filtered: the annotation
+defines the complete denominator, including mitochondrial DNA and scaffolds.
+
+## Chain coordinates
+
+Only positive-length alignment blocks are marked. Full chain spans and the gaps
+between blocks are never treated as aligned. `--side query` converts minus-strand
+query blocks to forward genomic coordinates before intersecting the annotation.
+The target/reference strand must be `+`, and malformed block spans are rejected.
+
+## Output
+
+Output is tab-separated and written to standard output:
+
+```text
+chrom total_bases covered_bases coverage_fraction
+chr1 3894019 3585875 0.920867
+chr2 2799576 2623407 0.937073
+total 6693595 6209282 0.927645
+```
+
+Rows are sorted by chromosome name. Fractions use six decimal places, and the
+final `total` row is always present. An empty annotation reports `0.000000`,
+never `NaN`.
+
+## Memory and parallelism
+
+Merged annotation intervals are flattened into compact feature space with one
+atomic bit per feature base, so genomic gaps use no bitmap memory. Annotation
+normalization is serial; interval indexing, chain parsing and marking, and
+bitmap counting use the global thread pool. Parsed chain files are held in
+memory one input file at a time.
diff --git a/assets/usage/usage.md b/assets/usage/usage.md
index 68e0760..c2f53bc 100644
--- a/assets/usage/usage.md
+++ b/assets/usage/usage.md
@@ -10,8 +10,8 @@
-
-
+
+
@@ -36,7 +36,7 @@ Add this to your `Cargo.toml`:
```toml
[dependencies]
-chaintools = { version = "0.0.2", features = ["mmap", "gzip"] }
+chaintools = { version = "0.0.13", features = ["mmap", "gzip", "parallel"] }
```
### Features
@@ -51,6 +51,24 @@ chaintools = { version = "0.0.2", features = ["mmap", "gzip"] }
## Usage
+### Annotation Coverage
+
+Measure the fraction of unique annotation feature bases covered by aligned
+chain blocks:
+
+```bash
+chaintools coverage \
+ --chains hg38ToMm39.over.chain.gz \
+ --side reference \
+ --intervals gencode.v50.annotation.gtf.gz \
+ --feature cds \
+ > coverage.tsv
+```
+
+Available features are `cds`, `exon`, `intron`, and `utr`. Use the global
+`--threads` option to control parallel work. See the
+[coverage tool guide](../tools/coverage.md) for input semantics and output.
+
### Basic File Reading
```rust
@@ -264,4 +282,4 @@ Run tests with all features:
```bash
cargo test --all-features
-`
+```
diff --git a/src/cli/bed.rs b/src/cli/bed.rs
index 3765b54..3ea177a 100644
--- a/src/cli/bed.rs
+++ b/src/cli/bed.rs
@@ -99,7 +99,7 @@ pub struct BedArgs {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
-enum BedSide {
+pub(crate) enum BedSide {
Reference,
Query,
}
diff --git a/src/cli/coverage.rs b/src/cli/coverage.rs
new file mode 100644
index 0000000..72ddaab
--- /dev/null
+++ b/src/cli/coverage.rs
@@ -0,0 +1,806 @@
+// Copyright (c) 2026 Alejandro Gonzales-Irribarren
+// Distributed under the terms of the Apache License, Version 2.0.
+
+use std::collections::BTreeMap;
+use std::fs::File;
+use std::io::{BufRead, BufReader, Write};
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use chaintools::{Chain, Reader, Strand};
+use clap::{Args, ValueEnum};
+use genepred::cli::feature::{FeatureKind, FeatureOptions, run as extract};
+#[cfg(feature = "parallel")]
+use rayon::prelude::*;
+
+use super::{CliError, bed::BedSide};
+
+const INPUT_LIST_BUFFER_CAPACITY: usize = 1024 * 1024;
+
+#[derive(Debug, Args)]
+pub struct CoverageArgs {
+ #[arg(
+ short = 'c',
+ long = "chains",
+ value_name = "PATH",
+ num_args = 1..,
+ conflicts_with = "file",
+ help = "Input chain files. If not provided, chain data is read from standard input."
+ )]
+ chains: Option>,
+
+ #[arg(
+ short = 'f',
+ long = "file",
+ value_name = "PATH",
+ conflicts_with = "chains",
+ help = "Path to a file listing one input chain path per line"
+ )]
+ file: Option,
+
+ #[arg(
+ long,
+ value_name = "SIDE",
+ value_enum,
+ help = "Chain side to measure in forward genomic coordinates"
+ )]
+ side: BedSide,
+
+ #[arg(
+ long,
+ value_name = "PATH",
+ help = "BED, GTF, or GFF annotation whose feature bases define the denominator"
+ )]
+ intervals: PathBuf,
+
+ #[arg(long, value_enum, help = "Annotation feature to measure")]
+ feature: Feature,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
+enum Feature {
+ Cds,
+ Exon,
+ Intron,
+ Utr,
+}
+
+impl std::fmt::Display for Feature {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Feature::Cds => f.write_str("cds"),
+ Feature::Exon => f.write_str("exon"),
+ Feature::Intron => f.write_str("intron"),
+ Feature::Utr => f.write_str("utr"),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct Interval {
+ start: u64,
+ end: u64,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct IndexedInterval {
+ start: u64,
+ end: u64,
+ offset: usize,
+}
+
+#[derive(Debug)]
+struct ChromCoverage {
+ intervals: Vec,
+ bitmap: Vec,
+ total_bases: u64,
+}
+
+struct FeatureSpace {
+ chroms: BTreeMap, ChromCoverage>,
+ records_in: u64,
+ intervals_out: u64,
+}
+
+pub fn run(
+ args: CoverageArgs,
+ stdin: &mut R,
+ stdout: &mut W,
+ _stderr: &mut E,
+) -> Result<(), CliError>
+where
+ R: BufRead,
+ W: Write,
+ E: Write,
+{
+ let inputs = collect_input_paths(&args)?;
+ super::ensure_inputs_exist(&[("intervals", &args.intervals)], &[])?;
+ let chain_inputs = inputs
+ .iter()
+ .map(|path| ("input chain", path.as_path()))
+ .collect::>();
+ super::ensure_inputs_exist(&chain_inputs, &[])?;
+
+ log::info!(
+ "coverage: side={}, feature={}, intervals={}, input={}",
+ args.side,
+ args.feature,
+ args.intervals.display(),
+ if inputs.is_empty() {
+ "".to_owned()
+ } else {
+ format!("{} file(s)", inputs.len())
+ }
+ );
+
+ let feature_space = load_feature_space(&args)?;
+ let chains = mark_inputs(stdin, &inputs, &feature_space.chroms, args.side)?;
+ let (total_bases, covered_bases) = write_report(stdout, &feature_space.chroms)?;
+ stdout.flush()?;
+
+ super::log_summary(
+ "coverage",
+ &[
+ (
+ "files",
+ if inputs.is_empty() {
+ 1
+ } else {
+ inputs.len() as u64
+ },
+ ),
+ ("chains", chains),
+ ("annotation_records", feature_space.records_in),
+ ("feature_intervals", feature_space.intervals_out),
+ ("total_bases", total_bases),
+ ("covered_bases", covered_bases),
+ ],
+ );
+ Ok(())
+}
+
+fn load_feature_space(args: &CoverageArgs) -> Result {
+ let mut bed3 = Vec::new();
+ let summary = extract(
+ &args.intervals,
+ &mut bed3,
+ &FeatureOptions {
+ kind: match args.feature {
+ Feature::Cds => FeatureKind::Cds,
+ Feature::Exon => FeatureKind::Exons,
+ Feature::Intron => FeatureKind::Introns,
+ Feature::Utr => FeatureKind::Utr,
+ },
+ bed_type: 3,
+ additional_fields: None,
+ unique: true,
+ },
+ )
+ .map_err(|err| CliError::Message(err.to_string()))?;
+
+ if summary.records_in > 0 && summary.intervals_out == 0 {
+ return Err(no_feature_intervals(args));
+ }
+
+ let chroms = index_intervals(parse_bed3(&bed3)?)?;
+ if summary.records_in > 0 && chroms.values().all(|chrom| chrom.total_bases == 0) {
+ return Err(no_feature_intervals(args));
+ }
+
+ Ok(FeatureSpace {
+ chroms,
+ records_in: summary.records_in,
+ intervals_out: summary.intervals_out,
+ })
+}
+
+fn no_feature_intervals(args: &CoverageArgs) -> CliError {
+ CliError::Message(format!(
+ "{} yielded no {} intervals: BED3/6 carries no CDS bounds or exon structure",
+ args.intervals.display(),
+ args.feature
+ ))
+}
+
+fn parse_bed3(bytes: &[u8]) -> Result, Vec>, CliError> {
+ let mut by_chrom = BTreeMap::, Vec>::new();
+ for (index, line) in bytes.split(|byte| *byte == b'\n').enumerate() {
+ if line.is_empty() {
+ continue;
+ }
+ let mut fields = line.split(|byte| *byte == b'\t');
+ let chrom = fields.next().filter(|field| !field.is_empty());
+ let start = fields.next().and_then(parse_u64);
+ let end = fields.next().and_then(parse_u64);
+ if chrom.is_none() || start.is_none() || end.is_none() || fields.next().is_some() {
+ return Err(extracted_bed_error(index + 1));
+ }
+ let (chrom, start, end) = (chrom.unwrap(), start.unwrap(), end.unwrap());
+ if start >= end {
+ return Err(extracted_bed_error(index + 1));
+ }
+ by_chrom
+ .entry(chrom.to_vec())
+ .or_default()
+ .push(Interval { start, end });
+ }
+ Ok(by_chrom)
+}
+
+fn parse_u64(bytes: &[u8]) -> Option {
+ std::str::from_utf8(bytes).ok()?.parse().ok()
+}
+
+fn extracted_bed_error(line: usize) -> CliError {
+ CliError::Message(format!(
+ "genepred emitted an invalid BED3 interval on line {line}"
+ ))
+}
+
+fn index_intervals(
+ raw: BTreeMap, Vec>,
+) -> Result, ChromCoverage>, CliError> {
+ let entries = raw.into_iter().collect::>();
+
+ #[cfg(feature = "parallel")]
+ let indexed = entries
+ .into_par_iter()
+ .map(index_chrom)
+ .collect::, _>>()?;
+
+ #[cfg(not(feature = "parallel"))]
+ let indexed = entries
+ .into_iter()
+ .map(index_chrom)
+ .collect::, _>>()?;
+
+ Ok(indexed.into_iter().collect())
+}
+
+fn index_chrom(
+ (chrom, intervals): (Vec, Vec),
+) -> Result<(Vec, ChromCoverage), CliError> {
+ let mut offset = 0usize;
+ let mut total_bases = 0u64;
+ let mut indexed = Vec::new();
+
+ for interval in merge_intervals(intervals) {
+ let length = interval.end - interval.start;
+ indexed.push(IndexedInterval {
+ start: interval.start,
+ end: interval.end,
+ offset,
+ });
+ offset = offset
+ .checked_add(usize::try_from(length).map_err(|_| feature_space_too_large(&chrom))?)
+ .ok_or_else(|| feature_space_too_large(&chrom))?;
+ total_bases = total_bases
+ .checked_add(length)
+ .ok_or_else(|| feature_space_too_large(&chrom))?;
+ }
+
+ let words = offset.div_ceil(u64::BITS as usize);
+ let mut bitmap = Vec::new();
+ bitmap
+ .try_reserve_exact(words)
+ .map_err(|_| feature_space_too_large(&chrom))?;
+ bitmap.extend((0..words).map(|_| AtomicU64::new(0)));
+
+ Ok((
+ chrom,
+ ChromCoverage {
+ intervals: indexed,
+ bitmap,
+ total_bases,
+ },
+ ))
+}
+
+fn feature_space_too_large(chrom: &[u8]) -> CliError {
+ CliError::Message(format!(
+ "feature space for {} is too large to index",
+ String::from_utf8_lossy(chrom)
+ ))
+}
+
+fn merge_intervals(mut intervals: Vec) -> Vec {
+ intervals.sort_unstable_by_key(|interval| (interval.start, interval.end));
+ let mut intervals = intervals.into_iter();
+ let Some(mut current) = intervals.next() else {
+ return Vec::new();
+ };
+
+ let mut merged = Vec::new();
+ for interval in intervals {
+ if interval.start <= current.end {
+ current.end = current.end.max(interval.end);
+ } else {
+ merged.push(current);
+ current = interval;
+ }
+ }
+ merged.push(current);
+ merged
+}
+
+fn collect_input_paths(args: &CoverageArgs) -> Result, CliError> {
+ if let Some(paths) = &args.chains {
+ return Ok(paths.clone());
+ }
+
+ let Some(list_path) = &args.file else {
+ return Ok(Vec::new());
+ };
+ let file = File::open(list_path)?;
+ let mut reader = BufReader::with_capacity(INPUT_LIST_BUFFER_CAPACITY, file);
+ let mut line = String::new();
+ let mut paths = Vec::new();
+
+ while reader.read_line(&mut line)? != 0 {
+ let path = line.trim();
+ if !path.is_empty() {
+ paths.push(PathBuf::from(path));
+ }
+ line.clear();
+ }
+
+ if paths.is_empty() {
+ return Err(CliError::Message(format!(
+ "{} does not list any input chain files",
+ list_path.display()
+ )));
+ }
+ Ok(paths)
+}
+
+fn mark_inputs(
+ stdin: &mut R,
+ inputs: &[PathBuf],
+ coverage: &BTreeMap, ChromCoverage>,
+ side: BedSide,
+) -> Result {
+ if inputs.is_empty() {
+ let mut bytes = Vec::new();
+ stdin.read_to_end(&mut bytes)?;
+ return mark_reader(read_chain_bytes(bytes)?, coverage, side);
+ }
+
+ let mut chains = 0u64;
+ for path in inputs {
+ log::debug!("measuring {}", path.display());
+ chains += mark_reader(read_chain_path(path)?, coverage, side)?;
+ }
+ Ok(chains)
+}
+
+#[cfg(feature = "parallel")]
+fn read_chain_path(path: &Path) -> Result, CliError> {
+ Ok(Reader::::from_path_parallel(path)?)
+}
+
+#[cfg(not(feature = "parallel"))]
+fn read_chain_path(path: &Path) -> Result, CliError> {
+ Ok(Reader::::from_path(path)?)
+}
+
+#[cfg(feature = "parallel")]
+fn read_chain_bytes(bytes: Vec) -> Result, CliError> {
+ Ok(Reader::::from_owned_bytes_parallel(bytes)?)
+}
+
+#[cfg(not(feature = "parallel"))]
+fn read_chain_bytes(bytes: Vec) -> Result, CliError> {
+ Ok(Reader::::from_owned_bytes(bytes)?)
+}
+
+fn mark_reader(
+ reader: Reader,
+ coverage: &BTreeMap, ChromCoverage>,
+ side: BedSide,
+) -> Result {
+ let count = reader.len() as u64;
+
+ #[cfg(feature = "parallel")]
+ {
+ // ponytail: whole-file parse held in RAM; batch through StreamingReader if peak RSS matters.
+ reader
+ .chains()
+ .collect::>()
+ .into_par_iter()
+ .try_for_each(|chain| mark_chain(coverage, chain, side))?;
+ }
+
+ #[cfg(not(feature = "parallel"))]
+ reader
+ .chains()
+ .try_for_each(|chain| mark_chain(coverage, chain, side))?;
+
+ Ok(count)
+}
+
+fn mark_chain(
+ coverage: &BTreeMap, ChromCoverage>,
+ chain: &Chain,
+ side: BedSide,
+) -> Result<(), CliError> {
+ let name = match side {
+ BedSide::Reference => chain.reference_name.as_bytes(),
+ BedSide::Query => chain.query_name.as_bytes(),
+ };
+ let Some(chrom) = coverage.get(name) else {
+ return Ok(());
+ };
+ validate_chain(chain)?;
+
+ let mut reference = u64::from(chain.reference_start);
+ let mut query = u64::from(chain.query_start);
+ for block in chain.blocks.as_slice() {
+ let size = u64::from(block.size);
+ let reference_end = reference + size;
+ let query_end = query + size;
+ let (start, end) = match side {
+ BedSide::Reference => (reference, reference_end),
+ BedSide::Query => query_block(chain, query, query_end)?,
+ };
+ mark_overlap(chrom, start, end);
+
+ reference = reference_end + u64::from(block.gap_reference);
+ query = query_end + u64::from(block.gap_query);
+ }
+
+ if reference != u64::from(chain.reference_end) || query != u64::from(chain.query_end) {
+ return Err(chain_error(
+ chain,
+ "chain block coordinates do not match header end",
+ ));
+ }
+ Ok(())
+}
+
+fn validate_chain(chain: &Chain) -> Result<(), CliError> {
+ if chain.reference_strand != Strand::Plus {
+ return Err(chain_error(
+ chain,
+ "chain target strand must be + for canonical chain coordinates",
+ ));
+ }
+ if chain.reference_start >= chain.reference_end || chain.query_start >= chain.query_end {
+ return Err(chain_error(chain, "chain span is empty or inverted"));
+ }
+ if chain.reference_end > chain.reference_size || chain.query_end > chain.query_size {
+ return Err(chain_error(chain, "chain span exceeds its sequence size"));
+ }
+ if chain.blocks.as_slice().iter().any(|block| block.size == 0) {
+ return Err(chain_error(
+ chain,
+ "chain contains an empty alignment block",
+ ));
+ }
+ Ok(())
+}
+
+fn query_block(
+ chain: &Chain,
+ oriented_start: u64,
+ oriented_end: u64,
+) -> Result<(u64, u64), CliError> {
+ match chain.query_strand {
+ Strand::Plus => Ok((oriented_start, oriented_end)),
+ Strand::Minus => {
+ let size = u64::from(chain.query_size);
+ let start = size
+ .checked_sub(oriented_end)
+ .ok_or_else(|| chain_error(chain, "query block exceeds its sequence size"))?;
+ let end = size
+ .checked_sub(oriented_start)
+ .ok_or_else(|| chain_error(chain, "query block exceeds its sequence size"))?;
+ Ok((start, end))
+ }
+ }
+}
+
+fn chain_error(chain: &Chain, message: &str) -> CliError {
+ CliError::Message(format!("chain {}: {message}", chain.id))
+}
+
+fn mark_overlap(coverage: &ChromCoverage, block_start: u64, block_end: u64) {
+ let mut index = coverage
+ .intervals
+ .partition_point(|interval| interval.end <= block_start);
+
+ while index < coverage.intervals.len() && coverage.intervals[index].start < block_end {
+ let interval = coverage.intervals[index];
+ let overlap_start = block_start.max(interval.start);
+ let overlap_end = block_end.min(interval.end);
+ if overlap_start < overlap_end {
+ set_range(
+ &coverage.bitmap,
+ interval.offset + (overlap_start - interval.start) as usize,
+ interval.offset + (overlap_end - interval.start) as usize,
+ );
+ }
+ index += 1;
+ }
+}
+
+fn set_range(bits: &[AtomicU64], start: usize, end: usize) {
+ if start >= end {
+ return;
+ }
+ debug_assert!(end <= bits.len() * u64::BITS as usize);
+
+ let first_word = start / u64::BITS as usize;
+ let last_word = (end - 1) / u64::BITS as usize;
+ let first_mask = u64::MAX << (start % u64::BITS as usize);
+ let end_bit = end % u64::BITS as usize;
+ let last_mask = if end_bit == 0 {
+ u64::MAX
+ } else {
+ (1u64 << end_bit) - 1
+ };
+
+ if first_word == last_word {
+ bits[first_word].fetch_or(first_mask & last_mask, Ordering::Relaxed);
+ return;
+ }
+
+ bits[first_word].fetch_or(first_mask, Ordering::Relaxed);
+ for word in &bits[first_word + 1..last_word] {
+ word.fetch_or(u64::MAX, Ordering::Relaxed);
+ }
+ bits[last_word].fetch_or(last_mask, Ordering::Relaxed);
+}
+
+fn covered_bases(coverage: &ChromCoverage) -> u64 {
+ coverage
+ .bitmap
+ .iter()
+ .map(|word| u64::from(word.load(Ordering::Relaxed).count_ones()))
+ .sum()
+}
+
+fn write_report(
+ writer: &mut W,
+ coverage: &BTreeMap, ChromCoverage>,
+) -> Result<(u64, u64), CliError> {
+ let entries = coverage.iter().collect::>();
+
+ #[cfg(feature = "parallel")]
+ let covered = entries
+ .par_iter()
+ .map(|(_, chrom)| covered_bases(chrom))
+ .collect::>();
+
+ #[cfg(not(feature = "parallel"))]
+ let covered = entries
+ .iter()
+ .map(|(_, chrom)| covered_bases(chrom))
+ .collect::>();
+
+ writeln!(
+ writer,
+ "chrom\ttotal_bases\tcovered_bases\tcoverage_fraction"
+ )?;
+ let mut total_bases = 0u64;
+ let mut total_covered = 0u64;
+ for ((name, chrom), covered_bases) in entries.into_iter().zip(covered) {
+ writer.write_all(name)?;
+ writeln!(
+ writer,
+ "\t{}\t{}\t{:.6}",
+ chrom.total_bases,
+ covered_bases,
+ fraction(covered_bases, chrom.total_bases)
+ )?;
+ total_bases += chrom.total_bases;
+ total_covered += covered_bases;
+ }
+ writeln!(
+ writer,
+ "total\t{total_bases}\t{total_covered}\t{:.6}",
+ fraction(total_covered, total_bases)
+ )?;
+ Ok((total_bases, total_covered))
+}
+
+fn fraction(covered: u64, total: u64) -> f64 {
+ if total == 0 {
+ 0.0
+ } else {
+ covered as f64 / total as f64
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+ use std::io::{BufReader, Cursor};
+ use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
+
+ static NEXT_TEMP_ID: AtomicUsize = AtomicUsize::new(0);
+
+ struct TempPath(PathBuf);
+
+ impl TempPath {
+ fn with_contents(suffix: &str, contents: &[u8]) -> Self {
+ let id = NEXT_TEMP_ID.fetch_add(1, AtomicOrdering::Relaxed);
+ let path = std::env::temp_dir().join(format!(
+ "chaintools-coverage-test-{}-{id}.{suffix}",
+ std::process::id()
+ ));
+ fs::write(&path, contents).expect("write temp file");
+ Self(path)
+ }
+ }
+
+ impl Drop for TempPath {
+ fn drop(&mut self) {
+ let _ = fs::remove_file(&self.0);
+ }
+ }
+
+ fn args(intervals: &TempPath, side: BedSide, feature: Feature) -> CoverageArgs {
+ CoverageArgs {
+ chains: None,
+ file: None,
+ side,
+ intervals: intervals.0.clone(),
+ feature,
+ }
+ }
+
+ fn run_stdin(
+ intervals: &TempPath,
+ side: BedSide,
+ feature: Feature,
+ chains: &str,
+ ) -> Result {
+ let mut stdin = BufReader::new(Cursor::new(chains.as_bytes()));
+ let mut stdout = Vec::new();
+ let mut stderr = Vec::new();
+ run(
+ args(intervals, side, feature),
+ &mut stdin,
+ &mut stdout,
+ &mut stderr,
+ )?;
+ Ok(String::from_utf8(stdout).expect("coverage output is UTF-8"))
+ }
+
+ #[test]
+ fn merge_combines_overlapping_and_bookended_intervals() {
+ assert_eq!(
+ merge_intervals(vec![
+ Interval {
+ start: 400,
+ end: 500,
+ },
+ Interval {
+ start: 100,
+ end: 200,
+ },
+ Interval {
+ start: 250,
+ end: 300,
+ },
+ Interval {
+ start: 150,
+ end: 250,
+ },
+ ]),
+ vec![
+ Interval {
+ start: 100,
+ end: 300,
+ },
+ Interval {
+ start: 400,
+ end: 500,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn bitmap_ranges_are_wordwise_and_idempotent() {
+ let bits = (0..2).map(|_| AtomicU64::new(0)).collect::>();
+ set_range(&bits, 1, 5);
+ set_range(&bits, 1, 5);
+ set_range(&bits, 60, 68);
+ assert_eq!(
+ bits.iter()
+ .map(|word| word.load(Ordering::Relaxed).count_ones())
+ .sum::(),
+ 12
+ );
+
+ set_range(&bits, 64, 128);
+ assert_eq!(
+ bits.iter()
+ .map(|word| word.load(Ordering::Relaxed).count_ones())
+ .sum::(),
+ 72
+ );
+ }
+
+ #[test]
+ fn aligned_blocks_do_not_cover_the_chain_gap() {
+ let intervals = TempPath::with_contents("bed", b"chr1\t140\t260\n");
+ let chains = "chain 100 chr1 1000 + 110 290 q1 1000 + 0 70 1\n\
+ 30 120 10\n\
+ 30\n\n";
+
+ assert_eq!(
+ run_stdin(&intervals, BedSide::Reference, Feature::Exon, chains).unwrap(),
+ "chrom\ttotal_bases\tcovered_bases\tcoverage_fraction\n\
+ chr1\t120\t0\t0.000000\n\
+ total\t120\t0\t0.000000\n"
+ );
+ }
+
+ #[test]
+ fn duplicate_chains_do_not_double_count_and_total_is_reported() {
+ let intervals = TempPath::with_contents(
+ "bed",
+ b"chr1\t100\t300\ttx1\t0\t+\t120\t280\t0,0,0\t2\t50,50,\t0,150,\n\
+ chr2\t10\t20\ttx2\t0\t+\t10\t20\t0,0,0\t1\t10,\t0,\n",
+ );
+ let chain = "chain 100 chr1 1000 + 110 290 q1 1000 + 0 70 1\n\
+ 30 120 10\n\
+ 30\n\n";
+ let chains = format!("{chain}{chain}");
+
+ assert_eq!(
+ run_stdin(&intervals, BedSide::Reference, Feature::Exon, &chains).unwrap(),
+ "chrom\ttotal_bases\tcovered_bases\tcoverage_fraction\n\
+ chr1\t100\t60\t0.600000\n\
+ chr2\t10\t0\t0.000000\n\
+ total\t110\t60\t0.545455\n"
+ );
+ }
+
+ #[test]
+ fn minus_query_is_marked_in_forward_genomic_coordinates() {
+ let intervals = TempPath::with_contents("bed", b"qry1\t435\t490\n");
+ let chains = "chain 100 chr1 1000 + 100 160 qry1 500 - 10 65 7\n\
+ 20 10 5\n\
+ 30\n\n";
+
+ assert_eq!(
+ run_stdin(&intervals, BedSide::Query, Feature::Exon, chains).unwrap(),
+ "chrom\ttotal_bases\tcovered_bases\tcoverage_fraction\n\
+ qry1\t55\t50\t0.909091\n\
+ total\t55\t50\t0.909091\n"
+ );
+ }
+
+ #[test]
+ fn bed3_cds_fails_loudly() {
+ let intervals = TempPath::with_contents("bed", b"chr1\t0\t10\n");
+ let err = run_stdin(&intervals, BedSide::Reference, Feature::Cds, "")
+ .expect_err("BED3 has no CDS bounds");
+ assert!(err.to_string().contains("yielded no cds intervals"));
+ }
+
+ #[test]
+ fn malformed_chain_block_span_is_rejected() {
+ let intervals = TempPath::with_contents("bed", b"chr1\t0\t10\n");
+ let chains = "chain 1 chr1 100 + 0 10 q 100 + 0 10 9\n5\n\n";
+ let err = run_stdin(&intervals, BedSide::Reference, Feature::Exon, chains)
+ .expect_err("block span must match the header");
+ assert!(
+ err.to_string()
+ .contains("chain block coordinates do not match header end")
+ );
+ }
+
+ #[test]
+ fn minus_reference_strand_is_rejected() {
+ let intervals = TempPath::with_contents("bed", b"chr1\t0\t10\n");
+ let chains = "chain 1 chr1 100 - 0 10 q 100 + 0 10 9\n10\n\n";
+ let err = run_stdin(&intervals, BedSide::Reference, Feature::Exon, chains)
+ .expect_err("minus target strand is invalid");
+ assert!(err.to_string().contains("chain target strand must be +"));
+ }
+}
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 4e4aa90..06e4311 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -5,6 +5,7 @@ pub mod anti_repeat;
pub mod bed;
pub mod compare;
pub mod convert;
+pub mod coverage;
pub mod filter;
pub mod liftover;
pub mod merge;
@@ -72,6 +73,8 @@ enum Command {
Compare(compare::CompareArgs),
#[command(about = "Convert chain files to VCF, MAF, or BAM")]
Convert(convert::ConvertArgs),
+ #[command(about = "Measure annotation feature coverage by aligned chain blocks")]
+ Coverage(coverage::CoverageArgs),
#[command(about = "Filter chain files")]
Filter(filter::FilterArgs),
#[command(about = "Map BED coordinates through a chain (reference/target -> query)")]
@@ -97,6 +100,7 @@ impl std::fmt::Display for Command {
Command::Bed(_) => f.write_str("bed"),
Command::Compare(_) => f.write_str("compare"),
Command::Convert(_) => f.write_str("convert"),
+ Command::Coverage(_) => f.write_str("coverage"),
Command::Filter(_) => f.write_str("filter"),
Command::Liftover(_) => f.write_str("liftover"),
Command::Merge(_) => f.write_str("merge"),
@@ -207,6 +211,7 @@ where
Command::Bed(args) => bed::run(args, stdin, stdout, stderr),
Command::Compare(args) => compare::run(args, stdin, stdout, stderr),
Command::Convert(args) => convert::run(args, stdin, stdout, stderr),
+ Command::Coverage(args) => coverage::run(args, stdin, stdout, stderr),
Command::Filter(args) => filter::run(args, stdin, stdout, stderr),
Command::Liftover(args) => liftover::run(args, stdin, stdout, stderr),
Command::Merge(args) => merge::run(args, stdin, stdout, stderr),