pcapsql_core/cache/
mod.rs

1//! Parse cache for avoiding redundant protocol parsing.
2//!
3//! In streaming mode with JOINs, multiple protocol readers traverse the same
4//! PCAP file. Without caching, each reader parses every packet independently,
5//! even though the parse result is the same.
6//!
7//! The cache stores parsed protocol data keyed by frame number, allowing
8//! subsequent readers to reuse previous parse results.
9
10mod lru;
11
12pub use lru::LruParseCache;
13
14use std::collections::HashMap;
15use std::sync::Arc;
16
17use crate::protocol::{FieldValue, OwnedFieldValue, TunnelType};
18
19/// Owned parse result for a single protocol layer.
20///
21/// This is an owned version of `ParseResult<'a>` that can be stored in the cache.
22/// All field values are owned (no borrowed references).
23#[derive(Clone, Debug)]
24pub struct OwnedParseResult {
25    /// Extracted field values, keyed by field name.
26    /// Field names are always static strings from protocol definitions.
27    pub fields: HashMap<&'static str, OwnedFieldValue>,
28    /// Parse error if partial parsing occurred.
29    pub error: Option<String>,
30    /// Encapsulation depth when this protocol was parsed (0 = outer layer).
31    pub encap_depth: u8,
32    /// Type of the innermost enclosing tunnel (if inside a tunnel).
33    pub tunnel_type: TunnelType,
34    /// Identifier of the innermost enclosing tunnel (VNI, GRE key, TEID, etc.).
35    pub tunnel_id: Option<u64>,
36}
37
38impl OwnedParseResult {
39    /// Create a new owned parse result from a borrowed one.
40    /// Converts all borrowed field values to owned values.
41    pub fn from_borrowed(
42        fields: &smallvec::SmallVec<[(&'static str, FieldValue<'_>); 16]>,
43        error: Option<&String>,
44    ) -> Self {
45        Self {
46            fields: fields.iter().map(|(k, v)| (*k, v.to_owned())).collect(),
47            error: error.cloned(),
48            encap_depth: 0,
49            tunnel_type: TunnelType::None,
50            tunnel_id: None,
51        }
52    }
53
54    /// Create a new owned parse result from a ParseResult with encap context.
55    pub fn from_parse_result(result: &crate::protocol::ParseResult<'_>) -> Self {
56        Self {
57            fields: result
58                .fields
59                .iter()
60                .map(|(k, v)| (*k, v.to_owned()))
61                .collect(),
62            error: result.error.clone(),
63            encap_depth: result.encap_depth,
64            tunnel_type: result.tunnel_type,
65            tunnel_id: result.tunnel_id,
66        }
67    }
68
69    /// Get a field value by name.
70    pub fn get(&self, name: &str) -> Option<&OwnedFieldValue> {
71        self.fields.get(name)
72    }
73}
74
75/// Cached parse result for a single packet.
76///
77/// Contains the parsed protocol data for all protocols found in the packet.
78/// Stored as Arc to enable zero-copy sharing between readers.
79#[derive(Clone, Debug)]
80pub struct CachedParse {
81    /// Frame number this parse result belongs to
82    pub frame_number: u64,
83    /// Parsed protocol results (protocol_name -> OwnedParseResult)
84    /// Protocol name is always a static string from the registry.
85    pub protocols: Vec<(&'static str, OwnedParseResult)>,
86}
87
88impl CachedParse {
89    /// Create a new cached parse from the parse_packet result.
90    pub fn from_parse_results(
91        frame_number: u64,
92        results: &[(&'static str, crate::protocol::ParseResult<'_>)],
93    ) -> Self {
94        let protocols = results
95            .iter()
96            .map(|(name, result)| (*name, OwnedParseResult::from_parse_result(result)))
97            .collect();
98
99        Self {
100            frame_number,
101            protocols,
102        }
103    }
104
105    /// Get the first parse result for a specific protocol.
106    ///
107    /// Note: For tunneled packets, the same protocol may appear multiple times
108    /// at different encapsulation depths. Use `get_all_protocols()` to get all
109    /// occurrences.
110    pub fn get_protocol(&self, name: &str) -> Option<&OwnedParseResult> {
111        self.protocols
112            .iter()
113            .find(|(n, _)| *n == name)
114            .map(|(_, r)| r)
115    }
116
117    /// Get ALL parse results for a specific protocol.
118    ///
119    /// For tunneled packets, the same protocol (e.g., IPv4) may appear at
120    /// multiple encapsulation depths. This method returns all occurrences.
121    pub fn get_all_protocols<'a>(
122        &'a self,
123        name: &'a str,
124    ) -> impl Iterator<Item = &'a OwnedParseResult> + 'a {
125        self.protocols
126            .iter()
127            .filter(move |(n, _)| *n == name)
128            .map(|(_, r)| r)
129    }
130
131    /// Count how many times a protocol appears in the packet.
132    ///
133    /// Returns 0 if the protocol is not present.
134    pub fn count_protocol(&self, name: &str) -> usize {
135        self.protocols.iter().filter(|(n, _)| *n == name).count()
136    }
137
138    /// Check if a specific protocol is present in the cached results.
139    pub fn has_protocol(&self, name: &str) -> bool {
140        self.protocols.iter().any(|(n, _)| *n == name)
141    }
142
143    /// Iterate over all protocol results.
144    pub fn iter(&self) -> impl Iterator<Item = (&'static str, &OwnedParseResult)> {
145        self.protocols.iter().map(|(n, r)| (*n, r))
146    }
147}
148
149/// Cache for parsed packet data.
150///
151/// Implementations must be thread-safe as multiple readers may access
152/// the cache concurrently.
153pub trait ParseCache: Send + Sync {
154    /// Get cached parse result for a frame, if available.
155    fn get(&self, frame_number: u64) -> Option<Arc<CachedParse>>;
156
157    /// Store parse result for a frame.
158    fn put(&self, frame_number: u64, parsed: Arc<CachedParse>);
159
160    /// Get cached result or compute and cache it. Returns (result, was_hit).
161    fn get_or_insert_with(
162        &self,
163        frame_number: u64,
164        f: Box<dyn FnOnce() -> Arc<CachedParse> + '_>,
165    ) -> (Arc<CachedParse>, bool) {
166        // Default implementation: separate get/put (no coordination)
167        if let Some(cached) = self.get(frame_number) {
168            return (cached, true);
169        }
170        let result = f();
171        self.put(frame_number, result.clone());
172        (result, false)
173    }
174
175    /// Hint that a reader has finished with frames up to this number.
176    ///
177    /// Used for eviction decisions. When all active readers have passed
178    /// a frame, it can be safely evicted.
179    fn reader_passed(&self, reader_id: usize, frame_number: u64);
180
181    /// Register a new reader and get its ID.
182    fn register_reader(&self) -> usize;
183
184    /// Unregister a reader (e.g., when stream completes).
185    fn unregister_reader(&self, reader_id: usize);
186
187    /// Get cache statistics (if available).
188    fn stats(&self) -> Option<CacheStats> {
189        None
190    }
191
192    /// Reset statistics counters. Default implementation does nothing.
193    fn reset_stats(&self) {}
194}
195
196/// No-op cache implementation for when caching is disabled.
197///
198/// All operations are no-ops. This is the default for small files
199/// where caching overhead exceeds benefit.
200#[derive(Clone, Debug, Default)]
201pub struct NoCache;
202
203impl ParseCache for NoCache {
204    fn get(&self, _frame_number: u64) -> Option<Arc<CachedParse>> {
205        None
206    }
207
208    fn put(&self, _frame_number: u64, _parsed: Arc<CachedParse>) {
209        // No-op
210    }
211
212    fn reader_passed(&self, _reader_id: usize, _frame_number: u64) {
213        // No-op
214    }
215
216    fn register_reader(&self) -> usize {
217        0
218    }
219
220    fn unregister_reader(&self, _reader_id: usize) {
221        // No-op
222    }
223}
224
225/// Cache statistics for monitoring.
226#[derive(Clone, Debug, Default)]
227pub struct CacheStats {
228    /// Number of cache hits.
229    pub hits: u64,
230    /// Number of cache misses.
231    pub misses: u64,
232    /// Current number of cached entries.
233    pub entries: usize,
234    /// Maximum number of entries allowed.
235    pub max_entries: usize,
236
237    /// Number of entries evicted due to LRU policy.
238    pub evictions_lru: u64,
239    /// Number of entries evicted because all readers passed them.
240    pub evictions_reader: u64,
241    /// Peak number of entries ever held (high watermark).
242    pub peak_entries: usize,
243    /// Number of currently active readers.
244    pub active_readers: usize,
245    /// Estimated memory usage in bytes.
246    pub memory_bytes_estimate: usize,
247}
248
249impl CacheStats {
250    /// Calculate the hit ratio (hits / total accesses).
251    pub fn hit_ratio(&self) -> f64 {
252        let total = self.hits + self.misses;
253        if total == 0 {
254            0.0
255        } else {
256            self.hits as f64 / total as f64
257        }
258    }
259
260    /// Calculate cache utilization (entries / max_entries).
261    pub fn utilization(&self) -> f64 {
262        if self.max_entries == 0 {
263            0.0
264        } else {
265            self.entries as f64 / self.max_entries as f64
266        }
267    }
268
269    /// Total evictions (LRU + reader-based).
270    pub fn total_evictions(&self) -> u64 {
271        self.evictions_lru + self.evictions_reader
272    }
273
274    /// Format statistics as a human-readable string.
275    pub fn format_summary(&self) -> String {
276        let hit_pct = self.hit_ratio() * 100.0;
277        let miss_pct = 100.0 - hit_pct;
278        let util_pct = self.utilization() * 100.0;
279
280        format!(
281            "Cache Statistics:\n\
282             \x20 Hits:        {:>10} ({:.1}%)\n\
283             \x20 Misses:      {:>10} ({:.1}%)\n\
284             \x20 Entries:     {:>10} / {} ({:.1}%)\n\
285             \x20 Peak:        {:>10}\n\
286             \x20 Evictions:   {:>10} (LRU: {}, Reader: {})\n\
287             \x20 Readers:     {:>10}\n\
288             \x20 Memory:      {:>10}",
289            self.hits,
290            hit_pct,
291            self.misses,
292            miss_pct,
293            self.entries,
294            self.max_entries,
295            util_pct,
296            self.peak_entries,
297            self.total_evictions(),
298            self.evictions_lru,
299            self.evictions_reader,
300            self.active_readers,
301            format_bytes(self.memory_bytes_estimate),
302        )
303    }
304}
305
306/// Format bytes as human-readable string.
307fn format_bytes(bytes: usize) -> String {
308    const KB: usize = 1024;
309    const MB: usize = KB * 1024;
310    const GB: usize = MB * 1024;
311
312    if bytes >= GB {
313        format!("{:.2} GB", bytes as f64 / GB as f64)
314    } else if bytes >= MB {
315        format!("{:.2} MB", bytes as f64 / MB as f64)
316    } else if bytes >= KB {
317        format!("{:.2} KB", bytes as f64 / KB as f64)
318    } else {
319        format!("{bytes} B")
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_no_cache() {
329        let cache = NoCache;
330
331        assert!(cache.get(1).is_none());
332
333        let parsed = Arc::new(CachedParse {
334            frame_number: 1,
335            protocols: vec![],
336        });
337        cache.put(1, parsed);
338
339        // Still returns None (no-op cache)
340        assert!(cache.get(1).is_none());
341    }
342
343    #[test]
344    fn test_cached_parse_has_protocol() {
345        let cached = CachedParse {
346            frame_number: 1,
347            protocols: vec![
348                (
349                    "ethernet",
350                    OwnedParseResult {
351                        fields: HashMap::new(),
352                        error: None,
353                        encap_depth: 0,
354                        tunnel_type: TunnelType::None,
355                        tunnel_id: None,
356                    },
357                ),
358                (
359                    "ipv4",
360                    OwnedParseResult {
361                        fields: HashMap::new(),
362                        error: None,
363                        encap_depth: 0,
364                        tunnel_type: TunnelType::None,
365                        tunnel_id: None,
366                    },
367                ),
368            ],
369        };
370
371        assert!(cached.has_protocol("ethernet"));
372        assert!(cached.has_protocol("ipv4"));
373        assert!(!cached.has_protocol("tcp"));
374    }
375
376    #[test]
377    fn test_cached_parse_get_all_protocols() {
378        // Simulates a VXLAN packet with outer and inner IPv4
379        let cached = CachedParse {
380            frame_number: 1,
381            protocols: vec![
382                (
383                    "ethernet",
384                    OwnedParseResult {
385                        fields: HashMap::new(),
386                        error: None,
387                        encap_depth: 0,
388                        tunnel_type: TunnelType::None,
389                        tunnel_id: None,
390                    },
391                ),
392                (
393                    "ipv4",
394                    OwnedParseResult {
395                        fields: {
396                            let mut f = HashMap::new();
397                            f.insert(
398                                "src_ip",
399                                OwnedFieldValue::OwnedString(compact_str::CompactString::new(
400                                    "10.0.0.1",
401                                )),
402                            );
403                            f
404                        },
405                        error: None,
406                        encap_depth: 0,
407                        tunnel_type: TunnelType::None,
408                        tunnel_id: None,
409                    },
410                ),
411                (
412                    "vxlan",
413                    OwnedParseResult {
414                        fields: {
415                            let mut f = HashMap::new();
416                            f.insert("vni", OwnedFieldValue::UInt32(100));
417                            f
418                        },
419                        error: None,
420                        encap_depth: 0,
421                        tunnel_type: TunnelType::None,
422                        tunnel_id: None,
423                    },
424                ),
425                (
426                    "ethernet",
427                    OwnedParseResult {
428                        fields: HashMap::new(),
429                        error: None,
430                        encap_depth: 1,
431                        tunnel_type: TunnelType::Vxlan,
432                        tunnel_id: Some(100),
433                    },
434                ),
435                (
436                    "ipv4",
437                    OwnedParseResult {
438                        fields: {
439                            let mut f = HashMap::new();
440                            f.insert(
441                                "src_ip",
442                                OwnedFieldValue::OwnedString(compact_str::CompactString::new(
443                                    "192.168.1.1",
444                                )),
445                            );
446                            f
447                        },
448                        error: None,
449                        encap_depth: 1,
450                        tunnel_type: TunnelType::Vxlan,
451                        tunnel_id: Some(100),
452                    },
453                ),
454            ],
455        };
456
457        // get_protocol returns first occurrence
458        let first_ipv4 = cached.get_protocol("ipv4").unwrap();
459        assert_eq!(first_ipv4.encap_depth, 0);
460
461        // get_all_protocols returns all occurrences
462        let all_ipv4: Vec<_> = cached.get_all_protocols("ipv4").collect();
463        assert_eq!(all_ipv4.len(), 2);
464        assert_eq!(all_ipv4[0].encap_depth, 0);
465        assert_eq!(all_ipv4[1].encap_depth, 1);
466        assert_eq!(all_ipv4[1].tunnel_type, TunnelType::Vxlan);
467        assert_eq!(all_ipv4[1].tunnel_id, Some(100));
468
469        // count_protocol
470        assert_eq!(cached.count_protocol("ethernet"), 2);
471        assert_eq!(cached.count_protocol("ipv4"), 2);
472        assert_eq!(cached.count_protocol("vxlan"), 1);
473        assert_eq!(cached.count_protocol("tcp"), 0);
474    }
475
476    #[test]
477    fn test_cache_stats_hit_ratio() {
478        let stats = CacheStats {
479            hits: 75,
480            misses: 25,
481            entries: 100,
482            max_entries: 1000,
483            ..Default::default()
484        };
485
486        assert!((stats.hit_ratio() - 0.75).abs() < 0.001);
487    }
488
489    #[test]
490    fn test_cache_stats_empty() {
491        let stats = CacheStats::default();
492        assert_eq!(stats.hit_ratio(), 0.0);
493    }
494
495    #[test]
496    fn test_owned_parse_result_with_fields() {
497        use std::net::{IpAddr, Ipv4Addr};
498
499        let mut fields = HashMap::new();
500        fields.insert(
501            "src_ip",
502            OwnedFieldValue::IpAddr(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))),
503        );
504        fields.insert("dst_port", OwnedFieldValue::UInt16(443));
505        fields.insert(
506            "payload",
507            OwnedFieldValue::OwnedBytes(vec![0x48, 0x65, 0x6c, 0x6c, 0x6f]),
508        );
509        fields.insert("flags", OwnedFieldValue::UInt8(0x18));
510        fields.insert("is_syn", OwnedFieldValue::Bool(false));
511
512        let result = OwnedParseResult {
513            fields,
514            error: None,
515            encap_depth: 0,
516            tunnel_type: TunnelType::None,
517            tunnel_id: None,
518        };
519
520        // Test get method
521        assert!(result.get("src_ip").is_some());
522        assert!(result.get("dst_port").is_some());
523        assert!(result.get("nonexistent").is_none());
524
525        // Verify field values
526        match result.get("dst_port") {
527            Some(OwnedFieldValue::UInt16(port)) => assert_eq!(*port, 443),
528            _ => panic!("Expected UInt16 for dst_port"),
529        }
530    }
531
532    #[test]
533    fn test_owned_parse_result_with_error() {
534        let mut fields = HashMap::new();
535        fields.insert("partial_field", OwnedFieldValue::UInt32(42));
536
537        let result = OwnedParseResult {
538            fields,
539            error: Some("Truncated packet: expected 20 bytes, got 12".to_string()),
540            encap_depth: 0,
541            tunnel_type: TunnelType::None,
542            tunnel_id: None,
543        };
544
545        assert!(result.error.is_some());
546        assert!(result.error.as_ref().unwrap().contains("Truncated"));
547        // Partial fields should still be accessible
548        assert!(result.get("partial_field").is_some());
549    }
550
551    #[test]
552    fn test_owned_parse_result_from_borrowed() {
553        use crate::protocol::FieldValue;
554        use smallvec::SmallVec;
555
556        let mut borrowed_fields: SmallVec<[(&'static str, FieldValue); 16]> = SmallVec::new();
557        borrowed_fields.push((
558            "src_mac",
559            FieldValue::MacAddr([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]),
560        ));
561        borrowed_fields.push(("ethertype", FieldValue::UInt16(0x0800)));
562
563        let error_msg = "Some error".to_string();
564        let owned = OwnedParseResult::from_borrowed(&borrowed_fields, Some(&error_msg));
565
566        // Keys should be converted to owned strings
567        assert!(owned.get("src_mac").is_some());
568        assert!(owned.get("ethertype").is_some());
569        assert_eq!(owned.error, Some("Some error".to_string()));
570    }
571
572    #[test]
573    fn test_cached_parse_get_protocol() {
574        let cached = CachedParse {
575            frame_number: 42,
576            protocols: vec![
577                (
578                    "ethernet",
579                    OwnedParseResult {
580                        fields: {
581                            let mut f = HashMap::new();
582                            f.insert("src_mac", OwnedFieldValue::MacAddr([0x00; 6]));
583                            f
584                        },
585                        error: None,
586                        encap_depth: 0,
587                        tunnel_type: TunnelType::None,
588                        tunnel_id: None,
589                    },
590                ),
591                (
592                    "ipv4",
593                    OwnedParseResult {
594                        fields: {
595                            let mut f = HashMap::new();
596                            f.insert("ttl", OwnedFieldValue::UInt8(64));
597                            f
598                        },
599                        error: None,
600                        encap_depth: 0,
601                        tunnel_type: TunnelType::None,
602                        tunnel_id: None,
603                    },
604                ),
605            ],
606        };
607
608        // Get existing protocol
609        let eth = cached.get_protocol("ethernet");
610        assert!(eth.is_some());
611        assert!(eth.unwrap().get("src_mac").is_some());
612
613        let ipv4 = cached.get_protocol("ipv4");
614        assert!(ipv4.is_some());
615        match ipv4.unwrap().get("ttl") {
616            Some(OwnedFieldValue::UInt8(ttl)) => assert_eq!(*ttl, 64),
617            _ => panic!("Expected TTL field"),
618        }
619
620        // Get non-existent protocol
621        assert!(cached.get_protocol("tcp").is_none());
622    }
623
624    #[test]
625    fn test_cached_parse_iter() {
626        let cached = CachedParse {
627            frame_number: 1,
628            protocols: vec![
629                (
630                    "ethernet",
631                    OwnedParseResult {
632                        fields: HashMap::new(),
633                        error: None,
634                        encap_depth: 0,
635                        tunnel_type: TunnelType::None,
636                        tunnel_id: None,
637                    },
638                ),
639                (
640                    "ipv4",
641                    OwnedParseResult {
642                        fields: HashMap::new(),
643                        error: None,
644                        encap_depth: 0,
645                        tunnel_type: TunnelType::None,
646                        tunnel_id: None,
647                    },
648                ),
649                (
650                    "tcp",
651                    OwnedParseResult {
652                        fields: HashMap::new(),
653                        error: None,
654                        encap_depth: 0,
655                        tunnel_type: TunnelType::None,
656                        tunnel_id: None,
657                    },
658                ),
659            ],
660        };
661
662        let protocol_names: Vec<&str> = cached.iter().map(|(name, _)| name).collect();
663        assert_eq!(protocol_names, vec!["ethernet", "ipv4", "tcp"]);
664    }
665
666    #[test]
667    fn test_cached_parse_empty_protocols() {
668        let cached = CachedParse {
669            frame_number: 100,
670            protocols: vec![],
671        };
672
673        assert!(!cached.has_protocol("ethernet"));
674        assert!(cached.get_protocol("anything").is_none());
675        assert_eq!(cached.iter().count(), 0);
676    }
677
678    #[test]
679    fn test_no_cache_register_reader() {
680        let cache = NoCache;
681
682        // All readers get ID 0 (no-op implementation)
683        let r1 = cache.register_reader();
684        let r2 = cache.register_reader();
685        assert_eq!(r1, 0);
686        assert_eq!(r2, 0);
687
688        // Unregister is a no-op
689        cache.unregister_reader(r1);
690        cache.reader_passed(r1, 100);
691
692        // Stats always None
693        assert!(cache.stats().is_none());
694    }
695
696    #[test]
697    fn test_cache_stats_various_ratios() {
698        // 50% hit ratio
699        let stats_50 = CacheStats {
700            hits: 50,
701            misses: 50,
702            entries: 100,
703            max_entries: 1000,
704            ..Default::default()
705        };
706        assert!((stats_50.hit_ratio() - 0.5).abs() < 0.001);
707
708        // 100% hit ratio
709        let stats_100 = CacheStats {
710            hits: 100,
711            misses: 0,
712            entries: 100,
713            max_entries: 1000,
714            ..Default::default()
715        };
716        assert!((stats_100.hit_ratio() - 1.0).abs() < 0.001);
717
718        // 0% hit ratio (all misses)
719        let stats_0 = CacheStats {
720            hits: 0,
721            misses: 100,
722            entries: 0,
723            max_entries: 1000,
724            ..Default::default()
725        };
726        assert!((stats_0.hit_ratio() - 0.0).abs() < 0.001);
727    }
728
729    #[test]
730    fn test_cache_stats_utilization() {
731        // 0% utilization
732        let stats_0 = CacheStats {
733            entries: 0,
734            max_entries: 100,
735            ..Default::default()
736        };
737        assert!((stats_0.utilization() - 0.0).abs() < 0.001);
738
739        // 50% utilization
740        let stats_50 = CacheStats {
741            entries: 50,
742            max_entries: 100,
743            ..Default::default()
744        };
745        assert!((stats_50.utilization() - 0.5).abs() < 0.001);
746
747        // 100% utilization
748        let stats_100 = CacheStats {
749            entries: 100,
750            max_entries: 100,
751            ..Default::default()
752        };
753        assert!((stats_100.utilization() - 1.0).abs() < 0.001);
754
755        // Edge case: max_entries = 0
756        let stats_zero_max = CacheStats {
757            entries: 0,
758            max_entries: 0,
759            ..Default::default()
760        };
761        assert!((stats_zero_max.utilization() - 0.0).abs() < 0.001);
762    }
763
764    #[test]
765    fn test_cache_stats_total_evictions() {
766        let stats = CacheStats {
767            evictions_lru: 50,
768            evictions_reader: 30,
769            ..Default::default()
770        };
771        assert_eq!(stats.total_evictions(), 80);
772    }
773
774    #[test]
775    fn test_cache_stats_format_summary() {
776        let stats = CacheStats {
777            hits: 75,
778            misses: 25,
779            entries: 500,
780            max_entries: 1000,
781            evictions_lru: 100,
782            evictions_reader: 50,
783            peak_entries: 750,
784            active_readers: 3,
785            memory_bytes_estimate: 512000, // 500 KB
786        };
787
788        let summary = stats.format_summary();
789
790        // Check that key information is present
791        assert!(summary.contains("Hits:"));
792        assert!(summary.contains("75"));
793        assert!(summary.contains("Misses:"));
794        assert!(summary.contains("25"));
795        assert!(summary.contains("Entries:"));
796        assert!(summary.contains("500"));
797        assert!(summary.contains("1000"));
798        assert!(summary.contains("Peak:"));
799        assert!(summary.contains("750"));
800        assert!(summary.contains("Evictions:"));
801        assert!(summary.contains("150")); // total evictions
802        assert!(summary.contains("LRU: 100"));
803        assert!(summary.contains("Reader: 50"));
804        assert!(summary.contains("Readers:"));
805        assert!(summary.contains("3"));
806        assert!(summary.contains("Memory:"));
807        assert!(summary.contains("KB"));
808    }
809
810    #[test]
811    fn test_format_bytes() {
812        // Bytes
813        assert_eq!(format_bytes(0), "0 B");
814        assert_eq!(format_bytes(500), "500 B");
815        assert_eq!(format_bytes(1023), "1023 B");
816
817        // KB
818        assert_eq!(format_bytes(1024), "1.00 KB");
819        assert_eq!(format_bytes(1536), "1.50 KB"); // 1.5 KB
820        assert_eq!(format_bytes(10240), "10.00 KB");
821
822        // MB
823        assert_eq!(format_bytes(1048576), "1.00 MB"); // 1 MB
824        assert_eq!(format_bytes(5242880), "5.00 MB"); // 5 MB
825        assert_eq!(format_bytes(10485760), "10.00 MB"); // 10 MB
826
827        // GB
828        assert_eq!(format_bytes(1073741824), "1.00 GB"); // 1 GB
829        assert_eq!(format_bytes(2147483648), "2.00 GB"); // 2 GB
830    }
831}