1use crate::{ThingsConfig, ThingsDatabase};
4use anyhow::Result;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct BackupMetadata {
13 pub created_at: DateTime<Utc>,
14 pub source_path: PathBuf,
15 pub backup_path: PathBuf,
16 pub file_size: u64,
17 pub version: String,
18 pub description: Option<String>,
19}
20
21pub struct BackupManager {
23 config: ThingsConfig,
24}
25
26impl BackupManager {
27 pub fn new(config: ThingsConfig) -> Self {
29 Self { config }
30 }
31
32 pub async fn create_backup(
34 &self,
35 backup_dir: &Path,
36 description: Option<&str>,
37 ) -> Result<BackupMetadata> {
38 let source_path = self.config.get_effective_database_path()?;
39
40 if !source_path.exists() {
41 return Err(anyhow::anyhow!(
42 "Source database does not exist: {:?}",
43 source_path
44 ));
45 }
46
47 fs::create_dir_all(backup_dir)?;
49
50 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
52 let backup_filename = format!("things_backup_{}.sqlite", timestamp);
53 let backup_path = backup_dir.join(backup_filename);
54
55 fs::copy(&source_path, &backup_path)?;
57
58 let file_size = fs::metadata(&backup_path)?.len();
60
61 let metadata = BackupMetadata {
63 created_at: Utc::now(),
64 source_path: source_path.clone(),
65 backup_path: backup_path.clone(),
66 file_size,
67 version: env!("CARGO_PKG_VERSION").to_string(),
68 description: description.map(|s| s.to_string()),
69 };
70
71 let metadata_path = backup_path.with_extension("json");
73 let metadata_json = serde_json::to_string_pretty(&metadata)?;
74 fs::write(&metadata_path, metadata_json)?;
75
76 Ok(metadata)
77 }
78
79 pub async fn restore_backup(&self, backup_path: &Path) -> Result<()> {
81 if !backup_path.exists() {
82 return Err(anyhow::anyhow!(
83 "Backup file does not exist: {:?}",
84 backup_path
85 ));
86 }
87
88 let target_path = self.config.get_effective_database_path()?;
89
90 if let Some(parent) = target_path.parent() {
92 fs::create_dir_all(parent)?;
93 }
94
95 fs::copy(backup_path, &target_path)?;
97
98 Ok(())
99 }
100
101 pub fn list_backups(&self, backup_dir: &Path) -> Result<Vec<BackupMetadata>> {
103 if !backup_dir.exists() {
104 return Ok(vec![]);
105 }
106
107 let mut backups = Vec::new();
108
109 for entry in fs::read_dir(backup_dir)? {
110 let entry = entry?;
111 let path = entry.path();
112
113 if path.extension().and_then(|s| s.to_str()) == Some("sqlite") {
114 let metadata_path = path.with_extension("json");
115 if metadata_path.exists() {
116 let metadata_json = fs::read_to_string(&metadata_path)?;
117 if let Ok(metadata) = serde_json::from_str::<BackupMetadata>(&metadata_json) {
118 backups.push(metadata);
119 }
120 }
121 }
122 }
123
124 backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
126
127 Ok(backups)
128 }
129
130 pub fn get_backup_metadata(&self, backup_path: &Path) -> Result<BackupMetadata> {
132 let metadata_path = backup_path.with_extension("json");
133 if !metadata_path.exists() {
134 return Err(anyhow::anyhow!(
135 "Backup metadata not found: {:?}",
136 metadata_path
137 ));
138 }
139
140 let metadata_json = fs::read_to_string(&metadata_path)?;
141 let metadata = serde_json::from_str::<BackupMetadata>(&metadata_json)?;
142 Ok(metadata)
143 }
144
145 pub fn delete_backup(&self, backup_path: &Path) -> Result<()> {
147 if backup_path.exists() {
148 fs::remove_file(backup_path)?;
149 }
150
151 let metadata_path = backup_path.with_extension("json");
152 if metadata_path.exists() {
153 fs::remove_file(&metadata_path)?;
154 }
155
156 Ok(())
157 }
158
159 pub fn cleanup_old_backups(&self, backup_dir: &Path, keep_count: usize) -> Result<usize> {
161 let mut backups = self.list_backups(backup_dir)?;
162
163 if backups.len() <= keep_count {
164 return Ok(0);
165 }
166
167 let to_delete = backups.split_off(keep_count);
168 let mut deleted_count = 0;
169
170 for backup in to_delete {
171 if let Err(e) = self.delete_backup(&backup.backup_path) {
172 eprintln!("Failed to delete backup {:?}: {}", backup.backup_path, e);
173 } else {
174 deleted_count += 1;
175 }
176 }
177
178 Ok(deleted_count)
179 }
180
181 pub fn verify_backup(&self, backup_path: &Path) -> Result<bool> {
183 if !backup_path.exists() {
184 return Ok(false);
185 }
186
187 match ThingsDatabase::new(backup_path) {
189 Ok(_) => Ok(true),
190 Err(_) => Ok(false),
191 }
192 }
193
194 pub fn get_backup_stats(&self, backup_dir: &Path) -> Result<BackupStats> {
196 let backups = self.list_backups(backup_dir)?;
197
198 let total_backups = backups.len();
199 let total_size: u64 = backups.iter().map(|b| b.file_size).sum();
200 let oldest_backup = backups.last().map(|b| b.created_at);
201 let newest_backup = backups.first().map(|b| b.created_at);
202
203 Ok(BackupStats {
204 total_backups,
205 total_size,
206 oldest_backup,
207 newest_backup,
208 })
209 }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct BackupStats {
215 pub total_backups: usize,
216 pub total_size: u64,
217 pub oldest_backup: Option<DateTime<Utc>>,
218 pub newest_backup: Option<DateTime<Utc>>,
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use tempfile::TempDir;
225
226 #[test]
227 fn test_backup_metadata_creation() {
228 let now = Utc::now();
229 let source_path = PathBuf::from("/path/to/source.db");
230 let backup_path = PathBuf::from("/path/to/backup.db");
231
232 let metadata = BackupMetadata {
233 created_at: now,
234 source_path: source_path.clone(),
235 backup_path: backup_path.clone(),
236 file_size: 1024,
237 version: "1.0.0".to_string(),
238 description: Some("Test backup".to_string()),
239 };
240
241 assert_eq!(metadata.source_path, source_path);
242 assert_eq!(metadata.backup_path, backup_path);
243 assert_eq!(metadata.file_size, 1024);
244 assert_eq!(metadata.version, "1.0.0");
245 assert_eq!(metadata.description, Some("Test backup".to_string()));
246 }
247
248 #[test]
249 fn test_backup_metadata_serialization() {
250 let now = Utc::now();
251 let metadata = BackupMetadata {
252 created_at: now,
253 source_path: PathBuf::from("/test/source.db"),
254 backup_path: PathBuf::from("/test/backup.db"),
255 file_size: 2048,
256 version: "2.0.0".to_string(),
257 description: Some("Serialization test".to_string()),
258 };
259
260 let json = serde_json::to_string(&metadata).unwrap();
262 assert!(json.contains("created_at"));
263 assert!(json.contains("source_path"));
264 assert!(json.contains("backup_path"));
265 assert!(json.contains("file_size"));
266 assert!(json.contains("version"));
267 assert!(json.contains("description"));
268
269 let deserialized: BackupMetadata = serde_json::from_str(&json).unwrap();
271 assert_eq!(deserialized.source_path, metadata.source_path);
272 assert_eq!(deserialized.backup_path, metadata.backup_path);
273 assert_eq!(deserialized.file_size, metadata.file_size);
274 assert_eq!(deserialized.version, metadata.version);
275 assert_eq!(deserialized.description, metadata.description);
276 }
277
278 #[test]
279 fn test_backup_manager_new() {
280 let config = ThingsConfig::from_env();
281 let _backup_manager = BackupManager::new(config);
282 }
285
286 #[test]
287 fn test_backup_stats_creation() {
288 let now = Utc::now();
289 let stats = BackupStats {
290 total_backups: 5,
291 total_size: 10240,
292 oldest_backup: Some(now - chrono::Duration::days(7)),
293 newest_backup: Some(now),
294 };
295
296 assert_eq!(stats.total_backups, 5);
297 assert_eq!(stats.total_size, 10240);
298 assert!(stats.oldest_backup.is_some());
299 assert!(stats.newest_backup.is_some());
300 }
301
302 #[test]
303 fn test_backup_stats_serialization() {
304 let now = Utc::now();
305 let stats = BackupStats {
306 total_backups: 3,
307 total_size: 5120,
308 oldest_backup: Some(now - chrono::Duration::days(3)),
309 newest_backup: Some(now - chrono::Duration::hours(1)),
310 };
311
312 let json = serde_json::to_string(&stats).unwrap();
314 assert!(json.contains("total_backups"));
315 assert!(json.contains("total_size"));
316 assert!(json.contains("oldest_backup"));
317 assert!(json.contains("newest_backup"));
318
319 let deserialized: BackupStats = serde_json::from_str(&json).unwrap();
321 assert_eq!(deserialized.total_backups, stats.total_backups);
322 assert_eq!(deserialized.total_size, stats.total_size);
323 }
324
325 #[test]
326 fn test_backup_stats_empty() {
327 let stats = BackupStats {
328 total_backups: 0,
329 total_size: 0,
330 oldest_backup: None,
331 newest_backup: None,
332 };
333
334 assert_eq!(stats.total_backups, 0);
335 assert_eq!(stats.total_size, 0);
336 assert!(stats.oldest_backup.is_none());
337 assert!(stats.newest_backup.is_none());
338 }
339
340 #[test]
341 fn test_backup_metadata_debug() {
342 let metadata = BackupMetadata {
343 created_at: Utc::now(),
344 source_path: PathBuf::from("/test/source.db"),
345 backup_path: PathBuf::from("/test/backup.db"),
346 file_size: 1024,
347 version: "1.0.0".to_string(),
348 description: Some("Debug test".to_string()),
349 };
350
351 let debug_str = format!("{:?}", metadata);
352 assert!(debug_str.contains("BackupMetadata"));
353 assert!(debug_str.contains("source_path"));
354 assert!(debug_str.contains("backup_path"));
355 }
356
357 #[test]
358 fn test_backup_stats_debug() {
359 let stats = BackupStats {
360 total_backups: 2,
361 total_size: 2048,
362 oldest_backup: Some(Utc::now()),
363 newest_backup: Some(Utc::now()),
364 };
365
366 let debug_str = format!("{:?}", stats);
367 assert!(debug_str.contains("BackupStats"));
368 assert!(debug_str.contains("total_backups"));
369 assert!(debug_str.contains("total_size"));
370 }
371
372 #[test]
373 fn test_backup_metadata_clone() {
374 let metadata = BackupMetadata {
375 created_at: Utc::now(),
376 source_path: PathBuf::from("/test/source.db"),
377 backup_path: PathBuf::from("/test/backup.db"),
378 file_size: 1024,
379 version: "1.0.0".to_string(),
380 description: Some("Clone test".to_string()),
381 };
382
383 let cloned = metadata.clone();
384 assert_eq!(metadata.source_path, cloned.source_path);
385 assert_eq!(metadata.backup_path, cloned.backup_path);
386 assert_eq!(metadata.file_size, cloned.file_size);
387 assert_eq!(metadata.version, cloned.version);
388 assert_eq!(metadata.description, cloned.description);
389 }
390
391 #[test]
392 fn test_backup_stats_clone() {
393 let stats = BackupStats {
394 total_backups: 1,
395 total_size: 512,
396 oldest_backup: Some(Utc::now()),
397 newest_backup: Some(Utc::now()),
398 };
399
400 let cloned = stats.clone();
401 assert_eq!(stats.total_backups, cloned.total_backups);
402 assert_eq!(stats.total_size, cloned.total_size);
403 assert_eq!(stats.oldest_backup, cloned.oldest_backup);
404 assert_eq!(stats.newest_backup, cloned.newest_backup);
405 }
406
407 #[tokio::test]
408 async fn test_backup_creation_with_nonexistent_database() {
409 let temp_dir = TempDir::new().unwrap();
410 let config = ThingsConfig::from_env();
411 let backup_manager = BackupManager::new(config);
412
413 let result = backup_manager
415 .create_backup(temp_dir.path(), Some("test backup"))
416 .await;
417
418 match result {
420 Ok(metadata) => {
421 assert!(!metadata.backup_path.to_string_lossy().is_empty());
423 assert!(metadata.file_size > 0);
424 }
425 Err(e) => {
426 let error_msg = e.to_string();
428 assert!(error_msg.contains("does not exist") || error_msg.contains("not found"));
429 }
430 }
431 }
432
433 #[tokio::test]
434 async fn test_backup_creation_with_nonexistent_backup_dir() {
435 let temp_dir = TempDir::new().unwrap();
436 let config = ThingsConfig::from_env();
437 let backup_manager = BackupManager::new(config);
438
439 let result = backup_manager
441 .create_backup(temp_dir.path(), Some("test backup"))
442 .await;
443
444 match result {
446 Ok(metadata) => {
447 assert!(!metadata.backup_path.to_string_lossy().is_empty());
449 assert!(metadata.file_size > 0);
450 }
451 Err(e) => {
452 let error_msg = e.to_string();
454 assert!(error_msg.contains("does not exist") || error_msg.contains("not found"));
455 }
456 }
457 }
458
459 #[test]
460 fn test_backup_listing_empty_directory() {
461 let temp_dir = TempDir::new().unwrap();
462 let config = ThingsConfig::from_env();
463 let backup_manager = BackupManager::new(config);
464
465 let backups = backup_manager.list_backups(temp_dir.path()).unwrap();
466 assert_eq!(backups.len(), 0);
467 }
468
469 #[test]
470 fn test_backup_listing_nonexistent_directory() {
471 let config = ThingsConfig::from_env();
472 let backup_manager = BackupManager::new(config);
473
474 let backups = backup_manager
475 .list_backups(Path::new("/nonexistent/directory"))
476 .unwrap();
477 assert_eq!(backups.len(), 0);
478 }
479
480 #[test]
481 fn test_get_backup_metadata_nonexistent() {
482 let config = ThingsConfig::from_env();
483 let backup_manager = BackupManager::new(config);
484
485 let result = backup_manager.get_backup_metadata(Path::new("/nonexistent/backup.db"));
486 assert!(result.is_err());
487 let error_msg = result.unwrap_err().to_string();
488 assert!(error_msg.contains("not found"));
489 }
490
491 #[test]
492 fn test_verify_backup_nonexistent() {
493 let config = ThingsConfig::from_env();
494 let backup_manager = BackupManager::new(config);
495
496 let result = backup_manager.verify_backup(Path::new("/nonexistent/backup.db"));
497 assert!(result.is_ok());
498 assert!(!result.unwrap());
499 }
500
501 #[test]
502 fn test_delete_backup_nonexistent() {
503 let config = ThingsConfig::from_env();
504 let backup_manager = BackupManager::new(config);
505
506 let result = backup_manager.delete_backup(Path::new("/nonexistent/backup.db"));
508 assert!(result.is_ok());
509 }
510
511 #[test]
512 fn test_cleanup_old_backups_empty_directory() {
513 let temp_dir = TempDir::new().unwrap();
514 let config = ThingsConfig::from_env();
515 let backup_manager = BackupManager::new(config);
516
517 let deleted_count = backup_manager
518 .cleanup_old_backups(temp_dir.path(), 5)
519 .unwrap();
520 assert_eq!(deleted_count, 0);
521 }
522
523 #[test]
524 fn test_cleanup_old_backups_nonexistent_directory() {
525 let config = ThingsConfig::from_env();
526 let backup_manager = BackupManager::new(config);
527
528 let deleted_count = backup_manager
529 .cleanup_old_backups(Path::new("/nonexistent"), 5)
530 .unwrap();
531 assert_eq!(deleted_count, 0);
532 }
533
534 #[test]
535 fn test_get_backup_stats_empty_directory() {
536 let temp_dir = TempDir::new().unwrap();
537 let config = ThingsConfig::from_env();
538 let backup_manager = BackupManager::new(config);
539
540 let stats = backup_manager.get_backup_stats(temp_dir.path()).unwrap();
541 assert_eq!(stats.total_backups, 0);
542 assert_eq!(stats.total_size, 0);
543 assert!(stats.oldest_backup.is_none());
544 assert!(stats.newest_backup.is_none());
545 }
546
547 #[test]
548 fn test_get_backup_stats_nonexistent_directory() {
549 let config = ThingsConfig::from_env();
550 let backup_manager = BackupManager::new(config);
551
552 let stats = backup_manager
553 .get_backup_stats(Path::new("/nonexistent"))
554 .unwrap();
555 assert_eq!(stats.total_backups, 0);
556 assert_eq!(stats.total_size, 0);
557 assert!(stats.oldest_backup.is_none());
558 assert!(stats.newest_backup.is_none());
559 }
560
561 #[tokio::test]
562 async fn test_restore_backup_nonexistent() {
563 let config = ThingsConfig::from_env();
564 let backup_manager = BackupManager::new(config);
565
566 let result = backup_manager
567 .restore_backup(Path::new("/nonexistent/backup.db"))
568 .await;
569 assert!(result.is_err());
570 let error_msg = result.unwrap_err().to_string();
571 assert!(error_msg.contains("does not exist"));
572 }
573
574 #[test]
575 fn test_backup_metadata_without_description() {
576 let now = Utc::now();
577 let metadata = BackupMetadata {
578 created_at: now,
579 source_path: PathBuf::from("/test/source.db"),
580 backup_path: PathBuf::from("/test/backup.db"),
581 file_size: 1024,
582 version: "1.0.0".to_string(),
583 description: None,
584 };
585
586 assert!(metadata.description.is_none());
587
588 let json = serde_json::to_string(&metadata).unwrap();
590 assert!(json.contains("null")); let deserialized: BackupMetadata = serde_json::from_str(&json).unwrap();
594 assert_eq!(deserialized.description, None);
595 }
596
597 #[test]
598 fn test_backup_metadata_path_operations() {
599 let source_path = PathBuf::from("/path/to/source.db");
600 let backup_path = PathBuf::from("/path/to/backup.db");
601
602 let metadata = BackupMetadata {
603 created_at: Utc::now(),
604 source_path: source_path.clone(),
605 backup_path: backup_path.clone(),
606 file_size: 1024,
607 version: "1.0.0".to_string(),
608 description: Some("Path test".to_string()),
609 };
610
611 assert_eq!(metadata.source_path.file_name().unwrap(), "source.db");
613 assert_eq!(metadata.backup_path.file_name().unwrap(), "backup.db");
614 assert_eq!(
615 metadata.source_path.parent().unwrap(),
616 Path::new("/path/to")
617 );
618 assert_eq!(
619 metadata.backup_path.parent().unwrap(),
620 Path::new("/path/to")
621 );
622 }
623}