atlassian_cli_api/
error.rs1use 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 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}