use crate::error::{Result, SyncError};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
pub const DEFAULT_CHUNK_SIZE: usize = 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransferState {
pub source_path: PathBuf,
pub dest_path: PathBuf,
pub total_size: u64,
pub bytes_transferred: u64,
pub chunk_size: usize,
pub mtime: SystemTime,
pub checksum: Option<String>,
pub last_updated: SystemTime,
}
impl TransferState {
pub fn new(source: &Path, dest: &Path, total_size: u64, mtime: SystemTime, chunk_size: usize) -> Self {
Self {
source_path: source.to_path_buf(),
dest_path: dest.to_path_buf(),
total_size,
bytes_transferred: 0,
chunk_size,
mtime,
checksum: None,
last_updated: SystemTime::now(),
}
}
pub fn is_complete(&self) -> bool {
self.bytes_transferred >= self.total_size
}
pub fn progress_percentage(&self) -> f64 {
if self.total_size == 0 { 100.0 } else { (self.bytes_transferred as f64 / self.total_size as f64) * 100.0 }
}
pub fn update_progress(&mut self, bytes: u64) {
self.bytes_transferred = bytes;
self.last_updated = SystemTime::now();
}
pub fn is_stale(&self, current_mtime: SystemTime) -> bool {
current_mtime != self.mtime
}
fn generate_state_id(source: &Path, dest: &Path, mtime: SystemTime) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(source.to_string_lossy().as_bytes());
hasher.update(dest.to_string_lossy().as_bytes());
if let Ok(duration) = mtime.duration_since(SystemTime::UNIX_EPOCH) {
hasher.update(&duration.as_secs().to_le_bytes());
hasher.update(&duration.subsec_nanos().to_le_bytes());
}
hasher.finalize().to_hex().to_string()
}
fn get_resume_dir() -> Result<PathBuf> {
let cache_dir = if let Ok(xdg_cache) = std::env::var("XDG_CACHE_HOME") {
PathBuf::from(xdg_cache)
} else if let Ok(home) = std::env::var("HOME") {
PathBuf::from(home).join(".cache")
} else {
return Err(SyncError::Config("Cannot determine cache directory (HOME not set)".to_string()));
};
let resume_dir = cache_dir.join("sy").join("resume");
fs::create_dir_all(&resume_dir)?;
Ok(resume_dir)
}
fn get_state_file_path(source: &Path, dest: &Path, mtime: SystemTime) -> Result<PathBuf> {
let state_id = Self::generate_state_id(source, dest, mtime);
let resume_dir = Self::get_resume_dir()?;
Ok(resume_dir.join(format!("{}.json", state_id)))
}
pub fn save(&self) -> Result<()> {
let state_file = Self::get_state_file_path(&self.source_path, &self.dest_path, self.mtime)?;
let temp_file = state_file.with_extension("json.tmp");
let json = serde_json::to_string_pretty(self).map_err(|e| SyncError::Config(format!("Failed to serialize resume state: {}", e)))?;
fs::write(&temp_file, json)?;
fs::rename(temp_file, state_file)?;
Ok(())
}
pub fn load(source: &Path, dest: &Path, mtime: SystemTime) -> Result<Option<Self>> {
let state_file = Self::get_state_file_path(source, dest, mtime)?;
if !state_file.exists() {
return Ok(None);
}
let json = fs::read_to_string(&state_file)?;
let state: Self = serde_json::from_str(&json).map_err(|e| SyncError::Config(format!("Failed to parse resume state: {}", e)))?;
if state.source_path != source || state.dest_path != dest {
eprintln!(
"Warning: Resume state mismatch (expected {:?} -> {:?}, got {:?} -> {:?}). Ignoring.",
source, dest, state.source_path, state.dest_path
);
return Ok(None);
}
if state.is_stale(mtime) {
eprintln!("Warning: Resume state is stale (file modified). Starting fresh transfer.");
Self::clear(source, dest, mtime)?;
return Ok(None);
}
Ok(Some(state))
}
pub fn clear(source: &Path, dest: &Path, mtime: SystemTime) -> Result<()> {
let state_file = Self::get_state_file_path(source, dest, mtime)?;
if state_file.exists() {
fs::remove_file(state_file)?;
}
Ok(())
}
#[allow(dead_code)] pub fn clear_all() -> Result<()> {
let resume_dir = Self::get_resume_dir()?;
if resume_dir.exists() {
for entry in fs::read_dir(&resume_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
fs::remove_file(path)?;
}
}
}
Ok(())
}
pub fn clear_stale_states(max_age: std::time::Duration) -> Result<usize> {
let resume_dir = Self::get_resume_dir()?;
let mut cleared_count = 0;
if !resume_dir.exists() {
return Ok(0);
}
let now = SystemTime::now();
for entry in fs::read_dir(&resume_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
if let Ok(metadata) = fs::metadata(&path)
&& let Ok(modified) = metadata.modified()
&& let Ok(age) = now.duration_since(modified)
&& age > max_age
{
if fs::remove_file(&path).is_ok() {
cleared_count += 1;
tracing::debug!("Cleaned up stale resume state: {} (age: {:?})", path.display(), age);
}
}
}
}
if cleared_count > 0 {
tracing::info!("Cleaned up {} stale resume state(s)", cleared_count);
}
Ok(cleared_count)
}
#[allow(dead_code)] pub fn total_chunks(&self) -> usize {
self.total_size.div_ceil(self.chunk_size as u64) as usize
}
#[allow(dead_code)] pub fn next_chunk(&self) -> Option<(u64, usize)> {
if self.is_complete() {
return None;
}
let start_offset = self.bytes_transferred;
let remaining = self.total_size - self.bytes_transferred;
let chunk_len = std::cmp::min(remaining, self.chunk_size as u64) as usize;
Some((start_offset, chunk_len))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_new_transfer_state() {
let source = PathBuf::from("/tmp/source.txt");
let dest = PathBuf::from("/tmp/dest.txt");
let mtime = SystemTime::now();
let state = TransferState::new(&source, &dest, 1024 * 1024, mtime, DEFAULT_CHUNK_SIZE);
assert_eq!(state.source_path, source);
assert_eq!(state.dest_path, dest);
assert_eq!(state.total_size, 1024 * 1024);
assert_eq!(state.bytes_transferred, 0);
assert_eq!(state.chunk_size, DEFAULT_CHUNK_SIZE);
assert!(!state.is_complete());
}
#[test]
fn test_progress_percentage() {
let source = PathBuf::from("/tmp/source.txt");
let dest = PathBuf::from("/tmp/dest.txt");
let mtime = SystemTime::now();
let mut state = TransferState::new(&source, &dest, 1000, mtime, DEFAULT_CHUNK_SIZE);
assert_eq!(state.progress_percentage(), 0.0);
state.update_progress(500);
assert_eq!(state.progress_percentage(), 50.0);
state.update_progress(1000);
assert_eq!(state.progress_percentage(), 100.0);
assert!(state.is_complete());
}
#[test]
fn test_progress_percentage_zero_size() {
let source = PathBuf::from("/tmp/source.txt");
let dest = PathBuf::from("/tmp/dest.txt");
let mtime = SystemTime::now();
let state = TransferState::new(&source, &dest, 0, mtime, DEFAULT_CHUNK_SIZE);
assert_eq!(state.progress_percentage(), 100.0);
assert!(state.is_complete());
}
#[test]
fn test_is_stale() {
let source = PathBuf::from("/tmp/source.txt");
let dest = PathBuf::from("/tmp/dest.txt");
let mtime = SystemTime::now();
let state = TransferState::new(&source, &dest, 1000, mtime, DEFAULT_CHUNK_SIZE);
assert!(!state.is_stale(mtime));
let new_mtime = mtime + Duration::from_secs(10);
assert!(state.is_stale(new_mtime));
}
#[test]
fn test_total_chunks() {
let source = PathBuf::from("/tmp/source.txt");
let dest = PathBuf::from("/tmp/dest.txt");
let mtime = SystemTime::now();
let state1 = TransferState::new(&source, &dest, 1024 * 1024, mtime, DEFAULT_CHUNK_SIZE);
assert_eq!(state1.total_chunks(), 1);
let state2 = TransferState::new(&source, &dest, 1024 * 1024 + 512 * 1024, mtime, DEFAULT_CHUNK_SIZE);
assert_eq!(state2.total_chunks(), 2);
let state3 = TransferState::new(&source, &dest, 10 * 1024 * 1024, mtime, DEFAULT_CHUNK_SIZE);
assert_eq!(state3.total_chunks(), 10);
}
#[test]
fn test_next_chunk() {
let source = PathBuf::from("/tmp/source.txt");
let dest = PathBuf::from("/tmp/dest.txt");
let mtime = SystemTime::now();
let chunk_size = 1024;
let mut state = TransferState::new(&source, &dest, 2500, mtime, chunk_size);
let (offset, len) = state.next_chunk().unwrap();
assert_eq!(offset, 0);
assert_eq!(len, 1024);
state.update_progress(1024);
let (offset, len) = state.next_chunk().unwrap();
assert_eq!(offset, 1024);
assert_eq!(len, 1024);
state.update_progress(2048);
let (offset, len) = state.next_chunk().unwrap();
assert_eq!(offset, 2048);
assert_eq!(len, 452);
state.update_progress(2500);
assert!(state.next_chunk().is_none());
}
#[test]
fn test_save_and_load() -> Result<()> {
let test_id = std::process::id();
let source = PathBuf::from(format!("/tmp/test_source_{}.txt", test_id));
let dest = PathBuf::from(format!("/tmp/test_dest_{}.txt", test_id));
let mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1600000000);
let mut state = TransferState::new(&source, &dest, 5000, mtime, DEFAULT_CHUNK_SIZE);
state.update_progress(2500);
state.checksum = Some("abc123".to_string());
state.save()?;
let loaded = TransferState::load(&source, &dest, mtime)?.unwrap();
assert_eq!(loaded.source_path, source);
assert_eq!(loaded.dest_path, dest);
assert_eq!(loaded.total_size, 5000);
assert_eq!(loaded.bytes_transferred, 2500);
assert_eq!(loaded.checksum, Some("abc123".to_string()));
TransferState::clear(&source, &dest, mtime)?;
assert!(TransferState::load(&source, &dest, mtime)?.is_none());
Ok(())
}
#[test]
fn test_load_stale_state() -> Result<()> {
let test_id = std::process::id();
let source = PathBuf::from(format!("/tmp/test_stale_source_{}.txt", test_id));
let dest = PathBuf::from(format!("/tmp/test_stale_dest_{}.txt", test_id));
let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1600000000);
let old_mtime = base_time - Duration::from_secs(3600);
let state = TransferState::new(&source, &dest, 1000, old_mtime, DEFAULT_CHUNK_SIZE);
state.save()?;
let new_mtime = base_time;
let loaded = TransferState::load(&source, &dest, new_mtime)?;
assert!(loaded.is_none());
Ok(())
}
#[test]
fn test_clear_all() -> Result<()> {
let source1 = PathBuf::from("/tmp/test_clear_source1.txt");
let dest1 = PathBuf::from("/tmp/test_clear_dest1.txt");
let source2 = PathBuf::from("/tmp/test_clear_source2.txt");
let dest2 = PathBuf::from("/tmp/test_clear_dest2.txt");
let mtime = SystemTime::now();
let state1 = TransferState::new(&source1, &dest1, 1000, mtime, DEFAULT_CHUNK_SIZE);
let state2 = TransferState::new(&source2, &dest2, 2000, mtime, DEFAULT_CHUNK_SIZE);
state1.save()?;
state2.save()?;
TransferState::clear_all()?;
assert!(TransferState::load(&source1, &dest1, mtime)?.is_none());
assert!(TransferState::load(&source2, &dest2, mtime)?.is_none());
Ok(())
}
#[test]
fn test_generate_state_id_uniqueness() {
let source1 = PathBuf::from("/tmp/source1.txt");
let source2 = PathBuf::from("/tmp/source2.txt");
let dest = PathBuf::from("/tmp/dest.txt");
let mtime1 = SystemTime::now();
let mtime2 = mtime1 + Duration::from_secs(1);
let id1 = TransferState::generate_state_id(&source1, &dest, mtime1);
let id2 = TransferState::generate_state_id(&source2, &dest, mtime1);
assert_ne!(id1, id2);
let id3 = TransferState::generate_state_id(&source1, &dest, mtime1);
let id4 = TransferState::generate_state_id(&source1, &dest, mtime2);
assert_ne!(id3, id4);
let id5 = TransferState::generate_state_id(&source1, &dest, mtime1);
assert_eq!(id1, id5);
}
}