Skip to main content

gog_core/
error.rs

1// gog-core error module
2// Ported from internal/googleapi/errors.go and internal/errfmt/errfmt.go
3
4use std::time::Duration;
5
6use thiserror::Error;
7
8// ---------------------------------------------------------------------------
9// Exit code constants (matching Go version for script compatibility)
10// ---------------------------------------------------------------------------
11
12pub 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// ---------------------------------------------------------------------------
25// GogError enum
26// ---------------------------------------------------------------------------
27
28#[derive(Debug, Error)]
29pub enum GogError {
30    /// Authentication required for a service/account.
31    #[error("auth required for {service} {email}")]
32    AuthRequired {
33        service: String,
34        email: String,
35        client: Option<String>,
36    },
37
38    /// Rate limit exceeded.
39    #[error("rate limit exceeded after {retries} retries")]
40    RateLimited {
41        retry_after: Option<Duration>,
42        retries: u32,
43    },
44
45    /// Circuit breaker tripped.
46    #[error("circuit breaker is open, too many recent failures - try again later")]
47    CircuitBreakerOpen,
48
49    /// API quota exceeded.
50    #[error("API quota exceeded")]
51    QuotaExceeded { resource: Option<String> },
52
53    /// Resource not found.
54    #[error("{resource} not found")]
55    NotFound {
56        resource: String,
57        id: Option<String>,
58    },
59
60    /// Permission denied.
61    #[error("permission denied")]
62    PermissionDenied {
63        resource: Option<String>,
64        action: Option<String>,
65    },
66
67    /// Google API error (HTTP status code + message).
68    #[error("Google API error ({code}): {message}")]
69    GoogleApi {
70        code: u16,
71        message: String,
72        reason: Option<String>,
73    },
74
75    /// User-facing message with optional cause.
76    /// NOTE: String is not an Error so thiserror won't auto-treat it as source.
77    #[error("{0}")]
78    UserFacing(String),
79
80    /// CLI usage error.
81    #[error("{0}")]
82    Usage(String),
83
84    /// Config error.
85    #[error(transparent)]
86    Config(#[from] crate::config::ConfigError),
87
88    /// IO error.
89    #[error(transparent)]
90    Io(#[from] std::io::Error),
91
92    /// Catch-all.
93    #[error(transparent)]
94    Other(#[from] anyhow::Error),
95}
96
97// ---------------------------------------------------------------------------
98// Methods
99// ---------------------------------------------------------------------------
100
101impl GogError {
102    /// Map each variant to the appropriate process exit code.
103    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    /// Return a user-friendly message, matching Go's errfmt.Format behaviour.
121    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// ---------------------------------------------------------------------------
156// Display overrides for variants with conditional formatting
157// ---------------------------------------------------------------------------
158
159// The #[error] attribute on NotFound only shows the resource.
160// format_for_user handles id; for Display we also want to show id when present.
161// We override via a custom Display using the existing thiserror-generated one
162// but we need to give callers std::fmt::Display that matches Go's Error() output.
163//
164// Instead, we keep the #[error] simple and add a dedicated impl fmt::Display
165// by relying on thiserror's generated one - it already handles the basic case.
166// We override only where the Go version had conditional logic via format_for_user.
167
168// ---------------------------------------------------------------------------
169// Tests
170// ---------------------------------------------------------------------------
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::io;
176
177    // -----------------------------------------------------------------------
178    // Exit code tests
179    // -----------------------------------------------------------------------
180
181    #[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    // -----------------------------------------------------------------------
239    // Display / format_for_user tests
240    // -----------------------------------------------------------------------
241
242    #[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        // format_for_user shows the id
249        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}