1use std::path::{Path, PathBuf};
2
3use crate::auth;
4use crate::auth::output;
5
6pub fn run() -> i32 {
7 run_with_json(false)
8}
9
10pub fn run_with_json(output_json: bool) -> i32 {
11 if !is_configured() {
12 if output_json {
13 let _ = output::emit_result(
14 "auth auto-refresh",
15 output::obj(vec![
16 ("refreshed", output::n(0)),
17 ("skipped", output::n(0)),
18 ("failed", output::n(0)),
19 ("min_age_days", output::n(0)),
20 ("targets", output::arr(Vec::new())),
21 ]),
22 );
23 }
24 return 0;
25 }
26
27 let min_days_raw =
28 std::env::var("GEMINI_AUTO_REFRESH_MIN_DAYS").unwrap_or_else(|_| "5".to_string());
29 let min_days = match min_days_raw.parse::<i64>() {
30 Ok(value) => value,
31 Err(_) => {
32 if output_json {
33 let _ = output::emit_error(
34 "auth auto-refresh",
35 "invalid-min-days",
36 format!(
37 "gemini-auto-refresh: invalid GEMINI_AUTO_REFRESH_MIN_DAYS: {}",
38 min_days_raw
39 ),
40 Some(output::obj(vec![("value", output::s(min_days_raw))])),
41 );
42 } else {
43 eprintln!(
44 "gemini-auto-refresh: invalid GEMINI_AUTO_REFRESH_MIN_DAYS: {}",
45 min_days_raw
46 );
47 }
48 return 64;
49 }
50 };
51
52 let min_seconds = min_days.saturating_mul(86_400);
53 let now_epoch = auth::now_epoch_seconds();
54
55 let auth_file = crate::paths::resolve_auth_file();
56 if auth_file.is_some() {
57 let sync_rc = auth::sync::run_with_json(false);
58 if sync_rc != 0 {
59 if output_json {
60 let _ = output::emit_error(
61 "auth auto-refresh",
62 "sync-failed",
63 "gemini-auto-refresh: failed to sync auth and secrets before refresh",
64 None,
65 );
66 }
67 return 1;
68 }
69 }
70
71 let mut targets = Vec::new();
72 if let Some(auth_file) = auth_file.as_ref() {
73 targets.push(auth_file.clone());
74 }
75 if let Some(secret_dir) = crate::paths::resolve_secret_dir()
76 && let Ok(entries) = std::fs::read_dir(&secret_dir)
77 {
78 for entry in entries.flatten() {
79 let path = entry.path();
80 if path.extension().and_then(|s| s.to_str()) == Some("json") {
81 targets.push(path);
82 }
83 }
84 }
85
86 let mut refreshed: i64 = 0;
87 let mut skipped: i64 = 0;
88 let mut failed: i64 = 0;
89 let mut target_results: Vec<output::JsonValue> = Vec::new();
90
91 for target in targets {
92 if !target.is_file() {
93 if auth_file.as_ref().map(|p| p == &target).unwrap_or(false) {
94 skipped += 1;
95 target_results.push(target_result(&target, "skipped", Some("auth-file-missing")));
96 continue;
97 }
98 if !output_json {
99 eprintln!("gemini-auto-refresh: missing file: {}", target.display());
100 }
101 failed += 1;
102 target_results.push(target_result(&target, "failed", Some("missing-file")));
103 continue;
104 }
105
106 let timestamp_path = timestamp_path(&target);
107 match should_refresh(&target, timestamp_path.as_deref(), now_epoch, min_seconds) {
108 RefreshDecision::Refresh => {
109 let rc = if auth_file.as_ref().map(|p| p == &target).unwrap_or(false) {
110 if output_json {
111 auth::refresh::run_silent(&[])
112 } else {
113 auth::refresh::run(&[])
114 }
115 } else {
116 let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("");
117 if output_json {
118 auth::refresh::run_silent(&[name.to_string()])
119 } else {
120 auth::refresh::run(&[name.to_string()])
121 }
122 };
123
124 if rc == 0 {
125 refreshed += 1;
126 target_results.push(target_result(&target, "refreshed", None));
127 } else {
128 failed += 1;
129 target_results.push(target_result(
130 &target,
131 "failed",
132 Some(&format!("refresh-exit-{rc}")),
133 ));
134 }
135 }
136 RefreshDecision::Skip => {
137 skipped += 1;
138 target_results.push(target_result(&target, "skipped", Some("not-due")));
139 }
140 RefreshDecision::WarnFuture => {
141 if !output_json {
142 eprintln!(
143 "gemini-auto-refresh: warning: future timestamp for {}",
144 target.display()
145 );
146 }
147 skipped += 1;
148 target_results.push(target_result(&target, "skipped", Some("future-timestamp")));
149 }
150 }
151 }
152
153 if output_json {
154 let _ = output::emit_result(
155 "auth auto-refresh",
156 output::obj(vec![
157 ("refreshed", output::n(refreshed)),
158 ("skipped", output::n(skipped)),
159 ("failed", output::n(failed)),
160 ("min_age_days", output::n(min_days)),
161 ("targets", output::arr(target_results)),
162 ]),
163 );
164 } else {
165 println!(
166 "gemini-auto-refresh: refreshed={} skipped={} failed={} (min_age_days={})",
167 refreshed, skipped, failed, min_days
168 );
169 }
170
171 if failed > 0 {
172 return 1;
173 }
174
175 0
176}
177
178fn target_result(target: &Path, status: &str, reason: Option<&str>) -> output::JsonValue {
179 let mut fields = vec![
180 (
181 "target_file".to_string(),
182 output::s(target.display().to_string()),
183 ),
184 ("status".to_string(), output::s(status)),
185 ];
186 if let Some(reason) = reason {
187 fields.push(("reason".to_string(), output::s(reason)));
188 }
189 output::obj_dynamic(fields)
190}
191
192fn is_configured() -> bool {
193 let mut candidates = Vec::new();
194 if let Some(auth_file) = crate::paths::resolve_auth_file() {
195 candidates.push(auth_file);
196 }
197 if let Some(secret_dir) = crate::paths::resolve_secret_dir()
198 && let Ok(entries) = std::fs::read_dir(&secret_dir)
199 {
200 for entry in entries.flatten() {
201 let path = entry.path();
202 if path.extension().and_then(|s| s.to_str()) == Some("json") {
203 candidates.push(path);
204 }
205 }
206 }
207
208 candidates.iter().any(|path| path.is_file())
209}
210
211enum RefreshDecision {
212 Refresh,
213 Skip,
214 WarnFuture,
215}
216
217fn should_refresh(
218 target: &Path,
219 timestamp_path: Option<&Path>,
220 now_epoch: i64,
221 min_seconds: i64,
222) -> RefreshDecision {
223 if let Some(last_epoch) = last_refresh_epoch(target, timestamp_path) {
224 let age = now_epoch - last_epoch;
225 if age < 0 {
226 return RefreshDecision::WarnFuture;
227 }
228 if age >= min_seconds {
229 RefreshDecision::Refresh
230 } else {
231 RefreshDecision::Skip
232 }
233 } else {
234 RefreshDecision::Refresh
235 }
236}
237
238fn last_refresh_epoch(target: &Path, timestamp_path: Option<&Path>) -> Option<i64> {
239 if let Some(path) = timestamp_path
240 && let Ok(content) = std::fs::read_to_string(path)
241 {
242 let iso = auth::normalize_iso(&content);
243 if let Some(epoch) = auth::parse_rfc3339_epoch(&iso) {
244 return Some(epoch);
245 }
246 }
247
248 let iso = auth::last_refresh_from_auth_file(target).ok().flatten()?;
249 let iso = auth::normalize_iso(&iso);
250 let epoch = auth::parse_rfc3339_epoch(&iso)?;
251 if let Some(path) = timestamp_path {
252 let _ = auth::write_timestamp(path, Some(&iso));
253 }
254 Some(epoch)
255}
256
257fn timestamp_path(target: &Path) -> Option<PathBuf> {
258 let cache_dir = crate::paths::resolve_secret_cache_dir()?;
259 let name = target
260 .file_name()
261 .and_then(|name| name.to_str())
262 .unwrap_or("auth.json");
263 Some(cache_dir.join(format!("{name}.timestamp")))
264}
265
266#[cfg(test)]
267mod tests {
268 use super::{
269 RefreshDecision, is_configured, last_refresh_epoch, run_with_json, should_refresh,
270 timestamp_path,
271 };
272 use crate::auth;
273 use std::ffi::{OsStr, OsString};
274 use std::fs;
275 use std::path::{Path, PathBuf};
276 use std::time::{SystemTime, UNIX_EPOCH};
277
278 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
279 crate::auth::test_env_lock()
280 }
281
282 struct EnvGuard {
283 key: &'static str,
284 old: Option<OsString>,
285 }
286
287 impl EnvGuard {
288 fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
289 let old = std::env::var_os(key);
290 unsafe { std::env::set_var(key, value) };
292 Self { key, old }
293 }
294 }
295
296 impl Drop for EnvGuard {
297 fn drop(&mut self) {
298 if let Some(value) = self.old.take() {
299 unsafe { std::env::set_var(self.key, value) };
301 } else {
302 unsafe { std::env::remove_var(self.key) };
304 }
305 }
306 }
307
308 struct TestDir {
309 path: PathBuf,
310 }
311
312 impl TestDir {
313 fn new(label: &str) -> Self {
314 let nanos = SystemTime::now()
315 .duration_since(UNIX_EPOCH)
316 .map(|duration| duration.as_nanos())
317 .unwrap_or(0);
318 let path = std::env::temp_dir().join(format!(
319 "nils-gemini-auto-refresh-{label}-{}-{nanos}",
320 std::process::id()
321 ));
322 let _ = fs::remove_dir_all(&path);
323 fs::create_dir_all(&path).expect("temp dir");
324 Self { path }
325 }
326
327 fn join(&self, child: &str) -> PathBuf {
328 self.path.join(child)
329 }
330 }
331
332 impl Drop for TestDir {
333 fn drop(&mut self) {
334 let _ = fs::remove_dir_all(&self.path);
335 }
336 }
337
338 fn write_auth(target: &Path, last_refresh: &str) {
339 fs::write(target, format!("{{\"last_refresh\":\"{last_refresh}\"}}")).expect("write auth");
340 }
341
342 #[test]
343 fn run_with_json_returns_zero_when_not_configured() {
344 let _lock = env_lock();
345 let dir = TestDir::new("not-configured");
346 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", dir.join("missing-auth.json"));
347 let _secret = EnvGuard::set("GEMINI_SECRET_DIR", dir.join("missing-secrets"));
348 assert_eq!(run_with_json(true), 0);
349 assert_eq!(run_with_json(false), 0);
350 }
351
352 #[test]
353 fn run_with_json_invalid_min_days_returns_64() {
354 let _lock = env_lock();
355 let dir = TestDir::new("invalid-min-days");
356 let secrets = dir.join("secrets");
357 fs::create_dir_all(&secrets).expect("secrets");
358 write_auth(&secrets.join("alpha.json"), "2026-01-01T00:00:00Z");
359
360 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", dir.join("missing-auth.json"));
361 let _secret = EnvGuard::set("GEMINI_SECRET_DIR", &secrets);
362 let _min_days = EnvGuard::set("GEMINI_AUTO_REFRESH_MIN_DAYS", "bogus");
363
364 assert_eq!(run_with_json(true), 64);
365 assert_eq!(run_with_json(false), 64);
366 }
367
368 #[test]
369 fn should_refresh_covers_refresh_skip_and_future() {
370 let dir = TestDir::new("should-refresh");
371 let auth_file = dir.join("auth.json");
372 write_auth(&auth_file, "2026-01-01T00:00:00Z");
373 let last_epoch = auth::parse_rfc3339_epoch("2026-01-01T00:00:00Z").expect("epoch");
374
375 assert!(matches!(
376 should_refresh(&auth_file, None, last_epoch + 86_400, 86_400),
377 RefreshDecision::Refresh
378 ));
379 assert!(matches!(
380 should_refresh(&auth_file, None, last_epoch + 100, 86_400),
381 RefreshDecision::Skip
382 ));
383 assert!(matches!(
384 should_refresh(&auth_file, None, last_epoch - 1, 86_400),
385 RefreshDecision::WarnFuture
386 ));
387 }
388
389 #[test]
390 fn last_refresh_epoch_prefers_timestamp_and_backfills_when_needed() {
391 let _lock = env_lock();
392 let dir = TestDir::new("last-refresh");
393 let auth_file = dir.join("auth.json");
394 let cache_dir = dir.join("cache");
395 fs::create_dir_all(&cache_dir).expect("cache dir");
396 let ts_file = cache_dir.join("auth.json.timestamp");
397 write_auth(&auth_file, "2026-01-01T00:00:00Z");
398
399 fs::write(&ts_file, "2026-01-02T00:00:00Z").expect("write timestamp");
400 let from_timestamp =
401 last_refresh_epoch(&auth_file, Some(&ts_file)).expect("epoch from timestamp");
402 let expected_from_ts = auth::parse_rfc3339_epoch("2026-01-02T00:00:00Z").expect("epoch");
403 assert_eq!(from_timestamp, expected_from_ts);
404
405 fs::write(&ts_file, "not-an-iso").expect("write bad timestamp");
406 let from_auth = last_refresh_epoch(&auth_file, Some(&ts_file)).expect("epoch from auth");
407 let expected_from_auth = auth::parse_rfc3339_epoch("2026-01-01T00:00:00Z").expect("epoch");
408 assert_eq!(from_auth, expected_from_auth);
409 assert!(
410 fs::read_to_string(&ts_file)
411 .expect("read backfilled")
412 .contains("2026-01-01")
413 );
414 }
415
416 #[test]
417 fn is_configured_detects_auth_or_secret_files() {
418 let _lock = env_lock();
419 let dir = TestDir::new("is-configured");
420 let auth_file = dir.join("auth.json");
421 let secrets = dir.join("secrets");
422 fs::create_dir_all(&secrets).expect("secrets");
423
424 let missing_auth = dir.join("missing-auth.json");
425 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &missing_auth);
426 let _secret = EnvGuard::set("GEMINI_SECRET_DIR", &secrets);
427 assert!(!is_configured());
428
429 write_auth(&auth_file, "2026-01-01T00:00:00Z");
430 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file);
431 assert!(is_configured());
432
433 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &missing_auth);
434 write_auth(&secrets.join("alpha.json"), "2026-01-01T00:00:00Z");
435 assert!(is_configured());
436 }
437
438 #[test]
439 fn timestamp_path_uses_secret_cache_dir() {
440 let _lock = env_lock();
441 let dir = TestDir::new("timestamp-path");
442 let cache_root = dir.join("cache");
443 fs::create_dir_all(&cache_root).expect("cache root");
444 let _cache = EnvGuard::set("GEMINI_SECRET_CACHE_DIR", &cache_root);
445 let path = timestamp_path(Path::new("/tmp/alpha.json")).expect("timestamp path");
446 assert_eq!(path, cache_root.join("alpha.json.timestamp"));
447 }
448
449 #[test]
450 fn run_with_json_reports_failed_for_missing_file_like_target() {
451 let _lock = env_lock();
452 let dir = TestDir::new("missing-target");
453 let secrets = dir.join("secrets");
454 fs::create_dir_all(&secrets).expect("secrets");
455
456 write_auth(&secrets.join("good.json"), "2100-01-01T00:00:00Z");
457 fs::create_dir_all(secrets.join("broken.json")).expect("broken json dir");
458
459 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", dir.join("missing-auth.json"));
460 let _secret = EnvGuard::set("GEMINI_SECRET_DIR", &secrets);
461 let _min_days = EnvGuard::set("GEMINI_AUTO_REFRESH_MIN_DAYS", "5");
462 assert_eq!(run_with_json(false), 1);
463 }
464
465 #[test]
466 fn run_with_json_emits_summary_when_targets_are_skipped() {
467 let _lock = env_lock();
468 let dir = TestDir::new("json-summary");
469 let secrets = dir.join("secrets");
470 fs::create_dir_all(&secrets).expect("secrets");
471 write_auth(&secrets.join("alpha.json"), "2026-01-01T00:00:00Z");
472
473 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", dir.join("missing-auth.json"));
474 let _secret = EnvGuard::set("GEMINI_SECRET_DIR", &secrets);
475 let _min_days = EnvGuard::set("GEMINI_AUTO_REFRESH_MIN_DAYS", "99999");
476 assert_eq!(run_with_json(true), 0);
477 }
478}