Skip to main content

formualizer_common/
error.rs

1//! Excel-style error representation that is both ergonomic **now**
2//! *and* flexible enough to grow new, data-rich variants later.
3//!
4//! - **`ExcelErrorKind`** : the canonical set of Excel error codes  
5//! - **`ErrorContext`**   : lightweight, sheet-agnostic location info  
6//! - **`ExcelErrorExtra`**: per-kind “extension slot” (e.g. `Spill`)  
7//! - **`ExcelError`**     : one struct that glues the three together
8//!
9//! When a future error needs its own payload, just add another variant
10//! to `ExcelErrorExtra`; existing code does not break.
11
12use std::{error::Error, fmt};
13
14#[cfg(feature = "serde")]
15use serde::{Deserialize, Serialize};
16
17use crate::LiteralValue;
18
19/// All recognised Excel error codes.
20///
21/// **Note:** names are CamelCase (idiomatic Rust) while `Display`
22/// renders them exactly as Excel shows them (`#DIV/0!`, …).
23#[non_exhaustive]
24#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
25#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
26pub enum ExcelErrorKind {
27    Null,
28    Ref,
29    Name,
30    Value,
31    Div,
32    Na,
33    Num,
34    Error,
35    NImpl,
36    Spill,
37    Calc,
38    Circ,
39    Cancelled,
40}
41
42impl fmt::Display for ExcelErrorKind {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str(match self {
45            Self::Null => "#NULL!",
46            Self::Ref => "#REF!",
47            Self::Name => "#NAME?",
48            Self::Value => "#VALUE!",
49            Self::Div => "#DIV/0!",
50            Self::Na => "#N/A",
51            Self::Num => "#NUM!",
52            Self::Error => "#ERROR!",
53            Self::NImpl => "#N/IMPL!",
54            Self::Spill => "#SPILL!",
55            Self::Calc => "#CALC!",
56            Self::Circ => "#CIRC!",
57            Self::Cancelled => "#CANCELLED!",
58        })
59    }
60}
61
62impl ExcelErrorKind {
63    pub fn try_parse(s: &str) -> Option<Self> {
64        match s.trim().to_ascii_lowercase().as_str() {
65            "#null!" => Some(Self::Null),
66            "#ref!" => Some(Self::Ref),
67            "#name?" => Some(Self::Name),
68            "#value!" => Some(Self::Value),
69            "#div/0!" => Some(Self::Div),
70            "#n/a" => Some(Self::Na),
71            "#num!" => Some(Self::Num),
72            "#error!" => Some(Self::Error),
73            "#n/impl!" => Some(Self::NImpl),
74            "#spill!" => Some(Self::Spill),
75            "#calc!" => Some(Self::Calc),
76            "#circ!" => Some(Self::Circ),
77            "#cancelled!" => Some(Self::Cancelled),
78            _ => None,
79        }
80    }
81
82    pub fn parse(s: &str) -> Self {
83        Self::try_parse(s).unwrap_or(Self::Error)
84    }
85}
86
87/// Generic, lightweight metadata that *any* error may carry.
88///
89/// Keep this minimal—anything only one error kind needs belongs in
90/// `ExcelErrorExtra`.
91#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
92#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
93pub struct ErrorContext {
94    pub row: Option<u32>,
95    pub col: Option<u32>,
96    // Origin location where the error first occurred (if different from row/col)
97    pub origin_row: Option<u32>,
98    pub origin_col: Option<u32>,
99    pub origin_sheet: Option<String>,
100}
101
102/// Stable reason for a resource exhaustion diagnostic.
103#[non_exhaustive]
104#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
105#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
106pub enum ResourceExhaustionReason {
107    Admission,
108    RetainedMemory,
109    ScratchMemory,
110    WorkUnits,
111    Deadline,
112    GraphVertices,
113    GraphEdges,
114    MaterializationCells,
115    ArithmeticOverflow,
116}
117
118impl ResourceExhaustionReason {
119    pub const fn as_str(self) -> &'static str {
120        match self {
121            Self::Admission => "admission",
122            Self::RetainedMemory => "retained_memory",
123            Self::ScratchMemory => "scratch_memory",
124            Self::WorkUnits => "work_units",
125            Self::Deadline => "deadline",
126            Self::GraphVertices => "graph_vertices",
127            Self::GraphEdges => "graph_edges",
128            Self::MaterializationCells => "materialization_cells",
129            Self::ArithmeticOverflow => "arithmetic_overflow",
130        }
131    }
132}
133
134/// Binding-safe typed resource exhaustion payload.
135#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
136#[derive(Debug, Clone, PartialEq, Eq, Hash)]
137pub struct ResourceExhaustionDetail {
138    pub reason: ResourceExhaustionReason,
139    pub limit: u64,
140    pub observed: u64,
141    pub request_id: Option<u64>,
142}
143
144/// Stable category for a target-preparation plan rejected at its final boundary.
145#[non_exhaustive]
146#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
147#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
148pub enum PreparationStaleReason {
149    Graph,
150    Authority,
151    Staged,
152    Symbols,
153    Semantic,
154    Provider,
155}
156
157impl PreparationStaleReason {
158    pub const fn as_str(self) -> &'static str {
159        match self {
160            Self::Graph => "graph",
161            Self::Authority => "authority",
162            Self::Staged => "staged",
163            Self::Symbols => "symbols",
164            Self::Semantic => "semantic",
165            Self::Provider => "provider",
166        }
167    }
168}
169
170/// Stable category for a reusable recalculation plan rejected before execution.
171#[non_exhaustive]
172#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
173#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
174pub enum PlanStaleReason {
175    Engine,
176    Provider,
177    Semantic,
178    Budget,
179    Staged,
180    Symbols,
181    Authority,
182    SpanGeneration,
183    Graph,
184}
185
186impl PlanStaleReason {
187    pub const fn as_str(self) -> &'static str {
188        match self {
189            Self::Engine => "engine",
190            Self::Provider => "provider",
191            Self::Semantic => "semantic",
192            Self::Budget => "budget",
193            Self::Staged => "staged",
194            Self::Symbols => "symbols",
195            Self::Authority => "authority",
196            Self::SpanGeneration => "span_generation",
197            Self::Graph => "graph",
198        }
199    }
200}
201
202/// Kind-specific payloads (“extension slot”).
203///
204/// Only variants that need extra data get it—rest stay at `None`.
205#[non_exhaustive]
206#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
207#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
208pub enum ExcelErrorExtra {
209    /// No additional payload (the vast majority of errors).
210    #[default]
211    None,
212
213    /// `#SPILL!` – information about the intended spill size.
214    Spill {
215        expected_rows: u32,
216        expected_cols: u32,
217    },
218
219    /// Typed resource exhaustion. The canonical Excel error kind remains
220    /// unchanged so existing bindings and formula error handling stay compatible.
221    Resource {
222        detail: Box<ResourceExhaustionDetail>,
223    },
224
225    PreparationStale {
226        reason: PreparationStaleReason,
227    },
228
229    PlanStale {
230        reason: PlanStaleReason,
231    },
232    // --- Add future custom payloads below -------------------------------
233    // AnotherKind { … },
234}
235
236/// The single struct your API passes around.
237///
238/// It combines:
239/// * **kind**   – the mandatory Excel error code
240/// * **message**– optional human explanation
241/// * **context**– generic location†
242/// * **extra**  – optional, kind-specific data
243///
244/// † If you *never* need row/col you can build the value with
245///   `ExcelError::from(kind)`, which sets `context = None`.
246#[derive(Clone, PartialEq, Eq)]
247pub struct ExcelError {
248    pub kind: ExcelErrorKind,
249    // Empty metadata is always represented by None, keeping derived equality logical.
250    payload: Option<Box<ExcelErrorPayload>>,
251}
252
253#[derive(Clone, PartialEq, Eq, Default)]
254struct ExcelErrorPayload {
255    message: Option<String>,
256    context: Option<ErrorContext>,
257    extra: ExcelErrorExtra,
258}
259
260// Keep the logical fields (rather than the storage representation) observable.
261impl fmt::Debug for ExcelError {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        f.debug_struct("ExcelError")
264            .field("kind", &self.kind)
265            .field("message", &self.message())
266            .field("context", &self.context())
267            .field("extra", self.extra())
268            .finish()
269    }
270}
271
272impl std::hash::Hash for ExcelError {
273    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
274        self.kind.hash(state);
275        self.message().hash(state);
276        self.context().hash(state);
277        self.extra().hash(state);
278    }
279}
280
281#[cfg(feature = "serde")]
282impl Serialize for ExcelError {
283    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
284        use serde::ser::SerializeStruct;
285        let mut fields = serializer.serialize_struct("ExcelError", 4)?;
286        fields.serialize_field("kind", &self.kind)?;
287        fields.serialize_field("message", &self.message())?;
288        fields.serialize_field("context", &self.context())?;
289        fields.serialize_field("extra", self.extra())?;
290        fields.end()
291    }
292}
293
294#[cfg(feature = "serde")]
295impl<'de> Deserialize<'de> for ExcelError {
296    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
297        #[derive(Deserialize)]
298        #[serde(rename = "ExcelError")]
299        struct Fields {
300            kind: ExcelErrorKind,
301            message: Option<String>,
302            context: Option<ErrorContext>,
303            extra: ExcelErrorExtra,
304        }
305        let fields = Fields::deserialize(deserializer)?;
306        let mut error = ExcelError::new(fields.kind);
307        error.set_message(fields.message);
308        error.set_context(fields.context);
309        error.set_extra(fields.extra);
310        Ok(error)
311    }
312}
313
314/* ───────────────────── Constructors & helpers ─────────────────────── */
315
316impl From<ExcelErrorKind> for ExcelError {
317    fn from(kind: ExcelErrorKind) -> Self {
318        Self {
319            kind,
320            payload: None,
321        }
322    }
323}
324
325impl ExcelError {
326    /// Basic constructor (no message, no location, no extra).
327    pub fn new(kind: ExcelErrorKind) -> Self {
328        kind.into()
329    }
330
331    /// Human-readable explanation, if supplied.
332    pub fn message(&self) -> Option<&str> {
333        self.payload
334            .as_ref()
335            .and_then(|payload| payload.message.as_deref())
336    }
337
338    /// Generic location metadata, if supplied.
339    pub fn context(&self) -> Option<&ErrorContext> {
340        self.payload
341            .as_ref()
342            .and_then(|payload| payload.context.as_ref())
343    }
344
345    /// Kind-specific data; payload-free errors return `ExcelErrorExtra::None`.
346    pub fn extra(&self) -> &ExcelErrorExtra {
347        self.payload
348            .as_ref()
349            .map_or(&ExcelErrorExtra::None, |payload| &payload.extra)
350    }
351
352    fn payload_mut(&mut self) -> &mut ExcelErrorPayload {
353        self.payload.get_or_insert_default()
354    }
355
356    fn discard_empty_payload(&mut self) {
357        if self.payload.as_ref().is_some_and(|payload| {
358            payload.message.is_none()
359                && payload.context.is_none()
360                && payload.extra == ExcelErrorExtra::None
361        }) {
362            self.payload = None;
363        }
364    }
365
366    /// Replace or clear the explanation, retaining any other metadata.
367    pub fn set_message(&mut self, message: Option<String>) {
368        if message.is_some() || self.payload.is_some() {
369            self.payload_mut().message = message;
370            self.discard_empty_payload();
371        }
372    }
373
374    /// Replace or clear the location, retaining any other metadata.
375    pub fn set_context(&mut self, context: Option<ErrorContext>) {
376        if context.is_some() || self.payload.is_some() {
377            self.payload_mut().context = context;
378            self.discard_empty_payload();
379        }
380    }
381
382    /// Replace or clear the kind-specific data, retaining any other metadata.
383    pub fn set_extra(&mut self, extra: ExcelErrorExtra) {
384        if extra != ExcelErrorExtra::None || self.payload.is_some() {
385            self.payload_mut().extra = extra;
386            self.discard_empty_payload();
387        }
388    }
389
390    /// Attach a human-readable explanation.
391    pub fn with_message<S: Into<String>>(mut self, msg: S) -> Self {
392        self.set_message(Some(msg.into()));
393        self
394    }
395
396    /// Attach generic row/column coordinates.
397    pub fn with_location(mut self, row: u32, col: u32) -> Self {
398        self.set_context(Some(ErrorContext {
399            row: Some(row),
400            col: Some(col),
401            origin_row: None,
402            origin_col: None,
403            origin_sheet: None,
404        }));
405        self
406    }
407
408    /// Attach origin location where the error first occurred.
409    pub fn with_origin(mut self, sheet: Option<String>, row: u32, col: u32) -> Self {
410        let ctx = self.payload_mut().context.get_or_insert_default();
411        ctx.origin_sheet = sheet;
412        ctx.origin_row = Some(row);
413        ctx.origin_col = Some(col);
414        self
415    }
416
417    /// Attach kind-specific extra data.
418    pub fn with_extra(mut self, extra: ExcelErrorExtra) -> Self {
419        self.set_extra(extra);
420        self
421    }
422
423    pub fn from_error_string(s: &str) -> Self {
424        match ExcelErrorKind::try_parse(s) {
425            Some(kind) => Self::new(kind),
426            None => {
427                Self::new(ExcelErrorKind::Error).with_message(format!("Unknown error code: {s}"))
428            }
429        }
430    }
431
432    pub fn new_value() -> Self {
433        Self::new(ExcelErrorKind::Value)
434    }
435
436    pub fn new_name() -> Self {
437        Self::new(ExcelErrorKind::Name)
438    }
439
440    pub fn new_div() -> Self {
441        Self::new(ExcelErrorKind::Div)
442    }
443
444    pub fn new_ref() -> Self {
445        Self::new(ExcelErrorKind::Ref)
446    }
447
448    pub fn new_circ() -> Self {
449        Self::new(ExcelErrorKind::Circ)
450    }
451
452    pub fn new_num() -> Self {
453        Self::new(ExcelErrorKind::Num)
454    }
455
456    pub fn new_na() -> Self {
457        Self::new(ExcelErrorKind::Na)
458    }
459}
460
461/* ───────────────────────── Display / Error ────────────────────────── */
462
463impl fmt::Display for ExcelError {
464    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
465        // Start with the canonical code:
466        write!(f, "{}", self.kind)?;
467
468        // Optional human message.
469        if let Some(msg) = self.message() {
470            write!(f, ": {msg}")?;
471        }
472
473        // Optional row/col context.
474        if let Some(ctx) = self.context() {
475            if let (Some(r), Some(c)) = (ctx.row, ctx.col) {
476                write!(f, " (row {r}, col {c})")?;
477            }
478
479            // Show origin if different from the evaluation location
480            if let (Some(or), Some(oc)) = (ctx.origin_row, ctx.origin_col) {
481                if ctx.row != Some(or) || ctx.col != Some(oc) {
482                    if let Some(ref sheet) = ctx.origin_sheet {
483                        write!(f, " [origin: {sheet}!R{or}C{oc}]")?;
484                    } else {
485                        write!(f, " [origin: R{or}C{oc}]")?;
486                    }
487                }
488            }
489        }
490
491        // Optional kind-specific payload - keep it terse for logs.
492        match self.extra() {
493            ExcelErrorExtra::None => {}
494            ExcelErrorExtra::Spill {
495                expected_rows,
496                expected_cols,
497            } => {
498                write!(f, " [spill {expected_rows}×{expected_cols}]")?;
499            }
500            ExcelErrorExtra::Resource { detail } => {
501                write!(
502                    f,
503                    " [resource {} {}/{}]",
504                    detail.reason.as_str(),
505                    detail.observed,
506                    detail.limit
507                )?;
508            }
509            ExcelErrorExtra::PreparationStale { reason } => {
510                write!(f, " [preparation stale {}]", reason.as_str())?;
511            }
512            ExcelErrorExtra::PlanStale { reason } => {
513                write!(f, " [plan stale {}]", reason.as_str())?;
514            }
515        }
516
517        Ok(())
518    }
519}
520
521impl Error for ExcelError {}
522impl From<ExcelError> for String {
523    fn from(error: ExcelError) -> Self {
524        format!("{error}")
525    }
526}
527impl From<ExcelError> for LiteralValue {
528    fn from(error: ExcelError) -> Self {
529        LiteralValue::Error(error)
530    }
531}
532
533impl PartialEq<str> for ExcelErrorKind {
534    fn eq(&self, other: &str) -> bool {
535        format!("{self}") == other
536    }
537}
538
539impl PartialEq<&str> for ExcelError {
540    fn eq(&self, other: &&str) -> bool {
541        self.kind.to_string() == *other
542    }
543}
544
545impl PartialEq<str> for ExcelError {
546    fn eq(&self, other: &str) -> bool {
547        self.kind.to_string() == other
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn parse_known_error_kind() {
557        assert_eq!(ExcelErrorKind::parse("#DIV/0!"), ExcelErrorKind::Div);
558        assert_eq!(ExcelErrorKind::parse("#n/a"), ExcelErrorKind::Na);
559    }
560
561    #[test]
562    fn parse_unknown_error_kind_falls_back() {
563        assert_eq!(ExcelErrorKind::parse("#BOGUS!"), ExcelErrorKind::Error);
564        let err = ExcelError::from_error_string("#BOGUS!");
565        assert_eq!(err.kind, ExcelErrorKind::Error);
566        assert!(err.message().unwrap_or_default().contains("#BOGUS!"));
567    }
568
569    #[test]
570    fn preparation_stale_reason_has_stable_snake_case_names() {
571        assert_eq!(PreparationStaleReason::Graph.as_str(), "graph");
572        assert_eq!(PreparationStaleReason::Authority.as_str(), "authority");
573        assert_eq!(PreparationStaleReason::Staged.as_str(), "staged");
574        assert_eq!(PreparationStaleReason::Symbols.as_str(), "symbols");
575        assert_eq!(PreparationStaleReason::Semantic.as_str(), "semantic");
576        assert_eq!(PreparationStaleReason::Provider.as_str(), "provider");
577    }
578
579    #[test]
580    fn plan_stale_reason_has_stable_snake_case_names() {
581        assert_eq!(PlanStaleReason::Engine.as_str(), "engine");
582        assert_eq!(PlanStaleReason::Provider.as_str(), "provider");
583        assert_eq!(PlanStaleReason::Semantic.as_str(), "semantic");
584        assert_eq!(PlanStaleReason::Budget.as_str(), "budget");
585        assert_eq!(PlanStaleReason::Staged.as_str(), "staged");
586        assert_eq!(PlanStaleReason::Symbols.as_str(), "symbols");
587        assert_eq!(PlanStaleReason::Authority.as_str(), "authority");
588        assert_eq!(PlanStaleReason::SpanGeneration.as_str(), "span_generation");
589        assert_eq!(PlanStaleReason::Graph.as_str(), "graph");
590    }
591}
592
593#[cfg(test)]
594mod compact_tests {
595    use super::*;
596    use std::hash::{Hash, Hasher};
597
598    #[test]
599    fn compact_layouts() {
600        assert!(size_of::<ExcelError>() <= 16);
601        assert!(size_of::<LiteralValue>() <= 32);
602        assert!(size_of::<Result<LiteralValue, ExcelError>>() <= 40);
603    }
604
605    #[test]
606    fn empty_errors_do_not_have_a_payload() {
607        let mut error = ExcelError::new_value().with_extra(ExcelErrorExtra::None);
608        error.set_message(None);
609        error.set_context(None);
610        assert!(error.payload.is_none());
611        error.set_message(Some(String::new()));
612        assert!(error.payload.is_some());
613        error.set_context(Some(ErrorContext::default()));
614        error.set_extra(ExcelErrorExtra::Spill {
615            expected_rows: 2,
616            expected_cols: 3,
617        });
618        error.set_message(None);
619        error.set_context(None);
620        assert!(error.payload.is_some());
621        error.set_extra(ExcelErrorExtra::None);
622        assert!(error.payload.is_none());
623        assert_eq!(error, ExcelError::new_value());
624    }
625
626    #[test]
627    fn logical_debug_hash_equality_and_display_are_unchanged() {
628        #[derive(Debug, Hash)]
629        struct ExcelError {
630            kind: ExcelErrorKind,
631            message: Option<String>,
632            context: Option<ErrorContext>,
633            extra: ExcelErrorExtra,
634        }
635        fn hash(value: &impl Hash) -> u64 {
636            let mut state = std::collections::hash_map::DefaultHasher::new();
637            value.hash(&mut state);
638            state.finish()
639        }
640        for error in [
641            super::ExcelError::new_div(),
642            super::ExcelError::new_value().with_message("explanation"),
643            super::ExcelError::new(ExcelErrorKind::Spill)
644                .with_message("blocked")
645                .with_location(2, 3)
646                .with_origin(Some("Sheet1".into()), 4, 5)
647                .with_extra(ExcelErrorExtra::Spill {
648                    expected_rows: 6,
649                    expected_cols: 7,
650                }),
651        ] {
652            let old = ExcelError {
653                kind: error.kind,
654                message: error.message().map(str::to_owned),
655                context: error.context().cloned(),
656                extra: error.extra().clone(),
657            };
658            assert_eq!(format!("{error:?}"), format!("{old:?}"));
659            assert_eq!(hash(&error), hash(&old));
660            assert_eq!(error, error.clone());
661        }
662        let error = super::ExcelError::new(ExcelErrorKind::Spill)
663            .with_message("blocked")
664            .with_location(2, 3)
665            .with_origin(Some("Sheet1".into()), 4, 5)
666            .with_extra(ExcelErrorExtra::Spill {
667                expected_rows: 6,
668                expected_cols: 7,
669            });
670        assert_eq!(
671            error.to_string(),
672            "#SPILL!: blocked (row 2, col 3) [origin: Sheet1!R4C5] [spill 6×7]"
673        );
674        assert_ne!(error, error.clone().with_message("different"));
675        assert_ne!(error, error.clone().with_location(1, 1));
676        assert_ne!(error, error.clone().with_extra(ExcelErrorExtra::None));
677    }
678}