Skip to main content

gwseq_io/bam/
record.rs

1//! One alignment.
2//!
3//! The design point is laziness: `cigar`, `sequence`,
4//! `qualities` and `tags` are decoded on first read and kept, so an alignment
5//! read for its coordinates never pays for the rest of it.
6//!
7//! The record owns `Bytes` — a refcounted slice of the decompressed BGZF chunk
8//! — so there is no per-record copy, and each derived field is a `OnceLock`.
9//!
10//! `OnceLock`, not the `OnceCell` a single-threaded cache would want: PyO3
11//! requires `#[pyclass]` types to be `Send + Sync`, and `OnceCell` is not
12//! `Sync`. The alternative — `#[pyclass(unsendable)]` — would pin every
13//! alignment to the thread that read it, which breaks handing a list of them
14//! to a Python thread pool. The cost is one acquire load per access on a field read a handful of times per
15//! record, against a decode that walks the whole record.
16
17use std::sync::{Arc, OnceLock};
18
19use bytes::Bytes;
20
21use crate::error::{Error, Result};
22
23/// Bytes of a record before its read name: `block_size` through `tlen`.
24pub const RECORD_HEADER_SIZE: usize = 36;
25
26/// The 36-byte fixed part of a record, decoded eagerly.
27#[derive(Debug, Clone, Copy)]
28pub struct RecordCore {
29    pub ref_id: i32,
30    pub pos: i32,
31    pub mapq: u8,
32    pub bai_bin: u16,
33    pub n_cigar_op: u16,
34    pub flag: u16,
35    pub l_seq: i32,
36    pub next_ref_id: i32,
37    pub next_pos: i32,
38    pub tlen: i32,
39}
40
41/// Where each variable-length field sits inside the record's own bytes.
42#[derive(Debug, Clone, Copy)]
43struct Layout {
44    name: usize,
45    cigar: usize,
46    seq: usize,
47    qual: usize,
48    tags: usize,
49    end: usize,
50}
51
52/// An optional field's value, typed as the file types it.
53#[derive(Debug, Clone, PartialEq)]
54pub enum TagValue {
55    Char(char),
56    Int(i64),
57    Float(f32),
58    Str(String),
59    IntArray(Vec<i64>),
60    FloatArray(Vec<f32>),
61}
62
63pub struct BamRecord {
64    /// The record's own bytes, from `block_size` to its end.
65    raw: Bytes,
66    core: RecordCore,
67    layout: Layout,
68    /// Reference length from the cigar, resolved at decode: the filter needs it
69    /// before anything else is built.
70    end: i64,
71    cigar_ops: Vec<u32>,
72    /// Whether the optional fields were kept at all.
73    has_tags: bool,
74    cigar: OnceLock<String>,
75    sequence: OnceLock<String>,
76    qualities: OnceLock<String>,
77    /// Holds a `Result`: a record with malformed optional fields reads fine and
78    /// fails here, which is the documented behaviour.
79    tags: OnceLock<Result<Vec<(String, TagValue)>>>,
80    /// Resolved reference names, shared with the reader.
81    chr_names: Arc<Vec<String>>,
82}
83
84impl std::fmt::Debug for BamRecord {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("BamRecord")
87            .field("chr", &self.chr())
88            .field("start", &self.start())
89            .field("end", &self.end)
90            .field("flag", &self.core.flag)
91            .finish()
92    }
93}
94
95impl BamRecord {
96    fn name_of(&self, index: i32) -> &str {
97        if index < 0 {
98            return "*";
99        }
100        self.chr_names
101            .get(index as usize)
102            .map(String::as_str)
103            .unwrap_or("*")
104    }
105
106    pub fn chr(&self) -> &str {
107        self.name_of(self.core.ref_id)
108    }
109
110    pub fn start(&self) -> i64 {
111        self.core.pos as i64
112    }
113
114    /// 0-based half-open end, derived from the cigar. Equal to `start` for an
115    /// alignment covering no reference.
116    pub fn end(&self) -> i64 {
117        self.end
118    }
119
120    pub fn read_name(&self) -> &str {
121        let bytes = &self.raw[self.layout.name..self.layout.cigar];
122        // NUL-terminated in the file.
123        let bytes = bytes.strip_suffix(&[0]).unwrap_or(bytes);
124        // A name is ASCII in every file anyone writes. One that is not gives
125        // back the part of it that is, rather than the empty string, which is
126        // what every such name used to come back as — so two of them collided
127        // with each other and with a record that has no name at all. Truncating
128        // costs no allocation, which a lossy conversion on a type that exists
129        // in millions would.
130        match std::str::from_utf8(bytes) {
131            Ok(name) => name,
132            Err(e) => std::str::from_utf8(&bytes[..e.valid_up_to()]).unwrap_or(""),
133        }
134    }
135
136    pub fn flag(&self) -> u16 {
137        self.core.flag
138    }
139
140    pub fn mapping_quality(&self) -> u8 {
141        self.core.mapq
142    }
143
144    pub fn bai_bin(&self) -> u16 {
145        self.core.bai_bin
146    }
147
148    pub fn next_chr(&self) -> &str {
149        self.name_of(self.core.next_ref_id)
150    }
151
152    pub fn next_start(&self) -> i64 {
153        self.core.next_pos as i64
154    }
155
156    pub fn template_length(&self) -> i64 {
157        self.core.tlen as i64
158    }
159
160    /// The CIGAR, e.g. `"10S80M10S"`.
161    ///
162    /// A cigar of more than 65 535 operations does not fit the record's own
163    /// field, so it is stored in a `CG` optional field and read from there at
164    /// decode; the placeholder the record carries in its place is never
165    /// returned, and the `CG` field is left in [`tags`](Self::tags).
166    pub fn cigar(&self) -> &str {
167        self.cigar.get_or_init(|| decode_cigar(&self.cigar_ops))
168    }
169
170    /// Bases of the reference the alignment covers.
171    pub fn reference_length(&self) -> i64 {
172        reference_length(&self.cigar_ops)
173    }
174
175    /// Bases of the read its cigar consumes.
176    pub fn query_length(&self) -> i64 {
177        query_length(&self.cigar_ops)
178    }
179
180    /// SEQ, unpacked from its 4-bit encoding, or `"*"` when the record carries
181    /// none.
182    pub fn sequence(&self) -> &str {
183        self.sequence.get_or_init(|| {
184            decode_sequence(
185                self.core.l_seq as i64,
186                &self.raw[self.layout.seq..self.layout.qual],
187            )
188        })
189    }
190
191    /// QUAL as phred+33, or `"*"` when the record carries no qualities at all.
192    /// A record with only some of them missing spells those `"*"` in place.
193    pub fn qualities(&self) -> &str {
194        self.qualities
195            .get_or_init(|| decode_qualities(&self.raw[self.layout.qual..self.layout.tags]))
196    }
197
198    /// Optional fields by two-letter tag, in the order the record stores them.
199    ///
200    /// Empty when the record was read with tags off. A record whose optional
201    /// fields are malformed reads fine and fails **here**.
202    pub fn tags(&self) -> Result<&[(String, TagValue)]> {
203        if !self.has_tags {
204            return Ok(&[]);
205        }
206        let parsed = self
207            .tags
208            .get_or_init(|| parse_tags(&self.raw[self.layout.tags..self.layout.end]));
209        match parsed {
210            Ok(tags) => Ok(tags),
211            // The cell holds the failure so a second read repeats it rather
212            // than re-walking bytes already known to be bad.
213            //
214            // Named by the alignment rather than by the file: a record does not
215            // carry its path — there are millions of them and it would be eight
216            // bytes each — and a caller holding one already knows which file it
217            // came out of. Which *alignment* is what they do not know, and it is
218            // what a `Corrupt` here used to say nothing about: the message read
219            // ": unterminated bam string tag RG (at offset 0)".
220            Err(e) => Err(Error::corrupt(
221                format!("{}:{}-{}", self.chr(), self.start(), self.end()),
222                self.core.pos.max(0) as u64,
223                format!("alignment {}: {e}", self.read_name()),
224            )),
225        }
226    }
227
228    // The flag bits the API decodes. Finer ones are the caller's to mask off
229    // `flag()`.
230    pub fn is_paired(&self) -> bool {
231        self.core.flag & 0x001 != 0
232    }
233    pub fn is_proper_pair(&self) -> bool {
234        self.core.flag & 0x002 != 0
235    }
236    pub fn is_mapped(&self) -> bool {
237        self.core.flag & 0x004 == 0
238    }
239    pub fn is_next_mapped(&self) -> bool {
240        self.core.flag & 0x008 == 0
241    }
242    pub fn is_reverse(&self) -> bool {
243        self.core.flag & 0x010 != 0
244    }
245    pub fn is_next_reverse(&self) -> bool {
246        self.core.flag & 0x020 != 0
247    }
248    pub fn is_first_in_pair(&self) -> bool {
249        self.core.flag & 0x040 != 0
250    }
251    pub fn is_last_in_pair(&self) -> bool {
252        self.core.flag & 0x080 != 0
253    }
254    pub fn is_secondary_or_supplementary(&self) -> bool {
255        self.core.flag & (0x100 | 0x800) != 0
256    }
257    pub fn is_failed_qc_or_duplicate(&self) -> bool {
258        self.core.flag & (0x200 | 0x400) != 0
259    }
260}
261
262/// M, D, N, `=`, X consume reference.
263fn reference_length(ops: &[u32]) -> i64 {
264    ops.iter()
265        .filter(|op| matches!(*op & 0xF, 0 | 2 | 3 | 7 | 8))
266        .map(|op| (op >> 4) as i64)
267        .sum()
268}
269
270/// M, I, S, `=`, X consume query.
271fn query_length(ops: &[u32]) -> i64 {
272    ops.iter()
273        .filter(|op| matches!(*op & 0xF, 0 | 1 | 4 | 7 | 8))
274        .map(|op| (op >> 4) as i64)
275        .sum()
276}
277
278fn decode_cigar(ops: &[u32]) -> String {
279    const OPS: &[u8] = b"MIDNSHP=X";
280    let mut out = String::with_capacity(ops.len() * 4);
281    for op in ops {
282        use std::fmt::Write as _;
283        let _ = write!(out, "{}", op >> 4);
284        out.push(match OPS.get((op & 0xF) as usize) {
285            Some(c) => *c as char,
286            None => '?',
287        });
288    }
289    out
290}
291
292fn decode_sequence(l_seq: i64, packed: &[u8]) -> String {
293    // SAM writes an absent sequence as "*". An empty string would read back as
294    // a sequence of no bases rather than as the absence of one.
295    if l_seq <= 0 {
296        return "*".to_string();
297    }
298    const LOOKUP: &[u8] = b"=ACMGRSVTWYHKDBN";
299    // Bounded by the read length rather than by the bytes holding it: an odd
300    // length leaves the low half of the last byte unused, and counting bytes
301    // would decode that padding as a base of its own.
302    let len = l_seq.min(packed.len() as i64 * 2) as usize;
303    let mut out = String::with_capacity(len);
304    for i in 0..len {
305        let byte = packed[i / 2];
306        let code = if i % 2 == 0 { byte >> 4 } else { byte & 0xF };
307        out.push(LOOKUP[code as usize] as char);
308    }
309    out
310}
311
312fn decode_qualities(quals: &[u8]) -> String {
313    // A record with no qualities stores 0xFF for every base, and SAM writes
314    // that back as a single "*". Rendering it per base would give a string as
315    // long as the read and indistinguishable from real scores of phred 9,
316    // '*' being 42 = 33 + 9.
317    if quals.iter().all(|q| *q == 0xFF) {
318        return "*".to_string();
319    }
320    // Widened before the offset: `q + 33` in `u8` overflows for any quality
321    // above 222, which is a panic in a debug build and a wrapped character in a
322    // release one. Phred qualities do not go that high, but these bytes come out
323    // of a file and nothing in the format stops them.
324    quals
325        .iter()
326        .map(|q| {
327            if *q == 0xFF {
328                '*'
329            } else {
330                char::from_u32(*q as u32 + 33).unwrap_or('?')
331            }
332        })
333        .collect()
334}
335
336/// Walk the optional fields, in the order the record stores them.
337///
338/// Every length read out of the block is checked against its end before
339/// anything is read with it: these are bytes from a file, and a corrupt block
340/// supplies them.
341fn parse_tags(raw: &[u8]) -> Result<Vec<(String, TagValue)>> {
342    let mut out = Vec::new();
343    let mut at = 0usize;
344    let size = raw.len();
345    // The message alone, without a path or an offset: neither is known here —
346    // these bytes are a slice of a record, which is a slice of a decompressed
347    // block — and `BamRecord::tags` wraps this in one that names the alignment.
348    let bad = |what: String| Error::invalid(what);
349
350    while at < size {
351        if at + 3 > size {
352            return Err(bad(
353                "truncated bam tag (no room for its tag and type)".into()
354            ));
355        }
356        // Exactly the two bytes the tag is, never trimmed: a corrupt block could
357        // hold a NUL there, and a shorter tag would then be reported.
358        let tag = String::from_utf8_lossy(&raw[at..at + 2]).into_owned();
359        let kind = raw[at + 2];
360        at += 3;
361
362        let value = match kind {
363            b'B' => {
364                if at + 5 > size {
365                    return Err(bad(format!("truncated bam array tag {tag}")));
366                }
367                let subtype = raw[at];
368                let count = u32::from_le_bytes([raw[at + 1], raw[at + 2], raw[at + 3], raw[at + 4]])
369                    as usize;
370                at += 5;
371                let element = match subtype {
372                    b'c' | b'C' => 1usize,
373                    b's' | b'S' => 2,
374                    b'i' | b'I' | b'f' => 4,
375                    other => {
376                        return Err(bad(format!(
377                            "unsupported bam array tag subtype {}",
378                            other as char
379                        )))
380                    }
381                };
382                let bytes = count
383                    .checked_mul(element)
384                    .filter(|n| at + n <= size)
385                    .ok_or_else(|| bad(format!("bam array tag {tag} runs past its record")))?;
386                let data = &raw[at..at + bytes];
387                at += bytes;
388                read_array(subtype, data)
389            }
390            b'Z' | b'H' => {
391                // Bounded by the block: an unterminated string would otherwise
392                // be scanned to the end of it and past it.
393                let start = at;
394                while at < size && raw[at] != 0 {
395                    at += 1;
396                }
397                if at >= size {
398                    return Err(bad(format!("unterminated bam string tag {tag}")));
399                }
400                let value = String::from_utf8_lossy(&raw[start..at]).into_owned();
401                at += 1;
402                TagValue::Str(value)
403            }
404            other => {
405                let width = match other {
406                    b'A' | b'c' | b'C' => 1usize,
407                    b's' | b'S' => 2,
408                    b'i' | b'I' | b'f' => 4,
409                    _ => return Err(bad(format!("unsupported tag type {}", other as char))),
410                };
411                if at + width > size {
412                    return Err(bad(format!("bam tag {tag} runs past its record")));
413                }
414                let data = &raw[at..at + width];
415                at += width;
416                read_scalar(other, data)
417            }
418        };
419        out.push((tag, value));
420    }
421    Ok(out)
422}
423
424fn read_scalar(kind: u8, data: &[u8]) -> TagValue {
425    match kind {
426        b'A' => TagValue::Char(data[0] as char),
427        b'c' => TagValue::Int(data[0] as i8 as i64),
428        b'C' => TagValue::Int(data[0] as i64),
429        b's' => TagValue::Int(i16::from_le_bytes([data[0], data[1]]) as i64),
430        b'S' => TagValue::Int(u16::from_le_bytes([data[0], data[1]]) as i64),
431        b'i' => TagValue::Int(i32::from_le_bytes([data[0], data[1], data[2], data[3]]) as i64),
432        b'I' => TagValue::Int(u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as i64),
433        _ => TagValue::Float(f32::from_le_bytes([data[0], data[1], data[2], data[3]])),
434    }
435}
436
437fn read_array(subtype: u8, data: &[u8]) -> TagValue {
438    match subtype {
439        b'f' => TagValue::FloatArray(
440            data.chunks_exact(4)
441                .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
442                .collect(),
443        ),
444        b'c' => TagValue::IntArray(data.iter().map(|b| *b as i8 as i64).collect()),
445        b'C' => TagValue::IntArray(data.iter().map(|b| *b as i64).collect()),
446        b's' => TagValue::IntArray(
447            data.chunks_exact(2)
448                .map(|c| i16::from_le_bytes([c[0], c[1]]) as i64)
449                .collect(),
450        ),
451        b'S' => TagValue::IntArray(
452            data.chunks_exact(2)
453                .map(|c| u16::from_le_bytes([c[0], c[1]]) as i64)
454                .collect(),
455        ),
456        b'i' => TagValue::IntArray(
457            data.chunks_exact(4)
458                .map(|c| i32::from_le_bytes([c[0], c[1], c[2], c[3]]) as i64)
459                .collect(),
460        ),
461        _ => TagValue::IntArray(
462            data.chunks_exact(4)
463                .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]) as i64)
464                .collect(),
465        ),
466    }
467}
468
469/// Whether a record's cigar is the placeholder standing in for a long one.
470///
471/// A cigar of more than 65 535 operations does not fit `n_cigar_op`, so the
472/// record stores `<l_seq>S<ref_len>N` there and the real operations in a `CG`
473/// optional field. Coordinates are unaffected either way, the placeholder's `N`
474/// carrying the reference length, so this is only about what the cigar itself
475/// reads back as.
476fn is_long_cigar_placeholder(ops: &[u32], l_seq: i32) -> bool {
477    ops.len() == 2
478        && (ops[0] & 0xF) == 4 // S
479        && (ops[0] >> 4) as i64 == l_seq as i64
480        && (ops[1] & 0xF) == 3 // N
481}
482
483/// The operations a record's `CG:B,I` field holds, or none when it carries no
484/// usable one.
485///
486/// Malformed optional fields are not an error here: a record reads fine and
487/// fails when its tags are looked at, and a long cigar is not the place to break
488/// that — the placeholder is kept instead, which is what the file says.
489///
490/// The `CG` field is left where it is rather than dropped once read, unlike
491/// htslib, which strips it. The tags of an alignment are the ones the file
492/// stores, and hiding one here would make `tags` disagree with the record.
493fn read_long_cigar(tag_bytes: &[u8]) -> Vec<u32> {
494    let Ok(tags) = parse_tags(tag_bytes) else {
495        return Vec::new();
496    };
497    for (tag, value) in tags {
498        if tag != "CG" {
499            continue;
500        }
501        if let TagValue::IntArray(values) = value {
502            return values.into_iter().map(|v| v as u32).collect();
503        }
504    }
505    Vec::new()
506}
507
508/// The default `filter=True`: drop unmapped alignments, improper pairs,
509/// secondary and supplementary records, and anything marked as failing QC or as
510/// a duplicate.
511#[derive(Debug, Clone, Copy)]
512pub struct RecordFilter {
513    pub enabled: bool,
514}
515
516impl Default for RecordFilter {
517    fn default() -> Self {
518        Self { enabled: true }
519    }
520}
521
522/// Which alignments a read keeps: the region, and optionally the flag rules.
523#[derive(Debug, Clone, Copy)]
524pub struct EntryFilter {
525    /// Reference index, or `None` to accept any.
526    pub chr_index: Option<i32>,
527    pub start: i64,
528    /// `None` to accept any position.
529    pub end: Option<i64>,
530    pub standard_flags: bool,
531}
532
533impl EntryFilter {
534    pub fn accepts(&self, chr_index: i32, start: i64, end: i64, flag: u16) -> bool {
535        if let Some(wanted) = self.chr_index {
536            if chr_index != wanted {
537                return false;
538            }
539        }
540        if let Some(region_end) = self.end {
541            // An empty region overlaps nothing, wherever the alignment sits.
542            if region_end <= self.start {
543                return false;
544            }
545            // An alignment whose cigar consumes no reference — a placed unmapped
546            // read, or one cigar'd entirely to insertions — has end == start and
547            // would otherwise be reported by a window strictly containing its
548            // position but not by one starting on it. htslib's `bam_endpos`
549            // treats a reference length of 0 as 1.
550            if start >= region_end || end.max(start + 1) <= self.start {
551                return false;
552            }
553        }
554        if self.standard_flags {
555            if flag & 0x004 != 0 {
556                return false; // unmapped
557            }
558            if flag & 0x001 != 0 && flag & 0x002 == 0 {
559                return false; // paired but not properly
560            }
561            if flag & (0x100 | 0x800) != 0 {
562                return false; // secondary or supplementary
563            }
564            if flag & (0x200 | 0x400) != 0 {
565                return false; // failed QC or duplicate
566            }
567        }
568        true
569    }
570}
571
572/// Read the alignment records packed into a block of decompressed BAM data.
573///
574/// `block` must start at a record boundary — which is what a virtual offset
575/// names. Every length read out of the file is checked against the end of the
576/// record it belongs to before anything is read with it.
577pub fn decode_block(
578    block: &Bytes,
579    parse_tags_flag: bool,
580    filter: &EntryFilter,
581    chr_names: &Arc<Vec<String>>,
582    path: &str,
583) -> Result<Vec<BamRecord>> {
584    let size = block.len();
585    let mut at = 0usize;
586    let mut out = Vec::new();
587
588    while at < size {
589        if at + 4 > size {
590            return Err(Error::corrupt(
591                path,
592                at as u64,
593                "truncated bam record (no room for its length)",
594            ));
595        }
596        let block_size =
597            u32::from_le_bytes([block[at], block[at + 1], block[at + 2], block[at + 3]]) as usize;
598        let record_end = at + 4 + block_size;
599        if block_size < RECORD_HEADER_SIZE - 4 || record_end > size {
600            return Err(Error::corrupt(
601                path,
602                at as u64,
603                format!(
604                    "truncated bam record (declares {block_size} bytes, {} left in the block)",
605                    size - at - 4
606                ),
607            ));
608        }
609
610        let r = &block[at..record_end];
611        let i32_at = |o: usize| i32::from_le_bytes([r[o], r[o + 1], r[o + 2], r[o + 3]]);
612        let u16_at = |o: usize| u16::from_le_bytes([r[o], r[o + 1]]);
613        let core = RecordCore {
614            ref_id: i32_at(4),
615            pos: i32_at(8),
616            mapq: r[13],
617            bai_bin: u16_at(14),
618            n_cigar_op: u16_at(16),
619            flag: u16_at(18),
620            l_seq: i32_at(20),
621            next_ref_id: i32_at(24),
622            next_pos: i32_at(28),
623            tlen: i32_at(32),
624        };
625        let l_read_name = r[12] as usize;
626
627        // The variable-length parts follow one another, so checking where the
628        // last of them ends checks all of them.
629        let name = RECORD_HEADER_SIZE;
630        let cigar = name + l_read_name;
631        let seq = cigar + core.n_cigar_op as usize * 4;
632        let qual = seq + (core.l_seq as usize).div_ceil(2);
633        let tags = qual + core.l_seq.max(0) as usize;
634        if l_read_name < 1 || core.l_seq < 0 || tags > r.len() {
635            return Err(Error::corrupt(
636                path,
637                at as u64,
638                format!(
639                    "bam record at {at} declares fields that do not fit its {block_size} bytes"
640                ),
641            ));
642        }
643        let layout = Layout {
644            name,
645            cigar,
646            seq,
647            qual,
648            tags,
649            end: r.len(),
650        };
651
652        let mut ops: Vec<u32> = r[cigar..seq]
653            .chunks_exact(4)
654            .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
655            .collect();
656        let mut end = core.pos as i64 + reference_length(&ops);
657
658        // Tested before the sequence and the tags are built, which is what makes
659        // skipping an alignment cheaper than keeping one.
660        if !filter.accepts(core.ref_id, core.pos as i64, end, core.flag) {
661            at = record_end;
662            continue;
663        }
664
665        // Read after the filter, and only for the records that carry one: the
666        // placeholder gives the same start and end as the cigar it stands for,
667        // so an alignment the request does not want costs nothing here. `end` is
668        // taken again from the real operations so the two cannot disagree.
669        if is_long_cigar_placeholder(&ops, core.l_seq) {
670            let real = read_long_cigar(&r[tags..]);
671            if !real.is_empty() {
672                end = core.pos as i64 + reference_length(&real);
673                ops = real;
674            }
675        }
676
677        out.push(BamRecord {
678            raw: block.slice(at..record_end),
679            core,
680            layout,
681            end,
682            cigar_ops: ops,
683            has_tags: parse_tags_flag,
684            cigar: OnceLock::new(),
685            sequence: OnceLock::new(),
686            qualities: OnceLock::new(),
687            tags: OnceLock::new(),
688            chr_names: chr_names.clone(),
689        });
690        at = record_end;
691    }
692    Ok(out)
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    fn names() -> Arc<Vec<String>> {
700        Arc::new(vec!["chr1".to_string(), "chr2".to_string()])
701    }
702
703    /// One record, laid out as the format lays one out.
704    #[allow(clippy::too_many_arguments)]
705    fn record(
706        ref_id: i32,
707        pos: i32,
708        flag: u16,
709        name: &str,
710        cigar: &[u32],
711        seq: &[u8],
712        quals: &[u8],
713        tags: &[u8],
714    ) -> Vec<u8> {
715        let l_seq = seq.len() as i32;
716        let packed: Vec<u8> = seq
717            .chunks(2)
718            .map(|pair| {
719                let hi = base_code(pair[0]);
720                let lo = pair.get(1).map(|b| base_code(*b)).unwrap_or(0);
721                (hi << 4) | lo
722            })
723            .collect();
724
725        let mut body = Vec::new();
726        body.extend_from_slice(&ref_id.to_le_bytes());
727        body.extend_from_slice(&pos.to_le_bytes());
728        body.push(name.len() as u8 + 1);
729        body.push(60); // mapq
730        body.extend_from_slice(&0u16.to_le_bytes()); // bin
731        body.extend_from_slice(&(cigar.len() as u16).to_le_bytes());
732        body.extend_from_slice(&flag.to_le_bytes());
733        body.extend_from_slice(&l_seq.to_le_bytes());
734        body.extend_from_slice(&(-1i32).to_le_bytes()); // next_ref_id
735        body.extend_from_slice(&(-1i32).to_le_bytes()); // next_pos
736        body.extend_from_slice(&0i32.to_le_bytes()); // tlen
737        body.extend_from_slice(name.as_bytes());
738        body.push(0);
739        for op in cigar {
740            body.extend_from_slice(&op.to_le_bytes());
741        }
742        body.extend_from_slice(&packed);
743        body.extend_from_slice(quals);
744        body.extend_from_slice(tags);
745
746        let mut out = ((body.len()) as u32).to_le_bytes().to_vec();
747        out.extend_from_slice(&body);
748        out
749    }
750
751    fn base_code(base: u8) -> u8 {
752        b"=ACMGRSVTWYHKDBN"
753            .iter()
754            .position(|b| *b == base)
755            .unwrap_or(15) as u8
756    }
757
758    fn op(len: u32, kind: u32) -> u32 {
759        (len << 4) | kind
760    }
761
762    fn any() -> EntryFilter {
763        EntryFilter {
764            chr_index: None,
765            start: 0,
766            end: None,
767            standard_flags: false,
768        }
769    }
770
771    fn decode(bytes: Vec<u8>, tags: bool) -> Vec<BamRecord> {
772        decode_block(&Bytes::from(bytes), tags, &any(), &names(), "test").unwrap()
773    }
774
775    #[test]
776    fn a_record_decodes_to_its_documented_fields() {
777        let bytes = record(
778            0,
779            100,
780            0x10,
781            "read1",
782            &[op(5, 4), op(80, 0)],
783            b"ACGTA",
784            &[30; 5],
785            &[],
786        );
787        let records = decode(bytes, false);
788        assert_eq!(records.len(), 1);
789        let r = &records[0];
790        assert_eq!(r.chr(), "chr1");
791        assert_eq!(r.start(), 100);
792        assert_eq!(r.end(), 180); // 80M consumes reference; 5S does not
793        assert_eq!(r.read_name(), "read1");
794        assert_eq!(r.cigar(), "5S80M");
795        assert_eq!(r.sequence(), "ACGTA");
796        // Phred 30 + 33 = 63 = '?'.
797        assert_eq!(r.qualities(), "?????");
798        assert_eq!(r.mapping_quality(), 60);
799        assert_eq!(r.reference_length(), 80);
800        assert_eq!(r.query_length(), 85);
801        assert!(r.is_reverse() && !r.is_paired());
802        assert_eq!(r.next_chr(), "*");
803    }
804
805    #[test]
806    fn an_absent_sequence_and_absent_qualities_read_as_a_star() {
807        let bytes = record(0, 10, 0, "r", &[op(10, 0)], b"", &[], &[]);
808        let records = decode(bytes, false);
809        assert_eq!(records[0].sequence(), "*");
810        assert_eq!(records[0].qualities(), "*");
811
812        // All-0xFF qualities are how the format says "none".
813        let bytes = record(0, 10, 0, "r", &[op(4, 0)], b"ACGT", &[0xFF; 4], &[]);
814        assert_eq!(decode(bytes, false)[0].qualities(), "*");
815    }
816
817    #[test]
818    fn one_missing_quality_among_real_ones_is_a_star_in_place() {
819        let bytes = record(
820            0,
821            10,
822            0,
823            "r",
824            &[op(4, 0)],
825            b"ACGT",
826            &[30, 0xFF, 30, 30],
827            &[],
828        );
829        let quals = decode(bytes, false)[0].qualities().to_string();
830        assert_eq!(quals.chars().nth(1), Some('*'));
831        assert_eq!(quals.len(), 4);
832    }
833
834    #[test]
835    fn an_odd_length_sequence_does_not_decode_its_padding() {
836        let bytes = record(0, 10, 0, "r", &[op(3, 0)], b"ACG", &[30; 3], &[]);
837        assert_eq!(decode(bytes, false)[0].sequence(), "ACG");
838    }
839
840    #[test]
841    fn tags_decode_to_their_declared_types_in_file_order() {
842        let mut tags = Vec::new();
843        tags.extend_from_slice(b"NMi");
844        tags.extend_from_slice(&3i32.to_le_bytes());
845        tags.extend_from_slice(b"RGZgroup1\0");
846        tags.extend_from_slice(b"XAA");
847        tags.push(b'x');
848        tags.extend_from_slice(b"XFf");
849        tags.extend_from_slice(&1.5f32.to_le_bytes());
850        tags.extend_from_slice(b"XBBc");
851        tags.extend_from_slice(&3u32.to_le_bytes());
852        tags.extend_from_slice(&[1u8, 2, 253]);
853
854        let bytes = record(0, 10, 0, "r", &[op(4, 0)], b"ACGT", &[30; 4], &tags);
855        let records = decode(bytes, true);
856        let got = records[0].tags().unwrap();
857        assert_eq!(got[0], ("NM".into(), TagValue::Int(3)));
858        assert_eq!(got[1], ("RG".into(), TagValue::Str("group1".into())));
859        assert_eq!(got[2], ("XA".into(), TagValue::Char('x')));
860        assert_eq!(got[3], ("XF".into(), TagValue::Float(1.5)));
861        // 'c' is signed, so 253 reads back as -3.
862        assert_eq!(got[4], ("XB".into(), TagValue::IntArray(vec![1, 2, -3])));
863    }
864
865    #[test]
866    fn tags_off_means_no_tags_rather_than_an_error() {
867        let mut tags = b"NMi".to_vec();
868        tags.extend_from_slice(&3i32.to_le_bytes());
869        let bytes = record(0, 10, 0, "r", &[op(4, 0)], b"ACGT", &[30; 4], &tags);
870        assert!(decode(bytes, false)[0].tags().unwrap().is_empty());
871    }
872
873    #[test]
874    fn a_record_with_malformed_tags_reads_fine_and_fails_at_tags() {
875        // A 'Z' string with no terminator.
876        let tags = b"RGZunterminated".to_vec();
877        let bytes = record(0, 10, 0, "r", &[op(4, 0)], b"ACGT", &[30; 4], &tags);
878        let records = decode(bytes, true);
879        // Everything else about the record still works.
880        assert_eq!(records[0].start(), 10);
881        assert_eq!(records[0].sequence(), "ACGT");
882        let err = records[0].tags().unwrap_err().to_string();
883        assert!(err.contains("unterminated"), "{err}");
884    }
885
886    #[test]
887    fn a_long_cigar_is_read_from_its_cg_tag_and_the_tag_is_kept() {
888        // The placeholder: <l_seq>S<ref_len>N, with the real ops in CG:B,I.
889        let real = [op(4, 0), op(6, 2)]; // 4M6D — 10 reference bases
890        let mut tags = b"CGBI".to_vec();
891        tags.extend_from_slice(&(real.len() as u32).to_le_bytes());
892        for o in real {
893            tags.extend_from_slice(&o.to_le_bytes());
894        }
895        let placeholder = [op(4, 4), op(10, 3)]; // 4S10N
896        let bytes = record(0, 50, 0, "r", &placeholder, b"ACGT", &[30; 4], &tags);
897
898        let records = decode(bytes, true);
899        assert_eq!(records[0].cigar(), "4M6D", "the placeholder was returned");
900        assert_eq!(records[0].end(), 60);
901        // The CG field is left where it is, unlike htslib which strips it.
902        assert!(records[0]
903            .tags()
904            .unwrap()
905            .iter()
906            .any(|(tag, _)| tag == "CG"));
907    }
908
909    #[test]
910    fn the_standard_filter_drops_what_it_documents() {
911        let filter = EntryFilter {
912            chr_index: None,
913            start: 0,
914            end: None,
915            standard_flags: true,
916        };
917        assert!(filter.accepts(0, 10, 20, 0x002)); // proper pair, mapped
918        assert!(filter.accepts(0, 10, 20, 0)); // unpaired, mapped
919        assert!(!filter.accepts(0, 10, 20, 0x004)); // unmapped
920        assert!(!filter.accepts(0, 10, 20, 0x001)); // paired, not properly
921        assert!(!filter.accepts(0, 10, 20, 0x100)); // secondary
922        assert!(!filter.accepts(0, 10, 20, 0x800)); // supplementary
923        assert!(!filter.accepts(0, 10, 20, 0x200)); // failed QC
924        assert!(!filter.accepts(0, 10, 20, 0x400)); // duplicate
925    }
926
927    #[test]
928    fn an_alignment_covering_no_reference_still_overlaps_a_window_on_it() {
929        // end == start, as a placed unmapped read has. htslib's bam_endpos
930        // treats a reference length of 0 as 1, and so does this.
931        let filter = EntryFilter {
932            chr_index: Some(0),
933            start: 100,
934            end: Some(200),
935            standard_flags: false,
936        };
937        assert!(filter.accepts(0, 100, 100, 0), "a window starting on it");
938        assert!(filter.accepts(0, 150, 150, 0));
939        assert!(!filter.accepts(0, 99, 99, 0));
940        assert!(!filter.accepts(0, 200, 200, 0));
941    }
942
943    #[test]
944    fn an_empty_region_overlaps_nothing() {
945        let filter = EntryFilter {
946            chr_index: Some(0),
947            start: 100,
948            end: Some(100),
949            standard_flags: false,
950        };
951        assert!(!filter.accepts(0, 100, 200, 0));
952    }
953
954    #[test]
955    fn a_truncated_record_is_corrupt_not_a_panic() {
956        let mut bytes = record(0, 10, 0, "r", &[op(4, 0)], b"ACGT", &[30; 4], &[]);
957        bytes.truncate(bytes.len() - 6);
958        let err = decode_block(&Bytes::from(bytes), false, &any(), &names(), "test")
959            .unwrap_err()
960            .to_string();
961        assert!(err.contains("truncated bam record"), "{err}");
962    }
963
964    #[test]
965    fn a_record_declaring_fields_past_its_own_length_is_refused() {
966        let mut bytes = record(0, 10, 0, "r", &[op(4, 0)], b"ACGT", &[30; 4], &[]);
967        // Claim 500 cigar operations in a record that holds one.
968        bytes[16 + 4] = 244;
969        bytes[17 + 4] = 1;
970        let err = decode_block(&Bytes::from(bytes), false, &any(), &names(), "test")
971            .unwrap_err()
972            .to_string();
973        assert!(err.contains("do not fit"), "{err}");
974    }
975
976    #[test]
977    fn several_records_in_one_block_all_decode() {
978        let mut bytes = record(0, 10, 0, "a", &[op(4, 0)], b"ACGT", &[30; 4], &[]);
979        bytes.extend(record(0, 20, 0, "b", &[op(4, 0)], b"TGCA", &[31; 4], &[]));
980        bytes.extend(record(1, 30, 0, "c", &[op(4, 0)], b"GGGG", &[32; 4], &[]));
981        let records = decode(bytes, false);
982        assert_eq!(records.len(), 3);
983        assert_eq!(records[2].chr(), "chr2");
984        assert_eq!(records[1].read_name(), "b");
985    }
986}