Skip to main content

oximedia_cache/
stats.rs

1//! Cache statistics: hit-rate tracking and JSON export.
2//!
3//! [`CacheStats`] records hits and misses in an atomic-friendly manner and
4//! derives the hit rate on demand.  A JSON snapshot can be exported via
5//! `to_json()` without any external serialisation dependency.
6//!
7//! # Example
8//!
9//! ```
10//! use oximedia_cache::stats::CacheStats;
11//!
12//! let mut stats = CacheStats::new();
13//! stats.record_hit();
14//! stats.record_hit();
15//! stats.record_miss();
16//! assert!((stats.hit_rate() - 2.0 / 3.0).abs() < 1e-6);
17//! let json = stats.to_json();
18//! assert!(json.contains("\"hits\":2"));
19//! ```
20
21#![allow(dead_code)]
22
23// ---------------------------------------------------------------------------
24// CacheStats
25// ---------------------------------------------------------------------------
26
27/// Accumulates cache hit/miss counters and produces derived statistics.
28#[derive(Debug, Clone, Default)]
29pub struct CacheStats {
30    hits: u64,
31    misses: u64,
32    evictions: u64,
33}
34
35impl CacheStats {
36    /// Create a new `CacheStats` with all counters at zero.
37    #[must_use]
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Record one cache hit.
43    pub fn record_hit(&mut self) {
44        self.hits = self.hits.saturating_add(1);
45    }
46
47    /// Record one cache miss.
48    pub fn record_miss(&mut self) {
49        self.misses = self.misses.saturating_add(1);
50    }
51
52    /// Record one eviction.
53    pub fn record_eviction(&mut self) {
54        self.evictions = self.evictions.saturating_add(1);
55    }
56
57    /// Total number of hits.
58    #[must_use]
59    pub fn hits(&self) -> u64 {
60        self.hits
61    }
62
63    /// Total number of misses.
64    #[must_use]
65    pub fn misses(&self) -> u64 {
66        self.misses
67    }
68
69    /// Total number of evictions.
70    #[must_use]
71    pub fn evictions(&self) -> u64 {
72        self.evictions
73    }
74
75    /// Total number of lookups (hits + misses).
76    #[must_use]
77    pub fn total_lookups(&self) -> u64 {
78        self.hits.saturating_add(self.misses)
79    }
80
81    /// Hit rate as a fraction in `[0.0, 1.0]`.
82    ///
83    /// Returns `0.0` when no lookups have occurred.
84    #[must_use]
85    pub fn hit_rate(&self) -> f32 {
86        let total = self.total_lookups();
87        if total == 0 {
88            return 0.0;
89        }
90        self.hits as f32 / total as f32
91    }
92
93    /// Miss rate as a fraction in `[0.0, 1.0]`.
94    #[must_use]
95    pub fn miss_rate(&self) -> f32 {
96        1.0 - self.hit_rate()
97    }
98
99    /// Export statistics as a compact JSON string.
100    ///
101    /// # Format
102    ///
103    /// ```json
104    /// {"hits":10,"misses":5,"evictions":2,"hit_rate":0.666667}
105    /// ```
106    #[must_use]
107    pub fn to_json(&self) -> String {
108        format!(
109            "{{\"hits\":{},\"misses\":{},\"evictions\":{},\"hit_rate\":{:.6}}}",
110            self.hits,
111            self.misses,
112            self.evictions,
113            self.hit_rate()
114        )
115    }
116
117    /// Reset all counters to zero.
118    pub fn reset(&mut self) {
119        self.hits = 0;
120        self.misses = 0;
121        self.evictions = 0;
122    }
123}
124
125// ---------------------------------------------------------------------------
126// Tests
127// ---------------------------------------------------------------------------
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    // ── new ──────────────────────────────────────────────────────────────────
134
135    #[test]
136    fn test_new_starts_at_zero() {
137        let s = CacheStats::new();
138        assert_eq!(s.hits(), 0);
139        assert_eq!(s.misses(), 0);
140        assert_eq!(s.evictions(), 0);
141    }
142
143    // ── record_hit / record_miss ──────────────────────────────────────────────
144
145    #[test]
146    fn test_record_hit_increments() {
147        let mut s = CacheStats::new();
148        s.record_hit();
149        s.record_hit();
150        assert_eq!(s.hits(), 2);
151    }
152
153    #[test]
154    fn test_record_miss_increments() {
155        let mut s = CacheStats::new();
156        s.record_miss();
157        assert_eq!(s.misses(), 1);
158    }
159
160    #[test]
161    fn test_record_eviction_increments() {
162        let mut s = CacheStats::new();
163        s.record_eviction();
164        s.record_eviction();
165        assert_eq!(s.evictions(), 2);
166    }
167
168    // ── hit_rate ─────────────────────────────────────────────────────────────
169
170    #[test]
171    fn test_hit_rate_zero_when_no_lookups() {
172        let s = CacheStats::new();
173        assert!((s.hit_rate() - 0.0).abs() < 1e-6);
174    }
175
176    #[test]
177    fn test_hit_rate_one_hundred_percent() {
178        let mut s = CacheStats::new();
179        s.record_hit();
180        assert!((s.hit_rate() - 1.0).abs() < 1e-6);
181    }
182
183    #[test]
184    fn test_hit_rate_two_thirds() {
185        let mut s = CacheStats::new();
186        s.record_hit();
187        s.record_hit();
188        s.record_miss();
189        assert!((s.hit_rate() - 2.0 / 3.0).abs() < 1e-5);
190    }
191
192    #[test]
193    fn test_miss_rate_complement() {
194        let mut s = CacheStats::new();
195        s.record_hit();
196        s.record_miss();
197        let hit = s.hit_rate();
198        let miss = s.miss_rate();
199        assert!((hit + miss - 1.0).abs() < 1e-6);
200    }
201
202    // ── total_lookups ─────────────────────────────────────────────────────────
203
204    #[test]
205    fn test_total_lookups_sums_hits_and_misses() {
206        let mut s = CacheStats::new();
207        s.record_hit();
208        s.record_hit();
209        s.record_miss();
210        assert_eq!(s.total_lookups(), 3);
211    }
212
213    // ── to_json ───────────────────────────────────────────────────────────────
214
215    #[test]
216    fn test_to_json_contains_hits() {
217        let mut s = CacheStats::new();
218        s.record_hit();
219        s.record_hit();
220        let json = s.to_json();
221        assert!(json.contains("\"hits\":2"), "JSON: {json}");
222    }
223
224    #[test]
225    fn test_to_json_contains_misses() {
226        let mut s = CacheStats::new();
227        s.record_miss();
228        let json = s.to_json();
229        assert!(json.contains("\"misses\":1"), "JSON: {json}");
230    }
231
232    #[test]
233    fn test_to_json_contains_evictions() {
234        let mut s = CacheStats::new();
235        s.record_eviction();
236        let json = s.to_json();
237        assert!(json.contains("\"evictions\":1"), "JSON: {json}");
238    }
239
240    #[test]
241    fn test_to_json_contains_hit_rate() {
242        let mut s = CacheStats::new();
243        s.record_hit();
244        s.record_miss();
245        let json = s.to_json();
246        assert!(json.contains("\"hit_rate\":"), "JSON: {json}");
247    }
248
249    #[test]
250    fn test_to_json_is_valid_braces() {
251        let s = CacheStats::new();
252        let json = s.to_json();
253        assert!(json.starts_with('{'));
254        assert!(json.ends_with('}'));
255    }
256
257    // ── reset ────────────────────────────────────────────────────────────────
258
259    #[test]
260    fn test_reset_clears_all_counters() {
261        let mut s = CacheStats::new();
262        s.record_hit();
263        s.record_miss();
264        s.record_eviction();
265        s.reset();
266        assert_eq!(s.hits(), 0);
267        assert_eq!(s.misses(), 0);
268        assert_eq!(s.evictions(), 0);
269    }
270}