use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheEntry {
pub data: Vec<u8>,
pub content_type: String,
pub cached_at: u64,
pub ttl_secs: Option<u64>,
pub etag: Option<String>,
}
impl CacheEntry {
pub fn new(
data: Vec<u8>,
content_type: impl Into<String>,
ttl_secs: Option<u64>,
etag: Option<String>,
) -> Self {
Self {
data,
content_type: content_type.into(),
cached_at: now_secs(),
ttl_secs,
etag,
}
}
pub fn is_valid_at(&self, now: u64) -> bool {
match self.ttl_secs {
None => true,
Some(ttl) => now < self.cached_at.saturating_add(ttl),
}
}
pub fn is_valid(&self) -> bool {
self.is_valid_at(now_secs())
}
pub fn age_secs(&self) -> u64 {
now_secs().saturating_sub(self.cached_at)
}
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingSyncRequest {
pub url: String,
pub method: String,
pub body: Vec<u8>,
pub content_type: String,
}
pub struct OfflineCache {
entries: HashMap<String, CacheEntry>,
pending_sync: Vec<PendingSyncRequest>,
default_ttl: Option<u64>,
}
impl OfflineCache {
pub fn new(default_ttl: Option<Duration>) -> Self {
Self {
entries: HashMap::new(),
pending_sync: Vec::new(),
default_ttl: default_ttl.map(|d| d.as_secs()),
}
}
pub fn put(
&mut self,
url: impl Into<String>,
data: Vec<u8>,
content_type: impl Into<String>,
ttl: Option<Duration>,
etag: Option<String>,
) {
let effective_ttl = ttl.map(|d| d.as_secs()).or(self.default_ttl);
let entry = CacheEntry::new(data, content_type, effective_ttl, etag);
self.entries.insert(url.into(), entry);
}
pub fn get(&mut self, url: &str) -> Option<&CacheEntry> {
let now = now_secs();
if let Some(entry) = self.entries.get(url) {
if entry.is_valid_at(now) {
return self.entries.get(url);
} else {
self.entries.remove(url);
}
}
None
}
pub fn evict(&mut self, url: &str) -> bool {
self.entries.remove(url).is_some()
}
pub fn evict_expired(&mut self) -> usize {
let now = now_secs();
let before = self.entries.len();
self.entries.retain(|_, e| e.is_valid_at(now));
before - self.entries.len()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn queue_sync(&mut self, req: PendingSyncRequest) {
self.pending_sync.push(req);
}
pub fn pending_sync_count(&self) -> usize {
self.pending_sync.len()
}
pub fn sync_on_online(&mut self) -> Vec<PendingSyncRequest> {
std::mem::take(&mut self.pending_sync)
}
pub fn contains(&mut self, url: &str) -> bool {
self.get(url).is_some()
}
pub fn cached_urls(&self) -> Vec<&str> {
self.entries.keys().map(|s| s.as_str()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mk_cache() -> OfflineCache {
OfflineCache::new(Some(Duration::from_secs(3600)))
}
#[test]
fn test_put_and_get() {
let mut cache = mk_cache();
cache.put(
"http://example.org/dataset.ttl",
b"<s> <p> <o> .".to_vec(),
"text/turtle",
None,
None,
);
assert!(cache.get("http://example.org/dataset.ttl").is_some());
}
#[test]
fn test_missing_url_returns_none() {
let mut cache = mk_cache();
assert!(cache.get("http://no.such.url/").is_none());
}
#[test]
fn test_evict() {
let mut cache = mk_cache();
cache.put(
"http://example.org/x",
b"data".to_vec(),
"text/plain",
None,
None,
);
assert!(cache.evict("http://example.org/x"));
assert!(
!cache.evict("http://example.org/x"),
"double evict returns false"
);
}
#[test]
fn test_expired_entry_not_returned() {
let mut cache = OfflineCache::new(None);
cache.put(
"http://example.org/expired",
b"data".to_vec(),
"text/plain",
Some(Duration::ZERO),
None,
);
let entry = cache.entries.get("http://example.org/expired").unwrap();
assert!(
!entry.is_valid_at(entry.cached_at + 1),
"entry with ttl=0 expires immediately"
);
}
#[test]
fn test_evict_expired() {
let mut cache = OfflineCache::new(None);
cache.entries.insert(
"http://example.org/old".into(),
CacheEntry {
data: vec![],
content_type: "text/plain".into(),
cached_at: 0, ttl_secs: Some(1),
etag: None,
},
);
cache.put(
"http://example.org/fresh",
b"ok".to_vec(),
"text/plain",
None,
None,
);
let evicted = cache.evict_expired();
assert_eq!(evicted, 1, "one expired entry evicted");
assert_eq!(cache.len(), 1, "fresh entry remains");
}
#[test]
fn test_pending_sync_queue_and_drain() {
let mut cache = mk_cache();
let req = PendingSyncRequest {
url: "http://api.example.org/update".into(),
method: "POST".into(),
body: b"INSERT DATA { <s> <p> <o> . }".to_vec(),
content_type: "application/sparql-update".into(),
};
cache.queue_sync(req);
assert_eq!(cache.pending_sync_count(), 1);
let drained = cache.sync_on_online();
assert_eq!(drained.len(), 1);
assert_eq!(cache.pending_sync_count(), 0, "queue cleared after sync");
}
#[test]
fn test_contains() {
let mut cache = mk_cache();
assert!(!cache.contains("http://x.org/"));
cache.put("http://x.org/", b"data".to_vec(), "text/plain", None, None);
assert!(cache.contains("http://x.org/"));
}
#[test]
fn test_cache_entry_age() {
let entry = CacheEntry::new(vec![], "text/plain", None, None);
assert!(entry.age_secs() < 2, "freshly created entry has age < 2s");
}
#[test]
fn test_no_expiry_entry_always_valid() {
let entry = CacheEntry::new(vec![], "text/plain", None, None);
assert!(entry.is_valid_at(u64::MAX), "no-TTL entry never expires");
}
}