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