use crate::fs::{AsyncFileSystem, BoxFuture};
use std::path::{Path, PathBuf};
use std::time::Duration;
#[derive(Clone, Debug, Default)]
pub enum FailurePolicy {
#[default]
Continue,
Retry(u32),
Abort,
}
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum CloudProvider {
S3 {
bucket: String,
region: String,
#[serde(default)]
prefix: Option<String>,
#[serde(default)]
endpoint: Option<String>, },
GoogleDrive {
#[serde(default)]
folder_id: Option<String>,
},
WebDAV {
url: String,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CloudBackupConfig {
pub id: String,
pub name: String,
pub provider: CloudProvider,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug)]
pub struct BackupResult {
pub success: bool,
pub files_processed: usize,
pub error: Option<String>,
}
impl BackupResult {
pub fn success(files_processed: usize) -> Self {
Self {
success: true,
files_processed,
error: None,
}
}
pub fn failure(error: impl Into<String>) -> Self {
Self {
success: false,
files_processed: 0,
error: Some(error.into()),
}
}
}
pub trait BackupTarget: Send + Sync {
fn name(&self) -> &str;
fn frequency(&self) -> Duration;
fn failure_policy(&self) -> FailurePolicy;
fn backup<'a>(
&'a self,
fs: &'a dyn AsyncFileSystem,
workspace_path: &'a Path,
) -> BoxFuture<'a, BackupResult>;
fn restore<'a>(
&'a self,
fs: &'a dyn AsyncFileSystem,
workspace_path: &'a Path,
) -> BoxFuture<'a, BackupResult>;
fn is_available(&self) -> bool;
fn get_last_sync(&self) -> Option<std::time::SystemTime> {
None }
}
#[derive(Debug)]
pub struct SyncResult {
pub success: bool,
pub files_pulled: usize,
pub files_pushed: usize,
pub conflicts: Vec<Conflict>,
pub error: Option<String>,
}
impl SyncResult {
pub fn success(files_pulled: usize, files_pushed: usize) -> Self {
Self {
success: true,
files_pulled,
files_pushed,
conflicts: Vec::new(),
error: None,
}
}
pub fn failure(error: impl Into<String>) -> Self {
Self {
success: false,
files_pulled: 0,
files_pushed: 0,
conflicts: Vec::new(),
error: Some(error.into()),
}
}
pub fn with_conflicts(conflicts: Vec<Conflict>) -> Self {
Self {
success: false,
files_pulled: 0,
files_pushed: 0,
conflicts,
error: Some("Conflicts detected".to_string()),
}
}
}
#[derive(Debug, Clone)]
pub struct Conflict {
pub path: PathBuf,
pub local_modified: std::time::SystemTime,
pub remote_modified: std::time::SystemTime,
}
#[derive(Debug, Clone)]
pub enum Resolution {
KeepLocal,
KeepRemote,
Merge(String),
}
pub trait SyncTarget: BackupTarget {
fn pull<'a>(
&'a self,
fs: &'a dyn AsyncFileSystem,
workspace_path: &'a Path,
) -> BoxFuture<'a, SyncResult>;
fn push<'a>(
&'a self,
fs: &'a dyn AsyncFileSystem,
workspace_path: &'a Path,
) -> BoxFuture<'a, SyncResult>;
fn resolve_conflict<'a>(
&'a self,
fs: &'a dyn AsyncFileSystem,
workspace_path: &'a Path,
conflict: &'a Conflict,
resolution: Resolution,
) -> BoxFuture<'a, BackupResult>;
}
pub struct BackupManager {
targets: Vec<Box<dyn BackupTarget>>,
primary_index: Option<usize>,
}
impl Default for BackupManager {
fn default() -> Self {
Self::new()
}
}
impl BackupManager {
pub fn new() -> Self {
Self {
targets: Vec::new(),
primary_index: None,
}
}
pub fn add_target(&mut self, target: Box<dyn BackupTarget>) {
self.targets.push(target);
if self.primary_index.is_none() {
self.primary_index = Some(0);
}
}
pub fn set_primary(&mut self, name: &str) -> bool {
for (i, target) in self.targets.iter().enumerate() {
if target.name() == name {
self.primary_index = Some(i);
return true;
}
}
false
}
pub fn primary_name(&self) -> Option<&str> {
self.primary_index
.and_then(|i| self.targets.get(i))
.map(|t| t.name())
}
pub fn target_names(&self) -> Vec<&str> {
self.targets.iter().map(|t| t.name()).collect()
}
pub async fn backup_all(
&self,
fs: &dyn AsyncFileSystem,
workspace_path: &Path,
) -> Vec<BackupResult> {
let mut results = Vec::with_capacity(self.targets.len());
for target in &self.targets {
if !target.is_available() {
results.push(BackupResult::failure(format!(
"Target '{}' is not available",
target.name()
)));
continue;
}
let result = target.backup(fs, workspace_path).await;
if !result.success {
match target.failure_policy() {
FailurePolicy::Abort => {
results.push(result);
break; }
FailurePolicy::Retry(max_retries) => {
let mut final_result = result;
for _ in 0..max_retries {
final_result = target.backup(fs, workspace_path).await;
if final_result.success {
break;
}
}
results.push(final_result);
}
FailurePolicy::Continue => {
results.push(result);
}
}
} else {
results.push(result);
}
}
results
}
pub async fn restore_from_primary(
&self,
fs: &dyn AsyncFileSystem,
workspace_path: &Path,
) -> Option<BackupResult> {
let primary = self.primary_index.and_then(|i| self.targets.get(i))?;
if !primary.is_available() {
return Some(BackupResult::failure(format!(
"Primary target '{}' is not available",
primary.name()
)));
}
Some(primary.restore(fs, workspace_path).await)
}
}
#[cfg(not(target_arch = "wasm32"))]
pub struct LocalDriveTarget {
name: String,
backup_path: PathBuf,
frequency: Duration,
failure_policy: FailurePolicy,
}
#[cfg(not(target_arch = "wasm32"))]
impl LocalDriveTarget {
pub fn new(name: impl Into<String>, backup_path: PathBuf) -> Self {
Self {
name: name.into(),
backup_path,
frequency: Duration::from_secs(300), failure_policy: FailurePolicy::Continue,
}
}
pub fn with_frequency(mut self, frequency: Duration) -> Self {
self.frequency = frequency;
self
}
pub fn with_failure_policy(mut self, policy: FailurePolicy) -> Self {
self.failure_policy = policy;
self
}
}
#[cfg(not(target_arch = "wasm32"))]
impl BackupTarget for LocalDriveTarget {
fn name(&self) -> &str {
&self.name
}
fn frequency(&self) -> Duration {
self.frequency
}
fn failure_policy(&self) -> FailurePolicy {
self.failure_policy.clone()
}
fn backup<'a>(
&'a self,
fs: &'a dyn AsyncFileSystem,
workspace_path: &'a Path,
) -> BoxFuture<'a, BackupResult> {
Box::pin(async move {
use std::fs as std_fs;
if let Err(e) = std_fs::create_dir_all(&self.backup_path) {
return BackupResult::failure(format!("Failed to create backup directory: {}", e));
}
let files = match fs.list_all_files_recursive(workspace_path).await {
Ok(files) => files,
Err(e) => return BackupResult::failure(format!("Failed to list files: {}", e)),
};
let mut files_processed = 0;
for file_path in files {
if fs.is_dir(&file_path).await {
continue;
}
let relative = match file_path.strip_prefix(workspace_path) {
Ok(rel) => rel,
Err(_) => continue,
};
let dest_path = self.backup_path.join(relative);
if let Some(parent) = dest_path.parent()
&& let Err(e) = std_fs::create_dir_all(parent)
{
return BackupResult::failure(format!(
"Failed to create directory {:?}: {}",
parent, e
));
}
let bytes = match fs.read_binary(&file_path).await {
Ok(bytes) => bytes,
Err(_) => match fs.read_to_string(&file_path).await {
Ok(s) => s.into_bytes(),
Err(e) => {
return BackupResult::failure(format!(
"Failed to read file {:?}: {}",
file_path, e
));
}
},
};
if let Err(e) = std_fs::write(&dest_path, &bytes) {
return BackupResult::failure(format!(
"Failed to write file {:?}: {}",
dest_path, e
));
}
files_processed += 1;
}
BackupResult::success(files_processed)
})
}
fn restore<'a>(
&'a self,
fs: &'a dyn AsyncFileSystem,
workspace_path: &'a Path,
) -> BoxFuture<'a, BackupResult> {
Box::pin(async move {
use std::fs as std_fs;
if !self.backup_path.exists() {
return BackupResult::failure("Backup directory does not exist");
}
let mut files_processed = 0;
fn visit_dir<'a>(
dir: PathBuf,
backup_root: PathBuf,
workspace_path: PathBuf,
fs: &'a dyn AsyncFileSystem,
files_processed: &'a mut usize,
) -> BoxFuture<'a, Result<(), String>> {
Box::pin(async move {
let entries = std_fs::read_dir(&dir)
.map_err(|e| format!("Failed to read directory {:?}: {}", dir, e))?;
for entry in entries {
let entry =
entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
if path.is_dir() {
visit_dir(
path,
backup_root.clone(),
workspace_path.clone(),
fs,
files_processed,
)
.await?;
} else {
let relative = path
.strip_prefix(&backup_root)
.map_err(|_| "Failed to calculate relative path")?;
let dest_path = workspace_path.join(relative);
if let Some(parent) = dest_path.parent() {
fs.create_dir_all(parent).await.map_err(|e| {
format!("Failed to create directory {:?}: {}", parent, e)
})?;
}
let bytes = std_fs::read(&path)
.map_err(|e| format!("Failed to read file {:?}: {}", path, e))?;
let s = String::from_utf8_lossy(&bytes).into_owned();
fs.write_file(&dest_path, &s).await.map_err(|e| {
format!("Failed to write file {:?}: {}", dest_path, e)
})?;
*files_processed += 1;
}
}
Ok(())
})
}
match visit_dir(
self.backup_path.clone(),
self.backup_path.clone(),
workspace_path.to_path_buf(),
fs,
&mut files_processed,
)
.await
{
Ok(()) => BackupResult::success(files_processed),
Err(e) => BackupResult::failure(e),
}
})
}
fn is_available(&self) -> bool {
self.backup_path
.parent()
.map(|p| p.exists() || p.as_os_str().is_empty())
.unwrap_or(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::{FileSystem, SyncToAsyncFs};
#[test]
fn test_backup_result_success() {
let result = BackupResult::success(10);
assert!(result.success);
assert_eq!(result.files_processed, 10);
assert!(result.error.is_none());
}
#[test]
fn test_backup_result_failure() {
let result = BackupResult::failure("Something went wrong");
assert!(!result.success);
assert_eq!(result.files_processed, 0);
assert_eq!(result.error, Some("Something went wrong".to_string()));
}
#[test]
fn test_backup_manager_empty() {
let manager = BackupManager::new();
assert!(manager.target_names().is_empty());
assert!(manager.primary_name().is_none());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_local_drive_target_creation() {
let target = LocalDriveTarget::new("Test Backup", PathBuf::from("/tmp/backup"))
.with_frequency(Duration::from_secs(60))
.with_failure_policy(FailurePolicy::Retry(3));
assert_eq!(target.name(), "Test Backup");
assert_eq!(target.frequency(), Duration::from_secs(60));
matches!(target.failure_policy(), FailurePolicy::Retry(3));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_backup_manager_add_target() {
let mut manager = BackupManager::new();
let target = LocalDriveTarget::new("Test", PathBuf::from("/tmp/backup"));
manager.add_target(Box::new(target));
assert_eq!(manager.target_names(), vec!["Test"]);
assert_eq!(manager.primary_name(), Some("Test"));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_backup_manager_set_primary() {
let mut manager = BackupManager::new();
manager.add_target(Box::new(LocalDriveTarget::new(
"First",
PathBuf::from("/tmp/first"),
)));
manager.add_target(Box::new(LocalDriveTarget::new(
"Second",
PathBuf::from("/tmp/second"),
)));
assert_eq!(manager.primary_name(), Some("First"));
assert!(manager.set_primary("Second"));
assert_eq!(manager.primary_name(), Some("Second"));
assert!(!manager.set_primary("NonExistent"));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_backup_and_restore_integration() {
use crate::fs::InMemoryFileSystem;
use tempfile::tempdir;
let fs = InMemoryFileSystem::new();
let workspace = PathBuf::from("/workspace");
fs.create_dir_all(&workspace).unwrap();
fs.write_file(&workspace.join("test.md"), "# Hello World")
.unwrap();
fs.write_file(&workspace.join("subdir/nested.md"), "Nested content")
.unwrap();
let async_fs = SyncToAsyncFs::new(fs);
let backup_dir = tempdir().unwrap();
let target = LocalDriveTarget::new("Test Backup", backup_dir.path().to_path_buf());
let result = crate::fs::block_on_test(target.backup(&async_fs, &workspace));
assert!(result.success, "Backup failed: {:?}", result.error);
assert_eq!(result.files_processed, 2);
assert!(backup_dir.path().join("test.md").exists());
assert!(backup_dir.path().join("subdir/nested.md").exists());
let fs2 = InMemoryFileSystem::new();
fs2.create_dir_all(&workspace).unwrap();
let async_fs2 = SyncToAsyncFs::new(fs2);
let restore_result = crate::fs::block_on_test(target.restore(&async_fs2, &workspace));
assert!(
restore_result.success,
"Restore failed: {:?}",
restore_result.error
);
assert_eq!(restore_result.files_processed, 2);
let fs2_inner = async_fs2.into_inner();
let content = fs2_inner
.read_to_string(&workspace.join("test.md"))
.unwrap();
assert_eq!(content, "# Hello World");
let nested = fs2_inner
.read_to_string(&workspace.join("subdir/nested.md"))
.unwrap();
assert_eq!(nested, "Nested content");
}
}