things3_core/
backup.rs

1//! Backup and restore functionality for Things 3 database
2
3use crate::{ThingsConfig, ThingsDatabase};
4use anyhow::Result;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Backup metadata
11#[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
21/// Backup manager for Things 3 database
22pub struct BackupManager {
23    config: ThingsConfig,
24}
25
26impl BackupManager {
27    /// Create a new backup manager
28    #[must_use]
29    pub const fn new(config: ThingsConfig) -> Self {
30        Self { config }
31    }
32
33    /// Create a backup of the Things 3 database
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if the backup directory cannot be created or if the database file cannot be copied.
38    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        // Create backup directory if it doesn't exist
53        fs::create_dir_all(backup_dir)?;
54
55        // Generate backup filename with timestamp
56        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        // Copy the database file
61        fs::copy(&source_path, &backup_path)?;
62
63        // Get file size
64        let file_size = fs::metadata(&backup_path)?.len();
65
66        // Create metadata
67        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        // Save metadata alongside backup
77        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    /// Restore from a backup
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if the backup file doesn't exist or if copying fails.
89    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        // Create target directory if it doesn't exist
100        if let Some(parent) = target_path.parent() {
101            fs::create_dir_all(parent)?;
102        }
103
104        // Copy backup to target location
105        fs::copy(backup_path, &target_path)?;
106
107        Ok(())
108    }
109
110    /// List available backups in a directory
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if the directory cannot be read or if metadata files are corrupted.
115    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        // Sort by creation date (newest first)
138        backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
139
140        Ok(backups)
141    }
142
143    /// Get backup metadata from a backup file
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the metadata file cannot be read or parsed.
148    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    /// Delete a backup and its metadata
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if the files cannot be deleted.
167    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    /// Clean up old backups, keeping only the specified number
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if the directory cannot be read or if files cannot be deleted.
185    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    /// Verify a backup by checking if it can be opened
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if the file cannot be accessed or opened.
215    pub async fn verify_backup(&self, backup_path: &Path) -> Result<bool> {
216        if !backup_path.exists() {
217            return Ok(false);
218        }
219
220        // Try to open the backup as a database
221        match ThingsDatabase::new(backup_path).await {
222            Ok(_) => Ok(true),
223            Err(_) => Ok(false),
224        }
225    }
226
227    /// Get backup statistics
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if the directory cannot be read or if metadata files are corrupted.
232    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/// Backup statistics
250#[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        // Test serialization
298        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        // Test deserialization
307        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        // Just test that it can be created
320        // Test passes if we reach this point
321    }
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        // Test serialization
350        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        // Test deserialization
357        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        // Test backup creation with non-existent database
451        let result = backup_manager.create_backup(temp_dir.path(), Some("test backup"));
452
453        // Should fail because database doesn't exist
454        match result {
455            Ok(metadata) => {
456                // If it succeeds, verify the metadata is reasonable
457                assert!(!metadata.backup_path.to_string_lossy().is_empty());
458                assert!(metadata.file_size > 0);
459            }
460            Err(e) => {
461                // If it fails, it should be because the database doesn't exist
462                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        // Test backup creation with non-existent backup directory
475        let result = backup_manager.create_backup(temp_dir.path(), Some("test backup"));
476
477        // Should either succeed or fail gracefully
478        match result {
479            Ok(metadata) => {
480                // If it succeeds, verify the metadata is reasonable
481                assert!(!metadata.backup_path.to_string_lossy().is_empty());
482                assert!(metadata.file_size > 0);
483            }
484            Err(e) => {
485                // If it fails, it should be because the database doesn't exist
486                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        // Should not error when trying to delete non-existent backup
542        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        // Test serialization with None description
622        let json = serde_json::to_string(&metadata).unwrap();
623        assert!(json.contains("null")); // Should contain null for description
624
625        // Test deserialization
626        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        // Test path operations
645        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}