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