1use std::fmt;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub struct RithmicRequestError {
17 pub rp_code: Vec<String>,
19 pub code: Option<String>,
21 pub message: Option<String>,
26}
27
28fn sanitize_for_display(s: &str) -> String {
33 s.chars().filter(|c| !c.is_control()).collect()
34}
35
36impl fmt::Display for RithmicRequestError {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 let message = self.message.as_deref().map(sanitize_for_display);
39
40 match self.code.as_deref() {
41 Some(code) if !code.is_empty() => {
42 let code = sanitize_for_display(code);
43
44 match message {
45 Some(m) if !m.is_empty() => write!(f, "[{code}] {m}"),
46 _ => write!(f, "[{code}]"),
47 }
48 }
49 _ => write!(f, "{}", message.unwrap_or_default()),
50 }
51 }
52}
53
54impl std::error::Error for RithmicRequestError {}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum RithmicError {
79 ConnectionFailed(String),
81 ConnectionClosed,
83 SendFailed,
91 EmptyResponse,
93 RequestRejected(RithmicRequestError),
96 ProtocolError(String),
100 InvalidArgument(String),
103 HeartbeatTimeout,
105 ForcedLogout(String),
107}
108
109impl RithmicError {
110 pub fn is_connection_issue(&self) -> bool {
113 matches!(
114 self,
115 Self::ConnectionFailed(_)
116 | Self::ConnectionClosed
117 | Self::SendFailed
118 | Self::HeartbeatTimeout
119 | Self::ForcedLogout(_)
120 )
121 }
122
123 pub fn as_connection_message(&self) -> crate::rti::messages::RithmicMessage {
129 match self {
130 Self::HeartbeatTimeout => crate::rti::messages::RithmicMessage::HeartbeatTimeout,
131 _ => crate::rti::messages::RithmicMessage::ConnectionError,
132 }
133 }
134}
135
136impl fmt::Display for RithmicError {
137 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
138 match self {
139 RithmicError::ConnectionFailed(msg) => write!(f, "connection failed: {msg}"),
140 RithmicError::ConnectionClosed => write!(f, "connection closed"),
141 RithmicError::SendFailed => write!(f, "WebSocket send failed or timed out"),
142 RithmicError::EmptyResponse => write!(f, "empty response"),
143 RithmicError::RequestRejected(err) => write!(f, "request rejected: {err}"),
144 RithmicError::ProtocolError(msg) => write!(f, "protocol error: {msg}"),
145 RithmicError::InvalidArgument(msg) => write!(f, "invalid argument: {msg}"),
146 RithmicError::HeartbeatTimeout => write!(f, "heartbeat timeout"),
147 RithmicError::ForcedLogout(reason) => {
148 write!(f, "forced logout: {}", sanitize_for_display(reason))
149 }
150 }
151 }
152}
153
154impl std::error::Error for RithmicError {
155 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
156 match self {
157 RithmicError::RequestRejected(inner) => Some(inner),
158 _ => None,
159 }
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use std::error::Error;
166
167 use super::*;
168
169 #[test]
170 fn request_error_display_formats_code_and_message() {
171 let err = RithmicRequestError {
172 rp_code: vec![
173 "1039".to_string(),
174 "FCM Id field is not received.".to_string(),
175 ],
176 code: Some("1039".to_string()),
177 message: Some("FCM Id field is not received.".to_string()),
178 };
179
180 assert_eq!(err.to_string(), "[1039] FCM Id field is not received.");
181 }
182
183 #[test]
184 fn request_error_display_without_code_uses_message_only() {
185 let err = RithmicRequestError {
186 rp_code: vec![],
187 code: None,
188 message: Some("something happened".to_string()),
189 };
190
191 assert_eq!(err.to_string(), "something happened");
192 }
193
194 #[test]
195 fn request_error_display_single_element_omits_trailing_slash() {
196 let err = RithmicRequestError {
199 rp_code: vec!["5".to_string()],
200 code: Some("5".to_string()),
201 message: None,
202 };
203
204 assert_eq!(err.to_string(), "[5]");
205 }
206
207 #[test]
208 fn request_error_display_sanitizes_control_chars() {
209 let err = RithmicRequestError {
215 rp_code: vec![
216 "3\n".to_string(),
217 "bad\x1b[31mredinjection\r\ndropped".to_string(),
218 ],
219 code: Some("3\n".to_string()),
220 message: Some("bad\x1b[31mredinjection\r\ndropped".to_string()),
221 };
222
223 assert_eq!(err.to_string(), "[3] bad[31mredinjectiondropped");
224 }
225
226 #[test]
227 fn request_error_equality() {
228 let a = RithmicRequestError {
229 rp_code: vec!["3".to_string(), "bad request".to_string()],
230 code: Some("3".to_string()),
231 message: Some("bad request".to_string()),
232 };
233
234 let b = RithmicRequestError {
235 rp_code: vec!["3".to_string(), "bad request".to_string()],
236 code: Some("3".to_string()),
237 message: Some("bad request".to_string()),
238 };
239
240 let c = RithmicRequestError {
241 rp_code: vec!["4".to_string(), "bad request".to_string()],
242 code: Some("4".to_string()),
243 message: Some("bad request".to_string()),
244 };
245
246 assert_eq!(a, b);
247 assert_ne!(a, c);
248 }
249
250 #[test]
251 fn rithmic_error_equality_for_unit_variants() {
252 assert_eq!(
255 RithmicError::ConnectionClosed,
256 RithmicError::ConnectionClosed
257 );
258 assert_ne!(RithmicError::ConnectionClosed, RithmicError::SendFailed);
259 }
260
261 #[test]
262 fn rithmic_error_source_chain_exposes_inner_request_error() {
263 let inner = RithmicRequestError {
266 rp_code: vec!["3".to_string(), "bad".to_string()],
267 code: Some("3".to_string()),
268 message: Some("bad".to_string()),
269 };
270
271 let err = RithmicError::RequestRejected(inner.clone());
272 let src = err
273 .source()
274 .expect("source should be Some for RequestRejected");
275
276 assert_eq!(src.to_string(), inner.to_string());
277
278 assert!(
279 RithmicError::ConnectionClosed.source().is_none(),
280 "unit variants should have no source"
281 );
282 }
283
284 #[test]
285 fn plant_rejection_mapping_produces_request_rejected() {
286 let err = RithmicRequestError {
289 rp_code: vec!["3".to_string(), "bad request".to_string()],
290 code: Some("3".to_string()),
291 message: Some("bad request".to_string()),
292 };
293
294 let mapped = RithmicError::RequestRejected(err.clone());
295
296 match mapped {
297 RithmicError::RequestRejected(inner) => {
298 assert_eq!(inner, err);
299 assert_eq!(inner.code.as_deref(), Some("3"));
300 assert_eq!(inner.message.as_deref(), Some("bad request"));
301 assert_eq!(
302 inner.rp_code,
303 vec!["3".to_string(), "bad request".to_string()]
304 );
305 }
306 other => panic!("expected RequestRejected, got {other:?}"),
307 }
308
309 let display = RithmicError::RequestRejected(err).to_string();
312
313 assert_eq!(display, "request rejected: [3] bad request");
314 }
315
316 #[test]
317 fn rithmic_error_request_rejected_display_delegates() {
318 let err = RithmicError::RequestRejected(RithmicRequestError {
319 rp_code: vec![
320 "7".to_string(),
321 "an error occurred while parsing data.".to_string(),
322 ],
323 code: Some("7".to_string()),
324 message: Some("an error occurred while parsing data.".to_string()),
325 });
326
327 assert_eq!(
328 err.to_string(),
329 "request rejected: [7] an error occurred while parsing data."
330 );
331 }
332
333 #[test]
334 fn rithmic_error_protocol_error_display() {
335 let err = RithmicError::ProtocolError("decode failed".to_string());
336
337 assert_eq!(err.to_string(), "protocol error: decode failed");
338 }
339
340 #[test]
341 fn heartbeat_timeout_display() {
342 assert_eq!(
343 RithmicError::HeartbeatTimeout.to_string(),
344 "heartbeat timeout"
345 );
346 }
347
348 #[test]
349 fn forced_logout_display() {
350 assert_eq!(
351 RithmicError::ForcedLogout("srv reason".into()).to_string(),
352 "forced logout: srv reason"
353 );
354 }
355
356 #[test]
357 fn forced_logout_sanitizes_control_chars() {
358 let err = RithmicError::ForcedLogout("bad\nreason".into());
359 assert_eq!(err.to_string(), "forced logout: badreason");
360 }
361
362 #[test]
363 fn is_connection_issue_true_for_transport_variants() {
364 assert!(RithmicError::ConnectionFailed("x".into()).is_connection_issue());
365 assert!(RithmicError::ConnectionClosed.is_connection_issue());
366 assert!(RithmicError::SendFailed.is_connection_issue());
367 assert!(RithmicError::HeartbeatTimeout.is_connection_issue());
368 assert!(RithmicError::ForcedLogout("x".into()).is_connection_issue());
369 }
370
371 #[test]
372 fn is_connection_issue_false_for_protocol_variants() {
373 let req = RithmicRequestError {
374 rp_code: vec!["3".into(), "x".into()],
375 code: Some("3".into()),
376 message: Some("x".into()),
377 };
378 assert!(!RithmicError::RequestRejected(req).is_connection_issue());
379 assert!(!RithmicError::ProtocolError("x".into()).is_connection_issue());
380 assert!(!RithmicError::InvalidArgument("x".into()).is_connection_issue());
381 assert!(!RithmicError::EmptyResponse.is_connection_issue());
382 }
383
384 #[test]
385 fn as_connection_message_heartbeat_timeout() {
386 assert!(matches!(
387 RithmicError::HeartbeatTimeout.as_connection_message(),
388 crate::rti::messages::RithmicMessage::HeartbeatTimeout
389 ));
390 }
391
392 #[test]
393 fn as_connection_message_connection_failed() {
394 assert!(matches!(
395 RithmicError::ConnectionFailed("x".into()).as_connection_message(),
396 crate::rti::messages::RithmicMessage::ConnectionError
397 ));
398 }
399}