use crate::tile_source::TileData;
use rustial_math::TileId;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use thiserror::Error;
const MAGIC: [u8; 2] = [b'R', b'M'];
const VERSION: u8 = 1;
const KIND_RASTER: u8 = 0;
const HEADER_LEN: usize = 12;
#[derive(Debug, Error)]
pub enum DiskCacheError {
#[error("disk cache I/O error: {0}")]
Io(#[from] io::Error),
#[error("disk cache decode error: {0}")]
Decode(String),
}
struct CacheFileInfo {
path: PathBuf,
size: u64,
modified: SystemTime,
}
#[derive(Debug)]
pub struct DiskCache {
base_dir: PathBuf,
}
impl DiskCache {
pub fn new(base_dir: impl Into<PathBuf>) -> Result<Self, DiskCacheError> {
let base_dir = base_dir.into();
std::fs::create_dir_all(&base_dir)?;
Ok(Self { base_dir })
}
pub fn get(&self, id: &TileId) -> Result<Option<TileData>, DiskCacheError> {
let path = self.tile_path(id);
match std::fs::read(&path) {
Ok(bytes) => decode_tile_data(&bytes).map(Some),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(DiskCacheError::Io(e)),
}
}
pub fn put(&self, id: &TileId, data: &TileData) -> Result<(), DiskCacheError> {
let bytes = encode_tile_data(data);
if bytes.is_empty() {
return Err(DiskCacheError::Decode(
"disk-cache serialisation not supported for this tile data kind".into(),
));
}
let path = self.tile_path(id);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}
pub fn contains(&self, id: &TileId) -> bool {
self.tile_path(id).exists()
}
pub fn remove(&self, id: &TileId) -> Result<bool, DiskCacheError> {
let path = self.tile_path(id);
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(DiskCacheError::Io(e)),
}
}
pub fn clear(&self) -> Result<(), DiskCacheError> {
if self.base_dir.exists() {
std::fs::remove_dir_all(&self.base_dir)?;
}
std::fs::create_dir_all(&self.base_dir)?;
Ok(())
}
pub fn evict_older_than(&self, max_age: std::time::Duration) -> Result<usize, DiskCacheError> {
let cutoff = SystemTime::now()
.checked_sub(max_age)
.unwrap_or(SystemTime::UNIX_EPOCH);
let mut removed = 0usize;
self.walk_and_evict(&self.base_dir, cutoff, &mut removed)?;
Ok(removed)
}
pub fn evict_to_size(&self, max_bytes: u64) -> Result<usize, DiskCacheError> {
let mut entries = Vec::new();
self.walk_file_info(&self.base_dir, &mut entries)?;
entries.sort_by_key(|e| e.modified);
let total: u64 = entries.iter().map(|e| e.size).sum();
if total <= max_bytes {
return Ok(0);
}
let mut current = total;
let mut removed = 0usize;
for entry in &entries {
if current <= max_bytes {
break;
}
if std::fs::remove_file(&entry.path).is_ok() {
current = current.saturating_sub(entry.size);
removed += 1;
if let Some(parent) = entry.path.parent() {
let _ = std::fs::remove_dir(parent);
}
}
}
Ok(removed)
}
pub fn len(&self) -> Result<usize, DiskCacheError> {
let mut count = 0usize;
self.walk_count(&self.base_dir, &mut count)?;
Ok(count)
}
pub fn is_empty(&self) -> Result<bool, DiskCacheError> {
Ok(self.len()? == 0)
}
pub fn base_dir(&self) -> &Path {
&self.base_dir
}
fn tile_path(&self, id: &TileId) -> PathBuf {
self.base_dir
.join(id.zoom.to_string())
.join(id.x.to_string())
.join(format!("{}.bin", id.y))
}
fn walk_and_evict(
&self,
dir: &Path,
cutoff: SystemTime,
removed: &mut usize,
) -> Result<(), DiskCacheError> {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(DiskCacheError::Io(e)),
};
for entry in entries {
let entry = entry?;
let ft = entry.file_type()?;
if ft.is_dir() {
self.walk_and_evict(&entry.path(), cutoff, removed)?;
let _ = std::fs::remove_dir(entry.path());
} else if ft.is_file() {
if let Some(ext) = entry.path().extension() {
if ext == "bin" {
let modified = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
if modified < cutoff {
std::fs::remove_file(entry.path())?;
*removed += 1;
}
}
}
}
}
Ok(())
}
fn walk_file_info(
&self,
dir: &Path,
out: &mut Vec<CacheFileInfo>,
) -> Result<(), DiskCacheError> {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(DiskCacheError::Io(e)),
};
for entry in entries {
let entry = entry?;
let ft = entry.file_type()?;
if ft.is_dir() {
self.walk_file_info(&entry.path(), out)?;
} else if ft.is_file() {
if let Some(ext) = entry.path().extension() {
if ext == "bin" {
let meta = entry.metadata()?;
out.push(CacheFileInfo {
path: entry.path(),
size: meta.len(),
modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
});
}
}
}
}
Ok(())
}
fn walk_count(&self, dir: &Path, count: &mut usize) -> Result<(), DiskCacheError> {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(DiskCacheError::Io(e)),
};
for entry in entries {
let entry = entry?;
let ft = entry.file_type()?;
if ft.is_dir() {
self.walk_count(&entry.path(), count)?;
} else if ft.is_file() {
if let Some(ext) = entry.path().extension() {
if ext == "bin" {
*count += 1;
}
}
}
}
Ok(())
}
}
fn encode_tile_data(data: &TileData) -> Vec<u8> {
match data {
TileData::Raster(img) => {
let mut buf = Vec::with_capacity(HEADER_LEN + img.data.len());
buf.extend_from_slice(&MAGIC);
buf.push(VERSION);
buf.push(KIND_RASTER);
buf.extend_from_slice(&img.width.to_le_bytes());
buf.extend_from_slice(&img.height.to_le_bytes());
buf.extend_from_slice(&img.data);
buf
}
TileData::Vector(_) | TileData::RawVector(_) => {
Vec::new()
}
}
}
fn decode_tile_data(bytes: &[u8]) -> Result<TileData, DiskCacheError> {
if bytes.len() < HEADER_LEN {
return Err(DiskCacheError::Decode(format![
"file too short ({} bytes, need at least {HEADER_LEN})",
bytes.len()
]));
}
if bytes[0..2] != MAGIC {
return Err(DiskCacheError::Decode(format![
"invalid magic: expected {:?}, got {:?}",
MAGIC,
&bytes[0..2]
]));
}
let version = bytes[2];
if version != VERSION {
return Err(DiskCacheError::Decode(format![
"unsupported version {version} (expected {VERSION})"
]));
}
let kind = bytes[3];
match kind {
KIND_RASTER => {
let width = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let height = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
let pixel_data = &bytes[HEADER_LEN..];
let expected_len = (width as usize)
.checked_mul(height as usize)
.and_then(|n| n.checked_mul(4))
.ok_or_else(|| {
DiskCacheError::Decode(format!["dimensions overflow: {width}x{height}"])
})?;
if pixel_data.len() != expected_len {
return Err(DiskCacheError::Decode(format![
"expected {expected_len} bytes of pixel data for {width}x{height}, got {}",
pixel_data.len()
]));
}
Ok(TileData::Raster(crate::tile_source::DecodedImage {
width,
height,
data: std::sync::Arc::new(pixel_data.to_vec()),
}))
}
_ => Err(DiskCacheError::Decode(format![
"unknown tile kind tag: {kind}"
])),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tile_source::DecodedImage;
fn temp_cache(name: &str) -> (DiskCache, PathBuf) {
let dir = std::env::temp_dir()
.join("rustial_disk_cache_test")
.join(name);
let _ = std::fs::remove_dir_all(&dir);
let cache = DiskCache::new(&dir).expect("create cache");
(cache, dir)
}
fn sample_tile() -> TileData {
TileData::Raster(DecodedImage {
width: 2,
height: 2,
data: vec![255u8; 16].into(), })
}
#[test]
fn roundtrip_raster_tile() {
let (cache, dir) = temp_cache("roundtrip");
let id = TileId::new(5, 10, 15);
let data = sample_tile();
assert!(!cache.contains(&id));
cache.put(&id, &data).expect("put");
assert!(cache.contains(&id));
match cache.get(&id).expect("get").expect("some") {
TileData::Raster(img) => {
assert_eq!(img.width, 2);
assert_eq!(img.height, 2);
assert_eq!(img.data.len(), 16);
assert!(img.data.iter().all(|&b| b == 255));
}
TileData::Vector(_) | TileData::RawVector(_) => panic!("expected Raster, got Vector"),
}
assert!(cache.remove(&id).expect("remove"));
assert!(!cache.contains(&id));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn get_nonexistent_returns_none() {
let (cache, dir) = temp_cache("miss");
assert!(cache.get(&TileId::new(0, 0, 0)).expect("get").is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn remove_nonexistent_returns_false() {
let (cache, dir) = temp_cache("remove_miss");
assert!(!cache.remove(&TileId::new(0, 0, 0)).expect("remove"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn decode_too_short() {
assert!(decode_tile_data(&[0, 1, 2]).is_err());
}
#[test]
fn decode_bad_magic() {
let mut data = vec![b'X', b'Y', VERSION, KIND_RASTER];
data.extend_from_slice(&1u32.to_le_bytes()); data.extend_from_slice(&1u32.to_le_bytes()); data.extend_from_slice(&[0u8; 4]); assert!(decode_tile_data(&data).is_err());
}
#[test]
fn decode_wrong_version() {
let mut data = vec![b'R', b'M', 99, KIND_RASTER];
data.extend_from_slice(&1u32.to_le_bytes());
data.extend_from_slice(&1u32.to_le_bytes());
data.extend_from_slice(&[0u8; 4]);
assert!(decode_tile_data(&data).is_err());
}
#[test]
fn decode_unknown_kind() {
let mut data = vec![b'R', b'M', VERSION, 77];
data.extend_from_slice(&1u32.to_le_bytes());
data.extend_from_slice(&1u32.to_le_bytes());
data.extend_from_slice(&[0u8; 4]);
assert!(decode_tile_data(&data).is_err());
}
#[test]
fn decode_wrong_pixel_length() {
let mut data = vec![b'R', b'M', VERSION, KIND_RASTER];
data.extend_from_slice(&2u32.to_le_bytes()); data.extend_from_slice(&2u32.to_le_bytes()); data.extend_from_slice(&[0u8; 4]); assert!(decode_tile_data(&data).is_err());
}
#[test]
fn decode_overflow_dimensions() {
let mut data = vec![b'R', b'M', VERSION, KIND_RASTER];
data.extend_from_slice(&u32::MAX.to_le_bytes()); data.extend_from_slice(&u32::MAX.to_le_bytes()); assert!(decode_tile_data(&data).is_err());
}
#[test]
fn clear_removes_all_tiles() {
let (cache, dir) = temp_cache("clear");
let data = sample_tile();
cache.put(&TileId::new(1, 0, 0), &data).expect("put");
cache.put(&TileId::new(2, 1, 1), &data).expect("put");
assert_eq!(cache.len().expect("len"), 2);
cache.clear().expect("clear");
assert!(cache.is_empty().expect("is_empty"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn len_counts_tiles() {
let (cache, dir) = temp_cache("len");
assert!(cache.is_empty().expect("empty"));
let data = sample_tile();
cache.put(&TileId::new(0, 0, 0), &data).expect("put");
cache.put(&TileId::new(1, 0, 0), &data).expect("put");
assert_eq!(cache.len().expect("len"), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn evict_removes_old_tiles() {
let (cache, dir) = temp_cache("evict");
let data = sample_tile();
cache.put(&TileId::new(0, 0, 0), &data).expect("put");
let removed = cache
.evict_older_than(std::time::Duration::ZERO)
.expect("evict");
assert_eq!(removed, 1);
assert!(cache.is_empty().expect("is_empty"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn evict_keeps_recent_tiles() {
let (cache, dir) = temp_cache("evict_keep");
let data = sample_tile();
cache.put(&TileId::new(0, 0, 0), &data).expect("put");
let removed = cache
.evict_older_than(std::time::Duration::from_secs(3600))
.expect("evict");
assert_eq!(removed, 0);
assert_eq!(cache.len().expect("len"), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn evict_to_size_removes_excess_files() {
let (cache, dir) = temp_cache("evict_to_size");
let data = sample_tile();
cache.put(&TileId::new(0, 0, 0), &data).expect("put");
cache.put(&TileId::new(1, 0, 0), &data).expect("put");
cache.put(&TileId::new(2, 0, 0), &data).expect("put");
assert_eq!(cache.len().expect("len"), 3);
let removed = cache.evict_to_size(100).expect("evict_to_size");
assert_eq!(removed, 0);
assert_eq!(cache.len().expect("len"), 3);
let removed = cache.evict_to_size(60).expect("evict_to_size");
assert_eq!(removed, 1);
assert_eq!(cache.len().expect("len"), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn evict_to_size_removes_oldest() {
let (cache, dir) = temp_cache("evict_size");
let data = sample_tile();
cache.put(&TileId::new(0, 0, 0), &data).expect("put");
std::thread::sleep(std::time::Duration::from_millis(50));
cache.put(&TileId::new(1, 0, 0), &data).expect("put");
assert_eq!(cache.len().expect("len"), 2);
let removed = cache.evict_to_size(30).expect("evict");
assert_eq!(removed, 1);
assert_eq!(cache.len().expect("len"), 1);
assert!(!cache.contains(&TileId::new(0, 0, 0)));
assert!(cache.contains(&TileId::new(1, 0, 0)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn evict_to_size_noop_when_under_limit() {
let (cache, dir) = temp_cache("evict_size_noop");
let data = sample_tile();
cache.put(&TileId::new(0, 0, 0), &data).expect("put");
let removed = cache.evict_to_size(1_000_000).expect("evict");
assert_eq!(removed, 0);
assert_eq!(cache.len().expect("len"), 1);
let _ = std::fs::remove_dir_all(&dir);
}
}