use std::fs;
use std::io;
use std::path::PathBuf;
use directories::ProjectDirs;
use thiserror::Error;
use crate::Cache;
const CACHE_FILENAME: &str = "cache.json";
const QUALIFIER: &str = "";
const ORGANIZATION: &str = "";
const APPLICATION: &str = "td";
#[derive(Debug, Error)]
pub enum CacheStoreError {
#[error("failed to determine cache directory: no valid home directory found")]
NoCacheDir,
#[error("failed to read cache file '{path}': {source}")]
ReadError {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to write cache file '{path}': {source}")]
WriteError {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to create cache directory '{path}': {source}")]
CreateDirError {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to delete cache file '{path}': {source}")]
DeleteError {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, CacheStoreError>;
#[derive(Debug, Clone)]
pub struct CacheStore {
path: PathBuf,
}
impl CacheStore {
pub fn new() -> Result<Self> {
let path = Self::default_path()?;
Ok(Self { path })
}
pub fn with_path(path: PathBuf) -> Self {
Self { path }
}
pub fn default_path() -> Result<PathBuf> {
let project_dirs = ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
.ok_or(CacheStoreError::NoCacheDir)?;
let cache_dir = project_dirs.cache_dir();
Ok(cache_dir.join(CACHE_FILENAME))
}
pub fn path(&self) -> &PathBuf {
&self.path
}
pub fn load(&self) -> Result<Cache> {
let contents = fs::read_to_string(&self.path).map_err(|e| CacheStoreError::ReadError {
path: self.path.clone(),
source: e,
})?;
let mut cache: Cache = serde_json::from_str(&contents)?;
cache.rebuild_indexes();
Ok(cache)
}
pub fn load_or_default(&self) -> Result<Cache> {
match self.load() {
Ok(cache) => Ok(cache),
Err(CacheStoreError::ReadError { ref source, .. })
if source.kind() == io::ErrorKind::NotFound =>
{
Ok(Cache::default())
}
Err(e) => Err(e),
}
}
pub fn save(&self, cache: &Cache) -> Result<()> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).map_err(|e| CacheStoreError::CreateDirError {
path: parent.to_path_buf(),
source: e,
})?;
}
let json = serde_json::to_string_pretty(cache)?;
let temp_path = self.path.with_extension("tmp");
fs::write(&temp_path, &json).map_err(|e| CacheStoreError::WriteError {
path: temp_path.clone(),
source: e,
})?;
fs::rename(&temp_path, &self.path).map_err(|e| CacheStoreError::WriteError {
path: self.path.clone(),
source: e,
})?;
Ok(())
}
pub fn exists(&self) -> bool {
self.path.exists()
}
pub fn delete(&self) -> Result<()> {
match fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(CacheStoreError::DeleteError {
path: self.path.clone(),
source: e,
}),
}
}
pub async fn load_async(&self) -> Result<Cache> {
let contents = tokio::fs::read_to_string(&self.path).await.map_err(|e| {
CacheStoreError::ReadError {
path: self.path.clone(),
source: e,
}
})?;
let mut cache: Cache = serde_json::from_str(&contents)?;
cache.rebuild_indexes();
Ok(cache)
}
pub async fn load_or_default_async(&self) -> Result<Cache> {
match self.load_async().await {
Ok(cache) => Ok(cache),
Err(CacheStoreError::ReadError { ref source, .. })
if source.kind() == io::ErrorKind::NotFound =>
{
Ok(Cache::default())
}
Err(e) => Err(e),
}
}
pub async fn save_async(&self, cache: &Cache) -> Result<()> {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
CacheStoreError::CreateDirError {
path: parent.to_path_buf(),
source: e,
}
})?;
}
let json = serde_json::to_string_pretty(cache)?;
let temp_path = self.path.with_extension("tmp");
tokio::fs::write(&temp_path, &json)
.await
.map_err(|e| CacheStoreError::WriteError {
path: temp_path.clone(),
source: e,
})?;
tokio::fs::rename(&temp_path, &self.path)
.await
.map_err(|e| CacheStoreError::WriteError {
path: self.path.clone(),
source: e,
})?;
Ok(())
}
pub async fn delete_async(&self) -> Result<()> {
match tokio::fs::remove_file(&self.path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(CacheStoreError::DeleteError {
path: self.path.clone(),
source: e,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_path_returns_xdg_path() {
let path = CacheStore::default_path().expect("should get default path");
let path_str = path.to_string_lossy();
assert!(
path_str.ends_with("td/cache.json")
|| path_str.ends_with("td\\cache.json")
|| path_str.ends_with("td/cache/cache.json")
|| path_str.ends_with("td\\cache\\cache.json"),
"path should contain td and cache.json: {}",
path_str
);
assert!(path.is_absolute(), "path should be absolute: {:?}", path);
}
#[test]
fn test_cache_store_new_uses_default_path() {
let store = CacheStore::new().expect("should create store");
let default_path = CacheStore::default_path().expect("should get default path");
assert_eq!(store.path(), &default_path);
}
#[test]
fn test_cache_store_with_custom_path() {
let custom_path = PathBuf::from("/tmp/test/cache.json");
let store = CacheStore::with_path(custom_path.clone());
assert_eq!(store.path(), &custom_path);
}
#[test]
fn test_cache_store_path_contains_application_name() {
let path = CacheStore::default_path().expect("should get default path");
let path_str = path.to_string_lossy();
assert!(
path_str.contains("td"),
"path should contain 'td': {}",
path_str
);
}
#[test]
fn test_read_error_includes_file_path() {
let path = PathBuf::from("/nonexistent/path/to/cache.json");
let store = CacheStore::with_path(path.clone());
let result = store.load();
assert!(result.is_err());
let error = result.unwrap_err();
let error_msg = error.to_string();
assert!(
error_msg.contains("/nonexistent/path/to/cache.json"),
"error should include file path: {}",
error_msg
);
assert!(
error_msg.contains("failed to read cache file"),
"error should describe the operation: {}",
error_msg
);
}
#[test]
fn test_read_error_has_source() {
use std::error::Error;
let path = PathBuf::from("/nonexistent/path/to/cache.json");
let store = CacheStore::with_path(path);
let result = store.load();
let error = result.unwrap_err();
assert!(
error.source().is_some(),
"error should have a source io::Error"
);
}
#[test]
fn test_load_or_default_still_works_for_not_found() {
let path = PathBuf::from("/nonexistent/path/to/cache.json");
let store = CacheStore::with_path(path);
let result = store.load_or_default();
assert!(result.is_ok());
let cache = result.unwrap();
assert_eq!(cache.sync_token, "*");
}
#[test]
fn test_write_error_includes_file_path() {
use tempfile::tempdir;
let temp_dir = tempdir().expect("failed to create temp dir");
let blocker_file = temp_dir.path().join("blocker");
fs::write(&blocker_file, "blocking").expect("failed to create blocker file");
let path = blocker_file.join("subdir").join("cache.json");
let store = CacheStore::with_path(path);
let cache = crate::Cache::new();
let result = store.save(&cache);
assert!(result.is_err());
let error = result.unwrap_err();
let error_msg = error.to_string();
assert!(
error_msg.contains("failed to create cache directory")
|| error_msg.contains("failed to write cache file"),
"error should describe the operation: {}",
error_msg
);
assert!(
error_msg.contains("blocker"),
"error should include path component: {}",
error_msg
);
}
#[test]
fn test_delete_error_includes_file_path() {
use tempfile::tempdir;
let temp_dir = tempdir().expect("failed to create temp dir");
let path = temp_dir.path().join("cache.json");
fs::create_dir(&path).expect("failed to create directory");
let store = CacheStore::with_path(path.clone());
let result = store.delete();
if let Err(error) = result {
let error_msg = error.to_string();
assert!(
error_msg.contains("cache.json"),
"error should include file path: {}",
error_msg
);
assert!(
error_msg.contains("failed to delete cache file"),
"error should describe the operation: {}",
error_msg
);
}
}
#[test]
fn test_error_message_format_read() {
let error = CacheStoreError::ReadError {
path: PathBuf::from("/home/user/.cache/td/cache.json"),
source: io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"),
};
let msg = error.to_string();
assert_eq!(
msg,
"failed to read cache file '/home/user/.cache/td/cache.json': permission denied"
);
}
#[test]
fn test_error_message_format_write() {
let error = CacheStoreError::WriteError {
path: PathBuf::from("/home/user/.cache/td/cache.json"),
source: io::Error::other("disk full"),
};
let msg = error.to_string();
assert_eq!(
msg,
"failed to write cache file '/home/user/.cache/td/cache.json': disk full"
);
}
#[test]
fn test_error_message_format_create_dir() {
let error = CacheStoreError::CreateDirError {
path: PathBuf::from("/home/user/.cache/td"),
source: io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"),
};
let msg = error.to_string();
assert_eq!(
msg,
"failed to create cache directory '/home/user/.cache/td': permission denied"
);
}
#[test]
fn test_error_message_format_delete() {
let error = CacheStoreError::DeleteError {
path: PathBuf::from("/home/user/.cache/td/cache.json"),
source: io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"),
};
let msg = error.to_string();
assert_eq!(
msg,
"failed to delete cache file '/home/user/.cache/td/cache.json': permission denied"
);
}
#[tokio::test]
async fn test_save_and_load_async() {
use tempfile::tempdir;
let temp_dir = tempdir().expect("failed to create temp dir");
let path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(path);
let mut cache = crate::Cache::new();
cache.sync_token = "test-token".to_string();
store.save_async(&cache).await.expect("save_async failed");
let loaded = store.load_async().await.expect("load_async failed");
assert_eq!(loaded.sync_token, "test-token");
}
#[tokio::test]
async fn test_atomic_write_async() {
use tempfile::tempdir;
let temp_dir = tempdir().expect("failed to create temp dir");
let path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(path.clone());
let cache = crate::Cache::new();
store.save_async(&cache).await.expect("save_async failed");
let temp_path = path.with_extension("tmp");
assert!(!temp_path.exists(), "temp file should be cleaned up");
assert!(path.exists(), "cache file should exist");
}
#[tokio::test]
async fn test_load_async_missing_file() {
let path = PathBuf::from("/nonexistent/path/to/cache.json");
let store = CacheStore::with_path(path);
let result = store.load_async().await;
assert!(result.is_err());
match result.unwrap_err() {
CacheStoreError::ReadError { source, .. } => {
assert_eq!(source.kind(), io::ErrorKind::NotFound);
}
other => panic!("expected ReadError, got {:?}", other),
}
}
#[tokio::test]
async fn test_load_or_default_async_missing_file() {
let path = PathBuf::from("/nonexistent/path/to/cache.json");
let store = CacheStore::with_path(path);
let result = store.load_or_default_async().await;
assert!(result.is_ok());
let cache = result.unwrap();
assert_eq!(cache.sync_token, "*");
}
#[tokio::test]
async fn test_delete_async() {
use tempfile::tempdir;
let temp_dir = tempdir().expect("failed to create temp dir");
let path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(path.clone());
let cache = crate::Cache::new();
store.save_async(&cache).await.expect("save_async failed");
assert!(path.exists());
store.delete_async().await.expect("delete_async failed");
assert!(!path.exists());
}
#[tokio::test]
async fn test_delete_async_nonexistent() {
let path = PathBuf::from("/nonexistent/path/to/cache.json");
let store = CacheStore::with_path(path);
let result = store.delete_async().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_save_async_creates_directory() {
use tempfile::tempdir;
let temp_dir = tempdir().expect("failed to create temp dir");
let path = temp_dir
.path()
.join("subdir")
.join("nested")
.join("cache.json");
let store = CacheStore::with_path(path.clone());
assert!(!path.parent().unwrap().exists());
let cache = crate::Cache::new();
store.save_async(&cache).await.expect("save_async failed");
assert!(path.exists());
}
}