use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RithmicRequestError {
pub rp_code: Vec<String>,
pub code: Option<String>,
pub message: Option<String>,
}
fn sanitize_for_display(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
impl fmt::Display for RithmicRequestError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = self.message.as_deref().map(sanitize_for_display);
match self.code.as_deref() {
Some(code) if !code.is_empty() => {
let code = sanitize_for_display(code);
match message {
Some(m) if !m.is_empty() => write!(f, "[{code}] {m}"),
_ => write!(f, "[{code}]"),
}
}
_ => write!(f, "{}", message.unwrap_or_default()),
}
}
}
impl std::error::Error for RithmicRequestError {}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RithmicError {
ConnectionFailed(String),
ConnectionClosed,
SendFailed,
EmptyResponse,
RequestRejected(RithmicRequestError),
ProtocolError(String),
InvalidArgument(String),
HeartbeatTimeout,
ForcedLogout(String),
}
impl RithmicError {
pub fn is_connection_issue(&self) -> bool {
matches!(
self,
Self::ConnectionFailed(_)
| Self::ConnectionClosed
| Self::SendFailed
| Self::HeartbeatTimeout
| Self::ForcedLogout(_)
)
}
pub fn as_connection_message(&self) -> crate::rti::messages::RithmicMessage {
match self {
Self::HeartbeatTimeout => crate::rti::messages::RithmicMessage::HeartbeatTimeout,
_ => crate::rti::messages::RithmicMessage::ConnectionError,
}
}
}
impl fmt::Display for RithmicError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
RithmicError::ConnectionFailed(msg) => write!(f, "connection failed: {msg}"),
RithmicError::ConnectionClosed => write!(f, "connection closed"),
RithmicError::SendFailed => write!(f, "WebSocket send failed or timed out"),
RithmicError::EmptyResponse => write!(f, "empty response"),
RithmicError::RequestRejected(err) => write!(f, "request rejected: {err}"),
RithmicError::ProtocolError(msg) => write!(f, "protocol error: {msg}"),
RithmicError::InvalidArgument(msg) => write!(f, "invalid argument: {msg}"),
RithmicError::HeartbeatTimeout => write!(f, "heartbeat timeout"),
RithmicError::ForcedLogout(reason) => {
write!(f, "forced logout: {}", sanitize_for_display(reason))
}
}
}
}
impl std::error::Error for RithmicError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
RithmicError::RequestRejected(inner) => Some(inner),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use std::error::Error;
use super::*;
#[test]
fn request_error_display_formats_code_and_message() {
let err = RithmicRequestError {
rp_code: vec![
"1039".to_string(),
"FCM Id field is not received.".to_string(),
],
code: Some("1039".to_string()),
message: Some("FCM Id field is not received.".to_string()),
};
assert_eq!(err.to_string(), "[1039] FCM Id field is not received.");
}
#[test]
fn request_error_display_without_code_uses_message_only() {
let err = RithmicRequestError {
rp_code: vec![],
code: None,
message: Some("something happened".to_string()),
};
assert_eq!(err.to_string(), "something happened");
}
#[test]
fn request_error_display_single_element_omits_trailing_slash() {
let err = RithmicRequestError {
rp_code: vec!["5".to_string()],
code: Some("5".to_string()),
message: None,
};
assert_eq!(err.to_string(), "[5]");
}
#[test]
fn request_error_display_sanitizes_control_chars() {
let err = RithmicRequestError {
rp_code: vec![
"3\n".to_string(),
"bad\x1b[31mredinjection\r\ndropped".to_string(),
],
code: Some("3\n".to_string()),
message: Some("bad\x1b[31mredinjection\r\ndropped".to_string()),
};
assert_eq!(err.to_string(), "[3] bad[31mredinjectiondropped");
}
#[test]
fn request_error_equality() {
let a = RithmicRequestError {
rp_code: vec!["3".to_string(), "bad request".to_string()],
code: Some("3".to_string()),
message: Some("bad request".to_string()),
};
let b = RithmicRequestError {
rp_code: vec!["3".to_string(), "bad request".to_string()],
code: Some("3".to_string()),
message: Some("bad request".to_string()),
};
let c = RithmicRequestError {
rp_code: vec!["4".to_string(), "bad request".to_string()],
code: Some("4".to_string()),
message: Some("bad request".to_string()),
};
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn rithmic_error_equality_for_unit_variants() {
assert_eq!(
RithmicError::ConnectionClosed,
RithmicError::ConnectionClosed
);
assert_ne!(RithmicError::ConnectionClosed, RithmicError::SendFailed);
}
#[test]
fn rithmic_error_source_chain_exposes_inner_request_error() {
let inner = RithmicRequestError {
rp_code: vec!["3".to_string(), "bad".to_string()],
code: Some("3".to_string()),
message: Some("bad".to_string()),
};
let err = RithmicError::RequestRejected(inner.clone());
let src = err
.source()
.expect("source should be Some for RequestRejected");
assert_eq!(src.to_string(), inner.to_string());
assert!(
RithmicError::ConnectionClosed.source().is_none(),
"unit variants should have no source"
);
}
#[test]
fn plant_rejection_mapping_produces_request_rejected() {
let err = RithmicRequestError {
rp_code: vec!["3".to_string(), "bad request".to_string()],
code: Some("3".to_string()),
message: Some("bad request".to_string()),
};
let mapped = RithmicError::RequestRejected(err.clone());
match mapped {
RithmicError::RequestRejected(inner) => {
assert_eq!(inner, err);
assert_eq!(inner.code.as_deref(), Some("3"));
assert_eq!(inner.message.as_deref(), Some("bad request"));
assert_eq!(
inner.rp_code,
vec!["3".to_string(), "bad request".to_string()]
);
}
other => panic!("expected RequestRejected, got {other:?}"),
}
let display = RithmicError::RequestRejected(err).to_string();
assert_eq!(display, "request rejected: [3] bad request");
}
#[test]
fn rithmic_error_request_rejected_display_delegates() {
let err = RithmicError::RequestRejected(RithmicRequestError {
rp_code: vec![
"7".to_string(),
"an error occurred while parsing data.".to_string(),
],
code: Some("7".to_string()),
message: Some("an error occurred while parsing data.".to_string()),
});
assert_eq!(
err.to_string(),
"request rejected: [7] an error occurred while parsing data."
);
}
#[test]
fn rithmic_error_protocol_error_display() {
let err = RithmicError::ProtocolError("decode failed".to_string());
assert_eq!(err.to_string(), "protocol error: decode failed");
}
#[test]
fn heartbeat_timeout_display() {
assert_eq!(
RithmicError::HeartbeatTimeout.to_string(),
"heartbeat timeout"
);
}
#[test]
fn forced_logout_display() {
assert_eq!(
RithmicError::ForcedLogout("srv reason".into()).to_string(),
"forced logout: srv reason"
);
}
#[test]
fn forced_logout_sanitizes_control_chars() {
let err = RithmicError::ForcedLogout("bad\nreason".into());
assert_eq!(err.to_string(), "forced logout: badreason");
}
#[test]
fn is_connection_issue_true_for_transport_variants() {
assert!(RithmicError::ConnectionFailed("x".into()).is_connection_issue());
assert!(RithmicError::ConnectionClosed.is_connection_issue());
assert!(RithmicError::SendFailed.is_connection_issue());
assert!(RithmicError::HeartbeatTimeout.is_connection_issue());
assert!(RithmicError::ForcedLogout("x".into()).is_connection_issue());
}
#[test]
fn is_connection_issue_false_for_protocol_variants() {
let req = RithmicRequestError {
rp_code: vec!["3".into(), "x".into()],
code: Some("3".into()),
message: Some("x".into()),
};
assert!(!RithmicError::RequestRejected(req).is_connection_issue());
assert!(!RithmicError::ProtocolError("x".into()).is_connection_issue());
assert!(!RithmicError::InvalidArgument("x".into()).is_connection_issue());
assert!(!RithmicError::EmptyResponse.is_connection_issue());
}
#[test]
fn as_connection_message_heartbeat_timeout() {
assert!(matches!(
RithmicError::HeartbeatTimeout.as_connection_message(),
crate::rti::messages::RithmicMessage::HeartbeatTimeout
));
}
#[test]
fn as_connection_message_connection_failed() {
assert!(matches!(
RithmicError::ConnectionFailed("x".into()).as_connection_message(),
crate::rti::messages::RithmicMessage::ConnectionError
));
}
}