Skip to main content

gwseq_io/
error.rs

1//! The crate's error type.
2//!
3//! One enum for everything, because a caller mapping failures onto its own
4//! categories — the Python layer onto exception types, a CLI onto exit codes —
5//! wants a single exhaustive match, and that is the only shape that stays
6//! complete as variants are added.
7//!
8//! Every variant carries its *parts*, never a pre-assembled message. What a
9//! failure is and how it reads are different concerns: a caller inspecting
10//! [`Error::UnknownChromosome`] can reach the id and the names that were
11//! available without parsing English out of a string, and the sentence is
12//! built by [`std::fmt::Display`] at the point something prints it.
13//!
14//! [`Error::Format`] and [`Error::Corrupt`] are separate because they are
15//! different failures with different remedies: `Format` is "this is not the
16//! file you said it was" — the wrong path, the wrong tool — and `Corrupt` is
17//! "it is, and its bytes disagree with themselves", which is a damaged file or
18//! a bug in whatever wrote it.
19
20use std::fmt::Write as _;
21use std::ops::Range;
22
23pub type Result<T> = std::result::Result<T, Error>;
24
25#[derive(Debug, thiserror::Error)]
26pub enum Error {
27    /// A read that did not happen: the file is missing, unreadable, or the
28    /// device failed under it.
29    #[error("error reading {path}: {source}")]
30    Io {
31        path: String,
32        #[source]
33        source: std::io::Error,
34    },
35
36    /// A write that did not land. Separate from [`Error::Io`] because a full
37    /// device is usually only reported at the flush or the close — the writes
38    /// before it having gone into a buffer — so the moment it surfaces is not
39    /// the moment it happened, and `what` is what says which moment it was.
40    #[error("failed to write {path} ({what})")]
41    Write { path: String, what: String },
42
43    /// A URL that did not answer, or answered with something unusable.
44    #[error("error fetching {url}{}: {message}", .status.map(|s| format!(" (HTTP {s})")).unwrap_or_default())]
45    Http {
46        url: String,
47        status: Option<u16>,
48        message: String,
49    },
50
51    /// Use of a reader or writer whose `close()` has already run. Every method
52    /// checks this first.
53    #[error("{path} is closed")]
54    Closed { path: String },
55
56    /// A name no chromosome in the file answers to.
57    ///
58    /// The parts are here rather than a finished sentence, so that a caller can
59    /// use them: `available` is what to offer as a suggestion, `key_size` is
60    /// the width of the file's own name field, and `truncated_match` is the
61    /// chromosome the id's first `key_size` characters spell when the file
62    /// holds one — likely the same chromosome stored truncated, but only
63    /// likely, which is why it is named rather than resolved to.
64    #[error("{}", unknown_chr_message(.id, *.key_size, .truncated_match, .available))]
65    UnknownChromosome {
66        id: String,
67        key_size: Option<usize>,
68        truncated_match: Option<String>,
69        available: Vec<String>,
70    },
71
72    /// The caller asked for something that cannot be served: a bin size of
73    /// zero, an end before its start, a reduction that does not exist, an
74    /// array of the wrong shape.
75    #[error("{0}")]
76    InvalidArgument(String),
77
78    /// The file is not what it claims to be: wrong magic, wrong version,
79    /// byte-swapped, a bigBed where a bigWig was needed.
80    #[error("{path}: {what}")]
81    Format { path: String, what: String },
82
83    /// The file is the right format and its own bytes contradict each other:
84    /// a count reaching past the block, an offset past the end, a truncated
85    /// record.
86    #[error("{path}: {what} (at offset {offset})")]
87    Corrupt {
88        path: String,
89        offset: u64,
90        what: String,
91    },
92
93    /// A real feature of the format that this library does not implement.
94    #[error("{0}")]
95    Unsupported(String),
96}
97
98/// The sentence [`Error::UnknownChromosome`] prints, assembled from its parts.
99///
100/// Three clauses: always the name and what was available; when the id cannot
101/// fit the file's declared name field, that it *cannot* be found and how wide
102/// the field is; and when the id's first `key_size` characters do spell a
103/// chromosome the file holds, that one by name.
104fn unknown_chr_message(
105    id: &str,
106    key_size: Option<usize>,
107    truncated_match: &Option<String>,
108    available: &[String],
109) -> String {
110    let mut message = format!("Chromosome {id} not found");
111    if let Some(key_size) = key_size {
112        let _ = write!(
113            message,
114            ", and cannot be: it is {} characters long and this file stores \
115             chromosome names in a {key_size}-character field",
116            id.chars().count()
117        );
118        if let Some(name) = truncated_match {
119            let _ = write!(
120                message,
121                ". The file does hold {name}, which is what the first {key_size} \
122                 characters spell: if it was written from names too long for its \
123                 field, that is this chromosome stored truncated. Ids are matched \
124                 whole, so ask for it by the name the file carries"
125            );
126        }
127    }
128    let _ = write!(message, " (available: {})", available.join(", "));
129    message
130}
131
132impl Error {
133    pub fn invalid(msg: impl Into<String>) -> Self {
134        Error::InvalidArgument(msg.into())
135    }
136
137    pub fn format(path: impl Into<String>, what: impl Into<String>) -> Self {
138        Error::Format {
139            path: path.into(),
140            what: what.into(),
141        }
142    }
143
144    pub fn corrupt(path: impl Into<String>, offset: u64, what: impl Into<String>) -> Self {
145        Error::Corrupt {
146            path: path.into(),
147            offset,
148            what: what.into(),
149        }
150    }
151
152    pub fn write(path: impl Into<String>, what: impl Into<String>) -> Self {
153        Error::Write {
154            path: path.into(),
155            what: what.into(),
156        }
157    }
158
159    pub fn io(path: impl Into<String>, source: std::io::Error) -> Self {
160        Error::Io {
161            path: path.into(),
162            source,
163        }
164    }
165}
166
167/// Bounds check for a field read out of a buffer whose length came from the
168/// file. Format code calls this before slicing so that a bad count is a
169/// [`Error::Corrupt`] a caller can catch, not a panic it cannot.
170pub fn check_range(path: &str, buf_len: usize, range: Range<usize>, what: &str) -> Result<()> {
171    if range.end > buf_len || range.start > range.end {
172        return Err(Error::corrupt(
173            path,
174            range.start as u64,
175            format!(
176                "{what}: bytes {}..{} of a {buf_len}-byte buffer",
177                range.start, range.end
178            ),
179        ));
180    }
181    Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn names(ids: &[&str]) -> Vec<String> {
189        ids.iter().map(|s| s.to_string()).collect()
190    }
191
192    #[test]
193    fn an_unknown_chromosome_names_itself_and_what_was_available() {
194        let err = Error::UnknownChromosome {
195            id: "chrZ".into(),
196            key_size: None,
197            truncated_match: None,
198            available: names(&["chr1", "chr2"]),
199        };
200        assert_eq!(
201            err.to_string(),
202            "Chromosome chrZ not found (available: chr1, chr2)"
203        );
204    }
205
206    #[test]
207    fn an_over_long_id_says_the_field_is_too_narrow() {
208        let err = Error::UnknownChromosome {
209            id: "chrXVII".into(),
210            key_size: Some(4),
211            truncated_match: None,
212            available: names(&["chrI"]),
213        };
214        let message = err.to_string();
215        assert!(message.contains("7 characters long"), "{message}");
216        assert!(message.contains("4-character field"), "{message}");
217    }
218
219    #[test]
220    fn a_prefix_that_spells_a_real_chromosome_is_named() {
221        let err = Error::UnknownChromosome {
222            id: "chrXVII".into(),
223            key_size: Some(6),
224            truncated_match: Some("chrXVI".into()),
225            available: names(&["chrXVI"]),
226        };
227        let message = err.to_string();
228        assert!(message.contains("The file does hold chrXVI"), "{message}");
229    }
230
231    /// The parts are the point: a caller categorising failures should never
232    /// have to read the sentence back.
233    #[test]
234    fn the_parts_are_reachable_without_parsing_the_message() {
235        let err = Error::UnknownChromosome {
236            id: "12".into(),
237            key_size: Some(1),
238            truncated_match: Some("chr1".into()),
239            available: names(&["chr1"]),
240        };
241        let Error::UnknownChromosome {
242            id,
243            key_size,
244            truncated_match,
245            available,
246        } = &err
247        else {
248            panic!("wrong variant");
249        };
250        assert_eq!(id, "12");
251        assert_eq!(*key_size, Some(1));
252        assert_eq!(truncated_match.as_deref(), Some("chr1"));
253        assert_eq!(available.len(), 1);
254    }
255}