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