envelope_cli/backup/
manager.rs

1//! Backup manager for EnvelopeCLI
2//!
3//! Handles automatic rolling backups with configurable retention policies.
4//! Backups are stored as dated JSON archives.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use chrono::{DateTime, Datelike, NaiveDate, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::config::paths::EnvelopePaths;
13use crate::config::settings::BackupRetention;
14use crate::error::{EnvelopeError, EnvelopeResult};
15
16/// Metadata about a backup
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct BackupInfo {
19    /// Backup filename
20    pub filename: String,
21    /// Full path to backup
22    pub path: PathBuf,
23    /// When the backup was created
24    pub created_at: DateTime<Utc>,
25    /// Size in bytes
26    pub size_bytes: u64,
27    /// Whether this is a monthly backup (kept longer)
28    pub is_monthly: bool,
29}
30
31/// Backup archive format
32#[derive(Debug, Serialize, Deserialize)]
33pub struct BackupArchive {
34    /// Schema version for migration support
35    pub schema_version: u32,
36    /// When the backup was created
37    pub created_at: DateTime<Utc>,
38    /// Accounts data
39    pub accounts: serde_json::Value,
40    /// Transactions data
41    pub transactions: serde_json::Value,
42    /// Budget data (categories, groups, allocations)
43    pub budget: serde_json::Value,
44    /// Payees data
45    pub payees: serde_json::Value,
46}
47
48/// Manages backup creation and retention
49pub struct BackupManager {
50    /// Path to backup directory
51    backup_dir: PathBuf,
52    /// Paths to data files
53    paths: EnvelopePaths,
54    /// Retention policy
55    retention: BackupRetention,
56}
57
58impl BackupManager {
59    /// Create a new BackupManager
60    pub fn new(paths: EnvelopePaths, retention: BackupRetention) -> Self {
61        let backup_dir = paths.backup_dir();
62        Self {
63            backup_dir,
64            paths,
65            retention,
66        }
67    }
68
69    /// Create a backup of all data
70    ///
71    /// Returns the path to the created backup file.
72    pub fn create_backup(&self) -> EnvelopeResult<PathBuf> {
73        // Ensure backup directory exists
74        fs::create_dir_all(&self.backup_dir)
75            .map_err(|e| EnvelopeError::Io(format!("Failed to create backup directory: {}", e)))?;
76
77        let now = Utc::now();
78        let filename = format!(
79            "backup-{}-{:03}.json",
80            now.format("%Y%m%d-%H%M%S"),
81            now.timestamp_subsec_millis()
82        );
83        let backup_path = self.backup_dir.join(&filename);
84
85        // Read all data files
86        let archive = self.create_archive(now)?;
87
88        // Write backup file
89        let json = serde_json::to_string_pretty(&archive)
90            .map_err(|e| EnvelopeError::Json(format!("Failed to serialize backup: {}", e)))?;
91
92        fs::write(&backup_path, json)
93            .map_err(|e| EnvelopeError::Io(format!("Failed to write backup file: {}", e)))?;
94
95        Ok(backup_path)
96    }
97
98    /// Create a backup archive from current data
99    fn create_archive(&self, timestamp: DateTime<Utc>) -> EnvelopeResult<BackupArchive> {
100        Ok(BackupArchive {
101            schema_version: 1,
102            created_at: timestamp,
103            accounts: read_json_value(&self.paths.accounts_file())?,
104            transactions: read_json_value(&self.paths.transactions_file())?,
105            budget: read_json_value(&self.paths.budget_file())?,
106            payees: read_json_value(&self.paths.payees_file())?,
107        })
108    }
109
110    /// List all available backups
111    pub fn list_backups(&self) -> EnvelopeResult<Vec<BackupInfo>> {
112        if !self.backup_dir.exists() {
113            return Ok(Vec::new());
114        }
115
116        let mut backups = Vec::new();
117
118        for entry in fs::read_dir(&self.backup_dir)
119            .map_err(|e| EnvelopeError::Io(format!("Failed to read backup directory: {}", e)))?
120        {
121            let entry = entry
122                .map_err(|e| EnvelopeError::Io(format!("Failed to read directory entry: {}", e)))?;
123
124            let path = entry.path();
125            if path.extension().is_some_and(|ext| ext == "json") {
126                if let Some(info) = self.parse_backup_info(&path) {
127                    backups.push(info);
128                }
129            }
130        }
131
132        // Sort by date, newest first
133        backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
134
135        Ok(backups)
136    }
137
138    /// Parse backup info from a backup file
139    fn parse_backup_info(&self, path: &Path) -> Option<BackupInfo> {
140        let filename = path.file_name()?.to_string_lossy().to_string();
141
142        // Parse date from filename: backup-YYYYMMDD-HHMMSS.json
143        if !filename.starts_with("backup-") {
144            return None;
145        }
146
147        let date_part = filename.strip_prefix("backup-")?.strip_suffix(".json")?;
148        let created_at = parse_backup_timestamp(date_part)?;
149
150        let metadata = fs::metadata(path).ok()?;
151        let size_bytes = metadata.len();
152
153        // A backup is "monthly" if it's the first backup of the month
154        let is_monthly = self.is_first_of_month(&created_at);
155
156        Some(BackupInfo {
157            filename,
158            path: path.to_path_buf(),
159            created_at,
160            size_bytes,
161            is_monthly,
162        })
163    }
164
165    /// Check if this backup is the first of its month
166    fn is_first_of_month(&self, timestamp: &DateTime<Utc>) -> bool {
167        timestamp.day() == 1
168    }
169
170    /// Enforce retention policy by deleting old backups
171    pub fn enforce_retention(&self) -> EnvelopeResult<Vec<PathBuf>> {
172        let backups = self.list_backups()?;
173        let mut deleted = Vec::new();
174
175        // Separate daily and monthly backups
176        let (monthly, daily): (Vec<_>, Vec<_>) = backups.into_iter().partition(|b| b.is_monthly);
177
178        // Keep only the configured number of daily backups
179        for backup in daily.into_iter().skip(self.retention.daily_count as usize) {
180            fs::remove_file(&backup.path)
181                .map_err(|e| EnvelopeError::Io(format!("Failed to delete old backup: {}", e)))?;
182            deleted.push(backup.path);
183        }
184
185        // Keep only the configured number of monthly backups
186        for backup in monthly
187            .into_iter()
188            .skip(self.retention.monthly_count as usize)
189        {
190            fs::remove_file(&backup.path).map_err(|e| {
191                EnvelopeError::Io(format!("Failed to delete old monthly backup: {}", e))
192            })?;
193            deleted.push(backup.path);
194        }
195
196        Ok(deleted)
197    }
198
199    /// Create a backup and then enforce retention policy
200    pub fn create_backup_with_retention(&self) -> EnvelopeResult<(PathBuf, Vec<PathBuf>)> {
201        let backup_path = self.create_backup()?;
202        let deleted = self.enforce_retention()?;
203        Ok((backup_path, deleted))
204    }
205
206    /// Get backup directory path
207    pub fn backup_dir(&self) -> &PathBuf {
208        &self.backup_dir
209    }
210
211    /// Get a specific backup by filename
212    pub fn get_backup(&self, filename: &str) -> EnvelopeResult<Option<BackupInfo>> {
213        let path = self.backup_dir.join(filename);
214        if path.exists() {
215            Ok(self.parse_backup_info(&path))
216        } else {
217            Ok(None)
218        }
219    }
220
221    /// Get the most recent backup
222    pub fn get_latest_backup(&self) -> EnvelopeResult<Option<BackupInfo>> {
223        let backups = self.list_backups()?;
224        Ok(backups.into_iter().next())
225    }
226}
227
228/// Read a JSON file as a generic Value, returning empty object if file doesn't exist
229fn read_json_value(path: &Path) -> EnvelopeResult<serde_json::Value> {
230    if !path.exists() {
231        return Ok(serde_json::Value::Object(serde_json::Map::new()));
232    }
233
234    let contents = fs::read_to_string(path)
235        .map_err(|e| EnvelopeError::Io(format!("Failed to read file for backup: {}", e)))?;
236
237    serde_json::from_str(&contents)
238        .map_err(|e| EnvelopeError::Json(format!("Failed to parse JSON for backup: {}", e)))
239}
240
241/// Parse a backup timestamp from the filename date part
242fn parse_backup_timestamp(date_str: &str) -> Option<DateTime<Utc>> {
243    // Expected format: YYYYMMDD-HHMMSS or YYYYMMDD-HHMMSS-mmm (with milliseconds)
244    let parts: Vec<&str> = date_str.split('-').collect();
245    if parts.len() < 2 || parts.len() > 3 {
246        return None;
247    }
248
249    let date_part = parts[0];
250    let time_part = parts[1];
251    let millis: u32 = if parts.len() == 3 {
252        parts[2].parse().unwrap_or(0)
253    } else {
254        0
255    };
256
257    if date_part.len() != 8 || time_part.len() != 6 {
258        return None;
259    }
260
261    let year: i32 = date_part[0..4].parse().ok()?;
262    let month: u32 = date_part[4..6].parse().ok()?;
263    let day: u32 = date_part[6..8].parse().ok()?;
264    let hour: u32 = time_part[0..2].parse().ok()?;
265    let minute: u32 = time_part[2..4].parse().ok()?;
266    let second: u32 = time_part[4..6].parse().ok()?;
267
268    let date = NaiveDate::from_ymd_opt(year, month, day)?;
269    let time = chrono::NaiveTime::from_hms_milli_opt(hour, minute, second, millis)?;
270    let datetime = chrono::NaiveDateTime::new(date, time);
271
272    Some(DateTime::from_naive_utc_and_offset(datetime, Utc))
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use tempfile::TempDir;
279
280    fn create_test_manager() -> (BackupManager, TempDir) {
281        let temp_dir = TempDir::new().unwrap();
282        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
283        paths.ensure_directories().unwrap();
284
285        let retention = BackupRetention {
286            daily_count: 3,
287            monthly_count: 2,
288        };
289
290        let manager = BackupManager::new(paths, retention);
291        (manager, temp_dir)
292    }
293
294    #[test]
295    fn test_create_backup() {
296        let (manager, _temp) = create_test_manager();
297
298        let backup_path = manager.create_backup().unwrap();
299        assert!(backup_path.exists());
300        assert!(backup_path.to_string_lossy().contains("backup-"));
301    }
302
303    #[test]
304    fn test_list_backups() {
305        let (manager, _temp) = create_test_manager();
306
307        // Create a few backups
308        manager.create_backup().unwrap();
309        std::thread::sleep(std::time::Duration::from_millis(100));
310        manager.create_backup().unwrap();
311
312        let backups = manager.list_backups().unwrap();
313        assert_eq!(backups.len(), 2);
314
315        // Should be sorted newest first
316        assert!(backups[0].created_at >= backups[1].created_at);
317    }
318
319    #[test]
320    fn test_retention_policy() {
321        let (manager, _temp) = create_test_manager();
322
323        // Create more backups than retention allows
324        for _ in 0..5 {
325            manager.create_backup().unwrap();
326            std::thread::sleep(std::time::Duration::from_millis(50));
327        }
328
329        let deleted = manager.enforce_retention().unwrap();
330        assert_eq!(deleted.len(), 2); // 5 - 3 = 2 deleted
331
332        let remaining = manager.list_backups().unwrap();
333        assert_eq!(remaining.len(), 3);
334    }
335
336    #[test]
337    fn test_get_latest_backup() {
338        let (manager, _temp) = create_test_manager();
339
340        // No backups yet
341        assert!(manager.get_latest_backup().unwrap().is_none());
342
343        // Create backup
344        let path = manager.create_backup().unwrap();
345
346        let latest = manager.get_latest_backup().unwrap().unwrap();
347        assert_eq!(latest.path, path);
348    }
349
350    #[test]
351    fn test_parse_backup_timestamp() {
352        // Test old format without milliseconds
353        let timestamp = parse_backup_timestamp("20251127-143022").unwrap();
354        assert_eq!(timestamp.year(), 2025);
355        assert_eq!(timestamp.month(), 11);
356        assert_eq!(timestamp.day(), 27);
357
358        // Test new format with milliseconds
359        let timestamp = parse_backup_timestamp("20251127-143022-456").unwrap();
360        assert_eq!(timestamp.year(), 2025);
361        assert_eq!(timestamp.month(), 11);
362        assert_eq!(timestamp.day(), 27);
363    }
364
365    #[test]
366    fn test_backup_archive_structure() {
367        let (manager, _temp) = create_test_manager();
368
369        let backup_path = manager.create_backup().unwrap();
370
371        // Read and parse the backup
372        let contents = fs::read_to_string(&backup_path).unwrap();
373        let archive: BackupArchive = serde_json::from_str(&contents).unwrap();
374
375        assert_eq!(archive.schema_version, 1);
376        assert!(archive.accounts.is_object());
377        assert!(archive.transactions.is_object());
378    }
379
380    #[test]
381    fn test_empty_backup_dir() {
382        let (manager, _temp) = create_test_manager();
383
384        let backups = manager.list_backups().unwrap();
385        assert!(backups.is_empty());
386    }
387
388    #[test]
389    fn test_create_backup_with_retention() {
390        let (manager, _temp) = create_test_manager();
391
392        // Create initial backups
393        for _ in 0..5 {
394            manager.create_backup().unwrap();
395            std::thread::sleep(std::time::Duration::from_millis(50));
396        }
397
398        // This should create one more and delete old ones
399        let (new_backup, deleted) = manager.create_backup_with_retention().unwrap();
400
401        assert!(new_backup.exists());
402        assert!(!deleted.is_empty());
403    }
404}