use crate::Result;
use chrono::{DateTime, Utc};
use core::time::Duration;
use ohno::IntoAppError;
use serde::{Deserialize, Serialize};
use std::fs;
use std::fs::File;
use std::io::{BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
const LOG_TARGET: &str = " cache";
#[derive(Debug, Clone)]
pub enum CacheResult<T> {
Data(T),
NoData(String),
Miss,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct Envelope<T> {
timestamp: DateTime<Utc>,
payload: EnvelopePayload<T>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
enum EnvelopePayload<T> {
Data(T),
NoData(String),
}
#[derive(Debug, Clone)]
pub struct Cache {
dir: PathBuf,
ttl: Duration,
ignore: bool,
}
impl Cache {
#[must_use]
pub fn new(cache_dir: impl Into<PathBuf>, cache_ttl: Duration, ignore_cache: bool) -> Self {
Self {
dir: cache_dir.into(),
ttl: cache_ttl,
ignore: ignore_cache,
}
}
#[must_use]
pub fn dir(&self) -> &Path {
&self.dir
}
#[must_use]
pub fn load<T>(&self, filename: &str) -> CacheResult<T>
where
T: for<'de> Deserialize<'de>,
{
if self.ignore {
return CacheResult::Miss;
}
let path = self.dir.join(filename);
let file = match File::open(&path) {
Ok(file) => file,
Err(e) => {
log::debug!(target: LOG_TARGET, "Cache miss for {filename}: {e:#}");
return CacheResult::Miss;
}
};
let reader = BufReader::new(file);
let envelope: Envelope<T> = match serde_json::from_reader(reader) {
Ok(data) => data,
Err(e) => {
log::debug!(target: LOG_TARGET, "Cache miss for {filename}: {e:#}");
return CacheResult::Miss;
}
};
let age = Utc::now().signed_duration_since(envelope.timestamp);
if age.num_seconds() < 0 {
log::debug!(target: LOG_TARGET, "Cache timestamp is in the future for {filename} (clock skew detected), treating as fresh");
} else {
let age_duration = age.to_std().unwrap_or(Duration::MAX);
if age_duration >= self.ttl {
log::debug!(
target: LOG_TARGET,
"Cache expired for {filename} (age: {:.1} days, TTL: {:.1} days)",
age_duration.as_secs_f64() / 86400.0,
self.ttl.as_secs_f64() / 86400.0
);
return CacheResult::Miss;
}
log::debug!(target: LOG_TARGET, "Cache hit for {filename} (age: {:.1} days)", age_duration.as_secs_f64() / 86400.0);
}
match envelope.payload {
EnvelopePayload::Data(data) => CacheResult::Data(data),
EnvelopePayload::NoData(reason) => CacheResult::NoData(reason),
}
}
pub fn save<T>(&self, filename: &str, data: &T) -> Result<()>
where
T: Serialize,
{
let envelope = Envelope {
timestamp: Utc::now(),
payload: EnvelopePayload::Data(data),
};
self.write_envelope(filename, &envelope)
}
pub fn save_no_data(&self, filename: &str, reason: &str) -> Result<()> {
let envelope = Envelope::<()> {
timestamp: Utc::now(),
payload: EnvelopePayload::NoData(reason.to_string()),
};
self.write_envelope(filename, &envelope)
}
fn write_envelope<T: Serialize>(&self, filename: &str, envelope: &Envelope<T>) -> Result<()> {
let path = self.dir.join(filename);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).into_app_err_with(|| format!("creating directory '{}'", parent.display()))?;
}
let file = File::create(&path).into_app_err_with(|| format!("creating cache file '{}'", path.display()))?;
let mut writer = BufWriter::new(file);
#[cfg(debug_assertions)]
let result = serde_json::to_writer_pretty(&mut writer, envelope);
#[cfg(not(debug_assertions))]
let result = serde_json::to_writer(&mut writer, envelope);
result.into_app_err_with(|| format!("writing cache file '{}'", path.display()))?;
writer
.flush()
.into_app_err_with(|| format!("flushing cache file '{}'", path.display()))?;
Ok(())
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
struct TestData {
name: String,
value: u64,
}
fn make_cache(dir: &Path, ttl_secs: u64) -> Cache {
Cache::new(dir, Duration::from_secs(ttl_secs), false)
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot call GetTempPathW")]
fn save_and_load_data() {
let tmp = tempfile::tempdir().unwrap();
let cache = make_cache(tmp.path(), 3600);
let data = TestData { name: "test".to_string(), value: 42 };
cache.save("item.json", &data).unwrap();
match cache.load::<TestData>("item.json") {
CacheResult::Data(loaded) => assert_eq!(loaded, data),
other => panic!("expected Data, got {other:?}"),
}
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot call GetTempPathW")]
fn save_and_load_no_data() {
let tmp = tempfile::tempdir().unwrap();
let cache = make_cache(tmp.path(), 3600);
cache.save_no_data("missing.json", "not found").unwrap();
match cache.load::<TestData>("missing.json") {
CacheResult::NoData(reason) => assert_eq!(reason, "not found"),
other => panic!("expected NoData, got {other:?}"),
}
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot call GetFullPathNameW")]
fn load_nonexistent_file() {
let tmp = tempfile::tempdir().unwrap();
let cache = make_cache(tmp.path(), 3600);
assert!(matches!(cache.load::<TestData>("nope.json"), CacheResult::Miss));
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot call GetTempPathW")]
fn load_invalid_json() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("bad.json"), "not valid json").unwrap();
let cache = make_cache(tmp.path(), 3600);
assert!(matches!(cache.load::<TestData>("bad.json"), CacheResult::Miss));
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot call GetTempPathW")]
fn load_expired_entry() {
let tmp = tempfile::tempdir().unwrap();
let old_time = Utc::now() - chrono::Duration::hours(2);
let envelope = Envelope {
timestamp: old_time,
payload: EnvelopePayload::Data(TestData { name: "old".to_string(), value: 1 }),
};
let path = tmp.path().join("old.json");
let file = File::create(&path).unwrap();
serde_json::to_writer(file, &envelope).unwrap();
let cache = make_cache(tmp.path(), 3600);
assert!(matches!(cache.load::<TestData>("old.json"), CacheResult::Miss));
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot call GetTempPathW")]
fn load_future_timestamp_treated_as_fresh() {
let tmp = tempfile::tempdir().unwrap();
let future_time = Utc::now() + chrono::Duration::hours(1);
let envelope = Envelope {
timestamp: future_time,
payload: EnvelopePayload::Data(TestData { name: "future".to_string(), value: 1 }),
};
let path = tmp.path().join("future.json");
let file = File::create(&path).unwrap();
serde_json::to_writer(file, &envelope).unwrap();
let cache = make_cache(tmp.path(), 3600);
match cache.load::<TestData>("future.json") {
CacheResult::Data(d) => assert_eq!(d.name, "future"),
other => panic!("expected Data, got {other:?}"),
}
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot call GetTempPathW")]
fn ignore_cache_returns_miss() {
let tmp = tempfile::tempdir().unwrap();
let cache = Cache::new(tmp.path(), Duration::from_secs(3600), true);
let data = TestData { name: "ignored".to_string(), value: 1 };
make_cache(tmp.path(), 3600).save("item.json", &data).unwrap();
assert!(matches!(cache.load::<TestData>("item.json"), CacheResult::Miss));
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot call GetTempPathW")]
fn save_creates_parent_directories() {
let tmp = tempfile::tempdir().unwrap();
let cache = make_cache(tmp.path(), 3600);
let data = TestData { name: "nested".to_string(), value: 123 };
cache.save("sub/dir/item.json", &data).unwrap();
match cache.load::<TestData>("sub/dir/item.json") {
CacheResult::Data(loaded) => assert_eq!(loaded, data),
other => panic!("expected Data, got {other:?}"),
}
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot call GetTempPathW")]
fn save_overwrites_existing() {
let tmp = tempfile::tempdir().unwrap();
let cache = make_cache(tmp.path(), 3600);
cache.save("item.json", &TestData { name: "first".to_string(), value: 1 }).unwrap();
cache.save("item.json", &TestData { name: "second".to_string(), value: 2 }).unwrap();
match cache.load::<TestData>("item.json") {
CacheResult::Data(loaded) => assert_eq!(loaded.name, "second"),
other => panic!("expected Data, got {other:?}"),
}
}
#[test]
fn dir_accessor_returns_cache_dir() {
let cache = Cache::new("/some/path", Duration::from_secs(3600), false);
assert_eq!(cache.dir(), Path::new("/some/path"));
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot call GetTempPathW")]
fn save_no_data_then_overwrite_with_data() {
let tmp = tempfile::tempdir().unwrap();
let cache = make_cache(tmp.path(), 3600);
cache.save_no_data("item.json", "originally missing").unwrap();
assert!(matches!(cache.load::<TestData>("item.json"), CacheResult::NoData(r) if r == "originally missing"));
let data = TestData { name: "now available".to_string(), value: 99 };
cache.save("item.json", &data).unwrap();
match cache.load::<TestData>("item.json") {
CacheResult::Data(loaded) => assert_eq!(loaded, data),
other => panic!("expected Data, got {other:?}"),
}
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot call GetTempPathW")]
fn exactly_at_ttl_boundary_is_miss() {
let tmp = tempfile::tempdir().unwrap();
let ttl_seconds = 3600i64;
let old_time = Utc::now() - chrono::Duration::seconds(ttl_seconds);
let envelope = Envelope {
timestamp: old_time,
payload: EnvelopePayload::Data(TestData { name: "boundary".to_string(), value: 1 }),
};
let path = tmp.path().join("boundary.json");
let file = File::create(&path).unwrap();
serde_json::to_writer(file, &envelope).unwrap();
let cache = make_cache(tmp.path(), ttl_seconds.cast_unsigned());
assert!(matches!(cache.load::<TestData>("boundary.json"), CacheResult::Miss));
}
}