// libmarlin/src/backup.rs use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc}; use rusqlite; use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; use crate::error as marlin_error; #[derive(Debug, Clone)] pub struct BackupInfo { pub id: String, pub timestamp: DateTime, pub size_bytes: u64, pub hash: Option, } #[derive(Debug)] pub struct PruneResult { pub kept: Vec, pub removed: Vec, } #[derive(Debug)] pub struct BackupManager { live_db_path: PathBuf, backups_dir: PathBuf, } impl BackupManager { pub fn new, P2: AsRef>( live_db_path: P1, backups_dir: P2, ) -> Result { let backups_dir_path = backups_dir.as_ref().to_path_buf(); if !backups_dir_path.exists() { fs::create_dir_all(&backups_dir_path).with_context(|| { format!( "Failed to create backup directory at {}", backups_dir_path.display() ) })?; } else if !backups_dir_path.is_dir() { return Err(anyhow!( "Backups path exists but is not a directory: {}", backups_dir_path.display() )); } Ok(Self { live_db_path: live_db_path.as_ref().to_path_buf(), backups_dir: backups_dir_path, }) } pub fn create_backup(&self) -> Result { let stamp = Local::now().format("%Y-%m-%d_%H-%M-%S_%f"); let backup_file_name = format!("backup_{stamp}.db"); let backup_file_path = self.backups_dir.join(&backup_file_name); if !self.live_db_path.exists() { return Err(anyhow::Error::new(std::io::Error::new( std::io::ErrorKind::NotFound, format!( "Live DB path does not exist: {}", self.live_db_path.display() ), )) .context("Cannot create backup from non-existent live DB")); } let src_conn = rusqlite::Connection::open_with_flags( &self.live_db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, ) .with_context(|| { format!( "Failed to open source DB ('{}') for backup", self.live_db_path.display() ) })?; let mut dst_conn = rusqlite::Connection::open(&backup_file_path).with_context(|| { format!( "Failed to open destination backup file: {}", backup_file_path.display() ) })?; let backup_op = rusqlite::backup::Backup::new(&src_conn, &mut dst_conn).with_context(|| { format!( "Failed to initialize backup from {} to {}", self.live_db_path.display(), backup_file_path.display() ) })?; backup_op .run_to_completion(100, Duration::from_millis(250), None) .map_err(|e| anyhow::Error::new(e).context("SQLite backup operation failed"))?; let metadata = fs::metadata(&backup_file_path).with_context(|| { format!( "Failed to get metadata for backup file: {}", backup_file_path.display() ) })?; Ok(BackupInfo { id: backup_file_name, timestamp: DateTime::from(metadata.modified()?), size_bytes: metadata.len(), hash: None, }) } pub fn list_backups(&self) -> Result> { let mut backup_infos = Vec::new(); if !self.backups_dir.exists() { return Ok(backup_infos); } for entry_result in fs::read_dir(&self.backups_dir).with_context(|| { format!( "Failed to read backup directory: {}", self.backups_dir.display() ) })? { let entry = entry_result?; let path = entry.path(); if path.is_file() { if let Some(filename_osstr) = path.file_name() { if let Some(filename) = filename_osstr.to_str() { if filename.starts_with("backup_") && filename.ends_with(".db") { let metadata = fs::metadata(&path).with_context(|| { format!("Failed to get metadata for {}", path.display()) })?; let ts_str = filename .trim_start_matches("backup_") .trim_end_matches(".db"); let parsed_dt = NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d_%H-%M-%S_%f") .or_else(|_| { NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d_%H-%M-%S") }); let timestamp_utc = match parsed_dt { Ok(naive_dt) => { let local_dt_result = Local.from_local_datetime(&naive_dt); let local_dt = match local_dt_result { chrono::LocalResult::Single(dt) => dt, chrono::LocalResult::Ambiguous(dt1, _dt2) => { eprintln!("Warning: Ambiguous local time for backup {}, taking first interpretation.", filename); dt1 } chrono::LocalResult::None => { eprintln!( "Warning: Invalid local time for backup {}, skipping.", filename ); continue; } }; DateTime::::from(local_dt) } Err(_) => DateTime::::from(metadata.modified()?), }; backup_infos.push(BackupInfo { id: filename.to_string(), timestamp: timestamp_utc, size_bytes: metadata.len(), hash: None, }); } } } } } backup_infos.sort_by_key(|b| std::cmp::Reverse(b.timestamp)); Ok(backup_infos) } pub fn prune(&self, keep_count: usize) -> Result { let all_backups = self.list_backups()?; let mut kept = Vec::new(); let mut removed = Vec::new(); if keep_count >= all_backups.len() { kept = all_backups; } else { for (index, backup_info) in all_backups.into_iter().enumerate() { if index < keep_count { kept.push(backup_info); } else { let backup_file_path = self.backups_dir.join(&backup_info.id); if backup_file_path.exists() { fs::remove_file(&backup_file_path).with_context(|| { format!( "Failed to remove old backup file: {}", backup_file_path.display() ) })?; } removed.push(backup_info); } } } Ok(PruneResult { kept, removed }) } pub fn restore_from_backup(&self, backup_id: &str) -> Result<()> { let backup_file_path = self.backups_dir.join(backup_id); if !backup_file_path.exists() || !backup_file_path.is_file() { return Err(anyhow::Error::new(marlin_error::Error::NotFound(format!( "Backup file not found or is not a file: {}", backup_file_path.display() )))); } fs::copy(&backup_file_path, &self.live_db_path).with_context(|| { format!( "Failed to copy backup {} to live DB {}", backup_file_path.display(), self.live_db_path.display() ) })?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::db::open as open_marlin_db; use tempfile::tempdir; fn create_valid_live_db(path: &Path) -> rusqlite::Connection { let conn = open_marlin_db(path).unwrap_or_else(|e| { panic!( "Failed to open/create test DB at {}: {:?}", path.display(), e ) }); conn.execute_batch( "CREATE TABLE IF NOT EXISTS test_table (id INTEGER PRIMARY KEY, data TEXT); INSERT INTO test_table (data) VALUES ('initial_data');", ) .expect("Failed to initialize test table"); conn } #[test] fn test_backup_manager_new_creates_dir() { let base_tmp = tempdir().unwrap(); let live_db_path = base_tmp.path().join("live_new_creates.db"); let _conn = create_valid_live_db(&live_db_path); let backups_dir = base_tmp.path().join("my_backups_new_creates_test"); assert!(!backups_dir.exists()); let manager = BackupManager::new(&live_db_path, &backups_dir).unwrap(); assert!(manager.backups_dir.exists()); assert!(backups_dir.exists()); } #[test] fn test_backup_manager_new_with_existing_dir() { let base_tmp = tempdir().unwrap(); let live_db_path = base_tmp.path().join("live_existing_dir.db"); let _conn = create_valid_live_db(&live_db_path); let backups_dir = base_tmp.path().join("my_backups_existing_test"); std::fs::create_dir_all(&backups_dir).unwrap(); assert!(backups_dir.exists()); let manager_res = BackupManager::new(&live_db_path, &backups_dir); assert!(manager_res.is_ok()); let manager = manager_res.unwrap(); assert_eq!(manager.backups_dir, backups_dir); } #[test] fn test_backup_manager_new_fails_if_backup_path_is_file() { let base_tmp = tempdir().unwrap(); let live_db_path = base_tmp.path().join("live_backup_path_is_file.db"); let _conn = create_valid_live_db(&live_db_path); let file_as_backups_dir = base_tmp.path().join("file_as_backups_dir"); std::fs::write(&file_as_backups_dir, "i am a file").unwrap(); let manager_res = BackupManager::new(&live_db_path, &file_as_backups_dir); assert!(manager_res.is_err()); assert!(manager_res .unwrap_err() .to_string() .contains("Backups path exists but is not a directory")); } #[test] fn test_create_backup_failure_non_existent_live_db() { let base_tmp = tempdir().unwrap(); let live_db_path = base_tmp.path().join("non_existent_live.db"); let backups_dir = base_tmp.path().join("backups_fail_test"); let manager = BackupManager::new(&live_db_path, &backups_dir).unwrap(); let backup_result = manager.create_backup(); assert!(backup_result.is_err()); let err_str = backup_result.unwrap_err().to_string(); assert!( err_str.contains("Cannot create backup from non-existent live DB") || err_str.contains("Failed to open source DB") ); } #[test] fn test_create_list_prune_backups() { let tmp = tempdir().unwrap(); let live_db_file = tmp.path().join("live_for_clp_test.db"); let _conn_live = create_valid_live_db(&live_db_file); let backups_storage_dir = tmp.path().join("backups_clp_storage_test"); let manager = BackupManager::new(&live_db_file, &backups_storage_dir).unwrap(); let initial_list = manager.list_backups().unwrap(); assert!( initial_list.is_empty(), "Backup list should be empty initially" ); let prune_empty_result = manager.prune(2).unwrap(); assert!(prune_empty_result.kept.is_empty()); assert!(prune_empty_result.removed.is_empty()); let mut created_backup_ids = Vec::new(); for i in 0..5 { let info = manager .create_backup() .unwrap_or_else(|e| panic!("Failed to create backup {}: {:?}", i, e)); created_backup_ids.push(info.id.clone()); std::thread::sleep(std::time::Duration::from_millis(30)); } let listed_backups = manager.list_backups().unwrap(); assert_eq!(listed_backups.len(), 5); for id in &created_backup_ids { assert!( listed_backups.iter().any(|b| &b.id == id), "Backup ID {} not found in list", id ); } if listed_backups.len() >= 2 { assert!(listed_backups[0].timestamp >= listed_backups[1].timestamp); } let prune_to_zero_result = manager.prune(0).unwrap(); assert_eq!(prune_to_zero_result.kept.len(), 0); assert_eq!(prune_to_zero_result.removed.len(), 5); let listed_after_prune_zero = manager.list_backups().unwrap(); assert!(listed_after_prune_zero.is_empty()); created_backup_ids.clear(); for i in 0..5 { let info = manager .create_backup() .unwrap_or_else(|e| panic!("Failed to create backup {}: {:?}", i, e)); created_backup_ids.push(info.id.clone()); std::thread::sleep(std::time::Duration::from_millis(30)); } let prune_keep_more_result = manager.prune(10).unwrap(); assert_eq!(prune_keep_more_result.kept.len(), 5); assert_eq!(prune_keep_more_result.removed.len(), 0); let listed_after_prune_more = manager.list_backups().unwrap(); assert_eq!(listed_after_prune_more.len(), 5); let prune_result = manager.prune(2).unwrap(); assert_eq!(prune_result.kept.len(), 2); assert_eq!(prune_result.removed.len(), 3); let listed_after_prune = manager.list_backups().unwrap(); assert_eq!(listed_after_prune.len(), 2); assert_eq!(listed_after_prune[0].id, created_backup_ids[4]); assert_eq!(listed_after_prune[1].id, created_backup_ids[3]); for removed_info in prune_result.removed { assert!( !backups_storage_dir.join(&removed_info.id).exists(), "Removed backup file {} should not exist", removed_info.id ); } for kept_info in prune_result.kept { assert!( backups_storage_dir.join(&kept_info.id).exists(), "Kept backup file {} should exist", kept_info.id ); } } #[test] fn test_restore_backup() { let tmp = tempdir().unwrap(); let live_db_path = tmp.path().join("live_for_restore_test.db"); let initial_value = "initial_data_for_restore"; { let conn = create_valid_live_db(&live_db_path); conn.execute("DELETE FROM test_table", []).unwrap(); conn.execute("INSERT INTO test_table (data) VALUES (?1)", [initial_value]) .unwrap(); } let backups_dir = tmp.path().join("backups_for_restore_test_dir"); let manager = BackupManager::new(&live_db_path, &backups_dir).unwrap(); let backup_info = manager.create_backup().unwrap(); let modified_value = "modified_data_for_restore"; { let conn = rusqlite::Connection::open(&live_db_path) .expect("Failed to open live DB for modification"); conn.execute("UPDATE test_table SET data = ?1", [modified_value]) .expect("Failed to update data"); let modified_check: String = conn .query_row("SELECT data FROM test_table", [], |row| row.get(0)) .unwrap(); assert_eq!(modified_check, modified_value); } manager.restore_from_backup(&backup_info.id).unwrap(); { let conn_after_restore = rusqlite::Connection::open(&live_db_path) .expect("Failed to open live DB after restore"); let restored_data: String = conn_after_restore .query_row("SELECT data FROM test_table", [], |row| row.get(0)) .unwrap(); assert_eq!(restored_data, initial_value); } } #[test] fn test_restore_non_existent_backup() { let tmp = tempdir().unwrap(); let live_db_path = tmp.path().join("live_for_restore_fail_test.db"); let _conn = create_valid_live_db(&live_db_path); let backups_dir = tmp.path().join("backups_for_restore_fail_test"); let manager = BackupManager::new(&live_db_path, &backups_dir).unwrap(); let result = manager.restore_from_backup("non_existent_backup.db"); assert!(result.is_err()); let err_string = result.unwrap_err().to_string(); assert!( err_string.contains("Backup file not found"), "Error string was: {}", err_string ); } #[test] fn list_backups_with_non_backup_files() { let tmp = tempdir().unwrap(); let live_db_file = tmp.path().join("live_for_list_test.db"); let _conn = create_valid_live_db(&live_db_file); let backups_dir = tmp.path().join("backups_list_mixed_files_test"); let manager = BackupManager::new(&live_db_file, &backups_dir).unwrap(); manager.create_backup().unwrap(); std::fs::write(backups_dir.join("not_a_backup.txt"), "hello").unwrap(); std::fs::write(backups_dir.join("backup_malformed.db.tmp"), "temp data").unwrap(); std::fs::create_dir(backups_dir.join("a_subdir")).unwrap(); let listed_backups = manager.list_backups().unwrap(); assert_eq!( listed_backups.len(), 1, "Should only list the valid backup file" ); assert!(listed_backups[0].id.starts_with("backup_")); assert!(listed_backups[0].id.ends_with(".db")); } #[test] fn list_backups_handles_io_error_on_read_dir() { let tmp = tempdir().unwrap(); let live_db_file = tmp.path().join("live_for_list_io_error.db"); let _conn = create_valid_live_db(&live_db_file); let backups_dir_for_deletion = tmp.path().join("backups_dir_to_delete_test"); let manager_for_deletion = BackupManager::new(&live_db_file, &backups_dir_for_deletion).unwrap(); std::fs::remove_dir_all(&backups_dir_for_deletion).unwrap(); let list_res = manager_for_deletion.list_backups().unwrap(); assert!(list_res.is_empty()); } #[test] fn list_backups_fallback_modification_time() { let tmp = tempdir().unwrap(); let live_db = tmp.path().join("live_for_badformat.db"); let _conn = create_valid_live_db(&live_db); let backups_dir = tmp.path().join("backups_badformat_test"); let manager = BackupManager::new(&live_db, &backups_dir).unwrap(); let bad_backup_path = backups_dir.join("backup_badformat.db"); std::fs::write(&bad_backup_path, b"bad").unwrap(); let metadata = std::fs::metadata(&bad_backup_path).unwrap(); let expected_ts = chrono::DateTime::::from(metadata.modified().unwrap()); let listed = manager.list_backups().unwrap(); assert_eq!(listed.len(), 1); let info = &listed[0]; assert_eq!(info.id, "backup_badformat.db"); assert_eq!(info.timestamp, expected_ts); } }