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 nils_test_support::fs as test_fs;
274 use nils_test_support::{EnvGuard, GlobalStateLock};
275 use std::ffi::OsStr;
276 use std::fs;
277 use std::path::Path;
278 use tempfile::TempDir;
279
280 fn set_env(lock: &GlobalStateLock, key: &str, value: impl AsRef<OsStr>) -> EnvGuard {
281 let value = value.as_ref().to_string_lossy().into_owned();
282 EnvGuard::set(lock, key, &value)
283 }
284
285 fn write_auth(target: &Path, last_refresh: &str) {
286 test_fs::write_text(target, &format!("{{\"last_refresh\":\"{last_refresh}\"}}"));
287 }
288
289 #[test]
290 fn run_with_json_returns_zero_when_not_configured() {
291 let lock = GlobalStateLock::new();
292 let dir = TempDir::new().expect("tempdir");
293 let _auth = set_env(
294 &lock,
295 "GEMINI_AUTH_FILE",
296 dir.path().join("missing-auth.json"),
297 );
298 let _secret = set_env(
299 &lock,
300 "GEMINI_SECRET_DIR",
301 dir.path().join("missing-secrets"),
302 );
303 assert_eq!(run_with_json(true), 0);
304 assert_eq!(run_with_json(false), 0);
305 }
306
307 #[test]
308 fn run_with_json_invalid_min_days_returns_64() {
309 let lock = GlobalStateLock::new();
310 let dir = TempDir::new().expect("tempdir");
311 let secrets = dir.path().join("secrets");
312 fs::create_dir_all(&secrets).expect("secrets");
313 write_auth(&secrets.join("alpha.json"), "2026-01-01T00:00:00Z");
314
315 let _auth = set_env(
316 &lock,
317 "GEMINI_AUTH_FILE",
318 dir.path().join("missing-auth.json"),
319 );
320 let _secret = set_env(&lock, "GEMINI_SECRET_DIR", &secrets);
321 let _min_days = set_env(&lock, "GEMINI_AUTO_REFRESH_MIN_DAYS", "bogus");
322
323 assert_eq!(run_with_json(true), 64);
324 assert_eq!(run_with_json(false), 64);
325 }
326
327 #[test]
328 fn should_refresh_covers_refresh_skip_and_future() {
329 let dir = TempDir::new().expect("tempdir");
330 let auth_file = dir.path().join("auth.json");
331 write_auth(&auth_file, "2026-01-01T00:00:00Z");
332 let last_epoch = auth::parse_rfc3339_epoch("2026-01-01T00:00:00Z").expect("epoch");
333
334 assert!(matches!(
335 should_refresh(&auth_file, None, last_epoch + 86_400, 86_400),
336 RefreshDecision::Refresh
337 ));
338 assert!(matches!(
339 should_refresh(&auth_file, None, last_epoch + 100, 86_400),
340 RefreshDecision::Skip
341 ));
342 assert!(matches!(
343 should_refresh(&auth_file, None, last_epoch - 1, 86_400),
344 RefreshDecision::WarnFuture
345 ));
346 }
347
348 #[test]
349 fn last_refresh_epoch_prefers_timestamp_and_backfills_when_needed() {
350 let dir = TempDir::new().expect("tempdir");
351 let auth_file = dir.path().join("auth.json");
352 let cache_dir = dir.path().join("cache");
353 fs::create_dir_all(&cache_dir).expect("cache dir");
354 let ts_file = cache_dir.join("auth.json.timestamp");
355 write_auth(&auth_file, "2026-01-01T00:00:00Z");
356
357 test_fs::write_text(&ts_file, "2026-01-02T00:00:00Z");
358 let from_timestamp =
359 last_refresh_epoch(&auth_file, Some(&ts_file)).expect("epoch from timestamp");
360 let expected_from_ts = auth::parse_rfc3339_epoch("2026-01-02T00:00:00Z").expect("epoch");
361 assert_eq!(from_timestamp, expected_from_ts);
362
363 test_fs::write_text(&ts_file, "not-an-iso");
364 let from_auth = last_refresh_epoch(&auth_file, Some(&ts_file)).expect("epoch from auth");
365 let expected_from_auth = auth::parse_rfc3339_epoch("2026-01-01T00:00:00Z").expect("epoch");
366 assert_eq!(from_auth, expected_from_auth);
367 assert!(
368 fs::read_to_string(&ts_file)
369 .expect("read backfilled")
370 .contains("2026-01-01")
371 );
372 }
373
374 #[test]
375 fn is_configured_detects_auth_or_secret_files() {
376 let lock = GlobalStateLock::new();
377 let dir = TempDir::new().expect("tempdir");
378 let auth_file = dir.path().join("auth.json");
379 let secrets = dir.path().join("secrets");
380 fs::create_dir_all(&secrets).expect("secrets");
381
382 let missing_auth = dir.path().join("missing-auth.json");
383 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", &missing_auth);
384 let _secret = set_env(&lock, "GEMINI_SECRET_DIR", &secrets);
385 assert!(!is_configured());
386
387 write_auth(&auth_file, "2026-01-01T00:00:00Z");
388 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", &auth_file);
389 assert!(is_configured());
390
391 let _auth = set_env(&lock, "GEMINI_AUTH_FILE", &missing_auth);
392 write_auth(&secrets.join("alpha.json"), "2026-01-01T00:00:00Z");
393 assert!(is_configured());
394 }
395
396 #[test]
397 fn timestamp_path_uses_secret_cache_dir() {
398 let lock = GlobalStateLock::new();
399 let dir = TempDir::new().expect("tempdir");
400 let cache_root = dir.path().join("cache");
401 fs::create_dir_all(&cache_root).expect("cache root");
402 let _cache = set_env(&lock, "GEMINI_SECRET_CACHE_DIR", &cache_root);
403 let path = timestamp_path(Path::new("/tmp/alpha.json")).expect("timestamp path");
404 assert_eq!(path, cache_root.join("alpha.json.timestamp"));
405 }
406
407 #[test]
408 fn run_with_json_reports_failed_for_missing_file_like_target() {
409 let lock = GlobalStateLock::new();
410 let dir = TempDir::new().expect("tempdir");
411 let secrets = dir.path().join("secrets");
412 fs::create_dir_all(&secrets).expect("secrets");
413
414 write_auth(&secrets.join("good.json"), "2100-01-01T00:00:00Z");
415 fs::create_dir_all(secrets.join("broken.json")).expect("broken json dir");
416
417 let _auth = set_env(
418 &lock,
419 "GEMINI_AUTH_FILE",
420 dir.path().join("missing-auth.json"),
421 );
422 let _secret = set_env(&lock, "GEMINI_SECRET_DIR", &secrets);
423 let _min_days = set_env(&lock, "GEMINI_AUTO_REFRESH_MIN_DAYS", "5");
424 assert_eq!(run_with_json(false), 1);
425 }
426
427 #[test]
428 fn run_with_json_emits_summary_when_targets_are_skipped() {
429 let lock = GlobalStateLock::new();
430 let dir = TempDir::new().expect("tempdir");
431 let secrets = dir.path().join("secrets");
432 fs::create_dir_all(&secrets).expect("secrets");
433 write_auth(&secrets.join("alpha.json"), "2026-01-01T00:00:00Z");
434
435 let _auth = set_env(
436 &lock,
437 "GEMINI_AUTH_FILE",
438 dir.path().join("missing-auth.json"),
439 );
440 let _secret = set_env(&lock, "GEMINI_SECRET_DIR", &secrets);
441 let _min_days = set_env(&lock, "GEMINI_AUTO_REFRESH_MIN_DAYS", "99999");
442 assert_eq!(run_with_json(true), 0);
443 }
444}