Skip to main content

visi_core/
error.rs

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