Skip to main content

todoist_api_rs/
error.rs

1//! Error types for the Todoist API client.
2
3use thiserror::Error;
4
5/// Top-level error type for the Todoist API client.
6///
7/// This wraps all possible errors that can occur when using the client,
8/// including API-specific errors, network errors, and serialization errors.
9#[derive(Debug, Error)]
10pub enum Error {
11    /// An API-specific error (auth failure, rate limiting, validation, etc.)
12    #[error(transparent)]
13    Api(#[from] ApiError),
14
15    /// An HTTP request/response error from reqwest.
16    #[error("HTTP error: {0}")]
17    Http(#[from] reqwest::Error),
18
19    /// JSON serialization/deserialization error.
20    #[error("JSON error: {0}")]
21    Json(#[from] serde_json::Error),
22
23    /// Internal/unexpected error.
24    #[error("Internal error: {0}")]
25    Internal(String),
26}
27
28/// Result type alias using our Error type.
29pub type Result<T> = std::result::Result<T, Error>;
30
31/// API-specific errors from the Todoist API.
32///
33/// These represent errors returned by the API itself (not transport-level errors).
34#[derive(Debug, Clone, PartialEq, Eq, Error)]
35pub enum ApiError {
36    /// HTTP-level error with status code (for unexpected status codes).
37    #[error("HTTP error {status}: {message}")]
38    Http {
39        /// HTTP status code
40        status: u16,
41        /// Error message from the response
42        message: String,
43    },
44
45    /// Authentication failure (401 Unauthorized, 403 Forbidden).
46    #[error("Authentication failed: {message}")]
47    Auth {
48        /// Descriptive error message
49        message: String,
50    },
51
52    /// Rate limit exceeded (429 Too Many Requests).
53    #[error("{}", match .retry_after {
54        Some(secs) => format!("Rate limited, retry after {} seconds", secs),
55        None => "Rate limited".to_string(),
56    })]
57    RateLimit {
58        /// Seconds to wait before retrying (from Retry-After header)
59        retry_after: Option<u64>,
60    },
61
62    /// Resource not found (404 Not Found).
63    #[error("{resource} not found: {id}. It may have been deleted. Run 'td sync' to refresh your cache.")]
64    NotFound {
65        /// Type of resource (e.g., "task", "project")
66        resource: String,
67        /// ID of the resource that was not found
68        id: String,
69    },
70
71    /// API validation error (400 Bad Request with validation details).
72    #[error("{}", match .field {
73        Some(f) => format!("Validation error on {}: {}", f, .message),
74        None => format!("Validation error: {}", .message),
75    })]
76    Validation {
77        /// Field that failed validation (if known)
78        field: Option<String>,
79        /// Validation error message
80        message: String,
81    },
82
83    /// Network/connection error.
84    #[error("Network error: {message}")]
85    Network {
86        /// Descriptive error message
87        message: String,
88    },
89}
90
91impl Error {
92    /// Returns true if this error is potentially retryable.
93    pub fn is_retryable(&self) -> bool {
94        match self {
95            Error::Api(api_err) => api_err.is_retryable(),
96            Error::Http(req_err) => req_err.is_timeout() || req_err.is_connect(),
97            Error::Json(_) => false,
98            Error::Internal(_) => false,
99        }
100    }
101
102    /// Returns true if this error indicates an invalid sync token.
103    ///
104    /// This is used to detect when the API rejects a sync token, which
105    /// means the client should fall back to a full sync with `sync_token='*'`.
106    pub fn is_invalid_sync_token(&self) -> bool {
107        match self {
108            Error::Api(api_err) => api_err.is_invalid_sync_token(),
109            _ => false,
110        }
111    }
112
113    /// Returns the appropriate CLI exit code for this error.
114    ///
115    /// Exit codes follow the spec:
116    /// - 2: API error (auth failure, not found, validation error)
117    /// - 3: Network error (connection failed, timeout)
118    /// - 4: Rate limited (with retry-after information)
119    pub fn exit_code(&self) -> i32 {
120        match self {
121            Error::Api(api_err) => api_err.exit_code(),
122            Error::Http(req_err) => {
123                if req_err.is_timeout() || req_err.is_connect() {
124                    3 // Network error
125                } else {
126                    2 // API error
127                }
128            }
129            Error::Json(_) => 2,     // API error (bad response)
130            Error::Internal(_) => 2, // Treat as API error
131        }
132    }
133
134    /// Returns the underlying API error if this is an API error variant.
135    pub fn as_api_error(&self) -> Option<&ApiError> {
136        match self {
137            Error::Api(api_err) => Some(api_err),
138            _ => None,
139        }
140    }
141}
142
143impl ApiError {
144    /// Returns true if this error is potentially retryable.
145    pub fn is_retryable(&self) -> bool {
146        matches!(self, ApiError::RateLimit { .. } | ApiError::Network { .. })
147    }
148
149    /// Returns the appropriate CLI exit code for this error.
150    pub fn exit_code(&self) -> i32 {
151        match self {
152            ApiError::Network { .. } => 3,
153            ApiError::RateLimit { .. } => 4,
154            _ => 2,
155        }
156    }
157
158    /// Returns true if this error indicates an invalid sync token.
159    ///
160    /// The Todoist API returns a 400 status code with a validation error when
161    /// a sync token is invalid or expired. This method checks for common error
162    /// messages that indicate sync token issues.
163    pub fn is_invalid_sync_token(&self) -> bool {
164        match self {
165            ApiError::Validation { message, .. } => {
166                let msg_lower = message.to_lowercase();
167                msg_lower.contains("sync_token")
168                    || msg_lower.contains("sync token")
169                    || msg_lower.contains("invalid token")
170                    || msg_lower.contains("token invalid")
171            }
172            _ => false,
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_api_error_http_variant_exists() {
183        // ApiError should have an Http variant for HTTP-level errors
184        let status = 500;
185        let message = "Internal Server Error".to_string();
186        let error = ApiError::Http { status, message };
187
188        match error {
189            ApiError::Http {
190                status: s,
191                message: m,
192            } => {
193                assert_eq!(s, 500);
194                assert_eq!(m, "Internal Server Error");
195            }
196            _ => panic!("Expected Http variant"),
197        }
198    }
199
200    #[test]
201    fn test_api_error_auth_variant_exists() {
202        // ApiError should have an Auth variant for authentication failures
203        let error = ApiError::Auth {
204            message: "Invalid token".to_string(),
205        };
206
207        match error {
208            ApiError::Auth { message } => {
209                assert_eq!(message, "Invalid token");
210            }
211            _ => panic!("Expected Auth variant"),
212        }
213    }
214
215    #[test]
216    fn test_api_error_rate_limit_variant_exists() {
217        // ApiError should have a RateLimit variant with optional retry_after
218        let error = ApiError::RateLimit {
219            retry_after: Some(30),
220        };
221
222        match error {
223            ApiError::RateLimit { retry_after } => {
224                assert_eq!(retry_after, Some(30));
225            }
226            _ => panic!("Expected RateLimit variant"),
227        }
228    }
229
230    #[test]
231    fn test_api_error_not_found_variant_exists() {
232        // ApiError should have a NotFound variant for 404 responses
233        let error = ApiError::NotFound {
234            resource: "task".to_string(),
235            id: "abc123".to_string(),
236        };
237
238        match error {
239            ApiError::NotFound { resource, id } => {
240                assert_eq!(resource, "task");
241                assert_eq!(id, "abc123");
242            }
243            _ => panic!("Expected NotFound variant"),
244        }
245    }
246
247    #[test]
248    fn test_api_error_validation_variant_exists() {
249        // ApiError should have a Validation variant for API validation errors
250        let error = ApiError::Validation {
251            field: Some("due_date".to_string()),
252            message: "Invalid date format".to_string(),
253        };
254
255        match error {
256            ApiError::Validation { field, message } => {
257                assert_eq!(field, Some("due_date".to_string()));
258                assert_eq!(message, "Invalid date format");
259            }
260            _ => panic!("Expected Validation variant"),
261        }
262    }
263
264    #[test]
265    fn test_api_error_network_variant_exists() {
266        // ApiError should have a Network variant for connection issues
267        let error = ApiError::Network {
268            message: "Connection refused".to_string(),
269        };
270
271        match error {
272            ApiError::Network { message } => {
273                assert_eq!(message, "Connection refused");
274            }
275            _ => panic!("Expected Network variant"),
276        }
277    }
278
279    #[test]
280    fn test_api_error_implements_std_error() {
281        // ApiError should implement std::error::Error
282        let error: Box<dyn std::error::Error> = Box::new(ApiError::Network {
283            message: "timeout".to_string(),
284        });
285        assert!(error.to_string().contains("timeout"));
286    }
287
288    #[test]
289    fn test_api_error_display_http() {
290        let error = ApiError::Http {
291            status: 503,
292            message: "Service Unavailable".to_string(),
293        };
294        let display = error.to_string();
295        assert!(display.contains("503") || display.contains("Service Unavailable"));
296    }
297
298    #[test]
299    fn test_api_error_display_auth() {
300        let error = ApiError::Auth {
301            message: "Token expired".to_string(),
302        };
303        let display = error.to_string();
304        assert!(display.to_lowercase().contains("auth") || display.contains("Token expired"));
305    }
306
307    #[test]
308    fn test_api_error_display_rate_limit() {
309        let error = ApiError::RateLimit {
310            retry_after: Some(60),
311        };
312        let display = error.to_string();
313        assert!(display.to_lowercase().contains("rate") || display.contains("60"));
314    }
315
316    #[test]
317    fn test_api_error_display_not_found() {
318        let error = ApiError::NotFound {
319            resource: "project".to_string(),
320            id: "xyz789".to_string(),
321        };
322        let display = error.to_string();
323        assert!(
324            display.contains("project")
325                || display.contains("xyz789")
326                || display.to_lowercase().contains("not found")
327        );
328    }
329
330    #[test]
331    fn test_api_error_not_found_includes_sync_suggestion() {
332        let error = ApiError::NotFound {
333            resource: "task".to_string(),
334            id: "abc123".to_string(),
335        };
336        let display = error.to_string();
337        assert!(
338            display.contains("td sync"),
339            "NotFound error should include suggestion to run 'td sync': {}",
340            display
341        );
342        assert!(
343            display.contains("may have been deleted"),
344            "NotFound error should mention item may have been deleted: {}",
345            display
346        );
347    }
348
349    #[test]
350    fn test_api_error_display_validation() {
351        let error = ApiError::Validation {
352            field: Some("priority".to_string()),
353            message: "Must be between 1 and 4".to_string(),
354        };
355        let display = error.to_string();
356        assert!(display.contains("priority") || display.contains("Must be between 1 and 4"));
357    }
358
359    #[test]
360    fn test_api_error_display_network() {
361        let error = ApiError::Network {
362            message: "DNS lookup failed".to_string(),
363        };
364        let display = error.to_string();
365        assert!(
366            display.contains("DNS lookup failed") || display.to_lowercase().contains("network")
367        );
368    }
369
370    #[test]
371    fn test_api_error_is_retryable_rate_limit() {
372        // Rate limit errors should be retryable
373        let error = ApiError::RateLimit {
374            retry_after: Some(5),
375        };
376        assert!(error.is_retryable());
377    }
378
379    #[test]
380    fn test_api_error_is_retryable_network() {
381        // Network errors should be retryable
382        let error = ApiError::Network {
383            message: "Connection reset".to_string(),
384        };
385        assert!(error.is_retryable());
386    }
387
388    #[test]
389    fn test_api_error_is_not_retryable_auth() {
390        // Auth errors should not be retryable
391        let error = ApiError::Auth {
392            message: "Invalid credentials".to_string(),
393        };
394        assert!(!error.is_retryable());
395    }
396
397    #[test]
398    fn test_api_error_is_not_retryable_not_found() {
399        // NotFound errors should not be retryable
400        let error = ApiError::NotFound {
401            resource: "task".to_string(),
402            id: "123".to_string(),
403        };
404        assert!(!error.is_retryable());
405    }
406
407    #[test]
408    fn test_api_error_is_not_retryable_validation() {
409        // Validation errors should not be retryable
410        let error = ApiError::Validation {
411            field: None,
412            message: "Invalid request".to_string(),
413        };
414        assert!(!error.is_retryable());
415    }
416
417    #[test]
418    fn test_api_error_exit_code_auth() {
419        // Auth errors should map to exit code 2
420        let error = ApiError::Auth {
421            message: "Unauthorized".to_string(),
422        };
423        assert_eq!(error.exit_code(), 2);
424    }
425
426    #[test]
427    fn test_api_error_exit_code_not_found() {
428        // NotFound errors should map to exit code 2 (API error)
429        let error = ApiError::NotFound {
430            resource: "task".to_string(),
431            id: "abc".to_string(),
432        };
433        assert_eq!(error.exit_code(), 2);
434    }
435
436    #[test]
437    fn test_api_error_exit_code_validation() {
438        // Validation errors should map to exit code 2 (API error)
439        let error = ApiError::Validation {
440            field: Some("content".to_string()),
441            message: "Required".to_string(),
442        };
443        assert_eq!(error.exit_code(), 2);
444    }
445
446    #[test]
447    fn test_api_error_exit_code_network() {
448        // Network errors should map to exit code 3
449        let error = ApiError::Network {
450            message: "Timeout".to_string(),
451        };
452        assert_eq!(error.exit_code(), 3);
453    }
454
455    #[test]
456    fn test_api_error_exit_code_rate_limit() {
457        // Rate limit errors should map to exit code 4
458        let error = ApiError::RateLimit { retry_after: None };
459        assert_eq!(error.exit_code(), 4);
460    }
461
462    #[test]
463    fn test_api_error_exit_code_http() {
464        // Generic HTTP errors should map to exit code 2 (API error)
465        let error = ApiError::Http {
466            status: 500,
467            message: "Server error".to_string(),
468        };
469        assert_eq!(error.exit_code(), 2);
470    }
471
472    // Tests for the top-level Error type
473
474    #[test]
475    fn test_error_from_api_error() {
476        let api_error = ApiError::Auth {
477            message: "test".to_string(),
478        };
479        let error: Error = api_error.into();
480        assert!(matches!(error, Error::Api(_)));
481    }
482
483    #[test]
484    fn test_error_api_variant_is_retryable() {
485        let error: Error = ApiError::RateLimit {
486            retry_after: Some(5),
487        }
488        .into();
489        assert!(error.is_retryable());
490    }
491
492    #[test]
493    fn test_error_api_variant_not_retryable() {
494        let error: Error = ApiError::Auth {
495            message: "bad token".to_string(),
496        }
497        .into();
498        assert!(!error.is_retryable());
499    }
500
501    #[test]
502    fn test_error_json_not_retryable() {
503        // Create a JSON error by trying to parse invalid JSON
504        let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
505        let error: Error = json_err.into();
506        assert!(!error.is_retryable());
507    }
508
509    #[test]
510    fn test_error_internal_not_retryable() {
511        let error = Error::Internal("something went wrong".to_string());
512        assert!(!error.is_retryable());
513    }
514
515    #[test]
516    fn test_error_exit_code_api() {
517        let error: Error = ApiError::RateLimit { retry_after: None }.into();
518        assert_eq!(error.exit_code(), 4);
519
520        let error: Error = ApiError::Network {
521            message: "timeout".to_string(),
522        }
523        .into();
524        assert_eq!(error.exit_code(), 3);
525
526        let error: Error = ApiError::Auth {
527            message: "bad".to_string(),
528        }
529        .into();
530        assert_eq!(error.exit_code(), 2);
531    }
532
533    #[test]
534    fn test_error_exit_code_json() {
535        let json_err = serde_json::from_str::<serde_json::Value>("bad").unwrap_err();
536        let error: Error = json_err.into();
537        assert_eq!(error.exit_code(), 2);
538    }
539
540    #[test]
541    fn test_error_exit_code_internal() {
542        let error = Error::Internal("panic".to_string());
543        assert_eq!(error.exit_code(), 2);
544    }
545
546    #[test]
547    fn test_error_as_api_error() {
548        let api_error = ApiError::NotFound {
549            resource: "task".to_string(),
550            id: "123".to_string(),
551        };
552        let error: Error = api_error.clone().into();
553        assert_eq!(error.as_api_error(), Some(&api_error));
554    }
555
556    #[test]
557    fn test_error_as_api_error_none() {
558        let error = Error::Internal("test".to_string());
559        assert_eq!(error.as_api_error(), None);
560    }
561
562    #[test]
563    fn test_error_display_api() {
564        let error: Error = ApiError::Auth {
565            message: "Invalid token".to_string(),
566        }
567        .into();
568        let display = error.to_string();
569        assert!(display.contains("Invalid token"));
570    }
571
572    #[test]
573    fn test_error_display_internal() {
574        let error = Error::Internal("unexpected state".to_string());
575        let display = error.to_string();
576        assert!(display.contains("unexpected state"));
577    }
578
579    #[test]
580    fn test_error_implements_std_error() {
581        let error: Box<dyn std::error::Error> = Box::new(Error::Internal("test".to_string()));
582        assert!(error.to_string().contains("test"));
583    }
584
585    #[test]
586    fn test_result_type_alias() {
587        fn returns_result() -> Result<i32> {
588            Ok(42)
589        }
590        assert_eq!(returns_result().unwrap(), 42);
591    }
592
593    #[test]
594    fn test_result_type_alias_error() {
595        fn returns_error() -> Result<i32> {
596            Err(Error::Internal("failed".to_string()))
597        }
598        assert!(returns_error().is_err());
599    }
600
601    // Tests for is_invalid_sync_token
602
603    #[test]
604    fn test_api_error_is_invalid_sync_token_with_sync_token_message() {
605        let error = ApiError::Validation {
606            field: None,
607            message: "Invalid sync_token".to_string(),
608        };
609        assert!(error.is_invalid_sync_token());
610    }
611
612    #[test]
613    fn test_api_error_is_invalid_sync_token_with_sync_token_spaces() {
614        let error = ApiError::Validation {
615            field: None,
616            message: "Invalid sync token provided".to_string(),
617        };
618        assert!(error.is_invalid_sync_token());
619    }
620
621    #[test]
622    fn test_api_error_is_invalid_sync_token_with_token_invalid() {
623        let error = ApiError::Validation {
624            field: None,
625            message: "Token invalid or expired".to_string(),
626        };
627        assert!(error.is_invalid_sync_token());
628    }
629
630    #[test]
631    fn test_api_error_is_invalid_sync_token_case_insensitive() {
632        let error = ApiError::Validation {
633            field: None,
634            message: "SYNC_TOKEN is not valid".to_string(),
635        };
636        assert!(error.is_invalid_sync_token());
637    }
638
639    #[test]
640    fn test_api_error_is_invalid_sync_token_false_for_other_validation() {
641        let error = ApiError::Validation {
642            field: Some("content".to_string()),
643            message: "Content is required".to_string(),
644        };
645        assert!(!error.is_invalid_sync_token());
646    }
647
648    #[test]
649    fn test_api_error_is_invalid_sync_token_false_for_auth() {
650        let error = ApiError::Auth {
651            message: "Token expired".to_string(),
652        };
653        assert!(!error.is_invalid_sync_token());
654    }
655
656    #[test]
657    fn test_api_error_is_invalid_sync_token_false_for_http() {
658        let error = ApiError::Http {
659            status: 500,
660            message: "Server error".to_string(),
661        };
662        assert!(!error.is_invalid_sync_token());
663    }
664
665    #[test]
666    fn test_error_is_invalid_sync_token_delegates_to_api_error() {
667        let error: Error = ApiError::Validation {
668            field: None,
669            message: "Invalid sync_token".to_string(),
670        }
671        .into();
672        assert!(error.is_invalid_sync_token());
673    }
674
675    #[test]
676    fn test_error_is_invalid_sync_token_false_for_non_api() {
677        let error = Error::Internal("test".to_string());
678        assert!(!error.is_invalid_sync_token());
679    }
680
681    #[test]
682    fn test_error_is_invalid_sync_token_false_for_http_error() {
683        // HTTP errors from reqwest are not sync token errors
684        let error = Error::Json(serde_json::from_str::<serde_json::Value>("bad").unwrap_err());
685        assert!(!error.is_invalid_sync_token());
686    }
687}