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}