bixverse_sc_data/errors.rs
1//! Errors raised by `bixverse-sc-data`.
2
3use std::io;
4use thiserror::Error;
5
6/// Error variants raised by `bixverse-sc-data`.
7///
8/// Grouped by subsystem.
9#[derive(Debug, Error)]
10pub enum ScDataErrors {
11 // -- lower crates --
12 /// Propagates an error from `bixverse-sparse`.
13 #[error(transparent)]
14 Sparse(#[from] bixverse_sparse::errors::SparseErrors),
15
16 /// Propagates an error from `bixverse-core`.
17 #[cfg(feature = "analysis")]
18 #[error(transparent)]
19 Core(#[from] bixverse_core::errors::CoreErrors),
20
21 // -- arguments --
22 /// A caller-supplied argument is invalid.
23 #[error("Invalid argument: {0}")]
24 InvalidArgument(String),
25
26 // -- Binary file I/O --
27 /// Wraps any `std::io::Error` encountered while reading or writing the
28 /// bixverse binary sparse format.
29 ///
30 /// Covers file creation, seeks, buffered writes, and raw reads against
31 /// the memory-mapped region. Use `#[from]` conversion so `?` works on
32 /// any `io::Result`.
33 #[error("I/O error on binary file: {0}")]
34 BinaryIo(#[from] io::Error),
35
36 /// Error if counts do not seem to be raw counts during i/o
37 #[error("The counts you are trying to load in do not seem to be raw counts")]
38 NotRawCounts,
39
40 /// Bincode failed to encode the `FileHeader` or `SparseDataHeader`.
41 ///
42 /// Practically unreachable for the fixed-layout headers used here, but
43 /// kept as a non-panicking path so the writer never aborts a long ingest.
44 #[error("Failed to encode file header")]
45 HeaderEncodeFailed,
46
47 /// Bincode failed to decode the `FileHeader` or `SparseDataHeader`.
48 ///
49 /// Indicates the file is truncated, corrupt, or was not produced by
50 /// bixverse. The 64-byte fixed file header is the first thing read, so
51 /// most "wrong file type" errors surface here.
52 #[error("Failed to decode file header - file may be corrupt or truncated")]
53 HeaderDecodeFailed,
54
55 /// File version does not match the current `SC_FILE_VERSION`.
56 ///
57 /// Returned on read when an older or newer binary is opened. The fix is
58 /// to regenerate the file with the current bixverse version.
59 #[error("File version mismatch: expected {expected}, got {found}")]
60 FileVersionMismatch {
61 /// Version the current build expects.
62 expected: u32,
63 /// Version actually read from the file header.
64 found: u32,
65 },
66
67 /// A chunk buffer was shorter than the minimum header length.
68 ///
69 /// Raised before any field is parsed from a decompressed chunk, so the
70 /// reader can fail early instead of indexing out of bounds.
71 #[error("Chunk buffer too small: expected at least {expected} bytes, got {found}")]
72 ChunkBufferTooSmall {
73 /// Minimum bytes required for the chunk header.
74 expected: usize,
75 /// Actual bytes available in the buffer.
76 found: usize,
77 },
78
79 /// LZ4 decompression of a chunk failed.
80 ///
81 /// The offset is reported to make corrupt regions locatable. Usually
82 /// indicates file truncation or a mismatched compressed-size prefix.
83 #[error("Failed to decompress chunk at offset {0}")]
84 ChunkDecompressionFailed(u64),
85
86 /// A caller-supplied original index was not present in the file's index
87 /// map.
88 ///
89 /// Indicates either a bug in the calling code (asking for an index that
90 /// was never written) or a file that was generated with a different set
91 /// of cells/genes than the caller assumes.
92 #[error("Chunk index {0} not found in file index map")]
93 ChunkIndexNotFound(usize),
94
95 /// A cell-based reader method was called on a gene-based file, or vice
96 /// versa.
97 ///
98 /// Replaces the previous runtime asserts. The two string fields describe
99 /// the file's actual layout and the layout the caller requested.
100 #[error("Reader mode mismatch: file is {actual}, requested {requested}")]
101 ReaderModeMismatch {
102 /// What the file is: "cell-based" or "gene-based".
103 actual: &'static str,
104 /// What the caller requested: "cell-based" or "gene-based".
105 requested: &'static str,
106 },
107
108 /// A chunk's declared payload lengths exceed the bytes actually present.
109 ///
110 /// The three length fields in a chunk header are untrusted input. This is
111 /// raised after they have been parsed but before any payload slice is
112 /// taken, so a corrupt length cannot index out of bounds.
113 #[error("Chunk payload truncated: header declares {expected} bytes, buffer holds {found}")]
114 ChunkPayloadTruncated {
115 /// Total bytes the header's length fields imply.
116 expected: usize,
117 /// Actual bytes available in the decompressed chunk.
118 found: usize,
119 },
120
121 /// The raw-count element-size discriminant was not recognised.
122 ///
123 /// Valid values are `RAW_ELEM_U16` (2), `RAW_ELEM_U32` (4), and 0 for
124 /// legacy files where the byte was zero padding. Anything else means the
125 /// chunk is corrupt; reinterpreting it would misparse the whole payload.
126 #[error("Invalid raw count element size discriminant: {0}")]
127 RawElemSizeInvalid(u8),
128
129 /// A v4 chunk is malformed: a bad codec byte, a bit width above 32, a
130 /// body that ends before its own header says it should, or layers of
131 /// unequal length handed to the encoder.
132 #[error("Corrupt v4 chunk: {0}")]
133 V4ChunkCorrupt(String),
134
135 /// A v4 chunk drops its norm layer and recomputes it per entry, but the
136 /// file carries no per-cell library-size table to recompute it from.
137 #[error("v4 gene chunk needs the per-cell library-size table, which the file does not carry")]
138 V4MissingCellLibraryTable,
139
140 /// The file is not a bixverse count archive.
141 #[error("Not a bixverse count archive (magic string mismatch)")]
142 ArchiveMagicMismatch,
143
144 /// The archive was written by an incompatible archive format version.
145 #[error("Archive version mismatch: expected {expected}, got {found}")]
146 ArchiveVersionMismatch {
147 /// Version the current build expects.
148 expected: u32,
149 /// Version actually read from the archive.
150 found: u32,
151 },
152
153 /// The archive is truncated or corrupt, or a chunk cannot be archived.
154 #[error("Corrupt count archive: {0}")]
155 ArchiveCorrupt(String),
156
157 /// A raw count read from disk does not fit the requested numeric type.
158 ///
159 /// Raised by `from_gene_chunks` / `from_cell_chunks` instead of silently
160 /// saturating. Pick a wider `T` (`u32`, `f32`, `f64`) for the affected
161 /// dataset.
162 #[error("Raw count {value} does not fit target type {target_type}")]
163 RawCountOverflow {
164 /// The count that could not be represented.
165 value: u32,
166 /// Name of the type it was being converted into.
167 target_type: &'static str,
168 },
169
170 /// The `target_size` recorded in a file header disagrees with the one the
171 /// caller requested.
172 ///
173 /// Undoing a file's own library-size normalisation requires the exact
174 /// factor the writer used, so a mismatch always yields wrong numbers.
175 /// Files written before `target_size` entered the header report `0.0` and
176 /// are exempt from the check.
177 #[error(
178 "Target size mismatch: file was normalised against {header}, but {requested} was requested"
179 )]
180 TargetSizeMismatch {
181 /// Value stored in the file header.
182 header: f32,
183 /// Value the caller supplied.
184 requested: f32,
185 },
186
187 /// Error for h5 ingestion if feature type is not found
188 #[error(
189 "The requested feature type ({requested}) was not found. On file found features are {found}"
190 )]
191 FeatureTypeNotFound {
192 /// Requested feature type
193 requested: String,
194 /// Found feature types
195 found: String,
196 },
197
198 /// Error if the h5 string type cannot be read
199 #[error("h5: Unexpected str type identified: {0}")]
200 H5UnexpectedStringType(String),
201
202 // -- HDF5 / h5ad --
203 /// Wraps any error from the `hdf5` crate.
204 ///
205 /// Covers file open failures, missing datasets (`X/data`, `X/indices`,
206 /// `X/indptr`, `obs/*`), dtype mismatches, and slice reads. The wrapped
207 /// error carries the original HDF5 message.
208 #[error("HDF5 error: {0}")]
209 #[cfg(feature = "h5")]
210 Hdf5(#[from] hdf5::Error),
211
212 /// Provide custom error message for unsupported file formats.
213 #[error("Unknown or unsupported h5ad format: {0}")]
214 UnsupportH5ADFormat(String),
215
216 /// Two columns of an obs / var table end up with the same name after
217 /// snake_case. DuckDB rejects the table, so the export stops before
218 /// writing anything.
219 #[error("Duplicate column name after snake_case: '{0}'")]
220 DuplicateColumnName(String),
221
222 /// Wraps any error from the `parquet` crate while writing an obs / var
223 /// table.
224 #[cfg(feature = "parquet")]
225 #[error("Parquet error: {0}")]
226 Parquet(#[from] parquet::errors::ParquetError),
227
228 /// A dataset is compressed with a filter this build cannot decode.
229 ///
230 /// Raised before the first read, because HDF5 itself reports the missing
231 /// filter as a missing plugin directory: the path baked in at build time
232 /// is a cargo `OUT_DIR` that no longer exists. Either re-save the file
233 /// with `compression="gzip"`, or build with the `hdf5-filters` feature,
234 /// which adds LZF and Blosc.
235 #[error(
236 "HDF5 dataset '{dataset}' uses filter {filter_id} ({filter_name}), which this build cannot decode"
237 )]
238 UnsupportedH5Filter {
239 /// Path of the dataset inside the file.
240 dataset: String,
241 /// The HDF5 filter identifier.
242 filter_id: i32,
243 /// Human readable name for that identifier.
244 filter_name: String,
245 },
246
247 /// A chunk read directly from an HDF5 file did not decode to the size its
248 /// dataset says it should, or a read asked for elements past the end.
249 #[error("HDF5 chunk decode error: {0}")]
250 H5ChunkDecode(String),
251
252 /// A named `obs` column was requested but does not exist in the h5ad.
253 ///
254 /// Primarily raised by `write_h5_normalised_counts` when the caller
255 /// passes an `obs_lib_size_col` that isn't in the file.
256 #[error("obs column '{0}' not found in h5ad file")]
257 ObsColumnMissing(String),
258
259 /// The library-size vector read from `obs` does not match the number of
260 /// cells in `X`.
261 ///
262 /// Indicates a malformed h5ad — typical cause is a user-edited obs table
263 /// whose row count drifted from the matrix.
264 #[error("Library size column length ({found}) does not match cell count ({expected})")]
265 LibSizeLengthMismatch {
266 /// Expected length (number of cells in `X`).
267 expected: usize,
268 /// Actual length of the obs column.
269 found: usize,
270 },
271
272 // -- MTX --
273 /// The header of an MTX file is malformed.
274 ///
275 /// The static string describes which part of the header failed (shape
276 /// line missing, wrong field count, unparseable counts). Comment lines
277 /// starting with `%` are skipped before this fires.
278 #[error("Invalid MTX header: {0}")]
279 MtxHeaderInvalid(&'static str),
280
281 /// A field of the MTX shape line could not be parsed as a number.
282 ///
283 /// `field` names the offending column ("cell count", "gene count",
284 /// "entry count") so that malformed inputs can be diagnosed without
285 /// quoting user data. Which of the first two fields is which depends on
286 /// the `cells_as_rows` orientation.
287 #[error("Failed to parse MTX value as {field}")]
288 MtxParseError {
289 /// Which field failed: "cell count", "gene count", or "entry count".
290 field: &'static str,
291 },
292
293 /// The MTX banner names something the loader does not read: an object
294 /// other than `matrix`, the dense `array` format, a `complex` field or a
295 /// symmetry other than `general`.
296 #[error("Unsupported MTX file: {0}")]
297 MtxUnsupported(String),
298
299 /// An MTX value is not a raw count: negative, non-integral, not a number
300 /// or beyond `u32`. The store holds raw counts, so a `real` file of
301 /// normalised values cannot be loaded.
302 #[error("MTX value '{value}' is not a non-negative integral count")]
303 MtxNotACount {
304 /// The offending value as written in the file.
305 value: String,
306 },
307
308 // -- Format parsing --
309 /// The `cs_type` string did not match a known sparse format.
310 ///
311 /// Accepted values are "csc" and "csr" (case-insensitive at the parser
312 /// level). The unrecognised input is echoed back in the message.
313 #[error("Unknown compressed sparse format: '{0}' (expected 'csc' or 'csr')")]
314 UnknownSparseFormat(String),
315}