mod create;
mod restore;
pub use restore::{
get_backup_db_version,
is_backup_compatible,
get_db_version,
check_db_version,
};
use std::path::{Path, PathBuf};
use std::fs;
use chrono::{DateTime, Utc, TimeZone, NaiveDateTime};
use crate::database::Database;
use crate::error::Result;
pub const BACKUP_PREFIX: &str = "iwb";
pub const BACKUP_PREFIX_LEGACY: &str = "nswb";
pub const BACKUP_AUTO: &str = "auto";
pub const BACKUP_MANUAL: &str = "manual";
pub const BACKUP_IMPORTED: &str = "imported";
pub const BACKUP_DATE_FORMAT: &str = "%Y%m%d-%H%M%S";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackupType {
Auto,
Manual,
Imported,
}
pub struct BackupManager {
folder: PathBuf,
}
impl BackupManager {
pub fn new(folder: &Path) -> Self {
Self {
folder: folder.to_path_buf(),
}
}
pub fn folder(&self) -> &Path {
&self.folder
}
pub fn create_backup(&self, db: &Database, manual: bool) -> Result<PathBuf> {
create::create_backup(&self.folder, db, manual)
}
pub fn create_backup_from_path(&self, db_path: &Path, manual: bool) -> Result<PathBuf> {
create::create_backup_from_path(&self.folder, db_path, manual)
}
pub fn restore_backup(&self, backup_path: &Path, db_path: &Path) -> Result<()> {
restore::restore_backup(backup_path, db_path)
}
pub fn extract_backup(&self, backup_path: &Path, target_folder: &Path) -> Result<PathBuf> {
restore::extract_backup(backup_path, target_folder)
}
pub fn list_backups(&self) -> Result<Vec<BackupInfo>> {
let mut backups = Vec::new();
if !self.folder.exists() {
return Ok(backups);
}
for entry in fs::read_dir(&self.folder)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if (filename.starts_with(BACKUP_PREFIX) || filename.starts_with(BACKUP_PREFIX_LEGACY))
&& filename.ends_with(".zip")
{
if let Some(info) = parse_backup_filename(filename, &path) {
backups.push(info);
}
}
}
}
}
backups.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(backups)
}
pub fn verify_backup(&self, backup_path: &Path) -> Result<bool> {
restore::verify_backup(backup_path)
}
pub fn cleanup_old_backups(&self, keep_count: usize) -> Result<usize> {
let backups = self.list_backups()?;
if backups.len() <= keep_count {
return Ok(0);
}
let mut deleted = 0;
for backup in backups.iter().skip(keep_count) {
fs::remove_file(&backup.path)?;
deleted += 1;
}
Ok(deleted)
}
pub fn get_latest_backup(&self) -> Result<Option<BackupInfo>> {
let backups = self.list_backups()?;
Ok(backups.into_iter().next())
}
pub fn cleanup_auto_backups(&self, min_keep: usize, max_age_days: u32) -> Result<usize> {
let backups = self.list_backups()?;
let auto_backups: Vec<&BackupInfo> = backups.iter().filter(|b| b.backup_type == BackupType::Auto).collect();
if auto_backups.len() <= min_keep {
return Ok(0);
}
let cutoff = Utc::now() - chrono::Duration::days(max_age_days as i64);
let mut deleted = 0;
for backup in auto_backups.iter().skip(min_keep) {
if backup.timestamp < cutoff {
fs::remove_file(&backup.path)?;
deleted += 1;
}
}
Ok(deleted)
}
}
#[derive(Debug, Clone)]
pub struct BackupInfo {
pub path: PathBuf,
pub timestamp: DateTime<Utc>,
pub backup_type: BackupType,
pub size: u64,
}
fn parse_backup_filename(filename: &str, path: &Path) -> Option<BackupInfo> {
let parts: Vec<&str> = filename.split('-').collect();
if parts.len() != 4 {
return None;
}
if parts[0] != BACKUP_PREFIX && parts[0] != BACKUP_PREFIX_LEGACY {
return None;
}
let date_str = parts[1];
let time_str = parts[2];
if date_str.len() != 8 || time_str.len() != 6 {
return None;
}
let datetime_str = format!("{}-{}", date_str, time_str);
let ndt = NaiveDateTime::parse_from_str(&datetime_str, BACKUP_DATE_FORMAT).ok()?;
let timestamp = Utc.from_utc_datetime(&ndt);
let type_str = parts[3].trim_end_matches(".zip");
let backup_type = match type_str {
BACKUP_MANUAL => BackupType::Manual,
BACKUP_IMPORTED => BackupType::Imported,
_ => BackupType::Auto,
};
let size = path.metadata().ok()?.len();
Some(BackupInfo {
path: path.to_path_buf(),
timestamp,
backup_type,
size,
})
}
pub fn get_date_from_backup_filename(filename: &str) -> Option<DateTime<Utc>> {
let name = Path::new(filename)
.file_name()
.and_then(|n| n.to_str())?;
let parts: Vec<&str> = name.split('-').collect();
if parts.len() < 3 {
return None;
}
let date_str = parts[1];
let time_str = parts[2];
if date_str.len() != 8 || time_str.len() != 6 {
return None;
}
let datetime_str = format!("{}-{}", date_str, time_str);
let ndt = NaiveDateTime::parse_from_str(&datetime_str, BACKUP_DATE_FORMAT).ok()?;
Some(Utc.from_utc_datetime(&ndt))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, Timelike};
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_parse_backup_filename() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("iwb-20171203-113108-auto.zip");
let mut file = std::fs::File::create(&path).unwrap();
file.write_all(b"test").unwrap();
let info = parse_backup_filename("iwb-20171203-113108-auto.zip", &path).unwrap();
assert_eq!(info.backup_type, BackupType::Auto);
assert_eq!(info.timestamp.year(), 2017);
assert_eq!(info.timestamp.month(), 12);
assert_eq!(info.timestamp.day(), 3);
}
#[test]
fn test_parse_legacy_backup_filename() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("nswb-20171203-113108-auto.zip");
let mut file = std::fs::File::create(&path).unwrap();
file.write_all(b"test").unwrap();
let info = parse_backup_filename("nswb-20171203-113108-auto.zip", &path).unwrap();
assert_eq!(info.backup_type, BackupType::Auto);
assert_eq!(info.timestamp.year(), 2017);
assert_eq!(info.timestamp.month(), 12);
assert_eq!(info.timestamp.day(), 3);
}
#[test]
fn test_parse_imported_backup_filename() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("iwb-20231215-143022-imported.zip");
let mut file = std::fs::File::create(&path).unwrap();
file.write_all(b"test").unwrap();
let info = parse_backup_filename("iwb-20231215-143022-imported.zip", &path).unwrap();
assert_eq!(info.backup_type, BackupType::Imported);
assert_eq!(info.timestamp.year(), 2023);
assert_eq!(info.timestamp.month(), 12);
assert_eq!(info.timestamp.day(), 15);
}
#[test]
fn test_get_date_from_backup_filename() {
let date = get_date_from_backup_filename("iwb-20171203-113108-auto.zip").unwrap();
assert_eq!(date.year(), 2017);
assert_eq!(date.month(), 12);
assert_eq!(date.day(), 3);
assert_eq!(date.hour(), 11);
assert_eq!(date.minute(), 31);
assert_eq!(date.second(), 8);
}
#[test]
fn test_get_date_from_legacy_backup_filename() {
let date = get_date_from_backup_filename("nswb-20171203-113108-auto.zip").unwrap();
assert_eq!(date.year(), 2017);
assert_eq!(date.month(), 12);
assert_eq!(date.day(), 3);
assert_eq!(date.hour(), 11);
assert_eq!(date.minute(), 31);
assert_eq!(date.second(), 8);
}
#[test]
fn test_get_date_from_full_path_iwb_prefix() {
let date = get_date_from_backup_filename("/var/backups/wallet/iwb-20231215-143022-manual.zip").unwrap();
assert_eq!(date.year(), 2023);
assert_eq!(date.month(), 12);
assert_eq!(date.day(), 15);
assert_eq!(date.hour(), 14);
assert_eq!(date.minute(), 30);
assert_eq!(date.second(), 22);
}
#[test]
fn test_invalid_filename() {
assert!(get_date_from_backup_filename("some text").is_none());
assert!(get_date_from_backup_filename("path/a/b/iwb-20171203-113108-auto.zip").is_some());
assert!(get_date_from_backup_filename("path/a/b/nswb-20171203-113108-auto.zip").is_some());
assert!(get_date_from_backup_filename("invalid-format.zip").is_none());
}
#[test]
fn test_backup_manager_folder() {
let temp_dir = TempDir::new().unwrap();
let mgr = BackupManager::new(temp_dir.path());
assert_eq!(mgr.folder(), temp_dir.path());
}
#[test]
fn test_list_backups_empty() {
let temp_dir = TempDir::new().unwrap();
let mgr = BackupManager::new(temp_dir.path());
let backups = mgr.list_backups().unwrap();
assert!(backups.is_empty());
}
#[test]
fn test_list_backups_nonexistent_folder() {
let mgr = BackupManager::new(Path::new("/nonexistent/path/12345"));
let backups = mgr.list_backups().unwrap();
assert!(backups.is_empty());
}
#[test]
fn test_list_backups_with_files() {
let temp_dir = TempDir::new().unwrap();
let file1 = temp_dir.path().join("iwb-20231201-100000-auto.zip");
let file2 = temp_dir.path().join("iwb-20231202-120000-manual.zip");
let file3 = temp_dir.path().join("other-file.txt");
std::fs::File::create(&file1).unwrap().write_all(b"test1").unwrap();
std::fs::File::create(&file2).unwrap().write_all(b"test2").unwrap();
std::fs::File::create(&file3).unwrap().write_all(b"test3").unwrap();
let mgr = BackupManager::new(temp_dir.path());
let backups = mgr.list_backups().unwrap();
assert_eq!(backups.len(), 2);
assert_eq!(backups[0].backup_type, BackupType::Manual); assert_eq!(backups[1].backup_type, BackupType::Auto); }
#[test]
fn test_list_backups_with_legacy_files() {
let temp_dir = TempDir::new().unwrap();
let file1 = temp_dir.path().join("nswb-20231201-100000-auto.zip");
let file2 = temp_dir.path().join("nswb-20231202-120000-manual.zip");
std::fs::File::create(&file1).unwrap().write_all(b"test1").unwrap();
std::fs::File::create(&file2).unwrap().write_all(b"test2").unwrap();
let mgr = BackupManager::new(temp_dir.path());
let backups = mgr.list_backups().unwrap();
assert_eq!(backups.len(), 2);
assert_eq!(backups[0].backup_type, BackupType::Manual);
assert_eq!(backups[1].backup_type, BackupType::Auto);
}
#[test]
fn test_get_latest_backup() {
let temp_dir = TempDir::new().unwrap();
let mgr = BackupManager::new(temp_dir.path());
assert!(mgr.get_latest_backup().unwrap().is_none());
let file1 = temp_dir.path().join("iwb-20231201-100000-auto.zip");
let file2 = temp_dir.path().join("iwb-20231202-120000-manual.zip");
std::fs::File::create(&file1).unwrap().write_all(b"test1").unwrap();
std::fs::File::create(&file2).unwrap().write_all(b"test2").unwrap();
let latest = mgr.get_latest_backup().unwrap().unwrap();
assert_eq!(latest.backup_type, BackupType::Manual); }
#[test]
fn test_cleanup_old_backups() {
let temp_dir = TempDir::new().unwrap();
for i in 1..=5 {
let file = temp_dir.path().join(format!("iwb-2023120{}-100000-auto.zip", i));
std::fs::File::create(&file).unwrap().write_all(b"test").unwrap();
}
let mgr = BackupManager::new(temp_dir.path());
assert_eq!(mgr.list_backups().unwrap().len(), 5);
let deleted = mgr.cleanup_old_backups(2).unwrap();
assert_eq!(deleted, 3);
assert_eq!(mgr.list_backups().unwrap().len(), 2);
}
#[test]
fn test_cleanup_nothing_to_delete() {
let temp_dir = TempDir::new().unwrap();
let file1 = temp_dir.path().join("iwb-20231201-100000-auto.zip");
let file2 = temp_dir.path().join("iwb-20231202-100000-auto.zip");
std::fs::File::create(&file1).unwrap().write_all(b"test").unwrap();
std::fs::File::create(&file2).unwrap().write_all(b"test").unwrap();
let mgr = BackupManager::new(temp_dir.path());
let deleted = mgr.cleanup_old_backups(5).unwrap();
assert_eq!(deleted, 0);
assert_eq!(mgr.list_backups().unwrap().len(), 2);
}
#[test]
fn test_parse_manual_backup() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("iwb-20231215-143022-manual.zip");
std::fs::File::create(&path).unwrap().write_all(b"test").unwrap();
let info = parse_backup_filename("iwb-20231215-143022-manual.zip", &path).unwrap();
assert_eq!(info.backup_type, BackupType::Manual);
assert_eq!(info.timestamp.year(), 2023);
assert_eq!(info.timestamp.month(), 12);
assert_eq!(info.timestamp.day(), 15);
}
#[test]
fn test_cleanup_auto_backups_deletes_old() {
let temp_dir = TempDir::new().unwrap();
for i in 1..=5 {
let file = temp_dir.path().join(format!("iwb-2020010{}-100000-auto.zip", i));
std::fs::File::create(&file).unwrap().write_all(b"test").unwrap();
}
let mgr = BackupManager::new(temp_dir.path());
assert_eq!(mgr.list_backups().unwrap().len(), 5);
let deleted = mgr.cleanup_auto_backups(3, 30).unwrap();
assert_eq!(deleted, 2);
assert_eq!(mgr.list_backups().unwrap().len(), 3);
}
#[test]
fn test_cleanup_auto_backups_below_minimum() {
let temp_dir = TempDir::new().unwrap();
let file1 = temp_dir.path().join("iwb-20200101-100000-auto.zip");
let file2 = temp_dir.path().join("iwb-20200102-100000-auto.zip");
std::fs::File::create(&file1).unwrap().write_all(b"test").unwrap();
std::fs::File::create(&file2).unwrap().write_all(b"test").unwrap();
let mgr = BackupManager::new(temp_dir.path());
let deleted = mgr.cleanup_auto_backups(3, 30).unwrap();
assert_eq!(deleted, 0);
assert_eq!(mgr.list_backups().unwrap().len(), 2);
}
#[test]
fn test_cleanup_auto_backups_ignores_manual() {
let temp_dir = TempDir::new().unwrap();
for i in 1..=5 {
let file = temp_dir.path().join(format!("iwb-2020010{}-100000-auto.zip", i));
std::fs::File::create(&file).unwrap().write_all(b"test").unwrap();
}
for i in 6..=8 {
let file = temp_dir.path().join(format!("iwb-2020010{}-100000-manual.zip", i));
std::fs::File::create(&file).unwrap().write_all(b"test").unwrap();
}
let mgr = BackupManager::new(temp_dir.path());
assert_eq!(mgr.list_backups().unwrap().len(), 8);
let deleted = mgr.cleanup_auto_backups(3, 30).unwrap();
assert_eq!(deleted, 2);
let remaining = mgr.list_backups().unwrap();
assert_eq!(remaining.len(), 6); let manual_count = remaining.iter().filter(|b| b.backup_type == BackupType::Manual).count();
assert_eq!(manual_count, 3);
}
#[test]
fn test_cleanup_auto_backups_ignores_imported() {
let temp_dir = TempDir::new().unwrap();
for i in 1..=5 {
let file = temp_dir.path().join(format!("iwb-2020010{}-100000-auto.zip", i));
std::fs::File::create(&file).unwrap().write_all(b"test").unwrap();
}
for i in 6..=7 {
let file = temp_dir.path().join(format!("iwb-2020010{}-100000-imported.zip", i));
std::fs::File::create(&file).unwrap().write_all(b"test").unwrap();
}
let mgr = BackupManager::new(temp_dir.path());
assert_eq!(mgr.list_backups().unwrap().len(), 7);
let deleted = mgr.cleanup_auto_backups(3, 30).unwrap();
assert_eq!(deleted, 2);
let remaining = mgr.list_backups().unwrap();
assert_eq!(remaining.len(), 5); let imported_count = remaining.iter().filter(|b| b.backup_type == BackupType::Imported).count();
assert_eq!(imported_count, 2);
}
#[test]
fn test_cleanup_auto_backups_mixed_ages() {
let temp_dir = TempDir::new().unwrap();
let now = Utc::now();
for i in 0..3 {
let ts = now - chrono::Duration::days(i);
let file = temp_dir.path().join(format!(
"iwb-{}-auto.zip",
ts.format(BACKUP_DATE_FORMAT)
));
std::fs::File::create(&file).unwrap().write_all(b"test").unwrap();
}
let file_old1 = temp_dir.path().join("iwb-20200101-100000-auto.zip");
let file_old2 = temp_dir.path().join("iwb-20200102-100000-auto.zip");
std::fs::File::create(&file_old1).unwrap().write_all(b"test").unwrap();
std::fs::File::create(&file_old2).unwrap().write_all(b"test").unwrap();
let mgr = BackupManager::new(temp_dir.path());
assert_eq!(mgr.list_backups().unwrap().len(), 5);
let deleted = mgr.cleanup_auto_backups(3, 30).unwrap();
assert_eq!(deleted, 2);
assert_eq!(mgr.list_backups().unwrap().len(), 3);
}
#[test]
fn test_cleanup_auto_backups_empty() {
let temp_dir = TempDir::new().unwrap();
let mgr = BackupManager::new(temp_dir.path());
let deleted = mgr.cleanup_auto_backups(3, 30).unwrap();
assert_eq!(deleted, 0);
}
}