Skip to main content

emotiv_cortex_v2/
error.rs

1//! # Error Types
2//!
3//! Semantic error types for the Emotiv Cortex v2 API. Every variant carries
4//! enough context to diagnose the problem without digging through logs.
5//!
6//! ## Error Code Mapping
7//!
8//! The Cortex API returns numeric error codes in JSON-RPC error responses.
9//! [`CortexError::from_api_error`] maps known codes to semantic variants
10//! with actionable error messages.
11
12use thiserror::Error;
13
14/// Convenient Result alias for Cortex operations.
15pub type CortexResult<T> = std::result::Result<T, CortexError>;
16
17/// All errors that can occur when interacting with the Emotiv Cortex API.
18#[derive(Error, Debug)]
19pub enum CortexError {
20    // ─── Connection ─────────────────────────────────────────────────
21    /// Failed to establish a WebSocket connection to the Cortex service.
22    #[error("Failed to connect to Cortex at {url}: {reason}. Is the EMOTIV Launcher running?")]
23    ConnectionFailed { url: String, reason: String },
24
25    /// WebSocket connection was lost after being established.
26    #[error("Connection to Cortex lost: {reason}")]
27    ConnectionLost { reason: String },
28
29    /// The client is not connected to the Cortex service.
30    #[error("Not connected to Cortex")]
31    NotConnected,
32
33    // ─── Authentication ─────────────────────────────────────────────
34    /// Authentication failed (invalid `client_id/client_secret` or expired token).
35    #[error(
36        "Authentication failed: {reason}. Check your client_id and client_secret from the Emotiv Developer Portal."
37    )]
38    AuthenticationFailed { reason: String },
39
40    /// The Cortex token has expired and needs to be refreshed.
41    #[error("Cortex token expired — re-authentication required")]
42    TokenExpired,
43
44    /// Access denied — the user hasn't approved the app in the Emotiv Launcher.
45    #[error("Access denied: {reason}. Approve the application in the EMOTIV Launcher.")]
46    AccessDenied { reason: String },
47
48    /// User is not logged in to `EmotivID` in the Launcher.
49    #[error("User not logged in to EmotivID. Open the EMOTIV Launcher and sign in.")]
50    UserNotLoggedIn,
51
52    /// The application has not been approved in the EMOTIV Launcher.
53    #[error("Application not approved. Open the EMOTIV Launcher and approve access for your app.")]
54    NotApproved,
55
56    // ─── License ────────────────────────────────────────────────────
57    /// License expired, invalid, or missing for the requested operation.
58    #[error("Emotiv license error: {reason}")]
59    LicenseError { reason: String },
60
61    // ─── Headset ────────────────────────────────────────────────────
62    /// No headset found (either not paired or not powered on).
63    #[error("No headset found. Ensure the headset is powered on and within range.")]
64    NoHeadsetFound,
65
66    /// The headset is being used by another session or application.
67    #[error("Headset is in use by another session")]
68    HeadsetInUse,
69
70    /// Headset connection failed or the headset disconnected unexpectedly.
71    #[error("Headset connection error: {reason}")]
72    HeadsetError { reason: String },
73
74    // ─── Session ────────────────────────────────────────────────────
75    /// Session-related error (create, update, close failed).
76    #[error("Session error: {reason}")]
77    SessionError { reason: String },
78
79    // ─── Streams ────────────────────────────────────────────────────
80    /// Subscribe/unsubscribe failed for the requested streams.
81    #[error("Stream error: {reason}")]
82    StreamError { reason: String },
83
84    // ─── API ────────────────────────────────────────────────────────
85    /// Raw Cortex API error that doesn't map to a more specific variant.
86    #[error("Cortex API error {code}: {message}")]
87    ApiError { code: i32, message: String },
88
89    /// The Cortex service is still starting up — try again shortly.
90    #[error("Cortex service is starting up — retry in a few seconds")]
91    CortexStarting,
92
93    /// The requested API method was not found (likely a version mismatch).
94    #[error("API method not found: {method}")]
95    MethodNotFound { method: String },
96
97    // ─── Timeout ────────────────────────────────────────────────────
98    /// An operation timed out waiting for a response.
99    #[error("Operation timed out after {seconds}s")]
100    Timeout { seconds: u64 },
101
102    // ─── Retry ──────────────────────────────────────────────────────
103    /// All retry attempts have been exhausted.
104    #[error("Operation failed after {attempts} attempts: {last_error}")]
105    RetriesExhausted {
106        attempts: u32,
107        last_error: Box<CortexError>,
108    },
109
110    // ─── Protocol ───────────────────────────────────────────────────
111    /// Received an unexpected or malformed message from the Cortex service.
112    #[error("Protocol error: {reason}")]
113    ProtocolError { reason: String },
114
115    // ─── Config ─────────────────────────────────────────────────────
116    /// Configuration file error (missing, malformed, or invalid values).
117    #[error("Configuration error: {reason}")]
118    ConfigError { reason: String },
119
120    // ─── WebSocket ──────────────────────────────────────────────────
121    /// Low-level WebSocket transport error.
122    #[error("WebSocket error: {0}")]
123    WebSocket(String),
124
125    /// TLS/SSL error during connection.
126    #[error("TLS error: {0}")]
127    Tls(String),
128
129    // ─── I/O ────────────────────────────────────────────────────────
130    /// Filesystem or I/O error (config file reading, etc.).
131    #[error("I/O error: {0}")]
132    Io(#[from] std::io::Error),
133
134    /// JSON serialization/deserialization error.
135    #[error("JSON error: {0}")]
136    Json(#[from] serde_json::Error),
137}
138
139impl CortexError {
140    /// Map a Cortex API error code + message to the most specific error variant.
141    ///
142    /// Known error codes from the Cortex v2 API docs (2026-02-12):
143    /// - `-32601`: Method not found
144    /// - `-32001`: No headset connected
145    /// - `-32002`: Invalid license ID
146    /// - `-32004`: Headset unavailable
147    /// - `-32005`: Session already exists
148    /// - `-32012`: Session must be activated
149    /// - `-32014`: Invalid cortex token
150    /// - `-32015`: Cortex token expired
151    /// - `-32016`: Invalid stream
152    /// - `-32021`: Invalid client credentials
153    /// - `-32024`: License expired
154    /// - `-32033`: User not logged in
155    /// - `-32142`: Unpublished/unapproved application
156    /// - `-32152`: Headset not ready
157    ///
158    /// Legacy Cortex deployments may also return older codes such as
159    /// `-32102` and `-32122`.
160    ///
161    /// # Examples
162    ///
163    /// ```
164    /// use emotiv_cortex_v2::CortexError;
165    ///
166    /// let err = CortexError::from_api_error(-32015, "token expired");
167    /// assert!(matches!(err, CortexError::TokenExpired));
168    ///
169    /// let err = CortexError::from_api_error(-32001, "no headset");
170    /// assert!(matches!(err, CortexError::NoHeadsetFound));
171    /// ```
172    pub fn from_api_error(code: i32, message: impl Into<String>) -> Self {
173        let message = message.into();
174        match code {
175            -32601 => CortexError::MethodNotFound {
176                method: message.clone(),
177            },
178            -32001 | -32004 => CortexError::NoHeadsetFound,
179            -32002 | -32024 => CortexError::LicenseError { reason: message },
180            -32005 | -32012 => CortexError::SessionError { reason: message },
181            -32014 | -32021 => CortexError::AuthenticationFailed { reason: message },
182            -32015 => CortexError::TokenExpired,
183            -32016 => CortexError::StreamError { reason: message },
184            -32033 => CortexError::UserNotLoggedIn,
185            -32142 => CortexError::NotApproved,
186            -32152 => CortexError::HeadsetError { reason: message },
187            // Legacy/older documented mappings.
188            -32102 => CortexError::NotApproved,
189            -32122 => CortexError::CortexStarting,
190            _ => CortexError::ApiError { code, message },
191        }
192    }
193
194    /// Returns `true` if this error is transient and the operation can be retried.
195    ///
196    /// # Examples
197    ///
198    /// ```
199    /// use emotiv_cortex_v2::CortexError;
200    ///
201    /// assert!(CortexError::Timeout { seconds: 10 }.is_retryable());
202    /// assert!(CortexError::CortexStarting.is_retryable());
203    /// assert!(!CortexError::NoHeadsetFound.is_retryable());
204    /// ```
205    #[must_use]
206    pub fn is_retryable(&self) -> bool {
207        matches!(
208            self,
209            CortexError::ConnectionLost { .. }
210                | CortexError::Timeout { .. }
211                | CortexError::CortexStarting
212                | CortexError::WebSocket(_)
213        )
214    }
215
216    /// Returns `true` if this error indicates the connection is dead
217    /// and a reconnect is needed.
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use emotiv_cortex_v2::CortexError;
223    ///
224    /// assert!(CortexError::NotConnected.is_connection_error());
225    /// assert!(!CortexError::TokenExpired.is_connection_error());
226    /// ```
227    #[must_use]
228    pub fn is_connection_error(&self) -> bool {
229        matches!(
230            self,
231            CortexError::ConnectionFailed { .. }
232                | CortexError::ConnectionLost { .. }
233                | CortexError::NotConnected
234                | CortexError::WebSocket(_)
235        )
236    }
237}
238
239// ─── From impls for external error types ────────────────────────────────
240
241impl From<tokio_tungstenite::tungstenite::Error> for CortexError {
242    fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
243        CortexError::WebSocket(err.to_string())
244    }
245}
246
247#[cfg(feature = "native-tls")]
248impl From<native_tls::Error> for CortexError {
249    fn from(err: native_tls::Error) -> Self {
250        CortexError::Tls(err.to_string())
251    }
252}
253
254#[cfg(feature = "config-toml")]
255impl From<toml::de::Error> for CortexError {
256    fn from(err: toml::de::Error) -> Self {
257        CortexError::ConfigError {
258            reason: err.to_string(),
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_from_api_error_known_codes() {
269        assert!(matches!(
270            CortexError::from_api_error(-32001, "no headset"),
271            CortexError::NoHeadsetFound
272        ));
273        assert!(matches!(
274            CortexError::from_api_error(-32002, "invalid license"),
275            CortexError::LicenseError { .. }
276        ));
277        assert!(matches!(
278            CortexError::from_api_error(-32004, "headset unavailable"),
279            CortexError::NoHeadsetFound
280        ));
281        assert!(matches!(
282            CortexError::from_api_error(-32005, "session already exists"),
283            CortexError::SessionError { .. }
284        ));
285        assert!(matches!(
286            CortexError::from_api_error(-32012, "session must be activated"),
287            CortexError::SessionError { .. }
288        ));
289        assert!(matches!(
290            CortexError::from_api_error(-32014, "invalid token"),
291            CortexError::AuthenticationFailed { .. }
292        ));
293        assert!(matches!(
294            CortexError::from_api_error(-32015, "expired token"),
295            CortexError::TokenExpired
296        ));
297        assert!(matches!(
298            CortexError::from_api_error(-32016, "invalid stream"),
299            CortexError::StreamError { .. }
300        ));
301        assert!(matches!(
302            CortexError::from_api_error(-32021, "invalid credentials"),
303            CortexError::AuthenticationFailed { .. }
304        ));
305        assert!(matches!(
306            CortexError::from_api_error(-32024, "license expired"),
307            CortexError::LicenseError { .. }
308        ));
309        assert!(matches!(
310            CortexError::from_api_error(-32033, "not logged in"),
311            CortexError::UserNotLoggedIn
312        ));
313        assert!(matches!(
314            CortexError::from_api_error(-32142, "not approved"),
315            CortexError::NotApproved
316        ));
317        assert!(matches!(
318            CortexError::from_api_error(-32152, "headset not ready"),
319            CortexError::HeadsetError { .. }
320        ));
321        assert!(matches!(
322            CortexError::from_api_error(-32601, "unknown"),
323            CortexError::MethodNotFound { .. }
324        ));
325    }
326
327    #[test]
328    fn test_from_api_error_legacy_codes() {
329        assert!(matches!(
330            CortexError::from_api_error(-32102, "legacy not approved"),
331            CortexError::NotApproved
332        ));
333        assert!(matches!(
334            CortexError::from_api_error(-32122, "legacy starting"),
335            CortexError::CortexStarting
336        ));
337    }
338
339    #[test]
340    fn test_from_api_error_unknown_code() {
341        let err = CortexError::from_api_error(-99999, "something weird");
342        assert!(matches!(err, CortexError::ApiError { code: -99999, .. }));
343        assert_eq!(err.to_string(), "Cortex API error -99999: something weird");
344    }
345
346    #[test]
347    fn test_from_api_error_unknown_code_preserves_message() {
348        let err = CortexError::from_api_error(12345, "custom server message");
349        match &err {
350            CortexError::ApiError { code, message } => {
351                assert_eq!(*code, 12345);
352                assert_eq!(message.as_str(), "custom server message");
353            }
354            _ => panic!("expected ApiError, got {err:?}"),
355        }
356    }
357
358    #[test]
359    fn test_from_api_error_method_not_found_preserves_method() {
360        let err = CortexError::from_api_error(-32601, "authorize");
361        match &err {
362            CortexError::MethodNotFound { method } => assert_eq!(method, "authorize"),
363            _ => panic!("expected MethodNotFound, got {err:?}"),
364        }
365    }
366
367    #[test]
368    fn test_is_retryable() {
369        assert!(CortexError::CortexStarting.is_retryable());
370        assert!(CortexError::Timeout { seconds: 10 }.is_retryable());
371        assert!(CortexError::ConnectionLost { reason: "x".into() }.is_retryable());
372        assert!(!CortexError::NoHeadsetFound.is_retryable());
373        assert!(!CortexError::SessionError { reason: "x".into() }.is_retryable());
374    }
375
376    #[test]
377    fn test_is_connection_error() {
378        assert!(CortexError::NotConnected.is_connection_error());
379        assert!(CortexError::ConnectionLost { reason: "x".into() }.is_connection_error());
380        assert!(!CortexError::TokenExpired.is_connection_error());
381    }
382
383    #[test]
384    fn test_from_tungstenite_error() {
385        let ws_error = tokio_tungstenite::tungstenite::Error::Io(std::io::Error::new(
386            std::io::ErrorKind::BrokenPipe,
387            "broken pipe",
388        ));
389        let err: CortexError = ws_error.into();
390        assert!(matches!(err, CortexError::WebSocket(_)));
391        assert!(err.to_string().contains("WebSocket error"));
392    }
393
394    #[cfg(feature = "config-toml")]
395    #[test]
396    fn test_from_toml_error_conversion() {
397        #[derive(Debug, serde::Deserialize)]
398        struct DummyConfig {
399            _value: String,
400        }
401
402        let toml_err = toml::from_str::<DummyConfig>("value = [").unwrap_err();
403        let err: CortexError = toml_err.into();
404        assert!(matches!(err, CortexError::ConfigError { .. }));
405        assert!(err.to_string().contains("Configuration error"));
406    }
407
408    #[test]
409    fn test_is_retryable_additional_variants() {
410        assert!(CortexError::WebSocket("transport reset".into()).is_retryable());
411        assert!(
412            !CortexError::ConnectionFailed {
413                url: "wss://localhost:6868".into(),
414                reason: "refused".into(),
415            }
416            .is_retryable()
417        );
418        assert!(
419            !CortexError::ProtocolError {
420                reason: "bad frame".into()
421            }
422            .is_retryable()
423        );
424    }
425
426    #[test]
427    fn test_is_connection_error_additional_variants() {
428        assert!(
429            CortexError::ConnectionFailed {
430                url: "wss://localhost:6868".into(),
431                reason: "dial failed".into(),
432            }
433            .is_connection_error()
434        );
435        assert!(CortexError::WebSocket("closed".into()).is_connection_error());
436        assert!(!CortexError::Timeout { seconds: 1 }.is_connection_error());
437        assert!(
438            !CortexError::AuthenticationFailed {
439                reason: "bad auth".into()
440            }
441            .is_connection_error()
442        );
443    }
444}