1use std::fmt;
4
5use thiserror::Error;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum GrantContext {
14 CodeExchange,
16 Refresh,
18}
19
20impl fmt::Display for GrantContext {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 match self {
23 Self::CodeExchange => write!(
24 f,
25 "the authorization code was invalid, already used, expired (codes are \
26 single-use and valid only a few minutes), or the PKCE code_verifier did not \
27 match the code_challenge sent at the start of login. Run \
28 `omni-dev gmail auth login` again."
29 ),
30 Self::Refresh => write!(
31 f,
32 "this almost always means either (1) your Gmail OAuth client is in \"Testing\" \
33 publishing status, where refresh tokens expire after 7 days — publish it to \
34 \"In production\" in Google Cloud Console to avoid this, or (2) access was \
35 revoked. Run `omni-dev gmail auth login` again to re-authenticate."
36 ),
37 }
38 }
39}
40
41#[derive(Error, Debug)]
43pub enum GmailError {
44 #[error("Gmail credentials not configured. Run `omni-dev gmail auth login`")]
46 CredentialsNotFound,
47
48 #[error("Gmail API request failed: HTTP {status}: {body}")]
50 ApiRequestFailed {
51 status: u16,
53 body: String,
56 reason: Option<String>,
60 },
61
62 #[error(
65 "OAuth state mismatch: the browser callback did not present the expected state \
66 value; aborting login for safety"
67 )]
68 StateMismatch,
69
70 #[error("Google denied the authorization request: {0}")]
72 AuthorizationDenied(String),
73
74 #[error(
76 "Timed out after {0}s waiting for the browser sign-in callback; re-run \
77 `omni-dev gmail auth login`"
78 )]
79 CallbackTimeout(u64),
80
81 #[error(
83 "The browser's sign-in callback was malformed or missing the `code`/`state` parameters"
84 )]
85 MalformedCallback,
86
87 #[error("Google rejected the request (invalid_grant): {0}")]
89 InvalidGrant(GrantContext),
90
91 #[error("Gmail OAuth token response was malformed: missing `{0}`")]
93 MalformedTokenResponse(&'static str),
94
95 #[error(
98 "Google did not grant a Gmail scope (received: {0}).\n On the consent screen, tick the \
99 Gmail permission — restricted scopes are not granted by default. Re-run \
100 `omni-dev gmail auth login`."
101 )]
102 NoGmailScopeGranted(String),
103
104 #[error("Invalid browser launch command: {0}")]
106 InvalidBrowserCommand(String),
107}
108
109impl GmailError {
110 #[must_use]
119 pub(crate) fn authorization_denied(reason: &str, description: Option<&str>) -> Self {
120 let detail = match description {
121 Some(d) if !d.is_empty() => format!("{reason} ({d})"),
122 _ => reason.to_string(),
123 };
124 Self::AuthorizationDenied(detail)
125 }
126
127 pub(crate) fn reason(&self) -> Option<&str> {
131 match self {
132 Self::ApiRequestFailed { reason, .. } => reason.as_deref(),
133 _ => None,
134 }
135 }
136}
137
138#[cfg(test)]
139#[allow(clippy::unwrap_used, clippy::expect_used)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn credentials_not_found_display() {
145 let err = GmailError::CredentialsNotFound;
146 assert!(err.to_string().contains("not configured"));
147 assert!(err.to_string().contains("gmail auth login"));
148 }
149
150 #[test]
151 fn api_request_failed_display() {
152 let err = GmailError::ApiRequestFailed {
153 status: 403,
154 body: "insufficientPermissions".to_string(),
155 reason: None,
156 };
157 let msg = err.to_string();
158 assert!(msg.contains("403"));
159 assert!(msg.contains("insufficientPermissions"));
160 }
161
162 #[test]
163 fn state_mismatch_display_mentions_state() {
164 assert!(GmailError::StateMismatch.to_string().contains("state"));
165 }
166
167 #[test]
168 fn reason_returns_the_structured_field() {
169 let err = GmailError::ApiRequestFailed {
170 status: 404,
171 body: "Not Found (reason: notFound)".to_string(),
172 reason: Some("notFound".to_string()),
173 };
174 assert_eq!(err.reason(), Some("notFound"));
175 }
176
177 #[test]
178 fn reason_is_none_when_the_structured_field_is_absent() {
179 let err = GmailError::ApiRequestFailed {
180 status: 500,
181 body: "Internal Server Error".to_string(),
182 reason: None,
183 };
184 assert_eq!(err.reason(), None);
185 }
186
187 #[test]
188 fn reason_ignores_a_reason_like_substring_embedded_in_the_body() {
189 let err = GmailError::ApiRequestFailed {
190 status: 400,
191 body: "Message already explains itself (reason: not the real one)".to_string(),
192 reason: None,
193 };
194 assert_eq!(err.reason(), None);
195 }
196
197 #[test]
198 fn reason_is_none_for_non_api_request_failed_variants() {
199 assert_eq!(GmailError::StateMismatch.reason(), None);
200 }
201
202 #[test]
203 fn authorization_denied_includes_description_when_present() {
204 let err = GmailError::authorization_denied("access_denied", Some("user declined"));
205 let msg = err.to_string();
206 assert!(msg.contains("access_denied"));
207 assert!(msg.contains("user declined"));
208 assert!(msg.contains('('));
209 }
210
211 #[test]
212 fn authorization_denied_omits_parens_when_absent() {
213 let err = GmailError::authorization_denied("access_denied", None);
214 let msg = err.to_string();
215 assert!(msg.contains("access_denied"));
216 assert!(!msg.contains('('));
217 }
218
219 #[test]
220 fn authorization_denied_omits_parens_when_description_empty() {
221 let err = GmailError::authorization_denied("access_denied", Some(""));
222 let msg = err.to_string();
223 assert!(!msg.contains('('));
224 }
225
226 #[test]
227 fn callback_timeout_display_includes_seconds() {
228 let err = GmailError::CallbackTimeout(120);
229 assert!(err.to_string().contains("120"));
230 }
231
232 #[test]
233 fn malformed_callback_display() {
234 assert!(GmailError::MalformedCallback
235 .to_string()
236 .to_lowercase()
237 .contains("malformed"));
238 }
239
240 #[test]
241 fn invalid_grant_code_exchange_display_mentions_pkce_and_expired_code() {
242 let err = GmailError::InvalidGrant(GrantContext::CodeExchange);
243 let msg = err.to_string();
244 assert!(msg.contains("PKCE"));
245 assert!(msg.contains("expired"));
246 assert!(msg.contains("auth login"));
247 }
248
249 #[test]
250 fn invalid_grant_refresh_display_mentions_7_days_and_testing_mode() {
251 let err = GmailError::InvalidGrant(GrantContext::Refresh);
252 let msg = err.to_string();
253 assert!(msg.contains("7 days"));
254 assert!(msg.contains("Testing"));
255 assert!(msg.contains("auth login"));
256 }
257
258 #[test]
259 fn grant_context_variants_produce_distinct_messages() {
260 let code = GrantContext::CodeExchange.to_string();
261 let refresh = GrantContext::Refresh.to_string();
262 assert_ne!(code, refresh);
263 }
264
265 #[test]
266 fn malformed_token_response_names_the_missing_field() {
267 let err = GmailError::MalformedTokenResponse("refresh_token");
268 assert!(err.to_string().contains("refresh_token"));
269 }
270
271 #[test]
272 fn invalid_browser_command_display_includes_detail() {
273 let err = GmailError::InvalidBrowserCommand("empty command".to_string());
274 assert!(err.to_string().contains("empty command"));
275 }
276
277 #[test]
278 fn no_gmail_scope_granted_display_names_the_received_scopes() {
279 let err = GmailError::NoGmailScopeGranted("openid, email, profile".to_string());
280 let msg = err.to_string();
281 assert!(msg.contains("openid, email, profile"));
282 assert!(msg.contains("consent screen"));
283 assert!(msg.contains("auth login"));
284 }
285}