use anyhow::{bail, Result};
use molecular_annotation::{Encoding, MolecularAnnotations, QualitySpec, Strand};
use rust_htslib::bam::{self, record::Aux};
pub const NUC_TYPE: &str = "nuc";
pub const MSP_TYPE: &str = "msp";
pub const FIRE_TYPE: &str = "fire";
pub const LEGACY_READ_TAGS: &[&[u8]] = &[b"ns", b"nl", b"as", b"al", b"aq"];
type NucMspArrays = (Vec<i64>, Vec<i64>, Vec<i64>, Vec<i64>, Vec<u8>);
type MspInput<'a> = (&'a [u32], &'a [u32], Option<&'a [u8]>);
pub fn read_record(record: &bam::Record) -> Result<MolecularAnnotations> {
let mut annot = MolecularAnnotations::from_record(record);
let has_ma = matches!(record.aux(b"MA"), Ok(Aux::String(_)));
if !has_ma {
let has_legacy_nuc = record.aux(b"ns").is_ok();
let has_legacy_msp = record.aux(b"as").is_ok();
if has_legacy_nuc || has_legacy_msp {
merge_missing_types(&mut annot, read_legacy_nuc_msp(record)?);
}
if record.aux(b"fs").is_ok() {
merge_missing_types(&mut annot, read_legacy_fibertig(record)?);
}
}
Ok(annot)
}
fn merge_missing_types(dst: &mut MolecularAnnotations, src: MolecularAnnotations) {
for t in src.annotation_types.into_iter() {
if dst.get_type(&t.name).is_some() {
continue;
}
let new_t = dst.add_annotation_type(&t.name, t.quality_spec.clone(), t.encoding);
for a in t.annotations.into_iter() {
new_t.add_shared(a.start, a.length, a.strand, a.qualities, a.name);
}
}
}
pub fn write_record(record: &mut bam::Record, annot: &MolecularAnnotations) {
annot.to_record(record);
}
pub fn write_record_with_basemods(record: &mut bam::Record, annot: &MolecularAnnotations) {
annot.to_record(record);
annot.write_mm_ml(record);
}
pub fn read_annotations(record: &bam::Record) -> Result<MolecularAnnotations> {
if let Some(annot) = read_ma_tags(record)? {
return Ok(annot);
}
read_legacy_nuc_msp(record)
}
fn read_ma_tags(record: &bam::Record) -> Result<Option<MolecularAnnotations>> {
let ma = match record.aux(b"MA") {
Ok(Aux::String(s)) => s.to_string(),
_ => return Ok(None),
};
let aq: Option<Vec<u8>> = match record.aux(b"AQ") {
Ok(Aux::ArrayU8(arr)) => Some(arr.iter().collect()),
_ => None,
};
let an: Option<String> = match record.aux(b"AN") {
Ok(Aux::String(s)) => Some(s.to_string()),
_ => None,
};
let mut annot = MolecularAnnotations::from_tags(&ma, aq.as_deref(), an.as_deref())
.map_err(|e| anyhow::anyhow!("MA tag parse error: {e}"))?;
annot.set_aligned_blocks_raw(
molecular_annotation::AlignedBlocks::from_record(record),
record.is_reverse(),
);
Ok(Some(annot))
}
fn read_legacy_nuc_msp(record: &bam::Record) -> Result<MolecularAnnotations> {
let mut annot = MolecularAnnotations::from_record(record);
let ns = u32_array(record, b"ns");
let nl = u32_array(record, b"nl");
let a_starts = u32_array(record, b"as");
let a_lens = u32_array(record, b"al");
let aq = u8_array(record, b"aq");
if let (Some(ns), Some(nl)) = (ns.as_ref(), nl.as_ref()) {
if ns.len() != nl.len() {
bail!(
"legacy ns ({}) and nl ({}) length mismatch",
ns.len(),
nl.len()
);
}
if !ns.is_empty() {
let nuc = annot.add_annotation_type(NUC_TYPE, QualitySpec::none(), Encoding::Ma);
for (s, l) in ns.iter().zip(nl.iter()) {
nuc.add(*s, *l, Strand::Unknown, vec![], None);
}
}
}
if let (Some(starts), Some(lens)) = (a_starts.as_ref(), a_lens.as_ref()) {
if starts.len() != lens.len() {
bail!(
"legacy as ({}) and al ({}) length mismatch",
starts.len(),
lens.len()
);
}
if let Some(ref q) = aq {
if q.len() != starts.len() {
bail!(
"legacy aq ({}) and as ({}) length mismatch",
q.len(),
starts.len()
);
}
}
if !starts.is_empty() {
let msp = annot.add_annotation_type(MSP_TYPE, QualitySpec::none(), Encoding::Ma);
for (s, l) in starts.iter().zip(lens.iter()) {
msp.add(*s, *l, Strand::Unknown, vec![], None);
}
}
if let Some(ref q) = aq {
let fire: Vec<(u32, u32, u8)> = starts
.iter()
.zip(lens.iter())
.zip(q.iter())
.filter(|(_, &p)| p > 0)
.map(|((s, l), &p)| (*s, *l, p))
.collect();
if !fire.is_empty() {
let q_spec = "Q".parse::<QualitySpec>()?;
let fire_t = annot.add_annotation_type(FIRE_TYPE, q_spec, Encoding::Ma);
for (s, l, p) in fire {
fire_t.add(s, l, Strand::Unknown, vec![p], None);
}
}
}
}
Ok(annot)
}
fn read_legacy_fibertig(record: &bam::Record) -> Result<MolecularAnnotations> {
use crate::utils::fibertig::FIBERTIG_TYPE;
let mut annot = MolecularAnnotations::from_record(record);
let (Some(fs), Some(fl)) = (u32_array(record, b"fs"), u32_array(record, b"fl")) else {
return Ok(annot);
};
if fs.len() != fl.len() {
bail!(
"legacy fibertig fs ({}) and fl ({}) length mismatch",
fs.len(),
fl.len()
);
}
if fs.is_empty() {
return Ok(annot);
}
let names: Option<Vec<Option<String>>> = match record.aux(b"fa") {
Ok(Aux::String(s)) => {
let parts: Vec<Option<String>> = s
.split('|')
.map(|p| (!p.is_empty()).then(|| p.to_string()))
.collect();
if parts.len() != fs.len() {
bail!(
"legacy fibertig fa ({}) and fs ({}) length mismatch",
parts.len(),
fs.len()
);
}
Some(parts)
}
_ => None,
};
let t = annot.add_annotation_type(FIBERTIG_TYPE, QualitySpec::none(), Encoding::Ma);
for (i, (s, l)) in fs.iter().zip(fl.iter()).enumerate() {
let name = names.as_ref().and_then(|v| v[i].clone());
t.add(*s, *l, Strand::Forward, vec![], name);
}
Ok(annot)
}
pub fn extract_nuc_msp_arrays(record: &bam::Record) -> Result<NucMspArrays> {
let annot = read_record(record)?;
let (nuc_starts, nuc_lengths) = annot
.get_type(NUC_TYPE)
.map(|t| {
(
t.annotations.iter().map(|a| a.start as i64).collect(),
t.annotations.iter().map(|a| a.length as i64).collect(),
)
})
.unwrap_or_default();
let (msp_starts, msp_lengths, msp_qual) = annot
.get_type(MSP_TYPE)
.map(|t| {
let starts: Vec<i64> = t.annotations.iter().map(|a| a.start as i64).collect();
let lens: Vec<i64> = t.annotations.iter().map(|a| a.length as i64).collect();
let qs: Vec<u8> = if t.quality_spec.has_quality() {
t.annotations
.iter()
.map(|a| crate::utils::bamannotations::primary_qual(&a.qualities, MSP_TYPE))
.collect()
} else {
Vec::new()
};
(starts, lens, qs)
})
.unwrap_or_default();
Ok((nuc_starts, nuc_lengths, msp_starts, msp_lengths, msp_qual))
}
pub fn add_nuc_annotations(annot: &mut MolecularAnnotations, starts: &[u32], lens: &[u32]) {
if starts.is_empty() {
return;
}
let t = annot.add_annotation_type(NUC_TYPE, QualitySpec::none(), Encoding::Ma);
for (s, l) in starts.iter().zip(lens.iter()) {
t.add(*s, *l, Strand::Unknown, vec![], None);
}
}
pub fn add_msp_annotations(
annot: &mut MolecularAnnotations,
starts: &[u32],
lens: &[u32],
qualities: Option<&[u8]>,
) {
if starts.is_empty() {
return;
}
let qspec = match qualities {
Some(_) => "Q".parse::<QualitySpec>().expect("Q parses"),
None => QualitySpec::none(),
};
let t = annot.add_annotation_type(MSP_TYPE, qspec, Encoding::Ma);
for (i, (s, l)) in starts.iter().zip(lens.iter()).enumerate() {
let qv = qualities.map(|q| vec![q[i]]).unwrap_or_default();
t.add(*s, *l, Strand::Unknown, qv, None);
}
}
pub fn add_fire_annotations(
annot: &mut MolecularAnnotations,
starts: &[u32],
lens: &[u32],
precisions: &[u8],
) {
if starts.is_empty() {
return;
}
let t = annot.add_annotation_type(
FIRE_TYPE,
"Q".parse::<QualitySpec>().expect("Q parses"),
Encoding::Ma,
);
for (i, (s, l)) in starts.iter().zip(lens.iter()).enumerate() {
t.add(*s, *l, Strand::Unknown, vec![precisions[i]], None);
}
}
pub fn build_annotations(
record: &bam::Record,
nuc: Option<(&[u32], &[u32])>,
msp: Option<MspInput>,
fire: Option<(&[u32], &[u32], &[u8])>,
) -> MolecularAnnotations {
let mut annot = MolecularAnnotations::from_record(record);
if let Some((starts, lens)) = nuc {
add_nuc_annotations(&mut annot, starts, lens);
}
if let Some((starts, lens, q)) = msp {
add_msp_annotations(&mut annot, starts, lens, q);
}
if let Some((starts, lens, precisions)) = fire {
add_fire_annotations(&mut annot, starts, lens, precisions);
}
annot
}
pub fn build_nuc_msp_annotations(
record: &bam::Record,
nuc_starts: &[u32],
nuc_lengths: &[u32],
msp_starts: &[u32],
msp_lengths: &[u32],
msp_qual: Option<&[u8]>,
) -> MolecularAnnotations {
let nuc = (!nuc_starts.is_empty()).then_some((nuc_starts, nuc_lengths));
let msp = (!msp_starts.is_empty()).then_some((msp_starts, msp_lengths, msp_qual));
build_annotations(record, nuc, msp, None)
}
pub fn extract_fire_arrays(record: &bam::Record) -> Result<(Vec<u32>, Vec<u32>, Vec<u8>)> {
let annot = read_record(record)?;
Ok(annot
.get_type(FIRE_TYPE)
.map(|t| {
let starts = t.annotations.iter().map(|a| a.start).collect();
let lens = t.annotations.iter().map(|a| a.length).collect();
let precisions = t
.annotations
.iter()
.map(|a| crate::utils::bamannotations::primary_qual(&a.qualities, FIRE_TYPE))
.collect();
(starts, lens, precisions)
})
.unwrap_or_default())
}
fn u32_array(record: &bam::Record, tag: &[u8]) -> Option<Vec<u32>> {
match record.aux(tag) {
Ok(Aux::ArrayU32(arr)) => Some(arr.iter().collect()),
Ok(Aux::ArrayI32(arr)) => Some(arr.iter().map(|v| v as u32).collect()),
_ => None,
}
}
fn u8_array(record: &bam::Record, tag: &[u8]) -> Option<Vec<u8>> {
match record.aux(tag) {
Ok(Aux::ArrayU8(arr)) => Some(arr.iter().collect()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use molecular_annotation::{MolecularAnnotations, QualitySpec, Strand};
fn synth_record(seq: &[u8]) -> bam::Record {
let mut record = bam::Record::new();
let qual = vec![60u8; seq.len()];
record.set(b"test_read", None, seq, &qual);
record
}
#[test]
fn read_write_record_roundtrips_basemod_and_msp() {
use crate::utils::basemods::{canonical_header, M6A_TYPE};
let mut record = synth_record(b"ATCGATCGAT");
let mut annot = MolecularAnnotations::from_record(&record);
let qspec_q = "Q".parse::<QualitySpec>().expect("Q parses");
let m6a_type = annot.add_annotation_type(M6A_TYPE, qspec_q.clone(), Encoding::mm_ml());
let header = canonical_header(M6A_TYPE, b'A').unwrap().to_string();
m6a_type.add(0, 1, Strand::Forward, vec![240], Some(header));
let msp_type = annot.add_annotation_type(MSP_TYPE, qspec_q, Encoding::Ma);
msp_type.add(5, 3, Strand::Unknown, vec![100], None);
write_record_with_basemods(&mut record, &annot);
let back = read_record(&record).expect("read_record");
let m6a = back
.annotation_types
.iter()
.find(|t| t.name == M6A_TYPE)
.expect("m6a type present after round-trip");
assert_eq!(m6a.annotations.len(), 1);
assert_eq!(m6a.annotations[0].start, 0);
assert_eq!(m6a.annotations[0].qualities.to_vec(), vec![240]);
let msp = back
.annotation_types
.iter()
.find(|t| t.name == MSP_TYPE)
.expect("msp type present after round-trip");
assert_eq!(msp.annotations.len(), 1);
assert_eq!(msp.annotations[0].start, 5);
assert_eq!(msp.annotations[0].length, 3);
}
#[test]
fn read_record_falls_back_to_legacy_ns_as() {
use rust_htslib::bam::record::Aux;
let mut record = synth_record(b"ATCGATCGAT");
let ns: Vec<u32> = vec![2];
let nl: Vec<u32> = vec![2];
let as_starts: Vec<u32> = vec![6];
let al: Vec<u32> = vec![3];
record.push_aux(b"ns", Aux::ArrayU32((&ns).into())).unwrap();
record.push_aux(b"nl", Aux::ArrayU32((&nl).into())).unwrap();
record
.push_aux(b"as", Aux::ArrayU32((&as_starts).into()))
.unwrap();
record.push_aux(b"al", Aux::ArrayU32((&al).into())).unwrap();
let annot = read_record(&record).expect("read_record");
let nuc = annot
.annotation_types
.iter()
.find(|t| t.name == NUC_TYPE)
.expect("nuc populated from legacy ns/nl");
assert_eq!(nuc.annotations.len(), 1);
assert_eq!(nuc.annotations[0].start, 2);
assert_eq!(nuc.annotations[0].length, 2);
let msp = annot
.annotation_types
.iter()
.find(|t| t.name == MSP_TYPE)
.expect("msp populated from legacy as/al");
assert_eq!(msp.annotations.len(), 1);
assert_eq!(msp.annotations[0].start, 6);
assert_eq!(msp.annotations[0].length, 3);
}
#[test]
fn read_record_splits_legacy_aq_into_msp_and_fire() {
use rust_htslib::bam::record::Aux;
let mut record = synth_record(b"ATCGATCGATCGATCGATCG");
let as_starts: Vec<u32> = vec![2, 10];
let al: Vec<u32> = vec![3, 4];
let aq: Vec<u8> = vec![200, 0];
record
.push_aux(b"as", Aux::ArrayU32((&as_starts).into()))
.unwrap();
record.push_aux(b"al", Aux::ArrayU32((&al).into())).unwrap();
record.push_aux(b"aq", Aux::ArrayU8((&aq).into())).unwrap();
let annot = read_record(&record).expect("read_record");
let msp = annot
.annotation_types
.iter()
.find(|t| t.name == MSP_TYPE)
.expect("msp populated from legacy as/al");
assert!(
!msp.quality_spec.has_quality(),
"msp must carry no quality after legacy ingest"
);
assert_eq!(msp.annotations.len(), 2);
assert_eq!(msp.annotations[0].start, 2);
assert_eq!(msp.annotations[1].start, 10);
let fire = annot
.annotation_types
.iter()
.find(|t| t.name == FIRE_TYPE)
.expect("fire synthesized from the aq>0 MSP subset");
assert!(fire.quality_spec.has_quality());
assert_eq!(fire.annotations.len(), 1);
assert_eq!(fire.annotations[0].start, 2);
assert_eq!(fire.annotations[0].length, 3);
assert_eq!(fire.annotations[0].qualities.to_vec(), vec![200]);
}
#[test]
fn read_record_falls_back_to_legacy_fibertig_fs_fl_fa() {
use crate::utils::fibertig::FIBERTIG_TYPE;
use rust_htslib::bam::record::Aux;
let mut record = synth_record(b"ATCGATCGATCGATCGATCG");
let fs: Vec<u32> = vec![2, 10];
let fl: Vec<u32> = vec![3, 4];
record.push_aux(b"fs", Aux::ArrayU32((&fs).into())).unwrap();
record.push_aux(b"fl", Aux::ArrayU32((&fl).into())).unwrap();
record.push_aux(b"fa", Aux::String("gene_a|")).unwrap();
let annot = read_record(&record).expect("read_record");
let tig = annot
.annotation_types
.iter()
.find(|t| t.name == FIBERTIG_TYPE)
.expect("fibertig populated from legacy fs/fl/fa");
assert_eq!(tig.annotations.len(), 2);
assert_eq!(tig.annotations[0].start, 2);
assert_eq!(tig.annotations[0].length, 3);
assert_eq!(tig.annotations[0].name.as_deref(), Some("gene_a"));
assert_eq!(tig.annotations[1].start, 10);
assert_eq!(tig.annotations[1].length, 4);
assert_eq!(tig.annotations[1].name, None);
}
#[test]
fn read_record_rejects_mismatched_legacy_fibertig_lengths() {
use rust_htslib::bam::record::Aux;
let mut record = synth_record(b"ATCGATCGAT");
let fs: Vec<u32> = vec![2, 6];
let fl: Vec<u32> = vec![3];
record.push_aux(b"fs", Aux::ArrayU32((&fs).into())).unwrap();
record.push_aux(b"fl", Aux::ArrayU32((&fl).into())).unwrap();
assert!(
read_record(&record).is_err(),
"mismatched fs/fl lengths must be a hard error, not a silent drop"
);
}
#[test]
fn rewrite_replaces_ma_tag_instead_of_appending() {
let mut record = synth_record(b"ATCGATCGAT");
let qspec_q = "Q".parse::<QualitySpec>().unwrap();
let mut v1 = MolecularAnnotations::from_record(&record);
v1.add_annotation_type(MSP_TYPE, qspec_q.clone(), Encoding::Ma)
.add(100, 50, Strand::Unknown, vec![10], None);
write_record(&mut record, &v1);
let mut v2 = MolecularAnnotations::from_record(&record);
v2.annotation_types.clear();
v2.add_annotation_type(MSP_TYPE, qspec_q, Encoding::Ma).add(
200,
60,
Strand::Unknown,
vec![20],
None,
);
write_record(&mut record, &v2);
let ma_count = record
.aux_iter()
.filter_map(Result::ok)
.filter(|(tag, _)| *tag == b"MA")
.count();
assert_eq!(ma_count, 1, "expected exactly one MA tag, found {ma_count}");
let back = read_record(&record).expect("read_record");
let msp = back
.annotation_types
.iter()
.find(|t| t.name == MSP_TYPE)
.expect("msp present");
assert_eq!(msp.annotations.len(), 1);
assert_eq!(msp.annotations[0].start, 200, "read back stale MA tag");
}
}