files_sdk/
error.rs

1//! Error types for the Files.com SDK
2//!
3//! This module provides comprehensive error handling with contextual information
4//! to make debugging and error handling easier.
5
6use thiserror::Error;
7
8/// Errors that can occur when using the Files.com API
9///
10/// Each error variant includes contextual information to help with debugging
11/// and provide meaningful error messages to users.
12#[derive(Error, Debug)]
13pub enum FilesError {
14    /// HTTP request failed
15    #[error("HTTP request failed: {0}")]
16    Request(#[from] reqwest::Error),
17
18    /// Bad Request (400) - Invalid parameters or malformed request
19    #[error("Bad Request (400): {message}")]
20    BadRequest {
21        message: String,
22        /// Optional field that caused the error
23        field: Option<String>,
24    },
25
26    /// Authentication failed (401)
27    #[error("Authentication failed (401): {message}")]
28    AuthenticationFailed {
29        message: String,
30        /// The authentication method that failed
31        auth_type: Option<String>,
32    },
33
34    /// Forbidden (403) - Valid credentials but insufficient permissions
35    #[error("Forbidden (403): {message}")]
36    Forbidden {
37        message: String,
38        /// The resource that was forbidden
39        resource: Option<String>,
40    },
41
42    /// Not Found (404) - Resource does not exist
43    #[error("Not Found (404): {message}")]
44    NotFound {
45        message: String,
46        /// Type of resource (e.g., "file", "folder", "user")
47        resource_type: Option<String>,
48        /// Path or identifier of the resource
49        path: Option<String>,
50    },
51
52    /// Conflict (409) - Resource already exists or state conflict
53    #[error("Conflict (409): {message}")]
54    Conflict {
55        message: String,
56        /// The conflicting resource path or identifier
57        resource: Option<String>,
58    },
59
60    /// Precondition Failed (412) - Conditional request failed
61    #[error("Precondition Failed (412): {message}")]
62    PreconditionFailed {
63        message: String,
64        /// The condition that failed
65        condition: Option<String>,
66    },
67
68    /// Unprocessable Entity (422) - Validation error
69    #[error("Unprocessable Entity (422): {message}")]
70    UnprocessableEntity {
71        message: String,
72        /// Field that failed validation
73        field: Option<String>,
74        /// The invalid value provided
75        value: Option<String>,
76    },
77
78    /// Locked (423) - Resource is locked
79    #[error("Locked (423): {message}")]
80    Locked {
81        message: String,
82        /// The locked resource
83        resource: Option<String>,
84    },
85
86    /// Rate Limited (429) - Too many requests
87    #[error("Rate Limited (429): {message}")]
88    RateLimited {
89        message: String,
90        /// Seconds until retry is allowed
91        retry_after: Option<u64>,
92    },
93
94    /// Internal Server Error (500)
95    #[error("Internal Server Error (500): {message}")]
96    InternalServerError {
97        message: String,
98        /// Request ID for support purposes
99        request_id: Option<String>,
100    },
101
102    /// Service Unavailable (503)
103    #[error("Service Unavailable (503): {message}")]
104    ServiceUnavailable {
105        message: String,
106        /// Seconds until service might be available
107        retry_after: Option<u64>,
108    },
109
110    /// Generic API error with status code
111    #[error("API error ({code}): {message}")]
112    ApiError {
113        code: u16,
114        message: String,
115        /// The endpoint that returned the error
116        endpoint: Option<String>,
117    },
118
119    /// Configuration error
120    #[error("Configuration error: {0}")]
121    ConfigError(String),
122
123    /// JSON serialization/deserialization error
124    #[error("JSON error: {0}")]
125    JsonError(#[from] serde_json::Error),
126
127    /// JSON deserialization error with path information
128    #[error("JSON deserialization error at '{path}': {source}")]
129    JsonPathError {
130        path: String,
131        source: serde_json::Error,
132    },
133
134    /// I/O error (file operations)
135    #[error("I/O error: {0}")]
136    IoError(String),
137
138    /// URL parsing error
139    #[error("URL parse error: {0}")]
140    UrlParseError(#[from] url::ParseError),
141}
142
143impl FilesError {
144    /// Create a NotFound error with context
145    pub fn not_found(message: impl Into<String>) -> Self {
146        FilesError::NotFound {
147            message: message.into(),
148            resource_type: None,
149            path: None,
150        }
151    }
152
153    /// Create a NotFound error for a specific resource type
154    pub fn not_found_resource(
155        message: impl Into<String>,
156        resource_type: impl Into<String>,
157        path: impl Into<String>,
158    ) -> Self {
159        FilesError::NotFound {
160            message: message.into(),
161            resource_type: Some(resource_type.into()),
162            path: Some(path.into()),
163        }
164    }
165
166    /// Create a BadRequest error with optional field context
167    pub fn bad_request(message: impl Into<String>) -> Self {
168        FilesError::BadRequest {
169            message: message.into(),
170            field: None,
171        }
172    }
173
174    /// Create a BadRequest error with field context
175    pub fn bad_request_field(message: impl Into<String>, field: impl Into<String>) -> Self {
176        FilesError::BadRequest {
177            message: message.into(),
178            field: Some(field.into()),
179        }
180    }
181
182    /// Create an UnprocessableEntity error with validation context
183    pub fn validation_failed(
184        message: impl Into<String>,
185        field: impl Into<String>,
186        value: impl Into<String>,
187    ) -> Self {
188        FilesError::UnprocessableEntity {
189            message: message.into(),
190            field: Some(field.into()),
191            value: Some(value.into()),
192        }
193    }
194
195    /// Create a RateLimited error with retry-after context
196    pub fn rate_limited(message: impl Into<String>, retry_after: Option<u64>) -> Self {
197        FilesError::RateLimited {
198            message: message.into(),
199            retry_after,
200        }
201    }
202
203    /// Add resource context to a NotFound error
204    pub fn with_resource_type(mut self, resource_type: impl Into<String>) -> Self {
205        if let FilesError::NotFound {
206            resource_type: rt, ..
207        } = &mut self
208        {
209            *rt = Some(resource_type.into());
210        }
211        self
212    }
213
214    /// Add path context to a NotFound error
215    pub fn with_path(mut self, path: impl Into<String>) -> Self {
216        if let FilesError::NotFound { path: p, .. } = &mut self {
217            *p = Some(path.into());
218        }
219        self
220    }
221
222    /// Add field context to a BadRequest error
223    pub fn with_field(mut self, field: impl Into<String>) -> Self {
224        if let FilesError::BadRequest { field: f, .. } = &mut self {
225            *f = Some(field.into());
226        }
227        self
228    }
229
230    /// Extract the HTTP status code if this is an API error
231    pub fn status_code(&self) -> Option<u16> {
232        match self {
233            FilesError::BadRequest { .. } => Some(400),
234            FilesError::AuthenticationFailed { .. } => Some(401),
235            FilesError::Forbidden { .. } => Some(403),
236            FilesError::NotFound { .. } => Some(404),
237            FilesError::Conflict { .. } => Some(409),
238            FilesError::PreconditionFailed { .. } => Some(412),
239            FilesError::UnprocessableEntity { .. } => Some(422),
240            FilesError::Locked { .. } => Some(423),
241            FilesError::RateLimited { .. } => Some(429),
242            FilesError::InternalServerError { .. } => Some(500),
243            FilesError::ServiceUnavailable { .. } => Some(503),
244            FilesError::ApiError { code, .. } => Some(*code),
245            _ => None,
246        }
247    }
248
249    /// Check if this error is retryable
250    pub fn is_retryable(&self) -> bool {
251        matches!(
252            self,
253            FilesError::RateLimited { .. }
254                | FilesError::ServiceUnavailable { .. }
255                | FilesError::InternalServerError { .. }
256        )
257    }
258
259    /// Get retry-after duration if available
260    pub fn retry_after(&self) -> Option<u64> {
261        match self {
262            FilesError::RateLimited { retry_after, .. } => *retry_after,
263            FilesError::ServiceUnavailable { retry_after, .. } => *retry_after,
264            _ => None,
265        }
266    }
267}
268
269/// Result type for Files.com operations
270pub type Result<T> = std::result::Result<T, FilesError>;
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_not_found_with_context() {
278        let error = FilesError::not_found_resource("File not found", "file", "/path/to/file.txt");
279        assert!(matches!(error, FilesError::NotFound { .. }));
280        assert!(error.to_string().contains("Not Found"));
281    }
282
283    #[test]
284    fn test_bad_request_with_field() {
285        let error = FilesError::bad_request_field("Invalid value", "username");
286        if let FilesError::BadRequest { field, .. } = error {
287            assert_eq!(field, Some("username".to_string()));
288        } else {
289            panic!("Expected BadRequest error");
290        }
291    }
292
293    #[test]
294    fn test_validation_failed() {
295        let error = FilesError::validation_failed("Invalid email format", "email", "not-an-email");
296        if let FilesError::UnprocessableEntity { field, value, .. } = error {
297            assert_eq!(field, Some("email".to_string()));
298            assert_eq!(value, Some("not-an-email".to_string()));
299        } else {
300            panic!("Expected UnprocessableEntity error");
301        }
302    }
303
304    #[test]
305    fn test_rate_limited_with_retry() {
306        let error = FilesError::rate_limited("Too many requests", Some(60));
307        assert_eq!(error.retry_after(), Some(60));
308        assert!(error.is_retryable());
309    }
310
311    #[test]
312    fn test_status_code_extraction() {
313        assert_eq!(FilesError::not_found("test").status_code(), Some(404));
314        assert_eq!(FilesError::bad_request("test").status_code(), Some(400));
315        assert_eq!(
316            FilesError::rate_limited("test", None).status_code(),
317            Some(429)
318        );
319    }
320
321    #[test]
322    fn test_is_retryable() {
323        assert!(FilesError::rate_limited("test", None).is_retryable());
324        assert!(
325            FilesError::InternalServerError {
326                message: "test".to_string(),
327                request_id: None
328            }
329            .is_retryable()
330        );
331        assert!(!FilesError::not_found("test").is_retryable());
332    }
333
334    #[test]
335    fn test_builder_pattern() {
336        let error = FilesError::not_found("File not found")
337            .with_resource_type("file")
338            .with_path("/test.txt");
339
340        if let FilesError::NotFound {
341            resource_type,
342            path,
343            ..
344        } = error
345        {
346            assert_eq!(resource_type, Some("file".to_string()));
347            assert_eq!(path, Some("/test.txt".to_string()));
348        } else {
349            panic!("Expected NotFound error");
350        }
351    }
352}