Skip to main content

gwseq_io/genomic/
chr.rs

1//! Chromosome names and sizes, and how a requested name is resolved to one.
2//!
3//! # The resolution rule
4//!
5//! [`ChrMap::get`] tries the id, then the `chr`-toggled form of it. Each of
6//! those goes through [`ChrMap::try_lookup`], which tries four things in
7//! order: the id as given, its lowercase form, its uppercase form, and finally
8//! a walk of the map comparing case-insensitively. The walk is there because
9//! the three hashed forms only reach an id whose case is uniform — `"ChrX"`
10//! misses a file spelling it `"chrX"` — and it takes the first match **in the
11//! file's own order**, a file holding both `chrX` and `CHRX` having two, with
12//! neither more the answer than the other.
13//!
14//! # Ids are compared whole
15//!
16//! Never by prefix, and this is worth stating because the alternative looks
17//! reasonable: truncate the query to the length of the longest key first, so
18//! that a name longer than the file's fixed-width key field can still match — a
19//! bbi chromosome tree stores fixed-width keys, and a writer whose key size is
20//! shorter than a name stores that name truncated. But the key size cannot be
21//! inferred from the map. The longest *present* name is only a lower bound on
22//! the file's declared key size, so a file holding `chr1` alone reads as having
23//! a key size of 4, and every longer query is then answered with whichever
24//! chromosome it shares a prefix with — `12` resolving to `chr1`, yeast's
25//! `chrXVII` to `chrXVI`. Writes take the same path, so writing to `12` against
26//! `chr_sizes={"chr1": ...}` would land on chr1.
27//!
28//! The declared key size is still threaded through, for the *error message*
29//! only: a lookup that fails against a file whose key field is narrower than
30//! the query says so, and names the chromosome the query's first `key_size`
31//! characters spell when the file holds one.
32
33use indexmap::IndexMap;
34
35use crate::error::{Error, Result};
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ChrEntry {
39    pub id: String,
40    pub size: i64,
41    /// 0-based position in the file's reference order, as bbi and BAM store it.
42    pub index: usize,
43}
44
45/// Insertion-ordered chromosome map. Order is the file's reference order, which
46/// callers depend on — `chr_sizes` is documented as coming back in it, and the
47/// case-insensitive walk in `try_lookup` — private, so not linked — resolves
48/// ties by it.
49#[derive(Debug, Clone, Default)]
50pub struct ChrMap {
51    entries: IndexMap<String, ChrEntry>,
52    /// The key width the file declares, or `None` when there is no file behind
53    /// this map. Used in error messages only — never in matching.
54    declared_key_size: Option<usize>,
55}
56
57impl ChrMap {
58    /// Build from names and sizes, numbering them by insertion order.
59    ///
60    /// A name given twice keeps the index it was first given, and its later
61    /// size wins. Numbering from `map.len()` instead left the two entries
62    /// sharing an index — the second overwrote the first in the map while the
63    /// count stood still — and an index is what a data record carries.
64    pub fn from_entries(entries: impl IntoIterator<Item = (String, i64)>) -> Self {
65        let mut map: IndexMap<String, ChrEntry> = IndexMap::new();
66        for (id, size) in entries {
67            let index = map.get(&id).map_or(map.len(), |e| e.index);
68            map.insert(id.clone(), ChrEntry { id, size, index });
69        }
70        Self {
71            entries: map,
72            declared_key_size: None,
73        }
74    }
75
76    /// Build from names, sizes and the indices **the file gives them**.
77    ///
78    /// A bbi chromosome tree and a BAM reference list both store an explicit
79    /// index, and that index is what data records and R-tree items carry — so
80    /// it cannot be re-derived from position here. Entries are sorted by it,
81    /// which is the order `chr_sizes` reports them in.
82    pub fn from_indexed_entries(entries: impl IntoIterator<Item = (String, i64, usize)>) -> Self {
83        let mut list: Vec<ChrEntry> = entries
84            .into_iter()
85            .map(|(id, size, index)| ChrEntry { id, size, index })
86            .collect();
87        list.sort_by_key(|e| e.index);
88        let mut map = IndexMap::with_capacity(list.len());
89        for entry in list {
90            map.insert(entry.id.clone(), entry);
91        }
92        Self {
93            entries: map,
94            declared_key_size: None,
95        }
96    }
97
98    /// The fixed key width a bbi chromosome tree declares. Message-only.
99    pub fn with_key_size(mut self, key_size: usize) -> Self {
100        self.declared_key_size = Some(key_size);
101        self
102    }
103
104    pub fn declared_key_size(&self) -> Option<usize> {
105        self.declared_key_size
106    }
107
108    pub fn len(&self) -> usize {
109        self.entries.len()
110    }
111
112    pub fn is_empty(&self) -> bool {
113        self.entries.is_empty()
114    }
115
116    pub fn iter(&self) -> impl Iterator<Item = &ChrEntry> {
117        self.entries.values()
118    }
119
120    pub fn names(&self) -> Vec<String> {
121        self.entries.keys().cloned().collect()
122    }
123
124    /// The chromosome the **file** numbers `index`, not the one at that
125    /// position.
126    ///
127    /// The two coincide on every file whose indices are dense and 0-based,
128    /// which is every file any writer here or at UCSC produces — so the
129    /// positional lookup is tried first and the walk is the fallback for a file
130    /// that numbers its chromosomes otherwise.
131    pub fn by_index(&self, index: usize) -> Option<&ChrEntry> {
132        if let Some((_, entry)) = self.entries.get_index(index) {
133            if entry.index == index {
134                return Some(entry);
135            }
136        }
137        self.entries.values().find(|e| e.index == index)
138    }
139
140    /// Total size of every chromosome, which is what a whole-genome walk covers.
141    pub fn genome_size(&self) -> i64 {
142        self.entries.values().map(|e| e.size).sum()
143    }
144
145    /// The id as given, then its lowercase form, then its uppercase form, then
146    /// a case-insensitive walk in file order.
147    fn try_lookup(&self, id: &str) -> Option<&ChrEntry> {
148        if let Some(entry) = self.entries.get(id) {
149            return Some(entry);
150        }
151        if let Some(entry) = self.entries.get(&id.to_ascii_lowercase()) {
152            return Some(entry);
153        }
154        if let Some(entry) = self.entries.get(&id.to_ascii_uppercase()) {
155            return Some(entry);
156        }
157        self.entries
158            .iter()
159            .find(|(key, _)| key.len() == id.len() && key.eq_ignore_ascii_case(id))
160            .map(|(_, entry)| entry)
161    }
162
163    /// The `chr`-toggled form: `chr1` ↔ `1`.
164    ///
165    /// `get(..3)` rather than `&id[..3]`, which would panic on an id whose
166    /// third byte is inside a multi-byte character.
167    fn alt_id(id: &str) -> String {
168        match id.get(..3) {
169            Some(prefix) if prefix.eq_ignore_ascii_case("chr") => id[3..].to_string(),
170            _ => format!("chr{id}"),
171        }
172    }
173
174    pub fn get(&self, id: &str) -> Option<&ChrEntry> {
175        if let Some(entry) = self.try_lookup(id) {
176            return Some(entry);
177        }
178        self.try_lookup(&Self::alt_id(id))
179    }
180
181    pub fn contains(&self, id: &str) -> bool {
182        self.get(id).is_some()
183    }
184
185    /// Resolve a requested name, or fail with everything known about why not.
186    pub fn resolve(&self, id: &str) -> Result<&ChrEntry> {
187        match self.get(id) {
188            Some(entry) => Ok(entry),
189            None => Err(self.not_found(id)),
190        }
191    }
192
193    /// The error a failed lookup carries, as parts rather than a sentence.
194    ///
195    /// `key_size` is set only when the id cannot fit the file's declared name
196    /// field — which is what makes the lookup impossible rather than merely
197    /// unsuccessful — and `truncated_match` only when the id's first
198    /// `key_size` characters do spell a chromosome the file holds. Both forms
199    /// of the id are tried, since "chrX" may not fit a field that "X" would.
200    /// [`crate::error::Error`] turns them into the sentence.
201    fn not_found(&self, id: &str) -> Error {
202        let alt = Self::alt_id(id);
203        let mut key_size = None;
204        let mut truncated_match = None;
205        if let Some(declared) = self.declared_key_size.filter(|&k| k > 0) {
206            if id.chars().count() > declared && alt.chars().count() > declared {
207                key_size = Some(declared);
208                truncated_match = [id, alt.as_str()]
209                    .into_iter()
210                    .filter_map(|candidate| candidate.get(..declared))
211                    .find_map(|prefix| self.try_lookup(prefix))
212                    .map(|entry| entry.id.clone());
213            }
214        }
215        Error::UnknownChromosome {
216            id: id.to_string(),
217            key_size,
218            truncated_match,
219            available: self.entries.keys().cloned().collect(),
220        }
221    }
222
223    /// Which chromosomes a request walks: the ones named, resolved, in the
224    /// order they were asked for — or every chromosome in the file's order when
225    /// none were named.
226    pub fn select(&self, requested: &[String]) -> Result<Vec<ChrEntry>> {
227        if requested.is_empty() {
228            return Ok(self.entries.values().cloned().collect());
229        }
230        requested
231            .iter()
232            .map(|id| self.resolve(id).cloned())
233            .collect()
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn map(names: &[(&str, i64)]) -> ChrMap {
242        ChrMap::from_entries(names.iter().map(|(n, s)| (n.to_string(), *s)))
243    }
244
245    #[test]
246    fn resolves_exact_case_and_prefix_toggle() {
247        let m = map(&[("chr1", 100), ("chr2", 200)]);
248        for asked in ["chr1", "CHR1", "Chr1", "1"] {
249            assert_eq!(m.get(asked).unwrap().id, "chr1", "asking for {asked}");
250        }
251        let m = map(&[("1", 100), ("2", 200)]);
252        for asked in ["1", "chr1", "CHR1"] {
253            assert_eq!(m.get(asked).unwrap().id, "1", "asking for {asked}");
254        }
255    }
256
257    #[test]
258    fn mixed_case_id_reaches_a_uniform_case_key() {
259        // "ChrX" is neither all-lower nor all-upper, so only the walk finds it.
260        let m = map(&[("chrX", 10)]);
261        assert_eq!(m.get("ChrX").unwrap().id, "chrX");
262        assert_eq!(m.get("X").unwrap().id, "chrX");
263    }
264
265    #[test]
266    fn the_uppercase_form_is_tried_before_the_walk() {
267        // "ChrX" uppercases to "CHRX", which this map has, so the hashed
268        // lookup answers and the walk never runs.
269        let m = map(&[("chrX", 1), ("CHRX", 2)]);
270        assert_eq!(m.get("ChrX").unwrap().id, "CHRX");
271    }
272
273    #[test]
274    fn ties_in_the_walk_go_to_file_order() {
275        // Neither "chrx" nor "CHRX" is present, so only the walk can match,
276        // and it takes the first of the two in the file's own order.
277        let m = map(&[("chrX", 1), ("cHRx", 2)]);
278        assert_eq!(m.get("ChrX").unwrap().id, "chrX");
279        let m = map(&[("cHRx", 1), ("chrX", 2)]);
280        assert_eq!(m.get("ChrX").unwrap().id, "cHRx");
281    }
282
283    #[test]
284    fn ids_are_compared_whole() {
285        // Prefix matching would resolve "12" to "chr1", "chrXVII" to "chrXVI".
286        let m = map(&[("chr1", 100)]);
287        assert!(m.get("chr12").is_none());
288        assert!(m.get("12").is_none());
289        let m = map(&[("chrXVI", 100)]);
290        assert!(m.get("chrXVII").is_none());
291    }
292
293    #[test]
294    fn not_found_message_lists_what_is_available() {
295        let m = map(&[("chr1", 1), ("chr2", 2)]);
296        let message = m.resolve("chrZ").unwrap_err().to_string();
297        assert!(message.contains("not found"), "{message}");
298        assert!(message.contains("(available: chr1, chr2)"), "{message}");
299        assert!(!message.contains("field"), "{message}");
300    }
301
302    #[test]
303    fn an_over_long_id_is_told_it_cannot_fit() {
304        let m = map(&[("chr1_GL456210_random", 1)]).with_key_size(20);
305        // 24 characters, and stripping "chr" still leaves 21 — over the field
306        // both ways, which is what makes the clause apply.
307        let message = m
308            .resolve("chrX_GL456210_random_ext")
309            .unwrap_err()
310            .to_string();
311        assert!(message.contains("20-character field"), "{message}");
312        assert!(message.contains("24 characters long"), "{message}");
313    }
314
315    #[test]
316    fn an_over_long_id_names_the_chromosome_its_prefix_spells() {
317        let m = map(&[("chr1_GL456210_random", 1)]).with_key_size(20);
318        // 25 characters, 22 once "chr" comes off: over the field both ways, and
319        // its first 20 characters spell a chromosome the file does hold.
320        let message = m
321            .resolve("chr1_GL456210_random_v234")
322            .unwrap_err()
323            .to_string();
324        assert!(
325            message.contains("does hold chr1_GL456210_random"),
326            "{message}"
327        );
328    }
329
330    #[test]
331    fn an_id_that_fits_once_stripped_gets_no_field_clause() {
332        // 23 characters, but "chr" comes off and 20 fits the field, so the
333        // file could have held it and the clause must not fire.
334        let m = map(&[("chr1_GL456210_random", 1)]).with_key_size(20);
335        let message = m
336            .resolve("chrX_GL456210_random_ab")
337            .unwrap_err()
338            .to_string();
339        assert!(!message.contains("field"), "{message}");
340        assert!(message.contains("not found"), "{message}");
341    }
342
343    #[test]
344    fn select_takes_request_order_and_defaults_to_file_order() {
345        let m = map(&[("chr1", 1), ("chr2", 2), ("chr3", 3)]);
346        let all: Vec<_> = m
347            .select(&[])
348            .unwrap()
349            .iter()
350            .map(|e| e.id.clone())
351            .collect();
352        assert_eq!(all, ["chr1", "chr2", "chr3"]);
353        let some: Vec<_> = m
354            .select(&["chr3".into(), "1".into()])
355            .unwrap()
356            .iter()
357            .map(|e| e.id.clone())
358            .collect();
359        assert_eq!(some, ["chr3", "chr1"]);
360    }
361
362    #[test]
363    fn index_follows_insertion_order() {
364        let m = map(&[("chr1", 1), ("chr2", 2)]);
365        assert_eq!(m.get("chr2").unwrap().index, 1);
366        assert_eq!(m.by_index(0).unwrap().id, "chr1");
367        assert_eq!(m.genome_size(), 3);
368    }
369
370    #[test]
371    fn file_indices_are_kept_and_sorted_by() {
372        // Deliberately out of order and not 0-based, which is what a positional
373        // by_index would get wrong.
374        let m = ChrMap::from_indexed_entries([
375            ("chrB".to_string(), 20, 7),
376            ("chrA".to_string(), 10, 3),
377        ]);
378        assert_eq!(m.names(), ["chrA", "chrB"]);
379        assert_eq!(m.get("chrA").unwrap().index, 3);
380        assert_eq!(m.by_index(3).unwrap().id, "chrA");
381        assert_eq!(m.by_index(7).unwrap().id, "chrB");
382        assert!(m.by_index(0).is_none());
383    }
384}