1use std::fs;
7use std::path::Path;
8
9use crate::config::paths::EnvelopePaths;
10use crate::error::{EnvelopeError, EnvelopeResult};
11use crate::export::FullExport;
12
13use super::manager::BackupArchive;
14
15#[derive(Debug)]
17pub enum BackupFileFormat {
18 Backup(BackupArchive),
20 Export(FullExport),
22}
23
24fn parse_backup_contents(path: &Path, contents: &str) -> EnvelopeResult<BackupFileFormat> {
29 let extension = path
30 .extension()
31 .and_then(|e| e.to_str())
32 .unwrap_or("")
33 .to_lowercase();
34
35 match extension.as_str() {
36 "yaml" | "yml" => {
37 if let Ok(export) = serde_yaml::from_str::<FullExport>(contents) {
39 return Ok(BackupFileFormat::Export(export));
40 }
41 serde_yaml::from_str::<BackupArchive>(contents)
43 .map(BackupFileFormat::Backup)
44 .map_err(|e| {
45 EnvelopeError::Json(format!("Failed to parse YAML backup file: {}", e))
46 })
47 }
48 _ => {
49 if let Ok(export) = serde_json::from_str::<FullExport>(contents) {
51 return Ok(BackupFileFormat::Export(export));
52 }
53 serde_json::from_str::<BackupArchive>(contents)
55 .map(BackupFileFormat::Backup)
56 .map_err(|e| EnvelopeError::Json(format!("Failed to parse backup file: {}", e)))
57 }
58 }
59}
60
61pub struct RestoreManager {
63 paths: EnvelopePaths,
64}
65
66impl RestoreManager {
67 pub fn new(paths: EnvelopePaths) -> Self {
69 Self { paths }
70 }
71
72 pub fn restore_from_file(&self, backup_path: &Path) -> EnvelopeResult<RestoreResult> {
79 let contents = fs::read_to_string(backup_path)
81 .map_err(|e| EnvelopeError::Io(format!("Failed to read backup file: {}", e)))?;
82
83 let parsed = parse_backup_contents(backup_path, &contents)?;
84
85 match parsed {
86 BackupFileFormat::Backup(archive) => self.restore_from_archive(&archive),
87 BackupFileFormat::Export(export) => self.restore_from_export(&export),
88 }
89 }
90
91 pub fn restore_from_archive(&self, archive: &BackupArchive) -> EnvelopeResult<RestoreResult> {
93 self.paths.ensure_directories()?;
95
96 let mut result = RestoreResult::default();
97
98 if !archive.accounts.is_null() {
100 let json = serde_json::to_string_pretty(&archive.accounts)
101 .map_err(|e| EnvelopeError::Json(format!("Failed to serialize accounts: {}", e)))?;
102 fs::write(self.paths.accounts_file(), json)
103 .map_err(|e| EnvelopeError::Io(format!("Failed to restore accounts: {}", e)))?;
104 result.accounts_restored = true;
105 }
106
107 if !archive.transactions.is_null() {
109 let json = serde_json::to_string_pretty(&archive.transactions).map_err(|e| {
110 EnvelopeError::Json(format!("Failed to serialize transactions: {}", e))
111 })?;
112 fs::write(self.paths.transactions_file(), json)
113 .map_err(|e| EnvelopeError::Io(format!("Failed to restore transactions: {}", e)))?;
114 result.transactions_restored = true;
115 }
116
117 if !archive.budget.is_null() {
119 let json = serde_json::to_string_pretty(&archive.budget)
120 .map_err(|e| EnvelopeError::Json(format!("Failed to serialize budget: {}", e)))?;
121 fs::write(self.paths.budget_file(), json)
122 .map_err(|e| EnvelopeError::Io(format!("Failed to restore budget: {}", e)))?;
123 result.budget_restored = true;
124 }
125
126 if !archive.payees.is_null() {
128 let json = serde_json::to_string_pretty(&archive.payees)
129 .map_err(|e| EnvelopeError::Json(format!("Failed to serialize payees: {}", e)))?;
130 fs::write(self.paths.payees_file(), json)
131 .map_err(|e| EnvelopeError::Io(format!("Failed to restore payees: {}", e)))?;
132 result.payees_restored = true;
133 }
134
135 result.schema_version = archive.schema_version;
136 result.backup_date = archive.created_at;
137 result.is_export_format = false;
138
139 Ok(result)
140 }
141
142 fn restore_from_export(&self, export: &FullExport) -> EnvelopeResult<RestoreResult> {
144 self.paths.ensure_directories()?;
146
147 let storage = crate::storage::Storage::new(self.paths.clone())?;
149
150 let export_result = crate::export::restore_from_export(&storage, export)?;
152
153 Ok(RestoreResult {
155 schema_version: 1, backup_date: export_result.exported_at,
157 accounts_restored: export_result.accounts_restored > 0,
158 transactions_restored: export_result.transactions_restored > 0,
159 budget_restored: export_result.categories_restored > 0
160 || export_result.category_groups_restored > 0
161 || export_result.allocations_restored > 0,
162 payees_restored: export_result.payees_restored > 0,
163 is_export_format: true,
164 export_schema_version: Some(export_result.schema_version),
165 export_counts: Some(ExportRestoreCounts {
166 accounts: export_result.accounts_restored,
167 category_groups: export_result.category_groups_restored,
168 categories: export_result.categories_restored,
169 transactions: export_result.transactions_restored,
170 allocations: export_result.allocations_restored,
171 payees: export_result.payees_restored,
172 }),
173 })
174 }
175
176 pub fn validate_backup(&self, backup_path: &Path) -> EnvelopeResult<ValidationResult> {
180 let contents = fs::read_to_string(backup_path)
181 .map_err(|e| EnvelopeError::Io(format!("Failed to read backup file: {}", e)))?;
182
183 let parsed = parse_backup_contents(backup_path, &contents)?;
184
185 match parsed {
186 BackupFileFormat::Backup(archive) => Ok(ValidationResult {
187 is_valid: true,
188 schema_version: archive.schema_version,
189 backup_date: archive.created_at,
190 has_accounts: !archive.accounts.is_null() && archive.accounts.is_object(),
191 has_transactions: !archive.transactions.is_null()
192 && archive.transactions.is_object(),
193 has_budget: !archive.budget.is_null() && archive.budget.is_object(),
194 has_payees: !archive.payees.is_null() && archive.payees.is_object(),
195 is_export_format: false,
196 export_schema_version: None,
197 }),
198 BackupFileFormat::Export(export) => Ok(ValidationResult {
199 is_valid: true,
200 schema_version: 1, backup_date: export.exported_at,
202 has_accounts: !export.accounts.is_empty(),
203 has_transactions: !export.transactions.is_empty(),
204 has_budget: !export.categories.is_empty() || !export.category_groups.is_empty(),
205 has_payees: !export.payees.is_empty(),
206 is_export_format: true,
207 export_schema_version: Some(export.schema_version),
208 }),
209 }
210 }
211}
212
213#[derive(Debug, Default, Clone)]
215pub struct ExportRestoreCounts {
216 pub accounts: usize,
218 pub category_groups: usize,
220 pub categories: usize,
222 pub transactions: usize,
224 pub allocations: usize,
226 pub payees: usize,
228}
229
230#[derive(Debug, Default)]
232pub struct RestoreResult {
233 pub schema_version: u32,
235 pub backup_date: chrono::DateTime<chrono::Utc>,
237 pub accounts_restored: bool,
239 pub transactions_restored: bool,
241 pub budget_restored: bool,
243 pub payees_restored: bool,
245 pub is_export_format: bool,
247 pub export_schema_version: Option<String>,
249 pub export_counts: Option<ExportRestoreCounts>,
251}
252
253impl RestoreResult {
254 pub fn all_restored(&self) -> bool {
256 self.accounts_restored
257 && self.transactions_restored
258 && self.budget_restored
259 && self.payees_restored
260 }
261
262 pub fn summary(&self) -> String {
264 if let Some(counts) = &self.export_counts {
265 format!(
266 "Restored: {} accounts, {} groups, {} categories, {} transactions, {} allocations, {} payees",
267 counts.accounts,
268 counts.category_groups,
269 counts.categories,
270 counts.transactions,
271 counts.allocations,
272 counts.payees
273 )
274 } else {
275 let mut parts = Vec::new();
276 if self.accounts_restored {
277 parts.push("accounts");
278 }
279 if self.transactions_restored {
280 parts.push("transactions");
281 }
282 if self.budget_restored {
283 parts.push("budget");
284 }
285 if self.payees_restored {
286 parts.push("payees");
287 }
288 format!("Restored: {}", parts.join(", "))
289 }
290 }
291}
292
293#[derive(Debug)]
295pub struct ValidationResult {
296 pub is_valid: bool,
298 pub schema_version: u32,
300 pub backup_date: chrono::DateTime<chrono::Utc>,
302 pub has_accounts: bool,
304 pub has_transactions: bool,
306 pub has_budget: bool,
308 pub has_payees: bool,
310 pub is_export_format: bool,
312 pub export_schema_version: Option<String>,
314}
315
316impl ValidationResult {
317 pub fn is_complete(&self) -> bool {
319 self.has_accounts && self.has_transactions && self.has_budget && self.has_payees
320 }
321
322 pub fn summary(&self) -> String {
324 let mut present = Vec::new();
325 let mut missing = Vec::new();
326
327 if self.has_accounts {
328 present.push("accounts");
329 } else {
330 missing.push("accounts");
331 }
332 if self.has_transactions {
333 present.push("transactions");
334 } else {
335 missing.push("transactions");
336 }
337 if self.has_budget {
338 present.push("budget");
339 } else {
340 missing.push("budget");
341 }
342 if self.has_payees {
343 present.push("payees");
344 } else {
345 missing.push("payees");
346 }
347
348 let version_str = if let Some(ref export_ver) = self.export_schema_version {
349 format!("v{}", export_ver)
350 } else {
351 format!("v{}", self.schema_version)
352 };
353
354 let format_str = if self.is_export_format {
355 "export"
356 } else {
357 "backup"
358 };
359
360 if missing.is_empty() {
361 format!("Complete {} ({})", format_str, version_str)
362 } else {
363 format!(
364 "Partial {} ({}): has {}, missing {}",
365 format_str,
366 version_str,
367 present.join(", "),
368 missing.join(", ")
369 )
370 }
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use crate::backup::manager::BackupManager;
378 use crate::config::settings::BackupRetention;
379 use tempfile::TempDir;
380
381 fn create_test_env() -> (RestoreManager, BackupManager, TempDir) {
382 let temp_dir = TempDir::new().unwrap();
383 let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
384 paths.ensure_directories().unwrap();
385
386 let retention = BackupRetention::default();
387 let backup_manager = BackupManager::new(paths.clone(), retention);
388 let restore_manager = RestoreManager::new(paths);
389
390 (restore_manager, backup_manager, temp_dir)
391 }
392
393 #[test]
394 fn test_restore_from_backup() {
395 let (restore_manager, backup_manager, _temp) = create_test_env();
396
397 let backup_path = backup_manager.create_backup().unwrap();
399
400 let result = restore_manager.restore_from_file(&backup_path).unwrap();
402
403 assert!(result.accounts_restored);
404 assert!(result.transactions_restored);
405 assert!(result.budget_restored);
406 assert!(result.payees_restored);
407 }
408
409 #[test]
410 fn test_validate_backup() {
411 let (restore_manager, backup_manager, _temp) = create_test_env();
412
413 let backup_path = backup_manager.create_backup().unwrap();
415
416 let result = restore_manager.validate_backup(&backup_path).unwrap();
418
419 assert!(result.is_valid);
420 assert_eq!(result.schema_version, 1);
421 }
422
423 #[test]
424 fn test_restore_result_summary() {
425 let result = RestoreResult {
426 schema_version: 1,
427 backup_date: chrono::Utc::now(),
428 accounts_restored: true,
429 transactions_restored: true,
430 budget_restored: false,
431 payees_restored: true,
432 is_export_format: false,
433 export_schema_version: None,
434 export_counts: None,
435 };
436
437 assert!(!result.all_restored());
438 assert!(result.summary().contains("accounts"));
439 assert!(result.summary().contains("transactions"));
440 assert!(!result.summary().contains("budget"));
441 }
442
443 #[test]
444 fn test_validation_result_summary() {
445 let result = ValidationResult {
446 is_valid: true,
447 schema_version: 1,
448 backup_date: chrono::Utc::now(),
449 has_accounts: true,
450 has_transactions: true,
451 has_budget: true,
452 has_payees: true,
453 is_export_format: false,
454 export_schema_version: None,
455 };
456
457 assert!(result.is_complete());
458 assert!(result.summary().contains("Complete backup"));
459 }
460
461 #[test]
462 fn test_restore_creates_files() {
463 let (restore_manager, backup_manager, temp) = create_test_env();
464
465 let backup_path = backup_manager.create_backup().unwrap();
467
468 let data_dir = temp.path().join("data");
470 if data_dir.exists() {
471 fs::remove_dir_all(&data_dir).unwrap();
472 }
473
474 restore_manager.restore_from_file(&backup_path).unwrap();
476
477 assert!(restore_manager.paths.accounts_file().exists());
479 assert!(restore_manager.paths.transactions_file().exists());
480 assert!(restore_manager.paths.budget_file().exists());
481 assert!(restore_manager.paths.payees_file().exists());
482 }
483}