use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupMetadata {
pub created_at: DateTime<Utc>,
pub file_count: usize,
pub total_size: u64,
pub files: HashMap<PathBuf, BackupFileInfo>,
pub description: Option<String>,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupFileInfo {
pub original_path: PathBuf,
pub backup_path: PathBuf,
pub size: u64,
pub modified_at: DateTime<Utc>,
pub checksum: Option<String>,
}
pub struct BackupManager {
backup_dir: PathBuf,
max_backups: usize,
}
#[derive(Debug, Clone)]
pub struct BackupEntry {
pub id: String,
pub created_at: DateTime<Utc>,
pub file_count: usize,
pub total_size: u64,
pub description: Option<String>,
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct BackupDiff {
pub added_files: Vec<PathBuf>,
pub removed_files: Vec<PathBuf>,
pub modified_files: Vec<PathBuf>,
}
impl BackupManager {
pub fn new<P: AsRef<Path>>(backup_dir: P, max_backups: usize) -> Result<Self> {
let backup_dir = backup_dir.as_ref().to_path_buf();
if !backup_dir.exists() {
fs::create_dir_all(&backup_dir)
.with_context(|| format!("Failed to create backup directory: {:?}", backup_dir))?;
info!("Created backup directory: {:?}", backup_dir);
}
Ok(Self {
backup_dir,
max_backups,
})
}
pub fn create_backup(
&self,
files: &[PathBuf],
description: Option<String>,
) -> Result<String> {
let backup_id = Self::generate_backup_id();
let backup_path = self.backup_dir.join(&backup_id);
info!("Creating backup with ID: {}", backup_id);
fs::create_dir_all(&backup_path)
.with_context(|| format!("Failed to create backup directory: {:?}", backup_path))?;
let mut metadata = BackupMetadata {
created_at: Utc::now(),
file_count: 0,
total_size: 0,
files: HashMap::new(),
description,
version: crate::VERSION.to_string(),
};
for original_file in files {
if !original_file.exists() {
warn!("Skipping non-existent file: {:?}", original_file);
continue;
}
if !original_file.is_file() {
warn!("Skipping non-file path: {:?}", original_file);
continue;
}
let backup_file_path = self.create_backup_file_path(&backup_path, original_file)?;
self.copy_file_with_metadata(original_file, &backup_file_path)?;
let file_metadata = fs::metadata(original_file)
.with_context(|| format!("Failed to read metadata for: {:?}", original_file))?;
let size = file_metadata.len();
let modified_at = file_metadata.modified()
.ok()
.and_then(|t| DateTime::from_timestamp(
t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs() as i64,
0
))
.unwrap_or_else(Utc::now);
let checksum = self.calculate_checksum(original_file).ok();
let relative_backup_path = backup_file_path
.strip_prefix(&backup_path)
.unwrap_or(&backup_file_path)
.to_path_buf();
let file_info = BackupFileInfo {
original_path: original_file.clone(),
backup_path: relative_backup_path,
size,
modified_at,
checksum,
};
metadata.files.insert(original_file.clone(), file_info);
metadata.file_count += 1;
metadata.total_size += size;
debug!("Backed up file: {:?} ({} bytes)", original_file, size);
}
self.save_metadata(&backup_path, &metadata)?;
info!(
"Backup created: {} files, {} bytes total",
metadata.file_count, metadata.total_size
);
self.rotate_backups()?;
Ok(backup_id)
}
pub fn restore_backup(&self, backup_id: &str, validate: bool) -> Result<usize> {
let backup_path = self.backup_dir.join(backup_id);
if !backup_path.exists() {
anyhow::bail!("Backup not found: {}", backup_id);
}
info!("Restoring backup: {}", backup_id);
let metadata = self.load_metadata(&backup_path)?;
if validate {
self.validate_backup(&backup_path, &metadata)?;
}
let mut restored_count = 0;
for (original_path, file_info) in &metadata.files {
let backup_file = backup_path.join(&file_info.backup_path);
if !backup_file.exists() {
warn!("Backup file not found: {:?}, skipping", backup_file);
continue;
}
if let Some(parent) = original_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create parent directory: {:?}", parent)
})?;
}
fs::copy(&backup_file, original_path).with_context(|| {
format!("Failed to restore file from {:?} to {:?}", backup_file, original_path)
})?;
restored_count += 1;
debug!("Restored file: {:?}", original_path);
}
info!("Restored {} files from backup", restored_count);
Ok(restored_count)
}
pub fn list_backups(&self) -> Result<Vec<BackupEntry>> {
let mut entries = Vec::new();
if !self.backup_dir.exists() {
return Ok(entries);
}
let dir_entries = fs::read_dir(&self.backup_dir)
.with_context(|| format!("Failed to read backup directory: {:?}", self.backup_dir))?;
for entry in dir_entries {
let entry = entry.context("Failed to read directory entry")?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let backup_id = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
match self.load_metadata(&path) {
Ok(metadata) => {
entries.push(BackupEntry {
id: backup_id,
created_at: metadata.created_at,
file_count: metadata.file_count,
total_size: metadata.total_size,
description: metadata.description,
path,
});
}
Err(e) => {
warn!("Failed to load metadata for backup {}: {}", backup_id, e);
}
}
}
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(entries)
}
pub fn diff_backups(&self, backup_id1: &str, backup_id2: &str) -> Result<BackupDiff> {
let backup1_path = self.backup_dir.join(backup_id1);
let backup2_path = self.backup_dir.join(backup_id2);
let metadata1 = self.load_metadata(&backup1_path)?;
let metadata2 = self.load_metadata(&backup2_path)?;
let mut added_files = Vec::new();
let mut removed_files = Vec::new();
let mut modified_files = Vec::new();
for (path, info2) in &metadata2.files {
match metadata1.files.get(path) {
Some(info1) => {
if self.files_differ(info1, info2) {
modified_files.push(path.clone());
}
}
None => {
added_files.push(path.clone());
}
}
}
for path in metadata1.files.keys() {
if !metadata2.files.contains_key(path) {
removed_files.push(path.clone());
}
}
Ok(BackupDiff {
added_files,
removed_files,
modified_files,
})
}
pub fn delete_backup(&self, backup_id: &str) -> Result<()> {
let backup_path = self.backup_dir.join(backup_id);
if !backup_path.exists() {
anyhow::bail!("Backup not found: {}", backup_id);
}
fs::remove_dir_all(&backup_path)
.with_context(|| format!("Failed to delete backup: {:?}", backup_path))?;
info!("Deleted backup: {}", backup_id);
Ok(())
}
pub fn get_latest_backup_id(&self) -> Result<Option<String>> {
let backups = self.list_backups()?;
Ok(backups.first().map(|e| e.id.clone()))
}
fn rotate_backups(&self) -> Result<()> {
let mut backups = self.list_backups()?;
if backups.len() <= self.max_backups {
return Ok(());
}
backups.sort_by(|a, b| a.created_at.cmp(&b.created_at));
let to_delete = backups.len() - self.max_backups;
for backup in backups.iter().take(to_delete) {
info!("Rotating out old backup: {}", backup.id);
self.delete_backup(&backup.id)?;
}
Ok(())
}
fn generate_backup_id() -> String {
let now = Utc::now();
now.format("%Y%m%d_%H%M%S_%3f").to_string()
}
fn create_backup_file_path(
&self,
backup_dir: &Path,
original_file: &Path,
) -> Result<PathBuf> {
let absolute = original_file.canonicalize().unwrap_or_else(|_| original_file.to_path_buf());
let components: Vec<_> = absolute.components().collect();
let mut safe_path = backup_dir.to_path_buf();
for (i, component) in components.iter().enumerate() {
let component_str = component.as_os_str().to_string_lossy();
if i < 2 {
let sanitized = component_str.replace('/', "_").replace('\\', "_");
safe_path.push(sanitized);
} else {
safe_path.push(component_str.as_ref());
}
}
if let Some(parent) = safe_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create backup subdirectory: {:?}", parent))?;
}
Ok(safe_path)
}
fn copy_file_with_metadata(&self, source: &Path, destination: &Path) -> Result<()> {
fs::copy(source, destination).with_context(|| {
format!("Failed to copy file from {:?} to {:?}", source, destination)
})?;
Ok(())
}
fn calculate_checksum(&self, file_path: &Path) -> Result<String> {
use std::io::Read;
let mut file = fs::File::open(file_path)
.with_context(|| format!("Failed to open file for checksum: {:?}", file_path))?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let mut buffer = vec![0u8; 8192];
loop {
let bytes_read = file.read(&mut buffer)
.with_context(|| format!("Failed to read file for checksum: {:?}", file_path))?;
if bytes_read == 0 {
break;
}
use std::hash::Hasher;
hasher.write(&buffer[..bytes_read]);
}
use std::hash::Hasher;
Ok(format!("{:x}", hasher.finish()))
}
fn validate_backup(&self, backup_path: &Path, metadata: &BackupMetadata) -> Result<()> {
debug!("Validating backup at {:?}", backup_path);
for (original_path, file_info) in &metadata.files {
let backup_file = backup_path.join(&file_info.backup_path);
if !backup_file.exists() {
anyhow::bail!("Backup file missing: {:?}", backup_file);
}
let actual_size = fs::metadata(&backup_file)
.with_context(|| format!("Failed to read metadata: {:?}", backup_file))?
.len();
if actual_size != file_info.size {
anyhow::bail!(
"Size mismatch for {:?}: expected {}, got {}",
original_path,
file_info.size,
actual_size
);
}
if let Some(ref expected_checksum) = file_info.checksum {
let actual_checksum = self.calculate_checksum(&backup_file)?;
if &actual_checksum != expected_checksum {
anyhow::bail!(
"Checksum mismatch for {:?}: expected {}, got {}",
original_path,
expected_checksum,
actual_checksum
);
}
}
}
info!("Backup validation successful");
Ok(())
}
fn save_metadata(&self, backup_path: &Path, metadata: &BackupMetadata) -> Result<()> {
let metadata_file = backup_path.join("metadata.json");
let json = serde_json::to_string_pretty(metadata)
.context("Failed to serialize backup metadata")?;
fs::write(&metadata_file, json)
.with_context(|| format!("Failed to write metadata file: {:?}", metadata_file))?;
Ok(())
}
fn load_metadata(&self, backup_path: &Path) -> Result<BackupMetadata> {
let metadata_file = backup_path.join("metadata.json");
let json = fs::read_to_string(&metadata_file)
.with_context(|| format!("Failed to read metadata file: {:?}", metadata_file))?;
let metadata: BackupMetadata = serde_json::from_str(&json)
.context("Failed to parse backup metadata")?;
Ok(metadata)
}
fn files_differ(&self, info1: &BackupFileInfo, info2: &BackupFileInfo) -> bool {
if info1.size != info2.size {
return true;
}
match (&info1.checksum, &info2.checksum) {
(Some(c1), Some(c2)) => c1 != c2,
_ => {
info1.modified_at != info2.modified_at
}
}
}
pub fn get_total_backup_size(&self) -> Result<u64> {
let backups = self.list_backups()?;
Ok(backups.iter().map(|b| b.total_size).sum())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn create_test_file(dir: &Path, name: &str, content: &str) -> PathBuf {
let file_path = dir.join(name);
let mut file = fs::File::create(&file_path).unwrap();
file.write_all(content.as_bytes()).unwrap();
file_path
}
#[test]
fn test_backup_manager_creation() {
let temp_dir = TempDir::new().unwrap();
let backup_dir = temp_dir.path().join("backups");
let manager = BackupManager::new(&backup_dir, 5).unwrap();
assert!(backup_dir.exists());
}
#[test]
fn test_create_and_restore_backup() {
let temp_dir = TempDir::new().unwrap();
let backup_dir = temp_dir.path().join("backups");
let test_file = create_test_file(temp_dir.path(), "test.txt", "test content");
let manager = BackupManager::new(&backup_dir, 5).unwrap();
let backup_id = manager.create_backup(&[test_file.clone()], Some("Test backup".to_string())).unwrap();
assert!(!backup_id.is_empty());
fs::remove_file(&test_file).unwrap();
assert!(!test_file.exists());
let restored = manager.restore_backup(&backup_id, false).unwrap();
assert_eq!(restored, 1);
assert!(test_file.exists());
let content = fs::read_to_string(&test_file).unwrap();
assert_eq!(content, "test content");
}
#[test]
fn test_list_backups() {
let temp_dir = TempDir::new().unwrap();
let backup_dir = temp_dir.path().join("backups");
let test_file = create_test_file(temp_dir.path(), "test.txt", "content");
let manager = BackupManager::new(&backup_dir, 5).unwrap();
manager.create_backup(&[test_file.clone()], Some("Backup 1".to_string())).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
manager.create_backup(&[test_file.clone()], Some("Backup 2".to_string())).unwrap();
let backups = manager.list_backups().unwrap();
assert_eq!(backups.len(), 2);
assert_eq!(backups[0].description, Some("Backup 2".to_string()));
assert_eq!(backups[1].description, Some("Backup 1".to_string()));
}
#[test]
fn test_backup_rotation() {
let temp_dir = TempDir::new().unwrap();
let backup_dir = temp_dir.path().join("backups");
let test_file = create_test_file(temp_dir.path(), "test.txt", "content");
let manager = BackupManager::new(&backup_dir, 2).unwrap();
manager.create_backup(&[test_file.clone()], None).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
manager.create_backup(&[test_file.clone()], None).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
manager.create_backup(&[test_file.clone()], None).unwrap();
let backups = manager.list_backups().unwrap();
assert_eq!(backups.len(), 2);
}
#[test]
fn test_diff_backups() {
let temp_dir = TempDir::new().unwrap();
let backup_dir = temp_dir.path().join("backups");
let file1 = create_test_file(temp_dir.path(), "file1.txt", "content1");
let file2 = create_test_file(temp_dir.path(), "file2.txt", "content2");
let file3 = create_test_file(temp_dir.path(), "file3.txt", "content3");
let manager = BackupManager::new(&backup_dir, 5).unwrap();
let backup1 = manager.create_backup(&[file1.clone(), file2.clone()], None).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
fs::write(&file2, "modified content").unwrap();
let backup2 = manager.create_backup(&[file2.clone(), file3.clone()], None).unwrap();
let diff = manager.diff_backups(&backup1, &backup2).unwrap();
assert_eq!(diff.added_files.len(), 1);
assert!(diff.added_files.contains(&file3));
assert_eq!(diff.removed_files.len(), 1);
assert!(diff.removed_files.contains(&file1));
assert_eq!(diff.modified_files.len(), 1);
assert!(diff.modified_files.contains(&file2));
}
#[test]
fn test_backup_validation() {
let temp_dir = TempDir::new().unwrap();
let backup_dir = temp_dir.path().join("backups");
let test_file = create_test_file(temp_dir.path(), "test.txt", "content");
let manager = BackupManager::new(&backup_dir, 5).unwrap();
let backup_id = manager.create_backup(&[test_file], None).unwrap();
let backup_path = backup_dir.join(&backup_id);
let metadata = manager.load_metadata(&backup_path).unwrap();
assert!(manager.validate_backup(&backup_path, &metadata).is_ok());
}
#[test]
fn test_get_latest_backup() {
let temp_dir = TempDir::new().unwrap();
let backup_dir = temp_dir.path().join("backups");
let test_file = create_test_file(temp_dir.path(), "test.txt", "content");
let manager = BackupManager::new(&backup_dir, 5).unwrap();
assert!(manager.get_latest_backup_id().unwrap().is_none());
let backup1 = manager.create_backup(&[test_file.clone()], None).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let backup2 = manager.create_backup(&[test_file.clone()], None).unwrap();
let latest = manager.get_latest_backup_id().unwrap();
assert_eq!(latest, Some(backup2));
}
#[test]
fn test_delete_backup() {
let temp_dir = TempDir::new().unwrap();
let backup_dir = temp_dir.path().join("backups");
let test_file = create_test_file(temp_dir.path(), "test.txt", "content");
let manager = BackupManager::new(&backup_dir, 5).unwrap();
let backup_id = manager.create_backup(&[test_file], None).unwrap();
assert_eq!(manager.list_backups().unwrap().len(), 1);
manager.delete_backup(&backup_id).unwrap();
assert_eq!(manager.list_backups().unwrap().len(), 0);
}
#[test]
fn test_total_backup_size() {
let temp_dir = TempDir::new().unwrap();
let backup_dir = temp_dir.path().join("backups");
let file1 = create_test_file(temp_dir.path(), "file1.txt", "12345");
let file2 = create_test_file(temp_dir.path(), "file2.txt", "1234567890");
let manager = BackupManager::new(&backup_dir, 5).unwrap();
manager.create_backup(&[file1], None).unwrap();
manager.create_backup(&[file2], None).unwrap();
let total_size = manager.get_total_backup_size().unwrap();
assert_eq!(total_size, 15); }
}