ricecoder_files/
backup.rs

1//! Backup management with retention and restoration
2
3use crate::error::FileError;
4use crate::models::BackupMetadata;
5use crate::verifier::ContentVerifier;
6use chrono::Utc;
7use std::path::{Path, PathBuf};
8use tokio::fs;
9
10/// Manages backup creation, retention, and restoration
11#[derive(Debug, Clone)]
12pub struct BackupManager {
13    backup_dir: PathBuf,
14    retention_count: usize,
15}
16
17impl BackupManager {
18    /// Creates a new BackupManager instance
19    ///
20    /// # Arguments
21    ///
22    /// * `backup_dir` - Directory where backups will be stored
23    /// * `retention_count` - Number of backups to keep per file (default: 10)
24    pub fn new(backup_dir: PathBuf, retention_count: usize) -> Self {
25        BackupManager {
26            backup_dir,
27            retention_count,
28        }
29    }
30
31    /// Creates a timestamped backup of a file
32    ///
33    /// # Arguments
34    ///
35    /// * `path` - Path to the file to backup
36    ///
37    /// # Returns
38    ///
39    /// BackupMetadata containing backup location and hash, or an error
40    pub async fn create_backup(&self, path: &Path) -> Result<BackupMetadata, FileError> {
41        // Read the original file
42        let content = fs::read_to_string(path).await.map_err(|e| {
43            FileError::BackupFailed(format!("Failed to read file for backup: {}", e))
44        })?;
45
46        // Compute hash of the content
47        let content_hash = ContentVerifier::compute_hash(&content);
48
49        // Create backup directory structure
50        fs::create_dir_all(&self.backup_dir).await.map_err(|e| {
51            FileError::BackupFailed(format!("Failed to create backup directory: {}", e))
52        })?;
53
54        // Generate timestamped backup filename
55        let timestamp = Utc::now();
56        let filename = format!(
57            "{}.{}.bak",
58            path.file_name().and_then(|n| n.to_str()).unwrap_or("file"),
59            timestamp.format("%Y%m%d_%H%M%S_%f")
60        );
61
62        let backup_path = self.backup_dir.join(&filename);
63
64        // Write backup file
65        fs::write(&backup_path, &content)
66            .await
67            .map_err(|e| FileError::BackupFailed(format!("Failed to write backup file: {}", e)))?;
68
69        Ok(BackupMetadata {
70            original_path: path.to_path_buf(),
71            backup_path,
72            timestamp,
73            content_hash,
74        })
75    }
76
77    /// Enforces retention policy by keeping only the last N backups per file
78    ///
79    /// # Arguments
80    ///
81    /// * `path` - Path to the original file (used to identify related backups)
82    ///
83    /// # Returns
84    ///
85    /// Result indicating success or failure
86    pub async fn enforce_retention_policy(&self, path: &Path) -> Result<(), FileError> {
87        // Get the base filename for matching backups
88        let base_filename = path
89            .file_name()
90            .and_then(|n| n.to_str())
91            .ok_or_else(|| FileError::BackupFailed("Invalid file path".to_string()))?;
92
93        // Read all entries in backup directory
94        let mut entries = fs::read_dir(&self.backup_dir).await.map_err(|e| {
95            FileError::BackupFailed(format!("Failed to read backup directory: {}", e))
96        })?;
97
98        let mut backups = Vec::new();
99
100        // Collect all backups for this file
101        while let Some(entry) = entries
102            .next_entry()
103            .await
104            .map_err(|e| FileError::BackupFailed(format!("Failed to read backup entry: {}", e)))?
105        {
106            let path = entry.path();
107            if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
108                // Check if this backup belongs to our file
109                if filename.starts_with(base_filename) && filename.ends_with(".bak") {
110                    if let Ok(metadata) = fs::metadata(&path).await {
111                        backups.push((
112                            path,
113                            metadata
114                                .modified()
115                                .unwrap_or_else(|_| std::time::SystemTime::now()),
116                        ));
117                    }
118                }
119            }
120        }
121
122        // Sort by modification time (newest first)
123        backups.sort_by(|a, b| b.1.cmp(&a.1));
124
125        // Delete old backups beyond retention count
126        for (backup_path, _) in backups.iter().skip(self.retention_count) {
127            fs::remove_file(backup_path).await.map_err(|e| {
128                FileError::BackupFailed(format!("Failed to delete old backup: {}", e))
129            })?;
130        }
131
132        Ok(())
133    }
134
135    /// Restores a file from a backup
136    ///
137    /// # Arguments
138    ///
139    /// * `backup_path` - Path to the backup file
140    /// * `target_path` - Path where the file should be restored
141    ///
142    /// # Returns
143    ///
144    /// Result indicating success or failure
145    pub async fn restore_from_backup(
146        &self,
147        backup_path: &Path,
148        target_path: &Path,
149    ) -> Result<(), FileError> {
150        // Read backup content
151        let content = fs::read_to_string(backup_path)
152            .await
153            .map_err(|e| FileError::BackupFailed(format!("Failed to read backup file: {}", e)))?;
154
155        // Ensure target directory exists
156        if let Some(parent) = target_path.parent() {
157            fs::create_dir_all(parent).await.map_err(|e| {
158                FileError::BackupFailed(format!("Failed to create target directory: {}", e))
159            })?;
160        }
161
162        // Write to target location
163        fs::write(target_path, &content)
164            .await
165            .map_err(|e| FileError::BackupFailed(format!("Failed to restore file: {}", e)))?;
166
167        Ok(())
168    }
169
170    /// Verifies backup integrity by comparing stored hash with computed hash
171    ///
172    /// # Arguments
173    ///
174    /// * `backup_path` - Path to the backup file
175    /// * `stored_hash` - Previously stored hash to compare against
176    ///
177    /// # Returns
178    ///
179    /// Result indicating success or failure
180    pub async fn verify_backup_integrity(
181        &self,
182        backup_path: &Path,
183        stored_hash: &str,
184    ) -> Result<(), FileError> {
185        let verifier = ContentVerifier::new();
186        verifier.verify_backup(backup_path, stored_hash).await
187    }
188}
189
190impl Default for BackupManager {
191    fn default() -> Self {
192        Self::new(PathBuf::from(".ricecoder/backups"), 10)
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use tempfile::TempDir;
200
201    #[tokio::test]
202    async fn test_create_backup() {
203        let temp_dir = TempDir::new().unwrap();
204        let backup_dir = temp_dir.path().join("backups");
205        let file_path = temp_dir.path().join("test.txt");
206
207        // Create a test file
208        fs::write(&file_path, "test content").await.unwrap();
209
210        let manager = BackupManager::new(backup_dir, 10);
211        let metadata = manager.create_backup(&file_path).await.unwrap();
212
213        // Verify backup was created
214        assert!(metadata.backup_path.exists());
215        let backup_content = fs::read_to_string(&metadata.backup_path).await.unwrap();
216        assert_eq!(backup_content, "test content");
217    }
218
219    #[tokio::test]
220    async fn test_backup_metadata_hash() {
221        let temp_dir = TempDir::new().unwrap();
222        let backup_dir = temp_dir.path().join("backups");
223        let file_path = temp_dir.path().join("test.txt");
224
225        let content = "test content";
226        fs::write(&file_path, content).await.unwrap();
227
228        let manager = BackupManager::new(backup_dir, 10);
229        let metadata = manager.create_backup(&file_path).await.unwrap();
230
231        // Verify hash matches
232        let expected_hash = ContentVerifier::compute_hash(content);
233        assert_eq!(metadata.content_hash, expected_hash);
234    }
235
236    #[tokio::test]
237    async fn test_restore_from_backup() {
238        let temp_dir = TempDir::new().unwrap();
239        let backup_dir = temp_dir.path().join("backups");
240        let original_path = temp_dir.path().join("original.txt");
241        let restore_path = temp_dir.path().join("restored.txt");
242
243        let content = "original content";
244        fs::write(&original_path, content).await.unwrap();
245
246        let manager = BackupManager::new(backup_dir, 10);
247        let metadata = manager.create_backup(&original_path).await.unwrap();
248
249        // Restore from backup
250        manager
251            .restore_from_backup(&metadata.backup_path, &restore_path)
252            .await
253            .unwrap();
254
255        // Verify restored content matches original
256        let restored_content = fs::read_to_string(&restore_path).await.unwrap();
257        assert_eq!(restored_content, content);
258    }
259
260    #[tokio::test]
261    async fn test_verify_backup_integrity_success() {
262        let temp_dir = TempDir::new().unwrap();
263        let backup_dir = temp_dir.path().join("backups");
264        let file_path = temp_dir.path().join("test.txt");
265
266        let content = "test content";
267        fs::write(&file_path, content).await.unwrap();
268
269        let manager = BackupManager::new(backup_dir, 10);
270        let metadata = manager.create_backup(&file_path).await.unwrap();
271
272        // Verify backup integrity
273        let result = manager
274            .verify_backup_integrity(&metadata.backup_path, &metadata.content_hash)
275            .await;
276        assert!(result.is_ok());
277    }
278
279    #[tokio::test]
280    async fn test_verify_backup_integrity_corruption() {
281        let temp_dir = TempDir::new().unwrap();
282        let backup_dir = temp_dir.path().join("backups");
283        let file_path = temp_dir.path().join("test.txt");
284
285        let content = "test content";
286        fs::write(&file_path, content).await.unwrap();
287
288        let manager = BackupManager::new(backup_dir, 10);
289        let metadata = manager.create_backup(&file_path).await.unwrap();
290
291        // Modify the backup file to simulate corruption
292        fs::write(&metadata.backup_path, "corrupted content")
293            .await
294            .unwrap();
295
296        // Verify backup integrity fails
297        let result = manager
298            .verify_backup_integrity(&metadata.backup_path, &metadata.content_hash)
299            .await;
300        assert!(result.is_err());
301        match result {
302            Err(FileError::BackupCorrupted) => (),
303            _ => panic!("Expected BackupCorrupted error"),
304        }
305    }
306
307    #[tokio::test]
308    async fn test_enforce_retention_policy() {
309        let temp_dir = TempDir::new().unwrap();
310        let backup_dir = temp_dir.path().join("backups");
311        let file_path = temp_dir.path().join("test.txt");
312
313        fs::write(&file_path, "content").await.unwrap();
314
315        let manager = BackupManager::new(backup_dir.clone(), 3);
316
317        // Create multiple backups
318        for i in 0..5 {
319            fs::write(&file_path, &format!("content {}", i))
320                .await
321                .unwrap();
322            manager.create_backup(&file_path).await.unwrap();
323            // Small delay to ensure different timestamps
324            tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
325        }
326
327        // Enforce retention policy
328        manager.enforce_retention_policy(&file_path).await.unwrap();
329
330        // Count remaining backups
331        let mut entries = fs::read_dir(&backup_dir).await.unwrap();
332        let mut count = 0;
333        while let Some(_entry) = entries.next_entry().await.unwrap() {
334            count += 1;
335        }
336
337        // Should have at most 3 backups
338        assert!(count <= 3);
339    }
340}