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("{api} API request failed: HTTP {status}: {body}")]
50 ApiRequestFailed {
51 api: &'static str,
59 status: u16,
61 body: String,
64 reason: Option<String>,
68 },
69
70 #[error(
73 "OAuth state mismatch: the browser callback did not present the expected state \
74 value; aborting login for safety"
75 )]
76 StateMismatch,
77
78 #[error("Google denied the authorization request: {0}")]
80 AuthorizationDenied(String),
81
82 #[error(
84 "Timed out after {0}s waiting for the browser sign-in callback; re-run \
85 `omni-dev drive auth login`"
86 )]
87 CallbackTimeout(u64),
88
89 #[error(
91 "The browser's sign-in callback was malformed or missing the `code`/`state` parameters"
92 )]
93 MalformedCallback,
94
95 #[error("Google rejected the request (invalid_grant): {0}")]
97 InvalidGrant(GrantContext),
98
99 #[error("Drive OAuth token response was malformed: missing `{0}`")]
101 MalformedTokenResponse(&'static str),
102
103 #[error(
106 "Google did not grant the drive.readonly scope (received: {0}).\n On the consent \
107 screen, tick the Drive permission — restricted scopes are not granted by default. \
108 Re-run `omni-dev drive auth login`."
109 )]
110 NoScopeGranted(String),
111
112 #[error("Invalid browser launch command: {0}")]
114 InvalidBrowserCommand(String),
115}
116
117impl DriveError {
118 #[must_use]
127 pub(crate) fn authorization_denied(reason: &str, description: Option<&str>) -> Self {
128 let detail = match description {
129 Some(d) if !d.is_empty() => format!("{reason} ({d})"),
130 _ => reason.to_string(),
131 };
132 Self::AuthorizationDenied(detail)
133 }
134
135 #[allow(dead_code)]
145 pub(crate) fn reason(&self) -> Option<&str> {
146 match self {
147 Self::ApiRequestFailed { reason, .. } => reason.as_deref(),
148 _ => None,
149 }
150 }
151}
152
153#[cfg(test)]
154#[allow(clippy::unwrap_used, clippy::expect_used)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn credentials_not_found_display() {
160 let err = DriveError::CredentialsNotFound;
161 assert!(err.to_string().contains("not configured"));
162 assert!(err.to_string().contains("drive auth login"));
163 }
164
165 #[test]
166 fn api_request_failed_display() {
167 let err = DriveError::ApiRequestFailed {
168 api: "Drive",
169 status: 403,
170 body: "insufficientPermissions".to_string(),
171 reason: None,
172 };
173 let msg = err.to_string();
174 assert!(msg.contains("403"));
175 assert!(msg.contains("insufficientPermissions"));
176 }
177
178 #[test]
179 fn state_mismatch_display_mentions_state() {
180 assert!(DriveError::StateMismatch.to_string().contains("state"));
181 }
182
183 #[test]
184 fn reason_returns_the_structured_field() {
185 let err = DriveError::ApiRequestFailed {
186 api: "Drive",
187 status: 404,
188 body: "Not Found (reason: notFound)".to_string(),
189 reason: Some("notFound".to_string()),
190 };
191 assert_eq!(err.reason(), Some("notFound"));
192 }
193
194 #[test]
195 fn reason_is_none_when_the_structured_field_is_absent() {
196 let err = DriveError::ApiRequestFailed {
197 api: "Drive",
198 status: 500,
199 body: "Internal Server Error".to_string(),
200 reason: None,
201 };
202 assert_eq!(err.reason(), None);
203 }
204
205 #[test]
206 fn reason_ignores_a_reason_like_substring_embedded_in_the_body() {
207 let err = DriveError::ApiRequestFailed {
208 api: "Drive",
209 status: 400,
210 body: "Message already explains itself (reason: not the real one)".to_string(),
211 reason: None,
212 };
213 assert_eq!(err.reason(), None);
214 }
215
216 #[test]
217 fn reason_is_none_for_non_api_request_failed_variants() {
218 assert_eq!(DriveError::StateMismatch.reason(), None);
219 }
220
221 #[test]
222 fn authorization_denied_includes_description_when_present() {
223 let err = DriveError::authorization_denied("access_denied", Some("user declined"));
224 let msg = err.to_string();
225 assert!(msg.contains("access_denied"));
226 assert!(msg.contains("user declined"));
227 assert!(msg.contains('('));
228 }
229
230 #[test]
231 fn authorization_denied_omits_parens_when_absent() {
232 let err = DriveError::authorization_denied("access_denied", None);
233 let msg = err.to_string();
234 assert!(msg.contains("access_denied"));
235 assert!(!msg.contains('('));
236 }
237
238 #[test]
239 fn authorization_denied_omits_parens_when_description_empty() {
240 let err = DriveError::authorization_denied("access_denied", Some(""));
241 let msg = err.to_string();
242 assert!(!msg.contains('('));
243 }
244
245 #[test]
246 fn callback_timeout_display_includes_seconds() {
247 let err = DriveError::CallbackTimeout(120);
248 assert!(err.to_string().contains("120"));
249 }
250
251 #[test]
252 fn malformed_callback_display() {
253 assert!(DriveError::MalformedCallback
254 .to_string()
255 .to_lowercase()
256 .contains("malformed"));
257 }
258
259 #[test]
260 fn invalid_grant_code_exchange_display_mentions_pkce_and_expired_code() {
261 let err = DriveError::InvalidGrant(GrantContext::CodeExchange);
262 let msg = err.to_string();
263 assert!(msg.contains("PKCE"));
264 assert!(msg.contains("expired"));
265 assert!(msg.contains("auth login"));
266 }
267
268 #[test]
269 fn invalid_grant_refresh_display_mentions_7_days_and_testing_mode() {
270 let err = DriveError::InvalidGrant(GrantContext::Refresh);
271 let msg = err.to_string();
272 assert!(msg.contains("7 days"));
273 assert!(msg.contains("Testing"));
274 assert!(msg.contains("auth login"));
275 }
276
277 #[test]
278 fn grant_context_variants_produce_distinct_messages() {
279 let code = GrantContext::CodeExchange.to_string();
280 let refresh = GrantContext::Refresh.to_string();
281 assert_ne!(code, refresh);
282 }
283
284 #[test]
285 fn malformed_token_response_names_the_missing_field() {
286 let err = DriveError::MalformedTokenResponse("refresh_token");
287 assert!(err.to_string().contains("refresh_token"));
288 }
289
290 #[test]
291 fn invalid_browser_command_display_includes_detail() {
292 let err = DriveError::InvalidBrowserCommand("empty command".to_string());
293 assert!(err.to_string().contains("empty command"));
294 }
295
296 #[test]
297 fn no_scope_granted_display_names_the_received_scopes() {
298 let err = DriveError::NoScopeGranted("openid, email, profile".to_string());
299 let msg = err.to_string();
300 assert!(msg.contains("openid, email, profile"));
301 assert!(msg.contains("consent screen"));
302 assert!(msg.contains("auth login"));
303 }
304}