use indexmap::IndexMap;
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChrEntry {
pub id: String,
pub size: i64,
pub index: usize,
}
#[derive(Debug, Clone, Default)]
pub struct ChrMap {
entries: IndexMap<String, ChrEntry>,
declared_key_size: Option<usize>,
}
impl ChrMap {
pub fn from_entries(entries: impl IntoIterator<Item = (String, i64)>) -> Self {
let mut map: IndexMap<String, ChrEntry> = IndexMap::new();
for (id, size) in entries {
let index = map.get(&id).map_or(map.len(), |e| e.index);
map.insert(id.clone(), ChrEntry { id, size, index });
}
Self {
entries: map,
declared_key_size: None,
}
}
pub fn from_indexed_entries(entries: impl IntoIterator<Item = (String, i64, usize)>) -> Self {
let mut list: Vec<ChrEntry> = entries
.into_iter()
.map(|(id, size, index)| ChrEntry { id, size, index })
.collect();
list.sort_by_key(|e| e.index);
let mut map = IndexMap::with_capacity(list.len());
for entry in list {
map.insert(entry.id.clone(), entry);
}
Self {
entries: map,
declared_key_size: None,
}
}
pub fn with_key_size(mut self, key_size: usize) -> Self {
self.declared_key_size = Some(key_size);
self
}
pub fn declared_key_size(&self) -> Option<usize> {
self.declared_key_size
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &ChrEntry> {
self.entries.values()
}
pub fn names(&self) -> Vec<String> {
self.entries.keys().cloned().collect()
}
pub fn by_index(&self, index: usize) -> Option<&ChrEntry> {
if let Some((_, entry)) = self.entries.get_index(index) {
if entry.index == index {
return Some(entry);
}
}
self.entries.values().find(|e| e.index == index)
}
pub fn genome_size(&self) -> i64 {
self.entries.values().map(|e| e.size).sum()
}
fn try_lookup(&self, id: &str) -> Option<&ChrEntry> {
if let Some(entry) = self.entries.get(id) {
return Some(entry);
}
if let Some(entry) = self.entries.get(&id.to_ascii_lowercase()) {
return Some(entry);
}
if let Some(entry) = self.entries.get(&id.to_ascii_uppercase()) {
return Some(entry);
}
self.entries
.iter()
.find(|(key, _)| key.len() == id.len() && key.eq_ignore_ascii_case(id))
.map(|(_, entry)| entry)
}
fn alt_id(id: &str) -> String {
match id.get(..3) {
Some(prefix) if prefix.eq_ignore_ascii_case("chr") => id[3..].to_string(),
_ => format!("chr{id}"),
}
}
pub fn get(&self, id: &str) -> Option<&ChrEntry> {
if let Some(entry) = self.try_lookup(id) {
return Some(entry);
}
self.try_lookup(&Self::alt_id(id))
}
pub fn contains(&self, id: &str) -> bool {
self.get(id).is_some()
}
pub fn resolve(&self, id: &str) -> Result<&ChrEntry> {
match self.get(id) {
Some(entry) => Ok(entry),
None => Err(self.not_found(id)),
}
}
fn not_found(&self, id: &str) -> Error {
let alt = Self::alt_id(id);
let mut key_size = None;
let mut truncated_match = None;
if let Some(declared) = self.declared_key_size.filter(|&k| k > 0) {
if id.chars().count() > declared && alt.chars().count() > declared {
key_size = Some(declared);
truncated_match = [id, alt.as_str()]
.into_iter()
.filter_map(|candidate| candidate.get(..declared))
.find_map(|prefix| self.try_lookup(prefix))
.map(|entry| entry.id.clone());
}
}
Error::UnknownChromosome {
id: id.to_string(),
key_size,
truncated_match,
available: self.entries.keys().cloned().collect(),
}
}
pub fn select(&self, requested: &[String]) -> Result<Vec<ChrEntry>> {
if requested.is_empty() {
return Ok(self.entries.values().cloned().collect());
}
requested
.iter()
.map(|id| self.resolve(id).cloned())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn map(names: &[(&str, i64)]) -> ChrMap {
ChrMap::from_entries(names.iter().map(|(n, s)| (n.to_string(), *s)))
}
#[test]
fn resolves_exact_case_and_prefix_toggle() {
let m = map(&[("chr1", 100), ("chr2", 200)]);
for asked in ["chr1", "CHR1", "Chr1", "1"] {
assert_eq!(m.get(asked).unwrap().id, "chr1", "asking for {asked}");
}
let m = map(&[("1", 100), ("2", 200)]);
for asked in ["1", "chr1", "CHR1"] {
assert_eq!(m.get(asked).unwrap().id, "1", "asking for {asked}");
}
}
#[test]
fn mixed_case_id_reaches_a_uniform_case_key() {
let m = map(&[("chrX", 10)]);
assert_eq!(m.get("ChrX").unwrap().id, "chrX");
assert_eq!(m.get("X").unwrap().id, "chrX");
}
#[test]
fn the_uppercase_form_is_tried_before_the_walk() {
let m = map(&[("chrX", 1), ("CHRX", 2)]);
assert_eq!(m.get("ChrX").unwrap().id, "CHRX");
}
#[test]
fn ties_in_the_walk_go_to_file_order() {
let m = map(&[("chrX", 1), ("cHRx", 2)]);
assert_eq!(m.get("ChrX").unwrap().id, "chrX");
let m = map(&[("cHRx", 1), ("chrX", 2)]);
assert_eq!(m.get("ChrX").unwrap().id, "cHRx");
}
#[test]
fn ids_are_compared_whole() {
let m = map(&[("chr1", 100)]);
assert!(m.get("chr12").is_none());
assert!(m.get("12").is_none());
let m = map(&[("chrXVI", 100)]);
assert!(m.get("chrXVII").is_none());
}
#[test]
fn not_found_message_lists_what_is_available() {
let m = map(&[("chr1", 1), ("chr2", 2)]);
let message = m.resolve("chrZ").unwrap_err().to_string();
assert!(message.contains("not found"), "{message}");
assert!(message.contains("(available: chr1, chr2)"), "{message}");
assert!(!message.contains("field"), "{message}");
}
#[test]
fn an_over_long_id_is_told_it_cannot_fit() {
let m = map(&[("chr1_GL456210_random", 1)]).with_key_size(20);
let message = m
.resolve("chrX_GL456210_random_ext")
.unwrap_err()
.to_string();
assert!(message.contains("20-character field"), "{message}");
assert!(message.contains("24 characters long"), "{message}");
}
#[test]
fn an_over_long_id_names_the_chromosome_its_prefix_spells() {
let m = map(&[("chr1_GL456210_random", 1)]).with_key_size(20);
let message = m
.resolve("chr1_GL456210_random_v234")
.unwrap_err()
.to_string();
assert!(
message.contains("does hold chr1_GL456210_random"),
"{message}"
);
}
#[test]
fn an_id_that_fits_once_stripped_gets_no_field_clause() {
let m = map(&[("chr1_GL456210_random", 1)]).with_key_size(20);
let message = m
.resolve("chrX_GL456210_random_ab")
.unwrap_err()
.to_string();
assert!(!message.contains("field"), "{message}");
assert!(message.contains("not found"), "{message}");
}
#[test]
fn select_takes_request_order_and_defaults_to_file_order() {
let m = map(&[("chr1", 1), ("chr2", 2), ("chr3", 3)]);
let all: Vec<_> = m
.select(&[])
.unwrap()
.iter()
.map(|e| e.id.clone())
.collect();
assert_eq!(all, ["chr1", "chr2", "chr3"]);
let some: Vec<_> = m
.select(&["chr3".into(), "1".into()])
.unwrap()
.iter()
.map(|e| e.id.clone())
.collect();
assert_eq!(some, ["chr3", "chr1"]);
}
#[test]
fn index_follows_insertion_order() {
let m = map(&[("chr1", 1), ("chr2", 2)]);
assert_eq!(m.get("chr2").unwrap().index, 1);
assert_eq!(m.by_index(0).unwrap().id, "chr1");
assert_eq!(m.genome_size(), 3);
}
#[test]
fn file_indices_are_kept_and_sorted_by() {
let m = ChrMap::from_indexed_entries([
("chrB".to_string(), 20, 7),
("chrA".to_string(), 10, 3),
]);
assert_eq!(m.names(), ["chrA", "chrB"]);
assert_eq!(m.get("chrA").unwrap().index, 3);
assert_eq!(m.by_index(3).unwrap().id, "chrA");
assert_eq!(m.by_index(7).unwrap().id, "chrB");
assert!(m.by_index(0).is_none());
}
}