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"),
};
let _ = std::fs::create_dir_all(&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 = PathBuf::from(format!("{}.tmp", path.display()));
if std::fs::write(&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(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);
}
#[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);
}
}