1use moka::future::Cache;
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::RwLock;
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub enum CacheKey {
18 Page(String),
20 UserPage { path: String, user_id: String },
22 Content { content_type: String, id: String },
24 UserContent {
26 user_id: String,
27 content_type: String,
28 id: String,
29 },
30 External { url: String },
32 Custom(String),
34}
35
36impl CacheKey {
37 pub fn page(path: impl Into<String>) -> Self {
39 Self::Page(path.into())
40 }
41
42 pub fn user_page(path: impl Into<String>, user_id: impl Into<String>) -> Self {
44 Self::UserPage {
45 path: path.into(),
46 user_id: user_id.into(),
47 }
48 }
49
50 pub fn content(content_type: impl Into<String>, id: impl Into<String>) -> Self {
52 Self::Content {
53 content_type: content_type.into(),
54 id: id.into(),
55 }
56 }
57
58 pub fn user_content(
60 user_id: impl Into<String>,
61 content_type: impl Into<String>,
62 id: impl Into<String>,
63 ) -> Self {
64 Self::UserContent {
65 user_id: user_id.into(),
66 content_type: content_type.into(),
67 id: id.into(),
68 }
69 }
70
71 pub fn external(url: impl Into<String>) -> Self {
73 Self::External { url: url.into() }
74 }
75
76 pub fn custom(key: impl Into<String>) -> Self {
78 Self::Custom(key.into())
79 }
80
81 fn to_cache_key(&self) -> String {
83 match self {
84 Self::Page(path) => format!("page:{}", path),
85 Self::UserPage { path, user_id } => format!("user:{}:page:{}", user_id, path),
86 Self::Content { content_type, id } => format!("content:{}:{}", content_type, id),
87 Self::UserContent {
88 user_id,
89 content_type,
90 id,
91 } => format!("user:{}:content:{}:{}", user_id, content_type, id),
92 Self::External { url } => format!("external:{}", url),
93 Self::Custom(key) => format!("custom:{}", key),
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
100pub struct CachedValue {
101 pub content: String,
103 pub etag: Option<String>,
105 pub content_type: String,
107}
108
109impl CachedValue {
110 pub fn html(content: String) -> Self {
111 Self {
112 content,
113 etag: None,
114 content_type: "text/html".to_string(),
115 }
116 }
117
118 pub fn json(content: String) -> Self {
119 Self {
120 content,
121 etag: None,
122 content_type: "application/json".to_string(),
123 }
124 }
125
126 pub fn with_etag(mut self, etag: impl Into<String>) -> Self {
127 self.etag = Some(etag.into());
128 self
129 }
130}
131
132#[derive(Clone)]
134pub struct WhatCache {
135 content_cache: Cache<String, CachedValue>,
137 api_cache: Cache<String, String>,
139 #[allow(dead_code)]
141 default_ttl: Duration,
142 #[allow(dead_code)]
144 api_ttl: Duration,
145 tag_index: Arc<RwLock<HashMap<String, HashSet<String>>>>,
148}
149
150impl WhatCache {
151 pub fn new() -> Self {
153 Self::with_config(CacheConfig::default())
154 }
155
156 pub fn with_config(config: CacheConfig) -> Self {
158 let content_cache = Cache::builder()
159 .max_capacity(config.max_content_entries)
160 .time_to_live(Duration::from_secs(config.content_ttl_secs))
161 .build();
162
163 let api_cache = Cache::builder()
164 .max_capacity(config.max_api_entries)
165 .time_to_live(Duration::from_secs(config.api_ttl_secs))
166 .build();
167
168 Self {
169 content_cache,
170 api_cache,
171 default_ttl: Duration::from_secs(config.content_ttl_secs),
172 api_ttl: Duration::from_secs(config.api_ttl_secs),
173 tag_index: Arc::new(RwLock::new(HashMap::new())),
174 }
175 }
176
177 pub async fn get(&self, key: &CacheKey) -> Option<CachedValue> {
179 self.content_cache.get(&key.to_cache_key()).await
180 }
181
182 pub async fn set(&self, key: &CacheKey, value: CachedValue) {
184 self.content_cache.insert(key.to_cache_key(), value).await;
185 }
186
187 pub async fn set_with_tags(&self, key: &CacheKey, value: CachedValue, tags: &[&str]) {
190 let cache_key = key.to_cache_key();
191 self.content_cache.insert(cache_key.clone(), value).await;
192
193 if !tags.is_empty() {
194 let mut index = self.tag_index.write().await;
195 for tag in tags {
196 index
197 .entry(tag.to_string())
198 .or_default()
199 .insert(cache_key.clone());
200 }
201 }
202 }
203
204 pub async fn set_with_ttl(&self, key: &CacheKey, value: CachedValue, _ttl: Duration) {
206 self.content_cache.insert(key.to_cache_key(), value).await;
210 }
211
212 pub async fn get_api(&self, url: &str) -> Option<String> {
214 self.api_cache.get(&format!("api:{}", url)).await
215 }
216
217 pub async fn set_api(&self, url: &str, response: String) {
219 self.api_cache
220 .insert(format!("api:{}", url), response)
221 .await;
222 }
223
224 pub async fn invalidate(&self, key: &CacheKey) {
226 self.content_cache.invalidate(&key.to_cache_key()).await;
227 }
228
229 pub async fn invalidate_by_tag(&self, tag: &str) {
232 let mut index = self.tag_index.write().await;
233 if let Some(keys) = index.remove(tag) {
234 for key in &keys {
235 self.content_cache.invalidate(key).await;
236 }
237 self.content_cache.run_pending_tasks().await;
238 tracing::debug!(
239 "Cache: invalidated {} entries for tag '{}'",
240 keys.len(),
241 tag
242 );
243 }
244 }
247
248 pub async fn invalidate_content_type(&self, content_type: &str) {
252 let index = self.tag_index.read().await;
253 if index.contains_key(content_type) {
254 drop(index); self.invalidate_by_tag(content_type).await;
256 } else {
257 drop(index);
258 self.content_cache.invalidate_all();
260 self.content_cache.run_pending_tasks().await;
261 }
262 }
263
264 pub async fn invalidate_user(&self, user_id: &str) {
267 let prefix = format!("user:{}:", user_id);
268 let index = self.tag_index.read().await;
269 if let Some(keys) = index.get(user_id) {
270 let keys_to_remove: Vec<String> = keys.iter().cloned().collect();
271 drop(index);
272 for key in &keys_to_remove {
273 self.content_cache.invalidate(key).await;
274 }
275 } else {
276 drop(index);
277 let _ = prefix; self.content_cache.invalidate_all();
281 }
282 self.content_cache.run_pending_tasks().await;
283 }
284
285 pub async fn clear_all(&self) {
287 self.content_cache.invalidate_all();
288 self.api_cache.invalidate_all();
289 self.content_cache.run_pending_tasks().await;
290 self.api_cache.run_pending_tasks().await;
291 self.tag_index.write().await.clear();
292 }
293
294 pub fn stats(&self) -> CacheStats {
296 CacheStats {
297 content_entries: self.content_cache.entry_count(),
298 api_entries: self.api_cache.entry_count(),
299 }
300 }
301}
302
303impl Default for WhatCache {
304 fn default() -> Self {
305 Self::new()
306 }
307}
308
309#[derive(Debug, Clone)]
311pub struct CacheConfig {
312 pub max_content_entries: u64,
314 pub max_api_entries: u64,
316 pub content_ttl_secs: u64,
318 pub api_ttl_secs: u64,
320}
321
322impl Default for CacheConfig {
323 fn default() -> Self {
324 Self {
325 max_content_entries: 10_000,
326 max_api_entries: 1_000,
327 content_ttl_secs: 300, api_ttl_secs: 60, }
330 }
331}
332
333#[derive(Debug, Clone)]
335pub struct CacheStats {
336 pub content_entries: u64,
337 pub api_entries: u64,
338}
339
340#[derive(Debug, Clone, Default)]
342pub struct CacheControl {
343 pub scope: CacheScope,
345 pub max_age: Option<u64>,
347 pub must_revalidate: bool,
349 pub immutable: bool,
351}
352
353#[derive(Debug, Clone, Default)]
354pub enum CacheScope {
355 #[default]
357 Public,
358 Private,
360 NoCache,
362 NoStore,
364}
365
366impl CacheControl {
367 pub fn public(max_age: u64) -> Self {
368 Self {
369 scope: CacheScope::Public,
370 max_age: Some(max_age),
371 must_revalidate: false,
372 immutable: false,
373 }
374 }
375
376 pub fn private(max_age: u64) -> Self {
377 Self {
378 scope: CacheScope::Private,
379 max_age: Some(max_age),
380 must_revalidate: false,
381 immutable: false,
382 }
383 }
384
385 pub fn no_cache() -> Self {
386 Self {
387 scope: CacheScope::NoCache,
388 max_age: None,
389 must_revalidate: true,
390 immutable: false,
391 }
392 }
393
394 pub fn immutable() -> Self {
395 Self {
396 scope: CacheScope::Public,
397 max_age: Some(31536000), must_revalidate: false,
399 immutable: true,
400 }
401 }
402
403 pub fn to_header_value(&self) -> String {
405 let mut parts = Vec::new();
406
407 match self.scope {
408 CacheScope::Public => parts.push("public".to_string()),
409 CacheScope::Private => parts.push("private".to_string()),
410 CacheScope::NoCache => parts.push("no-cache".to_string()),
411 CacheScope::NoStore => parts.push("no-store".to_string()),
412 }
413
414 if let Some(max_age) = self.max_age {
415 parts.push(format!("max-age={}", max_age));
416 }
417
418 if self.must_revalidate {
419 parts.push("must-revalidate".to_string());
420 }
421
422 if self.immutable {
423 parts.push("immutable".to_string());
424 }
425
426 parts.join(", ")
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[tokio::test]
435 async fn test_cache_basic() {
436 let cache = WhatCache::new();
437
438 let key = CacheKey::page("/about");
439 let value = CachedValue::html("<h1>About</h1>".to_string());
440
441 cache.set(&key, value.clone()).await;
442
443 let retrieved = cache.get(&key).await.unwrap();
444 assert_eq!(retrieved.content, "<h1>About</h1>");
445 }
446
447 #[tokio::test]
448 async fn test_user_page_cache() {
449 let cache = WhatCache::new();
450
451 let key1 = CacheKey::user_page("/dashboard", "user1");
452 let key2 = CacheKey::user_page("/dashboard", "user2");
453
454 cache
455 .set(&key1, CachedValue::html("User 1 Dashboard".to_string()))
456 .await;
457 cache
458 .set(&key2, CachedValue::html("User 2 Dashboard".to_string()))
459 .await;
460
461 assert_eq!(cache.get(&key1).await.unwrap().content, "User 1 Dashboard");
462 assert_eq!(cache.get(&key2).await.unwrap().content, "User 2 Dashboard");
463 }
464
465 #[tokio::test]
466 async fn test_set_with_tags_and_invalidate_by_tag() {
467 let cache = WhatCache::new();
468
469 let key1 = CacheKey::page("/blog");
470 let key2 = CacheKey::page("/blog/post-1");
471 let key3 = CacheKey::page("/about");
472
473 cache
475 .set_with_tags(&key1, CachedValue::html("Blog list".into()), &["posts"])
476 .await;
477 cache
478 .set_with_tags(&key2, CachedValue::html("Post 1".into()), &["posts"])
479 .await;
480 cache
481 .set_with_tags(&key3, CachedValue::html("About page".into()), &["pages"])
482 .await;
483
484 assert!(cache.get(&key1).await.is_some());
486 assert!(cache.get(&key2).await.is_some());
487 assert!(cache.get(&key3).await.is_some());
488
489 cache.invalidate_by_tag("posts").await;
491
492 assert!(cache.get(&key1).await.is_none());
493 assert!(cache.get(&key2).await.is_none());
494 assert!(cache.get(&key3).await.is_some()); }
496
497 #[tokio::test]
498 async fn test_invalidate_content_type_targeted() {
499 let cache = WhatCache::new();
500
501 let key1 = CacheKey::page("/products");
502 let key2 = CacheKey::page("/cart");
503
504 cache
505 .set_with_tags(&key1, CachedValue::html("Products".into()), &["products"])
506 .await;
507 cache
508 .set_with_tags(&key2, CachedValue::html("Cart".into()), &["cart"])
509 .await;
510
511 cache.invalidate_content_type("products").await;
513
514 assert!(cache.get(&key1).await.is_none());
515 assert!(cache.get(&key2).await.is_some());
516 }
517
518 #[tokio::test]
519 async fn test_invalidate_content_type_fallback() {
520 let cache = WhatCache::new();
521
522 let key = CacheKey::page("/test");
523 cache.set(&key, CachedValue::html("Test".into())).await;
525
526 cache.invalidate_content_type("unknown").await;
528
529 assert!(cache.get(&key).await.is_none());
530 }
531
532 #[tokio::test]
533 async fn test_clear_all_clears_tag_index() {
534 let cache = WhatCache::new();
535
536 let key = CacheKey::page("/blog");
537 cache
538 .set_with_tags(&key, CachedValue::html("Blog".into()), &["posts"])
539 .await;
540
541 cache.clear_all().await;
542
543 assert!(cache.get(&key).await.is_none());
544 assert!(cache.tag_index.read().await.is_empty());
546 }
547
548 #[test]
549 fn test_cache_control_header() {
550 let cc = CacheControl::public(3600);
551 assert_eq!(cc.to_header_value(), "public, max-age=3600");
552
553 let cc = CacheControl::private(600);
554 assert_eq!(cc.to_header_value(), "private, max-age=600");
555
556 let cc = CacheControl::no_cache();
557 assert_eq!(cc.to_header_value(), "no-cache, must-revalidate");
558
559 let cc = CacheControl::immutable();
560 assert_eq!(cc.to_header_value(), "public, max-age=31536000, immutable");
561 }
562}