1use crate::tile_source::TileData;
34use rustial_math::TileId;
35use std::io;
36use std::path::{Path, PathBuf};
37use std::time::SystemTime;
38use thiserror::Error;
39
40const MAGIC: [u8; 2] = [b'R', b'M'];
46
47const VERSION: u8 = 1;
49
50const KIND_RASTER: u8 = 0;
52
53const HEADER_LEN: usize = 12;
55
56#[derive(Debug, Error)]
62pub enum DiskCacheError {
63 #[error("disk cache I/O error: {0}")]
65 Io(#[from] io::Error),
66 #[error("disk cache decode error: {0}")]
68 Decode(String),
69}
70
71struct CacheFileInfo {
77 path: PathBuf,
78 size: u64,
79 modified: SystemTime,
80}
81
82#[derive(Debug)]
103pub struct DiskCache {
104 base_dir: PathBuf,
105}
106
107impl DiskCache {
108 pub fn new(base_dir: impl Into<PathBuf>) -> Result<Self, DiskCacheError> {
113 let base_dir = base_dir.into();
114 std::fs::create_dir_all(&base_dir)?;
115 Ok(Self { base_dir })
116 }
117
118 pub fn get(&self, id: &TileId) -> Result<Option<TileData>, DiskCacheError> {
122 let path = self.tile_path(id);
123 match std::fs::read(&path) {
124 Ok(bytes) => decode_tile_data(&bytes).map(Some),
125 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
126 Err(e) => Err(DiskCacheError::Io(e)),
127 }
128 }
129
130 pub fn put(&self, id: &TileId, data: &TileData) -> Result<(), DiskCacheError> {
135 let bytes = encode_tile_data(data);
136 if bytes.is_empty() {
137 return Err(DiskCacheError::Decode(
139 "disk-cache serialisation not supported for this tile data kind".into(),
140 ));
141 }
142
143 let path = self.tile_path(id);
144 if let Some(parent) = path.parent() {
145 std::fs::create_dir_all(parent)?;
146 }
147
148 let tmp = path.with_extension("tmp");
150 std::fs::write(&tmp, bytes)?;
151 std::fs::rename(&tmp, &path)?;
152 Ok(())
153 }
154
155 pub fn contains(&self, id: &TileId) -> bool {
157 self.tile_path(id).exists()
158 }
159
160 pub fn remove(&self, id: &TileId) -> Result<bool, DiskCacheError> {
164 let path = self.tile_path(id);
165 match std::fs::remove_file(&path) {
166 Ok(()) => Ok(true),
167 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
168 Err(e) => Err(DiskCacheError::Io(e)),
169 }
170 }
171
172 pub fn clear(&self) -> Result<(), DiskCacheError> {
177 if self.base_dir.exists() {
178 std::fs::remove_dir_all(&self.base_dir)?;
179 }
180 std::fs::create_dir_all(&self.base_dir)?;
181 Ok(())
182 }
183
184 pub fn evict_older_than(&self, max_age: std::time::Duration) -> Result<usize, DiskCacheError> {
191 let cutoff = SystemTime::now()
192 .checked_sub(max_age)
193 .unwrap_or(SystemTime::UNIX_EPOCH);
194
195 let mut removed = 0usize;
196 self.walk_and_evict(&self.base_dir, cutoff, &mut removed)?;
197 Ok(removed)
198 }
199
200 pub fn evict_to_size(&self, max_bytes: u64) -> Result<usize, DiskCacheError> {
208 let mut entries = Vec::new();
209 self.walk_file_info(&self.base_dir, &mut entries)?;
210
211 entries.sort_by_key(|e| e.modified);
213
214 let total: u64 = entries.iter().map(|e| e.size).sum();
215 if total <= max_bytes {
216 return Ok(0);
217 }
218
219 let mut current = total;
220 let mut removed = 0usize;
221 for entry in &entries {
222 if current <= max_bytes {
223 break;
224 }
225 if std::fs::remove_file(&entry.path).is_ok() {
226 current = current.saturating_sub(entry.size);
227 removed += 1;
228 if let Some(parent) = entry.path.parent() {
230 let _ = std::fs::remove_dir(parent);
231 }
232 }
233 }
234 Ok(removed)
235 }
236
237 pub fn len(&self) -> Result<usize, DiskCacheError> {
239 let mut count = 0usize;
240 self.walk_count(&self.base_dir, &mut count)?;
241 Ok(count)
242 }
243
244 pub fn is_empty(&self) -> Result<bool, DiskCacheError> {
246 Ok(self.len()? == 0)
247 }
248
249 pub fn base_dir(&self) -> &Path {
251 &self.base_dir
252 }
253
254 fn tile_path(&self, id: &TileId) -> PathBuf {
257 self.base_dir
258 .join(id.zoom.to_string())
259 .join(id.x.to_string())
260 .join(format!("{}.bin", id.y))
261 }
262
263 fn walk_and_evict(
264 &self,
265 dir: &Path,
266 cutoff: SystemTime,
267 removed: &mut usize,
268 ) -> Result<(), DiskCacheError> {
269 let entries = match std::fs::read_dir(dir) {
270 Ok(e) => e,
271 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
272 Err(e) => return Err(DiskCacheError::Io(e)),
273 };
274
275 for entry in entries {
276 let entry = entry?;
277 let ft = entry.file_type()?;
278
279 if ft.is_dir() {
280 self.walk_and_evict(&entry.path(), cutoff, removed)?;
281 let _ = std::fs::remove_dir(entry.path());
283 } else if ft.is_file() {
284 if let Some(ext) = entry.path().extension() {
285 if ext == "bin" {
286 let modified = entry
287 .metadata()
288 .and_then(|m| m.modified())
289 .unwrap_or(SystemTime::UNIX_EPOCH);
290 if modified < cutoff {
291 std::fs::remove_file(entry.path())?;
292 *removed += 1;
293 }
294 }
295 }
296 }
297 }
298 Ok(())
299 }
300
301 fn walk_file_info(
302 &self,
303 dir: &Path,
304 out: &mut Vec<CacheFileInfo>,
305 ) -> Result<(), DiskCacheError> {
306 let entries = match std::fs::read_dir(dir) {
307 Ok(e) => e,
308 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
309 Err(e) => return Err(DiskCacheError::Io(e)),
310 };
311
312 for entry in entries {
313 let entry = entry?;
314 let ft = entry.file_type()?;
315 if ft.is_dir() {
316 self.walk_file_info(&entry.path(), out)?;
317 } else if ft.is_file() {
318 if let Some(ext) = entry.path().extension() {
319 if ext == "bin" {
320 let meta = entry.metadata()?;
321 out.push(CacheFileInfo {
322 path: entry.path(),
323 size: meta.len(),
324 modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
325 });
326 }
327 }
328 }
329 }
330 Ok(())
331 }
332
333 fn walk_count(&self, dir: &Path, count: &mut usize) -> Result<(), DiskCacheError> {
334 let entries = match std::fs::read_dir(dir) {
335 Ok(e) => e,
336 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
337 Err(e) => return Err(DiskCacheError::Io(e)),
338 };
339
340 for entry in entries {
341 let entry = entry?;
342 let ft = entry.file_type()?;
343 if ft.is_dir() {
344 self.walk_count(&entry.path(), count)?;
345 } else if ft.is_file() {
346 if let Some(ext) = entry.path().extension() {
347 if ext == "bin" {
348 *count += 1;
349 }
350 }
351 }
352 }
353 Ok(())
354 }
355}
356
357fn encode_tile_data(data: &TileData) -> Vec<u8> {
362 match data {
363 TileData::Raster(img) => {
364 let mut buf = Vec::with_capacity(HEADER_LEN + img.data.len());
365 buf.extend_from_slice(&MAGIC);
366 buf.push(VERSION);
367 buf.push(KIND_RASTER);
368 buf.extend_from_slice(&img.width.to_le_bytes());
369 buf.extend_from_slice(&img.height.to_le_bytes());
370 buf.extend_from_slice(&img.data);
371 buf
372 }
373 TileData::Vector(_) | TileData::RawVector(_) => {
374 Vec::new()
377 }
378 }
379}
380
381fn decode_tile_data(bytes: &[u8]) -> Result<TileData, DiskCacheError> {
382 if bytes.len() < HEADER_LEN {
383 return Err(DiskCacheError::Decode(format![
384 "file too short ({} bytes, need at least {HEADER_LEN})",
385 bytes.len()
386 ]));
387 }
388
389 if bytes[0..2] != MAGIC {
391 return Err(DiskCacheError::Decode(format![
392 "invalid magic: expected {:?}, got {:?}",
393 MAGIC,
394 &bytes[0..2]
395 ]));
396 }
397
398 let version = bytes[2];
400 if version != VERSION {
401 return Err(DiskCacheError::Decode(format![
402 "unsupported version {version} (expected {VERSION})"
403 ]));
404 }
405
406 let kind = bytes[3];
407 match kind {
408 KIND_RASTER => {
409 let width = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
410 let height = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
411 let pixel_data = &bytes[HEADER_LEN..];
412
413 let expected_len = (width as usize)
414 .checked_mul(height as usize)
415 .and_then(|n| n.checked_mul(4))
416 .ok_or_else(|| {
417 DiskCacheError::Decode(format!["dimensions overflow: {width}x{height}"])
418 })?;
419
420 if pixel_data.len() != expected_len {
421 return Err(DiskCacheError::Decode(format![
422 "expected {expected_len} bytes of pixel data for {width}x{height}, got {}",
423 pixel_data.len()
424 ]));
425 }
426
427 Ok(TileData::Raster(crate::tile_source::DecodedImage {
428 width,
429 height,
430 data: std::sync::Arc::new(pixel_data.to_vec()),
431 }))
432 }
433 _ => Err(DiskCacheError::Decode(format![
434 "unknown tile kind tag: {kind}"
435 ])),
436 }
437}
438
439#[cfg(test)]
444mod tests {
445 use super::*;
446 use crate::tile_source::DecodedImage;
447
448 fn temp_cache(name: &str) -> (DiskCache, PathBuf) {
450 let dir = std::env::temp_dir()
451 .join("rustial_disk_cache_test")
452 .join(name);
453 let _ = std::fs::remove_dir_all(&dir);
454 let cache = DiskCache::new(&dir).expect("create cache");
455 (cache, dir)
456 }
457
458 fn sample_tile() -> TileData {
459 TileData::Raster(DecodedImage {
460 width: 2,
461 height: 2,
462 data: vec![255u8; 16].into(), })
464 }
465
466 #[test]
469 fn roundtrip_raster_tile() {
470 let (cache, dir) = temp_cache("roundtrip");
471 let id = TileId::new(5, 10, 15);
472 let data = sample_tile();
473
474 assert!(!cache.contains(&id));
475 cache.put(&id, &data).expect("put");
476 assert!(cache.contains(&id));
477
478 match cache.get(&id).expect("get").expect("some") {
479 TileData::Raster(img) => {
480 assert_eq!(img.width, 2);
481 assert_eq!(img.height, 2);
482 assert_eq!(img.data.len(), 16);
483 assert!(img.data.iter().all(|&b| b == 255));
484 }
485 TileData::Vector(_) | TileData::RawVector(_) => panic!("expected Raster, got Vector"),
486 }
487
488 assert!(cache.remove(&id).expect("remove"));
489 assert!(!cache.contains(&id));
490 let _ = std::fs::remove_dir_all(&dir);
491 }
492
493 #[test]
496 fn get_nonexistent_returns_none() {
497 let (cache, dir) = temp_cache("miss");
498 assert!(cache.get(&TileId::new(0, 0, 0)).expect("get").is_none());
499 let _ = std::fs::remove_dir_all(&dir);
500 }
501
502 #[test]
505 fn remove_nonexistent_returns_false() {
506 let (cache, dir) = temp_cache("remove_miss");
507 assert!(!cache.remove(&TileId::new(0, 0, 0)).expect("remove"));
508 let _ = std::fs::remove_dir_all(&dir);
509 }
510
511 #[test]
514 fn decode_too_short() {
515 assert!(decode_tile_data(&[0, 1, 2]).is_err());
516 }
517
518 #[test]
519 fn decode_bad_magic() {
520 let mut data = vec![b'X', b'Y', VERSION, KIND_RASTER];
521 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());
525 }
526
527 #[test]
528 fn decode_wrong_version() {
529 let mut data = vec![b'R', b'M', 99, KIND_RASTER];
530 data.extend_from_slice(&1u32.to_le_bytes());
531 data.extend_from_slice(&1u32.to_le_bytes());
532 data.extend_from_slice(&[0u8; 4]);
533 assert!(decode_tile_data(&data).is_err());
534 }
535
536 #[test]
537 fn decode_unknown_kind() {
538 let mut data = vec![b'R', b'M', VERSION, 77];
539 data.extend_from_slice(&1u32.to_le_bytes());
540 data.extend_from_slice(&1u32.to_le_bytes());
541 data.extend_from_slice(&[0u8; 4]);
542 assert!(decode_tile_data(&data).is_err());
543 }
544
545 #[test]
546 fn decode_wrong_pixel_length() {
547 let mut data = vec![b'R', b'M', VERSION, KIND_RASTER];
548 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());
552 }
553
554 #[test]
555 fn decode_overflow_dimensions() {
556 let mut data = vec![b'R', b'M', VERSION, KIND_RASTER];
557 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());
561 }
562
563 #[test]
566 fn clear_removes_all_tiles() {
567 let (cache, dir) = temp_cache("clear");
568 let data = sample_tile();
569 cache.put(&TileId::new(1, 0, 0), &data).expect("put");
570 cache.put(&TileId::new(2, 1, 1), &data).expect("put");
571 assert_eq!(cache.len().expect("len"), 2);
572
573 cache.clear().expect("clear");
574 assert!(cache.is_empty().expect("is_empty"));
575 let _ = std::fs::remove_dir_all(&dir);
576 }
577
578 #[test]
581 fn len_counts_tiles() {
582 let (cache, dir) = temp_cache("len");
583 assert!(cache.is_empty().expect("empty"));
584
585 let data = sample_tile();
586 cache.put(&TileId::new(0, 0, 0), &data).expect("put");
587 cache.put(&TileId::new(1, 0, 0), &data).expect("put");
588 assert_eq!(cache.len().expect("len"), 2);
589 let _ = std::fs::remove_dir_all(&dir);
590 }
591
592 #[test]
595 fn evict_removes_old_tiles() {
596 let (cache, dir) = temp_cache("evict");
597 let data = sample_tile();
598 cache.put(&TileId::new(0, 0, 0), &data).expect("put");
599
600 let removed = cache
602 .evict_older_than(std::time::Duration::ZERO)
603 .expect("evict");
604 assert_eq!(removed, 1);
605 assert!(cache.is_empty().expect("is_empty"));
606 let _ = std::fs::remove_dir_all(&dir);
607 }
608
609 #[test]
610 fn evict_keeps_recent_tiles() {
611 let (cache, dir) = temp_cache("evict_keep");
612 let data = sample_tile();
613 cache.put(&TileId::new(0, 0, 0), &data).expect("put");
614
615 let removed = cache
617 .evict_older_than(std::time::Duration::from_secs(3600))
618 .expect("evict");
619 assert_eq!(removed, 0);
620 assert_eq!(cache.len().expect("len"), 1);
621 let _ = std::fs::remove_dir_all(&dir);
622 }
623
624 #[test]
625 fn evict_to_size_removes_excess_files() {
626 let (cache, dir) = temp_cache("evict_to_size");
627 let data = sample_tile();
628 cache.put(&TileId::new(0, 0, 0), &data).expect("put");
629 cache.put(&TileId::new(1, 0, 0), &data).expect("put");
630 cache.put(&TileId::new(2, 0, 0), &data).expect("put");
631 assert_eq!(cache.len().expect("len"), 3);
632
633 let removed = cache.evict_to_size(100).expect("evict_to_size");
638 assert_eq!(removed, 0);
639 assert_eq!(cache.len().expect("len"), 3);
640
641 let removed = cache.evict_to_size(60).expect("evict_to_size");
643 assert_eq!(removed, 1);
644 assert_eq!(cache.len().expect("len"), 2);
645
646 let _ = std::fs::remove_dir_all(&dir);
648 }
649
650 #[test]
651 fn evict_to_size_removes_oldest() {
652 let (cache, dir) = temp_cache("evict_size");
653 let data = sample_tile();
654
655 cache.put(&TileId::new(0, 0, 0), &data).expect("put");
657 std::thread::sleep(std::time::Duration::from_millis(50));
659 cache.put(&TileId::new(1, 0, 0), &data).expect("put");
660
661 assert_eq!(cache.len().expect("len"), 2);
662
663 let removed = cache.evict_to_size(30).expect("evict");
665 assert_eq!(removed, 1);
666 assert_eq!(cache.len().expect("len"), 1);
667 assert!(!cache.contains(&TileId::new(0, 0, 0)));
669 assert!(cache.contains(&TileId::new(1, 0, 0)));
670
671 let _ = std::fs::remove_dir_all(&dir);
672 }
673
674 #[test]
675 fn evict_to_size_noop_when_under_limit() {
676 let (cache, dir) = temp_cache("evict_size_noop");
677 let data = sample_tile();
678 cache.put(&TileId::new(0, 0, 0), &data).expect("put");
679
680 let removed = cache.evict_to_size(1_000_000).expect("evict");
681 assert_eq!(removed, 0);
682 assert_eq!(cache.len().expect("len"), 1);
683 let _ = std::fs::remove_dir_all(&dir);
684 }
685}