use crate::types::{PageContent, SearchResponse};
use moka::future::Cache;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, instrument};
pub const DEFAULT_CACHE_TTL_SECS: u64 = 300;
pub const DEFAULT_MAX_ENTRIES: u64 = 1000;
#[derive(Debug, Clone)]
pub struct CacheConfig {
pub ttl: Duration,
pub max_entries: u64,
pub enabled: bool,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
ttl: Duration::from_secs(DEFAULT_CACHE_TTL_SECS),
max_entries: DEFAULT_MAX_ENTRIES,
enabled: true,
}
}
}
#[derive(Clone)]
pub struct SearchCache {
search_cache: Arc<Cache<String, SearchResponse>>,
page_cache: Arc<Cache<String, PageContent>>,
enabled: bool,
}
impl SearchCache {
pub fn new(config: CacheConfig) -> Self {
let search_cache = Cache::builder()
.max_capacity(config.max_entries)
.time_to_live(config.ttl)
.build();
let page_cache = Cache::builder()
.max_capacity(config.max_entries)
.time_to_live(config.ttl)
.build();
Self {
search_cache: Arc::new(search_cache),
page_cache: Arc::new(page_cache),
enabled: config.enabled,
}
}
pub fn with_defaults() -> Self {
Self::new(CacheConfig::default())
}
pub fn disabled() -> Self {
Self::new(CacheConfig {
enabled: false,
..Default::default()
})
}
fn search_key(query: &str, region: &str, safe_search: &str) -> String {
format!("search:{}:{}:{}", query.to_lowercase(), region, safe_search)
}
fn page_key(url: &str, selector: Option<&str>) -> String {
match selector {
Some(sel) => format!("page:{}:{}", url, sel),
None => format!("page:{}", url),
}
}
#[instrument(skip(self))]
pub async fn get_search(
&self,
query: &str,
region: &str,
safe_search: &str,
) -> Option<SearchResponse> {
if !self.enabled {
return None;
}
let key = Self::search_key(query, region, safe_search);
let result = self.search_cache.get(&key).await;
if result.is_some() {
debug!(query = %query, "Cache hit for search query");
}
result
}
#[instrument(skip(self, response))]
pub async fn set_search(
&self,
query: &str,
region: &str,
safe_search: &str,
response: SearchResponse,
) {
if !self.enabled {
return;
}
let key = Self::search_key(query, region, safe_search);
self.search_cache.insert(key, response).await;
debug!(query = %query, "Cached search response");
}
#[instrument(skip(self))]
pub async fn get_page(&self, url: &str, selector: Option<&str>) -> Option<PageContent> {
if !self.enabled {
return None;
}
let key = Self::page_key(url, selector);
let result = self.page_cache.get(&key).await;
if result.is_some() {
debug!(url = %url, "Cache hit for page content");
}
result
}
#[instrument(skip(self, content))]
pub async fn set_page(&self, url: &str, selector: Option<&str>, content: PageContent) {
if !self.enabled {
return;
}
let key = Self::page_key(url, selector);
self.page_cache.insert(key, content).await;
debug!(url = %url, "Cached page content");
}
pub async fn clear(&self) {
self.search_cache.invalidate_all();
self.page_cache.invalidate_all();
debug!("Cache cleared");
}
pub fn stats(&self) -> CacheStats {
CacheStats {
search_entries: self.search_cache.entry_count(),
page_entries: self.page_cache.entry_count(),
enabled: self.enabled,
}
}
}
impl Default for SearchCache {
fn default() -> Self {
Self::with_defaults()
}
}
#[derive(Debug, Clone)]
pub struct CacheStats {
pub search_entries: u64,
pub page_entries: u64,
pub enabled: bool,
}
impl std::fmt::Display for CacheStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Cache Stats: {} search entries, {} page entries (enabled: {})",
self.search_entries, self.page_entries, self.enabled
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ContentType, ResultMetadata, SearchOptions, SearchResult};
#[tokio::test]
async fn test_cache_search() {
let cache = SearchCache::with_defaults();
let results = vec![SearchResult {
title: "Test".to_string(),
url: "https://example.com".to_string(),
description: "Test description".to_string(),
metadata: ResultMetadata {
content_type: ContentType::Article,
source: "example.com".to_string(),
favicon: None,
published_date: None,
},
}];
let options = SearchOptions::default();
let response = SearchResponse::new("test".to_string(), results, &options);
assert!(
cache
.get_search("test", "wt-wt", "MODERATE")
.await
.is_none()
);
cache
.set_search("test", "wt-wt", "MODERATE", response.clone())
.await;
let cached = cache.get_search("test", "wt-wt", "MODERATE").await;
assert!(cached.is_some());
assert_eq!(cached.unwrap().data.len(), 1);
}
#[tokio::test]
async fn test_cache_page() {
let cache = SearchCache::with_defaults();
let content = PageContent {
url: "https://example.com".to_string(),
title: "Test Page".to_string(),
content: "# Hello World".to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
word_count: 2,
links: None,
};
assert!(cache.get_page("https://example.com", None).await.is_none());
cache
.set_page("https://example.com", None, content.clone())
.await;
let cached = cache.get_page("https://example.com", None).await;
assert!(cached.is_some());
assert_eq!(cached.unwrap().title, "Test Page");
}
#[tokio::test]
async fn test_disabled_cache() {
let cache = SearchCache::disabled();
let results = vec![];
let options = SearchOptions::default();
let response = SearchResponse::new("test".to_string(), results, &options);
cache
.set_search("test", "wt-wt", "MODERATE", response)
.await;
assert!(
cache
.get_search("test", "wt-wt", "MODERATE")
.await
.is_none()
);
}
#[tokio::test]
async fn test_cache_stats() {
let cache = SearchCache::with_defaults();
let stats = cache.stats();
assert_eq!(stats.search_entries, 0);
assert_eq!(stats.page_entries, 0);
assert!(stats.enabled);
}
}