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    pub fn new(config: ThingsConfig) -> Self {
29        Self { config }
30    }
31
32    /// Create a backup of the Things 3 database
33    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        // Create backup directory if it doesn't exist
48        fs::create_dir_all(backup_dir)?;
49
50        // Generate backup filename with timestamp
51        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        // Copy the database file
56        fs::copy(&source_path, &backup_path)?;
57
58        // Get file size
59        let file_size = fs::metadata(&backup_path)?.len();
60
61        // Create metadata
62        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        // Save metadata alongside backup
72        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    /// Restore from a backup
80    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        // Create target directory if it doesn't exist
91        if let Some(parent) = target_path.parent() {
92            fs::create_dir_all(parent)?;
93        }
94
95        // Copy backup to target location
96        fs::copy(backup_path, &target_path)?;
97
98        Ok(())
99    }
100
101    /// List available backups in a directory
102    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        // Sort by creation date (newest first)
125        backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
126
127        Ok(backups)
128    }
129
130    /// Get backup metadata from a backup file
131    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    /// Delete a backup and its metadata
146    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    /// Clean up old backups, keeping only the specified number
160    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    /// Verify a backup by checking if it can be opened
182    pub fn verify_backup(&self, backup_path: &Path) -> Result<bool> {
183        if !backup_path.exists() {
184            return Ok(false);
185        }
186
187        // Try to open the backup as a database
188        match ThingsDatabase::new(backup_path) {
189            Ok(_) => Ok(true),
190            Err(_) => Ok(false),
191        }
192    }
193
194    /// Get backup statistics
195    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/// Backup statistics
213#[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        // Test serialization
261        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        // Test deserialization
270        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        // Just test that it can be created
283        // Test passes if we reach this point
284    }
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        // Test serialization
313        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        // Test deserialization
320        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        // Test backup creation with non-existent database
414        let result = backup_manager
415            .create_backup(temp_dir.path(), Some("test backup"))
416            .await;
417
418        // Should fail because database doesn't exist
419        match result {
420            Ok(metadata) => {
421                // If it succeeds, verify the metadata is reasonable
422                assert!(!metadata.backup_path.to_string_lossy().is_empty());
423                assert!(metadata.file_size > 0);
424            }
425            Err(e) => {
426                // If it fails, it should be because the database doesn't exist
427                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        // Test backup creation with non-existent backup directory
440        let result = backup_manager
441            .create_backup(temp_dir.path(), Some("test backup"))
442            .await;
443
444        // Should either succeed or fail gracefully
445        match result {
446            Ok(metadata) => {
447                // If it succeeds, verify the metadata is reasonable
448                assert!(!metadata.backup_path.to_string_lossy().is_empty());
449                assert!(metadata.file_size > 0);
450            }
451            Err(e) => {
452                // If it fails, it should be because the database doesn't exist
453                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        // Should not error when trying to delete non-existent backup
507        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        // Test serialization with None description
589        let json = serde_json::to_string(&metadata).unwrap();
590        assert!(json.contains("null")); // Should contain null for description
591
592        // Test deserialization
593        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        // Test path operations
612        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}