use serde::{Deserialize, Serialize};
use std::fmt;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum StorageError {
#[error("File not found: {0}")]
NotFound(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid path: {0}")]
InvalidPath(String),
#[error("Storage quota exceeded")]
QuotaExceeded,
#[error("File size {actual} exceeds limit of {limit} bytes")]
FileSizeExceeded {
actual: u64,
limit: u64,
},
#[error("Invalid MIME type: expected {expected:?}, got {actual}")]
InvalidMimeType {
expected: Vec<String>,
actual: String,
},
#[error("Storage error: {0}")]
Other(String),
}
pub type StorageResult<T> = Result<T, StorageError>;
#[derive(Debug, Clone)]
pub struct UploadedFile {
pub filename: String,
pub content_type: String,
pub data: Vec<u8>,
}
impl UploadedFile {
#[must_use]
pub fn new(filename: impl Into<String>, content_type: impl Into<String>, data: Vec<u8>) -> Self {
Self {
filename: filename.into(),
content_type: content_type.into(),
data,
}
}
#[must_use]
pub fn size(&self) -> u64 {
self.data.len() as u64
}
pub fn validate_size(&self, max_bytes: u64) -> StorageResult<()> {
let size = self.size();
if size > max_bytes {
return Err(StorageError::FileSizeExceeded {
actual: size,
limit: max_bytes,
});
}
Ok(())
}
pub fn validate_mime(&self, allowed_types: &[&str]) -> StorageResult<()> {
if !allowed_types.contains(&self.content_type.as_str()) {
return Err(StorageError::InvalidMimeType {
expected: allowed_types.iter().map(|s| (*s).to_string()).collect(),
actual: self.content_type.clone(),
});
}
Ok(())
}
#[must_use]
pub fn extension(&self) -> Option<&str> {
let parts: Vec<&str> = self.filename.rsplitn(2, '.').collect();
if parts.len() == 2 {
Some(parts[0])
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StoredFile {
pub id: String,
pub filename: String,
pub content_type: String,
pub size: u64,
pub storage_path: String,
}
impl StoredFile {
#[must_use]
pub fn new(
id: impl Into<String>,
filename: impl Into<String>,
content_type: impl Into<String>,
size: u64,
storage_path: impl Into<String>,
) -> Self {
Self {
id: id.into(),
filename: filename.into(),
content_type: content_type.into(),
size,
storage_path: storage_path.into(),
}
}
}
impl fmt::Display for StoredFile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"StoredFile(id={}, filename={}, size={})",
self.id, self.filename, self.size
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_uploaded_file_size() {
let file = UploadedFile::new("test.txt", "text/plain", vec![1, 2, 3, 4, 5]);
assert_eq!(file.size(), 5);
}
#[test]
fn test_validate_size_pass() {
let file = UploadedFile::new("test.txt", "text/plain", vec![1, 2, 3]);
assert!(file.validate_size(10).is_ok());
assert!(file.validate_size(3).is_ok());
}
#[test]
fn test_validate_size_fail() {
let file = UploadedFile::new("test.txt", "text/plain", vec![1, 2, 3, 4, 5]);
let result = file.validate_size(3);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
StorageError::FileSizeExceeded { actual: 5, limit: 3 }
));
}
#[test]
fn test_validate_mime_pass() {
let file = UploadedFile::new("photo.jpg", "image/jpeg", vec![]);
assert!(file.validate_mime(&["image/jpeg", "image/png"]).is_ok());
}
#[test]
fn test_validate_mime_fail() {
let file = UploadedFile::new("photo.jpg", "image/jpeg", vec![]);
let result = file.validate_mime(&["image/png", "image/gif"]);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
StorageError::InvalidMimeType { .. }
));
}
#[test]
fn test_extension() {
let file = UploadedFile::new("document.pdf", "application/pdf", vec![]);
assert_eq!(file.extension(), Some("pdf"));
let no_ext = UploadedFile::new("README", "text/plain", vec![]);
assert_eq!(no_ext.extension(), None);
let multiple_dots = UploadedFile::new("archive.tar.gz", "application/gzip", vec![]);
assert_eq!(multiple_dots.extension(), Some("gz"));
}
#[test]
fn test_stored_file_display() {
let stored = StoredFile::new("abc-123", "test.pdf", "application/pdf", 1024, "/uploads/abc-123/test.pdf");
let display = format!("{stored}");
assert!(display.contains("abc-123"));
assert!(display.contains("test.pdf"));
assert!(display.contains("1024"));
}
}