Skip to main content

max / goingson

7.2 KB · 253 lines History Blame Raw
1 //! Backup and restore utilities.
2 //!
3 //! Provides compressed JSON backup creation and restoration for all GoingsOn data.
4
5 use std::fs::File;
6 use std::io::{Read, Write};
7 use std::path::Path;
8
9 use chrono::{DateTime, Utc};
10 use flate2::read::GzDecoder;
11 use flate2::write::GzEncoder;
12 use flate2::Compression;
13 use serde::{Deserialize, Serialize};
14
15 use goingson_core::{Contact, Email, Event, Project, Task};
16
17 /// Full export of all GoingsOn data.
18 #[derive(Debug, Serialize, Deserialize)]
19 #[serde(rename_all = "camelCase")]
20 pub struct FullExport {
21 /// Export format version for compatibility checking.
22 pub version: String,
23 /// When the export was created.
24 pub exported_at: DateTime<Utc>,
25 /// All projects.
26 pub projects: Vec<Project>,
27 /// All tasks (including subtasks and annotations).
28 pub tasks: Vec<Task>,
29 /// All events.
30 pub events: Vec<Event>,
31 /// All emails.
32 pub emails: Vec<Email>,
33 /// All contacts.
34 #[serde(default)]
35 pub contacts: Vec<Contact>,
36 }
37
38 impl FullExport {
39 /// Current export format version.
40 pub const CURRENT_VERSION: &'static str = "1.1";
41
42 /// Creates a new full export with the current timestamp.
43 pub fn new(
44 projects: Vec<Project>,
45 tasks: Vec<Task>,
46 events: Vec<Event>,
47 emails: Vec<Email>,
48 contacts: Vec<Contact>,
49 ) -> Self {
50 Self {
51 version: Self::CURRENT_VERSION.to_string(),
52 exported_at: Utc::now(),
53 projects,
54 tasks,
55 events,
56 emails,
57 contacts,
58 }
59 }
60
61 /// Returns the total count of all items in the export.
62 pub fn total_count(&self) -> usize {
63 self.projects.len() + self.tasks.len() + self.events.len() + self.emails.len() + self.contacts.len()
64 }
65
66 /// Checks if this export version is compatible with the current version.
67 pub fn is_compatible(&self) -> bool {
68 // For now, only version 1.x is supported
69 self.version.starts_with("1.")
70 }
71 }
72
73 /// Writes a full export to a gzip-compressed JSON file.
74 ///
75 /// # Arguments
76 ///
77 /// * `export` - The data to export
78 /// * `path` - Destination file path
79 ///
80 /// # Returns
81 ///
82 /// The size of the compressed file in bytes.
83 pub fn write_backup<P: AsRef<Path>>(export: &FullExport, path: P) -> Result<u64, BackupError> {
84 let dest = path.as_ref();
85 let tmp_path = dest.with_extension("tmp");
86
87 let file = File::create(&tmp_path)?;
88 let mut encoder = GzEncoder::new(file, Compression::default());
89
90 let json = serde_json::to_vec(export)?;
91 encoder.write_all(&json)?;
92 encoder.finish()?;
93
94 // Prevents corrupt backups if the process crashes mid-write
95 std::fs::rename(&tmp_path, dest)?;
96
97 let metadata = std::fs::metadata(dest)?;
98 Ok(metadata.len())
99 }
100
101 /// Reads a full export from a gzip-compressed JSON file.
102 ///
103 /// # Arguments
104 ///
105 /// * `path` - Source file path
106 ///
107 /// # Returns
108 ///
109 /// The parsed export data.
110 pub fn read_backup<P: AsRef<Path>>(path: P) -> Result<FullExport, BackupError> {
111 let file = File::open(path.as_ref())?;
112 let decoder = GzDecoder::new(file);
113 // Limit decompressed size to 500 MB to prevent decompression bombs
114 let mut limited = decoder.take(500 * 1024 * 1024);
115
116 let mut json = String::new();
117 limited.read_to_string(&mut json)?;
118
119 let export: FullExport = serde_json::from_str(&json)?;
120
121 if !export.is_compatible() {
122 return Err(BackupError::IncompatibleVersion {
123 found: export.version,
124 expected: FullExport::CURRENT_VERSION.to_string(),
125 });
126 }
127
128 Ok(export)
129 }
130
131 /// Writes a full export to uncompressed JSON.
132 ///
133 /// # Arguments
134 ///
135 /// * `export` - The data to export
136 /// * `path` - Destination file path
137 ///
138 /// # Returns
139 ///
140 /// The size of the file in bytes.
141 pub fn write_json<P: AsRef<Path>>(export: &FullExport, path: P) -> Result<u64, BackupError> {
142 let json = serde_json::to_string_pretty(export)?;
143 let dest = path.as_ref();
144 let tmp = dest.with_extension("json.tmp");
145 std::fs::write(&tmp, &json)?;
146 std::fs::rename(&tmp, dest)?;
147
148 let metadata = std::fs::metadata(dest)?;
149 Ok(metadata.len())
150 }
151
152 /// Error type for backup operations.
153 #[derive(Debug)]
154 pub enum BackupError {
155 /// IO error (file not found, permission denied, etc.)
156 Io(std::io::Error),
157 /// JSON serialization/deserialization error
158 Json(serde_json::Error),
159 /// Backup version is not compatible
160 IncompatibleVersion { found: String, expected: String },
161 }
162
163 impl std::fmt::Display for BackupError {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 match self {
166 BackupError::Io(e) => write!(f, "IO error: {}", e),
167 BackupError::Json(e) => write!(f, "JSON error: {}", e),
168 BackupError::IncompatibleVersion { found, expected } => {
169 write!(
170 f,
171 "Incompatible backup version: found {}, expected {}",
172 found, expected
173 )
174 }
175 }
176 }
177 }
178
179 impl std::error::Error for BackupError {
180 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
181 match self {
182 BackupError::Io(e) => Some(e),
183 BackupError::Json(e) => Some(e),
184 BackupError::IncompatibleVersion { .. } => None,
185 }
186 }
187 }
188
189 impl From<std::io::Error> for BackupError {
190 fn from(err: std::io::Error) -> Self {
191 BackupError::Io(err)
192 }
193 }
194
195 impl From<serde_json::Error> for BackupError {
196 fn from(err: serde_json::Error) -> Self {
197 BackupError::Json(err)
198 }
199 }
200
201 #[cfg(test)]
202 mod tests {
203 use super::*;
204 use tempfile::tempdir;
205
206 #[test]
207 fn test_full_export_total_count() {
208 let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
209 assert_eq!(export.total_count(), 0);
210 }
211
212 #[test]
213 fn test_full_export_is_compatible() {
214 let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
215 assert!(export.is_compatible());
216
217 let mut old_export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
218 old_export.version = "1.5".to_string();
219 assert!(old_export.is_compatible());
220
221 let mut future_export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
222 future_export.version = "2.0".to_string();
223 assert!(!future_export.is_compatible());
224 }
225
226 #[test]
227 fn test_backup_round_trip() {
228 let dir = tempdir().unwrap();
229 let backup_path = dir.path().join("test.json.gz");
230
231 let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
232 write_backup(&export, &backup_path).unwrap();
233
234 let restored = read_backup(&backup_path).unwrap();
235 assert_eq!(restored.version, export.version);
236 assert_eq!(restored.total_count(), 0);
237 }
238
239 #[test]
240 fn test_json_export() {
241 let dir = tempdir().unwrap();
242 let json_path = dir.path().join("test.json");
243
244 let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
245 let size = write_json(&export, &json_path).unwrap();
246 assert!(size > 0);
247
248 let content = std::fs::read_to_string(&json_path).unwrap();
249 assert!(content.contains("\"version\""));
250 assert!(content.contains("\"projects\""));
251 }
252 }
253