1use std::collections::HashMap;
22use std::fs;
23use std::io;
24use std::path::{Path, PathBuf};
25
26use jiff::Timestamp;
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: Timestamp,
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: Timestamp::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: Timestamp::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 <= Timestamp::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 pub fn clear(&self) -> Result<(), CacheError> {
279 let path = self.path();
280 match fs::remove_file(&path) {
281 Ok(()) => Ok(()),
282 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
283 Err(cause) => Err(CacheError::Io { path, cause }),
284 }
285 }
286}
287
288pub struct LockStore {
292 root: PathBuf,
293}
294
295impl LockStore {
296 #[must_use]
297 pub fn new(root: PathBuf) -> Self {
298 Self { root }
299 }
300
301 #[must_use]
302 pub fn path(&self) -> PathBuf {
303 self.root.join(LOCK_FILE)
304 }
305
306 pub fn read(&self) -> Result<Option<Lock>, CacheError> {
314 let path = self.path();
315 let bytes = match fs::read(&path) {
316 Ok(bytes) => bytes,
317 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
318 Err(cause) => return Err(CacheError::Io { path, cause }),
319 };
320 if let Ok(text) = std::str::from_utf8(&bytes) {
323 if let Ok(mut lock) = serde_json::from_str::<Lock>(text) {
324 cap_blocked_until(&mut lock.blocked_until);
325 return Ok(Some(lock));
326 }
327 }
328 let meta = fs::metadata(&path).map_err(|cause| CacheError::Io {
332 path: path.clone(),
333 cause,
334 })?;
335 let mtime = meta.modified().map_err(|cause| CacheError::Io {
336 path: path.clone(),
337 cause,
338 })?;
339 let mtime_unix: i64 = match mtime.duration_since(std::time::UNIX_EPOCH) {
340 Ok(d) => d.as_secs() as i64,
341 Err(_) => {
342 debug_assert!(false, "lock file mtime before UNIX_EPOCH");
347 0
348 }
349 };
350 let mut blocked_until = mtime_unix + LEGACY_LOCK_TTL_SECS;
351 cap_blocked_until(&mut blocked_until);
352 Ok(Some(Lock {
353 blocked_until,
354 error: None,
355 }))
356 }
357
358 pub fn write(&self, lock: &Lock) -> Result<(), CacheError> {
359 atomic_write_json(&self.path(), lock)
360 }
361}
362
363fn cap_blocked_until(blocked_until: &mut i64) {
364 let max = Timestamp::now().as_second() + MAX_LOCK_DURATION_SECS;
365 if *blocked_until > max {
366 *blocked_until = max;
367 }
368}
369
370pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), CacheError> {
378 let parent = path.parent().ok_or_else(|| CacheError::Io {
379 path: path.to_path_buf(),
380 cause: io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"),
381 })?;
382 fs::create_dir_all(parent).map_err(|cause| CacheError::Io {
383 path: parent.to_path_buf(),
384 cause,
385 })?;
386 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|cause| CacheError::Io {
387 path: parent.to_path_buf(),
388 cause,
389 })?;
390 serde_json::to_writer_pretty(&tmp, value).map_err(|e| CacheError::Io {
391 path: path.to_path_buf(),
392 cause: io::Error::other(e),
393 })?;
394 tmp.as_file().sync_all().map_err(|cause| CacheError::Io {
395 path: path.to_path_buf(),
396 cause,
397 })?;
398 tmp.persist(path).map_err(|e| CacheError::Persist {
399 path: path.to_path_buf(),
400 cause: e.error,
401 })?;
402 Ok(())
403}
404
405#[cfg(test)]
408mod tests {
409 use super::*;
410 use jiff::SignedDuration;
411 use tempfile::TempDir;
412
413 fn sample_response() -> UsageApiResponse {
414 let json = r#"{
415 "five_hour": { "utilization": 22.0, "resets_at": "2026-04-19T05:00:00Z" },
416 "seven_day": { "utilization": 33.0, "resets_at": "2026-04-23T19:00:00Z" }
417 }"#;
418 serde_json::from_str(json).expect("parse")
419 }
420
421 #[test]
428 fn cache_round_trip_preserves_data_entry() {
429 let tmp = TempDir::new().unwrap();
430 let store = CacheStore::new(tmp.path().to_path_buf());
431 let entry = CachedUsage::with_data(sample_response());
432 store.write(&entry).expect("write");
433 let read_back = store.read().expect("read").expect("some");
434 assert_eq!(read_back, entry);
435 }
436
437 #[test]
438 fn cache_round_trip_preserves_error_entry() {
439 let tmp = TempDir::new().unwrap();
440 let store = CacheStore::new(tmp.path().to_path_buf());
441 let entry = CachedUsage::with_error("Timeout");
442 store.write(&entry).expect("write");
443 let read_back = store.read().expect("read").expect("some");
444 assert_eq!(read_back.error.unwrap().code, "Timeout");
445 assert!(read_back.data.is_none());
446 }
447
448 #[test]
449 fn cache_read_returns_none_when_missing() {
450 let tmp = TempDir::new().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_clear_is_idempotent_on_missing_file() {
457 let tmp = TempDir::new().unwrap();
462 let store = CacheStore::new(tmp.path().to_path_buf());
463 store.clear().expect("clear on missing file is Ok");
464 store.clear().expect("clear after clear is Ok");
466 }
467
468 #[test]
469 fn cache_clear_removes_existing_file() {
470 let tmp = TempDir::new().unwrap();
471 let store = CacheStore::new(tmp.path().to_path_buf());
472 store
473 .write(&CachedUsage::with_data(sample_response()))
474 .expect("write");
475 assert!(store.read().expect("read").is_some(), "fixture wrote");
476 store.clear().expect("clear");
477 assert!(
478 store.read().expect("read").is_none(),
479 "clear must remove the file",
480 );
481 }
482
483 #[test]
484 fn cache_reads_rfc3339_z_suffix_serde_format() {
485 let tmp = TempDir::new().unwrap();
494 let path = tmp.path().join(USAGE_FILE);
495 let payload = r#"{
496 "schema_version": 1,
497 "cached_at": "2026-04-19T12:00:00.000Z",
498 "data": {
499 "five_hour": { "utilization": 42.0, "resets_at": "2026-04-19T17:00:00.000Z" },
500 "seven_day": null,
501 "seven_day_opus": null,
502 "seven_day_sonnet": null,
503 "seven_day_oauth_apps": null,
504 "extra_usage": null,
505 "unknown_buckets": {}
506 },
507 "error": null
508 }"#;
509 fs::create_dir_all(path.parent().unwrap()).unwrap();
510 fs::write(&path, payload).unwrap();
511 let store = CacheStore::new(tmp.path().to_path_buf());
512 let read_back = store.read().expect("read").expect("some");
513 assert_eq!(read_back.cached_at.to_string(), "2026-04-19T12:00:00Z");
514 let bucket = read_back.data.as_ref().unwrap().five_hour.as_ref().unwrap();
515 assert_eq!(bucket.utilization.value(), 42.0);
516 assert_eq!(
517 bucket.resets_at.unwrap().to_string(),
518 "2026-04-19T17:00:00Z",
519 );
520 }
521
522 #[test]
523 fn cache_read_returns_none_for_schema_mismatch() {
524 let tmp = TempDir::new().unwrap();
525 let path = tmp.path().join(USAGE_FILE);
526 fs::create_dir_all(tmp.path()).unwrap();
527 fs::write(
528 &path,
529 r#"{ "schema_version": 9999, "cached_at": "2026-04-20T12:00:00Z", "data": null, "error": null }"#,
530 )
531 .unwrap();
532 let store = CacheStore::new(tmp.path().to_path_buf());
533 assert!(store.read().expect("read").is_none());
534 }
535
536 #[test]
537 fn cache_read_returns_none_for_clock_skew() {
538 let tmp = TempDir::new().unwrap();
540 let store = CacheStore::new(tmp.path().to_path_buf());
541 let mut entry = CachedUsage::with_data(sample_response());
542 entry.cached_at = Timestamp::now() + SignedDuration::from_mins(10);
543 store.write(&entry).expect("write");
544 assert!(store.read().expect("read").is_none());
545 }
546
547 #[test]
548 fn cache_read_returns_none_for_corrupt_json() {
549 let tmp = TempDir::new().unwrap();
550 fs::write(tmp.path().join(USAGE_FILE), "{ not valid json ").unwrap();
551 let store = CacheStore::new(tmp.path().to_path_buf());
552 assert!(store.read().expect("read").is_none());
553 }
554
555 #[test]
556 fn cache_read_returns_none_for_zero_byte_file() {
557 let tmp = TempDir::new().unwrap();
558 fs::write(tmp.path().join(USAGE_FILE), "").unwrap();
559 let store = CacheStore::new(tmp.path().to_path_buf());
560 assert!(store.read().expect("read").is_none());
561 }
562
563 #[test]
564 fn cache_read_returns_none_for_non_utf8_bytes() {
565 let tmp = TempDir::new().unwrap();
570 fs::write(tmp.path().join(USAGE_FILE), [0xFF, 0xFE, 0xFD]).unwrap();
571 let store = CacheStore::new(tmp.path().to_path_buf());
572 assert!(store.read().expect("read").is_none());
573 }
574
575 #[test]
576 fn cache_write_creates_missing_parent_directory() {
577 let tmp = TempDir::new().unwrap();
580 let nested = tmp.path().join("nested").join("linesmith");
581 let store = CacheStore::new(nested.clone());
582 store
583 .write(&CachedUsage::with_data(sample_response()))
584 .expect("write");
585 assert!(nested.join(USAGE_FILE).exists());
586 }
587
588 #[test]
589 fn cache_round_trip_preserves_unknown_buckets() {
590 let tmp = TempDir::new().unwrap();
594 let store = CacheStore::new(tmp.path().to_path_buf());
595 let json = r#"{
596 "five_hour": { "utilization": 10.0, "resets_at": "2026-04-19T05:00:00Z" },
597 "quokka_experimental": { "utilization": 99.0, "resets_at": null }
598 }"#;
599 let response: UsageApiResponse = serde_json::from_str(json).unwrap();
600 store
601 .write(&CachedUsage::with_data(response))
602 .expect("write");
603 let read_back = store.read().expect("read").expect("some");
604 let data = read_back.data.unwrap();
605 assert!(data.unknown_buckets.contains_key("quokka_experimental"));
606 }
607
608 #[test]
611 fn concurrent_writes_produce_intact_file() {
612 use std::sync::Arc;
613 use std::thread;
614
615 let tmp = TempDir::new().unwrap();
616 let store = Arc::new(CacheStore::new(tmp.path().to_path_buf()));
617
618 let store_a = Arc::clone(&store);
619 let handle_a = thread::spawn(move || {
620 let mut succeeded = 0;
621 for _ in 0..10 {
622 if store_a.write(&CachedUsage::with_error("Timeout")).is_ok() {
623 succeeded += 1;
624 }
625 }
626 succeeded
627 });
628 let store_b = Arc::clone(&store);
629 let handle_b = thread::spawn(move || {
630 let mut succeeded = 0;
631 for _ in 0..10 {
632 if store_b
633 .write(&CachedUsage::with_data(sample_response()))
634 .is_ok()
635 {
636 succeeded += 1;
637 }
638 }
639 succeeded
640 });
641 let succeeded = handle_a.join().unwrap() + handle_b.join().unwrap();
642
643 #[cfg(unix)]
652 assert_eq!(succeeded, 20, "POSIX rename(2) should never fail");
653 #[cfg(not(unix))]
654 assert!(succeeded > 0, "at least one concurrent write must win");
655
656 let read_back = store.read().expect("read").expect("some");
659 assert_eq!(read_back.schema_version, CACHE_SCHEMA_VERSION);
660 assert!(read_back.data.is_some() ^ read_back.error.is_some());
661 }
662
663 #[test]
666 fn lock_round_trip() {
667 let tmp = TempDir::new().unwrap();
668 let store = LockStore::new(tmp.path().to_path_buf());
669 let now = Timestamp::now().as_second();
671 let lock = Lock {
672 blocked_until: now + 60,
673 error: Some("rate-limited".into()),
674 };
675 store.write(&lock).expect("write");
676 let read_back = store.read().expect("read").expect("some");
677 assert_eq!(read_back, lock);
678 }
679
680 #[test]
681 fn lock_read_returns_none_when_missing() {
682 let tmp = TempDir::new().unwrap();
683 let store = LockStore::new(tmp.path().to_path_buf());
684 assert!(store.read().expect("read").is_none());
685 }
686
687 #[test]
688 fn lock_read_non_utf8_routes_through_legacy_fallback() {
689 let tmp = TempDir::new().unwrap();
694 let path = tmp.path().join(LOCK_FILE);
695 fs::write(&path, [0xFF, 0xFE, 0x00, 0xFD]).unwrap();
696 let mtime = fs::metadata(&path)
697 .unwrap()
698 .modified()
699 .unwrap()
700 .duration_since(std::time::UNIX_EPOCH)
701 .unwrap()
702 .as_secs() as i64;
703 let store = LockStore::new(tmp.path().to_path_buf());
704 let lock = store.read().expect("read").expect("some");
705 assert_eq!(lock.blocked_until, mtime + LEGACY_LOCK_TTL_SECS);
706 assert!(lock.error.is_none());
707 }
708
709 #[test]
710 fn lock_read_legacy_non_json_uses_mtime_plus_30s() {
711 let tmp = TempDir::new().unwrap();
712 let path = tmp.path().join(LOCK_FILE);
713 fs::write(&path, "# legacy lock from older linesmith").unwrap();
714 let mtime = fs::metadata(&path)
715 .unwrap()
716 .modified()
717 .unwrap()
718 .duration_since(std::time::UNIX_EPOCH)
719 .unwrap()
720 .as_secs() as i64;
721 let store = LockStore::new(tmp.path().to_path_buf());
722 let lock = store.read().expect("read").expect("some");
723 assert_eq!(lock.blocked_until, mtime + LEGACY_LOCK_TTL_SECS);
724 assert!(lock.error.is_none());
725 }
726
727 #[test]
728 fn lock_read_caps_pathological_blocked_until() {
729 let tmp = TempDir::new().unwrap();
733 let store = LockStore::new(tmp.path().to_path_buf());
734 let malicious = Lock {
735 blocked_until: i64::MAX,
736 error: None,
737 };
738 store.write(&malicious).expect("write");
739 let read_back = store.read().expect("read").expect("some");
740 let ceiling = Timestamp::now().as_second() + MAX_LOCK_DURATION_SECS;
741 assert!(
744 read_back.blocked_until <= ceiling + 1 && read_back.blocked_until >= ceiling - 1,
745 "blocked_until = {}, expected near {}",
746 read_back.blocked_until,
747 ceiling
748 );
749 }
750
751 #[test]
752 fn lock_error_omitted_from_serialized_form_when_none() {
753 let tmp = TempDir::new().unwrap();
756 let store = LockStore::new(tmp.path().to_path_buf());
757 store
758 .write(&Lock {
759 blocked_until: Timestamp::now().as_second() + 60,
760 error: None,
761 })
762 .expect("write");
763 let raw = fs::read_to_string(store.path()).unwrap();
764 assert!(!raw.contains("\"error\""), "unexpected error key: {raw}");
765 }
766
767 #[test]
777 fn atomic_write_json_rejects_path_without_parent() {
778 let err = atomic_write_json(
780 Path::new("/"),
781 &Lock {
782 blocked_until: 0,
783 error: None,
784 },
785 )
786 .unwrap_err();
787 match err {
788 CacheError::Io { cause, .. } => {
789 assert_eq!(cause.kind(), io::ErrorKind::InvalidInput);
790 }
791 other => panic!("expected Io(InvalidInput), got {other:?}"),
792 }
793 }
794
795 #[cfg(unix)]
798 #[test]
799 fn cache_read_surfaces_permission_denied() {
800 use std::os::unix::fs::PermissionsExt;
801 let tmp = TempDir::new().unwrap();
802 let path = tmp.path().join(USAGE_FILE);
803 fs::write(&path, "{}").unwrap();
804 fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();
805 let err = CacheStore::new(tmp.path().to_path_buf())
806 .read()
807 .unwrap_err();
808 assert!(matches!(err, CacheError::Io { .. }));
809 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
811 }
812
813 #[test]
816 fn cache_read_tolerates_entry_with_both_data_and_error() {
817 let tmp = TempDir::new().unwrap();
821 let path = tmp.path().join(USAGE_FILE);
822 fs::write(
823 &path,
824 r#"{
825 "schema_version": 1,
826 "cached_at": "2026-04-20T12:00:00Z",
827 "data": {
828 "five_hour": { "utilization": 0.0, "resets_at": null }
829 },
830 "error": { "code": "Timeout" }
831 }"#,
832 )
833 .unwrap();
834 let store = CacheStore::new(tmp.path().to_path_buf());
835 let entry = store.read().expect("read").expect("some");
836 assert!(entry.data.is_some());
837 assert!(entry.error.is_some());
838 }
839}