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    SheetNotFound,
44    MalformedSheet,
45    Other,
46}
47
48impl fmt::Display for ReadErrorKind {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            ReadErrorKind::SheetNotFound => f.write_str("sheet not found"),
52            ReadErrorKind::MalformedSheet => f.write_str("sheet is malformed"),
53            ReadErrorKind::Other => f.write_str("read failed"),
54        }
55    }
56}
57
58// ---------------------------------------------------------------------------
59// Limit kind (RFC-012 / RFC-033 §10)
60// ---------------------------------------------------------------------------
61
62/// Which resource limit was exceeded.
63#[derive(Clone, Copy, PartialEq, Eq, Debug)]
64pub enum LimitKind {
65    Sheets,
66    CellsRead,
67    CellsCompared,
68    DiffsReturned,
69    /// RFC-035 §5.4: the input size bound, checked before any read begins.
70    InputBytes,
71}
72
73impl fmt::Display for LimitKind {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self {
76            LimitKind::Sheets => f.write_str("max_sheets"),
77            LimitKind::CellsRead => f.write_str("max_cells_read"),
78            LimitKind::CellsCompared => f.write_str("max_cells_compared"),
79            LimitKind::DiffsReturned => f.write_str("max_diffs_returned"),
80            LimitKind::InputBytes => f.write_str("max_input_bytes"),
81        }
82    }
83}
84
85// ---------------------------------------------------------------------------
86// Boxed calamine error carrier
87// ---------------------------------------------------------------------------
88
89/// Opaque wrapper that owns the original `calamine::XlsxError` so that
90/// `SheetsDiffError::source()` can return it without naming calamine in any
91/// public signature (RFC-026).
92pub struct CalamiLineError(pub(crate) calamine::XlsxError);
93
94impl fmt::Debug for CalamiLineError {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(f, "calamine error: {}", self.0)
97    }
98}
99impl fmt::Display for CalamiLineError {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        write!(f, "{}", self.0)
102    }
103}
104impl std::error::Error for CalamiLineError {}
105
106// ---------------------------------------------------------------------------
107// SheetsDiffError (RFC-033 §9)
108// ---------------------------------------------------------------------------
109
110/// Fatal error returned by every v2 entry point.
111///
112/// The `calamine` source error is preserved behind `std::error::Error::source()`
113/// — it never appears in any public variant type.
114#[non_exhaustive]
115#[derive(Debug)]
116pub enum SheetsDiffError {
117    /// A workbook could not be opened or parsed.
118    OpenWorkbook {
119        side: Side,
120        source: SourceDescription,
121        kind: OpenErrorKind,
122        /// Boxed calamine error; accessible via `Error::source()`.
123        inner: Option<Box<CalamiLineError>>,
124    },
125    /// A specific sheet inside an opened workbook could not be read.
126    ReadSheet {
127        side: Side,
128        sheet: SheetRef,
129        kind: ReadErrorKind,
130        inner: Option<Box<CalamiLineError>>,
131    },
132    /// The bytes/reader are a valid ZIP but not a recognised xlsx workbook.
133    UnsupportedFormat { side: Side, detail: String },
134    /// The workbook is password-protected (calamine `XlsxError::Password`).
135    EncryptedWorkbook { side: Side },
136    /// A `DiffOptions` combination is invalid; detected before any I/O.
137    InvalidOptions { detail: String },
138    /// The caller's cancellation predicate returned `true`.
139    Cancelled,
140    /// A configured `Limits` bound was reached.
141    LimitExceeded { limit: LimitKind, observed: u64 },
142    /// An internal programming error; indicates a bug in `sheets-diff`.
143    Internal { detail: String },
144}
145
146impl fmt::Display for SheetsDiffError {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        match self {
149            SheetsDiffError::OpenWorkbook {
150                side, source, kind, ..
151            } => {
152                let name = source.display_name.as_deref().unwrap_or("<unknown>");
153                write!(f, "cannot open {side} workbook '{name}': {kind}")
154            }
155            SheetsDiffError::ReadSheet {
156                side, sheet, kind, ..
157            } => {
158                write!(
159                    f,
160                    "cannot read sheet '{}' from {side} workbook: {kind}",
161                    sheet.name
162                )
163            }
164            SheetsDiffError::UnsupportedFormat { side, detail } => {
165                write!(
166                    f,
167                    "{side} workbook is not a supported xlsx format: {detail}"
168                )
169            }
170            SheetsDiffError::EncryptedWorkbook { side } => {
171                write!(f, "{side} workbook is password-protected")
172            }
173            SheetsDiffError::InvalidOptions { detail } => {
174                write!(f, "invalid options: {detail}")
175            }
176            SheetsDiffError::Cancelled => f.write_str("comparison was cancelled"),
177            SheetsDiffError::LimitExceeded { limit, observed } => {
178                write!(f, "limit '{limit}' exceeded (observed {observed})")
179            }
180            SheetsDiffError::Internal { detail } => {
181                write!(f, "internal error: {detail}")
182            }
183        }
184    }
185}
186
187impl std::error::Error for SheetsDiffError {
188    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
189        match self {
190            SheetsDiffError::OpenWorkbook { inner, .. } => {
191                inner.as_deref().map(|e| e as &dyn std::error::Error)
192            }
193            SheetsDiffError::ReadSheet { inner, .. } => {
194                inner.as_deref().map(|e| e as &dyn std::error::Error)
195            }
196            _ => None,
197        }
198    }
199}
200
201// ---------------------------------------------------------------------------
202// Conversion helpers (crate-internal)
203// ---------------------------------------------------------------------------
204
205impl SheetsDiffError {
206    pub(crate) fn open_workbook(
207        side: Side,
208        source: SourceDescription,
209        calamine_err: calamine::XlsxError,
210    ) -> Self {
211        let kind = classify_open_error(&calamine_err);
212        SheetsDiffError::OpenWorkbook {
213            side,
214            source,
215            kind,
216            inner: Some(Box::new(CalamiLineError(calamine_err))),
217        }
218    }
219
220    pub(crate) fn read_sheet(
221        side: Side,
222        sheet: SheetRef,
223        calamine_err: calamine::XlsxError,
224    ) -> Self {
225        let kind = classify_read_error(&calamine_err);
226        SheetsDiffError::ReadSheet {
227            side,
228            sheet,
229            kind,
230            inner: Some(Box::new(CalamiLineError(calamine_err))),
231        }
232    }
233}
234
235fn classify_open_error(e: &calamine::XlsxError) -> OpenErrorKind {
236    use calamine::XlsxError;
237    match e {
238        XlsxError::Password => OpenErrorKind::NotXlsx, // reclassified below via EncryptedWorkbook
239        XlsxError::FileNotFound(_) => OpenErrorKind::NotFound,
240        XlsxError::Io(io) => match io.kind() {
241            std::io::ErrorKind::NotFound => OpenErrorKind::NotFound,
242            std::io::ErrorKind::PermissionDenied => OpenErrorKind::PermissionDenied,
243            _ => OpenErrorKind::Other,
244        },
245        XlsxError::Zip(_) => OpenErrorKind::NotXlsx,
246        _ => OpenErrorKind::Corrupt,
247    }
248}
249
250fn classify_read_error(e: &calamine::XlsxError) -> ReadErrorKind {
251    use calamine::XlsxError;
252    match e {
253        XlsxError::WorksheetNotFound(_) => ReadErrorKind::SheetNotFound,
254        _ => ReadErrorKind::MalformedSheet,
255    }
256}
257
258/// Convert a calamine open error, detecting `Password` to produce the
259/// dedicated `EncryptedWorkbook` variant.
260pub(crate) fn from_open_error(
261    side: Side,
262    source: SourceDescription,
263    e: calamine::XlsxError,
264) -> SheetsDiffError {
265    if matches!(e, calamine::XlsxError::Password) {
266        SheetsDiffError::EncryptedWorkbook { side }
267    } else {
268        SheetsDiffError::open_workbook(side, source, e)
269    }
270}