use crate::model::Snapshot;
use std::path::PathBuf;
use std::time::Duration;
pub const TTL: Duration = Duration::from_secs(60);
pub const FAIL_BACKOFF: Duration = Duration::from_secs(30);
pub struct Cache {
dir: PathBuf,
}
impl Cache {
pub fn new() -> Cache {
let dir = match std::env::var("XDG_CACHE_HOME") {
Ok(v) if !v.is_empty() => PathBuf::from(v).join("quotch"),
_ => std::env::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".cache")
.join("quotch"),
};
create_cache_dir(&dir);
Cache { dir }
}
fn path_for(&self, provider: &str, account: &str) -> PathBuf {
self.dir
.join(format!("{}.{}.json", sanitize(provider), sanitize(account)))
}
fn fail_path_for(&self, provider: &str, account: &str) -> PathBuf {
self.dir
.join(format!("{}.{}.fail", sanitize(provider), sanitize(account)))
}
pub fn load(&self, provider: &str, account: &str) -> Option<Snapshot> {
let bytes = std::fs::read(self.path_for(provider, account)).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn store(&self, snap: &Snapshot) {
let Ok(bytes) = serde_json::to_vec(snap) else {
return;
};
let path = self.path_for(&snap.provider, &snap.account);
let tmp = self.dir.join(format!(
"{}.{}.{}.json.tmp",
sanitize(&snap.provider),
sanitize(&snap.account),
std::process::id()
));
if write_private(&tmp, &bytes).is_err() {
return;
}
let _ = std::fs::rename(&tmp, &path);
}
pub fn mark_failure(&self, provider: &str, account: &str) {
let _ = std::fs::write(self.fail_path_for(provider, account), []);
}
pub fn clear_failure(&self, provider: &str, account: &str) {
let _ = std::fs::remove_file(self.fail_path_for(provider, account));
}
pub fn recent_failure(&self, provider: &str, account: &str) -> bool {
std::fs::metadata(self.fail_path_for(provider, account))
.and_then(|m| m.modified())
.is_ok_and(|modified| modified.elapsed().is_ok_and(|age| age < FAIL_BACKOFF))
}
}
impl Default for Cache {
fn default() -> Cache {
Cache::new()
}
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'-'
}
})
.collect()
}
#[cfg(unix)]
fn create_cache_dir(dir: &std::path::Path) {
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
let _ = std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir);
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
fn create_cache_dir(dir: &std::path::Path) {
let _ = std::fs::create_dir_all(dir);
}
#[cfg(unix)]
fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(bytes)
}
#[cfg(not(unix))]
fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
std::fs::write(path, bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Status, Unit, Window, WindowKind};
use chrono::Utc;
fn temp_cache(name: &str) -> Cache {
let dir =
std::env::temp_dir().join(format!("quotch-cache-test-{}-{}", name, std::process::id()));
let _ = std::fs::create_dir_all(&dir);
Cache { dir }
}
fn sample_snapshot() -> Snapshot {
Snapshot {
provider: "claude".to_string(),
account: "default".to_string(),
label: None,
plan: Some("pro".to_string()),
windows: vec![Window {
key: "5h".to_string(),
kind: WindowKind::Rolling,
unit: Unit::Percent,
used_pct: 42.0,
used: Some(42.0),
limit: Some(100.0),
unlimited: false,
resets_at: None,
}],
fetched_at: Utc::now(),
status: Status::Ok,
error: None,
raw: Some(serde_json::json!({"foo": "bar"})),
}
}
#[test]
fn store_then_load_roundtrips() {
let cache = temp_cache("roundtrip");
let snap = sample_snapshot();
cache.store(&snap);
let loaded = cache
.load(&snap.provider, &snap.account)
.expect("expected a cached snapshot");
assert_eq!(loaded.provider, snap.provider);
assert_eq!(loaded.account, snap.account);
assert_eq!(loaded.windows.len(), snap.windows.len());
assert_eq!(loaded.status, snap.status);
let _ = std::fs::remove_dir_all(&cache.dir);
}
#[test]
fn load_missing_entry_returns_none() {
let cache = temp_cache("missing");
assert!(cache.load("nope", "nobody").is_none());
let _ = std::fs::remove_dir_all(&cache.dir);
}
#[test]
fn load_corrupt_file_returns_none() {
let cache = temp_cache("corrupt");
let path = cache.path_for("claude", "default");
std::fs::write(&path, b"not valid json {{{").expect("write garbage");
assert!(cache.load("claude", "default").is_none());
let _ = std::fs::remove_dir_all(&cache.dir);
}
#[cfg(unix)]
#[test]
fn cache_file_and_dir_are_private() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("quotch-cache-perm-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
super::create_cache_dir(&dir);
let cache = Cache { dir: dir.clone() };
let snap = sample_snapshot();
cache.store(&snap);
let dir_mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700);
let file_mode = std::fs::metadata(cache.path_for(&snap.provider, &snap.account))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(file_mode, 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn failure_marker_roundtrips() {
let cache = temp_cache("failure-marker");
assert!(!cache.recent_failure("claude", "default"));
cache.mark_failure("claude", "default");
assert!(cache.recent_failure("claude", "default"));
cache.clear_failure("claude", "default");
assert!(!cache.recent_failure("claude", "default"));
let _ = std::fs::remove_dir_all(&cache.dir);
}
}