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
165/// A cached entry plus whether it is past the freshness TTL.
166pub struct StaleCacheRead {
167    pub entry: CacheEntry,
168    pub stale: bool,
169}
170
171/// Reads the cached entry without rejecting stale data, reporting whether it is
172/// past the freshness TTL.
173///
174/// Unlike [`read_cache_entry_for_cached_mode`] (which enforces the TTL for the
175/// explicit `--cached` mode), this serves the last-known values regardless of
176/// age so the diag command can degrade gracefully when a live fetch yields no
177/// usable window. Callers surface the `stale` flag to the user.
178pub fn read_cache_entry_allow_stale(target_file: &Path) -> Result<StaleCacheRead> {
179    let entry = read_cache_entry(target_file)?;
180    let stale = cache_entry_is_stale(&entry);
181    Ok(StaleCacheRead { entry, stale })
182}
183
184fn cache_entry_is_stale(entry: &CacheEntry) -> bool {
185    let fetched_at = match entry.fetched_at_epoch {
186        Some(value) if value > 0 => value,
187        _ => return true,
188    };
189    let now_epoch = chrono::Utc::now().timestamp();
190    if now_epoch <= 0 {
191        return false;
192    }
193    let ttl_i64 = i64::try_from(cache_ttl_seconds()).unwrap_or(i64::MAX);
194    now_epoch.saturating_sub(fetched_at) > ttl_i64
195}
196
197pub fn write_prompt_segment_cache(
198    target_file: &Path,
199    fetched_at_epoch: i64,
200    non_weekly_label: &str,
201    non_weekly_remaining: i64,
202    weekly_remaining: i64,
203    weekly_reset_epoch: i64,
204    non_weekly_reset_epoch: Option<i64>,
205) -> Result<()> {
206    let cache_file = cache_file_for_target(target_file)?;
207    if let Some(parent) = cache_file.parent() {
208        fs::create_dir_all(parent)?;
209    }
210
211    let mut lines = Vec::new();
212    lines.push(format!("fetched_at={fetched_at_epoch}"));
213    lines.push(format!("non_weekly_label={non_weekly_label}"));
214    lines.push(format!("non_weekly_remaining={non_weekly_remaining}"));
215    if let Some(epoch) = non_weekly_reset_epoch {
216        lines.push(format!("non_weekly_reset_epoch={epoch}"));
217    }
218    lines.push(format!("weekly_remaining={weekly_remaining}"));
219    lines.push(format!("weekly_reset_epoch={weekly_reset_epoch}"));
220
221    let data = lines.join("\n");
222    shared_fs::write_atomic(&cache_file, data.as_bytes(), shared_fs::SECRET_FILE_MODE)?;
223    Ok(())
224}
225
226fn prompt_segment_cache_dir() -> Result<PathBuf> {
227    let root = cache_root().context("cache root")?;
228    Ok(root.join("codex").join("prompt-segment-rate-limits"))
229}
230
231fn ensure_cache_fresh(target_file: &Path, entry: &CacheEntry) -> Result<()> {
232    let ttl_seconds = cache_ttl_seconds();
233    let ttl_i64 = i64::try_from(ttl_seconds).unwrap_or(i64::MAX);
234    let cache_file = cache_file_for_target(target_file)?;
235
236    let fetched_at_epoch = match entry.fetched_at_epoch {
237        Some(value) if value > 0 => value,
238        _ => {
239            anyhow::bail!(
240                "codex-rate-limits: cache expired (missing fetched_at): {} ({})",
241                cache_file.display(),
242                CACHE_MISS_HINT
243            );
244        }
245    };
246
247    let now_epoch = chrono::Utc::now().timestamp();
248    if now_epoch <= 0 {
249        return Ok(());
250    }
251
252    let age_seconds = if now_epoch >= fetched_at_epoch {
253        now_epoch - fetched_at_epoch
254    } else {
255        0
256    };
257    if age_seconds > ttl_i64 {
258        anyhow::bail!(
259            "codex-rate-limits: cache expired (age={}s, ttl={}s): {} ({})",
260            age_seconds,
261            ttl_seconds,
262            cache_file.display(),
263            CACHE_MISS_HINT
264        );
265    }
266
267    Ok(())
268}
269
270fn cache_ttl_seconds() -> u64 {
271    if let Ok(raw) = std::env::var("CODEX_RATE_LIMITS_CACHE_TTL")
272        && let Some(value) = shared_env::parse_duration_seconds(&raw)
273    {
274        return value;
275    }
276    DEFAULT_CACHE_TTL_SECONDS
277}
278
279fn cache_allow_stale() -> bool {
280    shared_env::env_truthy_or("CODEX_RATE_LIMITS_CACHE_ALLOW_STALE", false)
281}
282
283fn cache_root() -> Option<PathBuf> {
284    if let Ok(path) = std::env::var("ZSH_CACHE_DIR")
285        && !path.is_empty()
286    {
287        return Some(PathBuf::from(path));
288    }
289    let zdotdir = paths::resolve_zdotdir()?;
290    Some(zdotdir.join("cache"))
291}
292
293fn secret_name_for_auth(auth_file: &Path, secret_dir: &Path) -> Option<String> {
294    let auth_key = auth::identity_key_from_auth_file(auth_file)
295        .ok()
296        .flatten()?;
297    let entries = std::fs::read_dir(secret_dir).ok()?;
298    for entry in entries.flatten() {
299        let path = entry.path();
300        if path.extension().and_then(|s| s.to_str()) != Some("json") {
301            continue;
302        }
303        let candidate_key = match auth::identity_key_from_auth_file(&path).ok().flatten() {
304            Some(value) => value,
305            None => continue,
306        };
307        if candidate_key == auth_key {
308            return secret_file_basename(&path).ok();
309        }
310    }
311    None
312}
313
314fn secret_file_basename(path: &Path) -> Result<String> {
315    let file = path
316        .file_name()
317        .and_then(|name| name.to_str())
318        .unwrap_or_default();
319    let base = file.trim_end_matches(".json");
320    Ok(base.to_string())
321}
322
323fn cache_key(name: &str) -> Result<String> {
324    if name.is_empty() {
325        anyhow::bail!("missing cache key name");
326    }
327    let mut key = String::new();
328    for ch in name.to_lowercase().chars() {
329        if ch.is_ascii_alphanumeric() {
330            key.push(ch);
331        } else {
332            key.push('_');
333        }
334    }
335    while key.starts_with('_') {
336        key.remove(0);
337    }
338    while key.ends_with('_') {
339        key.pop();
340    }
341    if key.is_empty() {
342        anyhow::bail!("invalid cache key name");
343    }
344    Ok(key)
345}
346
347#[cfg(test)]
348mod tests {
349    use super::{
350        cache_file_for_target, clear_prompt_segment_cache, read_cache_entry,
351        read_cache_entry_for_cached_mode, secret_name_for_target, write_prompt_segment_cache,
352    };
353    use chrono::Utc;
354    use nils_common::fs as shared_fs;
355    use nils_test_support::{EnvGuard, GlobalStateLock};
356    use std::fs;
357    use std::path::Path;
358
359    const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
360    const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
361
362    fn token(payload: &str) -> String {
363        format!("{HEADER}.{payload}.sig")
364    }
365
366    fn auth_json(
367        payload: &str,
368        account_id: &str,
369        refresh_token: &str,
370        last_refresh: &str,
371    ) -> String {
372        format!(
373            r#"{{"tokens":{{"access_token":"{}","id_token":"{}","refresh_token":"{}","account_id":"{}"}},"last_refresh":"{}"}}"#,
374            token(payload),
375            token(payload),
376            refresh_token,
377            account_id,
378            last_refresh
379        )
380    }
381
382    fn set_cache_env(
383        lock: &GlobalStateLock,
384        secret_dir: &Path,
385        cache_root: &Path,
386    ) -> (EnvGuard, EnvGuard) {
387        let secret = EnvGuard::set(
388            lock,
389            "CODEX_SECRET_DIR",
390            secret_dir.to_str().expect("secret dir path"),
391        );
392        let cache = EnvGuard::set(
393            lock,
394            "ZSH_CACHE_DIR",
395            cache_root.to_str().expect("cache root path"),
396        );
397        (secret, cache)
398    }
399
400    #[test]
401    fn clear_prompt_segment_cache_rejects_relative_cache_root() {
402        let lock = GlobalStateLock::new();
403        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", "relative/cache");
404
405        let err = clear_prompt_segment_cache().expect_err("relative cache root should fail");
406        assert!(err.to_string().contains("non-absolute cache root"));
407    }
408
409    #[test]
410    fn clear_prompt_segment_cache_rejects_root_cache_path() {
411        let lock = GlobalStateLock::new();
412        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", "/");
413
414        let err = clear_prompt_segment_cache().expect_err("root cache path should fail");
415        assert!(err.to_string().contains("invalid cache root"));
416    }
417
418    #[test]
419    fn clear_prompt_segment_cache_removes_only_prompt_segment_cache_dir() {
420        let lock = GlobalStateLock::new();
421        let dir = tempfile::TempDir::new().expect("tempdir");
422        let cache_root = dir.path().join("cache-root");
423        let remove_dir = cache_root.join("codex").join("prompt-segment-rate-limits");
424        let keep_dir = cache_root.join("codex").join("secrets");
425        fs::create_dir_all(&remove_dir).expect("remove dir");
426        fs::create_dir_all(&keep_dir).expect("keep dir");
427        fs::write(
428            remove_dir.join("alpha.kv"),
429            "weekly_remaining=1\nweekly_reset_epoch=2",
430        )
431        .expect("write cached file");
432        fs::write(keep_dir.join("keep.txt"), "keep").expect("write keep file");
433        let _cache = EnvGuard::set(
434            &lock,
435            "ZSH_CACHE_DIR",
436            cache_root.to_str().expect("cache root path"),
437        );
438
439        clear_prompt_segment_cache().expect("clear cache");
440
441        assert!(
442            !remove_dir.exists(),
443            "prompt-segment cache dir should be removed"
444        );
445        assert!(keep_dir.is_dir(), "non-target cache dir should remain");
446    }
447
448    #[test]
449    fn cache_file_for_secret_target_uses_sanitized_secret_name() {
450        let lock = GlobalStateLock::new();
451        let dir = tempfile::TempDir::new().expect("tempdir");
452        let secret_dir = dir.path().join("secrets");
453        let cache_root = dir.path().join("cache");
454        fs::create_dir_all(&secret_dir).expect("secret dir");
455        fs::create_dir_all(&cache_root).expect("cache root");
456        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
457
458        let target = secret_dir.join("My.Secret+Name.json");
459        fs::write(&target, "{}").expect("write secret file");
460
461        let cache_file = cache_file_for_target(&target).expect("cache file");
462        assert_eq!(
463            cache_file,
464            cache_root
465                .join("codex")
466                .join("prompt-segment-rate-limits")
467                .join("my_secret_name.kv")
468        );
469    }
470
471    #[test]
472    fn cache_file_for_non_secret_target_falls_back_to_hashed_key() {
473        let lock = GlobalStateLock::new();
474        let dir = tempfile::TempDir::new().expect("tempdir");
475        let secret_dir = dir.path().join("secrets");
476        let cache_root = dir.path().join("cache");
477        fs::create_dir_all(&secret_dir).expect("secret dir");
478        fs::create_dir_all(&cache_root).expect("cache root");
479        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
480
481        let target = dir.path().join("auth.json");
482        fs::write(&target, "{\"tokens\":{\"access_token\":\"tok\"}}").expect("write auth file");
483
484        let hash = shared_fs::sha256_file(&target).expect("sha256");
485        let cache_file = cache_file_for_target(&target).expect("cache file");
486        assert_eq!(
487            cache_file,
488            cache_root
489                .join("codex")
490                .join("prompt-segment-rate-limits")
491                .join(format!("auth_{hash}.kv"))
492        );
493    }
494
495    #[test]
496    fn cache_file_for_auth_target_reuses_matching_secret_identity() {
497        let lock = GlobalStateLock::new();
498        let dir = tempfile::TempDir::new().expect("tempdir");
499        let secret_dir = dir.path().join("secrets");
500        let cache_root = dir.path().join("cache");
501        fs::create_dir_all(&secret_dir).expect("secret dir");
502        fs::create_dir_all(&cache_root).expect("cache root");
503        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
504
505        let target = dir.path().join("auth.json");
506        let target_content = auth_json(
507            PAYLOAD_ALPHA,
508            "acct_001",
509            "refresh_auth",
510            "2025-01-20T12:34:56Z",
511        );
512        fs::write(&target, target_content).expect("write auth file");
513
514        let secret_file = secret_dir.join("Alpha Team.json");
515        let secret_content = auth_json(
516            PAYLOAD_ALPHA,
517            "acct_001",
518            "refresh_secret",
519            "2025-01-21T12:34:56Z",
520        );
521        fs::write(&secret_file, secret_content).expect("write matching secret file");
522
523        let cache_file = cache_file_for_target(&target).expect("cache file");
524        assert_eq!(
525            cache_file.file_name().and_then(|name| name.to_str()),
526            Some("alpha_team.kv")
527        );
528        assert_eq!(
529            secret_name_for_target(&target),
530            Some("Alpha Team".to_string())
531        );
532    }
533
534    #[test]
535    fn write_then_read_cache_entry_preserves_optional_non_weekly_reset_epoch() {
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(
548            &target,
549            1700000000,
550            "5h",
551            91,
552            12,
553            1700600000,
554            Some(1700003600),
555        )
556        .expect("write cache");
557
558        let entry = read_cache_entry(&target).expect("read cache");
559        assert_eq!(entry.non_weekly_label, "5h");
560        assert_eq!(entry.non_weekly_remaining, 91);
561        assert_eq!(entry.non_weekly_reset_epoch, Some(1700003600));
562        assert_eq!(entry.weekly_remaining, 12);
563        assert_eq!(entry.weekly_reset_epoch, 1700600000);
564    }
565
566    #[test]
567    fn write_cache_omits_optional_non_weekly_reset_epoch_when_absent() {
568        let lock = GlobalStateLock::new();
569        let dir = tempfile::TempDir::new().expect("tempdir");
570        let secret_dir = dir.path().join("secrets");
571        let cache_root = dir.path().join("cache");
572        fs::create_dir_all(&secret_dir).expect("secret dir");
573        fs::create_dir_all(&cache_root).expect("cache root");
574        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
575
576        let target = secret_dir.join("alpha.json");
577        fs::write(&target, "{}").expect("write target");
578
579        write_prompt_segment_cache(&target, 1700000000, "daily", 45, 9, 1700600000, None)
580            .expect("write cache");
581
582        let cache_file = cache_file_for_target(&target).expect("cache path");
583        let content = fs::read_to_string(&cache_file).expect("read cache file");
584        assert!(!content.contains("non_weekly_reset_epoch="));
585
586        let entry = read_cache_entry(&target).expect("read cache");
587        assert_eq!(entry.non_weekly_label, "daily");
588        assert_eq!(entry.non_weekly_reset_epoch, None);
589    }
590
591    #[test]
592    fn read_cache_entry_reports_missing_weekly_data() {
593        let lock = GlobalStateLock::new();
594        let dir = tempfile::TempDir::new().expect("tempdir");
595        let secret_dir = dir.path().join("secrets");
596        let cache_root = dir.path().join("cache");
597        fs::create_dir_all(&secret_dir).expect("secret dir");
598        fs::create_dir_all(&cache_root).expect("cache root");
599        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
600
601        let target = secret_dir.join("alpha.json");
602        fs::write(&target, "{}").expect("write target");
603        let cache_file = cache_file_for_target(&target).expect("cache path");
604        fs::create_dir_all(cache_file.parent().expect("cache parent")).expect("cache parent dir");
605        fs::write(
606            &cache_file,
607            "fetched_at=1\nnon_weekly_label=5h\nnon_weekly_remaining=90\nweekly_remaining=1\n",
608        )
609        .expect("write invalid cache");
610
611        let err = read_cache_entry(&target).expect_err("missing weekly reset should fail");
612        assert!(err.to_string().contains("missing weekly data"));
613    }
614
615    #[test]
616    fn read_cache_entry_reports_missing_non_weekly_data() {
617        let lock = GlobalStateLock::new();
618        let dir = tempfile::TempDir::new().expect("tempdir");
619        let secret_dir = dir.path().join("secrets");
620        let cache_root = dir.path().join("cache");
621        fs::create_dir_all(&secret_dir).expect("secret dir");
622        fs::create_dir_all(&cache_root).expect("cache root");
623        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
624
625        let target = secret_dir.join("alpha.json");
626        fs::write(&target, "{}").expect("write target");
627        let cache_file = cache_file_for_target(&target).expect("cache path");
628        fs::create_dir_all(cache_file.parent().expect("cache parent")).expect("cache parent dir");
629        fs::write(
630            &cache_file,
631            "fetched_at=1\nweekly_remaining=1\nweekly_reset_epoch=1700600000\n",
632        )
633        .expect("write invalid cache");
634
635        let err = read_cache_entry(&target).expect_err("missing non-weekly fields should fail");
636        assert!(err.to_string().contains("missing non-weekly data"));
637    }
638
639    #[test]
640    fn read_cache_entry_for_cached_mode_rejects_expired_cache_by_default() {
641        let lock = GlobalStateLock::new();
642        let dir = tempfile::TempDir::new().expect("tempdir");
643        let secret_dir = dir.path().join("secrets");
644        let cache_root = dir.path().join("cache");
645        fs::create_dir_all(&secret_dir).expect("secret dir");
646        fs::create_dir_all(&cache_root).expect("cache root");
647        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
648
649        let target = secret_dir.join("alpha.json");
650        fs::write(&target, "{}").expect("write target");
651        write_prompt_segment_cache(&target, 1, "5h", 91, 12, 1_700_600_000, Some(1_700_003_600))
652            .expect("write cache");
653
654        let err = read_cache_entry_for_cached_mode(&target).expect_err("stale cache should fail");
655        assert!(err.to_string().contains("cache expired"));
656    }
657
658    #[test]
659    fn read_cache_entry_for_cached_mode_honors_ttl_env() {
660        let lock = GlobalStateLock::new();
661        let dir = tempfile::TempDir::new().expect("tempdir");
662        let secret_dir = dir.path().join("secrets");
663        let cache_root = dir.path().join("cache");
664        fs::create_dir_all(&secret_dir).expect("secret dir");
665        fs::create_dir_all(&cache_root).expect("cache root");
666        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
667        let _ttl = EnvGuard::set(&lock, "CODEX_RATE_LIMITS_CACHE_TTL", "1h");
668
669        let target = secret_dir.join("alpha.json");
670        fs::write(&target, "{}").expect("write target");
671        let now = Utc::now().timestamp();
672        let fetched_at = now.saturating_sub(30 * 60);
673        write_prompt_segment_cache(
674            &target,
675            fetched_at,
676            "5h",
677            91,
678            12,
679            1_700_600_000,
680            Some(1_700_003_600),
681        )
682        .expect("write cache");
683
684        let entry = read_cache_entry_for_cached_mode(&target).expect("fresh cache");
685        assert_eq!(entry.non_weekly_label, "5h");
686    }
687
688    #[test]
689    fn read_cache_entry_for_cached_mode_allows_stale_when_enabled() {
690        let lock = GlobalStateLock::new();
691        let dir = tempfile::TempDir::new().expect("tempdir");
692        let secret_dir = dir.path().join("secrets");
693        let cache_root = dir.path().join("cache");
694        fs::create_dir_all(&secret_dir).expect("secret dir");
695        fs::create_dir_all(&cache_root).expect("cache root");
696        let _env = set_cache_env(&lock, &secret_dir, &cache_root);
697        let _allow_stale = EnvGuard::set(&lock, "CODEX_RATE_LIMITS_CACHE_ALLOW_STALE", "true");
698
699        let target = secret_dir.join("alpha.json");
700        fs::write(&target, "{}").expect("write target");
701        write_prompt_segment_cache(&target, 1, "5h", 91, 12, 1_700_600_000, Some(1_700_003_600))
702            .expect("write cache");
703
704        let entry = read_cache_entry_for_cached_mode(&target).expect("allow stale");
705        assert_eq!(entry.non_weekly_remaining, 91);
706    }
707}