//! Typed API error system for Tauri commands. //! //! Provides structured error responses that replace string errors with typed errors //! containing machine-readable codes and human-readable messages. //! //! # Example Usage //! //! ```ignore //! use crate::commands::error::{ApiError, ErrorCode}; //! //! #[tauri::command] //! pub async fn get_task(id: Uuid) -> Result { //! // Validation error with details //! if id.is_nil() { //! return Err(ApiError::validation("id", "Task ID cannot be nil")); //! } //! //! // Not found error //! state.tasks.get_by_id(id) //! .await? //! .ok_or_else(|| ApiError::not_found("task", id)) //! } //! ``` use goingson_core::CoreError; use serde::Serialize; /// Machine-readable error codes for API responses. /// /// These codes can be used by the frontend to provide specific error handling /// or localized error messages. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum ErrorCode { /// Resource was not found (e.g., task, project, event). NotFound, /// Input validation failed (e.g., empty description, invalid date). ValidationError, /// Database operation failed. DatabaseError, /// Request was malformed or invalid. BadRequest, /// Authentication or authorization failed. AuthError, /// Parsing failed (e.g., invalid date format, malformed JSON). ParseError, /// An unexpected internal error occurred. InternalError, /// Operation conflict (e.g., duplicate entry, state conflict). Conflict, /// External service error (e.g., IMAP). ExternalServiceError, } impl ErrorCode { /// Returns the string representation of the error code. pub fn as_str(&self) -> &'static str { match self { ErrorCode::NotFound => "NOT_FOUND", ErrorCode::ValidationError => "VALIDATION_ERROR", ErrorCode::DatabaseError => "DATABASE_ERROR", ErrorCode::BadRequest => "BAD_REQUEST", ErrorCode::AuthError => "AUTH_ERROR", ErrorCode::ParseError => "PARSE_ERROR", ErrorCode::InternalError => "INTERNAL_ERROR", ErrorCode::Conflict => "CONFLICT", ErrorCode::ExternalServiceError => "EXTERNAL_SERVICE_ERROR", } } } /// Structured API error for Tauri command responses. /// /// This provides a consistent error format across all commands with: /// - A machine-readable `code` for programmatic error handling /// - A human-readable `message` for display to users /// - Optional `details` for additional context (field names, resource types, etc.) #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ApiError { /// Machine-readable error code. pub code: ErrorCode, /// Human-readable error message. pub message: String, /// Optional additional details (e.g., field name for validation errors). #[serde(skip_serializing_if = "Option::is_none")] pub details: Option, } /// Additional error details for specific error types. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ErrorDetails { /// The field that caused a validation error. #[serde(skip_serializing_if = "Option::is_none")] pub field: Option, /// The type of resource that was not found (e.g., "task", "project"). #[serde(skip_serializing_if = "Option::is_none")] pub resource: Option, /// The ID of the resource that was not found. #[serde(skip_serializing_if = "Option::is_none")] pub resource_id: Option, } impl ApiError { /// Creates a new API error with the given code and message. pub fn new(code: ErrorCode, message: impl Into) -> Self { Self { code, message: message.into(), details: None, } } /// Creates a new API error with details. pub fn with_details(code: ErrorCode, message: impl Into, details: ErrorDetails) -> Self { Self { code, message: message.into(), details: Some(details), } } // ============ Convenience constructors ============ /// Creates a not-found error for a specific resource. pub fn not_found(resource: &str, id: impl ToString) -> Self { let id_str = id.to_string(); Self::with_details( ErrorCode::NotFound, format!("{} not found: {}", resource, id_str), ErrorDetails { field: None, resource: Some(resource.to_string()), resource_id: Some(id_str), }, ) } /// Creates a validation error for a specific field. pub fn validation(field: &str, message: impl Into) -> Self { Self::with_details( ErrorCode::ValidationError, message.into(), ErrorDetails { field: Some(field.to_string()), resource: None, resource_id: None, }, ) } /// Creates a generic validation error without a specific field. pub fn validation_msg(message: impl Into) -> Self { Self::new(ErrorCode::ValidationError, message) } /// Creates a database error. pub fn database(message: impl Into) -> Self { Self::new(ErrorCode::DatabaseError, message) } /// Creates a bad request error. pub fn bad_request(message: impl Into) -> Self { Self::new(ErrorCode::BadRequest, message) } /// Creates an authentication error. pub fn auth(message: impl Into) -> Self { Self::new(ErrorCode::AuthError, message) } /// Creates a parse error. pub fn parse(message: impl Into) -> Self { Self::new(ErrorCode::ParseError, message) } /// Creates an internal error. pub fn internal(message: impl Into) -> Self { Self::new(ErrorCode::InternalError, message) } /// Creates a conflict error. pub fn conflict(message: impl Into) -> Self { Self::new(ErrorCode::Conflict, message) } /// Creates an external service error. pub fn external_service(message: impl Into) -> Self { Self::new(ErrorCode::ExternalServiceError, message) } } // ============ Extension Traits ============ /// Extension trait on `Option` for converting to `Result` with a not-found error. /// /// Replaces the verbose `.ok_or_else(|| ApiError::not_found("entity", id))?` pattern. /// /// # Example /// /// ```ignore /// use crate::commands::error::OptionNotFound; /// /// let task = state.tasks.get_by_id(id, user_id) /// .await? /// .or_not_found("task", id)?; /// ``` pub trait OptionNotFound { /// Converts `None` into `ApiError::not_found(entity, id)`. fn or_not_found(self, entity: &str, id: impl ToString) -> Result; } impl OptionNotFound for Option { fn or_not_found(self, entity: &str, id: impl ToString) -> Result { self.ok_or_else(|| ApiError::not_found(entity, id)) } } /// Extension trait on `Option` for converting to `Result` with an arbitrary error. /// /// Replaces patterns like `.ok_or_else(|| ApiError::bad_request("..."))`. /// /// # Example /// /// ```ignore /// use crate::commands::error::OptionApiError; /// /// let settings = state.backup_settings.get(user_id) /// .await? /// .or_api_err(|| ApiError::bad_request("Backup settings not configured"))?; /// ``` pub trait OptionApiError { /// Converts `None` into the `ApiError` returned by `f`. fn or_api_err(self, f: impl FnOnce() -> ApiError) -> Result; } impl OptionApiError for Option { fn or_api_err(self, f: impl FnOnce() -> ApiError) -> Result { self.ok_or_else(f) } } /// Extension trait on `Result` for mapping errors into `ApiError` with a formatted message. /// /// Replaces the verbose `.map_err(|e| ApiError::internal(format!("Failed to do X: {}", e)))` pattern. /// /// # Example /// /// ```ignore /// use crate::commands::error::ResultApiError; /// /// std::fs::write(&path, content) /// .map_api_err("Failed to write file", ApiError::internal)?; /// /// client.send().await /// .map_api_err("Failed to send email", ApiError::external_service)?; /// ``` pub trait ResultApiError { /// Maps the error into an `ApiError` using `constructor(format!("{prefix}: {err}"))`. fn map_api_err( self, prefix: &str, constructor: fn(String) -> ApiError, ) -> Result; } impl ResultApiError for Result { fn map_api_err( self, prefix: &str, constructor: fn(String) -> ApiError, ) -> Result { self.map_err(|e| constructor(format!("{}: {}", prefix, e))) } } impl std::fmt::Display for ApiError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "[{}] {}", self.code.as_str(), self.message) } } impl std::error::Error for ApiError {} /// Converts CoreError to ApiError, mapping error variants to appropriate codes. impl From for ApiError { fn from(err: CoreError) -> Self { match err { CoreError::NotFound { resource, id } => ApiError::with_details( ErrorCode::NotFound, format!("{} not found: {}", resource, id), ErrorDetails { field: None, resource: Some(resource.to_string()), resource_id: Some(id), }, ), CoreError::Validation { field, message } => ApiError::with_details( ErrorCode::ValidationError, message, ErrorDetails { field: Some(field.to_string()), resource: None, resource_id: None, }, ), CoreError::Database { message, .. } => ApiError::database(message), CoreError::Parse(message) => ApiError::parse(message), CoreError::BadRequest(message) => ApiError::bad_request(message), CoreError::Internal(message) => ApiError::internal(message), CoreError::Auth(message) => ApiError::auth(message), CoreError::Sync(message) => ApiError::external_service(message), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_not_found_error() { let err = ApiError::not_found("task", "123e4567-e89b-12d3-a456-426614174000"); assert_eq!(err.code, ErrorCode::NotFound); assert!(err.message.contains("task")); assert!(err.message.contains("123e4567-e89b-12d3-a456-426614174000")); let details = err.details.unwrap(); assert_eq!(details.resource, Some("task".to_string())); assert_eq!( details.resource_id, Some("123e4567-e89b-12d3-a456-426614174000".to_string()) ); } #[test] fn test_validation_error() { let err = ApiError::validation("description", "Description is required"); assert_eq!(err.code, ErrorCode::ValidationError); assert_eq!(err.message, "Description is required"); let details = err.details.unwrap(); assert_eq!(details.field, Some("description".to_string())); } #[test] fn test_core_error_conversion() { let core_err = CoreError::not_found("project", "abc123"); let api_err: ApiError = core_err.into(); assert_eq!(api_err.code, ErrorCode::NotFound); assert!(api_err.message.contains("project")); assert!(api_err.message.contains("abc123")); } #[test] fn test_error_display() { let err = ApiError::database("Connection failed"); assert_eq!(err.to_string(), "[DATABASE_ERROR] Connection failed"); } #[test] fn test_serialization() { let err = ApiError::validation("email", "Invalid email format"); let json = serde_json::to_string(&err).unwrap(); // Verify camelCase serialization assert!(json.contains("\"code\":\"VALIDATION_ERROR\"")); assert!(json.contains("\"message\":\"Invalid email format\"")); assert!(json.contains("\"field\":\"email\"")); } #[test] fn test_details_skip_none() { let err = ApiError::internal("Something went wrong"); let json = serde_json::to_string(&err).unwrap(); // Details should not be present when None assert!(!json.contains("details")); } }