Skip to main content

context7_cli/
errors.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Error types for the context7-cli library.
3//!
4//! [`Context7Error`] is used for all structured errors within the library.
5//! Binary code uses [`anyhow::Result`] for flexible propagation.
6use thiserror::Error;
7
8/// Structured errors for the Context7 API client.
9#[derive(Debug, Error)]
10pub enum Context7Error {
11    /// All available API keys have been exhausted after the given number of attempts.
12    #[error("No valid API key available after {attempts} attempts")]
13    RetriesExhausted {
14        /// Number of attempts made before exhaustion.
15        attempts: u32,
16    },
17
18    /// All keys failed due to authentication errors (401/403).
19    #[error("All API keys failed due to authentication errors")]
20    NoValidApiKey,
21
22    /// The API returned an unexpected HTTP status code.
23    #[error("Invalid API response: status {status}")]
24    InvalidResponse {
25        /// HTTP status code returned by the API.
26        status: u16,
27    },
28
29    /// The API returned HTTP 400 with an error message.
30    #[error("API returned error 400: {message}")]
31    ApiReturned400 {
32        /// Error message returned by the API.
33        message: String,
34    },
35
36    /// The requested library was not found (HTTP 404).
37    #[error("Library not found: {library_id}")]
38    LibraryNotFound {
39        /// Library identifier that was not found.
40        library_id: String,
41    },
42
43    /// A keys operation failed (e.g., invalid index, no keys stored).
44    /// The caller already printed a user-friendly message; this signals exit code 1.
45    #[error("")]
46    KeysOperationFailed,
47}
48
49impl Context7Error {
50    /// Maps each error variant to a BSD-style exit code (sysexits.h).
51    ///
52    /// | Code | Constant       | Meaning                          |
53    /// |------|----------------|----------------------------------|
54    /// |   1  | generic        | Unspecified runtime error         |
55    /// |  65  | EX_DATAERR     | Invalid input data               |
56    /// |  66  | EX_NOINPUT     | Requested resource not found     |
57    /// |  69  | EX_UNAVAILABLE | Service unavailable after retry  |
58    /// |  74  | EX_IOERR       | I/O or network error             |
59    /// |  77  | EX_NOPERM      | Permission / authentication denied|
60    #[must_use]
61    pub fn exit_code(&self) -> i32 {
62        match self {
63            Self::RetriesExhausted { .. } => 69,
64            Self::NoValidApiKey => 77,
65            Self::InvalidResponse { .. } => 74,
66            Self::ApiReturned400 { .. } => 65,
67            Self::LibraryNotFound { .. } => 66,
68            Self::KeysOperationFailed => 1,
69        }
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn test_error_no_api_keys_display() {
79        let err = Context7Error::NoValidApiKey;
80        let message = err.to_string();
81        assert!(
82            !message.is_empty(),
83            "NoValidApiKey must have non-empty message"
84        );
85        assert!(
86            message.to_lowercase().contains("key")
87                || message.to_lowercase().contains("api")
88                || message.to_lowercase().contains("auth"),
89            "Message must mention key/api/auth, got: {message}"
90        );
91    }
92
93    #[test]
94    fn test_error_retries_exhausted_contains_attempts_count() {
95        let err = Context7Error::RetriesExhausted { attempts: 3 };
96        let message = err.to_string();
97        assert!(
98            message.contains('3'),
99            "Message must contain number of attempts (3), got: {message}"
100        );
101    }
102
103    #[test]
104    fn test_error_invalid_response_contains_status() {
105        let err = Context7Error::InvalidResponse { status: 500 };
106        let message = err.to_string();
107        assert!(
108            message.contains("500"),
109            "Message must contain status code, got: {message}"
110        );
111    }
112
113    #[test]
114    fn test_error_api_400_contains_error_text() {
115        let err = Context7Error::ApiReturned400 {
116            message: "Invalid parameter".to_string(),
117        };
118        let message = err.to_string();
119        assert!(
120            message.contains("Invalid parameter"),
121            "Message must contain error text, got: {message}"
122        );
123    }
124
125    #[test]
126    fn test_result_alias_propagates_context7_error() {
127        fn fail() -> Result<(), Context7Error> {
128            Err(Context7Error::NoValidApiKey)
129        }
130        let result: Result<(), Context7Error> = fail();
131        assert!(result.is_err(), "Result must be Err");
132        let err = result.unwrap_err();
133        assert!(matches!(err, Context7Error::NoValidApiKey));
134    }
135}