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}