Skip to main content

gemini_cli/auth/
auto_refresh.rs

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}