//! Backup and restore utilities. //! //! Provides compressed JSON backup creation and restoration for all GoingsOn data. use std::fs::File; use std::io::{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::{Contact, Email, Event, Project, Task}; /// Full export of all GoingsOn data. #[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, } impl FullExport { /// Current export format version. pub const CURRENT_VERSION: &'static str = "1.1"; /// Creates a new full export with the current timestamp. pub fn new( projects: Vec, tasks: Vec, events: Vec, emails: Vec, contacts: Vec, ) -> Self { Self { version: Self::CURRENT_VERSION.to_string(), exported_at: Utc::now(), projects, tasks, events, emails, contacts, } } /// 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() } /// 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()); let json = serde_json::to_vec(export)?; encoder.write_all(&json)?; 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 json = serde_json::to_string_pretty(export)?; let dest = path.as_ref(); let tmp = dest.with_extension("json.tmp"); std::fs::write(&tmp, &json)?; 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; #[test] fn test_full_export_total_count() { let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]); assert_eq!(export.total_count(), 0); } #[test] fn test_full_export_is_compatible() { let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]); assert!(export.is_compatible()); let mut old_export = FullExport::new(vec![], vec![], vec![], vec![], vec![]); old_export.version = "1.5".to_string(); assert!(old_export.is_compatible()); let mut future_export = FullExport::new(vec![], vec![], vec![], vec![], vec![]); 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 = FullExport::new(vec![], vec![], vec![], vec![], vec![]); 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 = FullExport::new(vec![], vec![], vec![], vec![], vec![]); 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\"")); } }