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}