use crate::paths::Paths;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Entry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub five_h: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub five_h_reset: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seven_d: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seven_d_reset: Option<i64>,
pub at: i64,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub on_credits: bool,
}
pub type Cache = BTreeMap<String, Entry>;
fn file(paths: &Paths) -> std::path::PathBuf {
file_for(paths, "claude-code")
}
fn file_for(paths: &Paths, tool: &str) -> std::path::PathBuf {
let name = match tool {
"claude-code" => "quota-cache.json".to_string(),
t => format!("{t}-quota-cache.json"),
};
paths.store_dir().join(name)
}
pub fn load(paths: &Paths) -> Cache {
load_at(paths, now_secs())
}
pub fn load_for(paths: &Paths, tool: &str) -> Cache {
load_file_at(&file_for(paths, tool), now_secs())
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn expire_windows(mut e: Entry, now: i64) -> Entry {
if e.five_h_reset.is_some_and(|r| now >= r) {
e.five_h = None;
e.five_h_reset = None;
}
if e.seven_d_reset.is_some_and(|r| now >= r) {
e.seven_d = None;
e.seven_d_reset = None;
}
e
}
fn load_at(paths: &Paths, now: i64) -> Cache {
load_file_at(&file(paths), now)
}
fn load_file_at(path: &std::path::Path, now: i64) -> Cache {
let mut c: Cache = std::fs::read(path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
for e in c.values_mut() {
*e = expire_windows(*e, now);
}
c.retain(|_, e| e.five_h.is_some() || e.seven_d.is_some());
c.retain(|_, e| !was_clamped(e));
c
}
fn was_clamped(e: &Entry) -> bool {
let at_ceiling = |v: Option<f64>| v.is_none_or(|p| p >= 100.0);
at_ceiling(e.five_h) && at_ceiling(e.seven_d) && (e.five_h.is_some() || e.seven_d.is_some())
}
pub fn update(paths: &Paths, fresh: &[(String, Entry)]) {
update_for(paths, "claude-code", fresh);
}
pub fn update_for(paths: &Paths, tool: &str, fresh: &[(String, Entry)]) {
if fresh.is_empty() {
return;
}
let path = file_for(paths, tool);
let mut c = load_file_at(&path, now_secs());
for (name, e) in fresh {
c.insert(name.clone(), *e);
}
if let Ok(bytes) = serde_json::to_vec_pretty(&c) {
let _ = std::fs::create_dir_all(paths.store_dir());
let _ = crate::atomic::write_secret(&path, &bytes);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(pct: f64, at: i64) -> Entry {
Entry {
five_h: Some(pct),
at,
..Default::default()
}
}
#[test]
fn each_tool_remembers_its_accounts_separately() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
update_for(&paths, "claude-code", &[("work".into(), entry(10.0, 1))]);
update_for(&paths, "codex", &[("work".into(), entry(90.0, 1))]);
assert_eq!(load_for(&paths, "claude-code")["work"].five_h, Some(10.0));
assert_eq!(load_for(&paths, "codex")["work"].five_h, Some(90.0));
assert!(root
.path()
.join(".local/share/swapdex/quota-cache.json")
.exists());
}
#[test]
fn readings_persist_and_merge_without_erasing_others() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
assert!(load(&paths).is_empty());
update(&paths, &[("a".into(), entry(10.0, 100))]);
update(&paths, &[("b".into(), entry(20.0, 200))]);
let c = load(&paths);
assert_eq!(c.len(), 2, "a later write keeps the earlier account");
assert_eq!(c["a"].five_h, Some(10.0));
update(&paths, &[("a".into(), entry(55.0, 300))]);
let c = load(&paths);
assert_eq!(c["a"].five_h, Some(55.0));
assert_eq!(c["b"].five_h, Some(20.0), "b is untouched");
}
#[test]
fn readings_pinned_at_the_clamp_are_not_remembered() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let full = Entry {
five_h: Some(100.0),
seven_d: Some(100.0),
at: 1,
..Default::default()
};
let real = Entry {
five_h: Some(4.0),
seven_d: Some(59.0),
at: 2,
..Default::default()
};
let one_full = Entry {
five_h: Some(100.0),
seven_d: Some(59.0),
at: 3,
..Default::default()
};
update(
&paths,
&[
("clamped".into(), full),
("measured".into(), real),
("half".into(), one_full),
],
);
let c = load(&paths);
assert!(!c.contains_key("clamped"), "the artefact is dropped");
assert_eq!(c["measured"].five_h, Some(4.0));
assert_eq!(c["half"].five_h, Some(100.0), "a real 100 survives");
}
#[test]
fn an_unreadable_cache_is_empty_not_an_error() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
std::fs::create_dir_all(paths.store_dir()).unwrap();
std::fs::write(super::file(&paths), b"{ not json").unwrap();
assert!(load(&paths).is_empty());
update(&paths, &[("a".into(), entry(1.0, 1))]);
assert_eq!(load(&paths).len(), 1);
}
}
#[cfg(test)]
mod expiry_tests {
use super::*;
#[test]
fn a_window_whose_reset_has_passed_is_dropped_not_shown() {
let e = Entry {
five_h: Some(100.0),
five_h_reset: Some(1_000),
seven_d: Some(41.0),
seven_d_reset: Some(9_000),
at: 900,
..Default::default()
};
let before = expire_windows(e, 999);
assert_eq!(before.five_h, Some(100.0), "inside the window it stands");
let after = expire_windows(e, 1_001);
assert_eq!(after.five_h, None, "past the reset it is gone");
assert_eq!(after.five_h_reset, None, "and so is the reset it described");
assert_eq!(
after.seven_d,
Some(41.0),
"the other window is untouched - they turn over separately"
);
}
#[test]
fn a_reading_with_no_reset_is_left_alone() {
let e = Entry {
five_h: Some(30.0),
five_h_reset: None,
seven_d: None,
seven_d_reset: None,
at: 900,
..Default::default()
};
assert_eq!(expire_windows(e, 9_999_999), e);
}
#[test]
fn an_entry_left_with_nothing_is_not_kept() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
update(
&paths,
&[(
"spent".to_string(),
Entry {
five_h: Some(80.0),
five_h_reset: Some(1_000),
at: 900,
..Default::default()
},
)],
);
assert!(
!load_at(&paths, 2_000).contains_key("spent"),
"nothing left to say about it"
);
assert!(
load_at(&paths, 999).contains_key("spent"),
"and it was there while the window ran"
);
}
}