1use thiserror::Error;
4
5#[derive(Debug, Error)]
10pub enum Error {
11 #[error(transparent)]
13 Api(#[from] ApiError),
14
15 #[error("HTTP error: {0}")]
17 Http(#[from] reqwest::Error),
18
19 #[error("JSON error: {0}")]
21 Json(#[from] serde_json::Error),
22
23 #[error("Internal error: {0}")]
25 Internal(String),
26}
27
28pub type Result<T> = std::result::Result<T, Error>;
30
31#[derive(Debug, Clone, PartialEq, Eq, Error)]
35pub enum ApiError {
36 #[error("HTTP error {status}: {message}")]
38 Http {
39 status: u16,
41 message: String,
43 },
44
45 #[error("Authentication failed: {message}")]
47 Auth {
48 message: String,
50 },
51
52 #[error("{}", match .retry_after {
54 Some(secs) => format!("Rate limited, retry after {} seconds", secs),
55 None => "Rate limited".to_string(),
56 })]
57 RateLimit {
58 retry_after: Option<u64>,
60 },
61
62 #[error("{resource} not found: {id}. It may have been deleted. Run 'td sync' to refresh your cache.")]
64 NotFound {
65 resource: String,
67 id: String,
69 },
70
71 #[error("{}", match .field {
73 Some(f) => format!("Validation error on {}: {}", f, .message),
74 None => format!("Validation error: {}", .message),
75 })]
76 Validation {
77 field: Option<String>,
79 message: String,
81 },
82
83 #[error("Network error: {message}")]
85 Network {
86 message: String,
88 },
89}
90
91impl Error {
92 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 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 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 } else {
126 2 }
128 }
129 Error::Json(_) => 2, Error::Internal(_) => 2, }
132 }
133
134 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 pub fn is_retryable(&self) -> bool {
146 matches!(self, ApiError::RateLimit { .. } | ApiError::Network { .. })
147 }
148
149 pub fn exit_code(&self) -> i32 {
151 match self {
152 ApiError::Network { .. } => 3,
153 ApiError::RateLimit { .. } => 4,
154 _ => 2,
155 }
156 }
157
158 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let error = ApiError::Http {
466 status: 500,
467 message: "Server error".to_string(),
468 };
469 assert_eq!(error.exit_code(), 2);
470 }
471
472 #[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 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 #[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 let error = Error::Json(serde_json::from_str::<serde_json::Value>("bad").unwrap_err());
685 assert!(!error.is_invalid_sync_token());
686 }
687}