1use crate::cache::Entry;
7use crate::oembed::PageInfo;
8use lru::LruCache;
9use std::num::NonZeroUsize;
10use std::sync::Mutex;
11
12const OEMBED_CACHE_MAX_ENTRIES: usize = 10_000;
14
15pub struct OembedCache {
23 cache: Mutex<LruCache<String, Entry<PageInfo>>>,
26 current_size: Mutex<usize>,
28 max_size: usize,
30}
31
32impl OembedCache {
33 pub fn new(max_size_bytes: usize) -> Self {
40 let cap = const {
43 match NonZeroUsize::new(OEMBED_CACHE_MAX_ENTRIES) {
44 Some(cap) => cap,
45 None => panic!("OEMBED_CACHE_MAX_ENTRIES must be non-zero"),
46 }
47 };
48 Self {
49 cache: Mutex::new(LruCache::new(cap)),
50 current_size: Mutex::new(0),
51 max_size: max_size_bytes,
52 }
53 }
54
55 pub fn get(&self, url: &str) -> Option<PageInfo> {
60 if self.max_size == 0 {
61 return None;
62 }
63
64 let mut cache = self
67 .cache
68 .lock()
69 .unwrap_or_else(std::sync::PoisonError::into_inner);
70 match cache.get(url) {
71 Some(entry) => {
72 tracing::debug!("oembed cache hit: {}", url);
73 Some(entry.value.clone())
74 }
75 None => {
76 tracing::debug!("oembed cache miss: {}", url);
77 None
78 }
79 }
80 }
81
82 pub fn insert(&self, url: String, info: PageInfo) {
87 if self.max_size == 0 {
88 return;
89 }
90
91 let size_bytes = Entry::<PageInfo>::weigh(info.estimated_size(), url.len());
92
93 let entry = Entry::new(info, size_bytes);
94
95 let mut cache = self
97 .cache
98 .lock()
99 .unwrap_or_else(std::sync::PoisonError::into_inner);
100 let mut current_size = self
101 .current_size
102 .lock()
103 .unwrap_or_else(std::sync::PoisonError::into_inner);
104
105 if let Some(old) = cache.push(url.clone(), entry) {
107 *current_size -= old.1.size_bytes;
108 }
109 *current_size += size_bytes;
110
111 tracing::debug!("oembed cached: {} ({} bytes)", url, size_bytes);
112
113 while *current_size > self.max_size {
115 if let Some((_evicted_key, evicted_entry)) = cache.pop_lru() {
116 *current_size -= evicted_entry.size_bytes;
117 } else {
118 break;
119 }
120 }
121 }
122
123 #[cfg(test)]
125 pub fn current_size(&self) -> usize {
126 *self.current_size.lock().unwrap()
127 }
128
129 #[cfg(test)]
131 pub fn len(&self) -> usize {
132 self.cache.lock().unwrap().len()
133 }
134
135 #[cfg(test)]
137 pub fn is_empty(&self) -> bool {
138 self.cache.lock().unwrap().is_empty()
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 fn make_page_info(url: &str) -> PageInfo {
147 PageInfo {
148 url: url.to_string(),
149 title: Some("Test Title".to_string()),
150 description: Some("Test description".to_string()),
151 image: None,
152 embed_html: None,
153 }
154 }
155
156 #[test]
157 fn test_insert_and_retrieve() {
158 let cache = OembedCache::new(1024 * 1024); let url = "https://example.com/page";
161 let info = make_page_info(url);
162
163 cache.insert(url.to_string(), info.clone());
164
165 let retrieved = cache.get(url);
166 assert!(retrieved.is_some());
167 assert_eq!(retrieved.unwrap().url, url);
168 }
169
170 #[test]
171 fn test_cache_miss() {
172 let cache = OembedCache::new(1024 * 1024);
173
174 let retrieved = cache.get("https://nonexistent.com");
175 assert!(retrieved.is_none());
176 }
177
178 #[test]
179 fn test_disabled_cache() {
180 let cache = OembedCache::new(0); let url = "https://example.com";
183 let info = make_page_info(url);
184
185 cache.insert(url.to_string(), info);
186
187 assert!(cache.get(url).is_none());
189 }
190
191 #[test]
192 fn test_size_tracking() {
193 let cache = OembedCache::new(1024 * 1024);
194
195 assert_eq!(cache.current_size(), 0);
196
197 let url = "https://example.com";
198 let info = make_page_info(url);
199
200 cache.insert(url.to_string(), info);
201
202 assert!(cache.current_size() > 0);
203 }
204
205 #[test]
206 fn test_eviction_on_size_limit() {
207 let cache = OembedCache::new(500);
209
210 for i in 0..10 {
212 let url = format!("https://example.com/page{}", i);
213 let info = make_page_info(&url);
214 cache.insert(url, info);
215 }
216
217 assert!(cache.current_size() <= 500);
219 }
220
221 #[test]
222 fn test_multiple_entries() {
223 let cache = OembedCache::new(1024 * 1024);
224
225 let urls = vec![
226 "https://example.com/page1",
227 "https://example.com/page2",
228 "https://example.com/page3",
229 ];
230
231 for url in &urls {
232 let info = make_page_info(url);
233 cache.insert(url.to_string(), info);
234 }
235
236 assert_eq!(cache.len(), 3);
237
238 for url in &urls {
239 assert!(cache.get(url).is_some());
240 }
241 }
242
243 #[test]
244 fn test_overwrite_existing() {
245 let cache = OembedCache::new(1024 * 1024);
246
247 let url = "https://example.com";
248
249 let info1 = PageInfo {
250 url: url.to_string(),
251 title: Some("First Title".to_string()),
252 ..Default::default()
253 };
254
255 let info2 = PageInfo {
256 url: url.to_string(),
257 title: Some("Second Title".to_string()),
258 ..Default::default()
259 };
260
261 cache.insert(url.to_string(), info1);
262 cache.insert(url.to_string(), info2);
263
264 let retrieved = cache.get(url).unwrap();
265 assert_eq!(retrieved.title, Some("Second Title".to_string()));
266 }
267}