1use thiserror::Error;
13
14pub type CortexResult<T> = std::result::Result<T, CortexError>;
16
17#[derive(Error, Debug)]
19pub enum CortexError {
20 #[error("Failed to connect to Cortex at {url}: {reason}. Is the EMOTIV Launcher running?")]
23 ConnectionFailed { url: String, reason: String },
24
25 #[error("Connection to Cortex lost: {reason}")]
27 ConnectionLost { reason: String },
28
29 #[error("Not connected to Cortex")]
31 NotConnected,
32
33 #[error(
36 "Authentication failed: {reason}. Check your client_id and client_secret from the Emotiv Developer Portal."
37 )]
38 AuthenticationFailed { reason: String },
39
40 #[error("Cortex token expired — re-authentication required")]
42 TokenExpired,
43
44 #[error("Access denied: {reason}. Approve the application in the EMOTIV Launcher.")]
46 AccessDenied { reason: String },
47
48 #[error("User not logged in to EmotivID. Open the EMOTIV Launcher and sign in.")]
50 UserNotLoggedIn,
51
52 #[error("Application not approved. Open the EMOTIV Launcher and approve access for your app.")]
54 NotApproved,
55
56 #[error("Emotiv license error: {reason}")]
59 LicenseError { reason: String },
60
61 #[error("No headset found. Ensure the headset is powered on and within range.")]
64 NoHeadsetFound,
65
66 #[error("Headset is in use by another session")]
68 HeadsetInUse,
69
70 #[error("Headset connection error: {reason}")]
72 HeadsetError { reason: String },
73
74 #[error("Session error: {reason}")]
77 SessionError { reason: String },
78
79 #[error("Stream error: {reason}")]
82 StreamError { reason: String },
83
84 #[error("Cortex API error {code}: {message}")]
87 ApiError { code: i32, message: String },
88
89 #[error("Cortex service is starting up — retry in a few seconds")]
91 CortexStarting,
92
93 #[error("API method not found: {method}")]
95 MethodNotFound { method: String },
96
97 #[error("Operation timed out after {seconds}s")]
100 Timeout { seconds: u64 },
101
102 #[error("Operation failed after {attempts} attempts: {last_error}")]
105 RetriesExhausted {
106 attempts: u32,
107 last_error: Box<CortexError>,
108 },
109
110 #[error("Protocol error: {reason}")]
113 ProtocolError { reason: String },
114
115 #[error("Configuration error: {reason}")]
118 ConfigError { reason: String },
119
120 #[error("WebSocket error: {0}")]
123 WebSocket(String),
124
125 #[error("TLS error: {0}")]
127 Tls(String),
128
129 #[error("I/O error: {0}")]
132 Io(#[from] std::io::Error),
133
134 #[error("JSON error: {0}")]
136 Json(#[from] serde_json::Error),
137}
138
139impl CortexError {
140 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 -32102 => CortexError::NotApproved,
189 -32122 => CortexError::CortexStarting,
190 _ => CortexError::ApiError { code, message },
191 }
192 }
193
194 #[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 #[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
239impl 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}