use crate::error::{Result, SyncError};
use crate::sync::scanner::FileEntry;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedFile {
pub path: PathBuf,
pub size: u64,
pub modified: SystemTime,
pub is_dir: bool,
}
impl CachedFile {
pub fn from_file_entry(file: &FileEntry) -> Self {
Self { path: (*file.relative_path).clone(), size: file.size, modified: file.modified, is_dir: file.is_dir }
}
pub fn to_file_entry(&self, source_root: &Path) -> FileEntry {
FileEntry {
path: Arc::new(source_root.join(&self.path)),
relative_path: Arc::new(self.path.clone()),
size: self.size,
modified: self.modified,
is_dir: self.is_dir,
is_symlink: false, symlink_target: None,
is_sparse: false,
allocated_size: self.size,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectoryCache {
#[serde(rename = "directories")]
dir_entries: HashMap<PathBuf, SystemTime>,
#[serde(rename = "files", default)]
file_entries: HashMap<PathBuf, Vec<CachedFile>>,
#[serde(default = "default_version")]
version: u32,
#[serde(default = "SystemTime::now")]
last_updated: SystemTime,
}
fn default_version() -> u32 {
1
}
impl DirectoryCache {
const CURRENT_VERSION: u32 = 2; const CACHE_FILENAME: &'static str = ".sy-dir-cache.json";
pub fn new() -> Self {
Self {
dir_entries: HashMap::new(),
file_entries: HashMap::new(),
version: Self::CURRENT_VERSION,
last_updated: SystemTime::now(),
}
}
pub fn load(dest_root: &Path) -> Self {
let cache_path = dest_root.join(Self::CACHE_FILENAME);
match std::fs::read_to_string(&cache_path) {
Ok(content) => match serde_json::from_str::<Self>(&content) {
Ok(mut cache) => {
if cache.version != Self::CURRENT_VERSION {
tracing::warn!("Directory cache version mismatch (found {}, expected {}). Using empty cache.", cache.version, Self::CURRENT_VERSION);
return Self::new();
}
cache.last_updated = SystemTime::now();
tracing::debug!(
"Loaded directory cache: {} directories, {} file entries",
cache.dir_entries.len(),
cache.file_entries.values().map(|v| v.len()).sum::<usize>()
);
cache
}
Err(e) => {
tracing::warn!("Failed to parse directory cache: {}. Using empty cache.", e);
Self::new()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!("No existing directory cache found. Will create new cache.");
Self::new()
}
Err(e) => {
tracing::warn!("Failed to read directory cache: {}. Using empty cache.", e);
Self::new()
}
}
}
pub fn save(&self, dest_root: &Path) -> Result<()> {
let cache_path = dest_root.join(Self::CACHE_FILENAME);
let content =
serde_json::to_string_pretty(self).map_err(|e| SyncError::Io(std::io::Error::other(format!("Failed to serialize directory cache: {}", e))))?;
std::fs::write(&cache_path, content)
.map_err(|e| SyncError::Io(std::io::Error::other(format!("Failed to write directory cache to {}: {}", cache_path.display(), e))))?;
tracing::debug!(
"Saved directory cache: {} directories, {} files to {}",
self.dir_entries.len(),
self.file_entries.values().map(|v| v.len()).sum::<usize>(),
cache_path.display()
);
Ok(())
}
pub fn delete(dest_root: &Path) -> Result<()> {
let cache_path = dest_root.join(Self::CACHE_FILENAME);
if cache_path.exists() {
std::fs::remove_file(&cache_path).map_err(|e| SyncError::Io(std::io::Error::other(format!("Failed to delete directory cache: {}", e))))?;
tracing::debug!("Deleted directory cache");
}
Ok(())
}
pub fn needs_rescan(&self, dir_path: &Path, current_mtime: SystemTime) -> bool {
match self.dir_entries.get(dir_path) {
Some(&cached_mtime) => {
match current_mtime.duration_since(cached_mtime) {
Ok(duration) => duration.as_secs() > 1,
Err(e) => e.duration().as_secs() > 1,
}
}
None => {
true
}
}
}
pub fn update(&mut self, dir_path: PathBuf, mtime: SystemTime) {
self.dir_entries.insert(dir_path, mtime);
}
pub fn get_cached_files(&self, dir_path: &Path) -> Option<&Vec<CachedFile>> {
self.file_entries.get(dir_path)
}
pub fn cache_files(&mut self, dir_path: PathBuf, files: Vec<CachedFile>) {
self.file_entries.insert(dir_path, files);
}
#[allow(dead_code)] pub fn remove(&mut self, dir_path: &Path) -> bool {
let dir_removed = self.dir_entries.remove(dir_path).is_some();
let files_removed = self.file_entries.remove(dir_path).is_some();
dir_removed || files_removed
}
#[allow(dead_code)] pub fn clear(&mut self) {
self.dir_entries.clear();
self.file_entries.clear();
self.last_updated = SystemTime::now();
}
pub fn len(&self) -> usize {
self.dir_entries.len()
}
#[allow(dead_code)] pub fn is_empty(&self) -> bool {
self.dir_entries.is_empty() && self.file_entries.is_empty()
}
#[allow(dead_code)] pub fn cache_path(dest_root: &Path) -> PathBuf {
dest_root.join(Self::CACHE_FILENAME)
}
}
impl Default for DirectoryCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tempfile::TempDir;
#[test]
fn test_new_cache() {
let cache = DirectoryCache::new();
assert!(cache.is_empty());
assert_eq!(cache.version, DirectoryCache::CURRENT_VERSION);
}
#[test]
fn test_update_and_check() {
let mut cache = DirectoryCache::new();
let dir = PathBuf::from("test/dir");
let mtime = SystemTime::now();
assert!(cache.needs_rescan(&dir, mtime));
cache.update(dir.clone(), mtime);
assert_eq!(cache.len(), 1);
assert!(!cache.needs_rescan(&dir, mtime));
let new_mtime = mtime + Duration::from_secs(2);
assert!(cache.needs_rescan(&dir, new_mtime));
}
#[test]
fn test_save_and_load() {
let temp = TempDir::new().unwrap();
let mut cache = DirectoryCache::new();
cache.update(PathBuf::from("dir1"), SystemTime::now());
cache.update(PathBuf::from("dir2"), SystemTime::now());
cache.update(PathBuf::from("dir3/subdir"), SystemTime::now());
cache.save(temp.path()).unwrap();
let cache_path = DirectoryCache::cache_path(temp.path());
assert!(cache_path.exists());
let loaded = DirectoryCache::load(temp.path());
assert_eq!(loaded.len(), 3);
assert_eq!(loaded.version, DirectoryCache::CURRENT_VERSION);
assert!(loaded.dir_entries.contains_key(&PathBuf::from("dir1")));
assert!(loaded.dir_entries.contains_key(&PathBuf::from("dir2")));
assert!(loaded.dir_entries.contains_key(&PathBuf::from("dir3/subdir")));
}
#[test]
fn test_load_nonexistent() {
let temp = TempDir::new().unwrap();
let cache = DirectoryCache::load(temp.path());
assert!(cache.is_empty());
}
#[test]
fn test_load_corrupted() {
let temp = TempDir::new().unwrap();
let cache_path = DirectoryCache::cache_path(temp.path());
std::fs::write(&cache_path, "not valid json").unwrap();
let cache = DirectoryCache::load(temp.path());
assert!(cache.is_empty());
}
#[test]
fn test_delete_cache() {
let temp = TempDir::new().unwrap();
let mut cache = DirectoryCache::new();
cache.update(PathBuf::from("dir1"), SystemTime::now());
cache.save(temp.path()).unwrap();
let cache_path = DirectoryCache::cache_path(temp.path());
assert!(cache_path.exists());
DirectoryCache::delete(temp.path()).unwrap();
assert!(!cache_path.exists());
DirectoryCache::delete(temp.path()).unwrap();
}
#[test]
fn test_remove_entry() {
let mut cache = DirectoryCache::new();
let dir = PathBuf::from("test/dir");
cache.update(dir.clone(), SystemTime::now());
assert_eq!(cache.len(), 1);
assert!(cache.remove(&dir));
assert_eq!(cache.len(), 0);
assert!(!cache.remove(&dir));
}
#[test]
fn test_clear() {
let mut cache = DirectoryCache::new();
cache.update(PathBuf::from("dir1"), SystemTime::now());
cache.update(PathBuf::from("dir2"), SystemTime::now());
assert_eq!(cache.len(), 2);
cache.clear();
assert!(cache.is_empty());
}
#[test]
fn test_mtime_tolerance() {
let mut cache = DirectoryCache::new();
let dir = PathBuf::from("test/dir");
let mtime = SystemTime::now();
cache.update(dir.clone(), mtime);
assert!(!cache.needs_rescan(&dir, mtime));
let mtime_close = mtime + Duration::from_millis(500);
assert!(!cache.needs_rescan(&dir, mtime_close));
let mtime_far = mtime + Duration::from_secs(2);
assert!(cache.needs_rescan(&dir, mtime_far));
}
#[test]
fn test_cache_file_path() {
let temp = TempDir::new().unwrap();
let cache_path = DirectoryCache::cache_path(temp.path());
assert_eq!(cache_path, temp.path().join(DirectoryCache::CACHE_FILENAME));
}
}