Skip to main content

paginate_core/
error.rs

1//! Error type for the core engine.
2//!
3//! Mirrors the relevant arms of pypaginate's Python exception hierarchy
4//! (`ValidationError`, `FilterError`, `SortError`, `SearchError`). Each variant
5//! carries a stable [`ErrorKind`] (see [`CoreError::kind`]) so **both** binding
6//! layers map onto their host exception hierarchy from one classification and
7//! cannot drift apart — Python raises typed subclasses, Node sets matching error
8//! codes, all driven by the same `kind`.
9
10use thiserror::Error;
11
12/// Stable, message-independent classification of a [`CoreError`].
13///
14/// The binding layer dispatches on this — never on the human-readable message —
15/// so Python and Node stay symmetric. `#[non_exhaustive]`: the crate is
16/// published independently (`core-v*`), so a new kind must not be a breaking
17/// change; downstream `match`es need a `_` arm.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19#[non_exhaustive]
20pub enum ErrorKind {
21    /// A cursor string was malformed, truncated, or tampered with.
22    InvalidCursor,
23    /// A field path could not be resolved on an item.
24    FieldNotFound,
25    /// A filter operator could not be applied.
26    Filter,
27    /// A sort operation failed.
28    Sort,
29    /// A search query was invalid.
30    Search,
31    /// An input value failed validation.
32    Validation,
33}
34
35/// A recoverable error raised by the core engine.
36///
37/// It is a flat, `Clone`/`Eq` value type by design: it crosses the FFI boundary
38/// into Python / JS exceptions, so it carries owned messages rather than a
39/// borrowed `source()` chain. Match on [`CoreError::kind`] for behaviour.
40///
41/// `#[non_exhaustive]`: the crate is published independently (`core-v*`), so new
42/// variants must not be a breaking change — downstream `match`es need a `_` arm.
43#[derive(Debug, Clone, PartialEq, Eq, Error)]
44#[non_exhaustive]
45pub enum CoreError {
46    /// A cursor string was malformed, truncated, or tampered with.
47    #[error("invalid cursor: {reason}")]
48    InvalidCursor {
49        /// Machine-readable reason (e.g. `"base64"`, `"unknown type tag: x"`).
50        reason: String,
51    },
52    /// A field path could not be resolved on an item.
53    #[error("field not found: {field}")]
54    FieldNotFound {
55        /// The dotted field path that failed to resolve.
56        field: String,
57    },
58    /// A filter operator could not be applied (bad regex, bad operand, ...).
59    #[error("filter error: {message}")]
60    Filter {
61        /// Human-readable description.
62        message: String,
63    },
64    /// A sort operation failed (e.g. values were not comparable).
65    #[error("sort error: {message}")]
66    Sort {
67        /// Human-readable description.
68        message: String,
69    },
70    /// A search query was invalid.
71    #[error("search error: {message}")]
72    Search {
73        /// Human-readable description.
74        message: String,
75    },
76    /// An input value failed validation (out of range, mutually exclusive, ...).
77    // Validation messages are user-facing input feedback — surfaced verbatim.
78    #[error("{message}")]
79    Validation {
80        /// Human-readable description (surfaced verbatim to the host).
81        message: String,
82    },
83}
84
85impl CoreError {
86    /// The stable [`ErrorKind`] of this error.
87    ///
88    /// Binding layers map this onto their host exception hierarchy, keeping the
89    /// Python and Node error surfaces symmetric from a single source of truth.
90    #[must_use]
91    pub fn kind(&self) -> ErrorKind {
92        match self {
93            Self::InvalidCursor { .. } => ErrorKind::InvalidCursor,
94            Self::FieldNotFound { .. } => ErrorKind::FieldNotFound,
95            Self::Filter { .. } => ErrorKind::Filter,
96            Self::Sort { .. } => ErrorKind::Sort,
97            Self::Search { .. } => ErrorKind::Search,
98            Self::Validation { .. } => ErrorKind::Validation,
99        }
100    }
101}
102
103/// Convenience alias used throughout the crate.
104pub type Result<T> = std::result::Result<T, CoreError>;
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn display_strings_are_stable() {
112        let e = CoreError::InvalidCursor {
113            reason: "base64".into(),
114        };
115        assert_eq!(e.to_string(), "invalid cursor: base64");
116        let v = CoreError::Validation {
117            message: "limit must be >= 1".into(),
118        };
119        assert_eq!(v.to_string(), "limit must be >= 1");
120    }
121
122    #[test]
123    fn kind_is_stable_per_variant() {
124        assert_eq!(
125            CoreError::Filter {
126                message: String::new()
127            }
128            .kind(),
129            ErrorKind::Filter
130        );
131        assert_eq!(
132            CoreError::FieldNotFound {
133                field: String::new()
134            }
135            .kind(),
136            ErrorKind::FieldNotFound
137        );
138    }
139}