use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use super::usage::{ExtraUsage, UsageApiResponse, UsageBucket};
pub const CACHE_SCHEMA_VERSION: u32 = 1;
const USAGE_FILE: &str = "usage.json";
const LOCK_FILE: &str = "usage.lock";
const LEGACY_LOCK_TTL_SECS: i64 = 30;
const MAX_LOCK_DURATION_SECS: i64 = 24 * 60 * 60;
#[derive(Debug)]
#[non_exhaustive]
pub enum CacheError {
Io { path: PathBuf, cause: io::Error },
Persist { path: PathBuf, cause: io::Error },
}
impl std::fmt::Display for CacheError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, cause } => {
write!(f, "cache I/O error on {}: {}", path.display(), cause.kind())
}
Self::Persist { path, cause } => write!(
f,
"atomic persist failed for {}: {}",
path.display(),
cause.kind()
),
}
}
}
impl std::error::Error for CacheError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { cause, .. } | Self::Persist { cause, .. } => Some(cause),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CachedUsage {
pub schema_version: u32,
pub cached_at: Timestamp,
#[serde(default)]
pub data: Option<CachedData>,
#[serde(default)]
pub error: Option<CachedError>,
}
impl CachedUsage {
#[must_use]
pub fn with_data(data: UsageApiResponse) -> Self {
Self {
schema_version: CACHE_SCHEMA_VERSION,
cached_at: Timestamp::now(),
data: Some(CachedData::from(data)),
error: None,
}
}
#[must_use]
pub fn with_error(code: &str) -> Self {
Self {
schema_version: CACHE_SCHEMA_VERSION,
cached_at: Timestamp::now(),
data: None,
error: Some(CachedError {
code: code.to_string(),
}),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[non_exhaustive]
pub struct CachedData {
#[serde(default)]
pub five_hour: Option<UsageBucket>,
#[serde(default)]
pub seven_day: Option<UsageBucket>,
#[serde(default)]
pub seven_day_opus: Option<UsageBucket>,
#[serde(default)]
pub seven_day_sonnet: Option<UsageBucket>,
#[serde(default)]
pub seven_day_oauth_apps: Option<UsageBucket>,
#[serde(default)]
pub extra_usage: Option<ExtraUsage>,
#[serde(default)]
pub unknown_buckets: HashMap<String, serde_json::Value>,
}
impl From<UsageApiResponse> for CachedData {
fn from(r: UsageApiResponse) -> Self {
Self {
five_hour: r.five_hour,
seven_day: r.seven_day,
seven_day_opus: r.seven_day_opus,
seven_day_sonnet: r.seven_day_sonnet,
seven_day_oauth_apps: r.seven_day_oauth_apps,
extra_usage: r.extra_usage,
unknown_buckets: r.unknown_buckets,
}
}
}
impl From<CachedData> for UsageApiResponse {
fn from(c: CachedData) -> Self {
UsageApiResponse {
five_hour: c.five_hour,
seven_day: c.seven_day,
seven_day_opus: c.seven_day_opus,
seven_day_sonnet: c.seven_day_sonnet,
seven_day_oauth_apps: c.seven_day_oauth_apps,
extra_usage: c.extra_usage,
unknown_buckets: c.unknown_buckets,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CachedError {
pub code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Lock {
pub blocked_until: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[must_use]
pub fn default_root() -> Option<PathBuf> {
use super::xdg::{resolve_subdir, XdgEnv, XdgScope};
resolve_subdir(&XdgEnv::from_process_env(), XdgScope::Cache, "")
}
pub struct CacheStore {
root: PathBuf,
}
impl CacheStore {
#[must_use]
pub fn new(root: PathBuf) -> Self {
Self { root }
}
#[must_use]
pub fn path(&self) -> PathBuf {
self.root.join(USAGE_FILE)
}
pub fn read(&self) -> Result<Option<CachedUsage>, CacheError> {
let path = self.path();
let bytes = match fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(cause) => return Err(CacheError::Io { path, cause }),
};
let Ok(text) = std::str::from_utf8(&bytes) else {
return Ok(None);
};
match serde_json::from_str::<CachedUsage>(text) {
Ok(entry)
if entry.schema_version == CACHE_SCHEMA_VERSION
&& entry.cached_at <= Timestamp::now() =>
{
Ok(Some(entry))
}
_ => Ok(None),
}
}
pub fn write(&self, entry: &CachedUsage) -> Result<(), CacheError> {
atomic_write_json(&self.path(), entry)
}
pub fn clear(&self) -> Result<(), CacheError> {
let path = self.path();
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(cause) => Err(CacheError::Io { path, cause }),
}
}
}
pub struct LockStore {
root: PathBuf,
}
impl LockStore {
#[must_use]
pub fn new(root: PathBuf) -> Self {
Self { root }
}
#[must_use]
pub fn path(&self) -> PathBuf {
self.root.join(LOCK_FILE)
}
pub fn read(&self) -> Result<Option<Lock>, CacheError> {
let path = self.path();
let bytes = match fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(cause) => return Err(CacheError::Io { path, cause }),
};
if let Ok(text) = std::str::from_utf8(&bytes) {
if let Ok(mut lock) = serde_json::from_str::<Lock>(text) {
cap_blocked_until(&mut lock.blocked_until);
return Ok(Some(lock));
}
}
let meta = fs::metadata(&path).map_err(|cause| CacheError::Io {
path: path.clone(),
cause,
})?;
let mtime = meta.modified().map_err(|cause| CacheError::Io {
path: path.clone(),
cause,
})?;
let mtime_unix: i64 = match mtime.duration_since(std::time::UNIX_EPOCH) {
Ok(d) => d.as_secs() as i64,
Err(_) => {
debug_assert!(false, "lock file mtime before UNIX_EPOCH");
0
}
};
let mut blocked_until = mtime_unix + LEGACY_LOCK_TTL_SECS;
cap_blocked_until(&mut blocked_until);
Ok(Some(Lock {
blocked_until,
error: None,
}))
}
pub fn write(&self, lock: &Lock) -> Result<(), CacheError> {
atomic_write_json(&self.path(), lock)
}
}
fn cap_blocked_until(blocked_until: &mut i64) {
let max = Timestamp::now().as_second() + MAX_LOCK_DURATION_SECS;
if *blocked_until > max {
*blocked_until = max;
}
}
pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), CacheError> {
let parent = path.parent().ok_or_else(|| CacheError::Io {
path: path.to_path_buf(),
cause: io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"),
})?;
fs::create_dir_all(parent).map_err(|cause| CacheError::Io {
path: parent.to_path_buf(),
cause,
})?;
let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|cause| CacheError::Io {
path: parent.to_path_buf(),
cause,
})?;
serde_json::to_writer_pretty(&tmp, value).map_err(|e| CacheError::Io {
path: path.to_path_buf(),
cause: io::Error::other(e),
})?;
tmp.as_file().sync_all().map_err(|cause| CacheError::Io {
path: path.to_path_buf(),
cause,
})?;
tmp.persist(path).map_err(|e| CacheError::Persist {
path: path.to_path_buf(),
cause: e.error,
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use jiff::SignedDuration;
use tempfile::TempDir;
fn sample_response() -> UsageApiResponse {
let json = r#"{
"five_hour": { "utilization": 22.0, "resets_at": "2026-04-19T05:00:00Z" },
"seven_day": { "utilization": 33.0, "resets_at": "2026-04-23T19:00:00Z" }
}"#;
serde_json::from_str(json).expect("parse")
}
#[test]
fn cache_round_trip_preserves_data_entry() {
let tmp = TempDir::new().unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
let entry = CachedUsage::with_data(sample_response());
store.write(&entry).expect("write");
let read_back = store.read().expect("read").expect("some");
assert_eq!(read_back, entry);
}
#[test]
fn cache_round_trip_preserves_error_entry() {
let tmp = TempDir::new().unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
let entry = CachedUsage::with_error("Timeout");
store.write(&entry).expect("write");
let read_back = store.read().expect("read").expect("some");
assert_eq!(read_back.error.unwrap().code, "Timeout");
assert!(read_back.data.is_none());
}
#[test]
fn cache_read_returns_none_when_missing() {
let tmp = TempDir::new().unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
assert!(store.read().expect("read").is_none());
}
#[test]
fn cache_clear_is_idempotent_on_missing_file() {
let tmp = TempDir::new().unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
store.clear().expect("clear on missing file is Ok");
store.clear().expect("clear after clear is Ok");
}
#[test]
fn cache_clear_removes_existing_file() {
let tmp = TempDir::new().unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
store
.write(&CachedUsage::with_data(sample_response()))
.expect("write");
assert!(store.read().expect("read").is_some(), "fixture wrote");
store.clear().expect("clear");
assert!(
store.read().expect("read").is_none(),
"clear must remove the file",
);
}
#[test]
fn cache_reads_rfc3339_z_suffix_serde_format() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(USAGE_FILE);
let payload = r#"{
"schema_version": 1,
"cached_at": "2026-04-19T12:00:00.000Z",
"data": {
"five_hour": { "utilization": 42.0, "resets_at": "2026-04-19T17:00:00.000Z" },
"seven_day": null,
"seven_day_opus": null,
"seven_day_sonnet": null,
"seven_day_oauth_apps": null,
"extra_usage": null,
"unknown_buckets": {}
},
"error": null
}"#;
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, payload).unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
let read_back = store.read().expect("read").expect("some");
assert_eq!(read_back.cached_at.to_string(), "2026-04-19T12:00:00Z");
let bucket = read_back.data.as_ref().unwrap().five_hour.as_ref().unwrap();
assert_eq!(bucket.utilization.value(), 42.0);
assert_eq!(
bucket.resets_at.unwrap().to_string(),
"2026-04-19T17:00:00Z",
);
}
#[test]
fn cache_read_returns_none_for_schema_mismatch() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(USAGE_FILE);
fs::create_dir_all(tmp.path()).unwrap();
fs::write(
&path,
r#"{ "schema_version": 9999, "cached_at": "2026-04-20T12:00:00Z", "data": null, "error": null }"#,
)
.unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
assert!(store.read().expect("read").is_none());
}
#[test]
fn cache_read_returns_none_for_clock_skew() {
let tmp = TempDir::new().unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
let mut entry = CachedUsage::with_data(sample_response());
entry.cached_at = Timestamp::now() + SignedDuration::from_mins(10);
store.write(&entry).expect("write");
assert!(store.read().expect("read").is_none());
}
#[test]
fn cache_read_returns_none_for_corrupt_json() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(USAGE_FILE), "{ not valid json ").unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
assert!(store.read().expect("read").is_none());
}
#[test]
fn cache_read_returns_none_for_zero_byte_file() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(USAGE_FILE), "").unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
assert!(store.read().expect("read").is_none());
}
#[test]
fn cache_read_returns_none_for_non_utf8_bytes() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(USAGE_FILE), [0xFF, 0xFE, 0xFD]).unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
assert!(store.read().expect("read").is_none());
}
#[test]
fn cache_write_creates_missing_parent_directory() {
let tmp = TempDir::new().unwrap();
let nested = tmp.path().join("nested").join("linesmith");
let store = CacheStore::new(nested.clone());
store
.write(&CachedUsage::with_data(sample_response()))
.expect("write");
assert!(nested.join(USAGE_FILE).exists());
}
#[test]
fn cache_round_trip_preserves_unknown_buckets() {
let tmp = TempDir::new().unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
let json = r#"{
"five_hour": { "utilization": 10.0, "resets_at": "2026-04-19T05:00:00Z" },
"quokka_experimental": { "utilization": 99.0, "resets_at": null }
}"#;
let response: UsageApiResponse = serde_json::from_str(json).unwrap();
store
.write(&CachedUsage::with_data(response))
.expect("write");
let read_back = store.read().expect("read").expect("some");
let data = read_back.data.unwrap();
assert!(data.unknown_buckets.contains_key("quokka_experimental"));
}
#[test]
fn concurrent_writes_produce_intact_file() {
use std::sync::Arc;
use std::thread;
let tmp = TempDir::new().unwrap();
let store = Arc::new(CacheStore::new(tmp.path().to_path_buf()));
let store_a = Arc::clone(&store);
let handle_a = thread::spawn(move || {
let mut succeeded = 0;
for _ in 0..10 {
if store_a.write(&CachedUsage::with_error("Timeout")).is_ok() {
succeeded += 1;
}
}
succeeded
});
let store_b = Arc::clone(&store);
let handle_b = thread::spawn(move || {
let mut succeeded = 0;
for _ in 0..10 {
if store_b
.write(&CachedUsage::with_data(sample_response()))
.is_ok()
{
succeeded += 1;
}
}
succeeded
});
let succeeded = handle_a.join().unwrap() + handle_b.join().unwrap();
#[cfg(unix)]
assert_eq!(succeeded, 20, "POSIX rename(2) should never fail");
#[cfg(not(unix))]
assert!(succeeded > 0, "at least one concurrent write must win");
let read_back = store.read().expect("read").expect("some");
assert_eq!(read_back.schema_version, CACHE_SCHEMA_VERSION);
assert!(read_back.data.is_some() ^ read_back.error.is_some());
}
#[test]
fn lock_round_trip() {
let tmp = TempDir::new().unwrap();
let store = LockStore::new(tmp.path().to_path_buf());
let now = Timestamp::now().as_second();
let lock = Lock {
blocked_until: now + 60,
error: Some("rate-limited".into()),
};
store.write(&lock).expect("write");
let read_back = store.read().expect("read").expect("some");
assert_eq!(read_back, lock);
}
#[test]
fn lock_read_returns_none_when_missing() {
let tmp = TempDir::new().unwrap();
let store = LockStore::new(tmp.path().to_path_buf());
assert!(store.read().expect("read").is_none());
}
#[test]
fn lock_read_non_utf8_routes_through_legacy_fallback() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(LOCK_FILE);
fs::write(&path, [0xFF, 0xFE, 0x00, 0xFD]).unwrap();
let mtime = fs::metadata(&path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let store = LockStore::new(tmp.path().to_path_buf());
let lock = store.read().expect("read").expect("some");
assert_eq!(lock.blocked_until, mtime + LEGACY_LOCK_TTL_SECS);
assert!(lock.error.is_none());
}
#[test]
fn lock_read_legacy_non_json_uses_mtime_plus_30s() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(LOCK_FILE);
fs::write(&path, "# legacy lock from older linesmith").unwrap();
let mtime = fs::metadata(&path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let store = LockStore::new(tmp.path().to_path_buf());
let lock = store.read().expect("read").expect("some");
assert_eq!(lock.blocked_until, mtime + LEGACY_LOCK_TTL_SECS);
assert!(lock.error.is_none());
}
#[test]
fn lock_read_caps_pathological_blocked_until() {
let tmp = TempDir::new().unwrap();
let store = LockStore::new(tmp.path().to_path_buf());
let malicious = Lock {
blocked_until: i64::MAX,
error: None,
};
store.write(&malicious).expect("write");
let read_back = store.read().expect("read").expect("some");
let ceiling = Timestamp::now().as_second() + MAX_LOCK_DURATION_SECS;
assert!(
read_back.blocked_until <= ceiling + 1 && read_back.blocked_until >= ceiling - 1,
"blocked_until = {}, expected near {}",
read_back.blocked_until,
ceiling
);
}
#[test]
fn lock_error_omitted_from_serialized_form_when_none() {
let tmp = TempDir::new().unwrap();
let store = LockStore::new(tmp.path().to_path_buf());
store
.write(&Lock {
blocked_until: Timestamp::now().as_second() + 60,
error: None,
})
.expect("write");
let raw = fs::read_to_string(store.path()).unwrap();
assert!(!raw.contains("\"error\""), "unexpected error key: {raw}");
}
#[test]
fn atomic_write_json_rejects_path_without_parent() {
let err = atomic_write_json(
Path::new("/"),
&Lock {
blocked_until: 0,
error: None,
},
)
.unwrap_err();
match err {
CacheError::Io { cause, .. } => {
assert_eq!(cause.kind(), io::ErrorKind::InvalidInput);
}
other => panic!("expected Io(InvalidInput), got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn cache_read_surfaces_permission_denied() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(USAGE_FILE);
fs::write(&path, "{}").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();
let err = CacheStore::new(tmp.path().to_path_buf())
.read()
.unwrap_err();
assert!(matches!(err, CacheError::Io { .. }));
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
}
#[test]
fn cache_read_tolerates_entry_with_both_data_and_error() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(USAGE_FILE);
fs::write(
&path,
r#"{
"schema_version": 1,
"cached_at": "2026-04-20T12:00:00Z",
"data": {
"five_hour": { "utilization": 0.0, "resets_at": null }
},
"error": { "code": "Timeout" }
}"#,
)
.unwrap();
let store = CacheStore::new(tmp.path().to_path_buf());
let entry = store.read().expect("read").expect("some");
assert!(entry.data.is_some());
assert!(entry.error.is_some());
}
}