Skip to main content

elif_storage/
cleanup.rs

1//! Temporary file cleanup system
2
3use crate::{StorageResult, StorageError};
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6use tokio::time::interval;
7use tracing::{info, warn, error};
8
9/// Cleanup manager for temporary files
10#[derive(Debug)]
11pub struct CleanupManager {
12    temp_directories: Vec<PathBuf>,
13    max_age: Duration,
14    cleanup_interval: Duration,
15    running: std::sync::Arc<std::sync::atomic::AtomicBool>,
16}
17
18impl CleanupManager {
19    /// Create a new cleanup manager
20    pub fn new() -> Self {
21        Self {
22            temp_directories: vec![std::env::temp_dir()],
23            max_age: Duration::from_secs(24 * 60 * 60), // 24 hours
24            cleanup_interval: Duration::from_secs(60 * 60), // 1 hour
25            running: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
26        }
27    }
28    
29    /// Add a directory to monitor for cleanup
30    pub fn add_temp_directory<P: Into<PathBuf>>(mut self, path: P) -> Self {
31        self.temp_directories.push(path.into());
32        self
33    }
34    
35    /// Set maximum age for temporary files
36    pub fn with_max_age(mut self, max_age: Duration) -> Self {
37        self.max_age = max_age;
38        self
39    }
40    
41    /// Set cleanup interval
42    pub fn with_cleanup_interval(mut self, interval: Duration) -> Self {
43        self.cleanup_interval = interval;
44        self
45    }
46    
47    /// Start the cleanup background task
48    pub async fn start(&self) -> StorageResult<tokio::task::JoinHandle<()>> {
49        if self.running.swap(true, std::sync::atomic::Ordering::SeqCst) {
50            return Err(StorageError::Configuration("Cleanup manager already running".to_string()));
51        }
52        
53        let temp_directories = self.temp_directories.clone();
54        let max_age = self.max_age;
55        let cleanup_interval = self.cleanup_interval;
56        let running = self.running.clone();
57        
58        let handle = tokio::spawn(async move {
59            let mut interval = interval(cleanup_interval);
60            
61            info!("Starting cleanup manager with {} directories to monitor", temp_directories.len());
62            
63            while running.load(std::sync::atomic::Ordering::SeqCst) {
64                interval.tick().await;
65                
66                for dir in &temp_directories {
67                    if let Err(e) = cleanup_directory(dir, max_age).await {
68                        error!("Failed to cleanup directory {}: {}", dir.display(), e);
69                    }
70                }
71            }
72            
73            info!("Cleanup manager stopped");
74        });
75        
76        Ok(handle)
77    }
78    
79    /// Stop the cleanup manager
80    pub fn stop(&self) {
81        self.running.store(false, std::sync::atomic::Ordering::SeqCst);
82    }
83    
84    /// Run cleanup once
85    pub async fn run_once(&self) -> StorageResult<CleanupStats> {
86        let mut total_stats = CleanupStats::default();
87        
88        for dir in &self.temp_directories {
89            let stats = cleanup_directory_with_stats(dir, self.max_age).await?;
90            total_stats.files_deleted += stats.files_deleted;
91            total_stats.bytes_freed += stats.bytes_freed;
92            total_stats.directories_removed += stats.directories_removed;
93            total_stats.errors += stats.errors;
94        }
95        
96        Ok(total_stats)
97    }
98}
99
100impl Default for CleanupManager {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106/// Cleanup statistics
107#[derive(Debug, Default, Clone)]
108pub struct CleanupStats {
109    /// Number of files deleted
110    pub files_deleted: u64,
111    
112    /// Number of bytes freed
113    pub bytes_freed: u64,
114    
115    /// Number of empty directories removed
116    pub directories_removed: u64,
117    
118    /// Number of errors encountered
119    pub errors: u64,
120}
121
122/// Clean up old files in a directory
123async fn cleanup_directory(dir: &Path, max_age: Duration) -> StorageResult<()> {
124    let _stats = cleanup_directory_with_stats(dir, max_age).await?;
125    Ok(())
126}
127
128/// Clean up old files in a directory and return statistics
129async fn cleanup_directory_with_stats(dir: &Path, max_age: Duration) -> StorageResult<CleanupStats> {
130    if !dir.exists() {
131        return Ok(CleanupStats::default());
132    }
133    
134    let mut stats = CleanupStats::default();
135    let cutoff_time = SystemTime::now()
136        .checked_sub(max_age)
137        .unwrap_or(UNIX_EPOCH);
138    
139    match cleanup_recursive(dir, cutoff_time, &mut stats).await {
140        Ok(_) => {
141            if stats.files_deleted > 0 || stats.directories_removed > 0 {
142                info!(
143                    "Cleanup completed for {}: {} files deleted, {} bytes freed, {} directories removed",
144                    dir.display(), stats.files_deleted, stats.bytes_freed, stats.directories_removed
145                );
146            }
147            Ok(stats)
148        }
149        Err(e) => {
150            error!("Cleanup failed for {}: {}", dir.display(), e);
151            stats.errors += 1;
152            Ok(stats)
153        }
154    }
155}
156
157/// Recursively clean up files and directories
158fn cleanup_recursive<'a>(dir: &'a Path, cutoff_time: SystemTime, stats: &'a mut CleanupStats) -> std::pin::Pin<Box<dyn std::future::Future<Output = StorageResult<bool>> + Send + 'a>> {
159    Box::pin(async move {
160        let mut entries = tokio::fs::read_dir(dir).await?;
161        let mut dir_empty = true;
162        
163        while let Some(entry) = entries.next_entry().await? {
164            let path = entry.path();
165            let metadata = match entry.metadata().await {
166                Ok(metadata) => metadata,
167                Err(e) => {
168                    warn!("Failed to get metadata for {}: {}", path.display(), e);
169                    stats.errors += 1;
170                    dir_empty = false; // Assume not empty if we can't check
171                    continue;
172                }
173            };
174            
175            if metadata.is_file() {
176                // Check file age
177                match metadata.modified() {
178                    Ok(modified) if modified < cutoff_time => {
179                        // File is old enough to delete
180                        match tokio::fs::remove_file(&path).await {
181                            Ok(_) => {
182                                stats.files_deleted += 1;
183                                stats.bytes_freed += metadata.len();
184                                info!("Deleted old file: {}", path.display());
185                            }
186                            Err(e) => {
187                                warn!("Failed to delete file {}: {}", path.display(), e);
188                                stats.errors += 1;
189                                dir_empty = false;
190                            }
191                        }
192                    }
193                    Ok(_) => {
194                        // File is still young
195                        dir_empty = false;
196                    }
197                    Err(e) => {
198                        warn!("Failed to get modification time for {}: {}", path.display(), e);
199                        stats.errors += 1;
200                        dir_empty = false;
201                    }
202                }
203            } else if metadata.is_dir() {
204                // Recursively clean subdirectory
205                match cleanup_recursive(&path, cutoff_time, stats).await {
206                    Ok(subdir_empty) => {
207                        if subdir_empty {
208                            // Try to remove empty directory
209                            match tokio::fs::remove_dir(&path).await {
210                                Ok(_) => {
211                                    stats.directories_removed += 1;
212                                    info!("Removed empty directory: {}", path.display());
213                                }
214                                Err(e) => {
215                                    warn!("Failed to remove empty directory {}: {}", path.display(), e);
216                                    stats.errors += 1;
217                                    dir_empty = false;
218                                }
219                            }
220                        } else {
221                            dir_empty = false;
222                        }
223                    }
224                    Err(e) => {
225                        warn!("Failed to cleanup directory {}: {}", path.display(), e);
226                        stats.errors += 1;
227                        dir_empty = false;
228                    }
229                }
230            }
231        }
232        
233        Ok(dir_empty)
234    })
235}
236
237/// Temporary file handle that automatically cleans up on drop
238#[derive(Debug)]
239pub struct TempFile {
240    path: PathBuf,
241    keep_on_drop: bool,
242}
243
244impl TempFile {
245    /// Create a new temporary file
246    pub async fn new() -> StorageResult<Self> {
247        let temp_dir = std::env::temp_dir();
248        let filename = format!("elif-temp-{}", uuid::Uuid::new_v4());
249        let path = temp_dir.join(filename);
250        
251        // Create the file
252        tokio::fs::File::create(&path).await?;
253        
254        Ok(Self {
255            path,
256            keep_on_drop: false,
257        })
258    }
259    
260    /// Create a temporary file with a specific extension
261    pub async fn with_extension(extension: &str) -> StorageResult<Self> {
262        let temp_dir = std::env::temp_dir();
263        let filename = format!("elif-temp-{}.{}", uuid::Uuid::new_v4(), extension);
264        let path = temp_dir.join(filename);
265        
266        // Create the file
267        tokio::fs::File::create(&path).await?;
268        
269        Ok(Self {
270            path,
271            keep_on_drop: false,
272        })
273    }
274    
275    /// Get the path to the temporary file
276    pub fn path(&self) -> &Path {
277        &self.path
278    }
279    
280    /// Keep the file when this handle is dropped
281    pub fn keep_on_drop(mut self) -> Self {
282        self.keep_on_drop = true;
283        self
284    }
285    
286    /// Persist the temporary file to a permanent location
287    pub async fn persist<P: AsRef<Path>>(mut self, path: P) -> StorageResult<()> {
288        self.keep_on_drop = true;
289        tokio::fs::rename(&self.path, path).await?;
290        Ok(())
291    }
292}
293
294impl Drop for TempFile {
295    fn drop(&mut self) {
296        if !self.keep_on_drop && self.path.exists() {
297            let path_to_delete = self.path.clone();
298            // Use spawn_blocking to avoid blocking the async runtime
299            tokio::task::spawn_blocking(move || {
300                if let Err(e) = std::fs::remove_file(&path_to_delete) {
301                    warn!("Failed to cleanup temporary file {}: {}", path_to_delete.display(), e);
302                }
303            });
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use std::io::Write;
312    use tempfile::tempdir;
313    
314    #[tokio::test]
315    async fn test_cleanup_manager() {
316        let temp_dir = tempdir().unwrap();
317        let temp_path = temp_dir.path().to_path_buf();
318        
319        // Create some test files with different ages
320        let old_file = temp_path.join("old_file.txt");
321        let new_file = temp_path.join("new_file.txt");
322        
323        // Create old file and set its modification time to 2 days ago
324        std::fs::write(&old_file, "old content").unwrap();
325        let two_days_ago = SystemTime::now() - Duration::from_secs(2 * 24 * 60 * 60);
326        filetime::set_file_mtime(&old_file, filetime::FileTime::from(two_days_ago)).unwrap();
327        
328        // Create new file
329        std::fs::write(&new_file, "new content").unwrap();
330        
331        // Test cleanup
332        let manager = CleanupManager::new()
333            .add_temp_directory(temp_path)
334            .with_max_age(Duration::from_secs(24 * 60 * 60)); // 1 day
335        
336        let stats = manager.run_once().await.unwrap();
337        
338        // Old file should be deleted, new file should remain
339        assert!(!old_file.exists());
340        assert!(new_file.exists());
341        // The stats.files_deleted might include other temporary files, so just check it's > 0
342        assert!(stats.files_deleted > 0);
343        assert!(stats.bytes_freed > 0);
344    }
345    
346    #[tokio::test]
347    async fn test_temp_file() {
348        let temp_file = TempFile::new().await.unwrap();
349        let path = temp_file.path().to_path_buf();
350        
351        assert!(path.exists());
352        
353        // Write some content
354        let mut file = std::fs::OpenOptions::new()
355            .write(true)
356            .open(&path)
357            .unwrap();
358        file.write_all(b"test content").unwrap();
359        drop(file);
360        
361        // Drop the temp file - it should be cleaned up
362        drop(temp_file);
363        
364        // Since cleanup is async, wait a bit for it to complete
365        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
366        
367        // File should be deleted
368        assert!(!path.exists());
369    }
370    
371    #[tokio::test]
372    async fn test_temp_file_keep_on_drop() {
373        let temp_file = TempFile::new().await.unwrap();
374        let path = temp_file.path().to_path_buf();
375        
376        assert!(path.exists());
377        
378        // Keep on drop
379        let temp_file = temp_file.keep_on_drop();
380        drop(temp_file);
381        
382        // File should still exist
383        assert!(path.exists());
384        
385        // Clean up manually
386        std::fs::remove_file(&path).unwrap();
387    }
388    
389    #[tokio::test]
390    async fn test_temp_file_with_extension() {
391        let temp_file = TempFile::with_extension("txt").await.unwrap();
392        let path = temp_file.path().to_path_buf();
393        
394        assert!(path.exists());
395        assert_eq!(path.extension().unwrap(), "txt");
396        
397        drop(temp_file);
398        
399        // Since cleanup is async, wait a bit for it to complete
400        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
401        
402        assert!(!path.exists());
403    }
404}