envelope_cli/backup/
restore.rs

1//! Backup restoration for EnvelopeCLI
2//!
3//! Handles restoring data from backup archives.
4//! Supports both internal backup format and export format files.
5
6use 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/// File format type detected during parsing
16#[derive(Debug)]
17pub enum BackupFileFormat {
18    /// Internal backup format (schema_version as u32)
19    Backup(BackupArchive),
20    /// Export format (schema_version as semver string)
21    Export(FullExport),
22}
23
24/// Parse backup file contents, auto-detecting format
25///
26/// Tries to parse as export format first (which has string schema_version),
27/// then falls back to internal backup format (which has u32 schema_version).
28fn 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            // Try export format first (has string schema_version)
38            if let Ok(export) = serde_yaml::from_str::<FullExport>(contents) {
39                return Ok(BackupFileFormat::Export(export));
40            }
41            // Fall back to backup format
42            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            // Try export format first (has string schema_version)
50            if let Ok(export) = serde_json::from_str::<FullExport>(contents) {
51                return Ok(BackupFileFormat::Export(export));
52            }
53            // Fall back to backup format
54            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
61/// Handles restoring from backups
62pub struct RestoreManager {
63    paths: EnvelopePaths,
64}
65
66impl RestoreManager {
67    /// Create a new RestoreManager
68    pub fn new(paths: EnvelopePaths) -> Self {
69        Self { paths }
70    }
71
72    /// Restore data from a backup file
73    ///
74    /// This will overwrite all current data with the backup contents.
75    /// It's recommended to create a backup before restoring.
76    /// Supports both internal backup format and export format files.
77    /// Supports both JSON and YAML formats (detected by file extension).
78    pub fn restore_from_file(&self, backup_path: &Path) -> EnvelopeResult<RestoreResult> {
79        // Read and parse the backup
80        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    /// Restore data from a parsed backup archive (internal format)
92    pub fn restore_from_archive(&self, archive: &BackupArchive) -> EnvelopeResult<RestoreResult> {
93        // Ensure directories exist
94        self.paths.ensure_directories()?;
95
96        let mut result = RestoreResult::default();
97
98        // Restore accounts
99        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        // Restore transactions
108        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        // Restore budget (categories, groups, allocations)
118        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        // Restore payees
127        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    /// Restore data from an export file
143    fn restore_from_export(&self, export: &FullExport) -> EnvelopeResult<RestoreResult> {
144        // Ensure directories exist
145        self.paths.ensure_directories()?;
146
147        // Create a storage instance to use the proper upsert methods
148        let storage = crate::storage::Storage::new(self.paths.clone())?;
149
150        // Use the export restore function
151        let export_result = crate::export::restore_from_export(&storage, export)?;
152
153        // Convert to RestoreResult
154        Ok(RestoreResult {
155            schema_version: 1, // Export files use semver, convert to internal version
156            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    /// Validate a backup file without restoring it
177    /// Supports both internal backup format and export format files.
178    /// Supports both JSON and YAML formats (detected by file extension).
179    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, // Export files use semver, report as v1
201                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/// Detailed counts for export format restores
214#[derive(Debug, Default, Clone)]
215pub struct ExportRestoreCounts {
216    /// Number of accounts restored
217    pub accounts: usize,
218    /// Number of category groups restored
219    pub category_groups: usize,
220    /// Number of categories restored
221    pub categories: usize,
222    /// Number of transactions restored
223    pub transactions: usize,
224    /// Number of allocations restored
225    pub allocations: usize,
226    /// Number of payees restored
227    pub payees: usize,
228}
229
230/// Result of a restore operation
231#[derive(Debug, Default)]
232pub struct RestoreResult {
233    /// Schema version of the restored backup
234    pub schema_version: u32,
235    /// Date the backup was created
236    pub backup_date: chrono::DateTime<chrono::Utc>,
237    /// Whether accounts were restored
238    pub accounts_restored: bool,
239    /// Whether transactions were restored
240    pub transactions_restored: bool,
241    /// Whether budget data was restored
242    pub budget_restored: bool,
243    /// Whether payees were restored
244    pub payees_restored: bool,
245    /// Whether this was restored from an export format file
246    pub is_export_format: bool,
247    /// Schema version string for export format files
248    pub export_schema_version: Option<String>,
249    /// Detailed counts for export format restores
250    pub export_counts: Option<ExportRestoreCounts>,
251}
252
253impl RestoreResult {
254    /// Check if all data was restored
255    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    /// Get a summary of what was restored
263    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/// Result of validating a backup
294#[derive(Debug)]
295pub struct ValidationResult {
296    /// Whether the backup file is valid
297    pub is_valid: bool,
298    /// Schema version of the backup
299    pub schema_version: u32,
300    /// Date the backup was created
301    pub backup_date: chrono::DateTime<chrono::Utc>,
302    /// Whether backup contains accounts data
303    pub has_accounts: bool,
304    /// Whether backup contains transactions data
305    pub has_transactions: bool,
306    /// Whether backup contains budget data
307    pub has_budget: bool,
308    /// Whether backup contains payees data
309    pub has_payees: bool,
310    /// Whether this is an export format file
311    pub is_export_format: bool,
312    /// Schema version string for export format files
313    pub export_schema_version: Option<String>,
314}
315
316impl ValidationResult {
317    /// Check if all expected data is present
318    pub fn is_complete(&self) -> bool {
319        self.has_accounts && self.has_transactions && self.has_budget && self.has_payees
320    }
321
322    /// Get a summary of what data is present
323    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        // Create a backup
398        let backup_path = backup_manager.create_backup().unwrap();
399
400        // Restore from it
401        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        // Create a backup
414        let backup_path = backup_manager.create_backup().unwrap();
415
416        // Validate it
417        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        // Create backup with some data
466        let backup_path = backup_manager.create_backup().unwrap();
467
468        // Delete the data files
469        let data_dir = temp.path().join("data");
470        if data_dir.exists() {
471            fs::remove_dir_all(&data_dir).unwrap();
472        }
473
474        // Restore should recreate them
475        restore_manager.restore_from_file(&backup_path).unwrap();
476
477        // Check files exist
478        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}