Skip to main content

chamber_backup/
manager.rs

1use chamber_import_export::{ExportFormat, export_items};
2use chamber_vault::{BackupConfig, Item, Vault};
3use color_eyre::Result;
4use color_eyre::eyre::eyre;
5use std::fs;
6use std::path::{Path, PathBuf};
7use time::OffsetDateTime;
8
9pub trait VaultOperations {
10    /// Retrieves a list of items managed by the current instance.
11    ///
12    /// # Returns
13    /// * `Ok(Vec<Item>)` - If the operation is successful, returns a vector of `Item` objects.
14    /// * `Err` - Returns an error if the operation fails.
15    ///
16    /// # Errors
17    /// This function will return an error if there is an issue while fetching or processing the items.
18    ///
19    /// Note: Ensure to handle the `Result` properly to avoid runtime errors.
20    fn list_items(&self) -> Result<Vec<Item>>;
21}
22
23impl VaultOperations for Vault {
24    fn list_items(&self) -> Result<Vec<Item>> {
25        self.list_items()
26    }
27}
28
29pub struct BackupManager<V: VaultOperations> {
30    pub config: BackupConfig,
31    vault: V,
32}
33
34impl<V: VaultOperations> BackupManager<V> {
35    pub const fn new(vault: V, config: BackupConfig) -> Self {
36        Self { config, vault }
37    }
38
39    /// Attempts to create a backup if specific conditions are met.
40    ///
41    /// This function checks if the backup functionality is enabled in the configuration
42    /// and if the current state meets the criteria for performing a backup. If both conditions
43    /// are satisfied, the backup process is initiated.
44    ///
45    /// # Returns
46    /// * `Ok(Some(PathBuf))` - Path to the backup if a backup was successfully created.
47    /// * `Ok(None)` - If backup functionality is disabled or the conditions for backup are not met.
48    /// * `Err` - If an error occurs during the preconditions check or while performing the backup.
49    ///
50    /// # Errors
51    /// This function may return an error in the following cases:
52    /// * An error occurs while determining if a backup should be performed (`should_backup`).
53    /// * An error occurs during the execution of the backup process (`perform_backup`).
54    pub fn backup_if_needed(&mut self) -> Result<Option<PathBuf>> {
55        if !self.config.enabled {
56            return Ok(None);
57        }
58
59        if !self.should_backup()? {
60            return Ok(None);
61        }
62
63        self.perform_backup()
64    }
65
66    /// Forces the initiation of a backup and ensures its successful completion.
67    ///
68    /// This function triggers the backup process in a guaranteed manner and ensures that the
69    /// resulting backup is successfully created. It internally calls the `perform_backup`
70    /// function and expects it to return a successful result. If `perform_backup` fails or
71    /// does not produce a valid backup, this function will panic with a message indicating
72    /// the failure.
73    ///
74    /// # Returns
75    ///
76    /// Returns a `Result<PathBuf>` containing the path to the completed backup if successful,
77    /// or an error if the backup process fails.
78    ///
79    /// # Errors
80    ///
81    /// This function propagates any errors encountered during the call to `perform_backup`.
82    /// Additionally, it will panic if `perform_backup` does not yield a valid backup path.
83    ///
84    /// # Panics
85    ///
86    /// Panics with the message `"Backup failed to perform"` if the result of `perform_backup`
87    /// is `None` despite the operation succeeding.
88    #[allow(clippy::unwrap_in_result)]
89    #[allow(clippy::expect_used)]
90    pub fn force_backup(&mut self) -> Result<PathBuf> {
91        self.perform_backup().map(|opt| opt.expect("Backup failed to perform"))
92    }
93
94    fn should_backup(&self) -> Result<bool> {
95        // Check if a backup directory exists, create if not
96        if !self.config.backup_dir.exists() {
97            fs::create_dir_all(&self.config.backup_dir)?;
98            return Ok(true); // First backup
99        }
100
101        // Find the most recent backup
102        let most_recent = self.find_most_recent_backup()?;
103
104        if let Some(recent_path) = most_recent {
105            // Check the timestamp in the filename
106            if let Some(timestamp) = self.extract_timestamp_from_filename(&recent_path) {
107                let now = OffsetDateTime::now_utc();
108                let duration_since = now - timestamp;
109                #[allow(clippy::cast_possible_wrap)]
110                let interval = time::Duration::hours(self.config.interval_hours as i64);
111
112                return Ok(duration_since >= interval);
113            }
114        }
115
116        Ok(true) // No recent backup found
117    }
118
119    fn perform_backup(&mut self) -> Result<Option<PathBuf>> {
120        // Ensure backup directory exists
121        fs::create_dir_all(&self.config.backup_dir)?;
122
123        // Generate backup filename with timestamp
124        let timestamp = OffsetDateTime::now_utc();
125        let filename = self.generate_backup_filename(&timestamp)?;
126        let backup_path = self.config.backup_dir.join(&filename);
127
128        // Export the vault data
129        let items = self.vault.list_items()?;
130
131        let export_format = match self.config.format.as_str() {
132            "json" => ExportFormat::Json,
133            "csv" => ExportFormat::Csv,
134            "backup" => ExportFormat::ChamberBackup,
135            _ => return Err(eyre!("Invalid backup format: {}", self.config.format)),
136        };
137
138        // Perform the export
139        export_items(&items, &export_format, &backup_path)?;
140
141        // Compress if requested
142        let final_path = if self.config.compress {
143            Self::compress_backup(&backup_path)?
144        } else {
145            backup_path
146        };
147
148        // Verify backup if requested
149        if self.config.verify_after_backup {
150            self.verify_backup(&final_path)?;
151        }
152
153        // Clean up old backups
154        self.cleanup_old_backups()?;
155
156        Ok(Some(final_path))
157    }
158
159    fn generate_backup_filename(&self, timestamp: &OffsetDateTime) -> Result<String> {
160        let date_str = timestamp.format(&time::format_description::well_known::Rfc3339)?;
161        let safe_date = date_str.replace(':', "-").replace('T', "_");
162
163        let extension = if self.config.compress {
164            format!("{}.gz", self.config.format)
165        } else {
166            self.config.format.clone()
167        };
168
169        Ok(format!(
170            "chamber_backup_{}_{}.{}",
171            safe_date,
172            timestamp.unix_timestamp(),
173            extension
174        ))
175    }
176
177    fn compress_backup(path: &Path) -> Result<PathBuf> {
178        use std::fs::File;
179        use std::io::BufReader;
180
181        let compressed_path =
182            path.with_extension(format!("{}.gz", path.extension().unwrap_or_default().to_string_lossy()));
183
184        let input = File::open(path)?;
185        let output = File::create(&compressed_path)?;
186
187        let mut encoder = flate2::write::GzEncoder::new(output, flate2::Compression::default());
188        let mut reader = BufReader::new(input);
189
190        std::io::copy(&mut reader, &mut encoder)?;
191        encoder.finish()?;
192
193        // Remove original uncompressed file
194        fs::remove_file(path)?;
195
196        Ok(compressed_path)
197    }
198
199    fn verify_backup(&self, path: &Path) -> Result<()> {
200        // Basic verification - ensure file exists and is not empty
201        let metadata = fs::metadata(path)?;
202        if metadata.len() == 0 {
203            return Err(eyre!("Backup file is empty: {}", path.display()));
204        }
205
206        // For compressed files, try to decompress a small portion
207        if path.extension().and_then(|s| s.to_str()) == Some("gz") {
208            Self::verify_compressed_backup(path)?;
209        } else {
210            // For uncompressed files, try to parse the format
211            self.verify_uncompressed_backup(path)?;
212        }
213
214        Ok(())
215    }
216
217    fn verify_compressed_backup(path: &Path) -> Result<()> {
218        use std::fs::File;
219        use std::io::Read;
220
221        let file = File::open(path)?;
222        let mut decoder = flate2::read::GzDecoder::new(file);
223        let mut buffer = [0; 1024];
224
225        // Try to read some data to ensure it's a valid gzip
226        let _ = decoder.read(&mut buffer)?;
227        Ok(())
228    }
229
230    fn verify_uncompressed_backup(&self, path: &Path) -> Result<()> {
231        let content = fs::read_to_string(path)?;
232
233        match self.config.format.as_str() {
234            "json" => {
235                serde_json::from_str::<serde_json::Value>(&content)?;
236            }
237            "backup" => {
238                // Try to parse as ChamberBackup format
239                serde_json::from_str::<chamber_import_export::ChamberBackup>(&content)?;
240            }
241            "csv" => {
242                // Basic CSV validation - check header exists
243                if !content.starts_with("name,kind,value") {
244                    return Err(eyre!("Invalid CSV backup format"));
245                }
246            }
247            _ => return Err(eyre!("Unknown backup format for verification")),
248        }
249
250        Ok(())
251    }
252
253    fn cleanup_old_backups(&self) -> Result<()> {
254        let mut backups = self.find_all_backups()?;
255
256        if backups.len() <= self.config.max_backups {
257            return Ok(());
258        }
259
260        // Sort by timestamp (newest first)
261        backups.sort_by(|a, b| {
262            let time_a = self
263                .extract_timestamp_from_filename(a)
264                .unwrap_or(OffsetDateTime::UNIX_EPOCH);
265            let time_b = self
266                .extract_timestamp_from_filename(b)
267                .unwrap_or(OffsetDateTime::UNIX_EPOCH);
268            time_b.cmp(&time_a)
269        });
270
271        // Remove old backups
272        for old_backup in backups.iter().skip(self.config.max_backups) {
273            if let Err(e) = fs::remove_file(old_backup) {
274                eprintln!("Warning: Failed to remove old backup {}: {}", old_backup.display(), e);
275            }
276        }
277
278        Ok(())
279    }
280
281    /// Finds and returns all backup files located in the configured backup directory.
282    ///
283    /// This method scans the backup directory specified in the configuration
284    /// and identifies all files that meet the criteria for being considered
285    /// backup files (as determined by the `is_backup_file` method).
286    ///
287    /// # Returns
288    ///
289    /// * `Ok(Vec<PathBuf>)` - A vector containing the paths to all identified backup files.
290    /// * `Err(io::Error)` - If an error occurs while reading the backup directory or its entries.
291    ///
292    /// # Behavior
293    ///
294    /// * If the configured backup directory does not exist, the method
295    ///   returns an empty vector wrapped in `Ok`.
296    /// * If the directory exists, the method iterates through its contents and
297    ///   adds any files matching the backup file criteria to the result vector.
298    ///
299    /// # Errors
300    ///
301    /// This function returns an `Err` if:
302    /// - The `backup_dir` cannot be read (e.g., due to insufficient permissions).
303    /// - An error occurs while iterating over entries within the directory.
304    pub fn find_all_backups(&self) -> Result<Vec<PathBuf>> {
305        let mut backups = Vec::new();
306
307        if !self.config.backup_dir.exists() {
308            return Ok(backups);
309        }
310
311        for entry in fs::read_dir(&self.config.backup_dir)? {
312            let entry = entry?;
313            let path = entry.path();
314
315            if path.is_file() && Self::is_backup_file(&path) {
316                backups.push(path);
317            }
318        }
319
320        Ok(backups)
321    }
322
323    /// Finds the most recent backup file from a list of backup files.
324    ///
325    /// This method retrieves all backup files by invoking the `find_all_backups` method. It iterates
326    /// through each backup file to extract its timestamp using the `extract_timestamp_from_filename`
327    /// method. The backup file with the most recent timestamp is identified and returned.
328    ///
329    /// # Returns
330    ///
331    /// * `Ok(Some(PathBuf))` - A `PathBuf` representing the most recent backup file, if any backups exist.
332    /// * `Ok(None)` - Indicates no backup files were found.
333    /// * `Err` - Propagates any errors that occur while retrieving the list of backups or extracting timestamps.
334    ///
335    /// # Errors
336    ///
337    /// This method returns an error if:
338    /// - The `find_all_backups` method fails to retrieve the list of backup files.
339    /// - Any other internal method call fails.
340    ///
341    /// # Implementation Details
342    ///
343    /// - The backups are identified and sorted based on their timestamp. The timestamp is derived
344    ///   from the filename by using the `extract_timestamp_from_filename` method.
345    /// - The initial reference timestamp is set to the UNIX epoch (`OffsetDateTime::UNIX_EPOCH`).
346    pub fn find_most_recent_backup(&self) -> Result<Option<PathBuf>> {
347        let backups = self.find_all_backups()?;
348
349        let mut most_recent = None;
350        let mut most_recent_time = OffsetDateTime::UNIX_EPOCH;
351
352        for backup in backups {
353            if let Some(timestamp) = self.extract_timestamp_from_filename(&backup) {
354                if timestamp > most_recent_time {
355                    most_recent_time = timestamp;
356                    most_recent = Some(backup);
357                }
358            }
359        }
360
361        Ok(most_recent)
362    }
363
364    fn is_backup_file(path: &Path) -> bool {
365        if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
366            filename.starts_with("chamber_backup_")
367        } else {
368            false
369        }
370    }
371
372    pub fn extract_timestamp_from_filename(&self, path: &Path) -> Option<OffsetDateTime> {
373        if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
374            // Extract Unix timestamp from filename
375            // Format: chamber_backup_YYYY-MM-DD_HH-MM-SSZ_TIMESTAMP.format
376            if let Some(timestamp_part) = filename.split('_').nth(4) {
377                if let Some(timestamp_str) = timestamp_part.split('.').next() {
378                    if let Ok(timestamp) = timestamp_str.parse::<i64>() {
379                        return OffsetDateTime::from_unix_timestamp(timestamp).ok();
380                    }
381                }
382            }
383        }
384        None
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    #![allow(clippy::unwrap_used)]
391    #![allow(clippy::panic)]
392    use super::*;
393    use chamber_vault::{BackupConfig, Item, ItemKind};
394    use color_eyre::Result;
395    use std::fs;
396    use tempfile::TempDir;
397    use time::OffsetDateTime;
398
399    // Mock vault implementation for testing
400    struct MockVault {
401        items: Vec<Item>,
402        should_fail: bool,
403    }
404
405    impl MockVault {
406        fn new(items: Vec<Item>) -> Self {
407            Self {
408                items,
409                should_fail: false,
410            }
411        }
412
413        fn new_failing() -> Self {
414            Self {
415                items: vec![],
416                should_fail: true,
417            }
418        }
419    }
420
421    impl VaultOperations for MockVault {
422        fn list_items(&self) -> Result<Vec<Item>> {
423            if self.should_fail {
424                return Err(eyre!("Mock vault error"));
425            }
426            Ok(self.items.clone())
427        }
428    }
429
430    fn create_test_item(id: u64, name: &str) -> Item {
431        Item {
432            id,
433            name: name.to_string(),
434            kind: ItemKind::Password,
435            value: "test_value".to_string(),
436            created_at: OffsetDateTime::now_utc(),
437            updated_at: OffsetDateTime::now_utc(),
438        }
439    }
440
441    fn create_test_config(temp_dir: &TempDir) -> BackupConfig {
442        BackupConfig {
443            enabled: true,
444            backup_dir: temp_dir.path().join("backups"),
445            format: "json".to_string(),
446            compress: false,
447            interval_hours: 24,
448            max_backups: 5,
449            verify_after_backup: false, // Disable for testing since we can't mock export_items
450        }
451    }
452
453    fn create_test_config_with_options(
454        temp_dir: &TempDir,
455        format: &str,
456        compress: bool,
457        verify: bool,
458        max_backups: usize,
459        interval_hours: u64,
460    ) -> BackupConfig {
461        BackupConfig {
462            enabled: true,
463            backup_dir: temp_dir.path().join("backups"),
464            format: format.to_string(),
465            compress,
466            interval_hours,
467            max_backups,
468            verify_after_backup: verify,
469        }
470    }
471
472    #[test]
473    fn test_generic_backup_manager_creation() {
474        let temp_dir = TempDir::new().unwrap();
475        let config = create_test_config(&temp_dir);
476        let items = vec![create_test_item(1, "test_item")];
477        let vault = MockVault::new(items);
478
479        let manager = BackupManager::new(vault, config.clone());
480
481        assert_eq!(manager.config.enabled, config.enabled);
482        assert_eq!(manager.config.format, config.format);
483        assert_eq!(manager.config.max_backups, config.max_backups);
484    }
485
486    #[test]
487    fn test_vault_operations_trait() {
488        let items = vec![create_test_item(1, "test_item_1"), create_test_item(2, "test_item_2")];
489        let vault = MockVault::new(items);
490
491        let result = vault.list_items().unwrap();
492        assert_eq!(result.len(), 2);
493        assert_eq!(result[0].name, "test_item_1");
494        assert_eq!(result[1].name, "test_item_2");
495
496        // Verify items are the concrete Item type
497        assert_eq!(result[0].kind, ItemKind::Password);
498        assert_eq!(result[0].value, "test_value");
499    }
500
501    #[test]
502    fn test_vault_operations_failure() {
503        let vault = MockVault::new_failing();
504
505        let result = vault.list_items();
506        assert!(result.is_err());
507        assert!(result.unwrap_err().to_string().contains("Mock vault error"));
508    }
509
510    #[test]
511    fn test_backup_if_needed_disabled_with_generic() {
512        let temp_dir = TempDir::new().unwrap();
513        let mut config = create_test_config(&temp_dir);
514        config.enabled = false;
515        let items = vec![create_test_item(1, "test_item")];
516        let vault = MockVault::new(items);
517
518        let mut manager = BackupManager::new(vault, config);
519        let result = manager.backup_if_needed().unwrap();
520
521        assert!(result.is_none());
522    }
523
524    #[test]
525    fn test_should_backup_first_time() {
526        let temp_dir = TempDir::new().unwrap();
527        let config = create_test_config(&temp_dir);
528        let items = vec![create_test_item(1, "test_item")];
529        let vault = MockVault::new(items);
530
531        let manager = BackupManager::new(vault, config);
532
533        // Should return true for first backup (no backup directory exists)
534        assert!(manager.should_backup().unwrap());
535    }
536
537    #[test]
538    fn test_find_all_backups_empty_directory() {
539        let temp_dir = TempDir::new().unwrap();
540        let config = create_test_config(&temp_dir);
541        let vault = MockVault::new(vec![create_test_item(1, "test_item")]);
542        let manager = BackupManager::new(vault, config);
543
544        let backups = manager.find_all_backups().unwrap();
545        assert!(backups.is_empty());
546    }
547
548    #[test]
549    fn test_find_all_backups_with_files() {
550        let temp_dir = TempDir::new().unwrap();
551        let backup_dir = temp_dir.path().join("backups");
552        fs::create_dir_all(&backup_dir).unwrap();
553
554        // Create valid backup files
555        let backup1 = backup_dir.join("chamber_backup_2024-01-01_00-00-00Z_1640995200.json");
556        let backup2 = backup_dir.join("chamber_backup_2024-01-02_00-00-00Z_1641081600.json");
557
558        // Create invalid file
559        let invalid_file = backup_dir.join("not_a_backup.txt");
560
561        fs::write(&backup1, "backup1 content").unwrap();
562        fs::write(&backup2, "backup2 content").unwrap();
563        fs::write(&invalid_file, "invalid content").unwrap();
564
565        let config = create_test_config(&temp_dir);
566        let vault = MockVault::new(vec![create_test_item(1, "test_item")]);
567        let manager = BackupManager::new(vault, config);
568
569        let backups = manager.find_all_backups().unwrap();
570        assert_eq!(backups.len(), 2);
571        assert!(backups.contains(&backup1));
572        assert!(backups.contains(&backup2));
573        assert!(!backups.iter().any(|p| p == &invalid_file));
574    }
575
576    #[test]
577    fn test_concrete_item_usage() {
578        // Test that we can work with the concrete Item type
579        let items = vec![
580            Item {
581                id: 1,
582                name: "password_item".to_string(),
583                kind: ItemKind::Password,
584                value: "secret123".to_string(),
585                created_at: OffsetDateTime::now_utc(),
586                updated_at: OffsetDateTime::now_utc(),
587            },
588            Item {
589                id: 2,
590                name: "api_key_item".to_string(),
591                kind: ItemKind::ApiKey,
592                value: "api_key_abc".to_string(),
593                created_at: OffsetDateTime::now_utc(),
594                updated_at: OffsetDateTime::now_utc(),
595            },
596        ];
597
598        let vault = MockVault::new(items);
599        let result = vault.list_items().unwrap();
600
601        // Can access all Item fields without issues
602        assert_eq!(result[0].name, "password_item");
603        assert_eq!(result[0].kind, ItemKind::Password);
604        assert_eq!(result[0].value, "secret123");
605        assert!(result[0].id > 0);
606
607        assert_eq!(result[1].name, "api_key_item");
608        assert_eq!(result[1].kind, ItemKind::ApiKey);
609        assert_eq!(result[1].value, "api_key_abc");
610    }
611
612    #[test]
613    fn test_trait_object_compatibility() {
614        let temp_dir = TempDir::new().unwrap();
615        let _ = create_test_config(&temp_dir);
616
617        // Test that we can use trait objects
618        let vault: Box<dyn VaultOperations> = Box::new(MockVault::new(vec![create_test_item(1, "test")]));
619        let items = vault.list_items().unwrap();
620        assert_eq!(items.len(), 1);
621        assert_eq!(items[0].name, "test");
622    }
623
624    // Test with different vault implementations but same Item type
625    struct AlternativeVault {
626        data: Vec<Item>,
627    }
628
629    impl AlternativeVault {
630        fn new(data: Vec<Item>) -> Self {
631            Self { data }
632        }
633    }
634
635    impl VaultOperations for AlternativeVault {
636        fn list_items(&self) -> Result<Vec<Item>> {
637            Ok(self.data.clone())
638        }
639    }
640
641    #[test]
642    fn test_multiple_vault_implementations() {
643        let temp_dir1 = TempDir::new().unwrap();
644        let temp_dir2 = TempDir::new().unwrap();
645
646        let items = vec![create_test_item(1, "shared_item")];
647
648        // Both vault implementations work with the same Item type
649        let mock_vault = MockVault::new(items.clone());
650        let alt_vault = AlternativeVault::new(items);
651
652        let manager1 = BackupManager::new(mock_vault, create_test_config(&temp_dir1));
653        let manager2 = BackupManager::new(alt_vault, create_test_config(&temp_dir2));
654
655        // Both managers work with the same Item type
656        let items1 = manager1.vault.list_items().unwrap();
657        let items2 = manager2.vault.list_items().unwrap();
658
659        assert_eq!(items1[0].name, items2[0].name);
660        assert_eq!(items1[0].kind, items2[0].kind);
661        assert_eq!(items1[0].value, items2[0].value);
662    }
663
664    #[test]
665    fn test_generate_backup_filename() {
666        let temp_dir = TempDir::new().unwrap();
667        let config = create_test_config(&temp_dir);
668        let vault = MockVault::new(vec![]);
669        let manager = BackupManager::new(vault, config);
670
671        let timestamp = OffsetDateTime::from_unix_timestamp(1_640_995_200).unwrap(); // 2022-01-01 00:00:00 UTC
672        let filename = manager.generate_backup_filename(&timestamp).unwrap();
673
674        assert!(filename.starts_with("chamber_backup_"));
675        assert!(filename.contains("_1640995200"));
676        assert!(
677            Path::new(&filename)
678                .extension()
679                .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
680        );
681    }
682
683    #[test]
684    fn test_generate_backup_filename_with_compression() {
685        let temp_dir = TempDir::new().unwrap();
686        let config = create_test_config_with_options(&temp_dir, "json", true, false, 5, 24);
687        let vault = MockVault::new(vec![]);
688        let manager = BackupManager::new(vault, config);
689
690        let timestamp = OffsetDateTime::from_unix_timestamp(1_640_995_200).unwrap();
691        let filename = manager.generate_backup_filename(&timestamp).unwrap();
692
693        assert!(filename.starts_with("chamber_backup_"));
694        assert!(filename.ends_with(".json.gz"));
695    }
696
697    #[test]
698    fn test_generate_backup_filename_different_formats() {
699        let temp_dir = TempDir::new().unwrap();
700
701        for format in ["json", "csv", "backup"] {
702            let config = create_test_config_with_options(&temp_dir, format, false, false, 5, 24);
703            let vault = MockVault::new(vec![]);
704            let manager = BackupManager::new(vault, config);
705
706            let timestamp = OffsetDateTime::from_unix_timestamp(1_640_995_200).unwrap();
707            let filename = manager.generate_backup_filename(&timestamp).unwrap();
708
709            assert!(filename.ends_with(&format!(".{format}")));
710        }
711    }
712
713    #[test]
714    fn test_extract_timestamp_from_filename() {
715        let temp_dir = TempDir::new().unwrap();
716        let config = create_test_config(&temp_dir);
717        let vault = MockVault::new(vec![]);
718        let manager = BackupManager::new(vault, config);
719
720        // Test with actual generated filename format
721        let timestamp = OffsetDateTime::from_unix_timestamp(1_640_995_200).unwrap();
722        let generated_filename = manager.generate_backup_filename(&timestamp).unwrap();
723        let test_path = temp_dir.path().join(&generated_filename);
724
725        let extracted_timestamp = manager.extract_timestamp_from_filename(&test_path);
726
727        assert!(extracted_timestamp.is_some());
728        assert_eq!(extracted_timestamp.unwrap().unix_timestamp(), 1_640_995_200);
729    }
730
731    #[test]
732    fn test_extract_timestamp_from_invalid_filename() {
733        let temp_dir = TempDir::new().unwrap();
734        let config = create_test_config(&temp_dir);
735        let vault = MockVault::new(vec![]);
736        let manager = BackupManager::new(vault, config);
737
738        let test_path = temp_dir.path().join("invalid_filename.json");
739        let timestamp = manager.extract_timestamp_from_filename(&test_path);
740
741        assert!(timestamp.is_none());
742    }
743
744    #[test]
745    fn test_is_backup_file() {
746        assert!(BackupManager::<MockVault>::is_backup_file(Path::new(
747            "chamber_backup_2024-01-01_00-00-00Z_1640995200.json"
748        )));
749
750        assert!(!BackupManager::<MockVault>::is_backup_file(Path::new(
751            "not_a_backup.json"
752        )));
753
754        assert!(!BackupManager::<MockVault>::is_backup_file(Path::new(
755            "chamber_2024-01-01.json"
756        )));
757    }
758
759    #[test]
760    fn test_find_most_recent_backup() {
761        let temp_dir = TempDir::new().unwrap();
762        let backup_dir = temp_dir.path().join("backups");
763        fs::create_dir_all(&backup_dir).unwrap();
764
765        // Create backup files with different timestamps
766        let backup1 = backup_dir.join("chamber_backup_2024-01-01_00-00-00Z_1640995200.json");
767        let backup2 = backup_dir.join("chamber_backup_2024-01-02_00-00-00Z_1641081600.json");
768        let backup3 = backup_dir.join("chamber_backup_2024-01-03_00-00-00Z_1641168000.json");
769
770        fs::write(&backup1, "backup1").unwrap();
771        fs::write(&backup2, "backup2").unwrap();
772        fs::write(&backup3, "backup3").unwrap();
773
774        let config = create_test_config(&temp_dir);
775        let vault = MockVault::new(vec![]);
776        let manager = BackupManager::new(vault, config);
777
778        let most_recent = manager.find_most_recent_backup().unwrap();
779        assert!(most_recent.is_some());
780        assert_eq!(most_recent.unwrap(), backup3);
781    }
782
783    #[test]
784    fn test_find_most_recent_backup_empty() {
785        let temp_dir = TempDir::new().unwrap();
786        let config = create_test_config(&temp_dir);
787        let vault = MockVault::new(vec![]);
788        let manager = BackupManager::new(vault, config);
789
790        let most_recent = manager.find_most_recent_backup().unwrap();
791        assert!(most_recent.is_none());
792    }
793
794    #[test]
795    fn test_should_backup_with_recent_backup() {
796        let temp_dir = TempDir::new().unwrap();
797        let backup_dir = temp_dir.path().join("backups");
798        fs::create_dir_all(&backup_dir).unwrap();
799
800        // Create a recent backup (current time)
801        let now = OffsetDateTime::now_utc();
802        let timestamp = now.unix_timestamp();
803        let recent_backup = backup_dir.join(format!("chamber_backup_2024-01-01_00-00-00Z_{timestamp}.json"));
804        fs::write(&recent_backup, "recent backup").unwrap();
805
806        let config = create_test_config(&temp_dir);
807        let vault = MockVault::new(vec![]);
808        let manager = BackupManager::new(vault, config);
809
810        // Should return false because backup is too recent
811        assert!(!manager.should_backup().unwrap());
812    }
813
814    #[test]
815    fn test_should_backup_with_old_backup() {
816        let temp_dir = TempDir::new().unwrap();
817        let backup_dir = temp_dir.path().join("backups");
818        fs::create_dir_all(&backup_dir).unwrap();
819
820        // Create an old backup (25 hours ago)
821        let old_time = OffsetDateTime::now_utc() - time::Duration::hours(25);
822        let timestamp = old_time.unix_timestamp();
823        let old_backup = backup_dir.join(format!("chamber_backup_2024-01-01_00-00-00Z_{timestamp}.json"));
824        fs::write(&old_backup, "old backup").unwrap();
825
826        let config = create_test_config(&temp_dir);
827        let vault = MockVault::new(vec![]);
828        let manager = BackupManager::new(vault, config);
829
830        // Should return true because backup is older than interval
831        assert!(manager.should_backup().unwrap());
832    }
833
834    #[test]
835    fn test_compress_backup() {
836        let temp_dir = TempDir::new().unwrap();
837        let test_file = temp_dir.path().join("test.json");
838        let test_content = r#"{"test": "data", "items": [1, 2, 3]}"#;
839
840        fs::write(&test_file, test_content).unwrap();
841
842        let compressed_path = BackupManager::<MockVault>::compress_backup(&test_file).unwrap();
843
844        assert!(compressed_path.extension().unwrap() == "gz");
845        assert!(compressed_path.exists());
846        assert!(!test_file.exists()); // Original should be removed
847
848        // Verify compressed file is not empty
849        let metadata = fs::metadata(&compressed_path).unwrap();
850        assert!(metadata.len() > 0);
851    }
852
853    #[test]
854    fn test_verify_compressed_backup() {
855        let temp_dir = TempDir::new().unwrap();
856        let test_file = temp_dir.path().join("test.json");
857        let test_content = r#"{"test": "data"}"#;
858
859        fs::write(&test_file, test_content).unwrap();
860        let compressed_path = BackupManager::<MockVault>::compress_backup(&test_file).unwrap();
861
862        // Should not panic or return error
863        let result = BackupManager::<MockVault>::verify_compressed_backup(&compressed_path);
864        assert!(result.is_ok());
865    }
866
867    #[test]
868    fn test_verify_compressed_backup_invalid_file() {
869        let temp_dir = TempDir::new().unwrap();
870        let invalid_gz = temp_dir.path().join("invalid.gz");
871        fs::write(&invalid_gz, "not gzip data").unwrap();
872
873        let result = BackupManager::<MockVault>::verify_compressed_backup(&invalid_gz);
874        assert!(result.is_err());
875    }
876
877    #[test]
878    fn test_verify_uncompressed_backup_json() {
879        let temp_dir = TempDir::new().unwrap();
880        let config = create_test_config_with_options(&temp_dir, "json", false, false, 5, 24);
881        let vault = MockVault::new(vec![]);
882        let manager = BackupManager::new(vault, config);
883
884        let test_file = temp_dir.path().join("test.json");
885        let valid_json = r#"{"items": [{"name": "test", "value": "data"}]}"#;
886        fs::write(&test_file, valid_json).unwrap();
887
888        let result = manager.verify_uncompressed_backup(&test_file);
889        assert!(result.is_ok());
890    }
891
892    #[test]
893    fn test_verify_uncompressed_backup_invalid_json() {
894        let temp_dir = TempDir::new().unwrap();
895        let config = create_test_config_with_options(&temp_dir, "json", false, false, 5, 24);
896        let vault = MockVault::new(vec![]);
897        let manager = BackupManager::new(vault, config);
898
899        let test_file = temp_dir.path().join("test.json");
900        fs::write(&test_file, "invalid json content").unwrap();
901
902        let result = manager.verify_uncompressed_backup(&test_file);
903        assert!(result.is_err());
904    }
905
906    #[test]
907    fn test_verify_uncompressed_backup_csv() {
908        let temp_dir = TempDir::new().unwrap();
909        let config = create_test_config_with_options(&temp_dir, "csv", false, false, 5, 24);
910        let vault = MockVault::new(vec![]);
911        let manager = BackupManager::new(vault, config);
912
913        let test_file = temp_dir.path().join("test.csv");
914        let valid_csv = "name,kind,value\ntest,password,secret";
915        fs::write(&test_file, valid_csv).unwrap();
916
917        let result = manager.verify_uncompressed_backup(&test_file);
918        assert!(result.is_ok());
919    }
920
921    #[test]
922    fn test_verify_uncompressed_backup_invalid_csv() {
923        let temp_dir = TempDir::new().unwrap();
924        let config = create_test_config_with_options(&temp_dir, "csv", false, false, 5, 24);
925        let vault = MockVault::new(vec![]);
926        let manager = BackupManager::new(vault, config);
927
928        let test_file = temp_dir.path().join("test.csv");
929        fs::write(&test_file, "invalid csv header").unwrap();
930
931        let result = manager.verify_uncompressed_backup(&test_file);
932        assert!(result.is_err());
933    }
934
935    #[test]
936    fn test_verify_backup_empty_file() {
937        let temp_dir = TempDir::new().unwrap();
938        let config = create_test_config(&temp_dir);
939        let vault = MockVault::new(vec![]);
940        let manager = BackupManager::new(vault, config);
941
942        let empty_file = temp_dir.path().join("empty.json");
943        fs::write(&empty_file, "").unwrap();
944
945        let result = manager.verify_backup(&empty_file);
946        assert!(result.is_err());
947        assert!(result.unwrap_err().to_string().contains("empty"));
948    }
949
950    #[test]
951    fn test_cleanup_old_backups() {
952        let temp_dir = TempDir::new().unwrap();
953        let backup_dir = temp_dir.path().join("backups");
954        fs::create_dir_all(&backup_dir).unwrap();
955
956        // Create more backups than the limit
957        let backups = [
958            ("chamber_backup_2024-01-01_00-00-00Z_1640995200.json", 1_640_995_200),
959            ("chamber_backup_2024-01-02_00-00-00Z_1641081600.json", 1_641_081_600),
960            ("chamber_backup_2024-01-03_00-00-00Z_1641168000.json", 1_641_168_000),
961            ("chamber_backup_2024-01-04_00-00-00Z_1641254400.json", 1_641_254_400),
962            ("chamber_backup_2024-01-05_00-00-00Z_1641340800.json", 1_641_340_800),
963            ("chamber_backup_2024-01-06_00-00-00Z_1641427200.json", 1_641_427_200),
964            ("chamber_backup_2024-01-07_00-00-00Z_1641513600.json", 1_641_513_600),
965        ];
966
967        for (filename, _) in &backups {
968            let path = backup_dir.join(filename);
969            fs::write(&path, "backup content").unwrap();
970        }
971
972        let config = create_test_config_with_options(&temp_dir, "json", false, false, 3, 24);
973        let vault = MockVault::new(vec![]);
974        let manager = BackupManager::new(vault, config);
975
976        let result = manager.cleanup_old_backups();
977        assert!(result.is_ok());
978
979        // Should keep only 3 most recent backups
980        let remaining_backups = manager.find_all_backups().unwrap();
981        assert_eq!(remaining_backups.len(), 3);
982
983        // Verify the most recent ones are kept
984        let filenames: Vec<String> = remaining_backups
985            .iter()
986            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
987            .collect();
988
989        assert!(filenames.contains(&"chamber_backup_2024-01-07_00-00-00Z_1641513600.json".to_string()));
990        assert!(filenames.contains(&"chamber_backup_2024-01-06_00-00-00Z_1641427200.json".to_string()));
991        assert!(filenames.contains(&"chamber_backup_2024-01-05_00-00-00Z_1641340800.json".to_string()));
992    }
993
994    #[test]
995    fn test_cleanup_old_backups_under_limit() {
996        let temp_dir = TempDir::new().unwrap();
997        let backup_dir = temp_dir.path().join("backups");
998        fs::create_dir_all(&backup_dir).unwrap();
999
1000        // Create fewer backups than the limit
1001        let backup1 = backup_dir.join("chamber_backup_2024-01-01_00-00-00Z_1640995200.json");
1002        let backup2 = backup_dir.join("chamber_backup_2024-01-02_00-00-00Z_1641081600.json");
1003
1004        fs::write(&backup1, "backup1").unwrap();
1005        fs::write(&backup2, "backup2").unwrap();
1006
1007        let config = create_test_config_with_options(&temp_dir, "json", false, false, 5, 24);
1008        let vault = MockVault::new(vec![]);
1009        let manager = BackupManager::new(vault, config);
1010
1011        let result = manager.cleanup_old_backups();
1012        assert!(result.is_ok());
1013
1014        // Should keep all backups since under limit
1015        let remaining_backups = manager.find_all_backups().unwrap();
1016        assert_eq!(remaining_backups.len(), 2);
1017    }
1018
1019    #[test]
1020    fn test_backup_if_needed_vault_error() {
1021        let temp_dir = TempDir::new().unwrap();
1022        let config = create_test_config(&temp_dir);
1023        let vault = MockVault::new_failing();
1024
1025        let mut manager = BackupManager::new(vault, config);
1026        let result = manager.backup_if_needed();
1027
1028        // Should propagate vault error when trying to list items
1029        assert!(result.is_err());
1030        assert!(result.unwrap_err().to_string().contains("Mock vault error"));
1031    }
1032
1033    #[test]
1034    fn test_force_backup_with_items() {
1035        let temp_dir = TempDir::new().unwrap();
1036        let config = create_test_config(&temp_dir);
1037        let items = vec![create_test_item(1, "test_item")];
1038        let vault = MockVault::new(items);
1039
1040        let mut manager = BackupManager::new(vault, config);
1041
1042        // Test the force_backup method
1043        let result = manager.force_backup();
1044
1045        // The actual behavior may vary depending on export_items implementation
1046        // Let's test what actually happens rather than assuming it fails
1047        match result {
1048            Ok(path) => {
1049                // If it succeeds, verify we got a backup path
1050                assert!(path.exists() || path.parent().is_some_and(|_| false));
1051                println!("Force backup succeeded with path: {}", path.display());
1052            }
1053            Err(e) => {
1054                // If it fails, verify it's not a vault error
1055                let error_msg = e.to_string();
1056                assert!(
1057                    !error_msg.contains("Mock vault error"),
1058                    "Error should not be from vault operations, got: {error_msg}"
1059                );
1060                println!("Force backup failed as expected with: {error_msg}");
1061            }
1062        }
1063    }
1064
1065    #[test]
1066    fn test_force_backup_vault_error() {
1067        let temp_dir = TempDir::new().unwrap();
1068        let config = create_test_config(&temp_dir);
1069        let vault = MockVault::new_failing(); // Vault that fails on list_items
1070
1071        let mut manager = BackupManager::new(vault, config);
1072
1073        // This should fail because vault.list_items() fails
1074        let result = manager.force_backup();
1075        assert!(result.is_err());
1076        assert!(result.unwrap_err().to_string().contains("Mock vault error"));
1077    }
1078
1079    #[test]
1080    fn test_perform_backup_flow() {
1081        // Test that perform_backup follows the expected flow
1082        let temp_dir = TempDir::new().unwrap();
1083        let config = create_test_config(&temp_dir);
1084        let items = vec![create_test_item(1, "test_item")];
1085        let vault = MockVault::new(items);
1086
1087        let mut manager = BackupManager::new(vault, config);
1088
1089        // Test perform_backup directly
1090        let result = manager.perform_backup();
1091
1092        // Verify the backup directory was created
1093        assert!(manager.config.backup_dir.exists());
1094
1095        // Check the result
1096        match result {
1097            Ok(Some(path)) => {
1098                // Backup succeeded
1099                assert!(path.to_string_lossy().contains("chamber_backup_"));
1100                println!("Backup created at: {}", path.display());
1101            }
1102            Ok(None) => {
1103                panic!("perform_backup returned None, which shouldn't happen in this test");
1104            }
1105            Err(e) => {
1106                // Expected if export_items fails
1107                println!("Backup failed with: {e}");
1108                // Verify it's not a vault error
1109                assert!(!e.to_string().contains("Mock vault error"));
1110            }
1111        }
1112    }
1113
1114    #[test]
1115    fn test_different_backup_formats() {
1116        let temp_dir = TempDir::new().unwrap();
1117
1118        for format in ["json", "csv", "backup"] {
1119            let config = create_test_config_with_options(&temp_dir, format, false, false, 5, 24);
1120            let vault = MockVault::new(vec![create_test_item(1, "test")]);
1121            let manager = BackupManager::new(vault, config);
1122
1123            let timestamp = OffsetDateTime::now_utc();
1124            let filename = manager.generate_backup_filename(&timestamp).unwrap();
1125            assert!(filename.ends_with(&format!(".{format}")));
1126        }
1127    }
1128
1129    #[test]
1130    fn test_different_intervals() {
1131        let temp_dir = TempDir::new().unwrap();
1132
1133        for interval in [1, 12, 24, 48, 168] {
1134            // 1h, 12h, 24h, 48h, 1week
1135            let config = create_test_config_with_options(&temp_dir, "json", false, false, 5, interval);
1136            let vault = MockVault::new(vec![]);
1137            let manager = BackupManager::new(vault, config);
1138
1139            assert_eq!(manager.config.interval_hours, interval);
1140        }
1141    }
1142
1143    #[test]
1144    fn test_verify_unknown_format() {
1145        let temp_dir = TempDir::new().unwrap();
1146        let config = create_test_config_with_options(&temp_dir, "unknown", false, false, 5, 24);
1147        let vault = MockVault::new(vec![]);
1148        let manager = BackupManager::new(vault, config);
1149
1150        let test_file = temp_dir.path().join("test.unknown");
1151        fs::write(&test_file, "some content").unwrap();
1152
1153        let result = manager.verify_uncompressed_backup(&test_file);
1154        assert!(result.is_err());
1155        assert!(result.unwrap_err().to_string().contains("Unknown backup format"));
1156    }
1157
1158    #[test]
1159    fn test_extract_timestamp_edge_cases() {
1160        let temp_dir = TempDir::new().unwrap();
1161        let config = create_test_config(&temp_dir);
1162        let vault = MockVault::new(vec![]);
1163        let manager = BackupManager::new(vault, config);
1164
1165        // Test with a malformed timestamp
1166        let malformed_path = temp_dir.path().join("chamber_backup_2024-01-01_00-00-00Z_invalid.json");
1167        assert!(manager.extract_timestamp_from_filename(&malformed_path).is_none());
1168
1169        // Test with a missing timestamp section
1170        let missing_path = temp_dir.path().join("chamber_backup_2024-01-01_00-00-00Z.json");
1171        assert!(manager.extract_timestamp_from_filename(&missing_path).is_none());
1172
1173        // Test with valid edge case timestamps
1174        let edge_cases = [
1175            ("chamber_backup_2024-01-01_00-00-00Z_0.json", 0),
1176            ("chamber_backup_2024-01-01_00-00-00Z_2147483647.json", 2_147_483_647), // Max 32-bit
1177        ];
1178
1179        for (filename, expected) in edge_cases {
1180            let path = temp_dir.path().join(filename);
1181            let timestamp = manager.extract_timestamp_from_filename(&path);
1182            assert!(timestamp.is_some());
1183            assert_eq!(timestamp.unwrap().unix_timestamp(), expected);
1184        }
1185    }
1186}