use crate::db::Database; use serde::{Deserialize, Serialize}; use std::io::{Read, Write}; use std::path::Path; use zip::write::SimpleFileOptions; #[derive(Debug, Serialize, Deserialize)] pub struct BackupMeta { pub app_version: String, pub schema_version: i32, pub export_date: String, pub transaction_count: i64, pub category_count: i64, } #[derive(Debug)] pub enum BackupError { Db(rusqlite::Error), Io(std::io::Error), Zip(zip::result::ZipError), Json(serde_json::Error), InvalidBackup(String), } impl From for BackupError { fn from(e: rusqlite::Error) -> Self { BackupError::Db(e) } } impl From for BackupError { fn from(e: std::io::Error) -> Self { BackupError::Io(e) } } impl From for BackupError { fn from(e: zip::result::ZipError) -> Self { BackupError::Zip(e) } } impl From for BackupError { fn from(e: serde_json::Error) -> Self { BackupError::Json(e) } } impl std::fmt::Display for BackupError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { BackupError::Db(e) => write!(f, "Database error: {}", e), BackupError::Io(e) => write!(f, "IO error: {}", e), BackupError::Zip(e) => write!(f, "ZIP error: {}", e), BackupError::Json(e) => write!(f, "JSON error: {}", e), BackupError::InvalidBackup(msg) => write!(f, "Invalid backup: {}", msg), } } } pub fn create_backup(db: &Database, output_path: &Path) -> Result { let temp_db_path = output_path.with_extension("tmp.db"); // Use VACUUM INTO for a clean, consistent database copy db.conn.execute( "VACUUM INTO ?1", rusqlite::params![temp_db_path.to_str().unwrap()], )?; let db_bytes = std::fs::read(&temp_db_path)?; std::fs::remove_file(&temp_db_path)?; let txn_count: i64 = db.conn.query_row( "SELECT COUNT(*) FROM transactions", [], |row| row.get(0), )?; let cat_count: i64 = db.conn.query_row( "SELECT COUNT(*) FROM categories", [], |row| row.get(0), )?; let schema_version = db.schema_version()?; let meta = BackupMeta { app_version: env!("CARGO_PKG_VERSION").to_string(), schema_version, export_date: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(), transaction_count: txn_count, category_count: cat_count, }; let meta_json = serde_json::to_string_pretty(&meta)?; let file = std::fs::File::create(output_path)?; let mut zip = zip::ZipWriter::new(file); let options = SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); zip.start_file("outlay.db", options)?; zip.write_all(&db_bytes)?; zip.start_file("meta.json", options)?; zip.write_all(meta_json.as_bytes())?; zip.finish()?; Ok(meta) } pub fn read_backup_meta(backup_path: &Path) -> Result { let file = std::fs::File::open(backup_path)?; let mut archive = zip::ZipArchive::new(file)?; let mut meta_file = archive.by_name("meta.json").map_err(|_| { BackupError::InvalidBackup("meta.json not found in backup".to_string()) })?; let mut contents = String::new(); meta_file.read_to_string(&mut contents)?; let meta: BackupMeta = serde_json::from_str(&contents)?; Ok(meta) } pub fn restore_backup(backup_path: &Path, db_path: &Path) -> Result { let file = std::fs::File::open(backup_path)?; let mut archive = zip::ZipArchive::new(file)?; // Validate - must contain meta.json and outlay.db let meta = { let mut meta_file = archive.by_name("meta.json").map_err(|_| { BackupError::InvalidBackup("meta.json not found in backup".to_string()) })?; let mut contents = String::new(); meta_file.read_to_string(&mut contents)?; let meta: BackupMeta = serde_json::from_str(&contents)?; meta }; let mut db_file = archive.by_name("outlay.db").map_err(|_| { BackupError::InvalidBackup("outlay.db not found in backup".to_string()) })?; let mut db_bytes = Vec::new(); db_file.read_to_end(&mut db_bytes)?; if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(db_path, &db_bytes)?; Ok(meta) } #[cfg(test)] mod tests { use super::*; use crate::models::{NewTransaction, TransactionType}; use chrono::NaiveDate; fn setup_db_with_data(name: &str) -> (Database, std::path::PathBuf, std::path::PathBuf) { let dir = std::env::temp_dir().join(format!("outlay_backup_{}", name)); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let db_path = dir.join("test.db"); let db = Database::open(&db_path).unwrap(); let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); let txn = NewTransaction { amount: 42.0, transaction_type: TransactionType::Expense, category_id: cats[0].id, currency: "USD".to_string(), exchange_rate: 1.0, note: Some("Test transaction".to_string()), date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), recurring_id: None, payee: None, }; db.insert_transaction(&txn).unwrap(); (db, db_path, dir) } #[test] fn test_backup_creates_valid_zip() { let (db, _db_path, dir) = setup_db_with_data("zip"); let backup_path = dir.join("test_backup.outlay"); let meta = create_backup(&db, &backup_path).unwrap(); assert!(backup_path.exists()); assert_eq!(meta.transaction_count, 1); assert!(meta.category_count > 0); assert_eq!(meta.app_version, env!("CARGO_PKG_VERSION")); // Verify ZIP contents let file = std::fs::File::open(&backup_path).unwrap(); let mut archive = zip::ZipArchive::new(file).unwrap(); assert!(archive.by_name("outlay.db").is_ok()); assert!(archive.by_name("meta.json").is_ok()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_backup_meta_contains_version() { let (db, _db_path, dir) = setup_db_with_data("meta"); let backup_path = dir.join("test_meta.outlay"); create_backup(&db, &backup_path).unwrap(); let meta = read_backup_meta(&backup_path).unwrap(); assert_eq!(meta.app_version, env!("CARGO_PKG_VERSION")); assert!(meta.schema_version > 0); assert!(!meta.export_date.is_empty()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_restore_from_backup() { let (db, _db_path, dir) = setup_db_with_data("restore"); let backup_path = dir.join("test_restore.outlay"); create_backup(&db, &backup_path).unwrap(); // Restore to a new location let restore_path = dir.join("restored.db"); let meta = restore_backup(&backup_path, &restore_path).unwrap(); assert_eq!(meta.transaction_count, 1); // Open restored DB and verify data let restored_db = Database::open(&restore_path).unwrap(); let txns = restored_db.list_all_transactions(None, None).unwrap(); assert_eq!(txns.len(), 1); assert_eq!(txns[0].amount, 42.0); assert_eq!(txns[0].note.as_deref(), Some("Test transaction")); drop(restored_db); let _ = std::fs::remove_dir_all(&dir); } }