Add full state backup and restore with ZIP archives
This commit is contained in:
@@ -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<rusqlite::Error> for BackupError {
|
||||||
|
fn from(e: rusqlite::Error) -> Self {
|
||||||
|
BackupError::Db(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for BackupError {
|
||||||
|
fn from(e: std::io::Error) -> Self {
|
||||||
|
BackupError::Io(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<zip::result::ZipError> for BackupError {
|
||||||
|
fn from(e: zip::result::ZipError) -> Self {
|
||||||
|
BackupError::Zip(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> 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<BackupMeta, BackupError> {
|
||||||
|
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<BackupMeta, BackupError> {
|
||||||
|
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<BackupMeta, BackupError> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user