//! Backup and restore utilities. //! //! Provides compressed JSON backup creation and restoration for all GoingsOn data. use std::fs::File; use std::io::{BufWriter, Read, Write}; use std::path::Path; use chrono::{DateTime, Utc}; use flate2::read::GzDecoder; use flate2::write::GzEncoder; use flate2::Compression; use serde::{Deserialize, Serialize}; use goingson_core::{ Attachment, Contact, DailyNote, Email, Event, Milestone, MonthlyGoal, MonthlyReflection, Project, SavedView, SyncAccount, Task, TimeSession, WeeklyReview, }; /// Full export of all GoingsOn data. /// /// The supplemental collections (`time_sessions`, `milestones`, `daily_notes`, /// `attachments`, `sync_accounts`) are `#[serde(default)]` so backups written by /// older versions (which omitted them) still deserialize; on restore they simply /// come back empty. `attachments` carries the row metadata only -- the /// content-addressed blob files live in a separate store and are re-fetched by /// blob sync, not embedded in the JSON backup. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FullExport { /// Export format version for compatibility checking. pub version: String, /// When the export was created. pub exported_at: DateTime, /// All projects. pub projects: Vec, /// All tasks (including subtasks and annotations). pub tasks: Vec, /// All events. pub events: Vec, /// All emails. pub emails: Vec, /// All contacts. #[serde(default)] pub contacts: Vec, /// All time-tracking sessions. #[serde(default)] pub time_sessions: Vec, /// All milestones. #[serde(default)] pub milestones: Vec, /// All daily notes. #[serde(default)] pub daily_notes: Vec, /// All attachment records (blob metadata only; blob files are not embedded). #[serde(default)] pub attachments: Vec, /// All calendar/contact sync accounts. #[serde(default)] pub sync_accounts: Vec, /// All saved views / filters. #[serde(default)] pub saved_views: Vec, /// All weekly review rows (notes + vacation days). #[serde(default)] pub weekly_reviews: Vec, /// All monthly goals. #[serde(default)] pub monthly_goals: Vec, /// All monthly reflections. #[serde(default)] pub monthly_reflections: Vec, } impl FullExport { /// Current export format version. pub const CURRENT_VERSION: &'static str = "1.3"; /// Creates a new full export with the current timestamp. /// /// Takes every syncable collection so a backup is complete by construction; /// pass empty vecs for collections a caller does not have. #[allow(clippy::too_many_arguments)] pub fn new( projects: Vec, tasks: Vec, events: Vec, emails: Vec, contacts: Vec, time_sessions: Vec, milestones: Vec, daily_notes: Vec, attachments: Vec, sync_accounts: Vec, saved_views: Vec, weekly_reviews: Vec, monthly_goals: Vec, monthly_reflections: Vec, ) -> Self { Self { version: Self::CURRENT_VERSION.to_string(), exported_at: Utc::now(), projects, tasks, events, emails, contacts, time_sessions, milestones, daily_notes, attachments, sync_accounts, saved_views, weekly_reviews, monthly_goals, monthly_reflections, } } /// Returns the total count of all items in the export. pub fn total_count(&self) -> usize { self.projects.len() + self.tasks.len() + self.events.len() + self.emails.len() + self.contacts.len() + self.time_sessions.len() + self.milestones.len() + self.daily_notes.len() + self.attachments.len() + self.sync_accounts.len() + self.saved_views.len() + self.weekly_reviews.len() + self.monthly_goals.len() + self.monthly_reflections.len() } /// Checks if this export version is compatible with the current version. pub fn is_compatible(&self) -> bool { // For now, only version 1.x is supported self.version.starts_with("1.") } } /// Writes a full export to a gzip-compressed JSON file. /// /// # Arguments /// /// * `export` - The data to export /// * `path` - Destination file path /// /// # Returns /// /// The size of the compressed file in bytes. pub fn write_backup>(export: &FullExport, path: P) -> Result { let dest = path.as_ref(); let tmp_path = dest.with_extension("tmp"); let file = File::create(&tmp_path)?; let mut encoder = GzEncoder::new(file, Compression::default()); // Serialize straight into the compressor so we never hold the full // serialized JSON in memory alongside the already-in-memory export. serde_json::to_writer(&mut encoder, export)?; encoder.finish()?; // Prevents corrupt backups if the process crashes mid-write std::fs::rename(&tmp_path, dest)?; let metadata = std::fs::metadata(dest)?; Ok(metadata.len()) } /// Reads a full export from a gzip-compressed JSON file. /// /// # Arguments /// /// * `path` - Source file path /// /// # Returns /// /// The parsed export data. pub fn read_backup>(path: P) -> Result { let file = File::open(path.as_ref())?; let decoder = GzDecoder::new(file); // Limit decompressed size to 500 MB to prevent decompression bombs let mut limited = decoder.take(500 * 1024 * 1024); let mut json = String::new(); limited.read_to_string(&mut json)?; let export: FullExport = serde_json::from_str(&json)?; if !export.is_compatible() { return Err(BackupError::IncompatibleVersion { found: export.version, expected: FullExport::CURRENT_VERSION.to_string(), }); } Ok(export) } /// Writes a full export to uncompressed JSON. /// /// # Arguments /// /// * `export` - The data to export /// * `path` - Destination file path /// /// # Returns /// /// The size of the file in bytes. pub fn write_json>(export: &FullExport, path: P) -> Result { let dest = path.as_ref(); let tmp = dest.with_extension("json.tmp"); // Stream the serialized JSON directly to the file through a buffered // writer instead of building the whole pretty-printed string in memory. let mut writer = BufWriter::new(File::create(&tmp)?); serde_json::to_writer_pretty(&mut writer, export)?; writer.flush()?; std::fs::rename(&tmp, dest)?; let metadata = std::fs::metadata(dest)?; Ok(metadata.len()) } /// Error type for backup operations. #[derive(Debug)] pub enum BackupError { /// IO error (file not found, permission denied, etc.) Io(std::io::Error), /// JSON serialization/deserialization error Json(serde_json::Error), /// Backup version is not compatible IncompatibleVersion { found: String, expected: String }, } impl std::fmt::Display for BackupError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { BackupError::Io(e) => write!(f, "IO error: {}", e), BackupError::Json(e) => write!(f, "JSON error: {}", e), BackupError::IncompatibleVersion { found, expected } => { write!( f, "Incompatible backup version: found {}, expected {}", found, expected ) } } } } impl std::error::Error for BackupError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { BackupError::Io(e) => Some(e), BackupError::Json(e) => Some(e), BackupError::IncompatibleVersion { .. } => None, } } } impl From for BackupError { fn from(err: std::io::Error) -> Self { BackupError::Io(err) } } impl From for BackupError { fn from(err: serde_json::Error) -> Self { BackupError::Json(err) } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; /// An empty export with every collection vec defaulted -- keeps the tests from /// re-listing the positional collection arguments. fn empty_export() -> FullExport { FullExport::new( vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], ) } #[test] fn test_full_export_total_count() { let export = empty_export(); assert_eq!(export.total_count(), 0); } #[test] fn test_full_export_is_compatible() { let export = empty_export(); assert!(export.is_compatible()); let mut old_export = empty_export(); old_export.version = "1.5".to_string(); assert!(old_export.is_compatible()); let mut future_export = empty_export(); future_export.version = "2.0".to_string(); assert!(!future_export.is_compatible()); } #[test] fn test_backup_round_trip() { let dir = tempdir().unwrap(); let backup_path = dir.path().join("test.json.gz"); let export = empty_export(); write_backup(&export, &backup_path).unwrap(); let restored = read_backup(&backup_path).unwrap(); assert_eq!(restored.version, export.version); assert_eq!(restored.total_count(), 0); } #[test] fn test_json_export() { let dir = tempdir().unwrap(); let json_path = dir.path().join("test.json"); let export = empty_export(); let size = write_json(&export, &json_path).unwrap(); assert!(size > 0); let content = std::fs::read_to_string(&json_path).unwrap(); assert!(content.contains("\"version\"")); assert!(content.contains("\"projects\"")); } }