Skip to main content

sheets_diff/
error.rs

1//! Fatal error type for all fallible v2 entry points (RFC-005, RFC-033 §9).
2
3use std::fmt;
4
5use crate::model::{SheetRef, Side, SourceDescription};
6
7// ---------------------------------------------------------------------------
8// Open / read error kinds
9// ---------------------------------------------------------------------------
10
11/// Why a workbook could not be opened.
12#[non_exhaustive]
13#[derive(Debug)]
14pub enum OpenErrorKind {
15    NotFound,
16    PermissionDenied,
17    /// The bytes are not a valid ZIP / xlsx container.
18    NotXlsx,
19    /// Structurally valid ZIP, but xlsx internals are corrupt.
20    Corrupt,
21    /// File is locked or busy (OS-level).
22    Locked,
23    Other,
24}
25
26impl fmt::Display for OpenErrorKind {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            OpenErrorKind::NotFound => f.write_str("file not found"),
30            OpenErrorKind::PermissionDenied => f.write_str("permission denied"),
31            OpenErrorKind::NotXlsx => f.write_str("not an xlsx file"),
32            OpenErrorKind::Corrupt => f.write_str("file is corrupt"),
33            OpenErrorKind::Locked => f.write_str("file is locked"),
34            OpenErrorKind::Other => f.write_str("open failed"),
35        }
36    }
37}
38
39/// Why a sheet could not be read.
40#[non_exhaustive]
41#[derive(Debug)]
42pub enum ReadErrorKind {
43    /// The workbook's own sheet index names a sheet that could not be
44    /// located when reading it. Sound reasoning for the CLI's exit-code
45    /// mapping (3, alongside `MalformedSheet`) rests on there being no way
46    /// to *ask* for a specific sheet: this crate always reads every sheet
47    /// the index promises, so a missing one is the workbook's own internal
48    /// inconsistency, not a caller's request for something that was never
49    /// going to exist. If a sheet-selection option is ever added, this
50    /// variant becomes reachable as caller error too, and the exit-code
51    /// mapping would need to move to 2 for that case.
52    SheetNotFound,
53    /// The sheet exists but its content could not be parsed.
54    MalformedSheet,
55    /// Cannot occur through any input this crate currently accepts: the
56    /// workbook reader is `Xlsx<Cursor<Vec<u8>>>`, so every sheet read
57    /// operates on an in-memory cursor — there is no I/O left to fail
58    /// against at that point, and the `XlsxError::Io` case this variant
59    /// exists for cannot arise there. A match arm on this variant is
60    /// unreachable today; it is retained as a conservative default (see
61    /// `exit_code_for` in `main.rs`) against a future reader that performs
62    /// real I/O mid-read, not as a live case.
63    Other,
64}
65
66impl fmt::Display for ReadErrorKind {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            ReadErrorKind::SheetNotFound => f.write_str("sheet not found"),
70            ReadErrorKind::MalformedSheet => f.write_str("sheet is malformed"),
71            ReadErrorKind::Other => f.write_str("read failed"),
72        }
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Limit kind (RFC-012 / RFC-033 §10)
78// ---------------------------------------------------------------------------
79
80/// Which resource limit was exceeded.
81#[derive(Clone, Copy, PartialEq, Eq, Debug)]
82pub enum LimitKind {
83    Sheets,
84    CellsRead,
85    CellsCompared,
86    DiffsReturned,
87    /// RFC-035 §5.4: the input size bound, checked before any read begins.
88    InputBytes,
89}
90
91impl fmt::Display for LimitKind {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self {
94            LimitKind::Sheets => f.write_str("max_sheets"),
95            LimitKind::CellsRead => f.write_str("max_cells_read"),
96            LimitKind::CellsCompared => f.write_str("max_cells_compared"),
97            LimitKind::DiffsReturned => f.write_str("max_diffs_returned"),
98            LimitKind::InputBytes => f.write_str("max_input_bytes"),
99        }
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Boxed calamine error carrier
105// ---------------------------------------------------------------------------
106
107/// Opaque wrapper that owns the original `calamine::XlsxError` so that
108/// `SheetsDiffError::source()` can return it without naming calamine in any
109/// public signature (RFC-026).
110pub struct CalamiLineError(pub(crate) calamine::XlsxError);
111
112impl fmt::Debug for CalamiLineError {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        write!(f, "calamine error: {}", self.0)
115    }
116}
117impl fmt::Display for CalamiLineError {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        write!(f, "{}", self.0)
120    }
121}
122impl std::error::Error for CalamiLineError {}
123
124// ---------------------------------------------------------------------------
125// SheetsDiffError (RFC-033 §9)
126// ---------------------------------------------------------------------------
127
128/// Fatal error returned by every v2 entry point.
129///
130/// The `calamine` source error is preserved behind `std::error::Error::source()`
131/// — it never appears in any public variant type.
132#[non_exhaustive]
133#[derive(Debug)]
134pub enum SheetsDiffError {
135    /// A workbook could not be opened or parsed.
136    OpenWorkbook {
137        side: Side,
138        source: SourceDescription,
139        kind: OpenErrorKind,
140        /// Boxed calamine error; accessible via `Error::source()`.
141        inner: Option<Box<CalamiLineError>>,
142    },
143    /// A specific sheet inside an opened workbook could not be read.
144    ReadSheet {
145        side: Side,
146        sheet: SheetRef,
147        kind: ReadErrorKind,
148        inner: Option<Box<CalamiLineError>>,
149    },
150    /// The bytes/reader are a valid ZIP but not a recognised xlsx workbook.
151    UnsupportedFormat { side: Side, detail: String },
152    /// The workbook is password-protected (calamine `XlsxError::Password`).
153    EncryptedWorkbook { side: Side },
154    /// A `DiffOptions` combination is invalid; detected before any I/O.
155    InvalidOptions { detail: String },
156    /// The caller's cancellation predicate returned `true`.
157    Cancelled,
158    /// A configured `Limits` bound was reached.
159    LimitExceeded { limit: LimitKind, observed: u64 },
160    /// An internal programming error; indicates a bug in `sheets-diff`.
161    Internal { detail: String },
162}
163
164impl fmt::Display for SheetsDiffError {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        match self {
167            SheetsDiffError::OpenWorkbook {
168                side, source, kind, ..
169            } => {
170                let name = source.display_name.as_deref().unwrap_or("<unknown>");
171                write!(f, "cannot open {side} workbook '{name}': {kind}")
172            }
173            SheetsDiffError::ReadSheet {
174                side, sheet, kind, ..
175            } => {
176                write!(
177                    f,
178                    "cannot read sheet '{}' from {side} workbook: {kind}",
179                    sheet.name
180                )
181            }
182            SheetsDiffError::UnsupportedFormat { side, detail } => {
183                write!(
184                    f,
185                    "{side} workbook is not a supported xlsx format: {detail}"
186                )
187            }
188            SheetsDiffError::EncryptedWorkbook { side } => {
189                write!(f, "{side} workbook is password-protected")
190            }
191            SheetsDiffError::InvalidOptions { detail } => {
192                write!(f, "invalid options: {detail}")
193            }
194            SheetsDiffError::Cancelled => f.write_str("comparison was cancelled"),
195            SheetsDiffError::LimitExceeded { limit, observed } => {
196                write!(f, "limit '{limit}' exceeded (observed {observed})")
197            }
198            SheetsDiffError::Internal { detail } => {
199                write!(f, "internal error: {detail}")
200            }
201        }
202    }
203}
204
205impl std::error::Error for SheetsDiffError {
206    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
207        match self {
208            SheetsDiffError::OpenWorkbook { inner, .. } => {
209                inner.as_deref().map(|e| e as &dyn std::error::Error)
210            }
211            SheetsDiffError::ReadSheet { inner, .. } => {
212                inner.as_deref().map(|e| e as &dyn std::error::Error)
213            }
214            _ => None,
215        }
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Conversion helpers (crate-internal)
221// ---------------------------------------------------------------------------
222
223impl SheetsDiffError {
224    pub(crate) fn open_workbook(
225        side: Side,
226        source: SourceDescription,
227        calamine_err: calamine::XlsxError,
228    ) -> Self {
229        let kind = classify_open_error(&calamine_err);
230        SheetsDiffError::OpenWorkbook {
231            side,
232            source,
233            kind,
234            inner: Some(Box::new(CalamiLineError(calamine_err))),
235        }
236    }
237
238    pub(crate) fn read_sheet(
239        side: Side,
240        sheet: SheetRef,
241        calamine_err: calamine::XlsxError,
242    ) -> Self {
243        let kind = classify_read_error(&calamine_err);
244        SheetsDiffError::ReadSheet {
245            side,
246            sheet,
247            kind,
248            inner: Some(Box::new(CalamiLineError(calamine_err))),
249        }
250    }
251}
252
253fn classify_open_error(e: &calamine::XlsxError) -> OpenErrorKind {
254    use calamine::XlsxError;
255    match e {
256        XlsxError::Password => OpenErrorKind::NotXlsx, // reclassified below via EncryptedWorkbook
257        XlsxError::FileNotFound(_) => OpenErrorKind::NotFound,
258        XlsxError::Io(io) => match io.kind() {
259            std::io::ErrorKind::NotFound => OpenErrorKind::NotFound,
260            std::io::ErrorKind::PermissionDenied => OpenErrorKind::PermissionDenied,
261            _ => OpenErrorKind::Other,
262        },
263        XlsxError::Zip(_) => OpenErrorKind::NotXlsx,
264        _ => OpenErrorKind::Corrupt,
265    }
266}
267
268fn classify_read_error(e: &calamine::XlsxError) -> ReadErrorKind {
269    use calamine::XlsxError;
270    match e {
271        XlsxError::WorksheetNotFound(_) => ReadErrorKind::SheetNotFound,
272        // A disk or network-filesystem failure part-way through a read is
273        // not evidence the workbook itself is malformed; conflating the two
274        // reports a corrupt file when nothing is wrong with it.
275        XlsxError::Io(_) => ReadErrorKind::Other,
276        _ => ReadErrorKind::MalformedSheet,
277    }
278}
279
280/// Convert a calamine open error, detecting `Password` to produce the
281/// dedicated `EncryptedWorkbook` variant.
282pub(crate) fn from_open_error(
283    side: Side,
284    source: SourceDescription,
285    e: calamine::XlsxError,
286) -> SheetsDiffError {
287    if matches!(e, calamine::XlsxError::Password) {
288        SheetsDiffError::EncryptedWorkbook { side }
289    } else {
290        SheetsDiffError::open_workbook(side, source, e)
291    }
292}