use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use rustdoc_types::Crate;
use tokio::sync::RwLock;
use crate::client::docsrs::{DocsRsClient, DocsRsError};
struct CacheEntry {
krate: Arc<Crate>,
fetched_at: Instant,
last_accessed: Instant,
}
pub struct DocsCache {
entries: RwLock<HashMap<(String, String), CacheEntry>>,
max_entries: usize,
ttl: Duration,
}
impl DocsCache {
pub fn new(max_entries: usize, ttl: Duration) -> Self {
Self {
entries: RwLock::new(HashMap::new()),
max_entries,
ttl,
}
}
pub async fn get(&self, name: &str, version: &str) -> Option<Arc<Crate>> {
let key = (name.to_string(), version.to_string());
let mut entries = self.entries.write().await;
let entry = entries.get_mut(&key)?;
if entry.fetched_at.elapsed() > self.ttl {
entries.remove(&key);
return None;
}
entry.last_accessed = Instant::now();
Some(Arc::clone(&entry.krate))
}
pub async fn insert(&self, name: &str, version: &str, krate: Arc<Crate>) {
let key = (name.to_string(), version.to_string());
let mut entries = self.entries.write().await;
entries.retain(|_, v| v.fetched_at.elapsed() <= self.ttl);
if entries.len() >= self.max_entries
&& !entries.contains_key(&key)
&& let Some(lru_key) = entries
.iter()
.min_by_key(|(_, v)| v.last_accessed)
.map(|(k, _)| k.clone())
{
entries.remove(&lru_key);
}
let now = Instant::now();
entries.insert(
key,
CacheEntry {
krate,
fetched_at: now,
last_accessed: now,
},
);
}
pub async fn get_or_fetch(
&self,
client: &DocsRsClient,
name: &str,
version: &str,
) -> Result<Arc<Crate>, DocsRsError> {
if let Some(krate) = self.get(name, version).await {
return Ok(krate);
}
let krate = client.fetch_rustdoc(name, version).await?;
let krate = Arc::new(krate);
self.insert(name, version, Arc::clone(&krate)).await;
Ok(krate)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn synthetic_crate() -> Crate {
let json = serde_json::json!({
"root": 0,
"crate_version": "1.0.0",
"includes_private": false,
"index": {},
"paths": {},
"external_crates": {},
"target": {
"triple": "x86_64-unknown-linux-gnu",
"target_features": []
},
"format_version": rustdoc_types::FORMAT_VERSION
});
serde_json::from_value(json).unwrap()
}
#[tokio::test]
async fn insert_and_get() {
let cache = DocsCache::new(10, Duration::from_secs(3600));
let krate = Arc::new(synthetic_crate());
cache.insert("serde", "1.0.0", Arc::clone(&krate)).await;
let cached = cache.get("serde", "1.0.0").await;
assert!(cached.is_some());
}
#[tokio::test]
async fn miss_returns_none() {
let cache = DocsCache::new(10, Duration::from_secs(3600));
assert!(cache.get("nonexistent", "1.0.0").await.is_none());
}
#[tokio::test]
async fn ttl_expiration() {
let cache = DocsCache::new(10, Duration::from_millis(1));
let krate = Arc::new(synthetic_crate());
cache.insert("serde", "1.0.0", krate).await;
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(cache.get("serde", "1.0.0").await.is_none());
}
#[tokio::test]
async fn lru_eviction() {
let cache = DocsCache::new(2, Duration::from_secs(3600));
let k1 = Arc::new(synthetic_crate());
let k2 = Arc::new(synthetic_crate());
let k3 = Arc::new(synthetic_crate());
cache.insert("a", "1.0.0", k1).await;
cache.insert("b", "1.0.0", k2).await;
cache.get("a", "1.0.0").await;
cache.insert("c", "1.0.0", k3).await;
assert!(cache.get("a", "1.0.0").await.is_some());
assert!(cache.get("b", "1.0.0").await.is_none());
assert!(cache.get("c", "1.0.0").await.is_some());
}
}