acton_htmx/storage/
local.rs1use super::traits::FileStorage;
4use super::types::{StorageError, StorageResult, StoredFile, UploadedFile};
5use async_trait::async_trait;
6use std::path::{Path, PathBuf};
7use tokio::fs;
8use tokio::io::AsyncWriteExt;
9use uuid::Uuid;
10
11#[derive(Debug, Clone)]
49pub struct LocalFileStorage {
50 base_path: PathBuf,
52}
53
54impl LocalFileStorage {
55 pub fn new(base_path: PathBuf) -> StorageResult<Self> {
76 if base_path.exists() && !base_path.is_dir() {
78 return Err(StorageError::InvalidPath(format!(
79 "{} is not a directory",
80 base_path.display()
81 )));
82 }
83
84 Ok(Self { base_path })
85 }
86
87 fn get_file_directory(&self, id: &str) -> PathBuf {
92 let prefix = &id[..2.min(id.len())];
94 self.base_path.join(prefix).join(id)
95 }
96
97 fn get_file_path(&self, id: &str, filename: &str) -> PathBuf {
99 self.get_file_directory(id).join(filename)
100 }
101
102 fn get_metadata_path(&self, id: &str) -> PathBuf {
104 self.get_file_directory(id).join(".metadata.json")
105 }
106
107 async fn ensure_directory(&self, path: &Path) -> StorageResult<()> {
109 fs::create_dir_all(path).await?;
110 Ok(())
111 }
112}
113
114#[async_trait]
115impl FileStorage for LocalFileStorage {
116 async fn store(&self, file: UploadedFile) -> StorageResult<StoredFile> {
117 let id = Uuid::new_v4().to_string();
119
120 let dir = self.get_file_directory(&id);
122 self.ensure_directory(&dir).await?;
123
124 let file_path = self.get_file_path(&id, &file.filename);
126 let mut f = fs::File::create(&file_path).await?;
127 f.write_all(&file.data).await?;
128 f.flush().await?;
129
130 let stored = StoredFile {
132 id: id.clone(),
133 filename: file.filename.clone(),
134 content_type: file.content_type.clone(),
135 size: file.size(),
136 storage_path: file_path.to_string_lossy().to_string(),
137 };
138
139 let metadata_path = self.get_metadata_path(&id);
141 let metadata_json = serde_json::to_string_pretty(&stored)
142 .map_err(|e| StorageError::Other(format!("Failed to serialize metadata: {e}")))?;
143 fs::write(&metadata_path, metadata_json).await?;
144
145 Ok(stored)
146 }
147
148 async fn retrieve(&self, id: &str) -> StorageResult<Vec<u8>> {
149 let dir = self.get_file_directory(id);
152
153 if !dir.exists() {
154 return Err(StorageError::NotFound(id.to_string()));
155 }
156
157 let mut entries = fs::read_dir(&dir).await?;
158
159 while let Some(entry) = entries.next_entry().await? {
161 let file_path = entry.path();
162 if let Ok(metadata) = entry.metadata().await {
164 if metadata.is_file() {
165 if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
167 if !name.starts_with('.') {
168 let data = fs::read(&file_path).await?;
169 return Ok(data);
170 }
171 }
172 }
173 }
174 }
175
176 Err(StorageError::NotFound(id.to_string()))
177 }
178
179 async fn delete(&self, id: &str) -> StorageResult<()> {
180 let dir = self.get_file_directory(id);
181
182 if dir.exists() {
184 fs::remove_dir_all(&dir).await?;
185 }
186
187 Ok(())
188 }
189
190 async fn url(&self, id: &str) -> StorageResult<String> {
191 let dir = self.get_file_directory(id);
194
195 if !dir.exists() {
196 return Err(StorageError::NotFound(id.to_string()));
197 }
198
199 let mut entries = fs::read_dir(&dir).await?;
200
201 while let Some(entry) = entries.next_entry().await? {
203 let file_path = entry.path();
204 if let Ok(metadata) = entry.metadata().await {
206 if metadata.is_file() {
207 let filename = file_path
209 .file_name()
210 .and_then(|n| n.to_str())
211 .ok_or_else(|| StorageError::InvalidPath(format!("Invalid filename in {id}")))?;
212
213 if !filename.starts_with('.') {
214 let prefix = &id[..2.min(id.len())];
216 return Ok(format!("/uploads/{prefix}/{id}/{filename}"));
217 }
218 }
219 }
220 }
221
222 Err(StorageError::NotFound(id.to_string()))
223 }
224
225 async fn exists(&self, id: &str) -> StorageResult<bool> {
226 let dir = self.get_file_directory(id);
227 Ok(dir.exists())
228 }
229
230 async fn get_metadata(&self, id: &str) -> StorageResult<StoredFile> {
231 let metadata_path = self.get_metadata_path(id);
232
233 if !metadata_path.exists() {
234 return Err(StorageError::NotFound(id.to_string()));
235 }
236
237 let metadata_json = fs::read_to_string(&metadata_path).await?;
239 let stored: StoredFile = serde_json::from_str(&metadata_json)
240 .map_err(|e| StorageError::Other(format!("Failed to parse metadata: {e}")))?;
241
242 Ok(stored)
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use tempfile::TempDir;
250
251 fn create_test_storage() -> (LocalFileStorage, TempDir) {
252 let temp_dir = TempDir::new().unwrap();
253 let storage = LocalFileStorage::new(temp_dir.path().to_path_buf()).unwrap();
254 (storage, temp_dir)
255 }
256
257 #[tokio::test]
258 async fn test_store_and_retrieve() {
259 let (storage, _temp) = create_test_storage();
260
261 let file = UploadedFile::new("test.txt", "text/plain", b"Hello, World!".to_vec());
262
263 let stored = storage.store(file).await.unwrap();
265 assert!(!stored.id.is_empty());
266 assert_eq!(stored.filename, "test.txt");
267 assert_eq!(stored.size, 13);
268
269 let data = storage.retrieve(&stored.id).await.unwrap();
271 assert_eq!(data, b"Hello, World!");
272 }
273
274 #[tokio::test]
275 async fn test_delete() {
276 let (storage, _temp) = create_test_storage();
277
278 let file = UploadedFile::new("test.txt", "text/plain", b"Test".to_vec());
279 let stored = storage.store(file).await.unwrap();
280
281 assert!(storage.exists(&stored.id).await.unwrap());
283
284 storage.delete(&stored.id).await.unwrap();
286
287 assert!(!storage.exists(&stored.id).await.unwrap());
289
290 storage.delete(&stored.id).await.unwrap();
292 }
293
294 #[tokio::test]
295 async fn test_url_generation() {
296 let (storage, _temp) = create_test_storage();
297
298 let file = UploadedFile::new("photo.jpg", "image/jpeg", b"fake image".to_vec());
299 let stored = storage.store(file).await.unwrap();
300
301 let url = storage.url(&stored.id).await.unwrap();
302 assert!(url.starts_with("/uploads/"));
303 assert!(url.contains(&stored.id));
304 assert!(url.ends_with("/photo.jpg"));
305 }
306
307 #[tokio::test]
308 async fn test_retrieve_nonexistent() {
309 let (storage, _temp) = create_test_storage();
310
311 let result = storage.retrieve("nonexistent-id").await;
312 assert!(result.is_err());
313 assert!(matches!(result.unwrap_err(), StorageError::NotFound(_)));
314 }
315
316 #[tokio::test]
317 async fn test_exists() {
318 let (storage, _temp) = create_test_storage();
319
320 assert!(!storage.exists("nonexistent-id").await.unwrap());
322
323 let file = UploadedFile::new("test.txt", "text/plain", b"Test".to_vec());
325 let stored = storage.store(file).await.unwrap();
326
327 assert!(storage.exists(&stored.id).await.unwrap());
329 }
330
331 #[tokio::test]
332 async fn test_directory_structure() {
333 let (storage, temp) = create_test_storage();
334
335 let file = UploadedFile::new("test.txt", "text/plain", b"Test".to_vec());
336 let stored = storage.store(file).await.unwrap();
337
338 let prefix = &stored.id[..2];
340 let expected_path = temp.path().join(prefix).join(&stored.id).join("test.txt");
341 assert!(expected_path.exists());
342 }
343
344 #[tokio::test]
345 async fn test_invalid_base_path() {
346 let temp = TempDir::new().unwrap();
348 let file_path = temp.path().join("not-a-directory");
349 std::fs::write(&file_path, b"test").unwrap();
350
351 let result = LocalFileStorage::new(file_path);
352 assert!(result.is_err());
353 assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
354 }
355
356 #[tokio::test]
357 async fn test_get_metadata() {
358 let (storage, _temp) = create_test_storage();
359
360 let file = UploadedFile::new("document.pdf", "application/pdf", b"fake pdf".to_vec());
361 let stored = storage.store(file).await.unwrap();
362
363 let metadata = storage.get_metadata(&stored.id).await.unwrap();
365 assert_eq!(metadata.id, stored.id);
366 assert_eq!(metadata.filename, "document.pdf");
367 assert_eq!(metadata.content_type, "application/pdf");
368 assert_eq!(metadata.size, 8);
369 }
370
371 #[tokio::test]
372 async fn test_get_metadata_preserves_content_type() {
373 let (storage, _temp) = create_test_storage();
374
375 let test_cases = vec![
377 ("image.png", "image/png"),
378 ("video.mp4", "video/mp4"),
379 ("document.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
380 ("data.json", "application/json"),
381 ];
382
383 for (filename, content_type) in test_cases {
384 let file = UploadedFile::new(filename, content_type, b"test data".to_vec());
385 let stored = storage.store(file).await.unwrap();
386
387 let metadata = storage.get_metadata(&stored.id).await.unwrap();
389 assert_eq!(metadata.content_type, content_type, "Content type mismatch for {filename}");
390 assert_eq!(metadata.filename, filename);
391 }
392 }
393
394 #[tokio::test]
395 async fn test_get_metadata_nonexistent() {
396 let (storage, _temp) = create_test_storage();
397
398 let result = storage.get_metadata("nonexistent-id").await;
399 assert!(result.is_err());
400 assert!(matches!(result.unwrap_err(), StorageError::NotFound(_)));
401 }
402
403 #[tokio::test]
404 async fn test_metadata_file_created() {
405 let (storage, _temp) = create_test_storage();
406
407 let file = UploadedFile::new("test.txt", "text/plain", b"Hello".to_vec());
408 let stored = storage.store(file).await.unwrap();
409
410 let metadata_path = storage.get_metadata_path(&stored.id);
412 assert!(metadata_path.exists(), "Metadata file should exist");
413
414 let metadata_json = std::fs::read_to_string(&metadata_path).unwrap();
416 let metadata: StoredFile = serde_json::from_str(&metadata_json).unwrap();
417 assert_eq!(metadata.id, stored.id);
418 assert_eq!(metadata.filename, "test.txt");
419 assert_eq!(metadata.content_type, "text/plain");
420 }
421}