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    /// I/O error (file operations)
128    #[error("I/O error: {0}")]
129    IoError(String),
130
131    /// URL parsing error
132    #[error("URL parse error: {0}")]
133    UrlParseError(#[from] url::ParseError),
134}
135
136impl FilesError {
137    /// Create a NotFound error with context
138    pub fn not_found(message: impl Into<String>) -> Self {
139        FilesError::NotFound {
140            message: message.into(),
141            resource_type: None,
142            path: None,
143        }
144    }
145
146    /// Create a NotFound error for a specific resource type
147    pub fn not_found_resource(
148        message: impl Into<String>,
149        resource_type: impl Into<String>,
150        path: impl Into<String>,
151    ) -> Self {
152        FilesError::NotFound {
153            message: message.into(),
154            resource_type: Some(resource_type.into()),
155            path: Some(path.into()),
156        }
157    }
158
159    /// Create a BadRequest error with optional field context
160    pub fn bad_request(message: impl Into<String>) -> Self {
161        FilesError::BadRequest {
162            message: message.into(),
163            field: None,
164        }
165    }
166
167    /// Create a BadRequest error with field context
168    pub fn bad_request_field(message: impl Into<String>, field: impl Into<String>) -> Self {
169        FilesError::BadRequest {
170            message: message.into(),
171            field: Some(field.into()),
172        }
173    }
174
175    /// Create an UnprocessableEntity error with validation context
176    pub fn validation_failed(
177        message: impl Into<String>,
178        field: impl Into<String>,
179        value: impl Into<String>,
180    ) -> Self {
181        FilesError::UnprocessableEntity {
182            message: message.into(),
183            field: Some(field.into()),
184            value: Some(value.into()),
185        }
186    }
187
188    /// Create a RateLimited error with retry-after context
189    pub fn rate_limited(message: impl Into<String>, retry_after: Option<u64>) -> Self {
190        FilesError::RateLimited {
191            message: message.into(),
192            retry_after,
193        }
194    }
195
196    /// Add resource context to a NotFound error
197    pub fn with_resource_type(mut self, resource_type: impl Into<String>) -> Self {
198        if let FilesError::NotFound {
199            resource_type: rt, ..
200        } = &mut self
201        {
202            *rt = Some(resource_type.into());
203        }
204        self
205    }
206
207    /// Add path context to a NotFound error
208    pub fn with_path(mut self, path: impl Into<String>) -> Self {
209        if let FilesError::NotFound { path: p, .. } = &mut self {
210            *p = Some(path.into());
211        }
212        self
213    }
214
215    /// Add field context to a BadRequest error
216    pub fn with_field(mut self, field: impl Into<String>) -> Self {
217        if let FilesError::BadRequest { field: f, .. } = &mut self {
218            *f = Some(field.into());
219        }
220        self
221    }
222
223    /// Extract the HTTP status code if this is an API error
224    pub fn status_code(&self) -> Option<u16> {
225        match self {
226            FilesError::BadRequest { .. } => Some(400),
227            FilesError::AuthenticationFailed { .. } => Some(401),
228            FilesError::Forbidden { .. } => Some(403),
229            FilesError::NotFound { .. } => Some(404),
230            FilesError::Conflict { .. } => Some(409),
231            FilesError::PreconditionFailed { .. } => Some(412),
232            FilesError::UnprocessableEntity { .. } => Some(422),
233            FilesError::Locked { .. } => Some(423),
234            FilesError::RateLimited { .. } => Some(429),
235            FilesError::InternalServerError { .. } => Some(500),
236            FilesError::ServiceUnavailable { .. } => Some(503),
237            FilesError::ApiError { code, .. } => Some(*code),
238            _ => None,
239        }
240    }
241
242    /// Check if this error is retryable
243    pub fn is_retryable(&self) -> bool {
244        matches!(
245            self,
246            FilesError::RateLimited { .. }
247                | FilesError::ServiceUnavailable { .. }
248                | FilesError::InternalServerError { .. }
249        )
250    }
251
252    /// Get retry-after duration if available
253    pub fn retry_after(&self) -> Option<u64> {
254        match self {
255            FilesError::RateLimited { retry_after, .. } => *retry_after,
256            FilesError::ServiceUnavailable { retry_after, .. } => *retry_after,
257            _ => None,
258        }
259    }
260}
261
262/// Result type for Files.com operations
263pub type Result<T> = std::result::Result<T, FilesError>;
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_not_found_with_context() {
271        let error = FilesError::not_found_resource("File not found", "file", "/path/to/file.txt");
272        assert!(matches!(error, FilesError::NotFound { .. }));
273        assert!(error.to_string().contains("Not Found"));
274    }
275
276    #[test]
277    fn test_bad_request_with_field() {
278        let error = FilesError::bad_request_field("Invalid value", "username");
279        if let FilesError::BadRequest { field, .. } = error {
280            assert_eq!(field, Some("username".to_string()));
281        } else {
282            panic!("Expected BadRequest error");
283        }
284    }
285
286    #[test]
287    fn test_validation_failed() {
288        let error = FilesError::validation_failed("Invalid email format", "email", "not-an-email");
289        if let FilesError::UnprocessableEntity { field, value, .. } = error {
290            assert_eq!(field, Some("email".to_string()));
291            assert_eq!(value, Some("not-an-email".to_string()));
292        } else {
293            panic!("Expected UnprocessableEntity error");
294        }
295    }
296
297    #[test]
298    fn test_rate_limited_with_retry() {
299        let error = FilesError::rate_limited("Too many requests", Some(60));
300        assert_eq!(error.retry_after(), Some(60));
301        assert!(error.is_retryable());
302    }
303
304    #[test]
305    fn test_status_code_extraction() {
306        assert_eq!(FilesError::not_found("test").status_code(), Some(404));
307        assert_eq!(FilesError::bad_request("test").status_code(), Some(400));
308        assert_eq!(
309            FilesError::rate_limited("test", None).status_code(),
310            Some(429)
311        );
312    }
313
314    #[test]
315    fn test_is_retryable() {
316        assert!(FilesError::rate_limited("test", None).is_retryable());
317        assert!(
318            FilesError::InternalServerError {
319                message: "test".to_string(),
320                request_id: None
321            }
322            .is_retryable()
323        );
324        assert!(!FilesError::not_found("test").is_retryable());
325    }
326
327    #[test]
328    fn test_builder_pattern() {
329        let error = FilesError::not_found("File not found")
330            .with_resource_type("file")
331            .with_path("/test.txt");
332
333        if let FilesError::NotFound {
334            resource_type,
335            path,
336            ..
337        } = error
338        {
339            assert_eq!(resource_type, Some("file".to_string()));
340            assert_eq!(path, Some("/test.txt".to_string()));
341        } else {
342            panic!("Expected NotFound error");
343        }
344    }
345}