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 struct StaleCacheRead {
167 pub entry: CacheEntry,
168 pub stale: bool,
169}
170
171pub 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}