use std::{
cmp::Ordering,
fs,
io::{BufReader, BufWriter},
path::{Path, PathBuf},
time::Duration,
};
use dashmap::DashMap;
use directories::ProjectDirs;
use jiff::{Span, Timestamp, ToSpan};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use url::Url;
use crate::{args::Args, error::Result};
const MAX_SPAN_SEC: i64 = 631_107_417_600;
#[derive(Copy, Clone, Debug)]
pub(crate) enum CachePath<'a> {
Default,
#[allow(dead_code)]
Path(&'a Path),
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub(crate) struct CacheValue {
pub(crate) timestamp: Timestamp,
pub(crate) retry_after: Option<Span>,
pub(crate) last_modified: Option<String>,
pub(crate) etag: Option<String>,
pub(crate) body: Option<String>,
}
pub(crate) fn get_cache_path() -> Option<PathBuf> {
if let Some(proj_dirs) = ProjectDirs::from("dev", "hsiao", "openring") {
return Some(proj_dirs.cache_dir().join("cache.json"));
}
None
}
fn spans_equal(a: &Span, b: &Span) -> bool {
a.compare(b)
.expect("time‑only spans never require a relative datetime")
== Ordering::Equal
}
impl PartialEq for CacheValue {
fn eq(&self, other: &Self) -> bool {
self.timestamp == other.timestamp
&& self.last_modified == other.last_modified
&& self.etag == other.etag
&& self.body == other.body
&& match (&self.retry_after, &other.retry_after) {
(Some(a), Some(b)) => spans_equal(a, b),
(None, None) => true,
_ => false,
}
}
}
impl Eq for CacheValue {}
pub(crate) type Cache = DashMap<Url, CacheValue>;
pub(crate) trait StoreExt {
fn store<T: AsRef<Path>>(&self, path: T) -> Result<()>;
fn load<T: AsRef<Path>>(path: T, max_age_secs: u64, now: Timestamp) -> Result<Cache>;
}
impl StoreExt for Cache {
fn store<T: AsRef<Path>>(&self, path: T) -> Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let f = fs::File::create(path)?;
f.lock()?;
let w = BufWriter::new(f);
serde_json::to_writer_pretty(w, self)?;
Ok(())
}
fn load<T: AsRef<Path>>(path: T, max_age_secs: u64, now: Timestamp) -> Result<Cache> {
let clamped_secs: i64 = max_age_secs.min(MAX_SPAN_SEC as u64).cast_signed();
let f = fs::File::open(path)?;
f.lock_shared()?;
let r = BufReader::new(f);
let map: DashMap<Url, CacheValue> = serde_json::from_reader(r)?;
let current_ts = now;
let threshold = clamped_secs.seconds();
let keys_to_remove: Vec<Url> = map
.iter()
.filter_map(|entry| {
let v = entry.value();
if (current_ts - v.timestamp).compare(threshold).ok()? == std::cmp::Ordering::Less {
None
} else {
Some(entry.key().clone())
}
})
.collect();
for k in keys_to_remove {
map.remove(&k);
}
Ok(map)
}
}
pub(crate) fn load_cache(args: &Args, cache_path: CachePath) -> Option<Cache> {
if args.no_cache {
return None;
}
let default_cache_path = get_cache_path();
let cache_path = match cache_path {
CachePath::Default if default_cache_path.is_none() => return None,
CachePath::Default => default_cache_path.unwrap(),
CachePath::Path(p) => p.to_path_buf(),
};
match fs::metadata(&cache_path) {
Err(_e) => {
return None;
}
Ok(metadata) => {
let modified = metadata.modified().ok()?;
let elapsed = modified.elapsed().ok()?;
if elapsed > args.max_cache_age {
warn!(
"Cache is too old (age: {:#?}, max age: {:#?}). Discarding and recreating.",
Duration::from_secs(elapsed.as_secs()),
Duration::from_secs(args.max_cache_age.as_secs())
);
return None;
}
info!(
"Cache is recent (age: {:#?}, max age: {:#?}). Using.",
Duration::from_secs(elapsed.as_secs()),
Duration::from_secs(args.max_cache_age.as_secs())
);
}
}
let cache = Cache::load(cache_path, args.max_cache_age.as_secs(), Timestamp::now());
match cache {
Ok(cache) => Some(cache),
Err(e) => {
warn!("Error while loading cache: {e}. Continuing without.");
None
}
}
}
#[cfg(test)]
mod tests {
use std::{fs::File, thread::sleep, time::Duration as StdDuration};
use jiff::{Span, Timestamp};
use proptest::prelude::*;
use tempfile::NamedTempFile;
use tempfile::TempDir;
use url::Url;
use super::*;
fn bounded_timestamp(secs: i64) -> Timestamp {
let secs = secs.clamp(Timestamp::MIN.as_second(), Timestamp::MAX.as_second());
Timestamp::from_second(secs).expect("failed to clamp timestamp")
}
fn url_gen() -> impl Strategy<Value = Url> {
("https?://", "[a-z]{1,10}\\.[a-z]{2,5}", "/[a-z]{0,15}")
.prop_map(|(scheme, host, path)| format!("{scheme}{host}{path}"))
.prop_filter_map("valid URL", |s| Url::parse(&s).ok())
}
fn cache_value_gen() -> impl Strategy<Value = CacheValue> {
let ts = any::<i64>().prop_map(bounded_timestamp);
let retry_after = prop_oneof![
Just(None),
any::<i64>()
.prop_map(|secs| Span::new().seconds(secs.clamp(0, MAX_SPAN_SEC)))
.prop_map(Some)
];
let opt_string = ".*".prop_map(|s| if s.is_empty() { None } else { Some(s) });
(
ts,
retry_after,
opt_string.clone(),
opt_string.clone(),
opt_string,
)
.prop_map(
|(timestamp, retry_after, last_modified, etag, body)| CacheValue {
timestamp,
retry_after,
last_modified,
etag,
body,
},
)
}
proptest! {
#[test]
fn spans_equal_behavior(a_secs in 0i64..1_000_000i64, b_secs in 0i64..1_000_000i64) {
let a = Span::new().seconds(a_secs);
let b = Span::new().seconds(b_secs);
let eq = spans_equal(&a, &b);
prop_assert_eq!(eq, a_secs == b_secs);
}
#[test]
fn round_trip_all_none_fields(url in url_gen()) {
let cache: Cache = DashMap::new();
let cv = CacheValue {
timestamp: Timestamp::now(),
retry_after: None,
last_modified: None,
etag: None,
body: None,
};
cache.insert(url.clone(), cv.clone());
let tmp = NamedTempFile::new().expect("temp file");
cache.store(tmp.path()).expect("store succeeds");
let loaded = Cache::load(tmp.path(), u64::MAX, Timestamp::now()).expect("load succeeds");
let loaded_val = loaded.get(&url).expect("key present after load");
prop_assert_eq!(&*loaded_val, &cv);
}
#[test]
fn load_clamps_max_age(url in url_gen(), value in cache_value_gen()) {
let cache: Cache = DashMap::new();
cache.insert(url.clone(), value.clone());
let tmp = NamedTempFile::new().expect("temp file");
cache.store(tmp.path()).expect("store succeeds");
let loaded_large = Cache::load(tmp.path(), u64::MAX, Timestamp::now()).expect("load succeeds");
let loaded_clamped = Cache::load(tmp.path(), MAX_SPAN_SEC as u64, Timestamp::now()).expect("load succeeds");
prop_assert!(loaded_large.contains_key(&url));
prop_assert!(loaded_clamped.contains_key(&url));
}
#[test]
fn round_trip_preserves_entries(
entries in prop::collection::vec((url_gen(), cache_value_gen()), 0..100)
) {
let cache: Cache = DashMap::new();
for (url, value) in &entries {
cache.insert(url.clone(), value.clone());
}
let tmp = NamedTempFile::new().expect("temp file");
cache.store(tmp.path()).expect("store succeeds");
let loaded = Cache::load(tmp.path(), u64::MAX, Timestamp::now()).expect("load succeeds");
for (url, value) in entries {
let loaded_val = loaded.get(&url).expect("key present after load");
prop_assert_eq!(&*loaded_val, &value);
}
}
#[test]
fn age_filter_discards_old_entries(
now_secs in 10_000i64..Timestamp::MAX.as_second(),
max_age in 0u32..10_000,
entries in prop::collection::vec(
(url_gen(), cache_value_gen()),
0..200
)
) {
let now = bounded_timestamp(now_secs);
let max_age_span = Span::new().seconds(i64::from(max_age));
let cache: Cache = DashMap::new();
for (i, (url, mut value)) in entries.into_iter().enumerate() {
if i % 2 == 0 {
value.timestamp = now - max_age_span - Span::new().seconds(1);
} else {
value.timestamp = now;
}
cache.insert(url, value);
}
let tmp = NamedTempFile::new().expect("temp file");
cache.store(tmp.path()).expect("store succeeds");
let loaded = Cache::load(tmp.path(), max_age.into(), now).expect("load succeeds");
for entry in &cache {
let url = entry.key(); let value = entry.value();
let cutoff = now - max_age_span; let should_keep = value.timestamp > cutoff;
let present = loaded.contains_key(url);
prop_assert_eq!(present, should_keep);
}
}
}
#[test]
fn load_cache_returns_none_when_cache_disabled() {
let tmp_cache_path = NamedTempFile::new().expect("temp file");
let mut args = Args {
no_cache: true,
..Default::default()
};
args.no_cache = true;
assert!(super::load_cache(&args, CachePath::Path(tmp_cache_path.path())).is_none());
}
#[test]
fn load_cache_returns_none_when_no_file() {
let tmpdir = TempDir::new().expect("tempdir");
let tmp_cache_path = tmpdir.path().join("nonexistent");
let args = Args {
no_cache: false,
..Default::default()
};
let _ = fs::remove_file(&tmp_cache_path);
assert!(super::load_cache(&args, CachePath::Path(tmp_cache_path.as_path())).is_none());
}
#[test]
fn load_cache_discards_too_old_file_and_returns_none() {
let tmp_cache_path = NamedTempFile::new().expect("temp file");
File::create(&tmp_cache_path).expect("create cache file");
sleep(StdDuration::from_millis(10));
let args = Args {
no_cache: false,
max_cache_age: Duration::from_millis(1),
..Default::default()
};
assert!(super::load_cache(&args, CachePath::Path(tmp_cache_path.path())).is_none());
}
#[test]
fn load_cache_uses_recent_file_and_loads_entries() {
let tmp_cache_path = NamedTempFile::new().expect("temp file");
let url = Url::parse("https://example.test/").unwrap();
let value = CacheValue {
timestamp: Timestamp::now(),
retry_after: None,
last_modified: Some("Mon, 01 Jan 2000 00:00:00 GMT".into()),
etag: Some("etag".into()),
body: Some("body".into()),
};
let cache = Cache::new();
cache.insert(url.clone(), value.clone());
cache.store(&tmp_cache_path).expect("store");
let args = Args {
no_cache: false,
max_cache_age: Duration::from_hours(24),
..Default::default()
};
let loaded =
super::load_cache(&args, CachePath::Path(tmp_cache_path.path())).expect("some cache");
assert!(loaded.contains_key(&url));
let loaded_val = loaded.get(&url).expect("get");
assert_eq!(&*loaded_val, &value);
}
#[test]
fn cache_round_trip_prunes_entries_that_are_old() {
let tmp_cache_path = NamedTempFile::new().expect("temp file");
let cache = Cache::new();
let valid_url = Url::parse("https://example.test/").unwrap();
let valid_value = CacheValue {
timestamp: Timestamp::now(),
retry_after: None,
last_modified: Some("Mon, 01 Jan 2000 00:00:00 GMT".into()),
etag: Some("etag".into()),
body: Some("body".into()),
};
cache.insert(valid_url.clone(), valid_value.clone());
let expired_url = Url::parse("https://example2.test/").unwrap();
let expired_value = CacheValue {
timestamp: Timestamp::now() - Duration::from_hours(48),
retry_after: None,
last_modified: Some("Mon, 01 Jan 2000 00:00:00 GMT".into()),
etag: Some("etag".into()),
body: Some("body".into()),
};
cache.insert(expired_url.clone(), expired_value.clone());
cache.store(&tmp_cache_path).expect("store");
let args = Args {
no_cache: false,
max_cache_age: Duration::from_hours(24),
..Default::default()
};
let loaded =
super::load_cache(&args, CachePath::Path(tmp_cache_path.path())).expect("some cache");
assert!(loaded.contains_key(&valid_url));
assert!(!loaded.contains_key(&expired_url));
let loaded_val = loaded.get(&valid_url).expect("get");
assert_eq!(&*loaded_val, &valid_value);
loaded.store(&tmp_cache_path).expect("store");
let loaded =
super::load_cache(&args, CachePath::Path(tmp_cache_path.path())).expect("some cache");
assert!(loaded.contains_key(&valid_url));
assert!(!loaded.contains_key(&expired_url));
}
}