envelope_cli/backup/
restore.rs

1//! Backup restoration for EnvelopeCLI
2//!
3//! Handles restoring data from backup archives.
4
5use std::fs;
6use std::path::Path;
7
8use crate::config::paths::EnvelopePaths;
9use crate::error::{EnvelopeError, EnvelopeResult};
10
11use super::manager::BackupArchive;
12
13/// Handles restoring from backups
14pub struct RestoreManager {
15    paths: EnvelopePaths,
16}
17
18impl RestoreManager {
19    /// Create a new RestoreManager
20    pub fn new(paths: EnvelopePaths) -> Self {
21        Self { paths }
22    }
23
24    /// Restore data from a backup file
25    ///
26    /// This will overwrite all current data with the backup contents.
27    /// It's recommended to create a backup before restoring.
28    pub fn restore_from_file(&self, backup_path: &Path) -> EnvelopeResult<RestoreResult> {
29        // Read and parse the backup
30        let contents = fs::read_to_string(backup_path)
31            .map_err(|e| EnvelopeError::Io(format!("Failed to read backup file: {}", e)))?;
32
33        let archive: BackupArchive = serde_json::from_str(&contents)
34            .map_err(|e| EnvelopeError::Json(format!("Failed to parse backup file: {}", e)))?;
35
36        self.restore_from_archive(&archive)
37    }
38
39    /// Restore data from a parsed backup archive
40    pub fn restore_from_archive(&self, archive: &BackupArchive) -> EnvelopeResult<RestoreResult> {
41        // Ensure directories exist
42        self.paths.ensure_directories()?;
43
44        let mut result = RestoreResult::default();
45
46        // Restore accounts
47        if !archive.accounts.is_null() {
48            let json = serde_json::to_string_pretty(&archive.accounts)
49                .map_err(|e| EnvelopeError::Json(format!("Failed to serialize accounts: {}", e)))?;
50            fs::write(self.paths.accounts_file(), json)
51                .map_err(|e| EnvelopeError::Io(format!("Failed to restore accounts: {}", e)))?;
52            result.accounts_restored = true;
53        }
54
55        // Restore transactions
56        if !archive.transactions.is_null() {
57            let json = serde_json::to_string_pretty(&archive.transactions).map_err(|e| {
58                EnvelopeError::Json(format!("Failed to serialize transactions: {}", e))
59            })?;
60            fs::write(self.paths.transactions_file(), json)
61                .map_err(|e| EnvelopeError::Io(format!("Failed to restore transactions: {}", e)))?;
62            result.transactions_restored = true;
63        }
64
65        // Restore budget (categories, groups, allocations)
66        if !archive.budget.is_null() {
67            let json = serde_json::to_string_pretty(&archive.budget)
68                .map_err(|e| EnvelopeError::Json(format!("Failed to serialize budget: {}", e)))?;
69            fs::write(self.paths.budget_file(), json)
70                .map_err(|e| EnvelopeError::Io(format!("Failed to restore budget: {}", e)))?;
71            result.budget_restored = true;
72        }
73
74        // Restore payees
75        if !archive.payees.is_null() {
76            let json = serde_json::to_string_pretty(&archive.payees)
77                .map_err(|e| EnvelopeError::Json(format!("Failed to serialize payees: {}", e)))?;
78            fs::write(self.paths.payees_file(), json)
79                .map_err(|e| EnvelopeError::Io(format!("Failed to restore payees: {}", e)))?;
80            result.payees_restored = true;
81        }
82
83        result.schema_version = archive.schema_version;
84        result.backup_date = archive.created_at;
85
86        Ok(result)
87    }
88
89    /// Validate a backup file without restoring it
90    pub fn validate_backup(&self, backup_path: &Path) -> EnvelopeResult<ValidationResult> {
91        let contents = fs::read_to_string(backup_path)
92            .map_err(|e| EnvelopeError::Io(format!("Failed to read backup file: {}", e)))?;
93
94        let archive: BackupArchive = serde_json::from_str(&contents)
95            .map_err(|e| EnvelopeError::Json(format!("Failed to parse backup file: {}", e)))?;
96
97        Ok(ValidationResult {
98            is_valid: true,
99            schema_version: archive.schema_version,
100            backup_date: archive.created_at,
101            has_accounts: !archive.accounts.is_null() && archive.accounts.is_object(),
102            has_transactions: !archive.transactions.is_null() && archive.transactions.is_object(),
103            has_budget: !archive.budget.is_null() && archive.budget.is_object(),
104            has_payees: !archive.payees.is_null() && archive.payees.is_object(),
105        })
106    }
107}
108
109/// Result of a restore operation
110#[derive(Debug, Default)]
111pub struct RestoreResult {
112    /// Schema version of the restored backup
113    pub schema_version: u32,
114    /// Date the backup was created
115    pub backup_date: chrono::DateTime<chrono::Utc>,
116    /// Whether accounts were restored
117    pub accounts_restored: bool,
118    /// Whether transactions were restored
119    pub transactions_restored: bool,
120    /// Whether budget data was restored
121    pub budget_restored: bool,
122    /// Whether payees were restored
123    pub payees_restored: bool,
124}
125
126impl RestoreResult {
127    /// Check if all data was restored
128    pub fn all_restored(&self) -> bool {
129        self.accounts_restored
130            && self.transactions_restored
131            && self.budget_restored
132            && self.payees_restored
133    }
134
135    /// Get a summary of what was restored
136    pub fn summary(&self) -> String {
137        let mut parts = Vec::new();
138        if self.accounts_restored {
139            parts.push("accounts");
140        }
141        if self.transactions_restored {
142            parts.push("transactions");
143        }
144        if self.budget_restored {
145            parts.push("budget");
146        }
147        if self.payees_restored {
148            parts.push("payees");
149        }
150        format!("Restored: {}", parts.join(", "))
151    }
152}
153
154/// Result of validating a backup
155#[derive(Debug)]
156pub struct ValidationResult {
157    /// Whether the backup file is valid
158    pub is_valid: bool,
159    /// Schema version of the backup
160    pub schema_version: u32,
161    /// Date the backup was created
162    pub backup_date: chrono::DateTime<chrono::Utc>,
163    /// Whether backup contains accounts data
164    pub has_accounts: bool,
165    /// Whether backup contains transactions data
166    pub has_transactions: bool,
167    /// Whether backup contains budget data
168    pub has_budget: bool,
169    /// Whether backup contains payees data
170    pub has_payees: bool,
171}
172
173impl ValidationResult {
174    /// Check if all expected data is present
175    pub fn is_complete(&self) -> bool {
176        self.has_accounts && self.has_transactions && self.has_budget && self.has_payees
177    }
178
179    /// Get a summary of what data is present
180    pub fn summary(&self) -> String {
181        let mut present = Vec::new();
182        let mut missing = Vec::new();
183
184        if self.has_accounts {
185            present.push("accounts");
186        } else {
187            missing.push("accounts");
188        }
189        if self.has_transactions {
190            present.push("transactions");
191        } else {
192            missing.push("transactions");
193        }
194        if self.has_budget {
195            present.push("budget");
196        } else {
197            missing.push("budget");
198        }
199        if self.has_payees {
200            present.push("payees");
201        } else {
202            missing.push("payees");
203        }
204
205        if missing.is_empty() {
206            format!("Complete backup (v{})", self.schema_version)
207        } else {
208            format!(
209                "Partial backup (v{}): has {}, missing {}",
210                self.schema_version,
211                present.join(", "),
212                missing.join(", ")
213            )
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::backup::manager::BackupManager;
222    use crate::config::settings::BackupRetention;
223    use tempfile::TempDir;
224
225    fn create_test_env() -> (RestoreManager, BackupManager, TempDir) {
226        let temp_dir = TempDir::new().unwrap();
227        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
228        paths.ensure_directories().unwrap();
229
230        let retention = BackupRetention::default();
231        let backup_manager = BackupManager::new(paths.clone(), retention);
232        let restore_manager = RestoreManager::new(paths);
233
234        (restore_manager, backup_manager, temp_dir)
235    }
236
237    #[test]
238    fn test_restore_from_backup() {
239        let (restore_manager, backup_manager, _temp) = create_test_env();
240
241        // Create a backup
242        let backup_path = backup_manager.create_backup().unwrap();
243
244        // Restore from it
245        let result = restore_manager.restore_from_file(&backup_path).unwrap();
246
247        assert!(result.accounts_restored);
248        assert!(result.transactions_restored);
249        assert!(result.budget_restored);
250        assert!(result.payees_restored);
251    }
252
253    #[test]
254    fn test_validate_backup() {
255        let (restore_manager, backup_manager, _temp) = create_test_env();
256
257        // Create a backup
258        let backup_path = backup_manager.create_backup().unwrap();
259
260        // Validate it
261        let result = restore_manager.validate_backup(&backup_path).unwrap();
262
263        assert!(result.is_valid);
264        assert_eq!(result.schema_version, 1);
265    }
266
267    #[test]
268    fn test_restore_result_summary() {
269        let result = RestoreResult {
270            schema_version: 1,
271            backup_date: chrono::Utc::now(),
272            accounts_restored: true,
273            transactions_restored: true,
274            budget_restored: false,
275            payees_restored: true,
276        };
277
278        assert!(!result.all_restored());
279        assert!(result.summary().contains("accounts"));
280        assert!(result.summary().contains("transactions"));
281        assert!(!result.summary().contains("budget"));
282    }
283
284    #[test]
285    fn test_validation_result_summary() {
286        let result = ValidationResult {
287            is_valid: true,
288            schema_version: 1,
289            backup_date: chrono::Utc::now(),
290            has_accounts: true,
291            has_transactions: true,
292            has_budget: true,
293            has_payees: true,
294        };
295
296        assert!(result.is_complete());
297        assert!(result.summary().contains("Complete backup"));
298    }
299
300    #[test]
301    fn test_restore_creates_files() {
302        let (restore_manager, backup_manager, temp) = create_test_env();
303
304        // Create backup with some data
305        let backup_path = backup_manager.create_backup().unwrap();
306
307        // Delete the data files
308        let data_dir = temp.path().join("data");
309        if data_dir.exists() {
310            fs::remove_dir_all(&data_dir).unwrap();
311        }
312
313        // Restore should recreate them
314        restore_manager.restore_from_file(&backup_path).unwrap();
315
316        // Check files exist
317        assert!(restore_manager.paths.accounts_file().exists());
318        assert!(restore_manager.paths.transactions_file().exists());
319        assert!(restore_manager.paths.budget_file().exists());
320        assert!(restore_manager.paths.payees_file().exists());
321    }
322}