use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize};
use crate::diag;
const CACHE_VERSION: u32 = 1;
const MAX_AGE_DAYS: i64 = 7;
const MAX_ENTRIES: usize = 500;
#[derive(Debug, Clone)]
pub struct CacheEntry<T> {
pub value: T,
pub fetched_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CachePut {
Stored,
Stale,
Disabled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredEntry {
fetched_at: DateTime<Utc>,
value: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize)]
struct CacheFile {
#[serde(default)]
version: u32,
#[serde(default)]
entries: HashMap<String, StoredEntry>,
}
fn default_cache_path() -> PathBuf {
let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
base.join("forgetop").join("cache.json")
}
#[cfg(unix)]
async fn restrict_to_owner(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await
}
#[cfg(not(unix))]
async fn restrict_to_owner(path: &Path) -> std::io::Result<()> {
tokio::fs::metadata(path).await.map(|_| ())
}
fn with_extension(path: &Path, ext: &str) -> PathBuf {
let mut p = path.to_path_buf();
p.set_extension(ext);
p
}
pub struct CacheStore {
path: Option<PathBuf>,
entries: RwLock<HashMap<String, StoredEntry>>,
dirty: AtomicBool,
}
impl CacheStore {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: Some(path.into()), entries: RwLock::new(HashMap::new()), dirty: AtomicBool::new(false) }
}
pub fn at_default_path() -> Self {
Self::new(default_cache_path())
}
pub fn disabled() -> Self {
Self { path: None, entries: RwLock::new(HashMap::new()), dirty: AtomicBool::new(false) }
}
fn read_entries(&self) -> RwLockReadGuard<'_, HashMap<String, StoredEntry>> {
self.entries.read().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn write_entries(&self) -> RwLockWriteGuard<'_, HashMap<String, StoredEntry>> {
self.entries.write().unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub async fn load(&self) {
let Some(path) = self.path.as_ref() else { return };
let bytes = match tokio::fs::read(path).await {
Ok(bytes) => bytes,
Err(err) => {
diag::log("cache", &format!("no cache to load from {}: {err}", path.display()));
return;
}
};
let file: CacheFile = match serde_json::from_slice(&bytes) {
Ok(file) => file,
Err(err) => {
diag::log("cache", &format!("discarding unreadable cache {}: {err}", path.display()));
return;
}
};
if file.version != CACHE_VERSION {
diag::log(
"cache",
&format!("discarding cache at version {} (want {CACHE_VERSION})", file.version),
);
return;
}
let entries = prune(file.entries, Utc::now());
*self.write_entries() = entries;
}
pub fn get<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<CacheEntry<T>> {
let stored = self.read_entries().get(key).cloned()?;
match serde_json::from_value(stored.value) {
Ok(value) => Some(CacheEntry { value, fetched_at: stored.fetched_at }),
Err(err) => {
diag::log("cache", &format!("dropping unusable entry {key}: {err}"));
None
}
}
}
pub fn put<T: Serialize>(&self, key: &str, value: &T, fetched_at: DateTime<Utc>) -> CachePut {
if self.path.is_none() {
return CachePut::Disabled;
}
let value = match serde_json::to_value(value) {
Ok(value) => value,
Err(err) => {
diag::log("cache", &format!("not caching {key}: {err}"));
return CachePut::Disabled;
}
};
{
let mut entries = self.write_entries();
if entries.get(key).is_some_and(|existing| existing.fetched_at >= fetched_at) {
return CachePut::Stale;
}
entries.insert(key.to_string(), StoredEntry { fetched_at, value });
}
self.dirty.store(true, Ordering::Relaxed);
CachePut::Stored
}
pub fn rewrite<T: Serialize + serde::de::DeserializeOwned>(&self, key: &str, edit: impl FnOnce(T) -> T) -> bool {
if self.path.is_none() {
return false;
}
let Some(entry) = self.get::<T>(key) else { return false };
let value = match serde_json::to_value(edit(entry.value)) {
Ok(value) => value,
Err(err) => {
diag::log("cache", &format!("not rewriting {key}: {err}"));
return false;
}
};
{
let mut entries = self.write_entries();
match entries.get_mut(key) {
Some(stored) if stored.fetched_at == entry.fetched_at => stored.value = value,
_ => return false,
}
}
self.dirty.store(true, Ordering::Relaxed);
true
}
pub async fn flush(&self) {
let Some(path) = self.path.as_ref() else { return };
if !self.dirty.load(Ordering::Relaxed) {
return;
}
let json = {
let entries = self.read_entries().clone();
match serde_json::to_vec(&CacheFile { version: CACHE_VERSION, entries }) {
Ok(json) => json,
Err(err) => {
diag::log("cache", &format!("cannot serialize cache: {err}"));
return;
}
}
};
if let Some(dir) = path.parent() {
if let Err(err) = tokio::fs::create_dir_all(dir).await {
diag::log("cache", &format!("cannot create {}: {err}", dir.display()));
return;
}
}
let tmp = with_extension(path, "tmp");
if let Err(err) = tokio::fs::write(&tmp, &json).await {
diag::log("cache", &format!("cannot write {}: {err}", tmp.display()));
return;
}
if let Err(err) = restrict_to_owner(&tmp).await {
diag::log("cache", &format!("cannot secure {}: {err}", tmp.display()));
let _ = tokio::fs::remove_file(&tmp).await;
return;
}
if let Err(err) = tokio::fs::rename(&tmp, path).await {
diag::log("cache", &format!("cannot replace {}: {err}", path.display()));
return;
}
self.dirty.store(false, Ordering::Relaxed);
}
}
fn prune(entries: HashMap<String, StoredEntry>, now: DateTime<Utc>) -> HashMap<String, StoredEntry> {
let cutoff = now - TimeDelta::days(MAX_AGE_DAYS);
let mut kept: Vec<(String, StoredEntry)> = entries
.into_iter()
.filter(|(_, entry)| entry.fetched_at > cutoff && entry.fetched_at <= now)
.collect();
if kept.len() > MAX_ENTRIES {
kept.sort_by_key(|(_, entry)| std::cmp::Reverse(entry.fetched_at));
kept.truncate(MAX_ENTRIES);
}
kept.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Item {
id: u32,
title: String,
}
fn item(id: u32) -> Item {
Item { id, title: format!("item {id}") }
}
#[test]
fn a_put_value_comes_back_with_its_timestamp() {
let dir = tempfile::tempdir().expect("tempdir");
let store = CacheStore::new(dir.path().join("cache.json"));
let at = Utc::now();
assert_eq!(store.put("prs", &item(1), at), CachePut::Stored);
let entry = store.get::<Item>("prs").expect("hit");
assert_eq!(entry.value, item(1));
assert_eq!(entry.fetched_at, at);
assert!(store.get::<Item>("absent").is_none());
}
#[test]
fn an_older_put_cannot_overwrite_a_newer_entry() {
let dir = tempfile::tempdir().expect("tempdir");
let store = CacheStore::new(dir.path().join("cache.json"));
let newer = Utc::now();
let older = newer - TimeDelta::seconds(30);
assert_eq!(store.put("prs", &item(2), newer), CachePut::Stored);
assert_eq!(store.put("prs", &item(1), older), CachePut::Stale, "stale write must be refused");
let entry = store.get::<Item>("prs").expect("hit");
assert_eq!(entry.value, item(2), "the newer value must survive");
assert_eq!(entry.fetched_at, newer);
}
#[test]
fn rewrite_edits_an_entry_in_place_without_redating_it() {
let dir = tempfile::tempdir().expect("tempdir");
let store = CacheStore::new(dir.path().join("cache.json"));
let at = Utc::now() - TimeDelta::hours(3);
store.put("prs", &vec![item(1), item(2)], at);
let wrote = store.rewrite::<Vec<Item>>("prs", |rows| rows.into_iter().filter(|i| i.id != 1).collect());
assert!(wrote);
let entry = store.get::<Vec<Item>>("prs").expect("hit");
assert_eq!(entry.value, vec![item(2)]);
assert_eq!(entry.fetched_at, at, "a three-hour-old list must not read as current");
}
#[test]
fn rewrite_is_a_no_op_on_a_key_that_is_not_there() {
let dir = tempfile::tempdir().expect("tempdir");
let store = CacheStore::new(dir.path().join("cache.json"));
assert!(!store.rewrite::<Vec<Item>>("absent", |rows| rows));
assert!(store.get::<Vec<Item>>("absent").is_none());
}
#[test]
fn rewrite_yields_to_a_fetch_that_landed_underneath_it() {
let dir = tempfile::tempdir().expect("tempdir");
let store = CacheStore::new(dir.path().join("cache.json"));
let at = Utc::now() - TimeDelta::hours(3);
store.put("prs", &vec![item(1)], at);
let wrote = store.rewrite::<Vec<Item>>("prs", |rows| {
store.put("prs", &vec![item(7)], Utc::now());
rows.into_iter().filter(|i| i.id != 1).collect()
});
assert!(!wrote, "the edit must not overwrite the fresher entry");
assert_eq!(store.get::<Vec<Item>>("prs").expect("hit").value, vec![item(7)]);
}
#[test]
fn an_equal_timestamp_put_is_refused_too() {
let dir = tempfile::tempdir().expect("tempdir");
let store = CacheStore::new(dir.path().join("cache.json"));
let at = Utc::now();
assert_eq!(store.put("prs", &item(2), at), CachePut::Stored);
assert_eq!(store.put("prs", &item(9), at), CachePut::Stale);
assert_eq!(store.get::<Item>("prs").expect("hit").value, item(2));
}
#[test]
fn a_shape_change_reads_as_a_miss_rather_than_a_panic() {
let dir = tempfile::tempdir().expect("tempdir");
let store = CacheStore::new(dir.path().join("cache.json"));
store.put("prs", &item(1), Utc::now());
assert!(store.get::<Vec<String>>("prs").is_none());
assert!(store.get::<Item>("prs").is_some());
}
#[test]
fn a_value_that_cannot_be_serialized_is_not_reported_as_stale() {
let dir = tempfile::tempdir().expect("tempdir");
let store = CacheStore::new(dir.path().join("cache.json"));
let unserializable: HashMap<(u8, u8), u8> = HashMap::from([((1, 2), 3)]);
assert_eq!(store.put("prs", &unserializable, Utc::now()), CachePut::Disabled);
assert!(store.get::<Item>("prs").is_none(), "nothing was stored");
}
#[tokio::test]
async fn a_corrupt_file_loads_as_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cache.json");
std::fs::write(&path, b"{not json").expect("seed");
let store = CacheStore::new(&path);
store.load().await;
assert!(store.get::<Item>("prs").is_none());
}
#[tokio::test]
async fn a_file_from_another_version_is_discarded_wholesale() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cache.json");
let seeded = format!(
r#"{{"version":{},"entries":{{"prs":{{"fetched_at":"{}","value":{{"id":1,"title":"item 1"}}}}}}}}"#,
CACHE_VERSION + 1,
Utc::now().to_rfc3339()
);
std::fs::write(&path, seeded).expect("seed");
let store = CacheStore::new(&path);
store.load().await;
assert!(store.get::<Item>("prs").is_none());
}
#[tokio::test]
async fn a_missing_file_loads_as_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let store = CacheStore::new(dir.path().join("cache.json"));
store.load().await;
assert!(store.get::<Item>("prs").is_none());
}
#[tokio::test]
async fn entries_survive_a_flush_into_a_fresh_store() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cache.json");
let at = Utc::now();
let store = CacheStore::new(&path);
store.put("prs", &item(7), at);
store.flush().await;
let reopened = CacheStore::new(&path);
reopened.load().await;
let entry = reopened.get::<Item>("prs").expect("hit");
assert_eq!(entry.value, item(7));
assert_eq!(entry.fetched_at, at);
assert!(!with_extension(&path, "tmp").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn a_flushed_cache_is_readable_only_by_its_owner() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cache.json");
let store = CacheStore::new(&path);
store.put("prs", &item(7), Utc::now());
store.flush().await;
let mode = std::fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "cache.json had mode {mode:o}");
}
#[tokio::test]
async fn load_drops_entries_past_the_age_bound() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cache.json");
let now = Utc::now();
let store = CacheStore::new(&path);
store.put("fresh", &item(1), now - TimeDelta::days(MAX_AGE_DAYS - 1));
store.put("stale", &item(2), now - TimeDelta::days(MAX_AGE_DAYS + 1));
store.flush().await;
let reopened = CacheStore::new(&path);
reopened.load().await;
assert!(reopened.get::<Item>("fresh").is_some());
assert!(reopened.get::<Item>("stale").is_none(), "expired entry must not hydrate");
}
#[test]
fn prune_drops_future_dated_entries() {
let now = Utc::now();
let entries: HashMap<String, StoredEntry> = [
("sane".to_string(), StoredEntry { fetched_at: now - TimeDelta::hours(1), value: serde_json::json!(1) }),
("skewed".to_string(), StoredEntry { fetched_at: now + TimeDelta::days(400), value: serde_json::json!(2) }),
]
.into_iter()
.collect();
let kept = prune(entries, now);
assert!(kept.contains_key("sane"));
assert!(!kept.contains_key("skewed"), "a future-dated entry would wedge its key forever");
}
#[test]
fn prune_keeps_the_most_recent_entries_within_the_count_bound() {
let now = Utc::now();
let entries: HashMap<String, StoredEntry> = (0..MAX_ENTRIES + 10)
.map(|i| {
let fetched_at = now - TimeDelta::seconds(i as i64);
let value = serde_json::json!({ "id": i });
(format!("k{i}"), StoredEntry { fetched_at, value })
})
.collect();
let kept = prune(entries, now);
assert_eq!(kept.len(), MAX_ENTRIES);
assert!(kept.contains_key("k0"), "newest must survive");
assert!(!kept.contains_key(&format!("k{}", MAX_ENTRIES + 9)), "oldest must be dropped");
}
#[tokio::test]
async fn a_disabled_store_never_hits_and_never_writes() {
let dir = tempfile::tempdir().expect("tempdir");
let store = CacheStore::disabled();
assert_eq!(
store.put("prs", &item(1), Utc::now()),
CachePut::Disabled,
"a disabled store is not refusing the write as stale"
);
assert!(store.get::<Item>("prs").is_none());
store.load().await;
store.flush().await;
assert!(store.get::<Item>("prs").is_none());
let written = std::fs::read_dir(dir.path()).expect("read dir").count();
assert_eq!(written, 0, "a disabled store must not touch the filesystem");
}
}