Skip to main content

todoist_cache_rs/
store.rs

1//! Cache file storage with XDG path support.
2//!
3//! This module provides persistent storage for the Todoist cache using XDG-compliant
4//! paths. The cache is stored as JSON at `~/.cache/td/cache.json`.
5//!
6//! Both synchronous and asynchronous I/O methods are provided:
7//! - `save()`, `load()` - Synchronous methods using `std::fs`
8//! - `save_async()`, `load_async()` - Asynchronous methods using `tokio::fs`
9//!
10//! The async methods are recommended for use in async contexts (like `SyncManager::sync()`)
11//! to avoid blocking the tokio runtime.
12
13use std::fs;
14use std::io;
15use std::path::PathBuf;
16
17use directories::ProjectDirs;
18use thiserror::Error;
19
20use crate::Cache;
21
22/// Default cache filename.
23const CACHE_FILENAME: &str = "cache.json";
24
25/// Application qualifier (for XDG paths).
26const QUALIFIER: &str = "";
27
28/// Application organization (for XDG paths).
29const ORGANIZATION: &str = "";
30
31/// Application name (for XDG paths).
32const APPLICATION: &str = "td";
33
34/// Errors that can occur during cache storage operations.
35#[derive(Debug, Error)]
36pub enum CacheStoreError {
37    /// Failed to determine XDG cache directory.
38    #[error("failed to determine cache directory: no valid home directory found")]
39    NoCacheDir,
40
41    /// I/O error during file read.
42    #[error("failed to read cache file '{path}': {source}")]
43    ReadError {
44        /// The path that failed to read.
45        path: PathBuf,
46        /// The underlying I/O error.
47        #[source]
48        source: io::Error,
49    },
50
51    /// I/O error during file write.
52    #[error("failed to write cache file '{path}': {source}")]
53    WriteError {
54        /// The path that failed to write.
55        path: PathBuf,
56        /// The underlying I/O error.
57        #[source]
58        source: io::Error,
59    },
60
61    /// I/O error during directory creation.
62    #[error("failed to create cache directory '{path}': {source}")]
63    CreateDirError {
64        /// The directory path that failed to create.
65        path: PathBuf,
66        /// The underlying I/O error.
67        #[source]
68        source: io::Error,
69    },
70
71    /// I/O error during file delete.
72    #[error("failed to delete cache file '{path}': {source}")]
73    DeleteError {
74        /// The path that failed to delete.
75        path: PathBuf,
76        /// The underlying I/O error.
77        #[source]
78        source: io::Error,
79    },
80
81    /// JSON serialization/deserialization error.
82    #[error("JSON error: {0}")]
83    Json(#[from] serde_json::Error),
84}
85
86/// Result type for cache store operations.
87pub type Result<T> = std::result::Result<T, CacheStoreError>;
88
89/// Persistent storage for the Todoist cache.
90///
91/// `CacheStore` handles reading and writing the cache to disk using XDG-compliant
92/// paths. On Unix systems, the cache is stored at `~/.cache/td/cache.json`.
93///
94/// # Thread Safety
95///
96/// `CacheStore` is [`Send`] and [`Sync`], but file operations are not atomic.
97/// Concurrent calls to `save()` from multiple threads could result in corrupted
98/// data on disk. For concurrent access, use external synchronization:
99///
100/// ```no_run
101/// use std::sync::{Arc, Mutex};
102/// use todoist_cache_rs::CacheStore;
103///
104/// let store = Arc::new(Mutex::new(CacheStore::new()?));
105/// # Ok::<(), todoist_cache_rs::CacheStoreError>(())
106/// ```
107///
108/// In typical CLI usage, the store is owned by a single-threaded runtime
109/// and external synchronization is not needed.
110///
111/// # Example
112///
113/// ```no_run
114/// use todoist_cache_rs::{Cache, CacheStore};
115///
116/// let store = CacheStore::new()?;
117///
118/// // Load existing cache or create new one
119/// let cache = store.load().unwrap_or_default();
120///
121/// // Save cache to disk
122/// store.save(&cache)?;
123/// # Ok::<(), todoist_cache_rs::CacheStoreError>(())
124/// ```
125#[derive(Debug, Clone)]
126pub struct CacheStore {
127    /// Path to the cache file.
128    path: PathBuf,
129}
130
131impl CacheStore {
132    /// Creates a new `CacheStore` with the default XDG cache path.
133    ///
134    /// The cache file will be located at `~/.cache/td/cache.json` on Unix systems.
135    ///
136    /// # Errors
137    ///
138    /// Returns `CacheStoreError::NoCacheDir` if the home directory cannot be determined.
139    pub fn new() -> Result<Self> {
140        let path = Self::default_path()?;
141        Ok(Self { path })
142    }
143
144    /// Creates a new `CacheStore` with a custom path.
145    ///
146    /// This is primarily useful for testing.
147    pub fn with_path(path: PathBuf) -> Self {
148        Self { path }
149    }
150
151    /// Returns the default XDG cache path for the cache file.
152    ///
153    /// On Unix: `~/.cache/td/cache.json`
154    /// On macOS: `~/Library/Caches/td/cache.json`
155    /// On Windows: `C:\Users\<User>\AppData\Local\td\cache\cache.json`
156    ///
157    /// # Errors
158    ///
159    /// Returns `CacheStoreError::NoCacheDir` if the home directory cannot be determined.
160    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    /// Returns the path to the cache file.
169    pub fn path(&self) -> &PathBuf {
170        &self.path
171    }
172
173    /// Loads the cache from disk.
174    ///
175    /// # Errors
176    ///
177    /// - Returns `CacheStoreError::ReadError` if the file cannot be read.
178    /// - Returns `CacheStoreError::Json` if the file contains invalid JSON.
179    ///
180    /// # Note
181    ///
182    /// If the cache file does not exist, this returns an I/O error with
183    /// `ErrorKind::NotFound`. Use `load_or_default()` to get a default cache
184    /// when the file is missing.
185    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        // Rebuild indexes since they are not serialized
192        cache.rebuild_indexes();
193        Ok(cache)
194    }
195
196    /// Loads the cache from disk, returning a default cache if the file doesn't exist.
197    ///
198    /// # Errors
199    ///
200    /// - Returns `CacheStoreError::ReadError` for I/O errors other than "file not found".
201    /// - Returns `CacheStoreError::Json` if the file contains invalid JSON.
202    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    /// Saves the cache to disk atomically.
215    ///
216    /// Creates the parent directory if it doesn't exist. The cache is written
217    /// as pretty-printed JSON for easier debugging.
218    ///
219    /// Uses atomic write (tempfile + rename) to prevent corruption if the process
220    /// crashes mid-write.
221    ///
222    /// # Errors
223    ///
224    /// - Returns `CacheStoreError::CreateDirError` if the directory cannot be created.
225    /// - Returns `CacheStoreError::WriteError` if the file cannot be written.
226    /// - Returns `CacheStoreError::Json` if serialization fails.
227    pub fn save(&self, cache: &Cache) -> Result<()> {
228        // Ensure parent directory exists
229        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        // Atomic write: write to temp file, then rename
239        // This prevents corruption if the process crashes mid-write
240        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    /// Returns true if the cache file exists on disk.
254    pub fn exists(&self) -> bool {
255        self.path.exists()
256    }
257
258    /// Deletes the cache file from disk.
259    ///
260    /// # Errors
261    ///
262    /// Returns `CacheStoreError::DeleteError` if the file cannot be deleted.
263    /// Does not return an error if the file doesn't exist.
264    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    // =========================================================================
276    // Async I/O Methods
277    // =========================================================================
278
279    /// Loads the cache from disk asynchronously.
280    ///
281    /// This is the async equivalent of [`load()`](Self::load). Use this method
282    /// in async contexts to avoid blocking the tokio runtime.
283    ///
284    /// # Errors
285    ///
286    /// - Returns `CacheStoreError::ReadError` if the file cannot be read.
287    /// - Returns `CacheStoreError::Json` if the file contains invalid JSON.
288    ///
289    /// # Note
290    ///
291    /// If the cache file does not exist, this returns an I/O error with
292    /// `ErrorKind::NotFound`. Use [`load_or_default_async()`](Self::load_or_default_async)
293    /// to get a default cache when the file is missing.
294    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        // Rebuild indexes since they are not serialized
303        cache.rebuild_indexes();
304        Ok(cache)
305    }
306
307    /// Loads the cache from disk asynchronously, returning a default cache if the file doesn't exist.
308    ///
309    /// This is the async equivalent of [`load_or_default()`](Self::load_or_default).
310    ///
311    /// # Errors
312    ///
313    /// - Returns `CacheStoreError::ReadError` for I/O errors other than "file not found".
314    /// - Returns `CacheStoreError::Json` if the file contains invalid JSON.
315    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    /// Saves the cache to disk asynchronously using atomic write.
328    ///
329    /// This is the async equivalent of [`save()`](Self::save). Use this method
330    /// in async contexts to avoid blocking the tokio runtime.
331    ///
332    /// Creates the parent directory if it doesn't exist. The cache is written
333    /// as pretty-printed JSON for easier debugging.
334    ///
335    /// Uses atomic write (tempfile + rename) to prevent corruption if the process
336    /// crashes mid-write.
337    ///
338    /// # Errors
339    ///
340    /// - Returns `CacheStoreError::CreateDirError` if the directory cannot be created.
341    /// - Returns `CacheStoreError::WriteError` if the file cannot be written.
342    /// - Returns `CacheStoreError::Json` if serialization fails.
343    pub async fn save_async(&self, cache: &Cache) -> Result<()> {
344        // Ensure parent directory exists
345        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        // Atomic write: write to temp file, then rename
357        // This prevents corruption if the process crashes mid-write
358        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    /// Deletes the cache file from disk asynchronously.
376    ///
377    /// This is the async equivalent of [`delete()`](Self::delete).
378    ///
379    /// # Errors
380    ///
381    /// Returns `CacheStoreError::DeleteError` if the file cannot be deleted.
382    /// Does not return an error if the file doesn't exist.
383    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    // ==========================================================================
400    // Synchronous I/O Tests
401    // ==========================================================================
402
403    #[test]
404    fn test_default_path_returns_xdg_path() {
405        let path = CacheStore::default_path().expect("should get default path");
406
407        // Path should end with td/cache.json (or td\cache.json on Windows)
408        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        // Path should be absolute
419        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        // Error message should include the file path
462        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        // Should have an underlying source error
485        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        // load_or_default should return a default cache for missing files
497        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        // Create a file where we need a directory - this will cause mkdir to fail
509        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        // Try to create a cache file inside the blocker file (which is not a directory)
514        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        // Error message should describe the operation and include a path
525        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        // Create a directory where a file is expected - delete will fail
541        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        // Create a directory at the cache path (can't delete a directory with remove_file)
547        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        // On some systems this may succeed or fail depending on behavior
553        // If it fails, the error should include the path
554        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    // ==========================================================================
626    // Async I/O Tests
627    // ==========================================================================
628
629    #[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        // Create a cache with some data
638        let mut cache = crate::Cache::new();
639        cache.sync_token = "test-token".to_string();
640
641        // Save asynchronously
642        store.save_async(&cache).await.expect("save_async failed");
643
644        // Load asynchronously
645        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        // Verify no temp file left behind
661        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        // Should be a ReadError with NotFound
675        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        // Should return default cache for missing files
689        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        // Create the file
705        let cache = crate::Cache::new();
706        store.save_async(&cache).await.expect("save_async failed");
707        assert!(path.exists());
708
709        // Delete asynchronously
710        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        // Should not error for missing files
720        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        // Parent directory doesn't exist
737        assert!(!path.parent().unwrap().exists());
738
739        // Save should create it
740        let cache = crate::Cache::new();
741        store.save_async(&cache).await.expect("save_async failed");
742
743        assert!(path.exists());
744    }
745}