1use std::time::Duration;
5
6use thiserror::Error;
7
8pub mod exit_codes {
13 pub const SUCCESS: i32 = 0;
14 pub const GENERAL_ERROR: i32 = 1;
15 pub const USAGE_ERROR: i32 = 2;
16 pub const AUTH_REQUIRED: i32 = 3;
17 pub const NOT_FOUND: i32 = 4;
18 pub const PERMISSION_DENIED: i32 = 5;
19 pub const RATE_LIMITED: i32 = 6;
20 pub const QUOTA_EXCEEDED: i32 = 7;
21 pub const CIRCUIT_BREAKER: i32 = 8;
22}
23
24#[derive(Debug, Error)]
29pub enum GogError {
30 #[error("auth required for {service} {email}")]
32 AuthRequired {
33 service: String,
34 email: String,
35 client: Option<String>,
36 },
37
38 #[error("rate limit exceeded after {retries} retries")]
40 RateLimited {
41 retry_after: Option<Duration>,
42 retries: u32,
43 },
44
45 #[error("circuit breaker is open, too many recent failures - try again later")]
47 CircuitBreakerOpen,
48
49 #[error("API quota exceeded")]
51 QuotaExceeded { resource: Option<String> },
52
53 #[error("{resource} not found")]
55 NotFound {
56 resource: String,
57 id: Option<String>,
58 },
59
60 #[error("permission denied")]
62 PermissionDenied {
63 resource: Option<String>,
64 action: Option<String>,
65 },
66
67 #[error("Google API error ({code}): {message}")]
69 GoogleApi {
70 code: u16,
71 message: String,
72 reason: Option<String>,
73 },
74
75 #[error("{0}")]
78 UserFacing(String),
79
80 #[error("{0}")]
82 Usage(String),
83
84 #[error(transparent)]
86 Config(#[from] crate::config::ConfigError),
87
88 #[error(transparent)]
90 Io(#[from] std::io::Error),
91
92 #[error(transparent)]
94 Other(#[from] anyhow::Error),
95}
96
97impl GogError {
102 pub fn exit_code(&self) -> i32 {
104 match self {
105 GogError::AuthRequired { .. } => exit_codes::AUTH_REQUIRED,
106 GogError::RateLimited { .. } => exit_codes::RATE_LIMITED,
107 GogError::CircuitBreakerOpen => exit_codes::CIRCUIT_BREAKER,
108 GogError::QuotaExceeded { .. } => exit_codes::QUOTA_EXCEEDED,
109 GogError::NotFound { .. } => exit_codes::NOT_FOUND,
110 GogError::PermissionDenied { .. } => exit_codes::PERMISSION_DENIED,
111 GogError::GoogleApi { .. } => exit_codes::GENERAL_ERROR,
112 GogError::UserFacing(_) => exit_codes::GENERAL_ERROR,
113 GogError::Usage(_) => exit_codes::USAGE_ERROR,
114 GogError::Config(_) => exit_codes::GENERAL_ERROR,
115 GogError::Io(_) => exit_codes::GENERAL_ERROR,
116 GogError::Other(_) => exit_codes::GENERAL_ERROR,
117 }
118 }
119
120 pub fn format_for_user(&self) -> String {
122 match self {
123 GogError::AuthRequired { service, email, .. } => {
124 format!(
125 "No auth for {service} {email}.\n\nOAuth (browser flow):\n gog auth add {email} --services {service}\n\nWorkspace service account (domain-wide delegation):\n gog auth service-account set {email} --key <service-account.json>"
126 )
127 }
128
129 GogError::Config(crate::config::ConfigError::CredentialsMissing { path }) => {
130 format!(
131 "OAuth client credentials missing (OAuth client ID JSON).\nDownload from: https://console.cloud.google.com/apis/credentials (Create Credentials → OAuth client ID → Desktop app → Download JSON)\nThen run: gog auth credentials <credentials.json> (expected at {})",
132 path.display()
133 )
134 }
135
136 GogError::Usage(msg) => {
137 format!("{msg}\nRun with --help to see usage")
138 }
139
140 GogError::NotFound { resource, id } => match id {
141 Some(id) => format!("{resource} not found: {id}"),
142 None => format!("{resource} not found"),
143 },
144
145 GogError::GoogleApi { code, message, reason } => match reason {
146 Some(r) => format!("Google API error ({code} {r}): {message}"),
147 None => format!("Google API error ({code}): {message}"),
148 },
149
150 _ => self.to_string(),
151 }
152 }
153}
154
155#[cfg(test)]
173mod tests {
174 use super::*;
175 use std::io;
176
177 #[test]
182 fn test_auth_required_exit_code() {
183 let err = GogError::AuthRequired {
184 service: "gmail".to_string(),
185 email: "user@example.com".to_string(),
186 client: None,
187 };
188 assert_eq!(err.exit_code(), exit_codes::AUTH_REQUIRED);
189 }
190
191 #[test]
192 fn test_not_found_exit_code() {
193 let err = GogError::NotFound {
194 resource: "message".to_string(),
195 id: Some("abc123".to_string()),
196 };
197 assert_eq!(err.exit_code(), exit_codes::NOT_FOUND);
198 }
199
200 #[test]
201 fn test_rate_limited_exit_code() {
202 let err = GogError::RateLimited {
203 retry_after: None,
204 retries: 3,
205 };
206 assert_eq!(err.exit_code(), exit_codes::RATE_LIMITED);
207 }
208
209 #[test]
210 fn test_general_error_exit_code() {
211 let err = GogError::Io(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"));
212 assert_eq!(err.exit_code(), exit_codes::GENERAL_ERROR);
213 }
214
215 #[test]
216 fn test_usage_exit_code() {
217 let err = GogError::Usage("unknown flag --foo".to_string());
218 assert_eq!(err.exit_code(), exit_codes::USAGE_ERROR);
219 }
220
221 #[test]
222 fn test_circuit_breaker_exit_code() {
223 assert_eq!(GogError::CircuitBreakerOpen.exit_code(), exit_codes::CIRCUIT_BREAKER);
224 }
225
226 #[test]
227 fn test_quota_exceeded_exit_code() {
228 let err = GogError::QuotaExceeded { resource: None };
229 assert_eq!(err.exit_code(), exit_codes::QUOTA_EXCEEDED);
230 }
231
232 #[test]
233 fn test_permission_denied_exit_code() {
234 let err = GogError::PermissionDenied { resource: None, action: None };
235 assert_eq!(err.exit_code(), exit_codes::PERMISSION_DENIED);
236 }
237
238 #[test]
243 fn test_not_found_display() {
244 let err = GogError::NotFound {
245 resource: "message".to_string(),
246 id: Some("abc123".to_string()),
247 };
248 assert_eq!(err.format_for_user(), "message not found: abc123");
250 }
251
252 #[test]
253 fn test_not_found_display_no_id() {
254 let err = GogError::NotFound {
255 resource: "message".to_string(),
256 id: None,
257 };
258 assert_eq!(err.format_for_user(), "message not found");
259 }
260
261 #[test]
262 fn test_auth_required_format_for_user() {
263 let err = GogError::AuthRequired {
264 service: "gmail".to_string(),
265 email: "user@example.com".to_string(),
266 client: None,
267 };
268 let msg = err.format_for_user();
269 assert!(msg.contains("gog auth add"), "expected 'gog auth add' in: {msg}");
270 assert!(msg.contains("gmail"), "expected service name in: {msg}");
271 assert!(msg.contains("user@example.com"), "expected email in: {msg}");
272 }
273
274 #[test]
275 fn test_usage_format_for_user() {
276 let err = GogError::Usage("unknown flag --foo".to_string());
277 let msg = err.format_for_user();
278 assert!(msg.contains("--help"), "expected '--help' in: {msg}");
279 assert!(msg.contains("unknown flag --foo"), "expected original message in: {msg}");
280 }
281
282 #[test]
283 fn test_rate_limited_display() {
284 let err = GogError::RateLimited {
285 retry_after: Some(Duration::from_secs(30)),
286 retries: 5,
287 };
288 let msg = err.to_string();
289 assert!(msg.contains("5"), "expected retry count in: {msg}");
290 }
291
292 #[test]
293 fn test_rate_limited_display_no_retry_after() {
294 let err = GogError::RateLimited {
295 retry_after: None,
296 retries: 3,
297 };
298 let msg = err.to_string();
299 assert!(msg.contains("3"), "expected retry count in: {msg}");
300 }
301
302 #[test]
303 fn test_google_api_format_for_user_with_reason() {
304 let err = GogError::GoogleApi {
305 code: 403,
306 message: "Forbidden".to_string(),
307 reason: Some("rateLimitExceeded".to_string()),
308 };
309 let msg = err.format_for_user();
310 assert!(msg.contains("403"), "expected code in: {msg}");
311 assert!(msg.contains("rateLimitExceeded"), "expected reason in: {msg}");
312 assert!(msg.contains("Forbidden"), "expected message in: {msg}");
313 }
314
315 #[test]
316 fn test_google_api_format_for_user_no_reason() {
317 let err = GogError::GoogleApi {
318 code: 500,
319 message: "Internal Server Error".to_string(),
320 reason: None,
321 };
322 let msg = err.format_for_user();
323 assert_eq!(msg, "Google API error (500): Internal Server Error");
324 }
325
326 #[test]
327 fn test_circuit_breaker_display() {
328 let msg = GogError::CircuitBreakerOpen.to_string();
329 assert!(msg.contains("circuit breaker"), "expected 'circuit breaker' in: {msg}");
330 }
331
332 #[test]
333 fn test_user_facing_display() {
334 let err = GogError::UserFacing("something went wrong".to_string());
335 assert_eq!(err.to_string(), "something went wrong");
336 assert_eq!(err.format_for_user(), "something went wrong");
337 }
338
339 #[test]
340 fn test_config_credentials_missing_format_for_user() {
341 use crate::config::ConfigError;
342 let err = GogError::Config(ConfigError::CredentialsMissing {
343 path: "/home/user/.config/gogcli/credentials.json".into(),
344 });
345 let msg = err.format_for_user();
346 assert!(msg.contains("OAuth client credentials missing"), "got: {msg}");
347 assert!(msg.contains("console.cloud.google.com"), "got: {msg}");
348 }
349
350 #[test]
351 fn test_exit_code_constants() {
352 assert_eq!(exit_codes::SUCCESS, 0);
353 assert_eq!(exit_codes::GENERAL_ERROR, 1);
354 assert_eq!(exit_codes::USAGE_ERROR, 2);
355 assert_eq!(exit_codes::AUTH_REQUIRED, 3);
356 assert_eq!(exit_codes::NOT_FOUND, 4);
357 assert_eq!(exit_codes::PERMISSION_DENIED, 5);
358 assert_eq!(exit_codes::RATE_LIMITED, 6);
359 assert_eq!(exit_codes::QUOTA_EXCEEDED, 7);
360 assert_eq!(exit_codes::CIRCUIT_BREAKER, 8);
361 }
362}