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}