acton_htmx/storage/
types.rs

1//! Core types for file storage
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use thiserror::Error;
6
7/// Errors that can occur during file storage operations
8#[derive(Debug, Error)]
9pub enum StorageError {
10    /// File not found in storage
11    #[error("File not found: {0}")]
12    NotFound(String),
13
14    /// I/O error during storage operation
15    #[error("I/O error: {0}")]
16    Io(#[from] std::io::Error),
17
18    /// Invalid file path or identifier
19    #[error("Invalid path: {0}")]
20    InvalidPath(String),
21
22    /// Storage quota exceeded
23    #[error("Storage quota exceeded")]
24    QuotaExceeded,
25
26    /// File size exceeds limit
27    #[error("File size {actual} exceeds limit of {limit} bytes")]
28    FileSizeExceeded {
29        /// Actual file size
30        actual: u64,
31        /// Maximum allowed size
32        limit: u64,
33    },
34
35    /// Invalid MIME type
36    #[error("Invalid MIME type: expected {expected:?}, got {actual}")]
37    InvalidMimeType {
38        /// Expected MIME types
39        expected: Vec<String>,
40        /// Actual MIME type
41        actual: String,
42    },
43
44    /// Generic storage error
45    #[error("Storage error: {0}")]
46    Other(String),
47}
48
49/// Result type for storage operations
50pub type StorageResult<T> = Result<T, StorageError>;
51
52/// A file that has been uploaded but not yet stored
53///
54/// This represents the in-memory state of an uploaded file before it's
55/// persisted to the storage backend.
56///
57/// # Examples
58///
59/// ```rust
60/// use acton_htmx::storage::UploadedFile;
61///
62/// let file = UploadedFile {
63///     filename: "document.pdf".to_string(),
64///     content_type: "application/pdf".to_string(),
65///     data: vec![0x25, 0x50, 0x44, 0x46], // PDF magic bytes
66/// };
67///
68/// assert_eq!(file.size(), 4);
69/// ```
70#[derive(Debug, Clone)]
71pub struct UploadedFile {
72    /// Original filename from the upload
73    pub filename: String,
74
75    /// MIME content type (e.g., "image/png", "application/pdf")
76    pub content_type: String,
77
78    /// File data as bytes
79    pub data: Vec<u8>,
80}
81
82impl UploadedFile {
83    /// Creates a new uploaded file
84    ///
85    /// # Examples
86    ///
87    /// ```rust
88    /// use acton_htmx::storage::UploadedFile;
89    ///
90    /// let file = UploadedFile::new(
91    ///     "photo.jpg",
92    ///     "image/jpeg",
93    ///     vec![0xFF, 0xD8, 0xFF], // JPEG magic bytes
94    /// );
95    /// ```
96    #[must_use]
97    pub fn new(filename: impl Into<String>, content_type: impl Into<String>, data: Vec<u8>) -> Self {
98        Self {
99            filename: filename.into(),
100            content_type: content_type.into(),
101            data,
102        }
103    }
104
105    /// Returns the size of the file in bytes
106    ///
107    /// # Examples
108    ///
109    /// ```rust
110    /// use acton_htmx::storage::UploadedFile;
111    ///
112    /// let file = UploadedFile::new("test.txt", "text/plain", vec![1, 2, 3, 4, 5]);
113    /// assert_eq!(file.size(), 5);
114    /// ```
115    #[must_use]
116    pub fn size(&self) -> u64 {
117        self.data.len() as u64
118    }
119
120    /// Validates the file size against a maximum limit
121    ///
122    /// # Errors
123    ///
124    /// Returns `StorageError::FileSizeExceeded` if the file is larger than `max_bytes`
125    ///
126    /// # Examples
127    ///
128    /// ```rust
129    /// use acton_htmx::storage::UploadedFile;
130    ///
131    /// let file = UploadedFile::new("test.txt", "text/plain", vec![1, 2, 3]);
132    ///
133    /// // Passes - file is 3 bytes, limit is 10
134    /// assert!(file.validate_size(10).is_ok());
135    ///
136    /// // Fails - file is 3 bytes, limit is 2
137    /// assert!(file.validate_size(2).is_err());
138    /// ```
139    pub fn validate_size(&self, max_bytes: u64) -> StorageResult<()> {
140        let size = self.size();
141        if size > max_bytes {
142            return Err(StorageError::FileSizeExceeded {
143                actual: size,
144                limit: max_bytes,
145            });
146        }
147        Ok(())
148    }
149
150    /// Validates the file's MIME type against an allowlist
151    ///
152    /// # Errors
153    ///
154    /// Returns `StorageError::InvalidMimeType` if the content type is not in `allowed_types`
155    ///
156    /// # Examples
157    ///
158    /// ```rust
159    /// use acton_htmx::storage::UploadedFile;
160    ///
161    /// let file = UploadedFile::new("photo.jpg", "image/jpeg", vec![]);
162    ///
163    /// // Passes - JPEG is in the allowlist
164    /// assert!(file.validate_mime(&["image/jpeg", "image/png"]).is_ok());
165    ///
166    /// // Fails - JPEG is not in the allowlist of PNG-only
167    /// assert!(file.validate_mime(&["image/png"]).is_err());
168    /// ```
169    pub fn validate_mime(&self, allowed_types: &[&str]) -> StorageResult<()> {
170        if !allowed_types.contains(&self.content_type.as_str()) {
171            return Err(StorageError::InvalidMimeType {
172                expected: allowed_types.iter().map(|s| (*s).to_string()).collect(),
173                actual: self.content_type.clone(),
174            });
175        }
176        Ok(())
177    }
178
179    /// Extracts the file extension from the filename
180    ///
181    /// Returns `None` if the filename has no extension
182    ///
183    /// # Examples
184    ///
185    /// ```rust
186    /// use acton_htmx::storage::UploadedFile;
187    ///
188    /// let file = UploadedFile::new("document.pdf", "application/pdf", vec![]);
189    /// assert_eq!(file.extension(), Some("pdf"));
190    ///
191    /// let no_ext = UploadedFile::new("README", "text/plain", vec![]);
192    /// assert_eq!(no_ext.extension(), None);
193    /// ```
194    #[must_use]
195    pub fn extension(&self) -> Option<&str> {
196        let parts: Vec<&str> = self.filename.rsplitn(2, '.').collect();
197        // If there's exactly 2 parts, we have an extension
198        if parts.len() == 2 {
199            Some(parts[0])
200        } else {
201            None
202        }
203    }
204}
205
206/// A file that has been stored in the backend
207///
208/// This represents metadata about a file that has been persisted to storage.
209/// The actual file data is accessible via the storage backend.
210///
211/// # Examples
212///
213/// ```rust
214/// use acton_htmx::storage::StoredFile;
215///
216/// let stored = StoredFile {
217///     id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
218///     filename: "document.pdf".to_string(),
219///     content_type: "application/pdf".to_string(),
220///     size: 1024,
221///     storage_path: "/uploads/550e8400/document.pdf".to_string(),
222/// };
223///
224/// println!("File stored at: {}", stored.storage_path);
225/// ```
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
227pub struct StoredFile {
228    /// Unique identifier for the stored file (typically UUID)
229    pub id: String,
230
231    /// Original filename
232    pub filename: String,
233
234    /// MIME content type
235    pub content_type: String,
236
237    /// File size in bytes
238    pub size: u64,
239
240    /// Storage backend-specific path or key
241    ///
242    /// - For local storage: filesystem path
243    /// - For S3: object key
244    /// - For Azure: blob name
245    pub storage_path: String,
246}
247
248impl StoredFile {
249    /// Creates a new stored file metadata record
250    ///
251    /// # Examples
252    ///
253    /// ```rust
254    /// use acton_htmx::storage::StoredFile;
255    ///
256    /// let stored = StoredFile::new(
257    ///     "abc-123",
258    ///     "photo.jpg",
259    ///     "image/jpeg",
260    ///     2048,
261    ///     "/uploads/abc-123/photo.jpg",
262    /// );
263    /// ```
264    #[must_use]
265    pub fn new(
266        id: impl Into<String>,
267        filename: impl Into<String>,
268        content_type: impl Into<String>,
269        size: u64,
270        storage_path: impl Into<String>,
271    ) -> Self {
272        Self {
273            id: id.into(),
274            filename: filename.into(),
275            content_type: content_type.into(),
276            size,
277            storage_path: storage_path.into(),
278        }
279    }
280}
281
282impl fmt::Display for StoredFile {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        write!(
285            f,
286            "StoredFile(id={}, filename={}, size={})",
287            self.id, self.filename, self.size
288        )
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_uploaded_file_size() {
298        let file = UploadedFile::new("test.txt", "text/plain", vec![1, 2, 3, 4, 5]);
299        assert_eq!(file.size(), 5);
300    }
301
302    #[test]
303    fn test_validate_size_pass() {
304        let file = UploadedFile::new("test.txt", "text/plain", vec![1, 2, 3]);
305        assert!(file.validate_size(10).is_ok());
306        assert!(file.validate_size(3).is_ok());
307    }
308
309    #[test]
310    fn test_validate_size_fail() {
311        let file = UploadedFile::new("test.txt", "text/plain", vec![1, 2, 3, 4, 5]);
312        let result = file.validate_size(3);
313        assert!(result.is_err());
314        assert!(matches!(
315            result.unwrap_err(),
316            StorageError::FileSizeExceeded { actual: 5, limit: 3 }
317        ));
318    }
319
320    #[test]
321    fn test_validate_mime_pass() {
322        let file = UploadedFile::new("photo.jpg", "image/jpeg", vec![]);
323        assert!(file.validate_mime(&["image/jpeg", "image/png"]).is_ok());
324    }
325
326    #[test]
327    fn test_validate_mime_fail() {
328        let file = UploadedFile::new("photo.jpg", "image/jpeg", vec![]);
329        let result = file.validate_mime(&["image/png", "image/gif"]);
330        assert!(result.is_err());
331        assert!(matches!(
332            result.unwrap_err(),
333            StorageError::InvalidMimeType { .. }
334        ));
335    }
336
337    #[test]
338    fn test_extension() {
339        let file = UploadedFile::new("document.pdf", "application/pdf", vec![]);
340        assert_eq!(file.extension(), Some("pdf"));
341
342        let no_ext = UploadedFile::new("README", "text/plain", vec![]);
343        assert_eq!(no_ext.extension(), None);
344
345        let multiple_dots = UploadedFile::new("archive.tar.gz", "application/gzip", vec![]);
346        assert_eq!(multiple_dots.extension(), Some("gz"));
347    }
348
349    #[test]
350    fn test_stored_file_display() {
351        let stored = StoredFile::new("abc-123", "test.pdf", "application/pdf", 1024, "/uploads/abc-123/test.pdf");
352        let display = format!("{stored}");
353        assert!(display.contains("abc-123"));
354        assert!(display.contains("test.pdf"));
355        assert!(display.contains("1024"));
356    }
357}