Skip to main content

codex_cli/rate_limits/
cache.rs

1use anyhow::{Context, Result};
2use nils_common::fs as shared_fs;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::auth;
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_prompt_segment_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("prompt-segment-rate-limits");
40    let cache_dir_str = cache_dir.to_string_lossy();
41    if !cache_dir_str.ends_with("/codex/prompt-segment-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 = prompt_segment_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 = shared_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-cli prompt-segment, 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_prompt_segment_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    shared_fs::write_atomic(&cache_file, data.as_bytes(), shared_fs::SECRET_FILE_MODE)?;
191    Ok(())
192}
193
194fn prompt_segment_cache_dir() -> Result<PathBuf> {
195    let root = cache_root().context("cache root")?;
196    Ok(root.join("codex").join("prompt-segment-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) = shared_env::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 cache_root() -> Option<PathBuf> {
252    if let Ok(path) = std::env::var("ZSH_CACHE_DIR")
253        && !path.is_empty()
254    {
255        return Some(PathBuf::from(path));
256    }
257    let zdotdir = paths::resolve_zdotdir()?;
258    Some(zdotdir.join("cache"))
259}
260
261fn secret_name_for_auth(auth_file: &Path, secret_dir: &Path) -> Option<String> {
262    let auth_key = auth::identity_key_from_auth_file(auth_file)
263        .ok()
264        .flatten()?;
265    let entries = std::fs::read_dir(secret_dir).ok()?;
266    for entry in entries.flatten() {
267        let path = entry.path();
268        if path.extension().and_then(|s| s.to_str()) != Some("json") {
269            continue;
270        }
271        let candidate_key = match auth::identity_key_from_auth_file(&path).ok().flatten() {
272            Some(value) => value,
273            None => continue,
274        };
275        if candidate_key == auth_key {
276            return secret_file_basename(&path).ok();
277        }
278    }
279    None
280}
281
282fn secret_file_basename(path: &Path) -> Result<String> {
283    let file = path
284        .file_name()
285        .and_then(|name| name.to_str())
286        .unwrap_or_default();
287    let base = file.trim_end_matches(".json");
288    Ok(base.to_string())
289}
290
291fn cache_key(name: &str) -> Result<String> {
292    if name.is_empty() {
293        anyhow::bail!("missing cache key name");
294    }
295    let mut key = String::new();
296    for ch in name.to_lowercase().chars() {
297        if ch.is_ascii_alphanumeric() {
298            key.push(ch);
299        } else {
300            key.push('_');
301        }
302    }
303    while key.starts_with('_') {
304        key.remove(0);
305    }
306    while key.ends_with('_') {
307        key.pop();
308    }
309    if key.is_empty() {
310        anyhow::bail!("invalid cache key name");
311    }
312    Ok(key)
313}
314
315#[cfg(test)]
316mod tests {
317    use super::{
318        cache_file_for_target, clear_prompt_segment_cache, read_cache_entry,
319        read_cache_entry_for_cached_mode, secret_name_for_target, write_prompt_segment_cache,
320    };
321    use chrono::Utc;
322    use nils_common::fs as shared_fs;
323    use nils_test_support::{EnvGuard, GlobalStateLock};
324    use std::fs;
325    use std::path::Path;
326
327    const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
328    const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
329
330    fn token(payload: &str) -> String {
331        format!("{HEADER}.{payload}.sig")
332    }
333
334    fn auth_json(
335        payload: &str,
336        account_id: &str,
337        refresh_token: &str,
338        last_refresh: &str,
339    ) -> String {
340        format!(
341            r#"{{"tokens":{{"access_token":"{}","id_token":"{}","refresh_token":"{}","account_id":"{}"}},"last_refresh":"{}"}}"#,
342            token(payload),
343            token(payload),
344            refresh_token,
345            account_id,
346            last_refresh
347        )
348    }
349
350    fn set_cache_env(
351        lock: &GlobalStateLock,
352        secret_dir: &Path,
353        cache_root: &Path,
354    ) -> (EnvGuard, EnvGuard) {
355        let secret = EnvGuard::set(
356            lock,
357            "CODEX_SECRET_DIR",
358            secret_dir.to_str().expect("secret dir path"),
359        );
360        let cache = EnvGuard::set(
361            lock,
362            "ZSH_CACHE_DIR",
363            cache_root.to_str().expect("cache root path"),
364        );
365        (secret, cache)
366    }
367
368    #[test]
369    fn clear_prompt_segment_cache_rejects_relative_cache_root() {
370        let lock = GlobalStateLock::new();
371        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", "relative/cache");
372
373        let err = clear_prompt_segment_cache().expect_err("relative cache root should fail");
374        assert!(err.to_string().contains("non-absolute cache root"));
375    }
376
377    #[test]
378    fn clear_prompt_segment_cache_rejects_root_cache_path() {
379        let lock = GlobalStateLock::new();
380        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", "/");
381
382        let err = clear_prompt_segment_cache().expect_err("root cache path should fail");
383        assert!(err.to_string().contains("invalid cache root"));
384    }
385
386    #[test]
387    fn clear_prompt_segment_cache_removes_only_prompt_segment_cache_dir() {
388        let lock = GlobalStateLock::new();
389        let dir = tempfile::TempDir::new().expect("tempdir");
390        let cache_root = dir.path().join("cache-root");
391        let remove_dir = cache_root.join("codex").join("prompt-segment-rate-limits");
392        let keep_dir = cache_root.join("codex").join("secrets");
393        fs::create_dir_all(&remove_dir).expect("remove dir");
394        fs::create_dir_all(&keep_dir).expect("keep dir");
395        fs::write(
396            remove_dir.join("alpha.kv"),
397            "weekly_remaining=1\nweekly_reset_epoch=2",
398        )
399        .expect("write cached file");
400        fs::write(keep_dir.join("keep.txt"), "keep").expect("write keep file");
401        let _cache = EnvGuard::set(
402            &lock,
403            "ZSH_CACHE_DIR",
404            cache_root.to_str().expect("cache root path"),
405        );
406
407        clear_prompt_segment_cache().expect("clear cache");
408
409        assert!(
410            !remove_dir.exists(),
411            "prompt-segment cache dir should be removed"
412        );
413        assert!(keep_dir.is_dir(), "non-target cache dir should remain");
414    }
415
416    #[test]
417    fn cache_file_for_secret_target_uses_sanitized_secret_name() {
418        let lock = GlobalStateLock::new();
419        let dir = tempfile::TempDir::new().expect("tempdir");
420        let secret_dir = dir.path().join("secrets");
421        let cache_root = dir.path().join("cache");
422        fs::create_dir_all(&secret_dir).expect("secret dir");
423        fs::create_dir_all(&cache_root).expect("cache root");
424        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
425
426        let target = secret_dir.join("My.Secret+Name.json");
427        fs::write(&target, "{}").expect("write secret file");
428
429        let cache_file = cache_file_for_target(&target).expect("cache file");
430        assert_eq!(
431            cache_file,
432            cache_root
433                .join("codex")
434                .join("prompt-segment-rate-limits")
435                .join("my_secret_name.kv")
436        );
437    }
438
439    #[test]
440    fn cache_file_for_non_secret_target_falls_back_to_hashed_key() {
441        let lock = GlobalStateLock::new();
442        let dir = tempfile::TempDir::new().expect("tempdir");
443        let secret_dir = dir.path().join("secrets");
444        let cache_root = dir.path().join("cache");
445        fs::create_dir_all(&secret_dir).expect("secret dir");
446        fs::create_dir_all(&cache_root).expect("cache root");
447        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
448
449        let target = dir.path().join("auth.json");
450        fs::write(&target, "{\"tokens\":{\"access_token\":\"tok\"}}").expect("write auth file");
451
452        let hash = shared_fs::sha256_file(&target).expect("sha256");
453        let cache_file = cache_file_for_target(&target).expect("cache file");
454        assert_eq!(
455            cache_file,
456            cache_root
457                .join("codex")
458                .join("prompt-segment-rate-limits")
459                .join(format!("auth_{hash}.kv"))
460        );
461    }
462
463    #[test]
464    fn cache_file_for_auth_target_reuses_matching_secret_identity() {
465        let lock = GlobalStateLock::new();
466        let dir = tempfile::TempDir::new().expect("tempdir");
467        let secret_dir = dir.path().join("secrets");
468        let cache_root = dir.path().join("cache");
469        fs::create_dir_all(&secret_dir).expect("secret dir");
470        fs::create_dir_all(&cache_root).expect("cache root");
471        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
472
473        let target = dir.path().join("auth.json");
474        let target_content = auth_json(
475            PAYLOAD_ALPHA,
476            "acct_001",
477            "refresh_auth",
478            "2025-01-20T12:34:56Z",
479        );
480        fs::write(&target, target_content).expect("write auth file");
481
482        let secret_file = secret_dir.join("Alpha Team.json");
483        let secret_content = auth_json(
484            PAYLOAD_ALPHA,
485            "acct_001",
486            "refresh_secret",
487            "2025-01-21T12:34:56Z",
488        );
489        fs::write(&secret_file, secret_content).expect("write matching secret file");
490
491        let cache_file = cache_file_for_target(&target).expect("cache file");
492        assert_eq!(
493            cache_file.file_name().and_then(|name| name.to_str()),
494            Some("alpha_team.kv")
495        );
496        assert_eq!(
497            secret_name_for_target(&target),
498            Some("Alpha Team".to_string())
499        );
500    }
501
502    #[test]
503    fn write_then_read_cache_entry_preserves_optional_non_weekly_reset_epoch() {
504        let lock = GlobalStateLock::new();
505        let dir = tempfile::TempDir::new().expect("tempdir");
506        let secret_dir = dir.path().join("secrets");
507        let cache_root = dir.path().join("cache");
508        fs::create_dir_all(&secret_dir).expect("secret dir");
509        fs::create_dir_all(&cache_root).expect("cache root");
510        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
511
512        let target = secret_dir.join("alpha.json");
513        fs::write(&target, "{}").expect("write target");
514
515        write_prompt_segment_cache(
516            &target,
517            1700000000,
518            "5h",
519            91,
520            12,
521            1700600000,
522            Some(1700003600),
523        )
524        .expect("write cache");
525
526        let entry = read_cache_entry(&target).expect("read cache");
527        assert_eq!(entry.non_weekly_label, "5h");
528        assert_eq!(entry.non_weekly_remaining, 91);
529        assert_eq!(entry.non_weekly_reset_epoch, Some(1700003600));
530        assert_eq!(entry.weekly_remaining, 12);
531        assert_eq!(entry.weekly_reset_epoch, 1700600000);
532    }
533
534    #[test]
535    fn write_cache_omits_optional_non_weekly_reset_epoch_when_absent() {
536        let lock = GlobalStateLock::new();
537        let dir = tempfile::TempDir::new().expect("tempdir");
538        let secret_dir = dir.path().join("secrets");
539        let cache_root = dir.path().join("cache");
540        fs::create_dir_all(&secret_dir).expect("secret dir");
541        fs::create_dir_all(&cache_root).expect("cache root");
542        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
543
544        let target = secret_dir.join("alpha.json");
545        fs::write(&target, "{}").expect("write target");
546
547        write_prompt_segment_cache(&target, 1700000000, "daily", 45, 9, 1700600000, None)
548            .expect("write cache");
549
550        let cache_file = cache_file_for_target(&target).expect("cache path");
551        let content = fs::read_to_string(&cache_file).expect("read cache file");
552        assert!(!content.contains("non_weekly_reset_epoch="));
553
554        let entry = read_cache_entry(&target).expect("read cache");
555        assert_eq!(entry.non_weekly_label, "daily");
556        assert_eq!(entry.non_weekly_reset_epoch, None);
557    }
558
559    #[test]
560    fn read_cache_entry_reports_missing_weekly_data() {
561        let lock = GlobalStateLock::new();
562        let dir = tempfile::TempDir::new().expect("tempdir");
563        let secret_dir = dir.path().join("secrets");
564        let cache_root = dir.path().join("cache");
565        fs::create_dir_all(&secret_dir).expect("secret dir");
566        fs::create_dir_all(&cache_root).expect("cache root");
567        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
568
569        let target = secret_dir.join("alpha.json");
570        fs::write(&target, "{}").expect("write target");
571        let cache_file = cache_file_for_target(&target).expect("cache path");
572        fs::create_dir_all(cache_file.parent().expect("cache parent")).expect("cache parent dir");
573        fs::write(
574            &cache_file,
575            "fetched_at=1\nnon_weekly_label=5h\nnon_weekly_remaining=90\nweekly_remaining=1\n",
576        )
577        .expect("write invalid cache");
578
579        let err = read_cache_entry(&target).expect_err("missing weekly reset should fail");
580        assert!(err.to_string().contains("missing weekly data"));
581    }
582
583    #[test]
584    fn read_cache_entry_reports_missing_non_weekly_data() {
585        let lock = GlobalStateLock::new();
586        let dir = tempfile::TempDir::new().expect("tempdir");
587        let secret_dir = dir.path().join("secrets");
588        let cache_root = dir.path().join("cache");
589        fs::create_dir_all(&secret_dir).expect("secret dir");
590        fs::create_dir_all(&cache_root).expect("cache root");
591        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
592
593        let target = secret_dir.join("alpha.json");
594        fs::write(&target, "{}").expect("write target");
595        let cache_file = cache_file_for_target(&target).expect("cache path");
596        fs::create_dir_all(cache_file.parent().expect("cache parent")).expect("cache parent dir");
597        fs::write(
598            &cache_file,
599            "fetched_at=1\nweekly_remaining=1\nweekly_reset_epoch=1700600000\n",
600        )
601        .expect("write invalid cache");
602
603        let err = read_cache_entry(&target).expect_err("missing non-weekly fields should fail");
604        assert!(err.to_string().contains("missing non-weekly data"));
605    }
606
607    #[test]
608    fn read_cache_entry_for_cached_mode_rejects_expired_cache_by_default() {
609        let lock = GlobalStateLock::new();
610        let dir = tempfile::TempDir::new().expect("tempdir");
611        let secret_dir = dir.path().join("secrets");
612        let cache_root = dir.path().join("cache");
613        fs::create_dir_all(&secret_dir).expect("secret dir");
614        fs::create_dir_all(&cache_root).expect("cache root");
615        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
616
617        let target = secret_dir.join("alpha.json");
618        fs::write(&target, "{}").expect("write target");
619        write_prompt_segment_cache(&target, 1, "5h", 91, 12, 1_700_600_000, Some(1_700_003_600))
620            .expect("write cache");
621
622        let err = read_cache_entry_for_cached_mode(&target).expect_err("stale cache should fail");
623        assert!(err.to_string().contains("cache expired"));
624    }
625
626    #[test]
627    fn read_cache_entry_for_cached_mode_honors_ttl_env() {
628        let lock = GlobalStateLock::new();
629        let dir = tempfile::TempDir::new().expect("tempdir");
630        let secret_dir = dir.path().join("secrets");
631        let cache_root = dir.path().join("cache");
632        fs::create_dir_all(&secret_dir).expect("secret dir");
633        fs::create_dir_all(&cache_root).expect("cache root");
634        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
635        let _ttl = EnvGuard::set(&lock, "CODEX_RATE_LIMITS_CACHE_TTL", "1h");
636
637        let target = secret_dir.join("alpha.json");
638        fs::write(&target, "{}").expect("write target");
639        let now = Utc::now().timestamp();
640        let fetched_at = now.saturating_sub(30 * 60);
641        write_prompt_segment_cache(
642            &target,
643            fetched_at,
644            "5h",
645            91,
646            12,
647            1_700_600_000,
648            Some(1_700_003_600),
649        )
650        .expect("write cache");
651
652        let entry = read_cache_entry_for_cached_mode(&target).expect("fresh cache");
653        assert_eq!(entry.non_weekly_label, "5h");
654    }
655
656    #[test]
657    fn read_cache_entry_for_cached_mode_allows_stale_when_enabled() {
658        let lock = GlobalStateLock::new();
659        let dir = tempfile::TempDir::new().expect("tempdir");
660        let secret_dir = dir.path().join("secrets");
661        let cache_root = dir.path().join("cache");
662        fs::create_dir_all(&secret_dir).expect("secret dir");
663        fs::create_dir_all(&cache_root).expect("cache root");
664        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
665        let _allow_stale = EnvGuard::set(&lock, "CODEX_RATE_LIMITS_CACHE_ALLOW_STALE", "true");
666
667        let target = secret_dir.join("alpha.json");
668        fs::write(&target, "{}").expect("write target");
669        write_prompt_segment_cache(&target, 1, "5h", 91, 12, 1_700_600_000, Some(1_700_003_600))
670            .expect("write cache");
671
672        let entry = read_cache_entry_for_cached_mode(&target).expect("allow stale");
673        assert_eq!(entry.non_weekly_remaining, 91);
674    }
675}