Skip to main content

nntp_rs/
cache.rs

1//! Header caching for NNTP client
2//!
3//! This module provides in-memory caching for article metadata to reduce redundant network requests.
4//! The cache stores XOVER entries (article metadata) indexed by article number within a newsgroup context.
5//!
6//! # Cache Strategy
7//!
8//! - **Scope**: Per-newsgroup (cleared when changing groups)
9//! - **Index**: Article number (u64)
10//! - **Eviction**: LRU (Least Recently Used)
11//! - **Size Limit**: Configurable max entries
12//!
13//! # Example
14//!
15//! ```no_run
16//! use nntp_rs::cache::{HeaderCache, LruHeaderCache};
17//! use nntp_rs::XoverEntry;
18//!
19//! let mut cache = LruHeaderCache::new(1000); // Cache up to 1000 entries
20//!
21//! // Store article metadata
22//! let entry = XoverEntry {
23//!     article_number: 12345,
24//!     subject: "Test Article".to_string(),
25//!     author: "user@example.com".to_string(),
26//!     date: "2024-01-01".to_string(),
27//!     message_id: "<test@example.com>".to_string(),
28//!     references: "".to_string(),
29//!     bytes: 1024,
30//!     lines: 50,
31//! };
32//! cache.put(12345, entry.clone());
33//!
34//! // Retrieve from cache
35//! if let Some(cached) = cache.get(&12345) {
36//!     println!("Found in cache: {}", cached.subject);
37//! }
38//!
39//! // Clear cache when changing groups
40//! cache.clear();
41//! ```
42
43use crate::XoverEntry;
44use std::collections::HashMap;
45
46/// Trait for header caching implementations
47pub trait HeaderCache {
48    /// Store an article's overview data
49    fn put(&mut self, article_number: u64, entry: XoverEntry);
50
51    /// Retrieve an article's overview data
52    fn get(&mut self, article_number: &u64) -> Option<&XoverEntry>;
53
54    /// Check if an article exists in cache
55    fn contains(&self, article_number: &u64) -> bool;
56
57    /// Remove an article from cache
58    fn remove(&mut self, article_number: &u64) -> Option<XoverEntry>;
59
60    /// Clear all cached entries
61    fn clear(&mut self);
62
63    /// Get the number of cached entries
64    fn len(&self) -> usize;
65
66    /// Check if cache is empty
67    #[must_use]
68    fn is_empty(&self) -> bool {
69        self.len() == 0
70    }
71
72    /// Get the maximum cache size
73    fn capacity(&self) -> usize;
74}
75
76/// LRU (Least Recently Used) cache for article metadata
77///
78/// Implements a simple LRU cache using a HashMap and access ordering.
79/// When the cache is full, the least recently accessed entry is evicted.
80///
81/// # Example
82///
83/// ```
84/// use nntp_rs::cache::{HeaderCache, LruHeaderCache};
85/// use nntp_rs::XoverEntry;
86///
87/// let mut cache = LruHeaderCache::new(2); // Max 2 entries
88///
89/// let entry1 = XoverEntry {
90///     article_number: 1,
91///     subject: "First".to_string(),
92///     author: "author1@example.com".to_string(),
93///     date: "2024-01-01".to_string(),
94///     message_id: "<1@example.com>".to_string(),
95///     references: "".to_string(),
96///     bytes: 100,
97///     lines: 10,
98/// };
99///
100/// let entry2 = XoverEntry {
101///     article_number: 2,
102///     subject: "Second".to_string(),
103///     author: "author2@example.com".to_string(),
104///     date: "2024-01-02".to_string(),
105///     message_id: "<2@example.com>".to_string(),
106///     references: "".to_string(),
107///     bytes: 200,
108///     lines: 20,
109///     };
110///
111/// cache.put(1, entry1);
112/// cache.put(2, entry2);
113/// assert_eq!(cache.len(), 2);
114///
115/// // Access entry 1 to make it recently used
116/// cache.get(&1);
117///
118/// // Adding a third entry will evict entry 2 (least recently used)
119/// let entry3 = XoverEntry {
120///     article_number: 3,
121///     subject: "Third".to_string(),
122///     author: "author3@example.com".to_string(),
123///     date: "2024-01-03".to_string(),
124///     message_id: "<3@example.com>".to_string(),
125///     references: "".to_string(),
126///     bytes: 300,
127///     lines: 30,
128/// };
129/// cache.put(3, entry3);
130///
131/// assert_eq!(cache.len(), 2);
132/// assert!(cache.contains(&1)); // Still cached
133/// assert!(!cache.contains(&2)); // Evicted
134/// assert!(cache.contains(&3)); // Newly added
135/// ```
136#[derive(Debug, Clone)]
137pub struct LruHeaderCache {
138    /// Maximum number of entries
139    max_size: usize,
140    /// Storage for cached entries
141    entries: HashMap<u64, XoverEntry>,
142    /// Access order tracking (article_number -> access_count)
143    /// Higher access_count means more recently used
144    access_order: HashMap<u64, u64>,
145    /// Current access counter
146    access_counter: u64,
147}
148
149impl LruHeaderCache {
150    /// Create a new LRU cache with the specified maximum size
151    ///
152    /// # Arguments
153    ///
154    /// * `max_size` - Maximum number of entries to cache (must be > 0)
155    ///
156    /// # Panics
157    ///
158    /// Panics if `max_size` is 0
159    ///
160    /// # Example
161    ///
162    /// ```
163    /// use nntp_rs::cache::{LruHeaderCache, HeaderCache};
164    ///
165    /// let cache = LruHeaderCache::new(1000);
166    /// assert_eq!(cache.capacity(), 1000);
167    /// assert!(cache.is_empty());
168    /// ```
169    pub fn new(max_size: usize) -> Self {
170        assert!(max_size > 0, "Cache size must be greater than 0");
171        Self {
172            max_size,
173            entries: HashMap::new(),
174            access_order: HashMap::new(),
175            access_counter: 0,
176        }
177    }
178
179    /// Evict the least recently used entry
180    ///
181    /// Returns the article number that was evicted, or None if cache is empty
182    fn evict_lru(&mut self) -> Option<u64> {
183        if self.entries.is_empty() {
184            return None;
185        }
186
187        // Find the entry with the lowest access counter
188        let lru_article = self
189            .access_order
190            .iter()
191            .min_by_key(|&(_, &access_count)| access_count)
192            .map(|(&article_number, _)| article_number)?;
193
194        self.entries.remove(&lru_article);
195        self.access_order.remove(&lru_article);
196
197        Some(lru_article)
198    }
199
200    /// Update access time for an entry
201    fn touch(&mut self, article_number: &u64) {
202        self.access_counter = self.access_counter.wrapping_add(1);
203        self.access_order
204            .insert(*article_number, self.access_counter);
205    }
206}
207
208impl HeaderCache for LruHeaderCache {
209    fn put(&mut self, article_number: u64, entry: XoverEntry) {
210        // If we're at capacity and this is a new entry, evict LRU
211        if self.entries.len() >= self.max_size && !self.entries.contains_key(&article_number) {
212            self.evict_lru();
213        }
214
215        // Insert or update the entry
216        self.entries.insert(article_number, entry);
217        self.touch(&article_number);
218    }
219
220    fn get(&mut self, article_number: &u64) -> Option<&XoverEntry> {
221        if self.entries.contains_key(article_number) {
222            self.touch(article_number);
223            self.entries.get(article_number)
224        } else {
225            None
226        }
227    }
228
229    fn contains(&self, article_number: &u64) -> bool {
230        self.entries.contains_key(article_number)
231    }
232
233    fn remove(&mut self, article_number: &u64) -> Option<XoverEntry> {
234        self.access_order.remove(article_number);
235        self.entries.remove(article_number)
236    }
237
238    fn clear(&mut self) {
239        self.entries.clear();
240        self.access_order.clear();
241        self.access_counter = 0;
242    }
243
244    fn len(&self) -> usize {
245        self.entries.len()
246    }
247
248    fn capacity(&self) -> usize {
249        self.max_size
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn create_test_entry(article_number: u64, subject: &str) -> XoverEntry {
258        XoverEntry {
259            article_number,
260            subject: subject.to_string(),
261            author: format!("author{}@example.com", article_number),
262            date: format!("2024-01-{:02}", article_number % 31 + 1),
263            message_id: format!("<{}@example.com>", article_number),
264            references: String::new(),
265            bytes: (article_number * 100) as usize,
266            lines: (article_number * 10) as usize,
267        }
268    }
269
270    #[test]
271    fn test_cache_creation() {
272        let cache = LruHeaderCache::new(100);
273        assert_eq!(cache.capacity(), 100);
274        assert_eq!(cache.len(), 0);
275        assert!(cache.is_empty());
276    }
277
278    #[test]
279    #[should_panic(expected = "Cache size must be greater than 0")]
280    fn test_cache_zero_size_panics() {
281        LruHeaderCache::new(0);
282    }
283
284    #[test]
285    fn test_put_and_get() {
286        let mut cache = LruHeaderCache::new(10);
287        let entry = create_test_entry(12345, "Test Article");
288
289        cache.put(12345, entry.clone());
290        assert_eq!(cache.len(), 1);
291        assert!(!cache.is_empty());
292
293        let retrieved = cache.get(&12345).unwrap();
294        assert_eq!(retrieved.article_number, 12345);
295        assert_eq!(retrieved.subject, "Test Article");
296    }
297
298    #[test]
299    fn test_contains() {
300        let mut cache = LruHeaderCache::new(10);
301        let entry = create_test_entry(100, "Test");
302
303        assert!(!cache.contains(&100));
304        cache.put(100, entry);
305        assert!(cache.contains(&100));
306    }
307
308    #[test]
309    fn test_remove() {
310        let mut cache = LruHeaderCache::new(10);
311        let entry = create_test_entry(200, "Remove Me");
312
313        cache.put(200, entry.clone());
314        assert!(cache.contains(&200));
315
316        let removed = cache.remove(&200).unwrap();
317        assert_eq!(removed.article_number, 200);
318        assert!(!cache.contains(&200));
319        assert_eq!(cache.len(), 0);
320    }
321
322    #[test]
323    fn test_clear() {
324        let mut cache = LruHeaderCache::new(10);
325        for i in 1..=5 {
326            cache.put(i, create_test_entry(i, &format!("Article {}", i)));
327        }
328        assert_eq!(cache.len(), 5);
329
330        cache.clear();
331        assert_eq!(cache.len(), 0);
332        assert!(cache.is_empty());
333        for i in 1..=5 {
334            assert!(!cache.contains(&i));
335        }
336    }
337
338    #[test]
339    fn test_lru_eviction() {
340        let mut cache = LruHeaderCache::new(3);
341
342        // Fill cache to capacity
343        cache.put(1, create_test_entry(1, "First"));
344        cache.put(2, create_test_entry(2, "Second"));
345        cache.put(3, create_test_entry(3, "Third"));
346        assert_eq!(cache.len(), 3);
347
348        // Access entry 1 to make it recently used
349        cache.get(&1);
350
351        // Add a fourth entry - should evict entry 2 (least recently used)
352        cache.put(4, create_test_entry(4, "Fourth"));
353        assert_eq!(cache.len(), 3);
354        assert!(cache.contains(&1)); // Still there (recently accessed)
355        assert!(!cache.contains(&2)); // Evicted (least recently used)
356        assert!(cache.contains(&3)); // Still there
357        assert!(cache.contains(&4)); // Newly added
358    }
359
360    #[test]
361    fn test_lru_eviction_order() {
362        let mut cache = LruHeaderCache::new(2);
363
364        cache.put(1, create_test_entry(1, "First"));
365        cache.put(2, create_test_entry(2, "Second"));
366
367        // Access 1 to make it more recent
368        cache.get(&1);
369
370        // Add 3, should evict 2
371        cache.put(3, create_test_entry(3, "Third"));
372        assert!(cache.contains(&1));
373        assert!(!cache.contains(&2));
374        assert!(cache.contains(&3));
375
376        // Access 3
377        cache.get(&3);
378
379        // Add 4, should evict 1
380        cache.put(4, create_test_entry(4, "Fourth"));
381        assert!(!cache.contains(&1));
382        assert!(cache.contains(&3));
383        assert!(cache.contains(&4));
384    }
385
386    #[test]
387    fn test_update_existing_entry() {
388        let mut cache = LruHeaderCache::new(3);
389
390        cache.put(1, create_test_entry(1, "Original"));
391        cache.put(2, create_test_entry(2, "Second"));
392        cache.put(3, create_test_entry(3, "Third"));
393
394        // Update entry 1 (should not cause eviction)
395        cache.put(1, create_test_entry(1, "Updated"));
396        assert_eq!(cache.len(), 3);
397
398        let entry = cache.get(&1).unwrap();
399        assert_eq!(entry.subject, "Updated");
400    }
401
402    #[test]
403    fn test_get_nonexistent() {
404        let mut cache = LruHeaderCache::new(10);
405        assert!(cache.get(&999).is_none());
406    }
407
408    #[test]
409    fn test_remove_nonexistent() {
410        let mut cache = LruHeaderCache::new(10);
411        assert!(cache.remove(&999).is_none());
412    }
413
414    #[test]
415    fn test_large_cache() {
416        let mut cache = LruHeaderCache::new(1000);
417
418        // Add 500 entries
419        for i in 1..=500 {
420            cache.put(i, create_test_entry(i, &format!("Article {}", i)));
421        }
422        assert_eq!(cache.len(), 500);
423
424        // Verify all entries are retrievable
425        for i in 1..=500 {
426            assert!(cache.contains(&i));
427            let entry = cache.get(&i).unwrap();
428            assert_eq!(entry.article_number, i);
429        }
430    }
431
432    #[test]
433    fn test_single_entry_cache() {
434        let mut cache = LruHeaderCache::new(1);
435
436        cache.put(1, create_test_entry(1, "First"));
437        assert_eq!(cache.len(), 1);
438
439        // Adding second entry should evict first
440        cache.put(2, create_test_entry(2, "Second"));
441        assert_eq!(cache.len(), 1);
442        assert!(!cache.contains(&1));
443        assert!(cache.contains(&2));
444    }
445
446    #[test]
447    fn test_high_churn() {
448        let mut cache = LruHeaderCache::new(5);
449
450        // Add 100 entries with only 5 slots
451        for i in 1..=100 {
452            cache.put(i, create_test_entry(i, &format!("Article {}", i)));
453            assert!(cache.len() <= 5);
454        }
455
456        // Only the last 5 should remain (96-100)
457        assert_eq!(cache.len(), 5);
458        for i in 96..=100 {
459            assert!(cache.contains(&i));
460        }
461        for i in 1..=95 {
462            assert!(!cache.contains(&i));
463        }
464    }
465
466    #[test]
467    fn test_access_pattern_preservation() {
468        let mut cache = LruHeaderCache::new(3);
469
470        cache.put(1, create_test_entry(1, "First"));
471        cache.put(2, create_test_entry(2, "Second"));
472        cache.put(3, create_test_entry(3, "Third"));
473
474        // Keep accessing 1 and 2
475        for _ in 0..5 {
476            cache.get(&1);
477            cache.get(&2);
478        }
479
480        // Add new entries - 3 should be evicted first since it's LRU
481        cache.put(4, create_test_entry(4, "Fourth"));
482        assert!(cache.contains(&1));
483        assert!(cache.contains(&2));
484        assert!(!cache.contains(&3));
485        assert!(cache.contains(&4));
486
487        cache.put(5, create_test_entry(5, "Fifth"));
488        assert!(cache.contains(&1) || cache.contains(&2)); // At least one is still there
489        assert!(!cache.contains(&3));
490    }
491}