1use std::io::IsTerminal;
2use std::path::Path;
3use std::process::Command;
4use std::{fs, io};
5
6use crate::auth::output;
7
8const GOOGLE_USERINFO_URL: &str = "https://openidconnect.googleapis.com/v1/userinfo";
9
10#[derive(Copy, Clone, Debug, Eq, PartialEq)]
11enum LoginMethod {
12 GeminiBrowser,
13 GeminiDeviceCode,
14 ApiKey,
15}
16
17pub fn run(api_key: bool, device_code: bool) -> i32 {
18 run_with_json(api_key, device_code, false)
19}
20
21pub fn run_with_json(api_key: bool, device_code: bool, output_json: bool) -> i32 {
22 let method = match resolve_method(api_key, device_code) {
23 Ok(method) => method,
24 Err((code, message, details)) => {
25 if output_json {
26 let _ = output::emit_error("auth login", "invalid-usage", message, details);
27 } else {
28 eprintln!("{message}");
29 }
30 return code;
31 }
32 };
33
34 if method == LoginMethod::ApiKey {
35 return run_api_key_login(output_json);
36 }
37
38 run_oauth_login(method, output_json)
39}
40
41fn run_api_key_login(output_json: bool) -> i32 {
42 let source = if std::env::var("GEMINI_API_KEY")
43 .ok()
44 .filter(|value| !value.trim().is_empty())
45 .is_some()
46 {
47 Some("GEMINI_API_KEY")
48 } else if std::env::var("GOOGLE_API_KEY")
49 .ok()
50 .filter(|value| !value.trim().is_empty())
51 .is_some()
52 {
53 Some("GOOGLE_API_KEY")
54 } else {
55 None
56 };
57
58 let Some(source) = source else {
59 if output_json {
60 let _ = output::emit_error(
61 "auth login",
62 "missing-api-key",
63 "gemini-login: set GEMINI_API_KEY or GOOGLE_API_KEY before using --api-key",
64 None,
65 );
66 } else {
67 eprintln!("gemini-login: set GEMINI_API_KEY or GOOGLE_API_KEY before using --api-key");
68 }
69 return 64;
70 };
71
72 if output_json {
73 let _ = output::emit_result(
74 "auth login",
75 output::obj(vec![
76 ("method", output::s("api-key")),
77 ("provider", output::s("gemini-api")),
78 ("completed", output::b(true)),
79 ("source", output::s(source)),
80 ]),
81 );
82 } else {
83 println!("gemini: login complete (method: api-key)");
84 }
85
86 0
87}
88
89fn run_oauth_login(method: LoginMethod, output_json: bool) -> i32 {
90 if output_json {
91 return run_oauth_session_check(method, true);
92 }
93
94 let interactive_terminal = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
95 if !interactive_terminal {
96 return run_oauth_session_check(method, false);
98 }
99
100 run_oauth_interactive_login(method)
101}
102
103fn run_oauth_session_check(method: LoginMethod, output_json: bool) -> i32 {
104 let auth_file = match crate::paths::resolve_auth_file() {
105 Some(path) => path,
106 None => {
107 emit_login_error(
108 output_json,
109 "auth-file-not-configured",
110 "gemini-login: GEMINI_AUTH_FILE is not configured".to_string(),
111 None,
112 );
113 return 1;
114 }
115 };
116
117 if !auth_file.is_file() {
118 emit_login_error(
119 output_json,
120 "auth-file-not-found",
121 format!("gemini-login: auth file not found: {}", auth_file.display()),
122 Some(output::obj(vec![(
123 "auth_file",
124 output::s(auth_file.display().to_string()),
125 )])),
126 );
127 return 1;
128 }
129
130 let mut refresh_attempted = false;
131 if has_refresh_token(&auth_file) {
132 refresh_attempted = true;
133 let _ = crate::auth::refresh::run_silent(&[]);
135 }
136
137 let auth_json = match crate::json::read_json(&auth_file) {
138 Ok(value) => value,
139 Err(err) => {
140 emit_login_error(
141 output_json,
142 "auth-read-failed",
143 format!(
144 "gemini-login: failed to read auth file {}",
145 auth_file.display()
146 ),
147 Some(output::obj(vec![
148 ("auth_file", output::s(auth_file.display().to_string())),
149 ("error", output::s(err.to_string())),
150 ])),
151 );
152 return 1;
153 }
154 };
155
156 let access_token = access_token_from_json(&auth_json);
157 let access_token = match access_token {
158 Some(token) => token,
159 None => {
160 emit_login_error(
161 output_json,
162 "missing-access-token",
163 format!(
164 "gemini-login: missing access token in {}",
165 auth_file.display()
166 ),
167 Some(output::obj(vec![(
168 "auth_file",
169 output::s(auth_file.display().to_string()),
170 )])),
171 );
172 return 2;
173 }
174 };
175
176 let userinfo = match fetch_google_userinfo(&access_token) {
177 Ok(value) => value,
178 Err(err) => {
179 emit_login_error(output_json, err.code, err.message, err.details);
180 return err.exit_code;
181 }
182 };
183
184 let email = userinfo
185 .get("email")
186 .and_then(|value| value.as_str())
187 .unwrap_or_default()
188 .to_string();
189
190 if output_json {
191 let _ = output::emit_result(
192 "auth login",
193 output::obj(vec![
194 ("method", output::s(method.as_str())),
195 ("provider", output::s(method.provider())),
196 ("completed", output::b(true)),
197 ("auth_file", output::s(auth_file.display().to_string())),
198 (
199 "email",
200 if email.is_empty() {
201 output::null()
202 } else {
203 output::s(email)
204 },
205 ),
206 ("refresh_attempted", output::b(refresh_attempted)),
207 ]),
208 );
209 } else {
210 println!("gemini: login complete (method: {})", method.as_str());
211 }
212
213 0
214}
215
216fn run_oauth_interactive_login(method: LoginMethod) -> i32 {
217 let auth_file = match crate::paths::resolve_auth_file() {
218 Some(path) => path,
219 None => {
220 emit_login_error(
221 false,
222 "auth-file-not-configured",
223 "gemini-login: GEMINI_AUTH_FILE is not configured".to_string(),
224 None,
225 );
226 return 1;
227 }
228 };
229
230 let backup = match backup_auth_file(&auth_file) {
231 Ok(backup) => backup,
232 Err(err) => {
233 emit_login_error(false, "auth-read-failed", err.to_string(), None);
234 return 1;
235 }
236 };
237
238 if let Some(parent) = auth_file.parent()
239 && let Err(err) = fs::create_dir_all(parent)
240 {
241 emit_login_error(
242 false,
243 "auth-dir-create-failed",
244 format!(
245 "gemini-login: failed to prepare auth directory {}: {err}",
246 parent.display()
247 ),
248 Some(output::obj(vec![(
249 "auth_file",
250 output::s(auth_file.display().to_string()),
251 )])),
252 );
253 return 1;
254 }
255
256 if auth_file.is_file()
257 && let Err(err) = fs::remove_file(&auth_file)
258 {
259 emit_login_error(
260 false,
261 "auth-file-remove-failed",
262 format!(
263 "gemini-login: failed to remove auth file {}: {err}",
264 auth_file.display()
265 ),
266 Some(output::obj(vec![(
267 "auth_file",
268 output::s(auth_file.display().to_string()),
269 )])),
270 );
271 return 1;
272 }
273
274 if method == LoginMethod::GeminiBrowser {
275 println!("Code Assist login required. Opening authentication page in your browser.");
276 }
277
278 let status = match run_gemini_interactive_login(method, &auth_file) {
279 Ok(status) => status,
280 Err(err) => {
281 let _ = restore_auth_backup(&auth_file, backup.as_deref());
282 emit_login_error(false, err.code, err.message, err.details);
283 return err.exit_code;
284 }
285 };
286
287 if !status.success() {
288 let _ = restore_auth_backup(&auth_file, backup.as_deref());
289 let exit_code = status.code().unwrap_or(1).max(1);
290 emit_login_error(
291 false,
292 "login-failed",
293 format!("gemini-login: login failed for method {}", method.as_str()),
294 Some(output::obj(vec![
295 ("method", output::s(method.as_str())),
296 ("exit_code", output::n(i64::from(exit_code))),
297 ])),
298 );
299 return exit_code;
300 }
301
302 let auth_json = match crate::json::read_json(&auth_file) {
303 Ok(value) => value,
304 Err(err) => {
305 let _ = restore_auth_backup(&auth_file, backup.as_deref());
306 emit_login_error(
307 false,
308 "auth-read-failed",
309 format!(
310 "gemini-login: login completed but failed to read auth file {}: {err}",
311 auth_file.display()
312 ),
313 Some(output::obj(vec![(
314 "auth_file",
315 output::s(auth_file.display().to_string()),
316 )])),
317 );
318 return 1;
319 }
320 };
321
322 let access_token = match access_token_from_json(&auth_json) {
323 Some(token) => token,
324 None => {
325 let _ = restore_auth_backup(&auth_file, backup.as_deref());
326 emit_login_error(
327 false,
328 "missing-access-token",
329 format!(
330 "gemini-login: login completed but auth file is missing access token: {}",
331 auth_file.display()
332 ),
333 Some(output::obj(vec![(
334 "auth_file",
335 output::s(auth_file.display().to_string()),
336 )])),
337 );
338 return 2;
339 }
340 };
341
342 if let Err(err) = fetch_google_userinfo(&access_token) {
343 let _ = restore_auth_backup(&auth_file, backup.as_deref());
344 emit_login_error(false, err.code, err.message, err.details);
345 return err.exit_code;
346 }
347
348 println!("gemini: login complete (method: {})", method.as_str());
349 0
350}
351
352fn backup_auth_file(path: &Path) -> io::Result<Option<Vec<u8>>> {
353 if !path.is_file() {
354 return Ok(None);
355 }
356 fs::read(path).map(Some)
357}
358
359fn restore_auth_backup(path: &Path, backup: Option<&[u8]>) -> io::Result<()> {
360 match backup {
361 Some(contents) => crate::auth::write_atomic(path, contents, crate::auth::SECRET_FILE_MODE),
362 None => {
363 if path.is_file() {
364 fs::remove_file(path)
365 } else {
366 Ok(())
367 }
368 }
369 }
370}
371
372fn run_gemini_interactive_login(
373 method: LoginMethod,
374 auth_file: &Path,
375) -> Result<std::process::ExitStatus, LoginError> {
376 let mut command = Command::new("gemini");
377 command.arg("--prompt-interactive").arg("/quit");
378 if method == LoginMethod::GeminiBrowser {
379 command.arg("--yolo");
381 }
382 command.env("GEMINI_AUTH_FILE", auth_file.to_string_lossy().to_string());
383
384 if method == LoginMethod::GeminiDeviceCode {
385 command.env("NO_BROWSER", "true");
386 } else {
387 command.env_remove("NO_BROWSER");
388 }
389
390 let status = command.status().map_err(|_| LoginError {
391 code: "login-exec-failed",
392 message: format!(
393 "gemini-login: failed to run `gemini` for method {}",
394 method.as_str()
395 ),
396 details: Some(output::obj(vec![("method", output::s(method.as_str()))])),
397 exit_code: 1,
398 })?;
399
400 if !auth_file.is_file() {
401 return Err(LoginError {
402 code: "auth-file-not-found",
403 message: format!(
404 "gemini-login: interactive login did not produce auth file: {}",
405 auth_file.display()
406 ),
407 details: Some(output::obj(vec![
408 ("method", output::s(method.as_str())),
409 ("auth_file", output::s(auth_file.display().to_string())),
410 ("exit_code", output::n(status.code().unwrap_or(0) as i64)),
411 ])),
412 exit_code: 1,
413 });
414 }
415
416 Ok(status)
417}
418
419struct LoginError {
420 code: &'static str,
421 message: String,
422 details: Option<output::JsonValue>,
423 exit_code: i32,
424}
425
426fn fetch_google_userinfo(access_token: &str) -> Result<serde_json::Value, LoginError> {
427 let connect_timeout = env_timeout("GEMINI_LOGIN_CURL_CONNECT_TIMEOUT_SECONDS", 2);
428 let max_time = env_timeout("GEMINI_LOGIN_CURL_MAX_TIME_SECONDS", 8);
429
430 let response = Command::new("curl")
431 .arg("-sS")
432 .arg("--connect-timeout")
433 .arg(connect_timeout.to_string())
434 .arg("--max-time")
435 .arg(max_time.to_string())
436 .arg("-H")
437 .arg(format!("Authorization: Bearer {access_token}"))
438 .arg("-H")
439 .arg("Accept: application/json")
440 .arg(GOOGLE_USERINFO_URL)
441 .arg("-w")
442 .arg("\n__HTTP_STATUS__:%{http_code}")
443 .output()
444 .map_err(|_| LoginError {
445 code: "login-request-failed",
446 message: format!("gemini-login: failed to query {GOOGLE_USERINFO_URL}"),
447 details: Some(output::obj(vec![(
448 "endpoint",
449 output::s(GOOGLE_USERINFO_URL),
450 )])),
451 exit_code: 3,
452 })?;
453
454 if !response.status.success() {
455 return Err(LoginError {
456 code: "login-request-failed",
457 message: format!("gemini-login: failed to query {GOOGLE_USERINFO_URL}"),
458 details: Some(output::obj(vec![(
459 "endpoint",
460 output::s(GOOGLE_USERINFO_URL),
461 )])),
462 exit_code: 3,
463 });
464 }
465
466 let response_text = String::from_utf8_lossy(&response.stdout).to_string();
467 let (body, http_status) = split_http_status_marker(&response_text);
468 if http_status != 200 {
469 let summary = http_error_summary(&body);
470 let mut details = vec![
471 ("endpoint".to_string(), output::s(GOOGLE_USERINFO_URL)),
472 ("http_status".to_string(), output::n(http_status as i64)),
473 ];
474 if let Some(summary) = summary {
475 details.push(("summary".to_string(), output::s(summary)));
476 }
477 return Err(LoginError {
478 code: "login-http-error",
479 message: format!(
480 "gemini-login: userinfo request failed (HTTP {http_status}) at {GOOGLE_USERINFO_URL}"
481 ),
482 details: Some(output::obj_dynamic(details)),
483 exit_code: 3,
484 });
485 }
486
487 let json: serde_json::Value = serde_json::from_str(&body).map_err(|_| LoginError {
488 code: "login-invalid-json",
489 message: "gemini-login: userinfo endpoint returned invalid JSON".to_string(),
490 details: Some(output::obj(vec![(
491 "endpoint",
492 output::s(GOOGLE_USERINFO_URL),
493 )])),
494 exit_code: 4,
495 })?;
496 Ok(json)
497}
498
499fn has_refresh_token(auth_file: &Path) -> bool {
500 let value = match crate::json::read_json(auth_file) {
501 Ok(value) => value,
502 Err(_) => return false,
503 };
504 refresh_token_from_json(&value).is_some()
505}
506
507fn access_token_from_json(value: &serde_json::Value) -> Option<String> {
508 crate::json::string_at(value, &["tokens", "access_token"])
509 .or_else(|| crate::json::string_at(value, &["access_token"]))
510}
511
512fn refresh_token_from_json(value: &serde_json::Value) -> Option<String> {
513 crate::json::string_at(value, &["tokens", "refresh_token"])
514 .or_else(|| crate::json::string_at(value, &["refresh_token"]))
515}
516
517fn split_http_status_marker(raw: &str) -> (String, u16) {
518 let marker = "__HTTP_STATUS__:";
519 if let Some(index) = raw.rfind(marker) {
520 let body = raw[..index]
521 .trim_end_matches('\n')
522 .trim_end_matches('\r')
523 .to_string();
524 let status_raw = raw[index + marker.len()..].trim();
525 let status = status_raw.parse::<u16>().unwrap_or(0);
526 (body, status)
527 } else {
528 (raw.to_string(), 0)
529 }
530}
531
532fn http_error_summary(body: &str) -> Option<String> {
533 let value: serde_json::Value = serde_json::from_str(body).ok()?;
534 let mut parts = Vec::new();
535
536 if let Some(error) = value.get("error") {
537 if let Some(error_str) = error.as_str() {
538 if !error_str.is_empty() {
539 parts.push(error_str.to_string());
540 }
541 } else if let Some(error_obj) = error.as_object() {
542 if let Some(status) = error_obj.get("status").and_then(|value| value.as_str())
543 && !status.is_empty()
544 {
545 parts.push(status.to_string());
546 }
547 if let Some(message) = error_obj.get("message").and_then(|value| value.as_str())
548 && !message.is_empty()
549 {
550 parts.push(message.to_string());
551 }
552 }
553 }
554
555 if let Some(desc) = value
556 .get("error_description")
557 .and_then(|value| value.as_str())
558 && !desc.is_empty()
559 {
560 parts.push(desc.to_string());
561 }
562
563 if parts.is_empty() {
564 None
565 } else {
566 Some(parts.join(": "))
567 }
568}
569
570fn env_timeout(key: &str, default: u64) -> u64 {
571 std::env::var(key)
572 .ok()
573 .and_then(|raw| raw.parse::<u64>().ok())
574 .unwrap_or(default)
575}
576
577fn emit_login_error(
578 output_json: bool,
579 code: &str,
580 message: String,
581 details: Option<output::JsonValue>,
582) {
583 if output_json {
584 let _ = output::emit_error("auth login", code, message, details);
585 } else {
586 eprintln!("{message}");
587 }
588}
589
590fn resolve_method(
591 api_key: bool,
592 device_code: bool,
593) -> std::result::Result<LoginMethod, ErrorTriplet> {
594 if api_key && device_code {
595 return Err((
596 64,
597 "gemini-login: --api-key cannot be combined with --device-code".to_string(),
598 Some(output::obj(vec![
599 ("api_key", output::b(true)),
600 ("device_code", output::b(true)),
601 ])),
602 ));
603 }
604
605 if api_key {
606 return Ok(LoginMethod::ApiKey);
607 }
608 if device_code {
609 return Ok(LoginMethod::GeminiDeviceCode);
610 }
611 Ok(LoginMethod::GeminiBrowser)
612}
613
614type ErrorTriplet = (i32, String, Option<output::JsonValue>);
615
616impl LoginMethod {
617 fn as_str(self) -> &'static str {
618 match self {
619 Self::GeminiBrowser => "gemini-browser",
620 Self::GeminiDeviceCode => "gemini-device-code",
621 Self::ApiKey => "api-key",
622 }
623 }
624
625 fn provider(self) -> &'static str {
626 match self {
627 Self::GeminiBrowser | Self::GeminiDeviceCode => "gemini",
628 Self::ApiKey => "gemini-api",
629 }
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use std::ffi::OsStr;
636 use std::fs;
637
638 use nils_test_support::fs as test_fs;
639 use nils_test_support::{EnvGuard, GlobalStateLock, StubBinDir, prepend_path};
640 use pretty_assertions::assert_eq;
641 use serde_json::json;
642 use tempfile::TempDir;
643
644 use super::{
645 LoginMethod, access_token_from_json, backup_auth_file, env_timeout, fetch_google_userinfo,
646 has_refresh_token, http_error_summary, refresh_token_from_json, resolve_method,
647 restore_auth_backup, run, run_api_key_login, run_gemini_interactive_login,
648 run_oauth_interactive_login, run_oauth_session_check, run_with_json,
649 split_http_status_marker,
650 };
651
652 fn set_env(lock: &GlobalStateLock, key: &str, value: impl AsRef<OsStr>) -> EnvGuard {
653 let value = value.as_ref().to_string_lossy().into_owned();
654 EnvGuard::set(lock, key, &value)
655 }
656
657 fn remove_env(lock: &GlobalStateLock, key: &str) -> EnvGuard {
658 EnvGuard::remove(lock, key)
659 }
660
661 fn curl_success_script() -> &'static str {
662 r#"#!/bin/sh
663set -eu
664cat <<'EOF'
665{"email":"alpha@example.com"}
666__HTTP_STATUS__:200
667EOF
668"#
669 }
670
671 fn curl_http_error_script() -> &'static str {
672 r#"#!/bin/sh
673set -eu
674cat <<'EOF'
675{"error":{"status":"UNAUTHENTICATED","message":"token expired"},"error_description":"refresh needed"}
676__HTTP_STATUS__:401
677EOF
678"#
679 }
680
681 fn curl_invalid_json_script() -> &'static str {
682 r#"#!/bin/sh
683set -eu
684cat <<'EOF'
685not-json
686__HTTP_STATUS__:200
687EOF
688"#
689 }
690
691 fn curl_exit_failure_script() -> &'static str {
692 r#"#!/bin/sh
693exit 9
694"#
695 }
696
697 #[test]
698 fn run_delegates_to_run_with_json_non_json_mode() {
699 let lock = GlobalStateLock::new();
700 let _api = set_env(&lock, "GEMINI_API_KEY", "dummy");
701 let _google = remove_env(&lock, "GOOGLE_API_KEY");
702 assert_eq!(run(true, false), 0);
703 }
704
705 #[test]
706 fn run_with_json_reports_invalid_usage_for_conflicting_flags() {
707 assert_eq!(run_with_json(true, true, true), 64);
708 }
709
710 #[test]
711 fn run_api_key_login_json_errors_when_keys_are_missing() {
712 let lock = GlobalStateLock::new();
713 let _api = set_env(&lock, "GEMINI_API_KEY", "");
714 let _google = set_env(&lock, "GOOGLE_API_KEY", "");
715 assert_eq!(run_api_key_login(true), 64);
716 }
717
718 #[test]
719 fn run_api_key_login_uses_google_api_key_when_gemini_key_missing() {
720 let lock = GlobalStateLock::new();
721 let _api = set_env(&lock, "GEMINI_API_KEY", "");
722 let _google = set_env(&lock, "GOOGLE_API_KEY", "google-key");
723 assert_eq!(run_api_key_login(true), 0);
724 }
725
726 #[test]
727 fn run_oauth_session_check_missing_auth_file_returns_error() {
728 let lock = GlobalStateLock::new();
729 let temp = TempDir::new().expect("temp dir");
730 let auth_file = temp.path().join("missing-auth.json");
731 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
732 assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 1);
733 }
734
735 #[test]
736 fn run_oauth_session_check_invalid_auth_json_returns_error() {
737 let lock = GlobalStateLock::new();
738 let temp = TempDir::new().expect("temp dir");
739 let auth_file = temp.path().join("oauth.json");
740 test_fs::write_text(&auth_file, "{invalid");
741 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
742 assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 1);
743 }
744
745 #[test]
746 fn run_oauth_session_check_missing_access_token_returns_error() {
747 let lock = GlobalStateLock::new();
748 let temp = TempDir::new().expect("temp dir");
749 let auth_file = temp.path().join("oauth.json");
750 test_fs::write_text(&auth_file, "{}");
751 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
752 assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 2);
753 }
754
755 #[test]
756 fn run_oauth_session_check_http_error_returns_error() {
757 let lock = GlobalStateLock::new();
758 let temp = TempDir::new().expect("temp dir");
759 let stubs = StubBinDir::new();
760 stubs.write_exe("curl", curl_http_error_script());
761
762 let auth_file = temp.path().join("oauth.json");
763 test_fs::write_text(&auth_file, r#"{"access_token":"tok"}"#);
764 let _path = prepend_path(&lock, stubs.path());
765 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
766 assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 3);
767 }
768
769 #[test]
770 fn run_oauth_session_check_invalid_userinfo_json_returns_error() {
771 let lock = GlobalStateLock::new();
772 let temp = TempDir::new().expect("temp dir");
773 let stubs = StubBinDir::new();
774 stubs.write_exe("curl", curl_invalid_json_script());
775
776 let auth_file = temp.path().join("oauth.json");
777 test_fs::write_text(&auth_file, r#"{"access_token":"tok"}"#);
778 let _path = prepend_path(&lock, stubs.path());
779 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
780 assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 4);
781 }
782
783 #[test]
784 fn run_oauth_session_check_success_supports_nested_tokens() {
785 let lock = GlobalStateLock::new();
786 let temp = TempDir::new().expect("temp dir");
787 let stubs = StubBinDir::new();
788 stubs.write_exe("curl", curl_success_script());
789
790 let auth_file = temp.path().join("oauth.json");
791 test_fs::write_text(
792 &auth_file,
793 r#"{"tokens":{"access_token":"tok","refresh_token":"refresh-token"}}"#,
794 );
795 let _path = prepend_path(&lock, stubs.path());
796 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
797 assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 0);
798 }
799
800 #[test]
801 fn run_oauth_interactive_login_success_device_code_returns_zero() {
802 let lock = GlobalStateLock::new();
803 let temp = TempDir::new().expect("temp dir");
804 let stubs = StubBinDir::new();
805 stubs.write_exe("curl", curl_success_script());
806 stubs.write_exe(
807 "gemini",
808 r#"#!/bin/sh
809set -eu
810[ "${NO_BROWSER:-}" = "true" ]
811cat > "$GEMINI_AUTH_FILE" <<'EOF'
812{"access_token":"new-token"}
813EOF
814"#,
815 );
816
817 let auth_file = temp.path().join("oauth.json");
818 test_fs::write_text(&auth_file, r#"{"access_token":"old-token"}"#);
819 let _path = prepend_path(&lock, stubs.path());
820 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
821 assert_eq!(
822 run_oauth_interactive_login(LoginMethod::GeminiDeviceCode),
823 0
824 );
825 let updated = fs::read_to_string(&auth_file).expect("read auth");
826 assert!(updated.contains("new-token"));
827 }
828
829 #[test]
830 fn run_oauth_interactive_login_non_zero_status_restores_backup() {
831 let lock = GlobalStateLock::new();
832 let temp = TempDir::new().expect("temp dir");
833 let stubs = StubBinDir::new();
834 stubs.write_exe(
835 "gemini",
836 r#"#!/bin/sh
837set -eu
838cat > "$GEMINI_AUTH_FILE" <<'EOF'
839{"access_token":"new-token"}
840EOF
841exit 7
842"#,
843 );
844
845 let auth_file = temp.path().join("oauth.json");
846 let original = r#"{"access_token":"old-token"}"#;
847 test_fs::write_text(&auth_file, original);
848 let _path = prepend_path(&lock, stubs.path());
849 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
850 assert_eq!(run_oauth_interactive_login(LoginMethod::GeminiBrowser), 7);
851 assert_eq!(fs::read_to_string(&auth_file).expect("read auth"), original);
852 }
853
854 #[test]
855 fn run_oauth_interactive_login_missing_token_restores_backup() {
856 let lock = GlobalStateLock::new();
857 let temp = TempDir::new().expect("temp dir");
858 let stubs = StubBinDir::new();
859 stubs.write_exe(
860 "gemini",
861 r#"#!/bin/sh
862set -eu
863cat > "$GEMINI_AUTH_FILE" <<'EOF'
864{}
865EOF
866"#,
867 );
868
869 let auth_file = temp.path().join("oauth.json");
870 let original = r#"{"access_token":"old-token"}"#;
871 test_fs::write_text(&auth_file, original);
872 let _path = prepend_path(&lock, stubs.path());
873 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
874 assert_eq!(run_oauth_interactive_login(LoginMethod::GeminiBrowser), 2);
875 assert_eq!(fs::read_to_string(&auth_file).expect("read auth"), original);
876 }
877
878 #[test]
879 fn run_oauth_interactive_login_userinfo_error_restores_backup() {
880 let lock = GlobalStateLock::new();
881 let temp = TempDir::new().expect("temp dir");
882 let stubs = StubBinDir::new();
883 stubs.write_exe("curl", curl_http_error_script());
884 stubs.write_exe(
885 "gemini",
886 r#"#!/bin/sh
887set -eu
888cat > "$GEMINI_AUTH_FILE" <<'EOF'
889{"access_token":"new-token"}
890EOF
891"#,
892 );
893
894 let auth_file = temp.path().join("oauth.json");
895 let original = r#"{"access_token":"old-token"}"#;
896 test_fs::write_text(&auth_file, original);
897 let _path = prepend_path(&lock, stubs.path());
898 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
899 assert_eq!(run_oauth_interactive_login(LoginMethod::GeminiBrowser), 3);
900 assert_eq!(fs::read_to_string(&auth_file).expect("read auth"), original);
901 }
902
903 #[test]
904 fn run_gemini_interactive_login_errors_when_auth_file_not_created() {
905 let lock = GlobalStateLock::new();
906 let temp = TempDir::new().expect("temp dir");
907 let stubs = StubBinDir::new();
908 stubs.write_exe(
909 "gemini",
910 r#"#!/bin/sh
911exit 0
912"#,
913 );
914 let _path = prepend_path(&lock, stubs.path());
915 let auth_file = temp.path().join("missing-output.json");
916 let err = run_gemini_interactive_login(LoginMethod::GeminiBrowser, &auth_file)
917 .expect_err("missing output file should fail");
918 assert_eq!(err.code, "auth-file-not-found");
919 assert_eq!(err.exit_code, 1);
920 }
921
922 #[test]
923 fn fetch_google_userinfo_handles_command_failures_and_invalid_json() {
924 let lock = GlobalStateLock::new();
925 let stubs = StubBinDir::new();
926
927 stubs.write_exe("curl", curl_exit_failure_script());
928 let _path = prepend_path(&lock, stubs.path());
929 let request_err =
930 fetch_google_userinfo("token").expect_err("non-zero curl exit should be an error");
931 assert_eq!(request_err.code, "login-request-failed");
932 assert_eq!(request_err.exit_code, 3);
933
934 stubs.write_exe("curl", curl_invalid_json_script());
935 let invalid_json_err =
936 fetch_google_userinfo("token").expect_err("invalid payload should fail");
937 assert_eq!(invalid_json_err.code, "login-invalid-json");
938 assert_eq!(invalid_json_err.exit_code, 4);
939 }
940
941 #[test]
942 fn split_http_status_marker_and_error_summary_are_stable() {
943 let (body, status) = split_http_status_marker("{\"ok\":true}\n__HTTP_STATUS__:200");
944 assert_eq!(body, "{\"ok\":true}");
945 assert_eq!(status, 200);
946
947 let (body_without_marker, status_without_marker) = split_http_status_marker("plain-body");
948 assert_eq!(body_without_marker, "plain-body");
949 assert_eq!(status_without_marker, 0);
950
951 let summary = http_error_summary(
952 r#"{"error":{"status":"UNAUTHENTICATED","message":"token expired"},"error_description":"reauth"}"#,
953 );
954 assert_eq!(
955 summary,
956 Some("UNAUTHENTICATED: token expired: reauth".to_string())
957 );
958 }
959
960 #[test]
961 fn env_timeout_and_token_helpers_cover_defaults_and_nested_values() {
962 let lock = GlobalStateLock::new();
963 let _timeout = set_env(&lock, "GEMINI_LOGIN_CURL_MAX_TIME_SECONDS", "11");
964 assert_eq!(env_timeout("GEMINI_LOGIN_CURL_MAX_TIME_SECONDS", 8), 11);
965 assert_eq!(env_timeout("GEMINI_LOGIN_CURL_UNKNOWN", 5), 5);
966
967 let nested =
968 json!({"tokens":{"access_token":"nested-access","refresh_token":"nested-refresh"}});
969 assert_eq!(
970 access_token_from_json(&nested),
971 Some("nested-access".to_string())
972 );
973 assert_eq!(
974 refresh_token_from_json(&nested),
975 Some("nested-refresh".to_string())
976 );
977
978 let top_level = json!({"access_token":"top-access","refresh_token":"top-refresh"});
979 assert_eq!(
980 access_token_from_json(&top_level),
981 Some("top-access".to_string())
982 );
983 assert_eq!(
984 refresh_token_from_json(&top_level),
985 Some("top-refresh".to_string())
986 );
987 }
988
989 #[test]
990 fn backup_restore_and_refresh_detection_behave_as_expected() {
991 let temp = TempDir::new().expect("temp dir");
992 let auth_file = temp.path().join("oauth.json");
993
994 assert_eq!(
995 backup_auth_file(&auth_file).expect("backup missing file"),
996 None
997 );
998 assert_eq!(has_refresh_token(&auth_file), false);
999
1000 fs::write(&auth_file, r#"{"refresh_token":"refresh"}"#).expect("write auth");
1001 assert_eq!(has_refresh_token(&auth_file), true);
1002
1003 let backup = backup_auth_file(&auth_file).expect("backup existing file");
1004 fs::write(&auth_file, r#"{"access_token":"mutated"}"#).expect("mutate auth");
1005 restore_auth_backup(&auth_file, backup.as_deref()).expect("restore backup");
1006 assert_eq!(
1007 fs::read_to_string(&auth_file).expect("read restored auth"),
1008 r#"{"refresh_token":"refresh"}"#
1009 );
1010
1011 restore_auth_backup(&auth_file, None).expect("remove backup target");
1012 assert_eq!(auth_file.exists(), false);
1013 }
1014
1015 #[test]
1016 fn resolve_method_defaults_to_gemini_browser() {
1017 assert_eq!(
1018 resolve_method(false, false).expect("method"),
1019 LoginMethod::GeminiBrowser
1020 );
1021 }
1022
1023 #[test]
1024 fn resolve_method_selects_device_code_and_api_key() {
1025 assert_eq!(
1026 resolve_method(false, true).expect("method"),
1027 LoginMethod::GeminiDeviceCode
1028 );
1029 assert_eq!(
1030 resolve_method(true, false).expect("method"),
1031 LoginMethod::ApiKey
1032 );
1033 }
1034
1035 #[test]
1036 fn resolve_method_rejects_conflicting_flags() {
1037 let err = resolve_method(true, true).expect_err("conflict should fail");
1038 assert_eq!(err.0, 64);
1039 assert!(err.1.contains("--api-key"));
1040 }
1041
1042 #[test]
1043 fn login_method_strings_and_providers_are_stable() {
1044 assert_eq!(LoginMethod::GeminiBrowser.as_str(), "gemini-browser");
1045 assert_eq!(LoginMethod::GeminiDeviceCode.as_str(), "gemini-device-code");
1046 assert_eq!(LoginMethod::ApiKey.as_str(), "api-key");
1047
1048 assert_eq!(LoginMethod::GeminiBrowser.provider(), "gemini");
1049 assert_eq!(LoginMethod::GeminiDeviceCode.provider(), "gemini");
1050 assert_eq!(LoginMethod::ApiKey.provider(), "gemini-api");
1051 }
1052}