use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CloudProvider {
Local,
S3,
AzureBlob,
Gcs,
Dropbox,
ICloud,
OneDrive,
WebDav,
}
impl CloudProvider {
pub fn display_name(&self) -> &'static str {
match self {
Self::Local => "Local Storage",
Self::S3 => "Amazon S3",
Self::AzureBlob => "Azure Blob Storage",
Self::Gcs => "Google Cloud Storage",
Self::Dropbox => "Dropbox",
Self::ICloud => "iCloud Drive",
Self::OneDrive => "OneDrive",
Self::WebDav => "WebDAV",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudSyncConfig {
pub enabled: bool,
pub provider: CloudProvider,
pub provider_config: ProviderSpecificConfig,
pub sync_frequency_seconds: u64,
pub auto_sync: bool,
pub encrypt_before_upload: bool,
pub conflict_resolution: ConflictResolution,
}
impl Default for CloudSyncConfig {
fn default() -> Self {
Self {
enabled: false,
provider: CloudProvider::Local,
provider_config: ProviderSpecificConfig::Local(LocalConfig::default()),
sync_frequency_seconds: 300, auto_sync: true,
encrypt_before_upload: true,
conflict_resolution: ConflictResolution::LastWriteWins,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConflictResolution {
LastWriteWins,
LocalWins,
RemoteWins,
KeepBoth,
Manual,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ProviderSpecificConfig {
Local(LocalConfig),
S3(S3Config),
AzureBlob(AzureBlobConfig),
Gcs(GcsConfig),
Dropbox(DropboxConfig),
ICloud(ICloudConfig),
OneDrive(OneDriveConfig),
WebDav(WebDavConfig),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LocalConfig {
pub sync_directory: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct S3Config {
pub bucket: String,
pub region: String,
pub prefix: Option<String>,
pub access_key_id: Option<String>,
pub secret_access_key: Option<String>,
pub endpoint: Option<String>, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzureBlobConfig {
pub container: String,
pub connection_string: Option<String>,
pub account_name: Option<String>,
pub account_key: Option<String>,
pub prefix: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GcsConfig {
pub bucket: String,
pub project_id: String,
pub prefix: Option<String>,
pub credentials_file: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DropboxConfig {
pub access_token: Option<String>,
pub refresh_token: Option<String>,
pub folder_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ICloudConfig {
pub container_id: Option<String>,
pub folder_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OneDriveConfig {
pub access_token: Option<String>,
pub refresh_token: Option<String>,
pub folder_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebDavConfig {
pub url: String,
pub username: Option<String>,
pub password: Option<String>,
pub folder_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionSyncState {
pub session_id: String,
pub local_modified: i64,
pub remote_modified: Option<i64>,
pub local_hash: String,
pub remote_hash: Option<String>,
pub status: SyncStatus,
pub last_sync_attempt: Option<i64>,
pub last_sync_success: Option<i64>,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SyncStatus {
Synced,
PendingUpload,
PendingDownload,
Conflict,
Syncing,
Error,
NeverSynced,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncState {
pub last_full_sync: Option<i64>,
pub sessions: Vec<SessionSyncState>,
pub pending_uploads: u32,
pub pending_downloads: u32,
pub conflicts: u32,
}
impl SyncState {
pub fn new() -> Self {
Self {
last_full_sync: None,
sessions: Vec::new(),
pending_uploads: 0,
pending_downloads: 0,
conflicts: 0,
}
}
}
impl Default for SyncState {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
pub trait CloudSyncService: Send + Sync {
fn provider(&self) -> CloudProvider;
async fn test_connection(&self) -> Result<bool>;
async fn list_remote_sessions(&self) -> Result<Vec<RemoteSessionInfo>>;
async fn upload_session(&self, session_id: &str, data: &[u8]) -> Result<UploadResult>;
async fn download_session(&self, session_id: &str) -> Result<Vec<u8>>;
async fn delete_remote_session(&self, session_id: &str) -> Result<()>;
async fn get_remote_metadata(&self, session_id: &str) -> Result<Option<RemoteSessionInfo>>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteSessionInfo {
pub session_id: String,
pub modified_at: i64,
pub size_bytes: u64,
pub content_hash: String,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UploadResult {
pub success: bool,
pub remote_path: String,
pub content_hash: String,
pub uploaded_at: i64,
}
pub struct LocalSyncService {
sync_dir: PathBuf,
}
impl LocalSyncService {
pub fn new(sync_dir: PathBuf) -> Self {
Self { sync_dir }
}
fn session_path(&self, session_id: &str) -> PathBuf {
self.sync_dir.join(format!("{}.json", session_id))
}
}
#[async_trait::async_trait]
impl CloudSyncService for LocalSyncService {
fn provider(&self) -> CloudProvider {
CloudProvider::Local
}
async fn test_connection(&self) -> Result<bool> {
Ok(self.sync_dir.exists() || std::fs::create_dir_all(&self.sync_dir).is_ok())
}
async fn list_remote_sessions(&self) -> Result<Vec<RemoteSessionInfo>> {
let mut sessions = Vec::new();
if !self.sync_dir.exists() {
return Ok(sessions);
}
for entry in std::fs::read_dir(&self.sync_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let metadata = entry.metadata()?;
let modified = metadata
.modified()?
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let hash = format!("{}-{}", metadata.len(), modified);
sessions.push(RemoteSessionInfo {
session_id: stem.to_string(),
modified_at: modified,
size_bytes: metadata.len(),
content_hash: hash,
metadata: None,
});
}
}
}
Ok(sessions)
}
async fn upload_session(&self, session_id: &str, data: &[u8]) -> Result<UploadResult> {
std::fs::create_dir_all(&self.sync_dir)?;
let path = self.session_path(session_id);
std::fs::write(&path, data)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let hash = format!("{}-{}", data.len(), now);
Ok(UploadResult {
success: true,
remote_path: path.to_string_lossy().to_string(),
content_hash: hash,
uploaded_at: now,
})
}
async fn download_session(&self, session_id: &str) -> Result<Vec<u8>> {
let path = self.session_path(session_id);
std::fs::read(&path).map_err(|e| anyhow!("Failed to read session: {}", e))
}
async fn delete_remote_session(&self, session_id: &str) -> Result<()> {
let path = self.session_path(session_id);
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
async fn get_remote_metadata(&self, session_id: &str) -> Result<Option<RemoteSessionInfo>> {
let path = self.session_path(session_id);
if !path.exists() {
return Ok(None);
}
let metadata = std::fs::metadata(&path)?;
let modified = metadata
.modified()?
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let hash = format!("{}-{}", metadata.len(), modified);
Ok(Some(RemoteSessionInfo {
session_id: session_id.to_string(),
modified_at: modified,
size_bytes: metadata.len(),
content_hash: hash,
metadata: None,
}))
}
}
pub struct SyncManager {
config: CloudSyncConfig,
state: SyncState,
service: Option<Box<dyn CloudSyncService>>,
}
impl SyncManager {
pub fn new(config: CloudSyncConfig) -> Self {
Self {
config,
state: SyncState::new(),
service: None,
}
}
pub fn initialize(&mut self) -> Result<()> {
if !self.config.enabled {
return Ok(());
}
match &self.config.provider_config {
ProviderSpecificConfig::Local(local_config) => {
let sync_dir = local_config
.sync_directory
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("csm")
.join("sync")
});
self.service = Some(Box::new(LocalSyncService::new(sync_dir)));
}
_ => {
return Err(anyhow!(
"Cloud provider {:?} not yet implemented",
self.config.provider
));
}
}
Ok(())
}
pub async fn test_connection(&self) -> Result<bool> {
match &self.service {
Some(service) => service.test_connection().await,
None => Err(anyhow!("Sync service not initialized")),
}
}
pub fn get_state(&self) -> &SyncState {
&self.state
}
pub async fn sync_all(&mut self) -> Result<SyncResult> {
let service = self
.service
.as_ref()
.ok_or_else(|| anyhow!("Sync service not initialized"))?;
let result = SyncResult {
uploaded: 0,
downloaded: 0,
conflicts: 0,
errors: Vec::new(),
};
let _remote_sessions = service.list_remote_sessions().await?;
self.state.last_full_sync = Some(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
);
Ok(result)
}
pub async fn upload_session(&mut self, session_id: &str, data: &[u8]) -> Result<UploadResult> {
let service = self
.service
.as_ref()
.ok_or_else(|| anyhow!("Sync service not initialized"))?;
service.upload_session(session_id, data).await
}
pub async fn download_session(&self, session_id: &str) -> Result<Vec<u8>> {
let service = self
.service
.as_ref()
.ok_or_else(|| anyhow!("Sync service not initialized"))?;
service.download_session(session_id).await
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncResult {
pub uploaded: u32,
pub downloaded: u32,
pub conflicts: u32,
pub errors: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_local_sync_service() {
let temp_dir = tempdir().unwrap();
let sync_dir = temp_dir.path().join("sync");
let service = LocalSyncService::new(sync_dir.clone());
assert!(service.test_connection().await.unwrap());
let data = b"test session data";
let result = service.upload_session("test-session", data).await.unwrap();
assert!(result.success);
let sessions = service.list_remote_sessions().await.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].session_id, "test-session");
let downloaded = service.download_session("test-session").await.unwrap();
assert_eq!(downloaded, data);
service.delete_remote_session("test-session").await.unwrap();
let sessions = service.list_remote_sessions().await.unwrap();
assert!(sessions.is_empty());
}
}