claude_utils/file_manager/
mod.rs

1use sha2::{Digest, Sha256};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, SystemTime};
6use tokio::fs;
7use tracing::{error, info, warn};
8
9use crate::Result;
10
11#[derive(Debug, Clone)]
12pub struct StagedFile {
13    pub path: PathBuf,
14    pub size: usize,
15    pub format: String,
16    pub created_at: SystemTime,
17    pub thumbnail_path: Option<PathBuf>,
18}
19
20#[derive(Debug, Clone)]
21pub struct FileManagerConfig {
22    pub staging_dir: PathBuf,
23    pub cleanup_interval: Duration,
24    pub max_file_age: Duration,
25}
26
27impl Default for FileManagerConfig {
28    fn default() -> Self {
29        let staging_dir = std::env::temp_dir().join(crate::STAGING_DIR_NAME);
30
31        Self {
32            staging_dir,
33            cleanup_interval: Duration::from_secs(crate::CLEANUP_INTERVAL_MINS * 60),
34            max_file_age: Duration::from_secs(crate::CLEANUP_INTERVAL_MINS * 60),
35        }
36    }
37}
38
39pub struct FileManager {
40    config: FileManagerConfig,
41    cache: Arc<Mutex<HashMap<String, StagedFile>>>,
42}
43
44impl FileManager {
45    pub async fn new(config: FileManagerConfig) -> Result<Self> {
46        // Ensure staging directory exists
47        fs::create_dir_all(&config.staging_dir).await?;
48
49        let manager = Self {
50            config,
51            cache: Arc::new(Mutex::new(HashMap::new())),
52        };
53
54        // Start cleanup task
55        manager.start_cleanup_task();
56
57        Ok(manager)
58    }
59
60    pub async fn stage_image(&self, data: &[u8], format: &str) -> Result<StagedFile> {
61        // Calculate hash for deduplication
62        let hash = self.calculate_hash(data);
63        let filename = format!("clip-{}.{}", &hash[..8], format);
64        let file_path = self.config.staging_dir.join(&filename);
65
66        // Check cache first
67        if let Some(staged) = self.get_from_cache(&hash) {
68            if file_path.exists() {
69                info!("Using cached file: {}", file_path.display());
70                return Ok(staged);
71            }
72        }
73
74        // Write main file
75        fs::write(&file_path, data).await?;
76        info!(
77            "Staged file: {} ({} bytes)",
78            file_path.display(),
79            data.len()
80        );
81
82        // Generate thumbnail
83        let thumbnail_path = self.generate_thumbnail(&file_path, data, format).await?;
84
85        let staged_file = StagedFile {
86            path: file_path,
87            size: data.len(),
88            format: format.to_string(),
89            created_at: SystemTime::now(),
90            thumbnail_path,
91        };
92
93        // Update cache
94        self.update_cache(hash, staged_file.clone());
95
96        Ok(staged_file)
97    }
98
99    pub async fn stage_text(&self, text: &str) -> Result<StagedFile> {
100        let data = text.as_bytes();
101        let hash = self.calculate_hash(data);
102        let filename = format!("clip-{}.txt", &hash[..8]);
103        let file_path = self.config.staging_dir.join(&filename);
104
105        // Check cache
106        if let Some(staged) = self.get_from_cache(&hash) {
107            if file_path.exists() {
108                return Ok(staged);
109            }
110        }
111
112        // Write file
113        fs::write(&file_path, text).await?;
114
115        let staged_file = StagedFile {
116            path: file_path,
117            size: data.len(),
118            format: "txt".to_string(),
119            created_at: SystemTime::now(),
120            thumbnail_path: None,
121        };
122
123        self.update_cache(hash, staged_file.clone());
124
125        Ok(staged_file)
126    }
127
128    async fn generate_thumbnail(
129        &self,
130        file_path: &Path,
131        data: &[u8],
132        format: &str,
133    ) -> Result<Option<PathBuf>> {
134        use image::imageops::FilterType;
135
136        // Only generate thumbnails for supported image formats
137        if !["png", "jpg", "jpeg", "gif", "webp", "bmp"].contains(&format) {
138            return Ok(None);
139        }
140
141        let thumb_path = file_path.with_extension("thumb.png");
142
143        // Load and resize image
144        match image::load_from_memory(data) {
145            Ok(img) => {
146                let thumbnail = img.resize(256, 256, FilterType::Lanczos3);
147
148                // Save thumbnail
149                match thumbnail.save(&thumb_path) {
150                    Ok(_) => {
151                        info!("Generated thumbnail: {}", thumb_path.display());
152                        Ok(Some(thumb_path))
153                    }
154                    Err(e) => {
155                        warn!("Failed to save thumbnail: {}", e);
156                        Ok(None)
157                    }
158                }
159            }
160            Err(e) => {
161                warn!("Failed to generate thumbnail: {}", e);
162                Ok(None)
163            }
164        }
165    }
166
167    fn calculate_hash(&self, data: &[u8]) -> String {
168        let mut hasher = Sha256::new();
169        hasher.update(data);
170        format!("{:x}", hasher.finalize())
171    }
172
173    fn get_from_cache(&self, hash: &str) -> Option<StagedFile> {
174        self.cache.lock().ok()?.get(hash).cloned()
175    }
176
177    fn update_cache(&self, hash: String, file: StagedFile) {
178        if let Ok(mut cache) = self.cache.lock() {
179            cache.insert(hash, file);
180        }
181    }
182
183    fn start_cleanup_task(&self) {
184        let cache = self.cache.clone();
185        let staging_dir = self.config.staging_dir.clone();
186        let max_age = self.config.max_file_age;
187        let interval = self.config.cleanup_interval;
188
189        tokio::spawn(async move {
190            let mut interval_timer = tokio::time::interval(interval);
191
192            loop {
193                interval_timer.tick().await;
194
195                info!("Running cleanup task");
196
197                // Clean up old files
198                match fs::read_dir(&staging_dir).await {
199                    Ok(mut entries) => {
200                        while let Ok(Some(entry)) = entries.next_entry().await {
201                            if let Ok(metadata) = entry.metadata().await {
202                                if let Ok(modified) = metadata.modified() {
203                                    if let Ok(age) = modified.elapsed() {
204                                        if age > max_age {
205                                            let path = entry.path();
206                                            match fs::remove_file(&path).await {
207                                                Ok(_) => {
208                                                    info!("Cleaned up old file: {}", path.display())
209                                                }
210                                                Err(e) => warn!("Failed to remove file: {}", e),
211                                            }
212                                        }
213                                    }
214                                }
215                            }
216                        }
217                    }
218                    Err(e) => error!("Failed to read staging directory: {}", e),
219                }
220
221                // Clean cache
222                if let Ok(mut cache_guard) = cache.lock() {
223                    let _now = SystemTime::now();
224                    cache_guard.retain(|_, file| {
225                        file.created_at
226                            .elapsed()
227                            .map(|age| age < max_age)
228                            .unwrap_or(false)
229                    });
230                }
231            }
232        });
233    }
234
235    pub fn get_staging_dir(&self) -> &Path {
236        &self.config.staging_dir
237    }
238}