Skip to main content

oxirs_wasm/
error_handler.rs

1//! # WASM Error Handling Utilities
2//!
3//! Structured error types and an error-history handler for WebAssembly
4//! applications built on OxiRS.
5
6// ---------------------------------------------------------------------------
7// ErrorCode
8// ---------------------------------------------------------------------------
9
10/// Numeric error classification for WASM clients.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12#[repr(u32)]
13pub enum ErrorCode {
14    /// Malformed input (e.g. bad Turtle syntax).
15    ParseError = 1,
16    /// Network / HTTP failure.
17    NetworkError = 2,
18    /// Authentication or authorisation failure.
19    AuthError = 3,
20    /// Requested resource was not found.
21    NotFound = 4,
22    /// Internal server / engine error.
23    ServerError = 5,
24    /// Operation timed out.
25    Timeout = 6,
26    /// Caller supplied an invalid argument.
27    InvalidInput = 7,
28    /// Feature / operation not supported.
29    Unsupported = 8,
30}
31
32impl ErrorCode {
33    /// Convert from a raw `u32` discriminant.  Returns `None` for unknown codes.
34    pub fn from_u32(n: u32) -> Option<Self> {
35        match n {
36            1 => Some(Self::ParseError),
37            2 => Some(Self::NetworkError),
38            3 => Some(Self::AuthError),
39            4 => Some(Self::NotFound),
40            5 => Some(Self::ServerError),
41            6 => Some(Self::Timeout),
42            7 => Some(Self::InvalidInput),
43            8 => Some(Self::Unsupported),
44            _ => None,
45        }
46    }
47
48    /// Return the raw discriminant.
49    pub fn as_u32(self) -> u32 {
50        self as u32
51    }
52}
53
54impl std::fmt::Display for ErrorCode {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        let name = match self {
57            Self::ParseError => "ParseError",
58            Self::NetworkError => "NetworkError",
59            Self::AuthError => "AuthError",
60            Self::NotFound => "NotFound",
61            Self::ServerError => "ServerError",
62            Self::Timeout => "Timeout",
63            Self::InvalidInput => "InvalidInput",
64            Self::Unsupported => "Unsupported",
65        };
66        write!(f, "{name}")
67    }
68}
69
70// ---------------------------------------------------------------------------
71// WasmError
72// ---------------------------------------------------------------------------
73
74/// A structured WASM error.
75#[derive(Debug, Clone)]
76pub struct WasmError {
77    /// Error category.
78    pub code: ErrorCode,
79    /// Human-readable message.
80    pub message: String,
81    /// Optional additional detail (stack trace, inner error, etc.).
82    pub details: Option<String>,
83    /// `true` if the caller may retry or recover; `false` for fatal errors.
84    pub recoverable: bool,
85}
86
87impl WasmError {
88    /// Create a new, recoverable error.
89    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
90        Self {
91            code,
92            message: message.into(),
93            details: None,
94            recoverable: true,
95        }
96    }
97
98    /// Attach additional detail text (builder pattern).
99    pub fn with_details(mut self, details: impl Into<String>) -> Self {
100        self.details = Some(details.into());
101        self
102    }
103
104    /// Mark the error as unrecoverable (builder pattern).
105    pub fn unrecoverable(mut self) -> Self {
106        self.recoverable = false;
107        self
108    }
109
110    /// Serialise to a compact JSON string.
111    pub fn to_json(&self) -> String {
112        let details_part = match &self.details {
113            Some(d) => format!(r#","details":"{}""#, escape_json(d)),
114            None => String::new(),
115        };
116        format!(
117            r#"{{"code":{},"name":"{}","message":"{}","recoverable":{}{}}}"#,
118            self.code.as_u32(),
119            self.code,
120            escape_json(&self.message),
121            self.recoverable,
122            details_part,
123        )
124    }
125
126    /// Convenience constructor: attempt to build from a numeric code.
127    ///
128    /// Returns `None` when `code` does not map to a known `ErrorCode`.
129    pub fn from_code(code: u32, message: &str) -> Option<Self> {
130        ErrorCode::from_u32(code).map(|ec| Self::new(ec, message))
131    }
132
133    /// Returns `true` for "client-side" errors: codes 1, 4, 7, 8.
134    pub fn is_client_error(&self) -> bool {
135        matches!(
136            self.code,
137            ErrorCode::ParseError
138                | ErrorCode::NotFound
139                | ErrorCode::InvalidInput
140                | ErrorCode::Unsupported
141        )
142    }
143
144    /// Returns `true` for "server/transport" errors: codes 2, 5, 6.
145    pub fn is_server_error(&self) -> bool {
146        matches!(
147            self.code,
148            ErrorCode::NetworkError | ErrorCode::ServerError | ErrorCode::Timeout
149        )
150    }
151}
152
153impl std::fmt::Display for WasmError {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        write!(f, "[{}] {}", self.code, self.message)
156    }
157}
158
159impl std::error::Error for WasmError {}
160
161// ---------------------------------------------------------------------------
162// ErrorHandler
163// ---------------------------------------------------------------------------
164
165/// Accumulates `WasmError` values and provides diagnostic helpers.
166pub struct ErrorHandler {
167    history: Vec<WasmError>,
168    max_history: usize,
169}
170
171impl ErrorHandler {
172    /// Create a new handler that keeps at most `max_history` errors.
173    pub fn new(max_history: usize) -> Self {
174        Self {
175            history: Vec::new(),
176            max_history: max_history.max(1),
177        }
178    }
179
180    /// Record `error`, store it in history (evicting oldest when full), and
181    /// return its JSON representation.
182    pub fn handle(&mut self, error: WasmError) -> String {
183        let json = error.to_json();
184        if self.history.len() >= self.max_history {
185            self.history.remove(0);
186        }
187        self.history.push(error);
188        json
189    }
190
191    /// The most recently recorded error.
192    pub fn last_error(&self) -> Option<&WasmError> {
193        self.history.last()
194    }
195
196    /// Total number of errors currently in history.
197    pub fn error_count(&self) -> usize {
198        self.history.len()
199    }
200
201    /// `true` if any error in history is unrecoverable.
202    pub fn has_unrecoverable(&self) -> bool {
203        self.history.iter().any(|e| !e.recoverable)
204    }
205
206    /// Remove all errors from history.
207    pub fn clear_history(&mut self) {
208        self.history.clear();
209    }
210
211    /// Errors matching the given numeric code.
212    pub fn errors_by_code(&self, code: u32) -> Vec<&WasmError> {
213        self.history
214            .iter()
215            .filter(|e| e.code.as_u32() == code)
216            .collect()
217    }
218}
219
220// ---------------------------------------------------------------------------
221// Internal helper
222// ---------------------------------------------------------------------------
223
224fn escape_json(s: &str) -> String {
225    s.chars()
226        .flat_map(|c| match c {
227            '"' => vec!['\\', '"'],
228            '\\' => vec!['\\', '\\'],
229            '\n' => vec!['\\', 'n'],
230            '\r' => vec!['\\', 'r'],
231            '\t' => vec!['\\', 't'],
232            other => vec![other],
233        })
234        .collect()
235}
236
237// =============================================================================
238// Tests
239// =============================================================================
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    // -------------------------------------------------------------------------
246    // ErrorCode
247    // -------------------------------------------------------------------------
248    #[test]
249    fn test_error_code_as_u32() {
250        assert_eq!(ErrorCode::ParseError.as_u32(), 1);
251        assert_eq!(ErrorCode::NetworkError.as_u32(), 2);
252        assert_eq!(ErrorCode::AuthError.as_u32(), 3);
253        assert_eq!(ErrorCode::NotFound.as_u32(), 4);
254        assert_eq!(ErrorCode::ServerError.as_u32(), 5);
255        assert_eq!(ErrorCode::Timeout.as_u32(), 6);
256        assert_eq!(ErrorCode::InvalidInput.as_u32(), 7);
257        assert_eq!(ErrorCode::Unsupported.as_u32(), 8);
258    }
259
260    #[test]
261    fn test_error_code_from_u32_valid() {
262        assert_eq!(ErrorCode::from_u32(1), Some(ErrorCode::ParseError));
263        assert_eq!(ErrorCode::from_u32(8), Some(ErrorCode::Unsupported));
264    }
265
266    #[test]
267    fn test_error_code_from_u32_invalid() {
268        assert_eq!(ErrorCode::from_u32(0), None);
269        assert_eq!(ErrorCode::from_u32(9), None);
270        assert_eq!(ErrorCode::from_u32(u32::MAX), None);
271    }
272
273    #[test]
274    fn test_error_code_display() {
275        assert_eq!(ErrorCode::ParseError.to_string(), "ParseError");
276        assert_eq!(ErrorCode::Unsupported.to_string(), "Unsupported");
277    }
278
279    #[test]
280    fn test_error_code_equality() {
281        assert_eq!(ErrorCode::AuthError, ErrorCode::AuthError);
282        assert_ne!(ErrorCode::AuthError, ErrorCode::NotFound);
283    }
284
285    #[test]
286    fn test_error_code_all_round_trip() {
287        for n in 1u32..=8 {
288            let code = ErrorCode::from_u32(n).expect("valid code");
289            assert_eq!(code.as_u32(), n);
290        }
291    }
292
293    // -------------------------------------------------------------------------
294    // WasmError::new
295    // -------------------------------------------------------------------------
296    #[test]
297    fn test_wasm_error_new_code() {
298        let e = WasmError::new(ErrorCode::ParseError, "bad syntax");
299        assert_eq!(e.code, ErrorCode::ParseError);
300    }
301
302    #[test]
303    fn test_wasm_error_new_message() {
304        let e = WasmError::new(ErrorCode::NotFound, "not found");
305        assert_eq!(e.message, "not found");
306    }
307
308    #[test]
309    fn test_wasm_error_new_recoverable_default() {
310        let e = WasmError::new(ErrorCode::ParseError, "x");
311        assert!(e.recoverable);
312    }
313
314    #[test]
315    fn test_wasm_error_new_no_details() {
316        let e = WasmError::new(ErrorCode::ParseError, "x");
317        assert!(e.details.is_none());
318    }
319
320    // -------------------------------------------------------------------------
321    // WasmError builders
322    // -------------------------------------------------------------------------
323    #[test]
324    fn test_with_details() {
325        let e = WasmError::new(ErrorCode::ServerError, "msg").with_details("inner error");
326        assert_eq!(e.details.as_deref(), Some("inner error"));
327    }
328
329    #[test]
330    fn test_unrecoverable() {
331        let e = WasmError::new(ErrorCode::ServerError, "fatal").unrecoverable();
332        assert!(!e.recoverable);
333    }
334
335    #[test]
336    fn test_builder_chaining() {
337        let e = WasmError::new(ErrorCode::Timeout, "timed out")
338            .with_details("after 30s")
339            .unrecoverable();
340        assert!(!e.recoverable);
341        assert_eq!(e.details.as_deref(), Some("after 30s"));
342    }
343
344    // -------------------------------------------------------------------------
345    // WasmError::from_code
346    // -------------------------------------------------------------------------
347    #[test]
348    fn test_from_code_valid() {
349        let e = WasmError::from_code(2, "network failure");
350        assert!(e.is_some());
351        let e = e.expect("should succeed");
352        assert_eq!(e.code, ErrorCode::NetworkError);
353    }
354
355    #[test]
356    fn test_from_code_invalid() {
357        assert!(WasmError::from_code(99, "bad").is_none());
358    }
359
360    #[test]
361    fn test_from_code_zero() {
362        assert!(WasmError::from_code(0, "zero").is_none());
363    }
364
365    // -------------------------------------------------------------------------
366    // is_client_error / is_server_error
367    // -------------------------------------------------------------------------
368    #[test]
369    fn test_is_client_error_parse() {
370        let e = WasmError::new(ErrorCode::ParseError, "x");
371        assert!(e.is_client_error());
372        assert!(!e.is_server_error());
373    }
374
375    #[test]
376    fn test_is_client_error_not_found() {
377        assert!(WasmError::new(ErrorCode::NotFound, "x").is_client_error());
378    }
379
380    #[test]
381    fn test_is_client_error_invalid_input() {
382        assert!(WasmError::new(ErrorCode::InvalidInput, "x").is_client_error());
383    }
384
385    #[test]
386    fn test_is_client_error_unsupported() {
387        assert!(WasmError::new(ErrorCode::Unsupported, "x").is_client_error());
388    }
389
390    #[test]
391    fn test_is_server_error_network() {
392        let e = WasmError::new(ErrorCode::NetworkError, "x");
393        assert!(e.is_server_error());
394        assert!(!e.is_client_error());
395    }
396
397    #[test]
398    fn test_is_server_error_internal() {
399        assert!(WasmError::new(ErrorCode::ServerError, "x").is_server_error());
400    }
401
402    #[test]
403    fn test_is_server_error_timeout() {
404        assert!(WasmError::new(ErrorCode::Timeout, "x").is_server_error());
405    }
406
407    #[test]
408    fn test_auth_error_neither_client_nor_server() {
409        let e = WasmError::new(ErrorCode::AuthError, "x");
410        // AuthError (3) is neither client nor server per the spec
411        assert!(!e.is_client_error());
412        assert!(!e.is_server_error());
413    }
414
415    // -------------------------------------------------------------------------
416    // WasmError::to_json
417    // -------------------------------------------------------------------------
418    #[test]
419    fn test_to_json_contains_code() {
420        let e = WasmError::new(ErrorCode::ParseError, "bad syntax");
421        let json = e.to_json();
422        assert!(json.contains("\"code\":1"), "json={json}");
423    }
424
425    #[test]
426    fn test_to_json_contains_message() {
427        let e = WasmError::new(ErrorCode::NotFound, "resource missing");
428        let json = e.to_json();
429        assert!(json.contains("resource missing"), "json={json}");
430    }
431
432    #[test]
433    fn test_to_json_no_details_field_absent() {
434        let e = WasmError::new(ErrorCode::ParseError, "x");
435        let json = e.to_json();
436        assert!(!json.contains("\"details\""), "json={json}");
437    }
438
439    #[test]
440    fn test_to_json_with_details() {
441        let e = WasmError::new(ErrorCode::ServerError, "crash").with_details("line 42");
442        let json = e.to_json();
443        assert!(json.contains("\"details\""), "json={json}");
444        assert!(json.contains("line 42"), "json={json}");
445    }
446
447    #[test]
448    fn test_to_json_escapes_quotes() {
449        let e = WasmError::new(ErrorCode::ParseError, r#"He said "hello""#);
450        let json = e.to_json();
451        assert!(json.contains(r#"\""#), "json={json}");
452    }
453
454    #[test]
455    fn test_to_json_recoverable_true() {
456        let json = WasmError::new(ErrorCode::InvalidInput, "x").to_json();
457        assert!(json.contains("\"recoverable\":true"), "json={json}");
458    }
459
460    #[test]
461    fn test_to_json_recoverable_false() {
462        let json = WasmError::new(ErrorCode::ServerError, "x")
463            .unrecoverable()
464            .to_json();
465        assert!(json.contains("\"recoverable\":false"), "json={json}");
466    }
467
468    // -------------------------------------------------------------------------
469    // ErrorHandler
470    // -------------------------------------------------------------------------
471    #[test]
472    fn test_handler_new_empty() {
473        let h = ErrorHandler::new(10);
474        assert_eq!(h.error_count(), 0);
475    }
476
477    #[test]
478    fn test_handler_handle_increments_count() {
479        let mut h = ErrorHandler::new(10);
480        h.handle(WasmError::new(ErrorCode::ParseError, "e1"));
481        assert_eq!(h.error_count(), 1);
482    }
483
484    #[test]
485    fn test_handler_handle_returns_json() {
486        let mut h = ErrorHandler::new(10);
487        let json = h.handle(WasmError::new(ErrorCode::NotFound, "missing"));
488        assert!(json.contains("\"code\":4"), "json={json}");
489    }
490
491    #[test]
492    fn test_handler_last_error() {
493        let mut h = ErrorHandler::new(10);
494        h.handle(WasmError::new(ErrorCode::ParseError, "first"));
495        h.handle(WasmError::new(ErrorCode::NotFound, "second"));
496        let last = h.last_error().expect("some last error");
497        assert_eq!(last.message, "second");
498    }
499
500    #[test]
501    fn test_handler_last_error_empty() {
502        let h = ErrorHandler::new(10);
503        assert!(h.last_error().is_none());
504    }
505
506    #[test]
507    fn test_handler_eviction_when_full() {
508        let mut h = ErrorHandler::new(3);
509        for i in 0..5u32 {
510            h.handle(WasmError::new(ErrorCode::ParseError, format!("e{i}")));
511        }
512        assert_eq!(h.error_count(), 3);
513    }
514
515    #[test]
516    fn test_handler_has_unrecoverable_false() {
517        let mut h = ErrorHandler::new(10);
518        h.handle(WasmError::new(ErrorCode::ParseError, "x"));
519        assert!(!h.has_unrecoverable());
520    }
521
522    #[test]
523    fn test_handler_has_unrecoverable_true() {
524        let mut h = ErrorHandler::new(10);
525        h.handle(WasmError::new(ErrorCode::ServerError, "fatal").unrecoverable());
526        assert!(h.has_unrecoverable());
527    }
528
529    #[test]
530    fn test_handler_clear_history() {
531        let mut h = ErrorHandler::new(10);
532        h.handle(WasmError::new(ErrorCode::ParseError, "x"));
533        h.clear_history();
534        assert_eq!(h.error_count(), 0);
535    }
536
537    #[test]
538    fn test_handler_errors_by_code() {
539        let mut h = ErrorHandler::new(20);
540        h.handle(WasmError::new(ErrorCode::ParseError, "p1"));
541        h.handle(WasmError::new(ErrorCode::NotFound, "n1"));
542        h.handle(WasmError::new(ErrorCode::ParseError, "p2"));
543        let parse_errs = h.errors_by_code(1);
544        assert_eq!(parse_errs.len(), 2);
545    }
546
547    #[test]
548    fn test_handler_errors_by_code_none() {
549        let h = ErrorHandler::new(10);
550        assert!(h.errors_by_code(1).is_empty());
551    }
552
553    #[test]
554    fn test_display_impl() {
555        let e = WasmError::new(ErrorCode::Timeout, "took too long");
556        let s = e.to_string();
557        assert!(s.contains("Timeout"), "display={s}");
558        assert!(s.contains("took too long"), "display={s}");
559    }
560
561    #[test]
562    fn test_wasm_error_is_std_error() {
563        let e: Box<dyn std::error::Error> = Box::new(WasmError::new(ErrorCode::ParseError, "x"));
564        assert!(e.to_string().contains("ParseError"));
565    }
566}