Skip to main content

oxihuman_core/
asset_cache.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! LRU asset cache with size limits and eviction.
5
6#[allow(dead_code)]
7pub struct CacheEntry {
8    pub key: String,
9    pub data: Vec<u8>,
10    pub size_bytes: usize,
11    pub access_count: u64,
12    pub last_access: u64,
13}
14
15#[allow(dead_code)]
16pub struct AssetCache {
17    pub entries: Vec<CacheEntry>,
18    pub max_bytes: usize,
19    pub total_bytes: usize,
20    pub tick: u64,
21    pub hits: u64,
22    pub misses: u64,
23}
24
25#[allow(dead_code)]
26pub fn new_cache(max_bytes: usize) -> AssetCache {
27    AssetCache {
28        entries: Vec::new(),
29        max_bytes,
30        total_bytes: 0,
31        tick: 0,
32        hits: 0,
33        misses: 0,
34    }
35}
36
37#[allow(dead_code)]
38pub fn cache_insert(cache: &mut AssetCache, key: &str, data: Vec<u8>) {
39    // Remove existing entry with same key
40    if let Some(pos) = cache.entries.iter().position(|e| e.key == key) {
41        let old_size = cache.entries[pos].size_bytes;
42        cache.entries.remove(pos);
43        cache.total_bytes -= old_size;
44    }
45    let size = data.len();
46    cache.tick += 1;
47    let entry = CacheEntry {
48        key: key.to_string(),
49        data,
50        size_bytes: size,
51        access_count: 0,
52        last_access: cache.tick,
53    };
54    cache.entries.push(entry);
55    cache.total_bytes += size;
56    evict_until_fits(cache);
57}
58
59#[allow(dead_code)]
60pub fn cache_get<'a>(cache: &'a mut AssetCache, key: &str) -> Option<&'a [u8]> {
61    cache.tick += 1;
62    let tick = cache.tick;
63    if let Some(pos) = cache.entries.iter().position(|e| e.key == key) {
64        cache.entries[pos].access_count += 1;
65        cache.entries[pos].last_access = tick;
66        cache.hits += 1;
67        Some(&cache.entries[pos].data)
68    } else {
69        cache.misses += 1;
70        None
71    }
72}
73
74#[allow(dead_code)]
75pub fn cache_remove(cache: &mut AssetCache, key: &str) -> bool {
76    if let Some(pos) = cache.entries.iter().position(|e| e.key == key) {
77        let size = cache.entries[pos].size_bytes;
78        cache.entries.remove(pos);
79        cache.total_bytes -= size;
80        true
81    } else {
82        false
83    }
84}
85
86#[allow(dead_code)]
87pub fn cache_contains(cache: &AssetCache, key: &str) -> bool {
88    cache.entries.iter().any(|e| e.key == key)
89}
90
91#[allow(dead_code)]
92pub fn evict_lru(cache: &mut AssetCache) {
93    if cache.entries.is_empty() {
94        return;
95    }
96    let lru_pos = cache
97        .entries
98        .iter()
99        .enumerate()
100        .min_by_key(|(_, e)| e.last_access)
101        .map(|(i, _)| i);
102    let Some(lru_pos) = lru_pos else { return };
103    let size = cache.entries[lru_pos].size_bytes;
104    cache.entries.remove(lru_pos);
105    cache.total_bytes -= size;
106}
107
108#[allow(dead_code)]
109pub fn evict_until_fits(cache: &mut AssetCache) {
110    while cache.total_bytes > cache.max_bytes && !cache.entries.is_empty() {
111        evict_lru(cache);
112    }
113}
114
115#[allow(dead_code)]
116pub fn cache_size(cache: &AssetCache) -> usize {
117    cache.total_bytes
118}
119
120#[allow(dead_code)]
121pub fn cache_count(cache: &AssetCache) -> usize {
122    cache.entries.len()
123}
124
125#[allow(dead_code)]
126pub fn cache_hit_rate(cache: &AssetCache) -> f32 {
127    let total = cache.hits + cache.misses;
128    if total == 0 {
129        return 0.0;
130    }
131    cache.hits as f32 / total as f32
132}
133
134#[allow(dead_code)]
135pub fn cache_clear(cache: &mut AssetCache) {
136    cache.entries.clear();
137    cache.total_bytes = 0;
138}
139
140#[allow(dead_code)]
141pub fn most_accessed(cache: &AssetCache) -> Option<&CacheEntry> {
142    cache.entries.iter().max_by_key(|e| e.access_count)
143}
144
145#[allow(dead_code)]
146pub fn cache_stats(cache: &AssetCache) -> (usize, usize, f32) {
147    (cache_count(cache), cache_size(cache), cache_hit_rate(cache))
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_insert_and_get() {
156        let mut cache = new_cache(1024);
157        cache_insert(&mut cache, "foo", vec![1, 2, 3]);
158        let data = cache_get(&mut cache, "foo");
159        assert_eq!(data, Some([1u8, 2, 3].as_slice()));
160    }
161
162    #[test]
163    fn test_get_miss_returns_none() {
164        let mut cache = new_cache(1024);
165        let data = cache_get(&mut cache, "missing");
166        assert!(data.is_none());
167    }
168
169    #[test]
170    fn test_eviction_when_over_max() {
171        let mut cache = new_cache(10);
172        cache_insert(&mut cache, "a", vec![0u8; 6]);
173        cache_insert(&mut cache, "b", vec![0u8; 6]);
174        // "a" should be evicted as LRU
175        assert!(cache.total_bytes <= 10);
176    }
177
178    #[test]
179    fn test_hit_rate_calculation() {
180        let mut cache = new_cache(1024);
181        cache_insert(&mut cache, "x", vec![1]);
182        cache_get(&mut cache, "x");
183        cache_get(&mut cache, "x");
184        cache_get(&mut cache, "missing");
185        let rate = cache_hit_rate(&cache);
186        assert!((rate - 2.0 / 3.0).abs() < 1e-4);
187    }
188
189    #[test]
190    fn test_hit_rate_no_access() {
191        let cache = new_cache(1024);
192        assert_eq!(cache_hit_rate(&cache), 0.0);
193    }
194
195    #[test]
196    fn test_contains() {
197        let mut cache = new_cache(1024);
198        cache_insert(&mut cache, "k", vec![9]);
199        assert!(cache_contains(&cache, "k"));
200        assert!(!cache_contains(&cache, "nope"));
201    }
202
203    #[test]
204    fn test_remove() {
205        let mut cache = new_cache(1024);
206        cache_insert(&mut cache, "del", vec![1, 2]);
207        assert!(cache_remove(&mut cache, "del"));
208        assert!(!cache_contains(&cache, "del"));
209        assert!(!cache_remove(&mut cache, "del"));
210    }
211
212    #[test]
213    fn test_clear() {
214        let mut cache = new_cache(1024);
215        cache_insert(&mut cache, "a", vec![1]);
216        cache_insert(&mut cache, "b", vec![2]);
217        cache_clear(&mut cache);
218        assert_eq!(cache_count(&cache), 0);
219        assert_eq!(cache_size(&cache), 0);
220    }
221
222    #[test]
223    fn test_most_accessed() {
224        let mut cache = new_cache(1024);
225        cache_insert(&mut cache, "a", vec![1]);
226        cache_insert(&mut cache, "b", vec![2]);
227        cache_get(&mut cache, "b");
228        cache_get(&mut cache, "b");
229        cache_get(&mut cache, "a");
230        let top = most_accessed(&cache);
231        assert!(top.is_some());
232        assert_eq!(top.expect("should succeed").key, "b");
233    }
234
235    #[test]
236    fn test_most_accessed_empty() {
237        let cache = new_cache(1024);
238        assert!(most_accessed(&cache).is_none());
239    }
240
241    #[test]
242    fn test_lru_eviction_order() {
243        let mut cache = new_cache(20);
244        cache_insert(&mut cache, "first", vec![0u8; 8]);
245        cache_insert(&mut cache, "second", vec![0u8; 8]);
246        // Access "first" to make it more recent
247        cache_get(&mut cache, "first");
248        // Insert large entry that forces eviction
249        cache_insert(&mut cache, "third", vec![0u8; 8]);
250        // "second" should be evicted as LRU (last_access is oldest)
251        assert!(!cache_contains(&cache, "second"));
252        assert!(cache_contains(&cache, "first"));
253    }
254
255    #[test]
256    fn test_cache_size_tracking() {
257        let mut cache = new_cache(1024);
258        cache_insert(&mut cache, "a", vec![1, 2, 3]);
259        cache_insert(&mut cache, "b", vec![4, 5]);
260        assert_eq!(cache_size(&cache), 5);
261    }
262
263    #[test]
264    fn test_cache_count() {
265        let mut cache = new_cache(1024);
266        assert_eq!(cache_count(&cache), 0);
267        cache_insert(&mut cache, "a", vec![1]);
268        assert_eq!(cache_count(&cache), 1);
269        cache_insert(&mut cache, "b", vec![2]);
270        assert_eq!(cache_count(&cache), 2);
271    }
272
273    #[test]
274    fn test_cache_stats() {
275        let mut cache = new_cache(1024);
276        cache_insert(&mut cache, "x", vec![9]);
277        cache_get(&mut cache, "x");
278        let (count, size, rate) = cache_stats(&cache);
279        assert_eq!(count, 1);
280        assert_eq!(size, 1);
281        assert!((rate - 1.0).abs() < 1e-4);
282    }
283
284    #[test]
285    fn test_insert_overwrite_same_key() {
286        let mut cache = new_cache(1024);
287        cache_insert(&mut cache, "k", vec![1, 2, 3]);
288        cache_insert(&mut cache, "k", vec![9]);
289        assert_eq!(cache_count(&cache), 1);
290        assert_eq!(cache_size(&cache), 1);
291    }
292
293    #[test]
294    fn test_evict_lru_removes_oldest() {
295        let mut cache = new_cache(1024);
296        cache_insert(&mut cache, "old", vec![1]);
297        cache_insert(&mut cache, "new", vec![2]);
298        evict_lru(&mut cache);
299        assert!(!cache_contains(&cache, "old"));
300        assert!(cache_contains(&cache, "new"));
301    }
302}