Skip to main content

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}