use crate::settings::file_credentials::FileCredential;
use color_eyre::eyre::{self, Report};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::fs;
use tokio::sync::RwLock;
pub type TransferId = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResumableUpload {
pub id: TransferId,
pub upload_id: String,
pub source_path: String,
pub bucket: String,
pub key: String,
pub file_size: u64,
pub part_size: u64,
pub completed_parts: HashMap<i32, String>,
pub credentials: CredentialInfo,
pub started_at: u64,
pub last_updated: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResumableDownload {
pub id: TransferId,
pub bucket: String,
pub key: String,
pub destination_path: String,
pub total_size: u64,
pub bytes_downloaded: u64,
pub credentials: CredentialInfo,
pub started_at: u64,
pub last_updated: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialInfo {
pub name: String,
pub region: String,
}
impl From<&FileCredential> for CredentialInfo {
fn from(cred: &FileCredential) -> Self {
CredentialInfo {
name: cred.name.clone(),
region: cred.default_region.clone(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TransferState {
pub uploads: HashMap<TransferId, ResumableUpload>,
pub downloads: HashMap<TransferId, ResumableDownload>,
}
pub struct TransferStateStore {
state_file: PathBuf,
state: RwLock<TransferState>,
}
impl TransferStateStore {
pub async fn new(data_dir: PathBuf) -> eyre::Result<Self> {
let state_file = data_dir.join("transfer_state.json");
let state = if state_file.exists() {
match Self::load_from_file(&state_file).await {
Ok(state) => {
tracing::info!(
"Loaded transfer state: {} uploads, {} downloads",
state.uploads.len(),
state.downloads.len()
);
state
}
Err(e) => {
tracing::warn!("Failed to load transfer state, starting fresh: {}", e);
TransferState::default()
}
}
} else {
TransferState::default()
};
Ok(TransferStateStore {
state_file,
state: RwLock::new(state),
})
}
async fn load_from_file(path: &PathBuf) -> eyre::Result<TransferState> {
let content = fs::read_to_string(path).await?;
let state: TransferState = serde_json::from_str(&content)?;
Ok(state)
}
async fn save_to_file(&self) -> eyre::Result<()> {
let state = self.state.read().await;
let content = serde_json::to_string_pretty(&*state)?;
if let Some(parent) = self.state_file.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(&self.state_file, content).await?;
Ok(())
}
fn generate_id() -> TransferId {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("transfer_{}", timestamp)
}
fn current_timestamp() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[allow(clippy::too_many_arguments)]
pub async fn add_upload(
&self,
upload_id: String,
source_path: String,
bucket: String,
key: String,
file_size: u64,
part_size: u64,
credentials: &FileCredential,
) -> eyre::Result<TransferId> {
let transfer_id = Self::generate_id();
let now = Self::current_timestamp();
let upload = ResumableUpload {
id: transfer_id.clone(),
upload_id,
source_path,
bucket,
key,
file_size,
part_size,
completed_parts: HashMap::new(),
credentials: CredentialInfo::from(credentials),
started_at: now,
last_updated: now,
};
{
let mut state = self.state.write().await;
state.uploads.insert(transfer_id.clone(), upload);
}
self.save_to_file().await?;
Ok(transfer_id)
}
pub async fn update_upload_part(
&self,
transfer_id: &TransferId,
part_number: i32,
e_tag: String,
) -> eyre::Result<()> {
{
let mut state = self.state.write().await;
if let Some(upload) = state.uploads.get_mut(transfer_id) {
upload.completed_parts.insert(part_number, e_tag);
upload.last_updated = Self::current_timestamp();
} else {
return Err(Report::msg(format!(
"Upload not found: {}",
transfer_id
)));
}
}
self.save_to_file().await?;
Ok(())
}
pub async fn complete_upload(&self, transfer_id: &TransferId) -> eyre::Result<()> {
{
let mut state = self.state.write().await;
state.uploads.remove(transfer_id);
}
self.save_to_file().await?;
tracing::debug!("Upload completed and removed from state: {}", transfer_id);
Ok(())
}
pub async fn remove_upload(&self, transfer_id: &TransferId) -> eyre::Result<()> {
{
let mut state = self.state.write().await;
state.uploads.remove(transfer_id);
}
self.save_to_file().await?;
Ok(())
}
pub async fn add_download(
&self,
bucket: String,
key: String,
destination_path: String,
total_size: u64,
credentials: &FileCredential,
) -> eyre::Result<TransferId> {
let transfer_id = Self::generate_id();
let now = Self::current_timestamp();
let download = ResumableDownload {
id: transfer_id.clone(),
bucket,
key,
destination_path,
total_size,
bytes_downloaded: 0,
credentials: CredentialInfo::from(credentials),
started_at: now,
last_updated: now,
};
{
let mut state = self.state.write().await;
state.downloads.insert(transfer_id.clone(), download);
}
self.save_to_file().await?;
Ok(transfer_id)
}
pub async fn update_download_progress(
&self,
transfer_id: &TransferId,
bytes_downloaded: u64,
) -> eyre::Result<()> {
{
let mut state = self.state.write().await;
if let Some(download) = state.downloads.get_mut(transfer_id) {
download.bytes_downloaded = bytes_downloaded;
download.last_updated = Self::current_timestamp();
} else {
return Err(Report::msg(format!(
"Download not found: {}",
transfer_id
)));
}
}
self.save_to_file().await?;
Ok(())
}
pub async fn complete_download(&self, transfer_id: &TransferId) -> eyre::Result<()> {
{
let mut state = self.state.write().await;
state.downloads.remove(transfer_id);
}
self.save_to_file().await?;
tracing::debug!("Download completed and removed from state: {}", transfer_id);
Ok(())
}
pub async fn get_pending_counts(&self) -> (usize, usize) {
let state = self.state.read().await;
(state.uploads.len(), state.downloads.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_credential() -> FileCredential {
FileCredential {
name: "test".to_string(),
access_key: "test_key".to_string(),
secret_key: "test_secret".to_string(),
default_region: "us-east-1".to_string(),
selected: true,
endpoint_url: None,
force_path_style: false,
}
}
#[tokio::test]
async fn test_persistence_across_reload() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().to_path_buf();
{
let store = TransferStateStore::new(path.clone()).await.unwrap();
let creds = create_test_credential();
store
.add_upload(
"upload123".to_string(),
"/path/file.txt".to_string(),
"bucket".to_string(),
"key".to_string(),
1024,
1024,
&creds,
)
.await
.unwrap();
store
.add_download(
"bucket".to_string(),
"key".to_string(),
"/local/file.txt".to_string(),
2048,
&creds,
)
.await
.unwrap();
}
{
let store = TransferStateStore::new(path).await.unwrap();
let (uploads, downloads) = store.get_pending_counts().await;
assert_eq!(uploads, 1);
assert_eq!(downloads, 1);
}
}
}