1use std::fs;
14use std::io;
15use std::path::PathBuf;
16
17use directories::ProjectDirs;
18use thiserror::Error;
19
20use crate::Cache;
21
22const CACHE_FILENAME: &str = "cache.json";
24
25const QUALIFIER: &str = "";
27
28const ORGANIZATION: &str = "";
30
31const APPLICATION: &str = "td";
33
34#[derive(Debug, Error)]
36pub enum CacheStoreError {
37 #[error("failed to determine cache directory: no valid home directory found")]
39 NoCacheDir,
40
41 #[error("failed to read cache file '{path}': {source}")]
43 ReadError {
44 path: PathBuf,
46 #[source]
48 source: io::Error,
49 },
50
51 #[error("failed to write cache file '{path}': {source}")]
53 WriteError {
54 path: PathBuf,
56 #[source]
58 source: io::Error,
59 },
60
61 #[error("failed to create cache directory '{path}': {source}")]
63 CreateDirError {
64 path: PathBuf,
66 #[source]
68 source: io::Error,
69 },
70
71 #[error("failed to delete cache file '{path}': {source}")]
73 DeleteError {
74 path: PathBuf,
76 #[source]
78 source: io::Error,
79 },
80
81 #[error("JSON error: {0}")]
83 Json(#[from] serde_json::Error),
84}
85
86pub type Result<T> = std::result::Result<T, CacheStoreError>;
88
89#[derive(Debug, Clone)]
126pub struct CacheStore {
127 path: PathBuf,
129}
130
131impl CacheStore {
132 pub fn new() -> Result<Self> {
140 let path = Self::default_path()?;
141 Ok(Self { path })
142 }
143
144 pub fn with_path(path: PathBuf) -> Self {
148 Self { path }
149 }
150
151 pub fn default_path() -> Result<PathBuf> {
161 let project_dirs = ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
162 .ok_or(CacheStoreError::NoCacheDir)?;
163
164 let cache_dir = project_dirs.cache_dir();
165 Ok(cache_dir.join(CACHE_FILENAME))
166 }
167
168 pub fn path(&self) -> &PathBuf {
170 &self.path
171 }
172
173 pub fn load(&self) -> Result<Cache> {
186 let contents = fs::read_to_string(&self.path).map_err(|e| CacheStoreError::ReadError {
187 path: self.path.clone(),
188 source: e,
189 })?;
190 let mut cache: Cache = serde_json::from_str(&contents)?;
191 cache.rebuild_indexes();
193 Ok(cache)
194 }
195
196 pub fn load_or_default(&self) -> Result<Cache> {
203 match self.load() {
204 Ok(cache) => Ok(cache),
205 Err(CacheStoreError::ReadError { ref source, .. })
206 if source.kind() == io::ErrorKind::NotFound =>
207 {
208 Ok(Cache::default())
209 }
210 Err(e) => Err(e),
211 }
212 }
213
214 pub fn save(&self, cache: &Cache) -> Result<()> {
228 if let Some(parent) = self.path.parent() {
230 fs::create_dir_all(parent).map_err(|e| CacheStoreError::CreateDirError {
231 path: parent.to_path_buf(),
232 source: e,
233 })?;
234 }
235
236 let json = serde_json::to_string_pretty(cache)?;
237
238 let temp_path = self.path.with_extension("tmp");
241 fs::write(&temp_path, &json).map_err(|e| CacheStoreError::WriteError {
242 path: temp_path.clone(),
243 source: e,
244 })?;
245 fs::rename(&temp_path, &self.path).map_err(|e| CacheStoreError::WriteError {
246 path: self.path.clone(),
247 source: e,
248 })?;
249
250 Ok(())
251 }
252
253 pub fn exists(&self) -> bool {
255 self.path.exists()
256 }
257
258 pub fn delete(&self) -> Result<()> {
265 match fs::remove_file(&self.path) {
266 Ok(()) => Ok(()),
267 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
268 Err(e) => Err(CacheStoreError::DeleteError {
269 path: self.path.clone(),
270 source: e,
271 }),
272 }
273 }
274
275 pub async fn load_async(&self) -> Result<Cache> {
295 let contents = tokio::fs::read_to_string(&self.path).await.map_err(|e| {
296 CacheStoreError::ReadError {
297 path: self.path.clone(),
298 source: e,
299 }
300 })?;
301 let mut cache: Cache = serde_json::from_str(&contents)?;
302 cache.rebuild_indexes();
304 Ok(cache)
305 }
306
307 pub async fn load_or_default_async(&self) -> Result<Cache> {
316 match self.load_async().await {
317 Ok(cache) => Ok(cache),
318 Err(CacheStoreError::ReadError { ref source, .. })
319 if source.kind() == io::ErrorKind::NotFound =>
320 {
321 Ok(Cache::default())
322 }
323 Err(e) => Err(e),
324 }
325 }
326
327 pub async fn save_async(&self, cache: &Cache) -> Result<()> {
344 if let Some(parent) = self.path.parent() {
346 tokio::fs::create_dir_all(parent).await.map_err(|e| {
347 CacheStoreError::CreateDirError {
348 path: parent.to_path_buf(),
349 source: e,
350 }
351 })?;
352 }
353
354 let json = serde_json::to_string_pretty(cache)?;
355
356 let temp_path = self.path.with_extension("tmp");
359 tokio::fs::write(&temp_path, &json)
360 .await
361 .map_err(|e| CacheStoreError::WriteError {
362 path: temp_path.clone(),
363 source: e,
364 })?;
365 tokio::fs::rename(&temp_path, &self.path)
366 .await
367 .map_err(|e| CacheStoreError::WriteError {
368 path: self.path.clone(),
369 source: e,
370 })?;
371
372 Ok(())
373 }
374
375 pub async fn delete_async(&self) -> Result<()> {
384 match tokio::fs::remove_file(&self.path).await {
385 Ok(()) => Ok(()),
386 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
387 Err(e) => Err(CacheStoreError::DeleteError {
388 path: self.path.clone(),
389 source: e,
390 }),
391 }
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
404 fn test_default_path_returns_xdg_path() {
405 let path = CacheStore::default_path().expect("should get default path");
406
407 let path_str = path.to_string_lossy();
409 assert!(
410 path_str.ends_with("td/cache.json")
411 || path_str.ends_with("td\\cache.json")
412 || path_str.ends_with("td/cache/cache.json")
413 || path_str.ends_with("td\\cache\\cache.json"),
414 "path should contain td and cache.json: {}",
415 path_str
416 );
417
418 assert!(path.is_absolute(), "path should be absolute: {:?}", path);
420 }
421
422 #[test]
423 fn test_cache_store_new_uses_default_path() {
424 let store = CacheStore::new().expect("should create store");
425 let default_path = CacheStore::default_path().expect("should get default path");
426
427 assert_eq!(store.path(), &default_path);
428 }
429
430 #[test]
431 fn test_cache_store_with_custom_path() {
432 let custom_path = PathBuf::from("/tmp/test/cache.json");
433 let store = CacheStore::with_path(custom_path.clone());
434
435 assert_eq!(store.path(), &custom_path);
436 }
437
438 #[test]
439 fn test_cache_store_path_contains_application_name() {
440 let path = CacheStore::default_path().expect("should get default path");
441 let path_str = path.to_string_lossy();
442
443 assert!(
444 path_str.contains("td"),
445 "path should contain 'td': {}",
446 path_str
447 );
448 }
449
450 #[test]
451 fn test_read_error_includes_file_path() {
452 let path = PathBuf::from("/nonexistent/path/to/cache.json");
453 let store = CacheStore::with_path(path.clone());
454
455 let result = store.load();
456 assert!(result.is_err());
457
458 let error = result.unwrap_err();
459 let error_msg = error.to_string();
460
461 assert!(
463 error_msg.contains("/nonexistent/path/to/cache.json"),
464 "error should include file path: {}",
465 error_msg
466 );
467 assert!(
468 error_msg.contains("failed to read cache file"),
469 "error should describe the operation: {}",
470 error_msg
471 );
472 }
473
474 #[test]
475 fn test_read_error_has_source() {
476 use std::error::Error;
477
478 let path = PathBuf::from("/nonexistent/path/to/cache.json");
479 let store = CacheStore::with_path(path);
480
481 let result = store.load();
482 let error = result.unwrap_err();
483
484 assert!(
486 error.source().is_some(),
487 "error should have a source io::Error"
488 );
489 }
490
491 #[test]
492 fn test_load_or_default_still_works_for_not_found() {
493 let path = PathBuf::from("/nonexistent/path/to/cache.json");
494 let store = CacheStore::with_path(path);
495
496 let result = store.load_or_default();
498 assert!(result.is_ok());
499
500 let cache = result.unwrap();
501 assert_eq!(cache.sync_token, "*");
502 }
503
504 #[test]
505 fn test_write_error_includes_file_path() {
506 use tempfile::tempdir;
507
508 let temp_dir = tempdir().expect("failed to create temp dir");
510 let blocker_file = temp_dir.path().join("blocker");
511 fs::write(&blocker_file, "blocking").expect("failed to create blocker file");
512
513 let path = blocker_file.join("subdir").join("cache.json");
515 let store = CacheStore::with_path(path);
516
517 let cache = crate::Cache::new();
518 let result = store.save(&cache);
519 assert!(result.is_err());
520
521 let error = result.unwrap_err();
522 let error_msg = error.to_string();
523
524 assert!(
526 error_msg.contains("failed to create cache directory")
527 || error_msg.contains("failed to write cache file"),
528 "error should describe the operation: {}",
529 error_msg
530 );
531 assert!(
532 error_msg.contains("blocker"),
533 "error should include path component: {}",
534 error_msg
535 );
536 }
537
538 #[test]
539 fn test_delete_error_includes_file_path() {
540 use tempfile::tempdir;
542
543 let temp_dir = tempdir().expect("failed to create temp dir");
544 let path = temp_dir.path().join("cache.json");
545
546 fs::create_dir(&path).expect("failed to create directory");
548
549 let store = CacheStore::with_path(path.clone());
550 let result = store.delete();
551
552 if let Err(error) = result {
555 let error_msg = error.to_string();
556 assert!(
557 error_msg.contains("cache.json"),
558 "error should include file path: {}",
559 error_msg
560 );
561 assert!(
562 error_msg.contains("failed to delete cache file"),
563 "error should describe the operation: {}",
564 error_msg
565 );
566 }
567 }
568
569 #[test]
570 fn test_error_message_format_read() {
571 let error = CacheStoreError::ReadError {
572 path: PathBuf::from("/home/user/.cache/td/cache.json"),
573 source: io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"),
574 };
575
576 let msg = error.to_string();
577 assert_eq!(
578 msg,
579 "failed to read cache file '/home/user/.cache/td/cache.json': permission denied"
580 );
581 }
582
583 #[test]
584 fn test_error_message_format_write() {
585 let error = CacheStoreError::WriteError {
586 path: PathBuf::from("/home/user/.cache/td/cache.json"),
587 source: io::Error::other("disk full"),
588 };
589
590 let msg = error.to_string();
591 assert_eq!(
592 msg,
593 "failed to write cache file '/home/user/.cache/td/cache.json': disk full"
594 );
595 }
596
597 #[test]
598 fn test_error_message_format_create_dir() {
599 let error = CacheStoreError::CreateDirError {
600 path: PathBuf::from("/home/user/.cache/td"),
601 source: io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"),
602 };
603
604 let msg = error.to_string();
605 assert_eq!(
606 msg,
607 "failed to create cache directory '/home/user/.cache/td': permission denied"
608 );
609 }
610
611 #[test]
612 fn test_error_message_format_delete() {
613 let error = CacheStoreError::DeleteError {
614 path: PathBuf::from("/home/user/.cache/td/cache.json"),
615 source: io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"),
616 };
617
618 let msg = error.to_string();
619 assert_eq!(
620 msg,
621 "failed to delete cache file '/home/user/.cache/td/cache.json': permission denied"
622 );
623 }
624
625 #[tokio::test]
630 async fn test_save_and_load_async() {
631 use tempfile::tempdir;
632
633 let temp_dir = tempdir().expect("failed to create temp dir");
634 let path = temp_dir.path().join("cache.json");
635 let store = CacheStore::with_path(path);
636
637 let mut cache = crate::Cache::new();
639 cache.sync_token = "test-token".to_string();
640
641 store.save_async(&cache).await.expect("save_async failed");
643
644 let loaded = store.load_async().await.expect("load_async failed");
646 assert_eq!(loaded.sync_token, "test-token");
647 }
648
649 #[tokio::test]
650 async fn test_atomic_write_async() {
651 use tempfile::tempdir;
652
653 let temp_dir = tempdir().expect("failed to create temp dir");
654 let path = temp_dir.path().join("cache.json");
655 let store = CacheStore::with_path(path.clone());
656
657 let cache = crate::Cache::new();
658 store.save_async(&cache).await.expect("save_async failed");
659
660 let temp_path = path.with_extension("tmp");
662 assert!(!temp_path.exists(), "temp file should be cleaned up");
663 assert!(path.exists(), "cache file should exist");
664 }
665
666 #[tokio::test]
667 async fn test_load_async_missing_file() {
668 let path = PathBuf::from("/nonexistent/path/to/cache.json");
669 let store = CacheStore::with_path(path);
670
671 let result = store.load_async().await;
672 assert!(result.is_err());
673
674 match result.unwrap_err() {
676 CacheStoreError::ReadError { source, .. } => {
677 assert_eq!(source.kind(), io::ErrorKind::NotFound);
678 }
679 other => panic!("expected ReadError, got {:?}", other),
680 }
681 }
682
683 #[tokio::test]
684 async fn test_load_or_default_async_missing_file() {
685 let path = PathBuf::from("/nonexistent/path/to/cache.json");
686 let store = CacheStore::with_path(path);
687
688 let result = store.load_or_default_async().await;
690 assert!(result.is_ok());
691
692 let cache = result.unwrap();
693 assert_eq!(cache.sync_token, "*");
694 }
695
696 #[tokio::test]
697 async fn test_delete_async() {
698 use tempfile::tempdir;
699
700 let temp_dir = tempdir().expect("failed to create temp dir");
701 let path = temp_dir.path().join("cache.json");
702 let store = CacheStore::with_path(path.clone());
703
704 let cache = crate::Cache::new();
706 store.save_async(&cache).await.expect("save_async failed");
707 assert!(path.exists());
708
709 store.delete_async().await.expect("delete_async failed");
711 assert!(!path.exists());
712 }
713
714 #[tokio::test]
715 async fn test_delete_async_nonexistent() {
716 let path = PathBuf::from("/nonexistent/path/to/cache.json");
717 let store = CacheStore::with_path(path);
718
719 let result = store.delete_async().await;
721 assert!(result.is_ok());
722 }
723
724 #[tokio::test]
725 async fn test_save_async_creates_directory() {
726 use tempfile::tempdir;
727
728 let temp_dir = tempdir().expect("failed to create temp dir");
729 let path = temp_dir
730 .path()
731 .join("subdir")
732 .join("nested")
733 .join("cache.json");
734 let store = CacheStore::with_path(path.clone());
735
736 assert!(!path.parent().unwrap().exists());
738
739 let cache = crate::Cache::new();
741 store.save_async(&cache).await.expect("save_async failed");
742
743 assert!(path.exists());
744 }
745}