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 drive auth login` again."
29 ),
30 Self::Refresh => write!(
31 f,
32 "this almost always means either (1) your Drive 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 drive auth login` again to re-authenticate."
36 ),
37 }
38 }
39}
40
41#[derive(Error, Debug)]
43pub enum DriveError {
44 #[error("Drive credentials not configured. Run `omni-dev drive auth login`")]
46 CredentialsNotFound,
47
48 #[error("Drive 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 drive 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("Drive OAuth token response was malformed: missing `{0}`")]
93 MalformedTokenResponse(&'static str),
94
95 #[error(
98 "Google did not grant the drive.readonly scope (received: {0}).\n On the consent \
99 screen, tick the Drive permission — restricted scopes are not granted by default. \
100 Re-run `omni-dev drive auth login`."
101 )]
102 NoScopeGranted(String),
103
104 #[error("Invalid browser launch command: {0}")]
106 InvalidBrowserCommand(String),
107}
108
109impl DriveError {
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 #[allow(dead_code)]
137 pub(crate) fn reason(&self) -> Option<&str> {
138 match self {
139 Self::ApiRequestFailed { reason, .. } => reason.as_deref(),
140 _ => None,
141 }
142 }
143}
144
145#[cfg(test)]
146#[allow(clippy::unwrap_used, clippy::expect_used)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn credentials_not_found_display() {
152 let err = DriveError::CredentialsNotFound;
153 assert!(err.to_string().contains("not configured"));
154 assert!(err.to_string().contains("drive auth login"));
155 }
156
157 #[test]
158 fn api_request_failed_display() {
159 let err = DriveError::ApiRequestFailed {
160 status: 403,
161 body: "insufficientPermissions".to_string(),
162 reason: None,
163 };
164 let msg = err.to_string();
165 assert!(msg.contains("403"));
166 assert!(msg.contains("insufficientPermissions"));
167 }
168
169 #[test]
170 fn state_mismatch_display_mentions_state() {
171 assert!(DriveError::StateMismatch.to_string().contains("state"));
172 }
173
174 #[test]
175 fn reason_returns_the_structured_field() {
176 let err = DriveError::ApiRequestFailed {
177 status: 404,
178 body: "Not Found (reason: notFound)".to_string(),
179 reason: Some("notFound".to_string()),
180 };
181 assert_eq!(err.reason(), Some("notFound"));
182 }
183
184 #[test]
185 fn reason_is_none_when_the_structured_field_is_absent() {
186 let err = DriveError::ApiRequestFailed {
187 status: 500,
188 body: "Internal Server Error".to_string(),
189 reason: None,
190 };
191 assert_eq!(err.reason(), None);
192 }
193
194 #[test]
195 fn reason_ignores_a_reason_like_substring_embedded_in_the_body() {
196 let err = DriveError::ApiRequestFailed {
197 status: 400,
198 body: "Message already explains itself (reason: not the real one)".to_string(),
199 reason: None,
200 };
201 assert_eq!(err.reason(), None);
202 }
203
204 #[test]
205 fn reason_is_none_for_non_api_request_failed_variants() {
206 assert_eq!(DriveError::StateMismatch.reason(), None);
207 }
208
209 #[test]
210 fn authorization_denied_includes_description_when_present() {
211 let err = DriveError::authorization_denied("access_denied", Some("user declined"));
212 let msg = err.to_string();
213 assert!(msg.contains("access_denied"));
214 assert!(msg.contains("user declined"));
215 assert!(msg.contains('('));
216 }
217
218 #[test]
219 fn authorization_denied_omits_parens_when_absent() {
220 let err = DriveError::authorization_denied("access_denied", None);
221 let msg = err.to_string();
222 assert!(msg.contains("access_denied"));
223 assert!(!msg.contains('('));
224 }
225
226 #[test]
227 fn authorization_denied_omits_parens_when_description_empty() {
228 let err = DriveError::authorization_denied("access_denied", Some(""));
229 let msg = err.to_string();
230 assert!(!msg.contains('('));
231 }
232
233 #[test]
234 fn callback_timeout_display_includes_seconds() {
235 let err = DriveError::CallbackTimeout(120);
236 assert!(err.to_string().contains("120"));
237 }
238
239 #[test]
240 fn malformed_callback_display() {
241 assert!(DriveError::MalformedCallback
242 .to_string()
243 .to_lowercase()
244 .contains("malformed"));
245 }
246
247 #[test]
248 fn invalid_grant_code_exchange_display_mentions_pkce_and_expired_code() {
249 let err = DriveError::InvalidGrant(GrantContext::CodeExchange);
250 let msg = err.to_string();
251 assert!(msg.contains("PKCE"));
252 assert!(msg.contains("expired"));
253 assert!(msg.contains("auth login"));
254 }
255
256 #[test]
257 fn invalid_grant_refresh_display_mentions_7_days_and_testing_mode() {
258 let err = DriveError::InvalidGrant(GrantContext::Refresh);
259 let msg = err.to_string();
260 assert!(msg.contains("7 days"));
261 assert!(msg.contains("Testing"));
262 assert!(msg.contains("auth login"));
263 }
264
265 #[test]
266 fn grant_context_variants_produce_distinct_messages() {
267 let code = GrantContext::CodeExchange.to_string();
268 let refresh = GrantContext::Refresh.to_string();
269 assert_ne!(code, refresh);
270 }
271
272 #[test]
273 fn malformed_token_response_names_the_missing_field() {
274 let err = DriveError::MalformedTokenResponse("refresh_token");
275 assert!(err.to_string().contains("refresh_token"));
276 }
277
278 #[test]
279 fn invalid_browser_command_display_includes_detail() {
280 let err = DriveError::InvalidBrowserCommand("empty command".to_string());
281 assert!(err.to_string().contains("empty command"));
282 }
283
284 #[test]
285 fn no_scope_granted_display_names_the_received_scopes() {
286 let err = DriveError::NoScopeGranted("openid, email, profile".to_string());
287 let msg = err.to_string();
288 assert!(msg.contains("openid, email, profile"));
289 assert!(msg.contains("consent screen"));
290 assert!(msg.contains("auth login"));
291 }
292}