gwseq-io 0.2.0

Rust library for processing bigWig, bigBed, BAM and HiC files
Documentation
//! The crate's error type.
//!
//! One enum for everything, because a caller mapping failures onto its own
//! categories — the Python layer onto exception types, a CLI onto exit codes —
//! wants a single exhaustive match, and that is the only shape that stays
//! complete as variants are added.
//!
//! Every variant carries its *parts*, never a pre-assembled message. What a
//! failure is and how it reads are different concerns: a caller inspecting
//! [`Error::UnknownChromosome`] can reach the id and the names that were
//! available without parsing English out of a string, and the sentence is
//! built by [`std::fmt::Display`] at the point something prints it.
//!
//! [`Error::Format`] and [`Error::Corrupt`] are separate because they are
//! different failures with different remedies: `Format` is "this is not the
//! file you said it was" — the wrong path, the wrong tool — and `Corrupt` is
//! "it is, and its bytes disagree with themselves", which is a damaged file or
//! a bug in whatever wrote it.

use std::fmt::Write as _;
use std::ops::Range;

pub type Result<T> = std::result::Result<T, Error>;

#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// A read that did not happen: the file is missing, unreadable, or the
    /// device failed under it.
    #[error("error reading {path}: {source}")]
    Io {
        path: String,
        #[source]
        source: std::io::Error,
    },

    /// A write that did not land. Separate from [`Error::Io`] because a full
    /// device is usually only reported at the flush or the close — the writes
    /// before it having gone into a buffer — so the moment it surfaces is not
    /// the moment it happened, and `what` is what says which moment it was.
    #[error("failed to write {path} ({what})")]
    Write { path: String, what: String },

    /// A URL that did not answer, or answered with something unusable.
    #[error("error fetching {url}{}: {message}", .status.map(|s| format!(" (HTTP {s})")).unwrap_or_default())]
    Http {
        url: String,
        status: Option<u16>,
        message: String,
    },

    /// Use of a reader or writer whose `close()` has already run. Every method
    /// checks this first.
    #[error("{path} is closed")]
    Closed { path: String },

    /// A name no chromosome in the file answers to.
    ///
    /// The parts are here rather than a finished sentence, so that a caller can
    /// use them: `available` is what to offer as a suggestion, `key_size` is
    /// the width of the file's own name field, and `truncated_match` is the
    /// chromosome the id's first `key_size` characters spell when the file
    /// holds one — likely the same chromosome stored truncated, but only
    /// likely, which is why it is named rather than resolved to.
    #[error("{}", unknown_chr_message(.id, *.key_size, .truncated_match, .available))]
    UnknownChromosome {
        id: String,
        key_size: Option<usize>,
        truncated_match: Option<String>,
        available: Vec<String>,
    },

    /// The caller asked for something that cannot be served: a bin size of
    /// zero, an end before its start, a reduction that does not exist, an
    /// array of the wrong shape.
    #[error("{0}")]
    InvalidArgument(String),

    /// The file is not what it claims to be: wrong magic, wrong version,
    /// byte-swapped, a bigBed where a bigWig was needed.
    #[error("{path}: {what}")]
    Format { path: String, what: String },

    /// The file is the right format and its own bytes contradict each other:
    /// a count reaching past the block, an offset past the end, a truncated
    /// record.
    #[error("{path}: {what} (at offset {offset})")]
    Corrupt {
        path: String,
        offset: u64,
        what: String,
    },

    /// A real feature of the format that this library does not implement.
    #[error("{0}")]
    Unsupported(String),
}

/// The sentence [`Error::UnknownChromosome`] prints, assembled from its parts.
///
/// Three clauses: always the name and what was available; when the id cannot
/// fit the file's declared name field, that it *cannot* be found and how wide
/// the field is; and when the id's first `key_size` characters do spell a
/// chromosome the file holds, that one by name.
fn unknown_chr_message(
    id: &str,
    key_size: Option<usize>,
    truncated_match: &Option<String>,
    available: &[String],
) -> String {
    let mut message = format!("Chromosome {id} not found");
    if let Some(key_size) = key_size {
        let _ = write!(
            message,
            ", and cannot be: it is {} characters long and this file stores \
             chromosome names in a {key_size}-character field",
            id.chars().count()
        );
        if let Some(name) = truncated_match {
            let _ = write!(
                message,
                ". The file does hold {name}, which is what the first {key_size} \
                 characters spell: if it was written from names too long for its \
                 field, that is this chromosome stored truncated. Ids are matched \
                 whole, so ask for it by the name the file carries"
            );
        }
    }
    let _ = write!(message, " (available: {})", available.join(", "));
    message
}

impl Error {
    pub fn invalid(msg: impl Into<String>) -> Self {
        Error::InvalidArgument(msg.into())
    }

    pub fn format(path: impl Into<String>, what: impl Into<String>) -> Self {
        Error::Format {
            path: path.into(),
            what: what.into(),
        }
    }

    pub fn corrupt(path: impl Into<String>, offset: u64, what: impl Into<String>) -> Self {
        Error::Corrupt {
            path: path.into(),
            offset,
            what: what.into(),
        }
    }

    pub fn write(path: impl Into<String>, what: impl Into<String>) -> Self {
        Error::Write {
            path: path.into(),
            what: what.into(),
        }
    }

    pub fn io(path: impl Into<String>, source: std::io::Error) -> Self {
        Error::Io {
            path: path.into(),
            source,
        }
    }
}

/// Bounds check for a field read out of a buffer whose length came from the
/// file. Format code calls this before slicing so that a bad count is a
/// [`Error::Corrupt`] a caller can catch, not a panic it cannot.
pub fn check_range(path: &str, buf_len: usize, range: Range<usize>, what: &str) -> Result<()> {
    if range.end > buf_len || range.start > range.end {
        return Err(Error::corrupt(
            path,
            range.start as u64,
            format!(
                "{what}: bytes {}..{} of a {buf_len}-byte buffer",
                range.start, range.end
            ),
        ));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn names(ids: &[&str]) -> Vec<String> {
        ids.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn an_unknown_chromosome_names_itself_and_what_was_available() {
        let err = Error::UnknownChromosome {
            id: "chrZ".into(),
            key_size: None,
            truncated_match: None,
            available: names(&["chr1", "chr2"]),
        };
        assert_eq!(
            err.to_string(),
            "Chromosome chrZ not found (available: chr1, chr2)"
        );
    }

    #[test]
    fn an_over_long_id_says_the_field_is_too_narrow() {
        let err = Error::UnknownChromosome {
            id: "chrXVII".into(),
            key_size: Some(4),
            truncated_match: None,
            available: names(&["chrI"]),
        };
        let message = err.to_string();
        assert!(message.contains("7 characters long"), "{message}");
        assert!(message.contains("4-character field"), "{message}");
    }

    #[test]
    fn a_prefix_that_spells_a_real_chromosome_is_named() {
        let err = Error::UnknownChromosome {
            id: "chrXVII".into(),
            key_size: Some(6),
            truncated_match: Some("chrXVI".into()),
            available: names(&["chrXVI"]),
        };
        let message = err.to_string();
        assert!(message.contains("The file does hold chrXVI"), "{message}");
    }

    /// The parts are the point: a caller categorising failures should never
    /// have to read the sentence back.
    #[test]
    fn the_parts_are_reachable_without_parsing_the_message() {
        let err = Error::UnknownChromosome {
            id: "12".into(),
            key_size: Some(1),
            truncated_match: Some("chr1".into()),
            available: names(&["chr1"]),
        };
        let Error::UnknownChromosome {
            id,
            key_size,
            truncated_match,
            available,
        } = &err
        else {
            panic!("wrong variant");
        };
        assert_eq!(id, "12");
        assert_eq!(*key_size, Some(1));
        assert_eq!(truncated_match.as_deref(), Some("chr1"));
        assert_eq!(available.len(), 1);
    }
}