From 2fff781a53a9c6f797baa54a1861212145720584 Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 2 Mar 2026 00:53:17 +0200 Subject: [PATCH] Add full state backup and restore with ZIP archives --- outlay-core/src/backup.rs | 250 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/outlay-core/src/backup.rs b/outlay-core/src/backup.rs index e69de29..088650b 100644 --- a/outlay-core/src/backup.rs +++ b/outlay-core/src/backup.rs @@ -0,0 +1,250 @@ +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()], + )?; + + // Read the vacuumed database + let db_bytes = std::fs::read(&temp_db_path)?; + std::fs::remove_file(&temp_db_path)?; + + // Gather metadata + 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)?; + + // Create ZIP archive + 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 + }; + + // Extract database + 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)?; + + // Write the restored database + 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, + }; + 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); + } +}