elevator_core/host_error.rs
1//! Shared error classification for host bindings.
2//!
3//! Each host crate (FFI, wasm, gdext, Bevy) translates simulation
4//! failures into its own idiomatic error shape — `EvStatus` integers
5//! for FFI, `JsValue`-shaped exceptions for wasm, Godot exceptions
6//! for gdext. Without a shared classification the bindings used to
7//! enumerate the *kinds* of failures separately, drifting whenever a
8//! new failure mode landed.
9//!
10//! [`ErrorKind`](crate::host_error::ErrorKind) is the shared
11//! vocabulary. Hosts map it to their native error type (FFI provides
12//! `From<ErrorKind> for EvStatus`).
13//!
14//! See [Host Binding Parity](https://andymai.github.io/elevator-core/host-binding-parity.html)
15//! for the wider cross-host contract this enum is part of.
16
17use serde::{Deserialize, Serialize};
18
19/// Classification of failures every host binding can surface.
20///
21/// Variants mirror FFI's historical `EvStatus` enum (minus `Ok`,
22/// which represents *success* rather than an error kind). Hosts
23/// that can't yet emit a given variant should still match against
24/// the full set with a `_` fallback so future variants don't break
25/// existing call sites.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[non_exhaustive]
28pub enum ErrorKind {
29 /// A required pointer / handle argument was null.
30 NullArg,
31 /// A C string argument was not valid UTF-8.
32 InvalidUtf8,
33 /// A config file could not be read from disk.
34 ConfigLoad,
35 /// A config file failed to parse.
36 ConfigParse,
37 /// `SimulationBuilder::build` rejected the resolved config.
38 BuildFailed,
39 /// The referenced entity, group, or resource was not found.
40 NotFound,
41 /// The argument was structurally valid but semantically rejected.
42 InvalidArg,
43 /// A Rust panic was caught at the host boundary; the underlying
44 /// state may be partially mutated and the host handle should be
45 /// considered unsafe to reuse.
46 Panic,
47}
48
49impl ErrorKind {
50 /// Stable string label for a variant.
51 ///
52 /// Hosts that need to surface the kind to a non-Rust consumer
53 /// (e.g. wasm's JS side, gdext's `GDScript` side) can use this to
54 /// produce a kebab-case label without paying for full
55 /// `Debug` rendering. The set of returned strings is part of
56 /// the cross-host contract — adding a new variant requires
57 /// adding a label here.
58 #[must_use]
59 pub const fn label(self) -> &'static str {
60 match self {
61 Self::NullArg => "null-arg",
62 Self::InvalidUtf8 => "invalid-utf8",
63 Self::ConfigLoad => "config-load",
64 Self::ConfigParse => "config-parse",
65 Self::BuildFailed => "build-failed",
66 Self::NotFound => "not-found",
67 Self::InvalidArg => "invalid-arg",
68 Self::Panic => "panic",
69 }
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::ErrorKind;
76
77 #[test]
78 fn label_is_kebab_case_and_stable() {
79 // Locked: changing any of these breaks consumer parsers.
80 assert_eq!(ErrorKind::NullArg.label(), "null-arg");
81 assert_eq!(ErrorKind::InvalidUtf8.label(), "invalid-utf8");
82 assert_eq!(ErrorKind::ConfigLoad.label(), "config-load");
83 assert_eq!(ErrorKind::ConfigParse.label(), "config-parse");
84 assert_eq!(ErrorKind::BuildFailed.label(), "build-failed");
85 assert_eq!(ErrorKind::NotFound.label(), "not-found");
86 assert_eq!(ErrorKind::InvalidArg.label(), "invalid-arg");
87 assert_eq!(ErrorKind::Panic.label(), "panic");
88 }
89
90 #[test]
91 fn labels_are_unique() {
92 let labels = [
93 ErrorKind::NullArg.label(),
94 ErrorKind::InvalidUtf8.label(),
95 ErrorKind::ConfigLoad.label(),
96 ErrorKind::ConfigParse.label(),
97 ErrorKind::BuildFailed.label(),
98 ErrorKind::NotFound.label(),
99 ErrorKind::InvalidArg.label(),
100 ErrorKind::Panic.label(),
101 ];
102 let unique: std::collections::HashSet<_> = labels.iter().collect();
103 assert_eq!(unique.len(), labels.len());
104 }
105}