use anyhow::{Context, Result};
use noodles::bam;
use noodles::core::Region;
use noodles::sam::alignment::Record;
use noodles::sam::alignment::record::cigar::Cigar as SamCigar;
use noodles::sam::alignment::record::data::field::Tag as SamTag;
pub use crate::cigar::ToCigarOps;
use crate::paf::{PafIndexEntry, PafRecord, read_paf_record_from_reader};
use crate::utils::{
ReadCuts, calculate_qscore, extract_from_fasta_coords_reader, get_read_cuts, read_pos_at_ref,
revcomp,
};
#[derive(Debug, Clone)]
pub struct BamConfig {
pub min_mapq: u8,
pub include_secondary: bool,
pub include_supplementary: bool,
pub partial: bool,
pub min_partial_coverage: f64,
pub min_region_quality: f64,
}
impl Default for BamConfig {
fn default() -> Self {
Self {
min_mapq: 0,
include_secondary: false,
include_supplementary: false,
partial: false,
min_partial_coverage: 0.0,
min_region_quality: 0.0,
}
}
}
pub type BamRead = (String, Vec<u8>, String, usize, usize, u8);
pub type PafRead = (String, String, usize, usize, char, u8);
#[derive(Debug, Clone, Copy)]
pub struct StitchConfig {
pub enabled: bool,
pub max_gap: usize,
}
impl Default for StitchConfig {
fn default() -> Self {
Self {
enabled: false,
max_gap: 10_000,
}
}
}
fn find_stitch_chain(
records: &[PafRecord],
desired_start: usize,
desired_end: usize,
max_gap: usize,
) -> Option<Vec<usize>> {
let mut groups: std::collections::HashMap<(&str, char), Vec<usize>> =
std::collections::HashMap::new();
let mut key_order: Vec<(&str, char)> = Vec::new();
for (i, r) in records.iter().enumerate() {
let key = (r.query_name.as_str(), r.strand);
if !groups.contains_key(&key) {
key_order.push(key);
}
groups.entry(key).or_default().push(i);
}
for key in &key_order {
let idxs = groups
.get_mut(key)
.expect("key_order only holds keys already inserted into groups");
idxs.sort_by_key(|&i| records[i].target_start);
let mut run_start = 0usize;
for i in 1..idxs.len() {
let prev = &records[idxs[i - 1]];
let cur = &records[idxs[i]];
let gap = cur.target_start.saturating_sub(prev.target_end);
if gap > max_gap {
if let Some(span) =
winning_run(records, &idxs[run_start..i], desired_start, desired_end)
{
return Some(span);
}
run_start = i;
}
}
if let Some(span) = winning_run(records, &idxs[run_start..], desired_start, desired_end) {
return Some(span);
}
}
None
}
fn winning_run(
records: &[PafRecord],
run_idxs: &[usize],
desired_start: usize,
desired_end: usize,
) -> Option<Vec<usize>> {
if run_idxs.len() < 2 {
return None;
}
let first = &records[run_idxs[0]];
let last = &records[run_idxs[run_idxs.len() - 1]];
if first.target_start <= desired_start && last.target_end >= desired_end {
Some(run_idxs.to_vec())
} else {
None
}
}
fn build_stitched_read<R>(
records: &[PafRecord],
chain: &[usize],
desired_start: usize,
desired_end: usize,
fasta_reader: &mut noodles::fasta::io::IndexedReader<R>,
debug: bool,
) -> Result<Option<PafRead>>
where
R: std::io::BufRead + std::io::Seek,
{
let first = &records[chain[0]];
let last = &records[chain[chain.len() - 1]];
let (Some(first_cigar_str), Some(last_cigar_str)) = (&first.cigar, &last.cigar) else {
return Ok(None);
};
let (Ok(first_ops), Ok(last_ops)) = (
first_cigar_str.as_str().to_cigar_ops(),
last_cigar_str.as_str().to_cigar_ops(),
) else {
eprintln!("Warning: invalid CIGAR string in a stitch chain member, skipping stitch");
return Ok(None);
};
let (Some(read_pos_first), Some(read_pos_last)) = (
read_pos_at_ref(&first_ops, first.target_start, desired_start),
read_pos_at_ref(&last_ops, last.target_start, desired_end),
) else {
eprintln!("Warning: stitched chain produced no valid overlap, skipping");
return Ok(None);
};
let strand = first.strand;
let (query_start, query_end) = if strand == '+' {
(
first.query_start + read_pos_first,
last.query_start + read_pos_last,
)
} else {
(
last.query_end.saturating_sub(read_pos_last),
first.query_end.saturating_sub(read_pos_first),
)
};
if query_start > query_end {
eprintln!(
"Warning: stitched chain produced invalid coordinates (start {} > end {}), skipping",
query_start, query_end
);
return Ok(None);
}
if debug {
eprintln!(
"Stitched {} chained record(s): {}:{}-{} (strand {})",
chain.len(),
first.query_name,
query_start,
query_end,
strand
);
}
let sequence = match extract_from_fasta_coords_reader(
fasta_reader,
&first.query_name,
query_start,
query_end,
) {
Ok(seq) => seq,
Err(e) => {
eprintln!(
"Warning: failed to extract stitched sequence for {}: {:#}, skipping",
first.query_name, e
);
return Ok(None);
}
};
let sequence = if strand == '-' {
revcomp(&sequence)
} else {
sequence
};
let hap = first.haplotype.unwrap_or(0);
Ok(Some((
sequence,
first.query_name.clone(),
query_start,
query_end,
strand,
hap,
)))
}
fn resolve_cuts(
read_cuts: &ReadCuts,
config: &BamConfig,
spans: bool,
) -> Option<(usize, usize, usize, usize)> {
if read_cuts.read_end == 0 {
return None;
}
if !config.partial && !spans {
return None;
}
Some((
read_cuts.read_start,
read_cuts.read_end,
read_cuts.ref_start,
read_cuts.ref_end,
))
}
fn passes_min_partial_coverage(
config: &BamConfig,
desired_start: usize,
desired_end: usize,
ref_start: usize,
ref_end: usize,
) -> bool {
if config.min_partial_coverage <= 0.0 {
return true;
}
let desired_len = desired_end.saturating_sub(desired_start);
if desired_len == 0 {
return true;
}
let covered_len = ref_end.saturating_sub(ref_start);
(covered_len as f64) >= config.min_partial_coverage * (desired_len as f64)
}
pub fn get_bam_reads<R>(
config: &BamConfig,
query: bam::io::reader::Query<R>,
region: &Region,
lflank: usize,
rflank: usize,
) -> Result<(Vec<BamRead>, usize)>
where
R: noodles::bgzf::io::BufRead + noodles::bgzf::io::Seek,
{
let mut results: Vec<BamRead> = Vec::new();
let mut candidates_seen: usize = 0;
for result in query.records() {
let record = result.context("failed to read BAM record")?;
candidates_seen += 1;
let map_quality = record.mapping_quality().map(u8::from).unwrap_or(255);
if map_quality < config.min_mapq {
continue;
}
let flags = record.flags();
if flags.is_secondary() && !config.include_secondary {
continue;
}
if flags.is_supplementary() && !config.include_supplementary {
continue;
}
let align_start = usize::from(
record
.alignment_start()
.ok_or_else(|| anyhow::anyhow!("BAM record has no alignment start"))?
.context("invalid alignment start position")?,
);
let align_end = usize::from(
record
.alignment_end()
.ok_or_else(|| anyhow::anyhow!("BAM record has no alignment end"))?
.context("invalid alignment end position")?,
);
let name_bytes: &[u8] = record
.name()
.ok_or_else(|| anyhow::anyhow!("BAM record has no name"))?
.as_ref();
let name = String::from_utf8(name_bytes.to_vec())
.context("BAM record name contains invalid UTF-8")?;
let seq = record.sequence();
let i_seq: Vec<u8> = seq.iter().collect();
let i_qual = record
.quality_scores()
.as_ref()
.iter()
.map(|&score| score + 33)
.collect::<Vec<_>>();
let quality_scores_str: String = String::from_utf8_lossy(&i_qual).into_owned();
let cigar = record
.cigar()
.to_cigar_ops()
.context("invalid CIGAR in BAM record")?;
let region_start = region
.interval()
.start()
.map(usize::from)
.ok_or_else(|| anyhow::anyhow!("BED region has unbounded start"))?;
let region_end = region
.interval()
.end()
.map(usize::from)
.ok_or_else(|| anyhow::anyhow!("BED region has unbounded end"))?;
let desired_start = region_start.saturating_sub(lflank);
let desired_end = region_end + rflank;
if (align_end < desired_start) || (align_start > desired_end) {
continue;
}
let align_end_excl = align_end + 1;
let read_cuts: ReadCuts = get_read_cuts(
&cigar,
align_start,
align_end_excl,
desired_start,
desired_end,
);
let spans = align_start <= region_start && align_end_excl >= region_end;
let Some((read_start, read_end, ref_start, ref_end)) =
resolve_cuts(&read_cuts, config, spans)
else {
continue;
};
if !passes_min_partial_coverage(config, desired_start, desired_end, ref_start, ref_end) {
continue;
}
let subseq = i_seq[read_start..read_end].to_vec();
let subqual: String = quality_scores_str[read_start..read_end].to_string();
if config.min_region_quality > 0.0 && calculate_qscore(&subqual) < config.min_region_quality
{
continue;
}
let hap: u8 = record
.data()
.get(b"HP")
.and_then(|v| v.ok())
.and_then(|v| v.as_int())
.map(|i| i as u8)
.unwrap_or(0);
let ref_start = ref_start.saturating_sub(1);
let ref_end = ref_end.saturating_sub(1);
results.push((name, subseq, subqual, ref_start, ref_end, hap));
}
Ok((results, candidates_seen))
}
pub fn get_cram_reads(
config: &BamConfig,
query: impl Iterator<Item = std::io::Result<noodles::sam::alignment::RecordBuf>>,
region: &Region,
lflank: usize,
rflank: usize,
) -> Result<(Vec<BamRead>, usize)> {
use noodles::sam::alignment::record_buf::data::field::Value as RecordBufValue;
let mut results: Vec<BamRead> = Vec::new();
let mut candidates_seen: usize = 0;
let region_start = region
.interval()
.start()
.map(usize::from)
.ok_or_else(|| anyhow::anyhow!("BED region has unbounded start"))?;
let region_end = region
.interval()
.end()
.map(usize::from)
.ok_or_else(|| anyhow::anyhow!("BED region has unbounded end"))?;
for result in query {
let record = result.context("failed to read CRAM record")?;
candidates_seen += 1;
let map_quality = record.mapping_quality().map(u8::from).unwrap_or(255);
if map_quality < config.min_mapq {
continue;
}
let flags = record.flags();
if flags.is_secondary() && !config.include_secondary {
continue;
}
if flags.is_supplementary() && !config.include_supplementary {
continue;
}
let align_start = usize::from(
record
.alignment_start()
.ok_or_else(|| anyhow::anyhow!("CRAM record has no alignment start"))?,
);
let align_end = usize::from(
record
.alignment_end()
.ok_or_else(|| anyhow::anyhow!("CRAM record has no alignment end"))?,
);
let name_bytes: &[u8] = record
.name()
.ok_or_else(|| anyhow::anyhow!("CRAM record has no name"))?
.as_ref();
let name = String::from_utf8(name_bytes.to_vec())
.context("CRAM record name contains invalid UTF-8")?;
let i_seq: Vec<u8> = record.sequence().as_ref().to_vec();
let i_qual = record
.quality_scores()
.as_ref()
.iter()
.map(|&score| score + 33)
.collect::<Vec<_>>();
let quality_scores_str: String = String::from_utf8_lossy(&i_qual).into_owned();
let cigar = (record.cigar() as &dyn SamCigar)
.to_cigar_ops()
.context("invalid CIGAR in CRAM record")?;
let desired_start = region_start.saturating_sub(lflank);
let desired_end = region_end + rflank;
if (align_end < desired_start) || (align_start > desired_end) {
continue;
}
let align_end_excl = align_end + 1;
let read_cuts = get_read_cuts(
&cigar,
align_start,
align_end_excl,
desired_start,
desired_end,
);
let spans = align_start <= region_start && align_end_excl >= region_end;
let Some((read_start, read_end, ref_start, ref_end)) =
resolve_cuts(&read_cuts, config, spans)
else {
continue;
};
if !passes_min_partial_coverage(config, desired_start, desired_end, ref_start, ref_end) {
continue;
}
let subseq = i_seq[read_start..read_end].to_vec();
let subqual: String = quality_scores_str[read_start..read_end].to_string();
if config.min_region_quality > 0.0 && calculate_qscore(&subqual) < config.min_region_quality
{
continue;
}
let hp_tag = SamTag::new(b'H', b'P');
let hap: u8 = record
.data()
.get(&hp_tag)
.and_then(|v| {
if let RecordBufValue::Int32(i) = v {
Some(*i as u8)
} else {
v.as_int().map(|i| i as u8)
}
})
.unwrap_or(0);
let ref_start = ref_start.saturating_sub(1);
let ref_end = ref_end.saturating_sub(1);
results.push((name, subseq, subqual, ref_start, ref_end, hap));
}
Ok((results, candidates_seen))
}
#[allow(clippy::too_many_arguments)]
pub fn get_paf_reads<R>(
paf_reader: &mut std::io::BufReader<std::fs::File>,
fasta_reader: &mut noodles::fasta::io::IndexedReader<R>,
entries: &[&PafIndexEntry],
region_start: usize,
region_end: usize,
lflank: usize,
rflank: usize,
stitch: StitchConfig,
debug: bool,
) -> Result<Vec<PafRead>>
where
R: std::io::BufRead + std::io::Seek,
{
let mut results: Vec<PafRead> = Vec::new();
let mut records: Vec<PafRecord> = Vec::with_capacity(entries.len());
for entry in entries {
let paf_record = read_paf_record_from_reader(paf_reader, entry.offset)
.with_context(|| format!("failed to read PAF record at offset {}", entry.offset))?;
records.push(paf_record);
}
let desired_start = region_start.saturating_sub(lflank);
let desired_end = region_end + rflank;
let mut stitched_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
if stitch.enabled {
let already_covered = records
.iter()
.any(|r| r.target_start <= desired_start && r.target_end >= desired_end);
if !already_covered
&& let Some(chain) =
find_stitch_chain(&records, desired_start, desired_end, stitch.max_gap)
&& let Some(stitched) = build_stitched_read(
&records,
&chain,
desired_start,
desired_end,
fasta_reader,
debug,
)?
{
results.push(stitched);
stitched_indices.extend(chain.iter().copied());
}
}
for (i, paf_record) in records.iter().enumerate() {
if stitched_indices.contains(&i) {
continue;
}
if paf_record.target_start > region_start {
eprintln!("Warning: Alignment starts after region start, may be incomplete");
}
if paf_record.target_end < region_end {
eprintln!("Warning: Alignment ends before region end, may be incomplete");
}
let eff_start = desired_start.max(paf_record.target_start);
let eff_end = desired_end.min(paf_record.target_end);
let cigar_str = match &paf_record.cigar {
Some(s) => s.clone(),
None => {
eprintln!("Warning: PAF record has no CIGAR (cg:Z: tag), skipping");
continue;
}
};
let cigar_ops = match cigar_str.as_str().to_cigar_ops() {
Ok(ops) => ops,
Err(e) => {
eprintln!(
"Warning: invalid CIGAR string in PAF record: {:#}, skipping",
e
);
continue;
}
};
let (Some(read_start), Some(read_end)) = (
read_pos_at_ref(&cigar_ops, paf_record.target_start, eff_start),
read_pos_at_ref(&cigar_ops, paf_record.target_start, eff_end),
) else {
eprintln!("Warning: No valid overlap found, skipping");
continue;
};
if read_start > read_end {
eprintln!(
"Warning: Invalid coordinates (start {} > end {}), skipping",
read_start, read_end
);
continue;
}
let (query_start, query_end) = if paf_record.strand == '+' {
(
paf_record.query_start + read_start,
paf_record.query_start + read_end,
)
} else {
(
paf_record.query_end.saturating_sub(read_end),
paf_record.query_end.saturating_sub(read_start),
)
};
if debug {
eprintln!(
"Query coords: {}:{}-{} (strand {})",
paf_record.query_name, query_start, query_end, paf_record.strand
);
}
let sequence = match extract_from_fasta_coords_reader(
fasta_reader,
&paf_record.query_name,
query_start,
query_end,
) {
Ok(seq) => seq,
Err(e) => {
eprintln!(
"Warning: failed to extract sequence for {}: {:#}, skipping",
paf_record.query_name, e
);
continue;
}
};
let sequence = if paf_record.strand == '-' {
revcomp(&sequence)
} else {
sequence
};
let hap = paf_record.haplotype.unwrap_or(0);
results.push((
sequence,
paf_record.query_name.clone(),
query_start,
query_end,
paf_record.strand,
hap,
));
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::BufReader;
#[test]
fn bam_config_default_has_no_filters() {
let c = BamConfig::default();
assert_eq!(c.min_mapq, 0);
assert!(!c.include_secondary);
assert!(!c.include_supplementary);
assert!(!c.partial);
assert_eq!(c.min_partial_coverage, 0.0);
assert_eq!(c.min_region_quality, 0.0);
}
#[test]
fn bam_config_custom_values() {
let c = BamConfig {
min_mapq: 20,
include_secondary: true,
include_supplementary: false,
partial: true,
min_partial_coverage: 0.5,
min_region_quality: 15.0,
};
assert_eq!(c.min_mapq, 20);
assert!(c.include_secondary);
assert!(!c.include_supplementary);
assert!(c.partial);
assert_eq!(c.min_partial_coverage, 0.5);
assert_eq!(c.min_region_quality, 15.0);
}
fn partial_config() -> BamConfig {
BamConfig {
partial: true,
..BamConfig::default()
}
}
fn cuts_of(read_start: usize, read_end: usize, ref_start: usize, ref_end: usize) -> ReadCuts {
ReadCuts {
read_start,
read_end,
ref_start,
ref_end,
softclip_lead_start: read_start,
softclip_trail_end: read_end,
}
}
#[test]
fn resolve_cuts_spanning_returns_read_cuts_unchanged() {
let read_cuts = cuts_of(10, 50, 100, 140);
let resolved = resolve_cuts(&read_cuts, &BamConfig::default(), true);
assert_eq!(resolved, Some((10, 50, 100, 140)));
}
#[test]
fn resolve_cuts_no_overlap_is_skipped_even_in_partial_mode() {
let read_cuts = cuts_of(10, 0, 100, 0);
assert_eq!(resolve_cuts(&read_cuts, &BamConfig::default(), false), None);
assert_eq!(resolve_cuts(&read_cuts, &partial_config(), false), None);
}
#[test]
fn resolve_cuts_non_partial_non_spanning_is_skipped() {
let read_cuts = cuts_of(0, 40, 110, 150);
assert_eq!(resolve_cuts(&read_cuts, &BamConfig::default(), false), None);
}
#[test]
fn resolve_cuts_partial_non_spanning_returns_covered_subspan() {
let read_cuts = cuts_of(0, 40, 110, 150);
assert_eq!(
resolve_cuts(&read_cuts, &partial_config(), false),
Some((0, 40, 110, 150))
);
}
fn min_coverage_config(min_partial_coverage: f64) -> BamConfig {
BamConfig {
min_partial_coverage,
..BamConfig::default()
}
}
#[test]
fn min_partial_coverage_zero_accepts_any_overlap() {
let config = min_coverage_config(0.0);
assert!(passes_min_partial_coverage(&config, 100, 200, 150, 151));
}
#[test]
fn min_partial_coverage_full_span_always_passes() {
let config = min_coverage_config(1.0);
assert!(passes_min_partial_coverage(&config, 100, 200, 100, 200));
}
#[test]
fn min_partial_coverage_below_threshold_is_rejected() {
let config = min_coverage_config(0.5);
assert!(!passes_min_partial_coverage(&config, 100, 200, 100, 140));
}
#[test]
fn min_partial_coverage_at_exactly_threshold_passes() {
let config = min_coverage_config(0.5);
assert!(passes_min_partial_coverage(&config, 100, 200, 100, 150));
}
#[test]
fn min_partial_coverage_above_threshold_passes() {
let config = min_coverage_config(0.5);
assert!(passes_min_partial_coverage(&config, 100, 200, 105, 195));
}
#[test]
fn min_partial_coverage_empty_window_always_passes() {
let config = min_coverage_config(0.9);
assert!(passes_min_partial_coverage(&config, 100, 100, 100, 100));
}
#[test]
fn get_paf_reads_reuses_readers_across_repeated_calls() {
let paf_line = "q1\t20\t0\t20\t+\tchr1\t1000\t100\t120\t20\t20\t60\tcg:Z:20M\n";
let mut paf_file = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut paf_file, paf_line.as_bytes()).unwrap();
let mut fasta_file = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut fasta_file, b">q1\nACGTACGTACGTACGTACGT\n").unwrap();
let fasta_index = noodles::fasta::fs::index(fasta_file.path()).unwrap();
let mut fasta_reader = noodles::fasta::io::indexed_reader::Builder::default()
.set_index(fasta_index)
.build_from_path(fasta_file.path())
.unwrap();
let mut paf_reader = BufReader::new(File::open(paf_file.path()).unwrap());
let entry = PafIndexEntry {
offset: 0,
target_start: 100,
target_end: 120,
};
let entries = [&entry];
let reads = get_paf_reads(
&mut paf_reader,
&mut fasta_reader,
&entries,
105,
110,
0,
0,
StitchConfig::default(),
false,
)
.unwrap();
assert_eq!(reads.len(), 1);
let (sequence, query_name, query_start, query_end, strand, hap) = &reads[0];
assert_eq!(sequence.len(), 5);
assert_eq!(query_name, "q1");
assert_eq!((*query_start, *query_end), (5, 10));
assert_eq!(*strand, '+');
assert_eq!(*hap, 0);
let reads2 = get_paf_reads(
&mut paf_reader,
&mut fasta_reader,
&entries,
110,
118,
0,
0,
StitchConfig::default(),
false,
)
.unwrap();
assert_eq!(reads2.len(), 1);
let (sequence2, _, query_start2, query_end2, _, _) = &reads2[0];
assert_eq!(sequence2.len(), 8);
assert_eq!((*query_start2, *query_end2), (10, 18));
}
fn stitch_test_paf_plus() -> (&'static str, &'static str) {
(
"q1\t70\t0\t20\t+\tchr1\t2000\t1000\t1020\t20\t20\t60\tcg:Z:5M5M5M5M\n\
q1\t70\t50\t70\t+\tchr1\t2000\t1020\t1040\t20\t20\t60\tcg:Z:5M5M5M5M\n",
">q1\nCCCCCCCCCCGGGGGGGGGGTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTAAAAAAAAAACCCCCCCCCC\n",
)
}
fn setup_stitch_reader(
paf_contents: &str,
fasta_contents: &str,
) -> (
tempfile::NamedTempFile,
BufReader<File>,
noodles::fasta::io::IndexedReader<noodles::fasta::io::BufReader<File>>,
) {
let mut paf_file = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut paf_file, paf_contents.as_bytes()).unwrap();
let paf_reader = BufReader::new(File::open(paf_file.path()).unwrap());
let mut fasta_file = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut fasta_file, fasta_contents.as_bytes()).unwrap();
let fasta_index = noodles::fasta::fs::index(fasta_file.path()).unwrap();
let fasta_reader = noodles::fasta::io::indexed_reader::Builder::default()
.set_index(fasta_index)
.build_from_path(fasta_file.path())
.unwrap();
(paf_file, paf_reader, fasta_reader)
}
#[test]
fn stitch_disabled_only_produces_partial_fragments() {
let (paf_contents, fasta_contents) = stitch_test_paf_plus();
let (_paf_file, mut paf_reader, mut fasta_reader) =
setup_stitch_reader(paf_contents, fasta_contents);
let entry_a = PafIndexEntry {
offset: 0,
target_start: 1000,
target_end: 1020,
};
let entry_b = PafIndexEntry {
offset: paf_contents.lines().next().unwrap().len() as u64 + 1,
target_start: 1020,
target_end: 1040,
};
let entries = [&entry_a, &entry_b];
let reads = get_paf_reads(
&mut paf_reader,
&mut fasta_reader,
&entries,
1010,
1030,
0,
0,
StitchConfig::default(),
false,
)
.unwrap();
assert_eq!(reads.len(), 2);
let mut by_seq: std::collections::HashMap<&str, (usize, usize)> =
std::collections::HashMap::new();
for (sequence, _, query_start, query_end, ..) in &reads {
by_seq.insert(sequence.as_str(), (*query_start, *query_end));
}
assert_eq!(by_seq.get("GGGGGGGGGG"), Some(&(10, 20)));
assert_eq!(by_seq.get("AAAAAAAAAA"), Some(&(50, 60)));
}
#[test]
fn stitch_enabled_bridges_split_insertion_plus_strand() {
let (paf_contents, fasta_contents) = stitch_test_paf_plus();
let (_paf_file, mut paf_reader, mut fasta_reader) =
setup_stitch_reader(paf_contents, fasta_contents);
let entry_a = PafIndexEntry {
offset: 0,
target_start: 1000,
target_end: 1020,
};
let entry_b = PafIndexEntry {
offset: paf_contents.lines().next().unwrap().len() as u64 + 1,
target_start: 1020,
target_end: 1040,
};
let entries = [&entry_a, &entry_b];
let reads = get_paf_reads(
&mut paf_reader,
&mut fasta_reader,
&entries,
1010,
1030,
0,
0,
StitchConfig {
enabled: true,
max_gap: 100,
},
false,
)
.unwrap();
assert_eq!(reads.len(), 1);
let (sequence, query_name, query_start, query_end, strand, _hap) = &reads[0];
assert_eq!(query_name, "q1");
assert_eq!(*strand, '+');
assert_eq!((*query_start, *query_end), (10, 60));
let expected = "GGGGGGGGGG".to_string() + &"T".repeat(30) + "AAAAAAAAAA";
assert_eq!(*sequence, expected);
}
#[test]
fn stitch_enabled_bridges_split_insertion_minus_strand() {
let paf_contents = "q1\t70\t50\t70\t-\tchr1\t2000\t1000\t1020\t20\t20\t60\tcg:Z:5M5M5M5M\n\
q1\t70\t0\t20\t-\tchr1\t2000\t1020\t1040\t20\t20\t60\tcg:Z:5M5M5M5M\n";
let fasta_contents =
">q1\nCCCCCCCCCCGGGGGGGGGGTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTAAAAAAAAAACCCCCCCCCC\n";
let (_paf_file, mut paf_reader, mut fasta_reader) =
setup_stitch_reader(paf_contents, fasta_contents);
let entry_a = PafIndexEntry {
offset: 0,
target_start: 1000,
target_end: 1020,
};
let entry_b = PafIndexEntry {
offset: paf_contents.lines().next().unwrap().len() as u64 + 1,
target_start: 1020,
target_end: 1040,
};
let entries = [&entry_a, &entry_b];
let reads = get_paf_reads(
&mut paf_reader,
&mut fasta_reader,
&entries,
1010,
1030,
0,
0,
StitchConfig {
enabled: true,
max_gap: 100,
},
false,
)
.unwrap();
assert_eq!(reads.len(), 1);
let (sequence, _query_name, _query_start, _query_end, strand, _hap) = &reads[0];
assert_eq!(*strand, '-');
let forward = "GGGGGGGGGG".to_string() + &"T".repeat(30) + "AAAAAAAAAA";
let expected = revcomp(&forward);
assert_eq!(*sequence, expected);
}
#[test]
fn stitch_no_chain_when_window_exceeds_chain_coverage() {
let (paf_contents, fasta_contents) = stitch_test_paf_plus();
let (_paf_file, mut paf_reader, mut fasta_reader) =
setup_stitch_reader(paf_contents, fasta_contents);
let entry_a = PafIndexEntry {
offset: 0,
target_start: 1000,
target_end: 1020,
};
let entry_b = PafIndexEntry {
offset: paf_contents.lines().next().unwrap().len() as u64 + 1,
target_start: 1020,
target_end: 1040,
};
let entries = [&entry_a, &entry_b];
let reads = get_paf_reads(
&mut paf_reader,
&mut fasta_reader,
&entries,
1010,
1050,
0,
0,
StitchConfig {
enabled: true,
max_gap: 100,
},
false,
)
.unwrap();
assert!(reads.iter().all(|(sequence, ..)| sequence.len() < 30));
assert_eq!(reads.len(), 2);
let mut by_seq: std::collections::HashMap<&str, (usize, usize)> =
std::collections::HashMap::new();
for (sequence, _, query_start, query_end, ..) in &reads {
by_seq.insert(sequence.as_str(), (*query_start, *query_end));
}
assert_eq!(by_seq.get("GGGGGGGGGG"), Some(&(10, 20)));
assert_eq!(by_seq.get("AAAAAAAAAACCCCCCCCCC"), Some(&(50, 70)));
}
#[test]
fn window_start_exactly_at_align_start_now_extracts_correctly() {
let paf_contents = "q1\t20\t0\t20\t+\tchr1\t2000\t1000\t1020\t20\t20\t60\tcg:Z:20M\n";
let fasta_contents = ">q1\nAAAAAAAAAACCCCCCCCCC\n";
let (_paf_file, mut paf_reader, mut fasta_reader) =
setup_stitch_reader(paf_contents, fasta_contents);
let entry = PafIndexEntry {
offset: 0,
target_start: 1000,
target_end: 1020,
};
let entries = [&entry];
let reads = get_paf_reads(
&mut paf_reader,
&mut fasta_reader,
&entries,
1000,
1010,
0,
0,
StitchConfig::default(),
false,
)
.unwrap();
assert_eq!(reads.len(), 1);
let (sequence, _, query_start, query_end, ..) = &reads[0];
assert_eq!((*query_start, *query_end), (0, 10));
assert_eq!(sequence, "AAAAAAAAAA");
}
#[test]
fn window_end_exactly_at_align_end_now_extracts_correctly() {
let paf_contents = "q1\t20\t0\t20\t+\tchr1\t2000\t1000\t1020\t20\t20\t60\tcg:Z:20M\n";
let fasta_contents = ">q1\nAAAAAAAAAACCCCCCCCCC\n";
let (_paf_file, mut paf_reader, mut fasta_reader) =
setup_stitch_reader(paf_contents, fasta_contents);
let entry = PafIndexEntry {
offset: 0,
target_start: 1000,
target_end: 1020,
};
let entries = [&entry];
let reads = get_paf_reads(
&mut paf_reader,
&mut fasta_reader,
&entries,
1010,
1020,
0,
0,
StitchConfig::default(),
false,
)
.unwrap();
assert_eq!(reads.len(), 1);
let (sequence, _, query_start, query_end, ..) = &reads[0];
assert_eq!((*query_start, *query_end), (10, 20));
assert_eq!(sequence, "CCCCCCCCCC");
}
#[test]
fn window_entirely_inside_deletion_returns_empty_sequence_not_skipped() {
let paf_contents = "q1\t10\t0\t10\t+\tchr1\t2000\t1000\t1020\t10\t20\t60\tcg:Z:5M10D5M\n";
let fasta_contents = ">q1\nAAAAACCCCC\n";
let (_paf_file, mut paf_reader, mut fasta_reader) =
setup_stitch_reader(paf_contents, fasta_contents);
let entry = PafIndexEntry {
offset: 0,
target_start: 1000,
target_end: 1020,
};
let entries = [&entry];
let reads = get_paf_reads(
&mut paf_reader,
&mut fasta_reader,
&entries,
1007,
1012,
0,
0,
StitchConfig::default(),
false,
)
.unwrap();
assert_eq!(reads.len(), 1);
let (sequence, _, query_start, query_end, ..) = &reads[0];
assert_eq!(sequence, "");
assert_eq!((*query_start, *query_end), (5, 5));
}
#[test]
fn invalid_cigar_in_one_record_is_skipped_not_an_error() {
let paf_contents = "q1\t20\t0\t20\t+\tchr1\t2000\t1000\t1020\t20\t20\t60\tcg:Z:not_a_cigar\n\
q2\t20\t0\t20\t+\tchr1\t2000\t1000\t1020\t20\t20\t60\tcg:Z:20M\n";
let fasta_contents = ">q1\nAAAAAAAAAAAAAAAAAAAA\n>q2\nCCCCCCCCCCCCCCCCCCCC\n";
let (_paf_file, mut paf_reader, mut fasta_reader) =
setup_stitch_reader(paf_contents, fasta_contents);
let entry_bad = PafIndexEntry {
offset: 0,
target_start: 1000,
target_end: 1020,
};
let entry_good = PafIndexEntry {
offset: paf_contents.lines().next().unwrap().len() as u64 + 1,
target_start: 1000,
target_end: 1020,
};
let entries = [&entry_bad, &entry_good];
let reads = get_paf_reads(
&mut paf_reader,
&mut fasta_reader,
&entries,
1000,
1020,
0,
0,
StitchConfig::default(),
false,
)
.unwrap();
assert_eq!(reads.len(), 1);
assert_eq!(reads[0].1, "q2");
}
#[test]
fn missing_fasta_contig_in_one_record_is_skipped_not_an_error() {
let paf_contents = "q_missing\t20\t0\t20\t+\tchr1\t2000\t1000\t1020\t20\t20\t60\tcg:Z:20M\n\
q2\t20\t0\t20\t+\tchr1\t2000\t1000\t1020\t20\t20\t60\tcg:Z:20M\n";
let fasta_contents = ">q2\nCCCCCCCCCCCCCCCCCCCC\n";
let (_paf_file, mut paf_reader, mut fasta_reader) =
setup_stitch_reader(paf_contents, fasta_contents);
let entry_bad = PafIndexEntry {
offset: 0,
target_start: 1000,
target_end: 1020,
};
let entry_good = PafIndexEntry {
offset: paf_contents.lines().next().unwrap().len() as u64 + 1,
target_start: 1000,
target_end: 1020,
};
let entries = [&entry_bad, &entry_good];
let reads = get_paf_reads(
&mut paf_reader,
&mut fasta_reader,
&entries,
1000,
1020,
0,
0,
StitchConfig::default(),
false,
)
.unwrap();
assert_eq!(reads.len(), 1);
assert_eq!(reads[0].1, "q2");
}
#[test]
fn stitch_picks_the_chain_whose_key_appears_first_in_records_order() {
let paf_contents = "qA\t70\t0\t20\t+\tchr1\t2000\t1000\t1020\t20\t20\t60\tcg:Z:5M5M5M5M\n\
qA\t70\t50\t70\t+\tchr1\t2000\t1020\t1040\t20\t20\t60\tcg:Z:5M5M5M5M\n\
qB\t70\t100\t120\t+\tchr1\t2000\t1000\t1020\t20\t20\t60\tcg:Z:5M5M5M5M\n\
qB\t70\t150\t170\t+\tchr1\t2000\t1020\t1040\t20\t20\t60\tcg:Z:5M5M5M5M\n";
let fasta_contents = ">qA\nCCCCCCCCCCGGGGGGGGGGTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTAAAAAAAAAACCCCCCCCCC\n\
>qB\nCCCCCCCCCCGGGGGGGGGGTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTAAAAAAAAAACCCCCCCCCC\n";
let (_paf_file, mut paf_reader, mut fasta_reader) =
setup_stitch_reader(paf_contents, fasta_contents);
let mut offset = 0u64;
let mut offsets = Vec::new();
for line in paf_contents.lines() {
offsets.push(offset);
offset += line.len() as u64 + 1;
}
let entries: Vec<PafIndexEntry> = offsets
.iter()
.map(|&o| PafIndexEntry {
offset: o,
target_start: 1000,
target_end: 1040,
})
.collect();
let entry_refs: Vec<&PafIndexEntry> = entries.iter().collect();
let reads = get_paf_reads(
&mut paf_reader,
&mut fasta_reader,
&entry_refs,
1010,
1030,
0,
0,
StitchConfig {
enabled: true,
max_gap: 100,
},
false,
)
.unwrap();
let stitched: Vec<_> = reads.iter().filter(|(seq, ..)| seq.len() == 50).collect();
assert_eq!(
stitched.len(),
1,
"expected exactly one stitched (50bp) result"
);
assert_eq!(
stitched[0].1, "qA",
"qA's chain appears first in records and must win deterministically"
);
}
}