Skip to main content

atlassian_cli_api/
error.rs

1use thiserror::Error;
2
3#[derive(Error, Debug)]
4pub enum ApiError {
5    #[error("HTTP request failed: {0}")]
6    RequestFailed(#[from] reqwest::Error),
7
8    #[error("Rate limit exceeded. Retry after {retry_after} seconds")]
9    RateLimitExceeded { retry_after: u64 },
10
11    #[error("Authentication failed: {message}")]
12    AuthenticationFailed { message: String },
13
14    #[error("Access forbidden: {message}")]
15    Forbidden { message: String },
16
17    #[error("Resource not found: {resource}")]
18    NotFound { resource: String },
19
20    #[error("Invalid request: {message}")]
21    BadRequest { message: String },
22
23    #[error("Server error: {status} - {message}")]
24    ServerError { status: u16, message: String },
25
26    #[error("Invalid URL: {0}")]
27    InvalidUrl(#[from] url::ParseError),
28
29    #[error("JSON serialization error: {0}")]
30    JsonError(#[from] serde_json::Error),
31
32    #[error("Request timeout after {attempts} attempts")]
33    Timeout { attempts: usize },
34
35    #[error("API endpoint removed: {message}")]
36    EndpointGone { message: String },
37
38    #[error("Invalid response format: {0}")]
39    InvalidResponse(String),
40}
41
42impl ApiError {
43    pub fn is_retryable(&self) -> bool {
44        match self {
45            ApiError::RateLimitExceeded { .. } => true,
46            ApiError::ServerError { status, .. } if *status >= 500 => true,
47            ApiError::Timeout { .. } => true,
48            ApiError::EndpointGone { .. } => false,
49            _ => false,
50        }
51    }
52
53    pub fn suggestion(&self) -> Option<String> {
54        match self {
55            ApiError::AuthenticationFailed { message } => {
56                let base = "Verify tokens with: atlassian-cli auth list\nTest auth with: atlassian-cli auth test [--bitbucket]".to_string();
57                // A scope mismatch is not a bad token: re-issuing the same
58                // token changes nothing, so point at the scopes instead.
59                if message.to_lowercase().contains("scope") {
60                    Some(format!(
61                        "{base}\nThis looks like a missing scope, not a bad token. Re-create the token with the scopes the command needs at:\nhttps://id.atlassian.com/manage-profile/security/api-tokens"
62                    ))
63                } else {
64                    Some(base)
65                }
66            }
67            ApiError::Forbidden { message } => {
68                let base = "Verify tokens with: atlassian-cli auth list\nTest auth with: atlassian-cli auth test [--bitbucket]".to_string();
69                let lower = message.to_lowercase();
70                if lower.contains("scope") || lower.contains("privilege") || lower.contains("permission") {
71                    Some(format!("{base}\nAdd missing scopes at: https://bitbucket.org/account/settings/app-passwords/"))
72                } else {
73                    Some(base)
74                }
75            }
76            ApiError::RateLimitExceeded { .. } => {
77                Some("Consider reducing request frequency or use bulk operations".to_string())
78            }
79            ApiError::NotFound { .. } => Some("Check if the resource ID is correct".to_string()),
80            ApiError::BadRequest { message } => {
81                if message.contains("Version number must be 1") {
82                    Some("This is a draft page. Use 'confluence page publish' to publish for the first time".to_string())
83                } else if message.to_lowercase().contains("version") {
84                    Some("Version conflict detected. The content may have been modified. Fetch latest and retry".to_string())
85                } else {
86                    Some("Review the request parameters".to_string())
87                }
88            }
89            ApiError::Timeout { .. } => Some("Check your network connection or try again later".to_string()),
90            ApiError::EndpointGone { .. } => {
91                Some("This API endpoint has been removed by Atlassian. Update atlassian-cli to the latest version.".to_string())
92            }
93            _ => None,
94        }
95    }
96}
97
98pub type Result<T> = std::result::Result<T, ApiError>;
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn forbidden_has_suggestion() {
106        let err = ApiError::Forbidden {
107            message: "no access".to_string(),
108        };
109        assert!(err.suggestion().is_some());
110        assert!(err.suggestion().unwrap().contains("auth test"));
111    }
112
113    #[test]
114    fn forbidden_is_not_retryable() {
115        let err = ApiError::Forbidden {
116            message: "no access".to_string(),
117        };
118        assert!(!err.is_retryable());
119    }
120
121    #[test]
122    fn authentication_failed_has_suggestion() {
123        let err = ApiError::AuthenticationFailed {
124            message: "expired".to_string(),
125        };
126        assert!(err.suggestion().is_some());
127        assert!(err.suggestion().unwrap().contains("auth test"));
128    }
129
130    #[test]
131    fn forbidden_with_scope_message_includes_app_passwords_link() {
132        let err = ApiError::Forbidden {
133            message: "Your credentials lack the required scope.".to_string(),
134        };
135        let hint = err.suggestion().unwrap();
136        assert!(hint.contains("auth test"));
137        assert!(hint.contains("app-passwords"));
138    }
139
140    #[test]
141    fn forbidden_without_scope_omits_app_passwords_link() {
142        let err = ApiError::Forbidden {
143            message: "no access".to_string(),
144        };
145        let hint = err.suggestion().unwrap();
146        assert!(hint.contains("auth test"));
147        assert!(!hint.contains("app-passwords"));
148    }
149
150    #[test]
151    fn forbidden_with_permission_message_includes_link() {
152        let err = ApiError::Forbidden {
153            message: "Insufficient Permission to access this resource".to_string(),
154        };
155        let hint = err.suggestion().unwrap();
156        assert!(hint.contains("app-passwords"));
157    }
158
159    #[test]
160    fn authentication_failed_with_scope_message_points_at_scopes() {
161        let err = ApiError::AuthenticationFailed {
162            message: "Invalid or expired credentials (Unauthorized; scope does not match)"
163                .to_string(),
164        };
165        let hint = err.suggestion().unwrap();
166        assert!(hint.contains("missing scope"));
167        assert!(hint.contains("api-tokens"));
168    }
169
170    #[test]
171    fn authentication_failed_without_scope_message_omits_scope_hint() {
172        let err = ApiError::AuthenticationFailed {
173            message: "Invalid or expired credentials".to_string(),
174        };
175        let hint = err.suggestion().unwrap();
176        assert!(hint.contains("auth test"));
177        assert!(!hint.contains("missing scope"));
178    }
179
180    #[test]
181    fn endpoint_gone_has_suggestion() {
182        let err = ApiError::EndpointGone {
183            message: "The requested API has been removed".to_string(),
184        };
185        assert!(err.suggestion().is_some());
186        assert!(err.suggestion().unwrap().contains("Update atlassian-cli"));
187    }
188
189    #[test]
190    fn endpoint_gone_is_not_retryable() {
191        let err = ApiError::EndpointGone {
192            message: "removed".to_string(),
193        };
194        assert!(!err.is_retryable());
195    }
196}