1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct BackupInfo {
19 pub filename: String,
21 pub path: PathBuf,
23 pub created_at: DateTime<Utc>,
25 pub size_bytes: u64,
27 pub is_monthly: bool,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
33pub struct BackupArchive {
34 pub schema_version: u32,
36 pub created_at: DateTime<Utc>,
38 pub accounts: serde_json::Value,
40 pub transactions: serde_json::Value,
42 pub budget: serde_json::Value,
44 pub payees: serde_json::Value,
46}
47
48pub struct BackupManager {
50 backup_dir: PathBuf,
52 paths: EnvelopePaths,
54 retention: BackupRetention,
56}
57
58impl BackupManager {
59 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 pub fn create_backup(&self) -> EnvelopeResult<PathBuf> {
73 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 let archive = self.create_archive(now)?;
87
88 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 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 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 backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
134
135 Ok(backups)
136 }
137
138 fn parse_backup_info(&self, path: &Path) -> Option<BackupInfo> {
140 let filename = path.file_name()?.to_string_lossy().to_string();
141
142 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 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 fn is_first_of_month(&self, timestamp: &DateTime<Utc>) -> bool {
167 timestamp.day() == 1
168 }
169
170 pub fn enforce_retention(&self) -> EnvelopeResult<Vec<PathBuf>> {
172 let backups = self.list_backups()?;
173 let mut deleted = Vec::new();
174
175 let (monthly, daily): (Vec<_>, Vec<_>) = backups.into_iter().partition(|b| b.is_monthly);
177
178 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 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 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 pub fn backup_dir(&self) -> &PathBuf {
208 &self.backup_dir
209 }
210
211 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 pub fn get_latest_backup(&self) -> EnvelopeResult<Option<BackupInfo>> {
223 let backups = self.list_backups()?;
224 Ok(backups.into_iter().next())
225 }
226}
227
228fn 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
241fn parse_backup_timestamp(date_str: &str) -> Option<DateTime<Utc>> {
243 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 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 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 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); 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 assert!(manager.get_latest_backup().unwrap().is_none());
342
343 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 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 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 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 for _ in 0..5 {
394 manager.create_backup().unwrap();
395 std::thread::sleep(std::time::Duration::from_millis(50));
396 }
397
398 let (new_backup, deleted) = manager.create_backup_with_retention().unwrap();
400
401 assert!(new_backup.exists());
402 assert!(!deleted.is_empty());
403 }
404}