Skip to main content

visi_core/
error.rs

1//! The error type returned by `visi-core`'s public API.
2
3use crate::core::engine::EngineError;
4
5/// The kind of workbook object an [`Error`] refers to.
6///
7/// Used by the [`Error::NotFound`] / [`Error::AlreadyExists`] /
8/// [`Error::NameTaken`] variants so callers can distinguish "no such sheet"
9/// from "no such table" without parsing the message text.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11#[non_exhaustive]
12pub enum ObjectKind {
13    /// A worksheet.
14    Sheet,
15    /// An Excel Table (ListObject) -- a named range with a header row, not a
16    /// worksheet. See [`crate::core::ExcelTable`].
17    Table,
18    /// A column within an Excel Table.
19    TableColumn,
20    /// A pivot table.
21    PivotTable,
22    /// A field within a pivot table.
23    PivotField,
24    /// A chart.
25    Chart,
26    /// A VBA module.
27    VbaModule,
28}
29
30impl ObjectKind {
31    /// The human-readable name used in error messages ("sheet", "table", ...).
32    pub fn as_str(self) -> &'static str {
33        match self {
34            ObjectKind::Sheet => "sheet",
35            ObjectKind::Table => "table",
36            ObjectKind::TableColumn => "table column",
37            ObjectKind::PivotTable => "pivot table",
38            ObjectKind::PivotField => "pivot field",
39            ObjectKind::Chart => "chart",
40            ObjectKind::VbaModule => "VBA module",
41        }
42    }
43}
44
45impl std::fmt::Display for ObjectKind {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.write_str(self.as_str())
48    }
49}
50
51/// Errors returned by `visi-core`'s public API.
52///
53/// This enum is `#[non_exhaustive]`: match with a `_` arm, since new variants
54/// may be added in a minor release.
55#[derive(Debug, Clone, PartialEq)]
56#[non_exhaustive]
57pub enum Error {
58    /// No object of this kind goes by this name (or id, for charts).
59    NotFound {
60        /// What was being looked up.
61        kind: ObjectKind,
62        /// The name that was not found.
63        name: String,
64        /// The names that *do* exist, when the call can supply them cheaply,
65        /// so callers can render a "did you mean" hint. Often empty.
66        available: Vec<String>,
67    },
68    /// An object of this kind already goes by this name, so it cannot be added.
69    AlreadyExists {
70        /// What was being added.
71        kind: ObjectKind,
72        /// The name that collided.
73        name: String,
74    },
75    /// A rename was rejected because the new name is already in use.
76    ///
77    /// Distinct from [`Error::AlreadyExists`], which is raised when *creating*.
78    NameTaken {
79        /// What was being renamed.
80        kind: ObjectKind,
81        /// The requested new name.
82        name: String,
83    },
84    /// A name was rejected as structurally invalid, independent of collisions.
85    InvalidName {
86        /// What was being named.
87        kind: ObjectKind,
88        /// The rejected name.
89        name: String,
90        /// Why it was rejected.
91        reason: String,
92    },
93    /// A row or column index fell outside the sheet.
94    OutOfBounds {
95        /// What was being indexed ("row" or "column").
96        what: &'static str,
97        /// The offending 0-based index.
98        index: usize,
99        /// The number of rows/columns that exist.
100        len: usize,
101    },
102    /// A cell range was malformed -- for example, an end before its start.
103    InvalidRange(String),
104    /// The operation needs at least one sheet and the workbook has none.
105    EmptyWorkbook,
106    /// The last remaining sheet cannot be deleted; a workbook needs one.
107    LastSheetInWorkbook,
108    /// A worksheet can carry only one bound VBA document module.
109    DocumentModuleExists,
110    /// The operation was rejected by a lower layer that does not yet report a
111    /// typed error -- currently the Excel Table and pivot internals.
112    ///
113    /// Carries message text only. Do not match on the string; variants will be
114    /// carved out of this one as those layers are typed, which is why [`Error`]
115    /// is `#[non_exhaustive]`.
116    InvalidArgument(String),
117    /// Reading or writing the `.xlsx` container failed.
118    Xlsx(String),
119    /// Reading or writing the VBA project failed.
120    Vba(String),
121    /// A VBA module failed to parse.
122    ///
123    /// Carries the position separately from the message so a caller can point
124    /// at the offending line -- an editor integration, or `visi macro check
125    /// --json` -- without parsing the text back out.
126    VbaSyntax {
127        /// What went wrong, phrased for someone reading it.
128        message: String,
129        /// The module the error is in, when the caller knew one.
130        module: Option<String>,
131        /// 1-based line number within that module's source.
132        line: u32,
133        /// 1-based column number, counted in characters.
134        column: u32,
135    },
136    /// A VBA procedure raised a run-time error.
137    ///
138    /// Carries VBA's own `Err.Number` so a caller can compare it against what
139    /// Excel would have raised, which is what the differential fuzzer does.
140    VbaRuntime {
141        /// `Err.Description`.
142        message: String,
143        /// `Err.Number`.
144        number: i32,
145    },
146    /// Formula evaluation failed.
147    Eval(EngineError),
148}
149
150impl Error {
151    /// A [`Error::NotFound`] with no "did you mean" candidates.
152    pub fn not_found(kind: ObjectKind, name: impl Into<String>) -> Self {
153        Error::NotFound {
154            kind,
155            name: name.into(),
156            available: Vec::new(),
157        }
158    }
159
160    /// A [`Error::NotFound`] that also carries the names that do exist.
161    pub fn not_found_among(
162        kind: ObjectKind,
163        name: impl Into<String>,
164        available: Vec<String>,
165    ) -> Self {
166        Error::NotFound {
167            kind,
168            name: name.into(),
169            available,
170        }
171    }
172}
173
174impl std::fmt::Display for Error {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        match self {
177            Error::NotFound {
178                kind,
179                name,
180                available,
181            } => {
182                write!(f, "{kind} '{name}' not found")?;
183                if !available.is_empty() {
184                    write!(f, ". Available {kind}s: {}", available.join(", "))?;
185                }
186                Ok(())
187            }
188            Error::AlreadyExists { kind, name } => write!(f, "{kind} '{name}' already exists"),
189            Error::NameTaken { kind, name } => {
190                write!(f, "{kind} name '{name}' is already taken")
191            }
192            Error::InvalidName { kind, name, reason } => {
193                write!(f, "invalid {kind} name '{name}': {reason}")
194            }
195            Error::OutOfBounds { what, index, len } => {
196                write!(f, "{what} index {index} is out of bounds (sheet has {len})")
197            }
198            Error::InvalidRange(msg) => write!(f, "invalid range: {msg}"),
199            Error::EmptyWorkbook => f.write_str("workbook contains no sheets"),
200            Error::LastSheetInWorkbook => {
201                f.write_str("cannot delete the only sheet in the workbook")
202            }
203            Error::DocumentModuleExists => {
204                f.write_str("that sheet already has a bound document module")
205            }
206            Error::InvalidArgument(msg) => f.write_str(msg),
207            Error::Xlsx(msg) => write!(f, "xlsx error: {msg}"),
208            Error::Vba(msg) => write!(f, "VBA error: {msg}"),
209            Error::VbaRuntime { message, number } => {
210                write!(f, "run-time error {number}: {message}")
211            }
212            Error::VbaSyntax {
213                message,
214                module,
215                line,
216                column,
217            } => match module {
218                Some(m) => write!(f, "{m}({line},{column}): {message}"),
219                None => write!(f, "line {line}, column {column}: {message}"),
220            },
221            Error::Eval(err) => write!(f, "evaluation error: {err}"),
222        }
223    }
224}
225
226impl std::error::Error for Error {
227    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
228        match self {
229            Error::Eval(err) => Some(err),
230            _ => None,
231        }
232    }
233}
234
235impl From<EngineError> for Error {
236    fn from(err: EngineError) -> Self {
237        Error::Eval(err)
238    }
239}
240
241/// A `Result` whose error type is [`Error`].
242pub type Result<T> = std::result::Result<T, Error>;
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn display_reads_naturally() {
250        let e = Error::not_found(ObjectKind::PivotTable, "Sales");
251        assert_eq!(e.to_string(), "pivot table 'Sales' not found");
252
253        let e = Error::NameTaken {
254            kind: ObjectKind::Sheet,
255            name: "Data".into(),
256        };
257        assert_eq!(e.to_string(), "sheet name 'Data' is already taken");
258    }
259
260    #[test]
261    fn is_a_std_error() {
262        fn assert_std_error<E: std::error::Error>(_: &E) {}
263        assert_std_error(&Error::EmptyWorkbook);
264        let boxed: Box<dyn std::error::Error> = Box::new(Error::EmptyWorkbook);
265        assert_eq!(boxed.to_string(), "workbook contains no sheets");
266    }
267
268    #[test]
269    fn callers_can_match_on_kind_without_parsing_text() {
270        let e = Error::not_found(ObjectKind::Table, "Q1");
271        assert!(matches!(
272            e,
273            Error::NotFound {
274                kind: ObjectKind::Table,
275                ..
276            }
277        ));
278    }
279}