acton_htmx/storage/
local.rs

1//! Local filesystem storage implementation
2
3use 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/// Local filesystem storage backend
12///
13/// Stores files in a local directory with UUID-based organization.
14/// Each file is stored in a subdirectory based on the first 2 characters
15/// of its UUID to avoid hitting filesystem limits on files per directory.
16///
17/// # Directory Structure
18///
19/// ```text
20/// /var/uploads/
21/// ├── 55/
22/// │   └── 550e8400-e29b-41d4-a716-446655440000/
23/// │       └── document.pdf
24/// ├── a3/
25/// │   └── a3bb189e-8bf9-4a9a-b5c7-9f9c3b8e5d7a/
26/// │       └── image.png
27/// ```
28///
29/// # Examples
30///
31/// ```rust,no_run
32/// use acton_htmx::storage::{LocalFileStorage, FileStorage, UploadedFile};
33/// use std::path::PathBuf;
34///
35/// # async fn example() -> anyhow::Result<()> {
36/// // Create storage (creates directory if it doesn't exist)
37/// let storage = LocalFileStorage::new(PathBuf::from("/var/uploads"))?;
38///
39/// // Store a file
40/// let file = UploadedFile::new("photo.jpg", "image/jpeg", vec![/* ... */]);
41/// let stored = storage.store(file).await?;
42///
43/// // File is now at: /var/uploads/55/550e8400.../photo.jpg
44/// println!("Stored at: {}", stored.storage_path);
45/// # Ok(())
46/// # }
47/// ```
48#[derive(Debug, Clone)]
49pub struct LocalFileStorage {
50    /// Base directory for file storage
51    base_path: PathBuf,
52}
53
54impl LocalFileStorage {
55    /// Creates a new local file storage instance
56    ///
57    /// This will verify that the base path exists and is writable.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if:
62    /// - The base path doesn't exist and cannot be created
63    /// - The base path is not a directory
64    /// - Insufficient permissions to write to the directory
65    ///
66    /// # Examples
67    ///
68    /// ```rust,no_run
69    /// use acton_htmx::storage::LocalFileStorage;
70    /// use std::path::PathBuf;
71    ///
72    /// let storage = LocalFileStorage::new(PathBuf::from("/var/uploads"))?;
73    /// # Ok::<(), Box<dyn std::error::Error>>(())
74    /// ```
75    pub fn new(base_path: PathBuf) -> StorageResult<Self> {
76        // Validate base path (synchronous check is OK for initialization)
77        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    /// Gets the filesystem path for a file ID
88    ///
89    /// Returns the directory path where the file should be stored,
90    /// using the first 2 characters of the ID as a prefix.
91    fn get_file_directory(&self, id: &str) -> PathBuf {
92        // Use first 2 chars of ID as subdirectory to avoid too many files in one dir
93        let prefix = &id[..2.min(id.len())];
94        self.base_path.join(prefix).join(id)
95    }
96
97    /// Gets the full filesystem path for a stored file
98    fn get_file_path(&self, id: &str, filename: &str) -> PathBuf {
99        self.get_file_directory(id).join(filename)
100    }
101
102    /// Gets the path to the metadata file for a stored file
103    fn get_metadata_path(&self, id: &str) -> PathBuf {
104        self.get_file_directory(id).join(".metadata.json")
105    }
106
107    /// Ensures the storage directory exists
108    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        // Generate unique ID
118        let id = Uuid::new_v4().to_string();
119
120        // Create directory structure
121        let dir = self.get_file_directory(&id);
122        self.ensure_directory(&dir).await?;
123
124        // Write file to disk
125        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        // Create metadata
131        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        // Write metadata to sidecar file
140        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        // We need to find the file by ID, but we don't know the filename
150        // List files in the ID directory and read the first non-hidden file
151        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        // Read the first non-hidden file in the directory
160        while let Some(entry) = entries.next_entry().await? {
161            let file_path = entry.path();
162            // Use async metadata check
163            if let Ok(metadata) = entry.metadata().await {
164                if metadata.is_file() {
165                    // Skip hidden files (like .metadata.json)
166                    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        // Idempotent - don't error if directory doesn't exist
183        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        // For local storage, return a relative URL path
192        // In production, this would be served by the web server
193        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        // Find the first non-hidden file
202        while let Some(entry) = entries.next_entry().await? {
203            let file_path = entry.path();
204            // Use async metadata check
205            if let Ok(metadata) = entry.metadata().await {
206                if metadata.is_file() {
207                    // Skip hidden files (like .metadata.json)
208                    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                        // Use first 2 chars as prefix
215                        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        // Read and parse metadata JSON
238        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        // Store
264        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        // Retrieve
270        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        // Verify exists
282        assert!(storage.exists(&stored.id).await.unwrap());
283
284        // Delete
285        storage.delete(&stored.id).await.unwrap();
286
287        // Verify doesn't exist
288        assert!(!storage.exists(&stored.id).await.unwrap());
289
290        // Delete again (idempotent)
291        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        // Nonexistent
321        assert!(!storage.exists("nonexistent-id").await.unwrap());
322
323        // Create file
324        let file = UploadedFile::new("test.txt", "text/plain", b"Test".to_vec());
325        let stored = storage.store(file).await.unwrap();
326
327        // Should exist
328        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        // Verify directory structure: base/prefix/id/filename
339        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        // Try to create storage with a file instead of directory
347        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        // Get metadata
364        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        // Store files with various content types
376        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            // Verify metadata preserves original content type
388            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        // Verify metadata file exists
411        let metadata_path = storage.get_metadata_path(&stored.id);
412        assert!(metadata_path.exists(), "Metadata file should exist");
413
414        // Verify it's valid JSON with expected structure
415        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}