use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
Api(#[from] ApiError),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Internal error: {0}")]
Internal(String),
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ApiError {
#[error("HTTP error {status}: {message}")]
Http {
status: u16,
message: String,
},
#[error("Authentication failed: {message}")]
Auth {
message: String,
},
#[error("{}", match .retry_after {
Some(secs) => format!("Rate limited, retry after {} seconds", secs),
None => "Rate limited".to_string(),
})]
RateLimit {
retry_after: Option<u64>,
},
#[error("{resource} not found: {id}. It may have been deleted. Run 'td sync' to refresh your cache.")]
NotFound {
resource: String,
id: String,
},
#[error("{}", match .field {
Some(f) => format!("Validation error on {}: {}", f, .message),
None => format!("Validation error: {}", .message),
})]
Validation {
field: Option<String>,
message: String,
},
#[error("Network error: {message}")]
Network {
message: String,
},
}
impl Error {
pub fn is_retryable(&self) -> bool {
match self {
Error::Api(api_err) => api_err.is_retryable(),
Error::Http(req_err) => req_err.is_timeout() || req_err.is_connect(),
Error::Json(_) => false,
Error::Internal(_) => false,
}
}
pub fn is_invalid_sync_token(&self) -> bool {
match self {
Error::Api(api_err) => api_err.is_invalid_sync_token(),
_ => false,
}
}
pub fn exit_code(&self) -> i32 {
match self {
Error::Api(api_err) => api_err.exit_code(),
Error::Http(req_err) => {
if req_err.is_timeout() || req_err.is_connect() {
3 } else {
2 }
}
Error::Json(_) => 2, Error::Internal(_) => 2, }
}
pub fn as_api_error(&self) -> Option<&ApiError> {
match self {
Error::Api(api_err) => Some(api_err),
_ => None,
}
}
}
impl ApiError {
pub fn is_retryable(&self) -> bool {
matches!(self, ApiError::RateLimit { .. } | ApiError::Network { .. })
}
pub fn exit_code(&self) -> i32 {
match self {
ApiError::Network { .. } => 3,
ApiError::RateLimit { .. } => 4,
_ => 2,
}
}
pub fn is_invalid_sync_token(&self) -> bool {
match self {
ApiError::Validation { message, .. } => {
let msg_lower = message.to_lowercase();
msg_lower.contains("sync_token")
|| msg_lower.contains("sync token")
|| msg_lower.contains("invalid token")
|| msg_lower.contains("token invalid")
}
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_api_error_http_variant_exists() {
let status = 500;
let message = "Internal Server Error".to_string();
let error = ApiError::Http { status, message };
match error {
ApiError::Http {
status: s,
message: m,
} => {
assert_eq!(s, 500);
assert_eq!(m, "Internal Server Error");
}
_ => panic!("Expected Http variant"),
}
}
#[test]
fn test_api_error_auth_variant_exists() {
let error = ApiError::Auth {
message: "Invalid token".to_string(),
};
match error {
ApiError::Auth { message } => {
assert_eq!(message, "Invalid token");
}
_ => panic!("Expected Auth variant"),
}
}
#[test]
fn test_api_error_rate_limit_variant_exists() {
let error = ApiError::RateLimit {
retry_after: Some(30),
};
match error {
ApiError::RateLimit { retry_after } => {
assert_eq!(retry_after, Some(30));
}
_ => panic!("Expected RateLimit variant"),
}
}
#[test]
fn test_api_error_not_found_variant_exists() {
let error = ApiError::NotFound {
resource: "task".to_string(),
id: "abc123".to_string(),
};
match error {
ApiError::NotFound { resource, id } => {
assert_eq!(resource, "task");
assert_eq!(id, "abc123");
}
_ => panic!("Expected NotFound variant"),
}
}
#[test]
fn test_api_error_validation_variant_exists() {
let error = ApiError::Validation {
field: Some("due_date".to_string()),
message: "Invalid date format".to_string(),
};
match error {
ApiError::Validation { field, message } => {
assert_eq!(field, Some("due_date".to_string()));
assert_eq!(message, "Invalid date format");
}
_ => panic!("Expected Validation variant"),
}
}
#[test]
fn test_api_error_network_variant_exists() {
let error = ApiError::Network {
message: "Connection refused".to_string(),
};
match error {
ApiError::Network { message } => {
assert_eq!(message, "Connection refused");
}
_ => panic!("Expected Network variant"),
}
}
#[test]
fn test_api_error_implements_std_error() {
let error: Box<dyn std::error::Error> = Box::new(ApiError::Network {
message: "timeout".to_string(),
});
assert!(error.to_string().contains("timeout"));
}
#[test]
fn test_api_error_display_http() {
let error = ApiError::Http {
status: 503,
message: "Service Unavailable".to_string(),
};
let display = error.to_string();
assert!(display.contains("503") || display.contains("Service Unavailable"));
}
#[test]
fn test_api_error_display_auth() {
let error = ApiError::Auth {
message: "Token expired".to_string(),
};
let display = error.to_string();
assert!(display.to_lowercase().contains("auth") || display.contains("Token expired"));
}
#[test]
fn test_api_error_display_rate_limit() {
let error = ApiError::RateLimit {
retry_after: Some(60),
};
let display = error.to_string();
assert!(display.to_lowercase().contains("rate") || display.contains("60"));
}
#[test]
fn test_api_error_display_not_found() {
let error = ApiError::NotFound {
resource: "project".to_string(),
id: "xyz789".to_string(),
};
let display = error.to_string();
assert!(
display.contains("project")
|| display.contains("xyz789")
|| display.to_lowercase().contains("not found")
);
}
#[test]
fn test_api_error_not_found_includes_sync_suggestion() {
let error = ApiError::NotFound {
resource: "task".to_string(),
id: "abc123".to_string(),
};
let display = error.to_string();
assert!(
display.contains("td sync"),
"NotFound error should include suggestion to run 'td sync': {}",
display
);
assert!(
display.contains("may have been deleted"),
"NotFound error should mention item may have been deleted: {}",
display
);
}
#[test]
fn test_api_error_display_validation() {
let error = ApiError::Validation {
field: Some("priority".to_string()),
message: "Must be between 1 and 4".to_string(),
};
let display = error.to_string();
assert!(display.contains("priority") || display.contains("Must be between 1 and 4"));
}
#[test]
fn test_api_error_display_network() {
let error = ApiError::Network {
message: "DNS lookup failed".to_string(),
};
let display = error.to_string();
assert!(
display.contains("DNS lookup failed") || display.to_lowercase().contains("network")
);
}
#[test]
fn test_api_error_is_retryable_rate_limit() {
let error = ApiError::RateLimit {
retry_after: Some(5),
};
assert!(error.is_retryable());
}
#[test]
fn test_api_error_is_retryable_network() {
let error = ApiError::Network {
message: "Connection reset".to_string(),
};
assert!(error.is_retryable());
}
#[test]
fn test_api_error_is_not_retryable_auth() {
let error = ApiError::Auth {
message: "Invalid credentials".to_string(),
};
assert!(!error.is_retryable());
}
#[test]
fn test_api_error_is_not_retryable_not_found() {
let error = ApiError::NotFound {
resource: "task".to_string(),
id: "123".to_string(),
};
assert!(!error.is_retryable());
}
#[test]
fn test_api_error_is_not_retryable_validation() {
let error = ApiError::Validation {
field: None,
message: "Invalid request".to_string(),
};
assert!(!error.is_retryable());
}
#[test]
fn test_api_error_exit_code_auth() {
let error = ApiError::Auth {
message: "Unauthorized".to_string(),
};
assert_eq!(error.exit_code(), 2);
}
#[test]
fn test_api_error_exit_code_not_found() {
let error = ApiError::NotFound {
resource: "task".to_string(),
id: "abc".to_string(),
};
assert_eq!(error.exit_code(), 2);
}
#[test]
fn test_api_error_exit_code_validation() {
let error = ApiError::Validation {
field: Some("content".to_string()),
message: "Required".to_string(),
};
assert_eq!(error.exit_code(), 2);
}
#[test]
fn test_api_error_exit_code_network() {
let error = ApiError::Network {
message: "Timeout".to_string(),
};
assert_eq!(error.exit_code(), 3);
}
#[test]
fn test_api_error_exit_code_rate_limit() {
let error = ApiError::RateLimit { retry_after: None };
assert_eq!(error.exit_code(), 4);
}
#[test]
fn test_api_error_exit_code_http() {
let error = ApiError::Http {
status: 500,
message: "Server error".to_string(),
};
assert_eq!(error.exit_code(), 2);
}
#[test]
fn test_error_from_api_error() {
let api_error = ApiError::Auth {
message: "test".to_string(),
};
let error: Error = api_error.into();
assert!(matches!(error, Error::Api(_)));
}
#[test]
fn test_error_api_variant_is_retryable() {
let error: Error = ApiError::RateLimit {
retry_after: Some(5),
}
.into();
assert!(error.is_retryable());
}
#[test]
fn test_error_api_variant_not_retryable() {
let error: Error = ApiError::Auth {
message: "bad token".to_string(),
}
.into();
assert!(!error.is_retryable());
}
#[test]
fn test_error_json_not_retryable() {
let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
let error: Error = json_err.into();
assert!(!error.is_retryable());
}
#[test]
fn test_error_internal_not_retryable() {
let error = Error::Internal("something went wrong".to_string());
assert!(!error.is_retryable());
}
#[test]
fn test_error_exit_code_api() {
let error: Error = ApiError::RateLimit { retry_after: None }.into();
assert_eq!(error.exit_code(), 4);
let error: Error = ApiError::Network {
message: "timeout".to_string(),
}
.into();
assert_eq!(error.exit_code(), 3);
let error: Error = ApiError::Auth {
message: "bad".to_string(),
}
.into();
assert_eq!(error.exit_code(), 2);
}
#[test]
fn test_error_exit_code_json() {
let json_err = serde_json::from_str::<serde_json::Value>("bad").unwrap_err();
let error: Error = json_err.into();
assert_eq!(error.exit_code(), 2);
}
#[test]
fn test_error_exit_code_internal() {
let error = Error::Internal("panic".to_string());
assert_eq!(error.exit_code(), 2);
}
#[test]
fn test_error_as_api_error() {
let api_error = ApiError::NotFound {
resource: "task".to_string(),
id: "123".to_string(),
};
let error: Error = api_error.clone().into();
assert_eq!(error.as_api_error(), Some(&api_error));
}
#[test]
fn test_error_as_api_error_none() {
let error = Error::Internal("test".to_string());
assert_eq!(error.as_api_error(), None);
}
#[test]
fn test_error_display_api() {
let error: Error = ApiError::Auth {
message: "Invalid token".to_string(),
}
.into();
let display = error.to_string();
assert!(display.contains("Invalid token"));
}
#[test]
fn test_error_display_internal() {
let error = Error::Internal("unexpected state".to_string());
let display = error.to_string();
assert!(display.contains("unexpected state"));
}
#[test]
fn test_error_implements_std_error() {
let error: Box<dyn std::error::Error> = Box::new(Error::Internal("test".to_string()));
assert!(error.to_string().contains("test"));
}
#[test]
fn test_result_type_alias() {
fn returns_result() -> Result<i32> {
Ok(42)
}
assert_eq!(returns_result().unwrap(), 42);
}
#[test]
fn test_result_type_alias_error() {
fn returns_error() -> Result<i32> {
Err(Error::Internal("failed".to_string()))
}
assert!(returns_error().is_err());
}
#[test]
fn test_api_error_is_invalid_sync_token_with_sync_token_message() {
let error = ApiError::Validation {
field: None,
message: "Invalid sync_token".to_string(),
};
assert!(error.is_invalid_sync_token());
}
#[test]
fn test_api_error_is_invalid_sync_token_with_sync_token_spaces() {
let error = ApiError::Validation {
field: None,
message: "Invalid sync token provided".to_string(),
};
assert!(error.is_invalid_sync_token());
}
#[test]
fn test_api_error_is_invalid_sync_token_with_token_invalid() {
let error = ApiError::Validation {
field: None,
message: "Token invalid or expired".to_string(),
};
assert!(error.is_invalid_sync_token());
}
#[test]
fn test_api_error_is_invalid_sync_token_case_insensitive() {
let error = ApiError::Validation {
field: None,
message: "SYNC_TOKEN is not valid".to_string(),
};
assert!(error.is_invalid_sync_token());
}
#[test]
fn test_api_error_is_invalid_sync_token_false_for_other_validation() {
let error = ApiError::Validation {
field: Some("content".to_string()),
message: "Content is required".to_string(),
};
assert!(!error.is_invalid_sync_token());
}
#[test]
fn test_api_error_is_invalid_sync_token_false_for_auth() {
let error = ApiError::Auth {
message: "Token expired".to_string(),
};
assert!(!error.is_invalid_sync_token());
}
#[test]
fn test_api_error_is_invalid_sync_token_false_for_http() {
let error = ApiError::Http {
status: 500,
message: "Server error".to_string(),
};
assert!(!error.is_invalid_sync_token());
}
#[test]
fn test_error_is_invalid_sync_token_delegates_to_api_error() {
let error: Error = ApiError::Validation {
field: None,
message: "Invalid sync_token".to_string(),
}
.into();
assert!(error.is_invalid_sync_token());
}
#[test]
fn test_error_is_invalid_sync_token_false_for_non_api() {
let error = Error::Internal("test".to_string());
assert!(!error.is_invalid_sync_token());
}
#[test]
fn test_error_is_invalid_sync_token_false_for_http_error() {
let error = Error::Json(serde_json::from_str::<serde_json::Value>("bad").unwrap_err());
assert!(!error.is_invalid_sync_token());
}
}