Skip to main content

xl3_core/
errors.rs

1//! Stable error-code surface, mirroring xl3 (TS)'s `XtlError` /
2//! `xtlError` / `isXtlError` (ADR-0015) and xl3-py's `XtlError`.
3//!
4//! Status: this is the *type-level* parity. Internally most call
5//! sites still throw `anyhow::Error` with a free-form message — they
6//! migrate to `XtlError::new(code, msg)` as we touch each one. The
7//! down-cast helper lets a host (xl3-wasm, conformance runner) ask
8//! "is this a known XTL error?" today regardless of how many sites
9//! have moved.
10
11use std::fmt;
12
13/// One known XTL error code, mirroring the slash-namespaced strings
14/// the TS/py implementations emit (e.g. `xl3/source/sheet-missing`).
15/// Stored as a free-form string so we can stay in sync with the
16/// canonical catalogue without versioning a Rust enum every time a
17/// new code lands upstream.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct XtlError {
20    pub code: String,
21    pub message: String,
22}
23
24impl XtlError {
25    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
26        XtlError {
27            code: code.into(),
28            message: message.into(),
29        }
30    }
31}
32
33impl fmt::Display for XtlError {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        write!(f, "[{}] {}", self.code, self.message)
36    }
37}
38
39impl std::error::Error for XtlError {}
40
41/// Downcast helper mirroring xl3 (TS) `isXtlError(e)` / xl3-py
42/// `is_xtl_error(e)`. Returns the `&XtlError` view when the anyhow
43/// chain originated from an XtlError, otherwise `None`.
44pub fn is_xtl_error(err: &anyhow::Error) -> Option<&XtlError> {
45    err.downcast_ref::<XtlError>()
46}
47
48/// A few canonical codes hosts will want to match on without hard-
49/// coding magic strings. The full catalogue (36 in TS, 43 in py)
50/// stays in the slash-string namespace — these are just the ones the
51/// Rust core actively emits today.
52pub mod code {
53    pub const SOURCE_SHEET_MISSING: &str = "xl3/source/sheet-missing";
54    pub const SOURCE_NO_HEADER: &str = "xl3/source/no-header";
55    pub const SOURCE_DUPLICATE_COLUMN: &str = "xl3/source/duplicate-column";
56    pub const EVAL_DIV_BY_ZERO: &str = "xl3/eval/div-by-zero";
57    pub const EVAL_UNSUPPORTED_SYNTAX: &str = "xl3/eval/unsupported-syntax";
58    pub const EVAL_UNKNOWN_NAME: &str = "xl3/expression/unknown-name";
59    pub const EVAL_ARITY_MISMATCH: &str = "xl3/eval/arity-mismatch";
60    pub const EVAL_OPERAND_COERCION: &str = "xl3/eval/operand-coercion";
61    pub const DIRECTIVE_BAD_JOIN: &str = "xl3/directive/bad-join";
62    pub const XLOOKUP_BARE_BRACKET: &str = "xl3/xlookup/bare-bracket";
63    pub const XLOOKUP_SOURCE_MISMATCH: &str = "xl3/xlookup/source-mismatch";
64    pub const TEMPLATE_NO_SHEETS: &str = "xl3/template/no-visible-sheets";
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use anyhow::Result;
71
72    fn fails_with_code() -> Result<()> {
73        Err(XtlError::new(code::EVAL_DIV_BY_ZERO, "test message").into())
74    }
75
76    #[test]
77    fn downcasts_through_anyhow() {
78        let err = fails_with_code().unwrap_err();
79        let xtl = is_xtl_error(&err).expect("expected XtlError");
80        assert_eq!(xtl.code, "xl3/eval/div-by-zero");
81        assert_eq!(xtl.message, "test message");
82    }
83
84    #[test]
85    fn non_xtl_error_returns_none() {
86        let err: anyhow::Error = anyhow::anyhow!("plain anyhow");
87        assert!(is_xtl_error(&err).is_none());
88    }
89
90    #[test]
91    fn display_uses_bracket_code() {
92        let e = XtlError::new(code::SOURCE_SHEET_MISSING, "foo");
93        assert_eq!(format!("{e}"), "[xl3/source/sheet-missing] foo");
94    }
95}