1use std::collections::HashMap;
22use std::fs;
23use std::io;
24use std::path::{Path, PathBuf};
25
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29use super::usage::{ExtraUsage, UsageApiResponse, UsageBucket};
30
31pub const CACHE_SCHEMA_VERSION: u32 = 1;
36
37const USAGE_FILE: &str = "usage.json";
38const LOCK_FILE: &str = "usage.lock";
39
40const LEGACY_LOCK_TTL_SECS: i64 = 30;
44
45const MAX_LOCK_DURATION_SECS: i64 = 24 * 60 * 60;
51
52#[derive(Debug)]
59#[non_exhaustive]
60pub enum CacheError {
61 Io { path: PathBuf, cause: io::Error },
63 Persist { path: PathBuf, cause: io::Error },
65}
66
67impl std::fmt::Display for CacheError {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 match self {
70 Self::Io { path, cause } => {
71 write!(f, "cache I/O error on {}: {}", path.display(), cause.kind())
72 }
73 Self::Persist { path, cause } => write!(
74 f,
75 "atomic persist failed for {}: {}",
76 path.display(),
77 cause.kind()
78 ),
79 }
80 }
81}
82
83impl std::error::Error for CacheError {
84 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
85 match self {
86 Self::Io { cause, .. } | Self::Persist { cause, .. } => Some(cause),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100pub struct CachedUsage {
101 pub schema_version: u32,
102 pub cached_at: DateTime<Utc>,
103 #[serde(default)]
104 pub data: Option<CachedData>,
105 #[serde(default)]
106 pub error: Option<CachedError>,
107}
108
109impl CachedUsage {
110 #[must_use]
111 pub fn with_data(data: UsageApiResponse) -> Self {
112 Self {
113 schema_version: CACHE_SCHEMA_VERSION,
114 cached_at: Utc::now(),
115 data: Some(CachedData::from(data)),
116 error: None,
117 }
118 }
119
120 #[must_use]
121 pub fn with_error(code: &str) -> Self {
122 Self {
123 schema_version: CACHE_SCHEMA_VERSION,
124 cached_at: Utc::now(),
125 data: None,
126 error: Some(CachedError {
127 code: code.to_string(),
128 }),
129 }
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
140#[non_exhaustive]
141pub struct CachedData {
142 #[serde(default)]
143 pub five_hour: Option<UsageBucket>,
144 #[serde(default)]
145 pub seven_day: Option<UsageBucket>,
146 #[serde(default)]
147 pub seven_day_opus: Option<UsageBucket>,
148 #[serde(default)]
149 pub seven_day_sonnet: Option<UsageBucket>,
150 #[serde(default)]
151 pub seven_day_oauth_apps: Option<UsageBucket>,
152 #[serde(default)]
153 pub extra_usage: Option<ExtraUsage>,
154 #[serde(default)]
155 pub unknown_buckets: HashMap<String, serde_json::Value>,
156}
157
158impl From<UsageApiResponse> for CachedData {
159 fn from(r: UsageApiResponse) -> Self {
160 Self {
161 five_hour: r.five_hour,
162 seven_day: r.seven_day,
163 seven_day_opus: r.seven_day_opus,
164 seven_day_sonnet: r.seven_day_sonnet,
165 seven_day_oauth_apps: r.seven_day_oauth_apps,
166 extra_usage: r.extra_usage,
167 unknown_buckets: r.unknown_buckets,
168 }
169 }
170}
171
172impl From<CachedData> for UsageApiResponse {
173 fn from(c: CachedData) -> Self {
174 UsageApiResponse {
175 five_hour: c.five_hour,
176 seven_day: c.seven_day,
177 seven_day_opus: c.seven_day_opus,
178 seven_day_sonnet: c.seven_day_sonnet,
179 seven_day_oauth_apps: c.seven_day_oauth_apps,
180 extra_usage: c.extra_usage,
181 unknown_buckets: c.unknown_buckets,
182 }
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191pub struct CachedError {
192 pub code: String,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
200pub struct Lock {
201 pub blocked_until: i64,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub error: Option<String>,
204}
205
206#[must_use]
214pub fn default_root() -> Option<PathBuf> {
215 use super::xdg::{resolve_subdir, XdgEnv, XdgScope};
216 resolve_subdir(&XdgEnv::from_process_env(), XdgScope::Cache, "")
217}
218
219pub struct CacheStore {
223 root: PathBuf,
224}
225
226impl CacheStore {
227 #[must_use]
228 pub fn new(root: PathBuf) -> Self {
229 Self { root }
230 }
231
232 #[must_use]
233 pub fn path(&self) -> PathBuf {
234 self.root.join(USAGE_FILE)
235 }
236
237 pub fn read(&self) -> Result<Option<CachedUsage>, CacheError> {
243 let path = self.path();
244 let bytes = match fs::read(&path) {
245 Ok(bytes) => bytes,
246 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
247 Err(cause) => return Err(CacheError::Io { path, cause }),
248 };
249 let Ok(text) = std::str::from_utf8(&bytes) else {
253 return Ok(None);
254 };
255 match serde_json::from_str::<CachedUsage>(text) {
256 Ok(entry)
257 if entry.schema_version == CACHE_SCHEMA_VERSION
258 && entry.cached_at <= Utc::now() =>
259 {
260 Ok(Some(entry))
261 }
262 _ => Ok(None),
264 }
265 }
266
267 pub fn write(&self, entry: &CachedUsage) -> Result<(), CacheError> {
270 atomic_write_json(&self.path(), entry)
271 }
272}
273
274pub struct LockStore {
278 root: PathBuf,
279}
280
281impl LockStore {
282 #[must_use]
283 pub fn new(root: PathBuf) -> Self {
284 Self { root }
285 }
286
287 #[must_use]
288 pub fn path(&self) -> PathBuf {
289 self.root.join(LOCK_FILE)
290 }
291
292 pub fn read(&self) -> Result<Option<Lock>, CacheError> {
300 let path = self.path();
301 let bytes = match fs::read(&path) {
302 Ok(bytes) => bytes,
303 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
304 Err(cause) => return Err(CacheError::Io { path, cause }),
305 };
306 if let Ok(text) = std::str::from_utf8(&bytes) {
309 if let Ok(mut lock) = serde_json::from_str::<Lock>(text) {
310 cap_blocked_until(&mut lock.blocked_until);
311 return Ok(Some(lock));
312 }
313 }
314 let meta = fs::metadata(&path).map_err(|cause| CacheError::Io {
318 path: path.clone(),
319 cause,
320 })?;
321 let mtime = meta.modified().map_err(|cause| CacheError::Io {
322 path: path.clone(),
323 cause,
324 })?;
325 let mtime_unix: i64 = match mtime.duration_since(std::time::UNIX_EPOCH) {
326 Ok(d) => d.as_secs() as i64,
327 Err(_) => {
328 debug_assert!(false, "lock file mtime before UNIX_EPOCH");
333 0
334 }
335 };
336 let mut blocked_until = mtime_unix + LEGACY_LOCK_TTL_SECS;
337 cap_blocked_until(&mut blocked_until);
338 Ok(Some(Lock {
339 blocked_until,
340 error: None,
341 }))
342 }
343
344 pub fn write(&self, lock: &Lock) -> Result<(), CacheError> {
345 atomic_write_json(&self.path(), lock)
346 }
347}
348
349fn cap_blocked_until(blocked_until: &mut i64) {
350 let max = Utc::now().timestamp() + MAX_LOCK_DURATION_SECS;
351 if *blocked_until > max {
352 *blocked_until = max;
353 }
354}
355
356pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), CacheError> {
364 let parent = path.parent().ok_or_else(|| CacheError::Io {
365 path: path.to_path_buf(),
366 cause: io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"),
367 })?;
368 fs::create_dir_all(parent).map_err(|cause| CacheError::Io {
369 path: parent.to_path_buf(),
370 cause,
371 })?;
372 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|cause| CacheError::Io {
373 path: parent.to_path_buf(),
374 cause,
375 })?;
376 serde_json::to_writer_pretty(&tmp, value).map_err(|e| CacheError::Io {
377 path: path.to_path_buf(),
378 cause: io::Error::other(e),
379 })?;
380 tmp.as_file().sync_all().map_err(|cause| CacheError::Io {
381 path: path.to_path_buf(),
382 cause,
383 })?;
384 tmp.persist(path).map_err(|e| CacheError::Persist {
385 path: path.to_path_buf(),
386 cause: e.error,
387 })?;
388 Ok(())
389}
390
391#[cfg(test)]
394mod tests {
395 use super::*;
396 use chrono::Duration;
397 use tempfile::TempDir;
398
399 fn sample_response() -> UsageApiResponse {
400 let json = r#"{
401 "five_hour": { "utilization": 22.0, "resets_at": "2026-04-19T05:00:00Z" },
402 "seven_day": { "utilization": 33.0, "resets_at": "2026-04-23T19:00:00Z" }
403 }"#;
404 serde_json::from_str(json).expect("parse")
405 }
406
407 #[test]
414 fn cache_round_trip_preserves_data_entry() {
415 let tmp = TempDir::new().unwrap();
416 let store = CacheStore::new(tmp.path().to_path_buf());
417 let entry = CachedUsage::with_data(sample_response());
418 store.write(&entry).expect("write");
419 let read_back = store.read().expect("read").expect("some");
420 assert_eq!(read_back, entry);
421 }
422
423 #[test]
424 fn cache_round_trip_preserves_error_entry() {
425 let tmp = TempDir::new().unwrap();
426 let store = CacheStore::new(tmp.path().to_path_buf());
427 let entry = CachedUsage::with_error("Timeout");
428 store.write(&entry).expect("write");
429 let read_back = store.read().expect("read").expect("some");
430 assert_eq!(read_back.error.unwrap().code, "Timeout");
431 assert!(read_back.data.is_none());
432 }
433
434 #[test]
435 fn cache_read_returns_none_when_missing() {
436 let tmp = TempDir::new().unwrap();
437 let store = CacheStore::new(tmp.path().to_path_buf());
438 assert!(store.read().expect("read").is_none());
439 }
440
441 #[test]
442 fn cache_read_returns_none_for_schema_mismatch() {
443 let tmp = TempDir::new().unwrap();
444 let path = tmp.path().join(USAGE_FILE);
445 fs::create_dir_all(tmp.path()).unwrap();
446 fs::write(
447 &path,
448 r#"{ "schema_version": 9999, "cached_at": "2026-04-20T12:00:00Z", "data": null, "error": null }"#,
449 )
450 .unwrap();
451 let store = CacheStore::new(tmp.path().to_path_buf());
452 assert!(store.read().expect("read").is_none());
453 }
454
455 #[test]
456 fn cache_read_returns_none_for_clock_skew() {
457 let tmp = TempDir::new().unwrap();
459 let store = CacheStore::new(tmp.path().to_path_buf());
460 let mut entry = CachedUsage::with_data(sample_response());
461 entry.cached_at = Utc::now() + Duration::minutes(10);
462 store.write(&entry).expect("write");
463 assert!(store.read().expect("read").is_none());
464 }
465
466 #[test]
467 fn cache_read_returns_none_for_corrupt_json() {
468 let tmp = TempDir::new().unwrap();
469 fs::write(tmp.path().join(USAGE_FILE), "{ not valid json ").unwrap();
470 let store = CacheStore::new(tmp.path().to_path_buf());
471 assert!(store.read().expect("read").is_none());
472 }
473
474 #[test]
475 fn cache_read_returns_none_for_zero_byte_file() {
476 let tmp = TempDir::new().unwrap();
477 fs::write(tmp.path().join(USAGE_FILE), "").unwrap();
478 let store = CacheStore::new(tmp.path().to_path_buf());
479 assert!(store.read().expect("read").is_none());
480 }
481
482 #[test]
483 fn cache_read_returns_none_for_non_utf8_bytes() {
484 let tmp = TempDir::new().unwrap();
489 fs::write(tmp.path().join(USAGE_FILE), [0xFF, 0xFE, 0xFD]).unwrap();
490 let store = CacheStore::new(tmp.path().to_path_buf());
491 assert!(store.read().expect("read").is_none());
492 }
493
494 #[test]
495 fn cache_write_creates_missing_parent_directory() {
496 let tmp = TempDir::new().unwrap();
499 let nested = tmp.path().join("nested").join("linesmith");
500 let store = CacheStore::new(nested.clone());
501 store
502 .write(&CachedUsage::with_data(sample_response()))
503 .expect("write");
504 assert!(nested.join(USAGE_FILE).exists());
505 }
506
507 #[test]
508 fn cache_round_trip_preserves_unknown_buckets() {
509 let tmp = TempDir::new().unwrap();
513 let store = CacheStore::new(tmp.path().to_path_buf());
514 let json = r#"{
515 "five_hour": { "utilization": 10.0, "resets_at": "2026-04-19T05:00:00Z" },
516 "quokka_experimental": { "utilization": 99.0, "resets_at": null }
517 }"#;
518 let response: UsageApiResponse = serde_json::from_str(json).unwrap();
519 store
520 .write(&CachedUsage::with_data(response))
521 .expect("write");
522 let read_back = store.read().expect("read").expect("some");
523 let data = read_back.data.unwrap();
524 assert!(data.unknown_buckets.contains_key("quokka_experimental"));
525 }
526
527 #[test]
530 fn concurrent_writes_produce_intact_file() {
531 use std::sync::Arc;
532 use std::thread;
533
534 let tmp = TempDir::new().unwrap();
535 let store = Arc::new(CacheStore::new(tmp.path().to_path_buf()));
536
537 let store_a = Arc::clone(&store);
538 let handle_a = thread::spawn(move || {
539 let mut succeeded = 0;
540 for _ in 0..10 {
541 if store_a.write(&CachedUsage::with_error("Timeout")).is_ok() {
542 succeeded += 1;
543 }
544 }
545 succeeded
546 });
547 let store_b = Arc::clone(&store);
548 let handle_b = thread::spawn(move || {
549 let mut succeeded = 0;
550 for _ in 0..10 {
551 if store_b
552 .write(&CachedUsage::with_data(sample_response()))
553 .is_ok()
554 {
555 succeeded += 1;
556 }
557 }
558 succeeded
559 });
560 let succeeded = handle_a.join().unwrap() + handle_b.join().unwrap();
561
562 #[cfg(unix)]
571 assert_eq!(succeeded, 20, "POSIX rename(2) should never fail");
572 #[cfg(not(unix))]
573 assert!(succeeded > 0, "at least one concurrent write must win");
574
575 let read_back = store.read().expect("read").expect("some");
578 assert_eq!(read_back.schema_version, CACHE_SCHEMA_VERSION);
579 assert!(read_back.data.is_some() ^ read_back.error.is_some());
580 }
581
582 #[test]
585 fn lock_round_trip() {
586 let tmp = TempDir::new().unwrap();
587 let store = LockStore::new(tmp.path().to_path_buf());
588 let now = Utc::now().timestamp();
590 let lock = Lock {
591 blocked_until: now + 60,
592 error: Some("rate-limited".into()),
593 };
594 store.write(&lock).expect("write");
595 let read_back = store.read().expect("read").expect("some");
596 assert_eq!(read_back, lock);
597 }
598
599 #[test]
600 fn lock_read_returns_none_when_missing() {
601 let tmp = TempDir::new().unwrap();
602 let store = LockStore::new(tmp.path().to_path_buf());
603 assert!(store.read().expect("read").is_none());
604 }
605
606 #[test]
607 fn lock_read_non_utf8_routes_through_legacy_fallback() {
608 let tmp = TempDir::new().unwrap();
613 let path = tmp.path().join(LOCK_FILE);
614 fs::write(&path, [0xFF, 0xFE, 0x00, 0xFD]).unwrap();
615 let mtime = fs::metadata(&path)
616 .unwrap()
617 .modified()
618 .unwrap()
619 .duration_since(std::time::UNIX_EPOCH)
620 .unwrap()
621 .as_secs() as i64;
622 let store = LockStore::new(tmp.path().to_path_buf());
623 let lock = store.read().expect("read").expect("some");
624 assert_eq!(lock.blocked_until, mtime + LEGACY_LOCK_TTL_SECS);
625 assert!(lock.error.is_none());
626 }
627
628 #[test]
629 fn lock_read_legacy_non_json_uses_mtime_plus_30s() {
630 let tmp = TempDir::new().unwrap();
631 let path = tmp.path().join(LOCK_FILE);
632 fs::write(&path, "# legacy lock from older linesmith").unwrap();
633 let mtime = fs::metadata(&path)
634 .unwrap()
635 .modified()
636 .unwrap()
637 .duration_since(std::time::UNIX_EPOCH)
638 .unwrap()
639 .as_secs() as i64;
640 let store = LockStore::new(tmp.path().to_path_buf());
641 let lock = store.read().expect("read").expect("some");
642 assert_eq!(lock.blocked_until, mtime + LEGACY_LOCK_TTL_SECS);
643 assert!(lock.error.is_none());
644 }
645
646 #[test]
647 fn lock_read_caps_pathological_blocked_until() {
648 let tmp = TempDir::new().unwrap();
652 let store = LockStore::new(tmp.path().to_path_buf());
653 let malicious = Lock {
654 blocked_until: i64::MAX,
655 error: None,
656 };
657 store.write(&malicious).expect("write");
658 let read_back = store.read().expect("read").expect("some");
659 let ceiling = Utc::now().timestamp() + MAX_LOCK_DURATION_SECS;
660 assert!(
663 read_back.blocked_until <= ceiling + 1 && read_back.blocked_until >= ceiling - 1,
664 "blocked_until = {}, expected near {}",
665 read_back.blocked_until,
666 ceiling
667 );
668 }
669
670 #[test]
671 fn lock_error_omitted_from_serialized_form_when_none() {
672 let tmp = TempDir::new().unwrap();
675 let store = LockStore::new(tmp.path().to_path_buf());
676 store
677 .write(&Lock {
678 blocked_until: Utc::now().timestamp() + 60,
679 error: None,
680 })
681 .expect("write");
682 let raw = fs::read_to_string(store.path()).unwrap();
683 assert!(!raw.contains("\"error\""), "unexpected error key: {raw}");
684 }
685
686 #[test]
696 fn atomic_write_json_rejects_path_without_parent() {
697 let err = atomic_write_json(
699 Path::new("/"),
700 &Lock {
701 blocked_until: 0,
702 error: None,
703 },
704 )
705 .unwrap_err();
706 match err {
707 CacheError::Io { cause, .. } => {
708 assert_eq!(cause.kind(), io::ErrorKind::InvalidInput);
709 }
710 other => panic!("expected Io(InvalidInput), got {other:?}"),
711 }
712 }
713
714 #[cfg(unix)]
717 #[test]
718 fn cache_read_surfaces_permission_denied() {
719 use std::os::unix::fs::PermissionsExt;
720 let tmp = TempDir::new().unwrap();
721 let path = tmp.path().join(USAGE_FILE);
722 fs::write(&path, "{}").unwrap();
723 fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();
724 let err = CacheStore::new(tmp.path().to_path_buf())
725 .read()
726 .unwrap_err();
727 assert!(matches!(err, CacheError::Io { .. }));
728 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
730 }
731
732 #[test]
735 fn cache_read_tolerates_entry_with_both_data_and_error() {
736 let tmp = TempDir::new().unwrap();
740 let path = tmp.path().join(USAGE_FILE);
741 fs::write(
742 &path,
743 r#"{
744 "schema_version": 1,
745 "cached_at": "2026-04-20T12:00:00Z",
746 "data": {
747 "five_hour": { "utilization": 0.0, "resets_at": null }
748 },
749 "error": { "code": "Timeout" }
750 }"#,
751 )
752 .unwrap();
753 let store = CacheStore::new(tmp.path().to_path_buf());
754 let entry = store.read().expect("read").expect("some");
755 assert!(entry.data.is_some());
756 assert!(entry.error.is_some());
757 }
758}