Skip to main content

codex_cli/rate_limits/
cache.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::auth;
6use crate::fs as codex_fs;
7use crate::paths;
8use nils_common::env as shared_env;
9
10#[derive(Debug)]
11pub struct CacheEntry {
12    pub fetched_at_epoch: Option<i64>,
13    pub non_weekly_label: String,
14    pub non_weekly_remaining: i64,
15    pub non_weekly_reset_epoch: Option<i64>,
16    pub weekly_remaining: i64,
17    pub weekly_reset_epoch: i64,
18}
19
20const DEFAULT_CACHE_TTL_SECONDS: u64 = 180;
21const CACHE_MISS_HINT: &str =
22    "rerun without --cached to refresh, or set CODEX_RATE_LIMITS_CACHE_ALLOW_STALE=true";
23
24pub fn clear_starship_cache() -> Result<()> {
25    let root = cache_root().context("cache root")?;
26    if !root.is_absolute() {
27        anyhow::bail!(
28            "codex-rate-limits: refusing to clear cache with non-absolute cache root: {}",
29            root.display()
30        );
31    }
32    if root == Path::new("/") {
33        anyhow::bail!(
34            "codex-rate-limits: refusing to clear cache with invalid cache root: {}",
35            root.display()
36        );
37    }
38
39    let cache_dir = root.join("codex").join("starship-rate-limits");
40    let cache_dir_str = cache_dir.to_string_lossy();
41    if !cache_dir_str.ends_with("/codex/starship-rate-limits") {
42        anyhow::bail!(
43            "codex-rate-limits: refusing to clear unexpected cache dir: {}",
44            cache_dir.display()
45        );
46    }
47
48    if cache_dir.is_dir() {
49        fs::remove_dir_all(&cache_dir).ok();
50    }
51
52    Ok(())
53}
54
55pub fn cache_file_for_target(target_file: &Path) -> Result<PathBuf> {
56    let cache_dir = starship_cache_dir().context("cache dir")?;
57
58    if let Some(secret_dir) = paths::resolve_secret_dir() {
59        if target_file.starts_with(&secret_dir) {
60            let display = secret_file_basename(target_file)?;
61            let key = cache_key(&display)?;
62            return Ok(cache_dir.join(format!("{key}.kv")));
63        }
64
65        if let Some(secret_name) = secret_name_for_auth(target_file, &secret_dir) {
66            let key = cache_key(&secret_name)?;
67            return Ok(cache_dir.join(format!("{key}.kv")));
68        }
69    }
70
71    let hash = codex_fs::sha256_file(target_file)?;
72    Ok(cache_dir.join(format!("auth_{}.kv", hash.to_lowercase())))
73}
74
75pub fn secret_name_for_target(target_file: &Path) -> Option<String> {
76    let secret_dir = paths::resolve_secret_dir()?;
77    if target_file.starts_with(&secret_dir) {
78        return secret_file_basename(target_file).ok();
79    }
80    secret_name_for_auth(target_file, &secret_dir)
81}
82
83pub fn read_cache_entry(target_file: &Path) -> Result<CacheEntry> {
84    let cache_file = cache_file_for_target(target_file)?;
85    if !cache_file.is_file() {
86        anyhow::bail!(
87            "codex-rate-limits: cache not found (run codex-rate-limits without --cached, or codex-starship, to populate): {}",
88            cache_file.display()
89        );
90    }
91
92    let content = fs::read_to_string(&cache_file)
93        .with_context(|| format!("failed to read cache: {}", cache_file.display()))?;
94    let mut fetched_at_epoch: Option<i64> = None;
95    let mut non_weekly_label: Option<String> = None;
96    let mut non_weekly_remaining: Option<i64> = None;
97    let mut non_weekly_reset_epoch: Option<i64> = None;
98    let mut weekly_remaining: Option<i64> = None;
99    let mut weekly_reset_epoch: Option<i64> = None;
100
101    for line in content.lines() {
102        if let Some(value) = line.strip_prefix("fetched_at=") {
103            fetched_at_epoch = value.parse::<i64>().ok();
104        } else if let Some(value) = line.strip_prefix("non_weekly_label=") {
105            non_weekly_label = Some(value.to_string());
106        } else if let Some(value) = line.strip_prefix("non_weekly_remaining=") {
107            non_weekly_remaining = value.parse::<i64>().ok();
108        } else if let Some(value) = line.strip_prefix("non_weekly_reset_epoch=") {
109            non_weekly_reset_epoch = value.parse::<i64>().ok();
110        } else if let Some(value) = line.strip_prefix("weekly_remaining=") {
111            weekly_remaining = value.parse::<i64>().ok();
112        } else if let Some(value) = line.strip_prefix("weekly_reset_epoch=") {
113            weekly_reset_epoch = value.parse::<i64>().ok();
114        }
115    }
116
117    let non_weekly_label = match non_weekly_label {
118        Some(value) if !value.is_empty() => value,
119        _ => anyhow::bail!(
120            "codex-rate-limits: invalid cache (missing non-weekly data): {}",
121            cache_file.display()
122        ),
123    };
124    let non_weekly_remaining = match non_weekly_remaining {
125        Some(value) => value,
126        _ => anyhow::bail!(
127            "codex-rate-limits: invalid cache (missing non-weekly data): {}",
128            cache_file.display()
129        ),
130    };
131    let weekly_remaining = match weekly_remaining {
132        Some(value) => value,
133        _ => anyhow::bail!(
134            "codex-rate-limits: invalid cache (missing weekly data): {}",
135            cache_file.display()
136        ),
137    };
138    let weekly_reset_epoch = match weekly_reset_epoch {
139        Some(value) => value,
140        _ => anyhow::bail!(
141            "codex-rate-limits: invalid cache (missing weekly data): {}",
142            cache_file.display()
143        ),
144    };
145
146    Ok(CacheEntry {
147        fetched_at_epoch,
148        non_weekly_label,
149        non_weekly_remaining,
150        non_weekly_reset_epoch,
151        weekly_remaining,
152        weekly_reset_epoch,
153    })
154}
155
156pub fn read_cache_entry_for_cached_mode(target_file: &Path) -> Result<CacheEntry> {
157    let entry = read_cache_entry(target_file)?;
158    if cache_allow_stale() {
159        return Ok(entry);
160    }
161    ensure_cache_fresh(target_file, &entry)?;
162    Ok(entry)
163}
164
165pub fn write_starship_cache(
166    target_file: &Path,
167    fetched_at_epoch: i64,
168    non_weekly_label: &str,
169    non_weekly_remaining: i64,
170    weekly_remaining: i64,
171    weekly_reset_epoch: i64,
172    non_weekly_reset_epoch: Option<i64>,
173) -> Result<()> {
174    let cache_file = cache_file_for_target(target_file)?;
175    if let Some(parent) = cache_file.parent() {
176        fs::create_dir_all(parent)?;
177    }
178
179    let mut lines = Vec::new();
180    lines.push(format!("fetched_at={fetched_at_epoch}"));
181    lines.push(format!("non_weekly_label={non_weekly_label}"));
182    lines.push(format!("non_weekly_remaining={non_weekly_remaining}"));
183    if let Some(epoch) = non_weekly_reset_epoch {
184        lines.push(format!("non_weekly_reset_epoch={epoch}"));
185    }
186    lines.push(format!("weekly_remaining={weekly_remaining}"));
187    lines.push(format!("weekly_reset_epoch={weekly_reset_epoch}"));
188
189    let data = lines.join("\n");
190    codex_fs::write_atomic(&cache_file, data.as_bytes(), codex_fs::SECRET_FILE_MODE)?;
191    Ok(())
192}
193
194fn starship_cache_dir() -> Result<PathBuf> {
195    let root = cache_root().context("cache root")?;
196    Ok(root.join("codex").join("starship-rate-limits"))
197}
198
199fn ensure_cache_fresh(target_file: &Path, entry: &CacheEntry) -> Result<()> {
200    let ttl_seconds = cache_ttl_seconds();
201    let ttl_i64 = i64::try_from(ttl_seconds).unwrap_or(i64::MAX);
202    let cache_file = cache_file_for_target(target_file)?;
203
204    let fetched_at_epoch = match entry.fetched_at_epoch {
205        Some(value) if value > 0 => value,
206        _ => {
207            anyhow::bail!(
208                "codex-rate-limits: cache expired (missing fetched_at): {} ({})",
209                cache_file.display(),
210                CACHE_MISS_HINT
211            );
212        }
213    };
214
215    let now_epoch = chrono::Utc::now().timestamp();
216    if now_epoch <= 0 {
217        return Ok(());
218    }
219
220    let age_seconds = if now_epoch >= fetched_at_epoch {
221        now_epoch - fetched_at_epoch
222    } else {
223        0
224    };
225    if age_seconds > ttl_i64 {
226        anyhow::bail!(
227            "codex-rate-limits: cache expired (age={}s, ttl={}s): {} ({})",
228            age_seconds,
229            ttl_seconds,
230            cache_file.display(),
231            CACHE_MISS_HINT
232        );
233    }
234
235    Ok(())
236}
237
238fn cache_ttl_seconds() -> u64 {
239    if let Ok(raw) = std::env::var("CODEX_RATE_LIMITS_CACHE_TTL")
240        && let Some(value) = parse_duration_seconds(&raw)
241    {
242        return value;
243    }
244    DEFAULT_CACHE_TTL_SECONDS
245}
246
247fn cache_allow_stale() -> bool {
248    shared_env::env_truthy_or("CODEX_RATE_LIMITS_CACHE_ALLOW_STALE", false)
249}
250
251fn parse_duration_seconds(raw: &str) -> Option<u64> {
252    let raw = raw.trim();
253    if raw.is_empty() {
254        return None;
255    }
256
257    let raw = raw.to_ascii_lowercase();
258    let (num_part, multiplier): (&str, u64) = match raw.chars().last()? {
259        's' => (&raw[..raw.len().saturating_sub(1)], 1),
260        'm' => (&raw[..raw.len().saturating_sub(1)], 60),
261        'h' => (&raw[..raw.len().saturating_sub(1)], 60 * 60),
262        'd' => (&raw[..raw.len().saturating_sub(1)], 60 * 60 * 24),
263        'w' => (&raw[..raw.len().saturating_sub(1)], 60 * 60 * 24 * 7),
264        ch if ch.is_ascii_digit() => (raw.as_str(), 1),
265        _ => return None,
266    };
267
268    let num_part = num_part.trim();
269    if num_part.is_empty() {
270        return None;
271    }
272
273    let value = num_part.parse::<u64>().ok()?;
274    if value == 0 {
275        return None;
276    }
277
278    value.checked_mul(multiplier)
279}
280
281fn cache_root() -> Option<PathBuf> {
282    if let Ok(path) = std::env::var("ZSH_CACHE_DIR")
283        && !path.is_empty()
284    {
285        return Some(PathBuf::from(path));
286    }
287    let zdotdir = paths::resolve_zdotdir()?;
288    Some(zdotdir.join("cache"))
289}
290
291fn secret_name_for_auth(auth_file: &Path, secret_dir: &Path) -> Option<String> {
292    let auth_key = auth::identity_key_from_auth_file(auth_file)
293        .ok()
294        .flatten()?;
295    let entries = std::fs::read_dir(secret_dir).ok()?;
296    for entry in entries.flatten() {
297        let path = entry.path();
298        if path.extension().and_then(|s| s.to_str()) != Some("json") {
299            continue;
300        }
301        let candidate_key = match auth::identity_key_from_auth_file(&path).ok().flatten() {
302            Some(value) => value,
303            None => continue,
304        };
305        if candidate_key == auth_key {
306            return secret_file_basename(&path).ok();
307        }
308    }
309    None
310}
311
312fn secret_file_basename(path: &Path) -> Result<String> {
313    let file = path
314        .file_name()
315        .and_then(|name| name.to_str())
316        .unwrap_or_default();
317    let base = file.trim_end_matches(".json");
318    Ok(base.to_string())
319}
320
321fn cache_key(name: &str) -> Result<String> {
322    if name.is_empty() {
323        anyhow::bail!("missing cache key name");
324    }
325    let mut key = String::new();
326    for ch in name.to_lowercase().chars() {
327        if ch.is_ascii_alphanumeric() {
328            key.push(ch);
329        } else {
330            key.push('_');
331        }
332    }
333    while key.starts_with('_') {
334        key.remove(0);
335    }
336    while key.ends_with('_') {
337        key.pop();
338    }
339    if key.is_empty() {
340        anyhow::bail!("invalid cache key name");
341    }
342    Ok(key)
343}
344
345#[cfg(test)]
346mod tests {
347    use super::{
348        cache_file_for_target, clear_starship_cache, read_cache_entry,
349        read_cache_entry_for_cached_mode, secret_name_for_target, write_starship_cache,
350    };
351    use crate::fs as codex_fs;
352    use chrono::Utc;
353    use nils_test_support::{EnvGuard, GlobalStateLock};
354    use std::fs;
355    use std::path::Path;
356
357    const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
358    const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
359
360    fn token(payload: &str) -> String {
361        format!("{HEADER}.{payload}.sig")
362    }
363
364    fn auth_json(
365        payload: &str,
366        account_id: &str,
367        refresh_token: &str,
368        last_refresh: &str,
369    ) -> String {
370        format!(
371            r#"{{"tokens":{{"access_token":"{}","id_token":"{}","refresh_token":"{}","account_id":"{}"}},"last_refresh":"{}"}}"#,
372            token(payload),
373            token(payload),
374            refresh_token,
375            account_id,
376            last_refresh
377        )
378    }
379
380    fn set_cache_env(
381        lock: &GlobalStateLock,
382        secret_dir: &Path,
383        cache_root: &Path,
384    ) -> (EnvGuard, EnvGuard) {
385        let secret = EnvGuard::set(
386            lock,
387            "CODEX_SECRET_DIR",
388            secret_dir.to_str().expect("secret dir path"),
389        );
390        let cache = EnvGuard::set(
391            lock,
392            "ZSH_CACHE_DIR",
393            cache_root.to_str().expect("cache root path"),
394        );
395        (secret, cache)
396    }
397
398    #[test]
399    fn clear_starship_cache_rejects_relative_cache_root() {
400        let lock = GlobalStateLock::new();
401        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", "relative/cache");
402
403        let err = clear_starship_cache().expect_err("relative cache root should fail");
404        assert!(err.to_string().contains("non-absolute cache root"));
405    }
406
407    #[test]
408    fn clear_starship_cache_rejects_root_cache_path() {
409        let lock = GlobalStateLock::new();
410        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", "/");
411
412        let err = clear_starship_cache().expect_err("root cache path should fail");
413        assert!(err.to_string().contains("invalid cache root"));
414    }
415
416    #[test]
417    fn clear_starship_cache_removes_only_starship_cache_dir() {
418        let lock = GlobalStateLock::new();
419        let dir = tempfile::TempDir::new().expect("tempdir");
420        let cache_root = dir.path().join("cache-root");
421        let remove_dir = cache_root.join("codex").join("starship-rate-limits");
422        let keep_dir = cache_root.join("codex").join("secrets");
423        fs::create_dir_all(&remove_dir).expect("remove dir");
424        fs::create_dir_all(&keep_dir).expect("keep dir");
425        fs::write(
426            remove_dir.join("alpha.kv"),
427            "weekly_remaining=1\nweekly_reset_epoch=2",
428        )
429        .expect("write cached file");
430        fs::write(keep_dir.join("keep.txt"), "keep").expect("write keep file");
431        let _cache = EnvGuard::set(
432            &lock,
433            "ZSH_CACHE_DIR",
434            cache_root.to_str().expect("cache root path"),
435        );
436
437        clear_starship_cache().expect("clear cache");
438
439        assert!(!remove_dir.exists(), "starship cache dir should be removed");
440        assert!(keep_dir.is_dir(), "non-target cache dir should remain");
441    }
442
443    #[test]
444    fn cache_file_for_secret_target_uses_sanitized_secret_name() {
445        let lock = GlobalStateLock::new();
446        let dir = tempfile::TempDir::new().expect("tempdir");
447        let secret_dir = dir.path().join("secrets");
448        let cache_root = dir.path().join("cache");
449        fs::create_dir_all(&secret_dir).expect("secret dir");
450        fs::create_dir_all(&cache_root).expect("cache root");
451        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
452
453        let target = secret_dir.join("My.Secret+Name.json");
454        fs::write(&target, "{}").expect("write secret file");
455
456        let cache_file = cache_file_for_target(&target).expect("cache file");
457        assert_eq!(
458            cache_file,
459            cache_root
460                .join("codex")
461                .join("starship-rate-limits")
462                .join("my_secret_name.kv")
463        );
464    }
465
466    #[test]
467    fn cache_file_for_non_secret_target_falls_back_to_hashed_key() {
468        let lock = GlobalStateLock::new();
469        let dir = tempfile::TempDir::new().expect("tempdir");
470        let secret_dir = dir.path().join("secrets");
471        let cache_root = dir.path().join("cache");
472        fs::create_dir_all(&secret_dir).expect("secret dir");
473        fs::create_dir_all(&cache_root).expect("cache root");
474        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
475
476        let target = dir.path().join("auth.json");
477        fs::write(&target, "{\"tokens\":{\"access_token\":\"tok\"}}").expect("write auth file");
478
479        let hash = codex_fs::sha256_file(&target).expect("sha256");
480        let cache_file = cache_file_for_target(&target).expect("cache file");
481        assert_eq!(
482            cache_file,
483            cache_root
484                .join("codex")
485                .join("starship-rate-limits")
486                .join(format!("auth_{hash}.kv"))
487        );
488    }
489
490    #[test]
491    fn cache_file_for_auth_target_reuses_matching_secret_identity() {
492        let lock = GlobalStateLock::new();
493        let dir = tempfile::TempDir::new().expect("tempdir");
494        let secret_dir = dir.path().join("secrets");
495        let cache_root = dir.path().join("cache");
496        fs::create_dir_all(&secret_dir).expect("secret dir");
497        fs::create_dir_all(&cache_root).expect("cache root");
498        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
499
500        let target = dir.path().join("auth.json");
501        let target_content = auth_json(
502            PAYLOAD_ALPHA,
503            "acct_001",
504            "refresh_auth",
505            "2025-01-20T12:34:56Z",
506        );
507        fs::write(&target, target_content).expect("write auth file");
508
509        let secret_file = secret_dir.join("Alpha Team.json");
510        let secret_content = auth_json(
511            PAYLOAD_ALPHA,
512            "acct_001",
513            "refresh_secret",
514            "2025-01-21T12:34:56Z",
515        );
516        fs::write(&secret_file, secret_content).expect("write matching secret file");
517
518        let cache_file = cache_file_for_target(&target).expect("cache file");
519        assert_eq!(
520            cache_file.file_name().and_then(|name| name.to_str()),
521            Some("alpha_team.kv")
522        );
523        assert_eq!(
524            secret_name_for_target(&target),
525            Some("Alpha Team".to_string())
526        );
527    }
528
529    #[test]
530    fn write_then_read_cache_entry_preserves_optional_non_weekly_reset_epoch() {
531        let lock = GlobalStateLock::new();
532        let dir = tempfile::TempDir::new().expect("tempdir");
533        let secret_dir = dir.path().join("secrets");
534        let cache_root = dir.path().join("cache");
535        fs::create_dir_all(&secret_dir).expect("secret dir");
536        fs::create_dir_all(&cache_root).expect("cache root");
537        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
538
539        let target = secret_dir.join("alpha.json");
540        fs::write(&target, "{}").expect("write target");
541
542        write_starship_cache(
543            &target,
544            1700000000,
545            "5h",
546            91,
547            12,
548            1700600000,
549            Some(1700003600),
550        )
551        .expect("write cache");
552
553        let entry = read_cache_entry(&target).expect("read cache");
554        assert_eq!(entry.non_weekly_label, "5h");
555        assert_eq!(entry.non_weekly_remaining, 91);
556        assert_eq!(entry.non_weekly_reset_epoch, Some(1700003600));
557        assert_eq!(entry.weekly_remaining, 12);
558        assert_eq!(entry.weekly_reset_epoch, 1700600000);
559    }
560
561    #[test]
562    fn write_cache_omits_optional_non_weekly_reset_epoch_when_absent() {
563        let lock = GlobalStateLock::new();
564        let dir = tempfile::TempDir::new().expect("tempdir");
565        let secret_dir = dir.path().join("secrets");
566        let cache_root = dir.path().join("cache");
567        fs::create_dir_all(&secret_dir).expect("secret dir");
568        fs::create_dir_all(&cache_root).expect("cache root");
569        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
570
571        let target = secret_dir.join("alpha.json");
572        fs::write(&target, "{}").expect("write target");
573
574        write_starship_cache(&target, 1700000000, "daily", 45, 9, 1700600000, None)
575            .expect("write cache");
576
577        let cache_file = cache_file_for_target(&target).expect("cache path");
578        let content = fs::read_to_string(&cache_file).expect("read cache file");
579        assert!(!content.contains("non_weekly_reset_epoch="));
580
581        let entry = read_cache_entry(&target).expect("read cache");
582        assert_eq!(entry.non_weekly_label, "daily");
583        assert_eq!(entry.non_weekly_reset_epoch, None);
584    }
585
586    #[test]
587    fn read_cache_entry_reports_missing_weekly_data() {
588        let lock = GlobalStateLock::new();
589        let dir = tempfile::TempDir::new().expect("tempdir");
590        let secret_dir = dir.path().join("secrets");
591        let cache_root = dir.path().join("cache");
592        fs::create_dir_all(&secret_dir).expect("secret dir");
593        fs::create_dir_all(&cache_root).expect("cache root");
594        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
595
596        let target = secret_dir.join("alpha.json");
597        fs::write(&target, "{}").expect("write target");
598        let cache_file = cache_file_for_target(&target).expect("cache path");
599        fs::create_dir_all(cache_file.parent().expect("cache parent")).expect("cache parent dir");
600        fs::write(
601            &cache_file,
602            "fetched_at=1\nnon_weekly_label=5h\nnon_weekly_remaining=90\nweekly_remaining=1\n",
603        )
604        .expect("write invalid cache");
605
606        let err = read_cache_entry(&target).expect_err("missing weekly reset should fail");
607        assert!(err.to_string().contains("missing weekly data"));
608    }
609
610    #[test]
611    fn read_cache_entry_reports_missing_non_weekly_data() {
612        let lock = GlobalStateLock::new();
613        let dir = tempfile::TempDir::new().expect("tempdir");
614        let secret_dir = dir.path().join("secrets");
615        let cache_root = dir.path().join("cache");
616        fs::create_dir_all(&secret_dir).expect("secret dir");
617        fs::create_dir_all(&cache_root).expect("cache root");
618        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
619
620        let target = secret_dir.join("alpha.json");
621        fs::write(&target, "{}").expect("write target");
622        let cache_file = cache_file_for_target(&target).expect("cache path");
623        fs::create_dir_all(cache_file.parent().expect("cache parent")).expect("cache parent dir");
624        fs::write(
625            &cache_file,
626            "fetched_at=1\nweekly_remaining=1\nweekly_reset_epoch=1700600000\n",
627        )
628        .expect("write invalid cache");
629
630        let err = read_cache_entry(&target).expect_err("missing non-weekly fields should fail");
631        assert!(err.to_string().contains("missing non-weekly data"));
632    }
633
634    #[test]
635    fn read_cache_entry_for_cached_mode_rejects_expired_cache_by_default() {
636        let lock = GlobalStateLock::new();
637        let dir = tempfile::TempDir::new().expect("tempdir");
638        let secret_dir = dir.path().join("secrets");
639        let cache_root = dir.path().join("cache");
640        fs::create_dir_all(&secret_dir).expect("secret dir");
641        fs::create_dir_all(&cache_root).expect("cache root");
642        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
643
644        let target = secret_dir.join("alpha.json");
645        fs::write(&target, "{}").expect("write target");
646        write_starship_cache(&target, 1, "5h", 91, 12, 1_700_600_000, Some(1_700_003_600))
647            .expect("write cache");
648
649        let err = read_cache_entry_for_cached_mode(&target).expect_err("stale cache should fail");
650        assert!(err.to_string().contains("cache expired"));
651    }
652
653    #[test]
654    fn read_cache_entry_for_cached_mode_honors_ttl_env() {
655        let lock = GlobalStateLock::new();
656        let dir = tempfile::TempDir::new().expect("tempdir");
657        let secret_dir = dir.path().join("secrets");
658        let cache_root = dir.path().join("cache");
659        fs::create_dir_all(&secret_dir).expect("secret dir");
660        fs::create_dir_all(&cache_root).expect("cache root");
661        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
662        let _ttl = EnvGuard::set(&lock, "CODEX_RATE_LIMITS_CACHE_TTL", "1h");
663
664        let target = secret_dir.join("alpha.json");
665        fs::write(&target, "{}").expect("write target");
666        let now = Utc::now().timestamp();
667        let fetched_at = now.saturating_sub(30 * 60);
668        write_starship_cache(
669            &target,
670            fetched_at,
671            "5h",
672            91,
673            12,
674            1_700_600_000,
675            Some(1_700_003_600),
676        )
677        .expect("write cache");
678
679        let entry = read_cache_entry_for_cached_mode(&target).expect("fresh cache");
680        assert_eq!(entry.non_weekly_label, "5h");
681    }
682
683    #[test]
684    fn read_cache_entry_for_cached_mode_allows_stale_when_enabled() {
685        let lock = GlobalStateLock::new();
686        let dir = tempfile::TempDir::new().expect("tempdir");
687        let secret_dir = dir.path().join("secrets");
688        let cache_root = dir.path().join("cache");
689        fs::create_dir_all(&secret_dir).expect("secret dir");
690        fs::create_dir_all(&cache_root).expect("cache root");
691        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
692        let _allow_stale = EnvGuard::set(&lock, "CODEX_RATE_LIMITS_CACHE_ALLOW_STALE", "true");
693
694        let target = secret_dir.join("alpha.json");
695        fs::write(&target, "{}").expect("write target");
696        write_starship_cache(&target, 1, "5h", 91, 12, 1_700_600_000, Some(1_700_003_600))
697            .expect("write cache");
698
699        let entry = read_cache_entry_for_cached_mode(&target).expect("allow stale");
700        assert_eq!(entry.non_weekly_remaining, 91);
701    }
702}