#![allow(dead_code)]
#![allow(clippy::cast_precision_loss)]
use std::collections::HashMap;
use std::io::{self, BufReader, BufWriter};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedEntry {
pub path: String,
pub blake3_hex: String,
pub phash: u64,
pub thumbnail: Option<Vec<u8>>,
pub modified_secs: u64,
}
impl CachedEntry {
#[must_use]
pub fn thumbnail_valid(&self) -> bool {
self.thumbnail
.as_ref()
.map(|t| t.len() == 64)
.unwrap_or(true) }
}
#[derive(Debug, Clone)]
pub struct PersistentFingerprintCache {
cache_path: PathBuf,
entries: HashMap<String, CachedEntry>,
hits: u64,
misses: u64,
}
impl PersistentFingerprintCache {
#[must_use]
pub fn new(cache_path: PathBuf) -> Self {
Self {
cache_path,
entries: HashMap::new(),
hits: 0,
misses: 0,
}
}
pub fn load(cache_path: PathBuf) -> io::Result<Self> {
if !cache_path.exists() {
return Ok(Self::new(cache_path));
}
let file = std::fs::File::open(&cache_path)?;
let reader = BufReader::new(file);
let entries: HashMap<String, CachedEntry> =
serde_json::from_reader(reader).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("cache parse error: {e}"),
)
})?;
Ok(Self {
cache_path,
entries,
hits: 0,
misses: 0,
})
}
pub fn save(&self) -> io::Result<()> {
if let Some(parent) = self.cache_path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp_path = self.cache_path.with_extension("tmp");
{
let file = std::fs::File::create(&tmp_path)?;
let writer = BufWriter::new(file);
serde_json::to_writer(writer, &self.entries).map_err(|e| {
io::Error::new(io::ErrorKind::Other, format!("cache write error: {e}"))
})?;
}
std::fs::rename(&tmp_path, &self.cache_path)?;
Ok(())
}
pub fn insert(&mut self, entry: CachedEntry) {
self.entries.insert(entry.path.clone(), entry);
}
pub fn remove(&mut self, path: &str) -> Option<CachedEntry> {
self.entries.remove(path)
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn get(&self, path: &str) -> Option<&CachedEntry> {
self.entries.get(path)
}
pub fn get_valid(&mut self, path: &str) -> Option<&CachedEntry> {
let entry = match self.entries.get(path) {
Some(e) => e,
None => {
self.misses += 1;
return None;
}
};
match compute_blake3_hex(Path::new(path)) {
Ok(current_hex) => {
if current_hex == entry.blake3_hex {
self.hits += 1;
self.entries.get(path)
} else {
self.misses += 1;
self.entries.remove(path);
None
}
}
Err(_) => {
self.misses += 1;
None
}
}
}
#[must_use]
pub fn hits(&self) -> u64 {
self.hits
}
#[must_use]
pub fn misses(&self) -> u64 {
self.misses
}
#[must_use]
pub fn hit_rate(&self) -> f64 {
let total = self.hits + self.misses;
if total == 0 {
return 0.0;
}
self.hits as f64 / total as f64
}
pub fn reset_stats(&mut self) {
self.hits = 0;
self.misses = 0;
}
pub fn evict_missing(&mut self) -> usize {
let before = self.entries.len();
self.entries
.retain(|path, _| Path::new(path).exists());
before - self.entries.len()
}
pub fn evict_stale(&mut self) -> usize {
let paths: Vec<String> = self.entries.keys().cloned().collect();
let mut evicted = 0;
for path in paths {
let stale = if let Some(entry) = self.entries.get(&path) {
compute_blake3_hex(Path::new(&path))
.map(|h| h != entry.blake3_hex)
.unwrap_or(true) } else {
false
};
if stale {
self.entries.remove(&path);
evicted += 1;
}
}
evicted
}
pub fn merge_from(&mut self, other: &Self) {
for (path, entry) in &other.entries {
self.entries.insert(path.clone(), entry.clone());
}
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &CachedEntry)> {
self.entries.iter()
}
}
fn compute_blake3_hex(path: &Path) -> io::Result<String> {
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut hasher = blake3::Hasher::new();
let mut buf = vec![0u8; 65_536];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hasher.finalize().to_hex().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn tmp_cache_path(name: &str) -> PathBuf {
std::env::temp_dir()
.join("oximedia_persistent_cache_tests")
.join(name)
}
fn sample_entry(path: &str) -> CachedEntry {
CachedEntry {
path: path.to_string(),
blake3_hex: "0".repeat(64),
phash: 0xDEAD_BEEF_1234_5678,
thumbnail: None,
modified_secs: 1_700_000_000,
}
}
#[test]
fn test_new_cache_is_empty() {
let cache = PersistentFingerprintCache::new(tmp_cache_path("new_empty.json"));
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
}
#[test]
fn test_insert_and_get() {
let mut cache = PersistentFingerprintCache::new(tmp_cache_path("insert.json"));
cache.insert(sample_entry("/media/a.mp4"));
let e = cache.get("/media/a.mp4");
assert!(e.is_some());
assert_eq!(e.unwrap().phash, 0xDEAD_BEEF_1234_5678);
}
#[test]
fn test_remove() {
let mut cache = PersistentFingerprintCache::new(tmp_cache_path("remove.json"));
cache.insert(sample_entry("/media/b.mp4"));
assert!(cache.remove("/media/b.mp4").is_some());
assert!(cache.get("/media/b.mp4").is_none());
}
#[test]
fn test_save_and_load_roundtrip() {
let path = tmp_cache_path("roundtrip.json");
std::fs::create_dir_all(path.parent().unwrap()).ok();
let mut cache = PersistentFingerprintCache::new(path.clone());
cache.insert(sample_entry("/media/c.mp4"));
cache.save().expect("save should succeed");
let loaded = PersistentFingerprintCache::load(path).expect("load should succeed");
assert_eq!(loaded.len(), 1);
assert!(loaded.get("/media/c.mp4").is_some());
}
#[test]
fn test_load_nonexistent_returns_empty() {
let path = tmp_cache_path("nonexistent_xyzabc.json");
let _ = std::fs::remove_file(&path);
let cache = PersistentFingerprintCache::load(path).expect("should not fail");
assert!(cache.is_empty());
}
#[test]
fn test_hit_miss_counters() {
let mut cache = PersistentFingerprintCache::new(tmp_cache_path("stats.json"));
cache.insert(sample_entry("/x.mp4"));
let _ = cache.get("/x.mp4");
assert_eq!(cache.hits(), 0);
assert_eq!(cache.misses(), 0);
}
#[test]
fn test_hit_rate_zero_on_no_lookups() {
let cache = PersistentFingerprintCache::new(tmp_cache_path("hitrate.json"));
assert_eq!(cache.hit_rate(), 0.0);
}
#[test]
fn test_evict_missing_removes_nonexistent_paths() {
let mut cache = PersistentFingerprintCache::new(tmp_cache_path("evict.json"));
cache.insert(sample_entry("/definitely/does/not/exist/zzz.mp4"));
assert_eq!(cache.len(), 1);
let evicted = cache.evict_missing();
assert_eq!(evicted, 1);
assert!(cache.is_empty());
}
#[test]
fn test_evict_stale_removes_changed_files() {
let dir = std::env::temp_dir().join("oximedia_pc_stale_test");
std::fs::create_dir_all(&dir).ok();
let file_path = dir.join("media_file.bin");
{
let mut f = std::fs::File::create(&file_path).expect("create");
f.write_all(b"original content for hashing").expect("write");
}
let real_hash = compute_blake3_hex(&file_path).expect("hash ok");
let mut cache = PersistentFingerprintCache::new(tmp_cache_path("stale.json"));
cache.insert(CachedEntry {
path: file_path.to_string_lossy().to_string(),
blake3_hex: real_hash.clone(),
phash: 0x1111,
thumbnail: None,
modified_secs: 0,
});
let evicted = cache.evict_stale();
assert_eq!(evicted, 0, "file unchanged → no eviction");
{
let mut f = std::fs::File::create(&file_path).expect("create");
f.write_all(b"modified content, different bytes!").expect("write");
}
let evicted2 = cache.evict_stale();
assert_eq!(evicted2, 1, "changed file → entry evicted");
assert!(cache.is_empty());
let _ = std::fs::remove_file(&file_path);
}
#[test]
fn test_merge_from() {
let mut a = PersistentFingerprintCache::new(tmp_cache_path("merge_a.json"));
let mut b = PersistentFingerprintCache::new(tmp_cache_path("merge_b.json"));
a.insert(sample_entry("/file_a.mp4"));
b.insert(sample_entry("/file_b.mp4"));
a.merge_from(&b);
assert_eq!(a.len(), 2);
assert!(a.get("/file_a.mp4").is_some());
assert!(a.get("/file_b.mp4").is_some());
}
#[test]
fn test_thumbnail_valid_no_thumbnail() {
let entry = sample_entry("/x.mp4");
assert!(entry.thumbnail_valid()); }
#[test]
fn test_thumbnail_valid_correct_size() {
let entry = CachedEntry {
thumbnail: Some(vec![128u8; 64]), ..sample_entry("/y.mp4")
};
assert!(entry.thumbnail_valid());
}
#[test]
fn test_thumbnail_invalid_wrong_size() {
let entry = CachedEntry {
thumbnail: Some(vec![0u8; 32]), ..sample_entry("/z.mp4")
};
assert!(!entry.thumbnail_valid());
}
#[test]
fn test_reset_stats() {
let mut cache = PersistentFingerprintCache::new(tmp_cache_path("reset.json"));
let _ = cache.get_valid("/nonexistent.mp4");
assert!(cache.misses() > 0);
cache.reset_stats();
assert_eq!(cache.misses(), 0);
assert_eq!(cache.hits(), 0);
}
}