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(
191 &self,
192 max_age: std::time::Duration,
193 ) -> Result<usize, DiskCacheError> {
194 let cutoff = SystemTime::now()
195 .checked_sub(max_age)
196 .unwrap_or(SystemTime::UNIX_EPOCH);
197
198 let mut removed = 0usize;
199 self.walk_and_evict(&self.base_dir, cutoff, &mut removed)?;
200 Ok(removed)
201 }
202
203 pub fn evict_to_size(&self, max_bytes: u64) -> Result<usize, DiskCacheError> {
211 let mut entries = Vec::new();
212 self.walk_file_info(&self.base_dir, &mut entries)?;
213
214 entries.sort_by_key(|e| e.modified);
216
217 let total: u64 = entries.iter().map(|e| e.size).sum();
218 if total <= max_bytes {
219 return Ok(0);
220 }
221
222 let mut current = total;
223 let mut removed = 0usize;
224 for entry in &entries {
225 if current <= max_bytes {
226 break;
227 }
228 if std::fs::remove_file(&entry.path).is_ok() {
229 current = current.saturating_sub(entry.size);
230 removed += 1;
231 if let Some(parent) = entry.path.parent() {
233 let _ = std::fs::remove_dir(parent);
234 }
235 }
236 }
237 Ok(removed)
238 }
239
240 pub fn len(&self) -> Result<usize, DiskCacheError> {
242 let mut count = 0usize;
243 self.walk_count(&self.base_dir, &mut count)?;
244 Ok(count)
245 }
246
247 pub fn is_empty(&self) -> Result<bool, DiskCacheError> {
249 Ok(self.len()? == 0)
250 }
251
252 pub fn base_dir(&self) -> &Path {
254 &self.base_dir
255 }
256
257 fn tile_path(&self, id: &TileId) -> PathBuf {
260 self.base_dir
261 .join(id.zoom.to_string())
262 .join(id.x.to_string())
263 .join(format!("{}.bin", id.y))
264 }
265
266 fn walk_and_evict(
267 &self,
268 dir: &Path,
269 cutoff: SystemTime,
270 removed: &mut usize,
271 ) -> Result<(), DiskCacheError> {
272 let entries = match std::fs::read_dir(dir) {
273 Ok(e) => e,
274 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
275 Err(e) => return Err(DiskCacheError::Io(e)),
276 };
277
278 for entry in entries {
279 let entry = entry?;
280 let ft = entry.file_type()?;
281
282 if ft.is_dir() {
283 self.walk_and_evict(&entry.path(), cutoff, removed)?;
284 let _ = std::fs::remove_dir(entry.path());
286 } else if ft.is_file() {
287 if let Some(ext) = entry.path().extension() {
288 if ext == "bin" {
289 let modified = entry
290 .metadata()
291 .and_then(|m| m.modified())
292 .unwrap_or(SystemTime::UNIX_EPOCH);
293 if modified < cutoff {
294 std::fs::remove_file(entry.path())?;
295 *removed += 1;
296 }
297 }
298 }
299 }
300 }
301 Ok(())
302 }
303
304 fn walk_file_info(
305 &self,
306 dir: &Path,
307 out: &mut Vec<CacheFileInfo>,
308 ) -> Result<(), DiskCacheError> {
309 let entries = match std::fs::read_dir(dir) {
310 Ok(e) => e,
311 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
312 Err(e) => return Err(DiskCacheError::Io(e)),
313 };
314
315 for entry in entries {
316 let entry = entry?;
317 let ft = entry.file_type()?;
318 if ft.is_dir() {
319 self.walk_file_info(&entry.path(), out)?;
320 } else if ft.is_file() {
321 if let Some(ext) = entry.path().extension() {
322 if ext == "bin" {
323 let meta = entry.metadata()?;
324 out.push(CacheFileInfo {
325 path: entry.path(),
326 size: meta.len(),
327 modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
328 });
329 }
330 }
331 }
332 }
333 Ok(())
334 }
335
336 fn walk_count(&self, dir: &Path, count: &mut usize) -> Result<(), DiskCacheError> {
337 let entries = match std::fs::read_dir(dir) {
338 Ok(e) => e,
339 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
340 Err(e) => return Err(DiskCacheError::Io(e)),
341 };
342
343 for entry in entries {
344 let entry = entry?;
345 let ft = entry.file_type()?;
346 if ft.is_dir() {
347 self.walk_count(&entry.path(), count)?;
348 } else if ft.is_file() {
349 if let Some(ext) = entry.path().extension() {
350 if ext == "bin" {
351 *count += 1;
352 }
353 }
354 }
355 }
356 Ok(())
357 }
358}
359
360fn encode_tile_data(data: &TileData) -> Vec<u8> {
365 match data {
366 TileData::Raster(img) => {
367 let mut buf = Vec::with_capacity(HEADER_LEN + img.data.len());
368 buf.extend_from_slice(&MAGIC);
369 buf.push(VERSION);
370 buf.push(KIND_RASTER);
371 buf.extend_from_slice(&img.width.to_le_bytes());
372 buf.extend_from_slice(&img.height.to_le_bytes());
373 buf.extend_from_slice(&img.data);
374 buf
375 }
376 TileData::Vector(_) | TileData::RawVector(_) => {
377 Vec::new()
380 }
381 }
382}
383
384fn decode_tile_data(bytes: &[u8]) -> Result<TileData, DiskCacheError> {
385 if bytes.len() < HEADER_LEN {
386 return Err(DiskCacheError::Decode(format![
387 "file too short ({} bytes, need at least {HEADER_LEN})",
388 bytes.len()
389 ]));
390 }
391
392 if bytes[0..2] != MAGIC {
394 return Err(DiskCacheError::Decode(format![
395 "invalid magic: expected {:?}, got {:?}",
396 MAGIC,
397 &bytes[0..2]
398 ]));
399 }
400
401 let version = bytes[2];
403 if version != VERSION {
404 return Err(DiskCacheError::Decode(format![
405 "unsupported version {version} (expected {VERSION})"
406 ]));
407 }
408
409 let kind = bytes[3];
410 match kind {
411 KIND_RASTER => {
412 let width = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
413 let height = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
414 let pixel_data = &bytes[HEADER_LEN..];
415
416 let expected_len = (width as usize)
417 .checked_mul(height as usize)
418 .and_then(|n| n.checked_mul(4))
419 .ok_or_else(|| {
420 DiskCacheError::Decode(format![
421 "dimensions overflow: {width}x{height}"
422 ])
423 })?;
424
425 if pixel_data.len() != expected_len {
426 return Err(DiskCacheError::Decode(format![
427 "expected {expected_len} bytes of pixel data for {width}x{height}, got {}",
428 pixel_data.len()
429 ]));
430 }
431
432 Ok(TileData::Raster(crate::tile_source::DecodedImage {
433 width,
434 height,
435 data: std::sync::Arc::new(pixel_data.to_vec()),
436 }))
437 }
438 _ => Err(DiskCacheError::Decode(format![
439 "unknown tile kind tag: {kind}"
440 ])),
441 }
442}
443
444#[cfg(test)]
449mod tests {
450 use super::*;
451 use crate::tile_source::DecodedImage;
452
453 fn temp_cache(name: &str) -> (DiskCache, PathBuf) {
455 let dir = std::env::temp_dir()
456 .join("rustial_disk_cache_test")
457 .join(name);
458 let _ = std::fs::remove_dir_all(&dir);
459 let cache = DiskCache::new(&dir).expect("create cache");
460 (cache, dir)
461 }
462
463 fn sample_tile() -> TileData {
464 TileData::Raster(DecodedImage {
465 width: 2,
466 height: 2,
467 data: vec![255u8; 16].into(), })
469 }
470
471 #[test]
474 fn roundtrip_raster_tile() {
475 let (cache, dir) = temp_cache("roundtrip");
476 let id = TileId::new(5, 10, 15);
477 let data = sample_tile();
478
479 assert!(!cache.contains(&id));
480 cache.put(&id, &data).expect("put");
481 assert!(cache.contains(&id));
482
483 match cache.get(&id).expect("get").expect("some") {
484 TileData::Raster(img) => {
485 assert_eq!(img.width, 2);
486 assert_eq!(img.height, 2);
487 assert_eq!(img.data.len(), 16);
488 assert!(img.data.iter().all(|&b| b == 255));
489 }
490 TileData::Vector(_) | TileData::RawVector(_) => panic!("expected Raster, got Vector"),
491 }
492
493 assert!(cache.remove(&id).expect("remove"));
494 assert!(!cache.contains(&id));
495 let _ = std::fs::remove_dir_all(&dir);
496 }
497
498 #[test]
501 fn get_nonexistent_returns_none() {
502 let (cache, dir) = temp_cache("miss");
503 assert!(cache.get(&TileId::new(0, 0, 0)).expect("get").is_none());
504 let _ = std::fs::remove_dir_all(&dir);
505 }
506
507 #[test]
510 fn remove_nonexistent_returns_false() {
511 let (cache, dir) = temp_cache("remove_miss");
512 assert!(!cache.remove(&TileId::new(0, 0, 0)).expect("remove"));
513 let _ = std::fs::remove_dir_all(&dir);
514 }
515
516 #[test]
519 fn decode_too_short() {
520 assert!(decode_tile_data(&[0, 1, 2]).is_err());
521 }
522
523 #[test]
524 fn decode_bad_magic() {
525 let mut data = vec![b'X', b'Y', VERSION, KIND_RASTER];
526 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());
530 }
531
532 #[test]
533 fn decode_wrong_version() {
534 let mut data = vec![b'R', b'M', 99, KIND_RASTER];
535 data.extend_from_slice(&1u32.to_le_bytes());
536 data.extend_from_slice(&1u32.to_le_bytes());
537 data.extend_from_slice(&[0u8; 4]);
538 assert!(decode_tile_data(&data).is_err());
539 }
540
541 #[test]
542 fn decode_unknown_kind() {
543 let mut data = vec![b'R', b'M', VERSION, 77];
544 data.extend_from_slice(&1u32.to_le_bytes());
545 data.extend_from_slice(&1u32.to_le_bytes());
546 data.extend_from_slice(&[0u8; 4]);
547 assert!(decode_tile_data(&data).is_err());
548 }
549
550 #[test]
551 fn decode_wrong_pixel_length() {
552 let mut data = vec![b'R', b'M', VERSION, KIND_RASTER];
553 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());
557 }
558
559 #[test]
560 fn decode_overflow_dimensions() {
561 let mut data = vec![b'R', b'M', VERSION, KIND_RASTER];
562 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());
566 }
567
568 #[test]
571 fn clear_removes_all_tiles() {
572 let (cache, dir) = temp_cache("clear");
573 let data = sample_tile();
574 cache.put(&TileId::new(1, 0, 0), &data).expect("put");
575 cache.put(&TileId::new(2, 1, 1), &data).expect("put");
576 assert_eq!(cache.len().expect("len"), 2);
577
578 cache.clear().expect("clear");
579 assert!(cache.is_empty().expect("is_empty"));
580 let _ = std::fs::remove_dir_all(&dir);
581 }
582
583 #[test]
586 fn len_counts_tiles() {
587 let (cache, dir) = temp_cache("len");
588 assert!(cache.is_empty().expect("empty"));
589
590 let data = sample_tile();
591 cache.put(&TileId::new(0, 0, 0), &data).expect("put");
592 cache.put(&TileId::new(1, 0, 0), &data).expect("put");
593 assert_eq!(cache.len().expect("len"), 2);
594 let _ = std::fs::remove_dir_all(&dir);
595 }
596
597 #[test]
600 fn evict_removes_old_tiles() {
601 let (cache, dir) = temp_cache("evict");
602 let data = sample_tile();
603 cache.put(&TileId::new(0, 0, 0), &data).expect("put");
604
605 let removed = cache
607 .evict_older_than(std::time::Duration::ZERO)
608 .expect("evict");
609 assert_eq!(removed, 1);
610 assert!(cache.is_empty().expect("is_empty"));
611 let _ = std::fs::remove_dir_all(&dir);
612 }
613
614 #[test]
615 fn evict_keeps_recent_tiles() {
616 let (cache, dir) = temp_cache("evict_keep");
617 let data = sample_tile();
618 cache.put(&TileId::new(0, 0, 0), &data).expect("put");
619
620 let removed = cache
622 .evict_older_than(std::time::Duration::from_secs(3600))
623 .expect("evict");
624 assert_eq!(removed, 0);
625 assert_eq!(cache.len().expect("len"), 1);
626 let _ = std::fs::remove_dir_all(&dir);
627 }
628
629 #[test]
630 fn evict_to_size_removes_excess_files() {
631 let (cache, dir) = temp_cache("evict_to_size");
632 let data = sample_tile();
633 cache.put(&TileId::new(0, 0, 0), &data).expect("put");
634 cache.put(&TileId::new(1, 0, 0), &data).expect("put");
635 cache.put(&TileId::new(2, 0, 0), &data).expect("put");
636 assert_eq!(cache.len().expect("len"), 3);
637
638 let removed = cache
640 .evict_to_size(64)
641 .expect("evict_to_size");
642 assert_eq!(removed, 0);
643 assert_eq!(cache.len().expect("len"), 3);
644
645 let removed = cache
647 .evict_to_size(15)
648 .expect("evict_to_size");
649 assert_eq!(removed, 1);
650 assert_eq!(cache.len().expect("len"), 2);
651
652 let _ = std::fs::remove_dir_all(&dir);
654 }
655
656 #[test]
657 fn evict_to_size_removes_oldest() {
658 let (cache, dir) = temp_cache("evict_size");
659 let data = sample_tile();
660
661 cache.put(&TileId::new(0, 0, 0), &data).expect("put");
663 std::thread::sleep(std::time::Duration::from_millis(50));
665 cache.put(&TileId::new(1, 0, 0), &data).expect("put");
666
667 assert_eq!(cache.len().expect("len"), 2);
668
669 let removed = cache.evict_to_size(30).expect("evict");
671 assert_eq!(removed, 1);
672 assert_eq!(cache.len().expect("len"), 1);
673 assert!(!cache.contains(&TileId::new(0, 0, 0)));
675 assert!(cache.contains(&TileId::new(1, 0, 0)));
676
677 let _ = std::fs::remove_dir_all(&dir);
678 }
679
680 #[test]
681 fn evict_to_size_noop_when_under_limit() {
682 let (cache, dir) = temp_cache("evict_size_noop");
683 let data = sample_tile();
684 cache.put(&TileId::new(0, 0, 0), &data).expect("put");
685
686 let removed = cache.evict_to_size(1_000_000).expect("evict");
687 assert_eq!(removed, 0);
688 assert_eq!(cache.len().expect("len"), 1);
689 let _ = std::fs::remove_dir_all(&dir);
690 }
691}