use crate::error::{PbzError, Result};
use crate::region_query::RegionQuery;
use hashbrown::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ContigId(u32);
impl ContigId {
pub fn as_u32(self) -> u32 {
self.0
}
pub fn as_usize(self) -> usize {
self.0 as usize
}
}
impl std::fmt::Display for ContigId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "#{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Contig {
pub name: String,
pub length: u64,
}
#[derive(Debug, Clone)]
pub struct Genome {
contigs: Vec<Contig>,
by_name: HashMap<String, ContigId>,
name: Option<String>,
}
impl Genome {
pub fn new(contigs: Vec<Contig>) -> Result<Self> {
if contigs.len() > u32::MAX as usize {
return Err(PbzError::Metadata(format!(
"too many contigs: {} (max {})",
contigs.len(),
u32::MAX
)));
}
let mut by_name = HashMap::with_capacity(contigs.len());
for (i, c) in contigs.iter().enumerate() {
if c.name.is_empty() {
return Err(PbzError::Metadata(format!("contig #{i} has empty name")));
}
if by_name.insert(c.name.clone(), ContigId(i as u32)).is_some() {
return Err(PbzError::Metadata(format!(
"duplicate contig name: {:?}",
c.name
)));
}
}
Ok(Self {
contigs,
by_name,
name: None,
})
}
pub fn len(&self) -> usize {
self.contigs.len()
}
pub fn is_empty(&self) -> bool {
self.contigs.is_empty()
}
pub fn contigs(&self) -> &[Contig] {
&self.contigs
}
pub fn get(&self, id: ContigId) -> Option<&Contig> {
self.contigs.get(id.as_usize())
}
pub fn id(&self, name: &str) -> Option<ContigId> {
self.by_name.get(name).copied()
}
pub fn iter(&self) -> impl Iterator<Item = (ContigId, &Contig)> {
self.contigs
.iter()
.enumerate()
.map(|(i, c)| (ContigId(i as u32), c))
}
pub fn offsets(&self) -> Vec<i64> {
let mut out = Vec::with_capacity(self.contigs.len() + 1);
let mut acc: i64 = 0;
out.push(0);
for c in &self.contigs {
acc += c.length as i64;
out.push(acc);
}
out
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn checksum_payload(&self) -> String {
let mut ordered: Vec<&Contig> = self.contigs.iter().collect();
ordered.sort_by(|a, b| a.name.cmp(&b.name));
let mut buf = String::new();
for c in ordered {
buf.push_str(&c.name);
buf.push('\t');
buf.push_str(&c.length.to_string());
buf.push('\n');
}
buf
}
pub fn checksum(&self) -> String {
let digest = md5::compute(self.checksum_payload().as_bytes());
format!("md5:{digest:x}")
}
pub fn from_fai(path: impl AsRef<Path>) -> Result<Self> {
let text = std::fs::read_to_string(path.as_ref())
.map_err(|e| PbzError::Store(format!("read fai: {e}")))?;
let mut contigs = Vec::new();
for (lineno, line) in text.lines().enumerate() {
if line.is_empty() {
continue;
}
let mut cols = line.split('\t');
let name = cols.next().filter(|s| !s.is_empty()).ok_or_else(|| {
PbzError::Metadata(format!("fai line {}: missing name", lineno + 1))
})?;
let length: u64 = cols
.next()
.ok_or_else(|| {
PbzError::Metadata(format!("fai line {}: missing length", lineno + 1))
})?
.parse()
.map_err(|e| {
PbzError::Metadata(format!("fai line {}: bad length: {e}", lineno + 1))
})?;
contigs.push(Contig {
name: name.to_owned(),
length,
});
}
Genome::new(contigs)
}
pub fn resolve(&self, query: &RegionQuery) -> Result<Region> {
let id = self
.id(&query.contig)
.ok_or_else(|| PbzError::ContigNotFound {
contig: query.contig.clone(),
available: self.contigs.iter().map(|c| c.name.clone()).collect(),
})?;
let length = self.contigs[id.as_usize()].length;
let start = query.start.unwrap_or(0);
let end = query.end.unwrap_or(length).min(length);
if start >= end {
return Err(PbzError::InvalidRegion {
message: format!(
"resolved range is empty on {} (length {}): start={}, end={}",
query.contig, length, start, end
),
});
}
Ok(Region {
contig: id,
start,
end,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Region {
pub contig: ContigId,
pub start: u64,
pub end: u64,
}
impl Region {
pub fn len(&self) -> usize {
(self.end - self.start) as usize
}
pub fn is_empty(&self) -> bool {
self.end == self.start
}
}
impl std::fmt::Display for Region {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}-{}", self.contig, self.start, self.end)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offsets_is_prefix_sum() {
let g = Genome::new(vec![
Contig {
name: "chr1".into(),
length: 1000,
},
Contig {
name: "chr2".into(),
length: 500,
},
Contig {
name: "chr3".into(),
length: 250,
},
])
.unwrap();
assert_eq!(g.offsets(), vec![0, 1000, 1500, 1750]);
}
#[test]
fn name_defaults_none_and_sets() {
let g = Genome::new(vec![Contig {
name: "chr1".into(),
length: 10,
}])
.unwrap();
assert_eq!(g.name(), None);
let g = g.with_name("hg38");
assert_eq!(g.name(), Some("hg38"));
}
#[test]
fn checksum_payload_is_canonical() {
let g = Genome::new(vec![
Contig {
name: "chr2".into(),
length: 500,
},
Contig {
name: "chr1".into(),
length: 1000,
},
])
.unwrap();
assert_eq!(g.checksum_payload(), "chr1\t1000\nchr2\t500\n");
}
#[test]
fn checksum_excludes_name_and_is_md5_hex() {
let a = Genome::new(vec![Contig {
name: "chr1".into(),
length: 10,
}])
.unwrap();
let b = a.clone().with_name("hg38");
assert_eq!(a.checksum(), b.checksum());
let c = a.checksum();
assert!(c.starts_with("md5:"));
assert_eq!(c.len(), "md5:".len() + 32);
assert!(
c["md5:".len()..]
.chars()
.all(|ch| ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase())
);
}
#[test]
fn from_fai_reads_name_and_length() {
use std::io::Write;
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("ref.fa.fai");
let mut f = std::fs::File::create(&p).unwrap();
writeln!(f, "chr1\t1000\t6\t60\t61").unwrap();
writeln!(f, "chr2\t500\t1024\t60\t61").unwrap();
drop(f);
let g = Genome::from_fai(&p).unwrap();
assert_eq!(g.len(), 2);
assert_eq!(
g.contigs()[0],
Contig {
name: "chr1".into(),
length: 1000
}
);
assert_eq!(
g.contigs()[1],
Contig {
name: "chr2".into(),
length: 500
}
);
}
}