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    /// Formula evaluation failed.
122    Eval(EngineError),
123}
124
125impl Error {
126    /// A [`Error::NotFound`] with no "did you mean" candidates.
127    pub fn not_found(kind: ObjectKind, name: impl Into<String>) -> Self {
128        Error::NotFound {
129            kind,
130            name: name.into(),
131            available: Vec::new(),
132        }
133    }
134
135    /// A [`Error::NotFound`] that also carries the names that do exist.
136    pub fn not_found_among(
137        kind: ObjectKind,
138        name: impl Into<String>,
139        available: Vec<String>,
140    ) -> Self {
141        Error::NotFound {
142            kind,
143            name: name.into(),
144            available,
145        }
146    }
147}
148
149impl std::fmt::Display for Error {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        match self {
152            Error::NotFound {
153                kind,
154                name,
155                available,
156            } => {
157                write!(f, "{kind} '{name}' not found")?;
158                if !available.is_empty() {
159                    write!(f, ". Available {kind}s: {}", available.join(", "))?;
160                }
161                Ok(())
162            }
163            Error::AlreadyExists { kind, name } => write!(f, "{kind} '{name}' already exists"),
164            Error::NameTaken { kind, name } => {
165                write!(f, "{kind} name '{name}' is already taken")
166            }
167            Error::InvalidName { kind, name, reason } => {
168                write!(f, "invalid {kind} name '{name}': {reason}")
169            }
170            Error::OutOfBounds { what, index, len } => {
171                write!(f, "{what} index {index} is out of bounds (sheet has {len})")
172            }
173            Error::InvalidRange(msg) => write!(f, "invalid range: {msg}"),
174            Error::EmptyWorkbook => f.write_str("workbook contains no sheets"),
175            Error::LastSheetInWorkbook => {
176                f.write_str("cannot delete the only sheet in the workbook")
177            }
178            Error::DocumentModuleExists => {
179                f.write_str("that sheet already has a bound document module")
180            }
181            Error::InvalidArgument(msg) => f.write_str(msg),
182            Error::Xlsx(msg) => write!(f, "xlsx error: {msg}"),
183            Error::Vba(msg) => write!(f, "VBA error: {msg}"),
184            Error::Eval(err) => write!(f, "evaluation error: {err}"),
185        }
186    }
187}
188
189impl std::error::Error for Error {
190    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
191        match self {
192            Error::Eval(err) => Some(err),
193            _ => None,
194        }
195    }
196}
197
198impl From<EngineError> for Error {
199    fn from(err: EngineError) -> Self {
200        Error::Eval(err)
201    }
202}
203
204/// A `Result` whose error type is [`Error`].
205pub type Result<T> = std::result::Result<T, Error>;
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn display_reads_naturally() {
213        let e = Error::not_found(ObjectKind::PivotTable, "Sales");
214        assert_eq!(e.to_string(), "pivot table 'Sales' not found");
215
216        let e = Error::NameTaken {
217            kind: ObjectKind::Sheet,
218            name: "Data".into(),
219        };
220        assert_eq!(e.to_string(), "sheet name 'Data' is already taken");
221    }
222
223    #[test]
224    fn is_a_std_error() {
225        fn assert_std_error<E: std::error::Error>(_: &E) {}
226        assert_std_error(&Error::EmptyWorkbook);
227        let boxed: Box<dyn std::error::Error> = Box::new(Error::EmptyWorkbook);
228        assert_eq!(boxed.to_string(), "workbook contains no sheets");
229    }
230
231    #[test]
232    fn callers_can_match_on_kind_without_parsing_text() {
233        let e = Error::not_found(ObjectKind::Table, "Q1");
234        assert!(matches!(
235            e,
236            Error::NotFound {
237                kind: ObjectKind::Table,
238                ..
239            }
240        ));
241    }
242}