Skip to main content

gwseq_io/bbi/
header.rs

1//! bbi headers: the common header, the zoom ladder, the total summary, autoSql.
2//!
3//! Field offsets are Supp. Tables
4//! 5–7 of the bigWig paper; they are spelled out here rather than derived from
5//! a struct layout, because the format's fields are not aligned and a
6//! `#[repr(C)]` view of them would be undefined behaviour on half of them.
7
8use crate::error::{Error, Result};
9use crate::source::ByteSource;
10
11/// Byte sizes of the fixed-width blocks (Supp. Tables 4–8, 14).
12pub const BBI_HEADER_SIZE: u64 = 64;
13pub const ZOOM_HEADER_SIZE: u64 = 24;
14pub const TOTAL_SUMMARY_SIZE: u64 = 40;
15pub const CHR_TREE_HEADER_SIZE: u64 = 32;
16/// The R-tree index header the tree nodes follow.
17pub const DATA_TREE_HEADER_SIZE: u64 = 48;
18
19pub const BIGWIG_MAGIC_SWAPPED: u32 = 0x26FC_8F88;
20pub const BIGBED_MAGIC_SWAPPED: u32 = 0xEBF2_8987;
21pub const CHR_TREE_MAGIC: u32 = 0x78CA_8C91;
22pub const CHR_TREE_MAGIC_SWAPPED: u32 = 0x91CA_8C78;
23pub const DATA_TREE_MAGIC: u32 = 0x2468_ACE0;
24pub const DATA_TREE_MAGIC_SWAPPED: u32 = 0xE0AC_6824;
25
26pub const BBI_MIN_VERSION: u16 = 3;
27pub const BBI_OUTPUT_VERSION: u16 = 4;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum BbiKind {
31    BigWig,
32    BigBed,
33}
34
35impl BbiKind {
36    pub fn as_str(self) -> &'static str {
37        match self {
38            BbiKind::BigWig => "bigwig",
39            BbiKind::BigBed => "bigbed",
40        }
41    }
42
43    pub fn is_bigbed(self) -> bool {
44        matches!(self, BbiKind::BigBed)
45    }
46}
47
48#[derive(Debug, Clone)]
49pub struct BbiHeader {
50    pub kind: BbiKind,
51    pub version: u16,
52    pub zoom_levels: u16,
53    pub chr_tree_offset: u64,
54    pub full_data_offset: u64,
55    pub full_index_offset: u64,
56    /// bigBed only.
57    pub field_count: u16,
58    pub defined_field_count: u16,
59    pub auto_sql_offset: u64,
60    pub total_summary_offset: u64,
61    /// 0 when the file stores its blocks uncompressed.
62    pub uncompress_buffer_size: u32,
63}
64
65#[derive(Debug, Clone, Copy)]
66pub struct ZoomHeader {
67    pub reduction_level: u32,
68    pub data_offset: u64,
69    pub index_offset: u64,
70}
71
72/// The summary block is optional: a file that carries none sets its offset to
73/// 0, and the reader leaves this as it stands rather than parsing 40 bytes of
74/// whatever happens to sit at the start of the file. Hence NaN extremes.
75#[derive(Debug, Clone, Copy)]
76pub struct TotalSummary {
77    pub bases_covered: u64,
78    pub min_value: f64,
79    pub max_value: f64,
80    pub sum_data: f64,
81    pub sum_squared: f64,
82}
83
84impl Default for TotalSummary {
85    fn default() -> Self {
86        Self {
87            bases_covered: 0,
88            min_value: f64::NAN,
89            max_value: f64::NAN,
90            sum_data: 0.0,
91            sum_squared: 0.0,
92        }
93    }
94}
95
96#[derive(Debug, Clone, Copy)]
97pub struct ChrTreeHeader {
98    pub block_size: u32,
99    pub key_size: u32,
100    pub val_size: u32,
101    pub item_count: u64,
102}
103
104/// Read the common header.
105///
106/// A byte-swapped magic is recognised and **refused**: these readers do not
107/// swap, and saying so is more useful than reporting the file as unrecognisable.
108pub fn read_header(source: &dyn ByteSource) -> Result<BbiHeader> {
109    let buf = source.read_exact_at(0, BBI_HEADER_SIZE as usize)?;
110    let path = source.path();
111    let mut c = crate::bytes::LeCursor::new(&buf, 0, path);
112
113    let magic = c.read_u32()?;
114    let kind = match magic {
115        super::BIGWIG_MAGIC => BbiKind::BigWig,
116        super::BIGBED_MAGIC => BbiKind::BigBed,
117        BIGWIG_MAGIC_SWAPPED | BIGBED_MAGIC_SWAPPED => {
118            return Err(Error::format(path, "incompatible endianness"))
119        }
120        _ => return Err(Error::format(path, "not a bigwig or bigbed file")),
121    };
122
123    let version = c.read_u16()?;
124    if version < BBI_MIN_VERSION {
125        return Err(Error::format(
126            path,
127            format!("bigwig or bigbed version {version} unsupported (>= {BBI_MIN_VERSION})"),
128        ));
129    }
130
131    Ok(BbiHeader {
132        kind,
133        version,
134        zoom_levels: c.read_u16()?,
135        chr_tree_offset: c.read_u64()?,
136        full_data_offset: c.read_u64()?,
137        full_index_offset: c.read_u64()?,
138        field_count: c.read_u16()?,
139        defined_field_count: c.read_u16()?,
140        auto_sql_offset: c.read_u64()?,
141        total_summary_offset: c.read_u64()?,
142        uncompress_buffer_size: c.read_u32()?,
143        // Eight reserved bytes follow at offset 56. UCSC reads them as an
144        // extension offset on version 4 files and follows the pointer when they
145        // are not zero, so they cannot be repurposed.
146    })
147}
148
149pub fn read_zoom_headers(source: &dyn ByteSource, count: u16) -> Result<Vec<ZoomHeader>> {
150    if count == 0 {
151        return Ok(Vec::new());
152    }
153    let len = count as u64 * ZOOM_HEADER_SIZE;
154    let buf = source.read_exact_at(BBI_HEADER_SIZE, len as usize)?;
155    let mut c = crate::bytes::LeCursor::new(&buf, BBI_HEADER_SIZE, source.path());
156    let mut headers = Vec::with_capacity(count as usize);
157    for _ in 0..count {
158        let reduction_level = c.read_u32()?;
159        c.skip(4)?; // reserved
160        headers.push(ZoomHeader {
161            reduction_level,
162            data_offset: c.read_u64()?,
163            index_offset: c.read_u64()?,
164        });
165    }
166    Ok(headers)
167}
168
169/// Read the total summary, or the default when the file declares none.
170pub fn read_total_summary(source: &dyn ByteSource, offset: u64) -> Result<TotalSummary> {
171    if offset == 0 {
172        return Ok(TotalSummary::default());
173    }
174    let buf = source.read_exact_at(offset, TOTAL_SUMMARY_SIZE as usize)?;
175    let mut c = crate::bytes::LeCursor::new(&buf, offset, source.path());
176    Ok(TotalSummary {
177        bases_covered: c.read_u64()?,
178        min_value: c.read_f64()?,
179        max_value: c.read_f64()?,
180        sum_data: c.read_f64()?,
181        sum_squared: c.read_f64()?,
182    })
183}
184
185/// The R-tree index header that precedes the tree nodes.
186///
187/// Only the magic is checked here: everything else the walk needs, it reads off
188/// the nodes themselves.
189pub fn check_data_tree_magic(source: &dyn ByteSource, offset: u64) -> Result<()> {
190    let buf = source.read_exact_at(offset, 4)?;
191    let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
192    match magic {
193        DATA_TREE_MAGIC => Ok(()),
194        DATA_TREE_MAGIC_SWAPPED => Err(Error::format(
195            source.path(),
196            "incompatible endianness (data tree)",
197        )),
198        _ => Err(Error::format(
199            source.path(),
200            "invalid data tree magic number",
201        )),
202    }
203}
204
205/// The standard predefined BED field names, in order (Supp. Table 1), used to
206/// name the columns of a file carrying no autoSql of its own.
207pub const BED_FIELD_NAMES: &[&str] = &[
208    "chrom",
209    "chromStart",
210    "chromEnd",
211    "name",
212    "score",
213    "strand",
214    "thickStart",
215    "thickEnd",
216    "itemRgb",
217    "blockCount",
218    "blockSizes",
219    "blockStarts",
220];
221
222/// The bigBed column declaration. Field order matters — the first three are the
223/// coordinates — so this is an `IndexMap`, not a `HashMap`.
224///
225/// An offset of 0 means the file carries no autoSql, and the standard BED names
226/// stand in for as many columns as its header declares.
227///
228/// The text is one field per line, `type name1, name2;`. The parser takes
229/// everything up to the first semicolon on a line as the field list, which is
230/// why the writer must keep semicolons out of comments: one there would be read
231/// as another field and the count would stop matching the header.
232pub fn read_auto_sql(
233    source: &dyn ByteSource,
234    offset: u64,
235    field_count: u16,
236) -> Result<indexmap::IndexMap<String, String>> {
237    if offset == 0 {
238        return Ok(default_bed_fields(field_count as usize));
239    }
240    let text = read_nul_terminated(source, offset)?;
241
242    let mut fields = indexmap::IndexMap::new();
243    for line in text.lines() {
244        // `<type> <names>;` — the first whitespace-delimited token is the type,
245        // everything up to the first `;` is the comma-separated name list.
246        let Some((head, rest)) = split_once_whitespace(line.trim()) else {
247            continue;
248        };
249        let Some(list) = rest.split(';').next().filter(|l| !l.trim().is_empty()) else {
250            continue;
251        };
252        if !rest.contains(';') {
253            continue;
254        }
255        for name in list.split(',') {
256            // The declaration may carry an array size — `int[blockCount] sizes`
257            // — and a trailing comment; the name is the first token either way.
258            let name = name.split_whitespace().next().unwrap_or("");
259            if !name.is_empty() {
260                fields.insert(name.to_string(), head.to_string());
261            }
262        }
263    }
264
265    validate_bed_field_names(&fields, source.path())?;
266    if fields.len() != field_count as usize {
267        return Err(Error::format(
268            source.path(),
269            format!(
270                "field count {field_count} does not match autosql field count {}",
271                fields.len()
272            ),
273        ));
274    }
275    Ok(fields)
276}
277
278/// Split on the whitespace that separates an autoSql field's type from its
279/// names.
280///
281/// Not simply the first run of it: a real bigBed declares
282/// `enum("+", "-", ".") strand;`, and `set(a, b) flags;`, whose types carry
283/// spaces inside their parentheses. Whitespace inside a bracket or a quoted
284/// string is part of the type, and the split is the first run outside both — so
285/// the line above is the type `enum("+", "-", ".")` and the name `strand`,
286/// rather than the type `enum("+",` and a field count one short.
287fn split_once_whitespace(line: &str) -> Option<(&str, &str)> {
288    let (mut depth, mut quote) = (0i32, None::<char>);
289    let mut head_end = None;
290    for (at, c) in line.char_indices() {
291        match quote {
292            Some(q) => {
293                if c == q {
294                    quote = None;
295                }
296            }
297            None => match c {
298                '"' | '\'' => quote = Some(c),
299                '(' | '[' | '{' => depth += 1,
300                ')' | ']' | '}' => depth -= 1,
301                _ if depth <= 0 && c.is_whitespace() => {
302                    head_end = Some(at);
303                    break;
304                }
305                _ => {}
306            },
307        }
308    }
309    let (head, rest) = line.split_at(head_end?);
310    Some((head, rest.trim_start()))
311}
312
313/// The autoSql text is NUL-terminated and its length is nowhere in the header,
314/// so it is read in chunks until the terminator turns up.
315fn read_nul_terminated(source: &dyn ByteSource, offset: u64) -> Result<String> {
316    const CHUNK: usize = 4096;
317    // An autoSql block describing a few hundred columns is a few kilobytes; a
318    // file claiming megabytes of it is corrupt, not verbose.
319    const MAX: usize = 1 << 20;
320    let mut buf: Vec<u8> = Vec::new();
321    loop {
322        let chunk = source.read_at(offset + buf.len() as u64, CHUNK)?;
323        if chunk.is_empty() {
324            return Err(Error::corrupt(
325                source.path(),
326                offset,
327                "autosql block is not NUL-terminated before the end of the file",
328            ));
329        }
330        if let Some(at) = memchr::memchr(0, &chunk) {
331            buf.extend_from_slice(&chunk[..at]);
332            break;
333        }
334        buf.extend_from_slice(&chunk);
335        if buf.len() > MAX {
336            return Err(Error::corrupt(
337                source.path(),
338                offset,
339                format!("autosql block is longer than {MAX} bytes"),
340            ));
341        }
342    }
343    String::from_utf8(buf)
344        .map_err(|_| Error::corrupt(source.path(), offset, "autosql is not valid UTF-8"))
345}
346
347/// Check that the first three fields are the chromosome, the start and the end,
348/// in that order.
349///
350/// Shared by the reader, which validates the autoSql it parses, and by the
351/// writer, which validates the fields it was handed before turning them into
352/// autoSql: a description this accepts on the way out must be one it accepts on
353/// the way back in.
354///
355/// `chrom` may be spelled `chr`, since a BED written from this library names
356/// its first column that, and the start and end may or may not carry the
357/// `chrom` prefix. Hand-written matchers rather than regexes — three fixed
358/// shapes do not need a regex engine.
359pub fn validate_bed_field_names(
360    fields: &indexmap::IndexMap<String, String>,
361    path: &str,
362) -> Result<()> {
363    let bad = || Error::format(path, "missing or misplaced chr, start or end in autosql");
364    if fields.len() < 3 {
365        return Err(bad());
366    }
367    let name = |i: usize| fields.get_index(i).map(|(k, _)| k.as_str()).unwrap_or("");
368    if !is_chr_name(name(0)) || !is_coord_name(name(1), "start") || !is_coord_name(name(2), "end") {
369        return Err(bad());
370    }
371    Ok(())
372}
373
374/// `chr`, `chrom`, and either with an `id` or `name` suffix: `chr_id`,
375/// `chromName`, `chrom_id`, …
376fn is_chr_name(name: &str) -> bool {
377    let lower = name.to_ascii_lowercase();
378    let rest = match strip_chr_prefix(&lower) {
379        Some(rest) => rest,
380        None => return false,
381    };
382    let rest = rest.strip_prefix('_').unwrap_or(rest);
383    matches!(rest, "" | "id" | "name")
384}
385
386/// `start`/`end`, with an optional `chr`/`chrom` prefix: `chromStart`,
387/// `chrom_end`, `start`, …
388fn is_coord_name(name: &str, suffix: &str) -> bool {
389    let lower = name.to_ascii_lowercase();
390    if lower == suffix {
391        return true;
392    }
393    let Some(rest) = strip_chr_prefix(&lower) else {
394        return false;
395    };
396    rest.strip_prefix('_').unwrap_or(rest) == suffix
397}
398
399fn strip_chr_prefix(lower: &str) -> Option<&str> {
400    let rest = lower.strip_prefix("chr")?;
401    Some(rest.strip_prefix("om").unwrap_or(rest))
402}
403
404/// The standard BED column names for a given width — `field13` and up past the
405/// twelve the format names.
406pub fn default_bed_fields(col_count: usize) -> indexmap::IndexMap<String, String> {
407    (0..col_count)
408        .map(|i| {
409            let name = BED_FIELD_NAMES
410                .get(i)
411                .map(|n| (*n).to_string())
412                .unwrap_or_else(|| format!("field{}", i + 1));
413            (name, "string".to_string())
414        })
415        .collect()
416}
417
418/// Narrow a count or an offset to the 32 bits a bbi file stores it on.
419///
420/// Every coordinate, count and index the writer puts in a
421/// file goes through this: the format is 32-bit throughout, and a value that
422/// does not fit has to be refused where it is written rather than truncated
423/// into a file that reads back as something else.
424pub fn to_bbi_u32(value: i64, what: &str) -> Result<u32> {
425    if !(0..=0xFFFF_FFFF).contains(&value) {
426        return Err(Error::invalid(format!(
427            "{what} {value} does not fit the 32 bits a bigwig or bigbed file stores it on"
428        )));
429    }
430    Ok(value as u32)
431}
432
433/// Narrow an `f64` accumulator to the `f32` a zoom record stores it on,
434/// clamping to the range `f32` can represent.
435///
436/// An `as` cast saturates to infinity, and a reader would carry that value
437/// into every mean it computes. Clamping to the extremes keeps a summary
438/// finite.
439pub fn to_bbi_f32(value: f64) -> f32 {
440    const LIMIT: f64 = f32::MAX as f64;
441    if value > LIMIT {
442        return f32::MAX;
443    }
444    if value < -LIMIT {
445        return f32::MIN;
446    }
447    value as f32
448}
449
450/// The 64 bytes of the common header (Supp. Table 5), **with the magic left as
451/// zero**.
452///
453/// The writer stamps the magic separately and last, so a write that dies partway
454/// leaves a file no reader accepts rather than a header pointing at data that
455/// was never written.
456pub fn write_header(header: &BbiHeader) -> Result<Vec<u8>> {
457    let mut out = Vec::with_capacity(BBI_HEADER_SIZE as usize);
458    out.extend_from_slice(&0u32.to_le_bytes()); // magic, stamped separately
459    out.extend_from_slice(&header.version.to_le_bytes());
460    out.extend_from_slice(&header.zoom_levels.to_le_bytes());
461    out.extend_from_slice(&header.chr_tree_offset.to_le_bytes());
462    out.extend_from_slice(&header.full_data_offset.to_le_bytes());
463    out.extend_from_slice(&header.full_index_offset.to_le_bytes());
464    out.extend_from_slice(&header.field_count.to_le_bytes());
465    out.extend_from_slice(&header.defined_field_count.to_le_bytes());
466    out.extend_from_slice(&header.auto_sql_offset.to_le_bytes());
467    out.extend_from_slice(&header.total_summary_offset.to_le_bytes());
468    out.extend_from_slice(&header.uncompress_buffer_size.to_le_bytes());
469    out.extend_from_slice(&[0u8; 8]); // reserved
470    debug_assert_eq!(out.len(), BBI_HEADER_SIZE as usize);
471    Ok(out)
472}
473
474/// The 24 bytes of one zoom header (Supp. Table 6).
475pub fn write_zoom_header(header: &ZoomHeader) -> Vec<u8> {
476    let mut out = Vec::with_capacity(ZOOM_HEADER_SIZE as usize);
477    out.extend_from_slice(&header.reduction_level.to_le_bytes());
478    out.extend_from_slice(&0u32.to_le_bytes()); // reserved
479    out.extend_from_slice(&header.data_offset.to_le_bytes());
480    out.extend_from_slice(&header.index_offset.to_le_bytes());
481    out
482}
483
484/// The 40 bytes of the total summary (Supp. Table 7).
485///
486/// A file carrying no data writes zero extremes rather than the NaN the struct
487/// defaults to: the block is present, so a reader parses it, and NaN extremes
488/// would propagate into everything computed from them.
489pub fn write_total_summary(summary: &TotalSummary) -> Vec<u8> {
490    let empty = summary.bases_covered == 0;
491    let mut out = Vec::with_capacity(TOTAL_SUMMARY_SIZE as usize);
492    out.extend_from_slice(&summary.bases_covered.to_le_bytes());
493    out.extend_from_slice(&if empty { 0.0 } else { summary.min_value }.to_le_bytes());
494    out.extend_from_slice(&if empty { 0.0 } else { summary.max_value }.to_le_bytes());
495    out.extend_from_slice(&summary.sum_data.to_le_bytes());
496    out.extend_from_slice(&summary.sum_squared.to_le_bytes());
497    out
498}
499
500/// The field types an autoSql declaration may name.
501pub const BED_FIELD_TYPES: &[&str] = &["string", "int", "uint", "float"];
502
503/// An autoSql declaration and the two counts the header states alongside it.
504#[derive(Debug, Clone)]
505pub struct BedAutoSql {
506    pub text: String,
507    pub field_count: u16,
508    /// How many leading columns are the standard BED ones, which is what tells
509    /// a browser a bigBed is a bed4, a bed6 or a bed12.
510    pub defined_field_count: u16,
511}
512
513/// What the standard columns are for, as UCSC describes them (Supp. Table 2).
514///
515/// A browser shows these, so the ones it already knows are named the way it
516/// expects and the rest fall back to the name the caller gave.
517const BED_FIELD_COMMENTS: &[&str] = &[
518    "Reference sequence chromosome or scaffold",
519    "Start position in chromosome",
520    "End position in chromosome",
521    "Name of item",
522    "Score from 0-1000",
523    "+ or - for strand",
524    "Start of where display should be thick",
525    "End of where display should be thick",
526    "Used as itemRgb as of 2004-11-22",
527    "Number of blocks",
528    "Comma separated list of block sizes",
529    "Start positions relative to chromStart",
530];
531
532/// Turn a writer's declared columns into the autoSql its header points at.
533///
534/// The first three are the coordinates whatever the caller called them, and go
535/// out under the standard names and types: a browser reading the file expects
536/// `chrom`, `chromStart`, `chromEnd`, and the writer's own names are only ever
537/// used to address the values it is handed.
538pub fn build_auto_sql(fields: &indexmap::IndexMap<String, String>) -> Result<BedAutoSql> {
539    validate_bed_field_names(fields, "fields")?;
540    for (name, kind) in fields {
541        if !BED_FIELD_TYPES.contains(&kind.as_str()) {
542            return Err(Error::invalid(format!(
543                "field type {kind} of {name} invalid (string, int, uint, float)"
544            )));
545        }
546        // autoSql separates a type from its names with whitespace, its names
547        // from each other with commas, and a declaration from the next with a
548        // semicolon. A name carrying any of those writes a block this library's
549        // own reader parses into a different number of fields than the header
550        // declares — a file written here and then refused here.
551        if let Some(bad) = name
552            .chars()
553            .find(|c| c.is_whitespace() || matches!(c, ';' | ',' | '(' | ')' | '"'))
554        {
555            return Err(Error::invalid(format!(
556                "field name {name:?} contains {bad:?}, which autosql uses as punctuation                  (names may not hold whitespace, ';', ',', parentheses or quotes)"
557            )));
558        }
559        if name.is_empty() {
560            return Err(Error::invalid("a field name may not be empty"));
561        }
562    }
563
564    let named = |index: usize, given: &str| -> String {
565        if index < 3 {
566            BED_FIELD_NAMES[index].to_string()
567        } else {
568            given.to_string()
569        }
570    };
571
572    let mut text = String::from("table gwseqBed\n\"gwseq_io bed entries\"\n(\n");
573    for (index, (given, kind)) in fields.iter().enumerate() {
574        let name = named(index, given);
575        let standard =
576            index < 3 || (index < BED_FIELD_NAMES.len() && given == BED_FIELD_NAMES[index]);
577        let kind = if index < 3 {
578            if index == 0 {
579                "string"
580            } else {
581                "uint"
582            }
583        } else {
584            kind.as_str()
585        };
586        let comment = if standard {
587            BED_FIELD_COMMENTS[index]
588        } else {
589            &name
590        };
591        text.push_str(&format!("{kind} {name};\t\"{comment}\"\n"));
592    }
593    text.push_str(")\n");
594
595    let mut defined_field_count = 0u16;
596    for (index, (given, _)) in fields.iter().enumerate() {
597        let name = named(index, given);
598        if index >= BED_FIELD_NAMES.len() || name != BED_FIELD_NAMES[index] {
599            break;
600        }
601        defined_field_count += 1;
602    }
603
604    Ok(BedAutoSql {
605        text,
606        field_count: to_bbi_u32(fields.len() as i64, "fieldCount")? as u16,
607        defined_field_count,
608    })
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::source::testing::MemorySource;
615
616    /// A minimal but valid 64-byte header, plus whatever follows.
617    fn header_bytes(magic: u32, version: u16, zoom_levels: u16) -> Vec<u8> {
618        let mut b = Vec::new();
619        b.extend_from_slice(&magic.to_le_bytes());
620        b.extend_from_slice(&version.to_le_bytes());
621        b.extend_from_slice(&zoom_levels.to_le_bytes());
622        b.extend_from_slice(&1000u64.to_le_bytes()); // chromTreeOffset
623        b.extend_from_slice(&2000u64.to_le_bytes()); // fullDataOffset
624        b.extend_from_slice(&3000u64.to_le_bytes()); // fullIndexOffset
625        b.extend_from_slice(&9u16.to_le_bytes()); // fieldCount
626        b.extend_from_slice(&6u16.to_le_bytes()); // definedFieldCount
627        b.extend_from_slice(&400u64.to_le_bytes()); // autoSqlOffset
628        b.extend_from_slice(&500u64.to_le_bytes()); // totalSummaryOffset
629        b.extend_from_slice(&32768u32.to_le_bytes()); // uncompressBufSize
630        b.extend_from_slice(&0u64.to_le_bytes()); // reserved
631        assert_eq!(b.len(), 64);
632        b
633    }
634
635    #[test]
636    fn reads_a_bigwig_header() {
637        let source = MemorySource::new(header_bytes(super::super::BIGWIG_MAGIC, 4, 0));
638        let h = read_header(&source).unwrap();
639        assert_eq!(h.kind, BbiKind::BigWig);
640        assert_eq!(h.version, 4);
641        assert_eq!(h.chr_tree_offset, 1000);
642        assert_eq!(h.full_index_offset, 3000);
643        assert_eq!(h.field_count, 9);
644        assert_eq!(h.uncompress_buffer_size, 32768);
645    }
646
647    #[test]
648    fn a_bigbed_magic_gives_the_other_kind() {
649        let source = MemorySource::new(header_bytes(super::super::BIGBED_MAGIC, 4, 0));
650        assert_eq!(read_header(&source).unwrap().kind, BbiKind::BigBed);
651    }
652
653    #[test]
654    fn a_byte_swapped_file_is_refused_by_name() {
655        for magic in [BIGWIG_MAGIC_SWAPPED, BIGBED_MAGIC_SWAPPED] {
656            let source = MemorySource::new(header_bytes(magic, 4, 0));
657            let err = read_header(&source).unwrap_err().to_string();
658            assert!(err.contains("incompatible endianness"), "{err}");
659        }
660    }
661
662    #[test]
663    fn something_else_entirely_is_not_a_bbi_file() {
664        let source = MemorySource::new(header_bytes(0xDEAD_BEEF, 4, 0));
665        let err = read_header(&source).unwrap_err().to_string();
666        assert!(err.contains("not a bigwig or bigbed file"), "{err}");
667    }
668
669    #[test]
670    fn an_old_version_names_the_floor() {
671        let source = MemorySource::new(header_bytes(super::super::BIGWIG_MAGIC, 2, 0));
672        let err = read_header(&source).unwrap_err().to_string();
673        assert!(err.contains("version 2 unsupported (>= 3)"), "{err}");
674    }
675
676    #[test]
677    fn a_truncated_header_is_corrupt_not_a_panic() {
678        let source = MemorySource::new(vec![0u8; 40]);
679        assert!(matches!(
680            read_header(&source),
681            Err(crate::error::Error::Corrupt { .. })
682        ));
683    }
684
685    #[test]
686    fn zoom_headers_follow_the_common_one() {
687        let mut b = header_bytes(super::super::BIGWIG_MAGIC, 4, 2);
688        for (reduction, data, index) in [(10u32, 100u64, 200u64), (40, 300, 400)] {
689            b.extend_from_slice(&reduction.to_le_bytes());
690            b.extend_from_slice(&0u32.to_le_bytes());
691            b.extend_from_slice(&data.to_le_bytes());
692            b.extend_from_slice(&index.to_le_bytes());
693        }
694        let source = MemorySource::new(b);
695        let zooms = read_zoom_headers(&source, 2).unwrap();
696        assert_eq!(zooms.len(), 2);
697        assert_eq!(zooms[0].reduction_level, 10);
698        assert_eq!(zooms[1].index_offset, 400);
699        assert!(read_zoom_headers(&source, 0).unwrap().is_empty());
700    }
701
702    #[test]
703    fn a_summary_offset_of_zero_means_the_file_carries_none() {
704        let source = MemorySource::new(vec![0u8; 8]);
705        let s = read_total_summary(&source, 0).unwrap();
706        assert_eq!(s.bases_covered, 0);
707        assert!(s.min_value.is_nan() && s.max_value.is_nan());
708    }
709
710    #[test]
711    fn reads_a_total_summary() {
712        let mut b = vec![0u8; 64];
713        b.extend_from_slice(&1234u64.to_le_bytes());
714        b.extend_from_slice(&(-1.5f64).to_le_bytes());
715        b.extend_from_slice(&9.25f64.to_le_bytes());
716        b.extend_from_slice(&100.0f64.to_le_bytes());
717        b.extend_from_slice(&500.0f64.to_le_bytes());
718        let source = MemorySource::new(b);
719        let s = read_total_summary(&source, 64).unwrap();
720        assert_eq!(s.bases_covered, 1234);
721        assert_eq!((s.min_value, s.max_value), (-1.5, 9.25));
722        assert_eq!((s.sum_data, s.sum_squared), (100.0, 500.0));
723    }
724
725    #[test]
726    fn the_data_tree_magic_is_checked_both_ways() {
727        let source = MemorySource::new(DATA_TREE_MAGIC.to_le_bytes().to_vec());
728        assert!(check_data_tree_magic(&source, 0).is_ok());
729        let source = MemorySource::new(DATA_TREE_MAGIC_SWAPPED.to_le_bytes().to_vec());
730        let err = check_data_tree_magic(&source, 0).unwrap_err().to_string();
731        assert!(err.contains("incompatible endianness"), "{err}");
732        let source = MemorySource::new(vec![1, 2, 3, 4]);
733        assert!(check_data_tree_magic(&source, 0).is_err());
734    }
735}
736
737#[cfg(test)]
738mod write_tests {
739    use super::*;
740    use crate::source::testing::MemorySource;
741
742    fn fields(pairs: &[(&str, &str)]) -> indexmap::IndexMap<String, String> {
743        pairs
744            .iter()
745            .map(|(a, b)| ((*a).to_string(), (*b).to_string()))
746            .collect()
747    }
748
749    #[test]
750    fn a_written_header_reads_back_as_itself() {
751        let header = BbiHeader {
752            kind: BbiKind::BigBed,
753            version: BBI_OUTPUT_VERSION,
754            zoom_levels: 7,
755            chr_tree_offset: 4096,
756            full_data_offset: 512,
757            full_index_offset: 2048,
758            field_count: 6,
759            defined_field_count: 4,
760            auto_sql_offset: 368,
761            total_summary_offset: 400,
762            uncompress_buffer_size: 32768,
763        };
764        let mut bytes = write_header(&header).unwrap();
765        // The magic is stamped separately, so the bytes as written are refused.
766        let err = read_header(&MemorySource::new(bytes.clone())).unwrap_err();
767        assert!(err.to_string().contains("not a bigwig or bigbed"), "{err}");
768
769        bytes[..4].copy_from_slice(&super::super::BIGBED_MAGIC.to_le_bytes());
770        let read = read_header(&MemorySource::new(bytes)).unwrap();
771        assert_eq!(read.kind, BbiKind::BigBed);
772        assert_eq!(read.version, header.version);
773        assert_eq!(read.zoom_levels, header.zoom_levels);
774        assert_eq!(read.chr_tree_offset, header.chr_tree_offset);
775        assert_eq!(read.full_data_offset, header.full_data_offset);
776        assert_eq!(read.full_index_offset, header.full_index_offset);
777        assert_eq!(read.field_count, header.field_count);
778        assert_eq!(read.defined_field_count, header.defined_field_count);
779        assert_eq!(read.auto_sql_offset, header.auto_sql_offset);
780        assert_eq!(read.total_summary_offset, header.total_summary_offset);
781        assert_eq!(read.uncompress_buffer_size, header.uncompress_buffer_size);
782    }
783
784    #[test]
785    fn a_written_zoom_header_and_summary_read_back_as_themselves() {
786        let zooms = [
787            ZoomHeader {
788                reduction_level: 10,
789                data_offset: 100,
790                index_offset: 200,
791            },
792            ZoomHeader {
793                reduction_level: 40,
794                data_offset: 300,
795                index_offset: 400,
796            },
797        ];
798        let mut bytes = vec![0u8; BBI_HEADER_SIZE as usize];
799        for z in &zooms {
800            bytes.extend_from_slice(&write_zoom_header(z));
801        }
802        let read = read_zoom_headers(&MemorySource::new(bytes), 2).unwrap();
803        assert_eq!(read.len(), 2);
804        assert_eq!(read[1].reduction_level, 40);
805        assert_eq!(read[1].data_offset, 300);
806        assert_eq!(read[1].index_offset, 400);
807
808        let summary = TotalSummary {
809            bases_covered: 1234,
810            min_value: -1.5,
811            max_value: 9.25,
812            sum_data: 1000.0,
813            sum_squared: 5000.0,
814        };
815        let read = read_total_summary(&MemorySource::new(write_total_summary(&summary)), 0);
816        // Offset 0 means "no summary" to a reader, so it is read at 1 instead.
817        assert_eq!(read.unwrap().bases_covered, 0);
818        let mut padded = vec![0u8];
819        padded.extend_from_slice(&write_total_summary(&summary));
820        let read = read_total_summary(&MemorySource::new(padded), 1).unwrap();
821        assert_eq!(read.bases_covered, 1234);
822        assert_eq!(read.min_value, -1.5);
823        assert_eq!(read.sum_squared, 5000.0);
824    }
825
826    #[test]
827    fn an_empty_summary_writes_zero_extremes_rather_than_nan() {
828        let bytes = write_total_summary(&TotalSummary::default());
829        let mut padded = vec![0u8];
830        padded.extend_from_slice(&bytes);
831        let read = read_total_summary(&MemorySource::new(padded), 1).unwrap();
832        assert_eq!(read.min_value, 0.0);
833        assert_eq!(read.max_value, 0.0);
834    }
835
836    #[test]
837    fn built_autosql_reads_back_as_the_fields_it_describes() {
838        // The round trip is the point: a writer that emitted a declaration this
839        // library's own reader refuses would produce an unreadable file.
840        let declared = fields(&[
841            ("chr", "string"),
842            ("start", "uint"),
843            ("end", "uint"),
844            ("name", "string"),
845            ("score", "uint"),
846        ]);
847        let sql = build_auto_sql(&declared).unwrap();
848        assert_eq!(sql.field_count, 5);
849        // chr/chromStart/chromEnd/name/score are the first five standard ones.
850        assert_eq!(sql.defined_field_count, 5);
851
852        let mut bytes = vec![0u8];
853        bytes.extend_from_slice(sql.text.as_bytes());
854        bytes.push(0);
855        let read = read_auto_sql(&MemorySource::new(bytes), 1, 5).unwrap();
856        assert_eq!(
857            read.keys().map(String::as_str).collect::<Vec<_>>(),
858            ["chrom", "chromStart", "chromEnd", "name", "score"]
859        );
860        assert_eq!(read["chrom"], "string");
861        assert_eq!(read["score"], "uint");
862    }
863
864    #[test]
865    fn a_non_standard_column_stops_the_defined_field_count() {
866        let declared = fields(&[
867            ("chr", "string"),
868            ("start", "uint"),
869            ("end", "uint"),
870            ("pvalue", "float"),
871            ("score", "uint"),
872        ]);
873        let sql = build_auto_sql(&declared).unwrap();
874        // Three coordinates, then a column BED does not know.
875        assert_eq!(sql.defined_field_count, 3);
876        assert_eq!(sql.field_count, 5);
877        // And it keeps its own name and type, with itself as the comment.
878        assert!(
879            sql.text.contains("float pvalue;\t\"pvalue\"\n"),
880            "{}",
881            sql.text
882        );
883    }
884
885    #[test]
886    fn a_bad_field_name_or_type_is_refused_before_anything_is_written() {
887        let err = build_auto_sql(&fields(&[
888            ("name", "string"),
889            ("start", "uint"),
890            ("end", "uint"),
891        ]))
892        .unwrap_err()
893        .to_string();
894        assert!(err.contains("missing or misplaced"), "{err}");
895        let err = build_auto_sql(&fields(&[
896            ("chr", "string"),
897            ("start", "uint"),
898            ("end", "uint"),
899            ("x", "double"),
900        ]))
901        .unwrap_err()
902        .to_string();
903        assert!(err.contains("field type double"), "{err}");
904    }
905
906    #[test]
907    fn a_coordinate_past_32_bits_is_refused_rather_than_truncated() {
908        assert_eq!(to_bbi_u32(4_294_967_295, "chromEnd").unwrap(), u32::MAX);
909        let err = to_bbi_u32(4_294_967_296, "chromEnd")
910            .unwrap_err()
911            .to_string();
912        assert!(err.contains("chromEnd 4294967296"), "{err}");
913        assert!(to_bbi_u32(-1, "chromStart").is_err());
914    }
915
916    #[test]
917    fn narrowing_a_summary_clamps_rather_than_reaching_infinity() {
918        assert_eq!(to_bbi_f32(1.0e300), f32::MAX);
919        assert_eq!(to_bbi_f32(-1.0e300), f32::MIN);
920        assert_eq!(to_bbi_f32(1.5), 1.5f32);
921    }
922}
923
924#[cfg(test)]
925mod autosql_tests {
926    use super::*;
927
928    fn fields(pairs: &[(&str, &str)]) -> indexmap::IndexMap<String, String> {
929        pairs
930            .iter()
931            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
932            .collect()
933    }
934
935    /// A real bigBed declares `enum("+", "-", ".") strand;`, whose type carries
936    /// spaces inside its parentheses. Split on the first whitespace, that line
937    /// gave the type `enum("+",` and one field too few — and the whole file was
938    /// then refused for a field-count mismatch.
939    #[test]
940    fn a_type_with_spaces_inside_its_brackets_is_one_token() {
941        for (line, kind, rest) in [
942            (
943                "enum(\"+\", \"-\", \".\") strand;",
944                "enum(\"+\", \"-\", \".\")",
945                "strand;",
946            ),
947            ("set(a, b) flags;", "set(a, b)", "flags;"),
948            (
949                "int[blockCount] blockSizes;",
950                "int[blockCount]",
951                "blockSizes;",
952            ),
953            ("uint  score;", "uint", "score;"),
954            ("string name; comment", "string", "name; comment"),
955        ] {
956            assert_eq!(split_once_whitespace(line), Some((kind, rest)), "{line}");
957        }
958        // Nothing to split on at all.
959        assert_eq!(split_once_whitespace("uint"), None);
960    }
961
962    /// A name carrying autoSql's own punctuation writes a block this reader
963    /// parses into a different field count than the header declares — a file
964    /// written here and refused here.
965    #[test]
966    fn a_field_name_may_not_hold_autosql_punctuation() {
967        for bad in [
968            "two words",
969            "semi;colon",
970            "com,ma",
971            "paren(s)",
972            "quo\"te",
973            "",
974        ] {
975            let f = fields(&[
976                ("chr", "string"),
977                ("start", "uint"),
978                ("end", "uint"),
979                (bad, "string"),
980            ]);
981            assert!(
982                build_auto_sql(&f).is_err(),
983                "{bad:?} was accepted as a field name"
984            );
985        }
986        let ok = fields(&[
987            ("chr", "string"),
988            ("start", "uint"),
989            ("end", "uint"),
990            ("itemRgb", "string"),
991        ]);
992        assert!(build_auto_sql(&ok).is_ok());
993    }
994}