use super::traits::FileStorage;
use super::types::{StorageError, StorageResult, StoredFile, UploadedFile};
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct LocalFileStorage {
base_path: PathBuf,
}
impl LocalFileStorage {
pub fn new(base_path: PathBuf) -> StorageResult<Self> {
if base_path.exists() && !base_path.is_dir() {
return Err(StorageError::InvalidPath(format!(
"{} is not a directory",
base_path.display()
)));
}
Ok(Self { base_path })
}
fn get_file_directory(&self, id: &str) -> PathBuf {
let prefix = &id[..2.min(id.len())];
self.base_path.join(prefix).join(id)
}
fn get_file_path(&self, id: &str, filename: &str) -> PathBuf {
self.get_file_directory(id).join(filename)
}
fn get_metadata_path(&self, id: &str) -> PathBuf {
self.get_file_directory(id).join(".metadata.json")
}
async fn ensure_directory(&self, path: &Path) -> StorageResult<()> {
fs::create_dir_all(path).await?;
Ok(())
}
}
#[async_trait]
impl FileStorage for LocalFileStorage {
async fn store(&self, file: UploadedFile) -> StorageResult<StoredFile> {
let id = Uuid::new_v4().to_string();
let dir = self.get_file_directory(&id);
self.ensure_directory(&dir).await?;
let file_path = self.get_file_path(&id, &file.filename);
let mut f = fs::File::create(&file_path).await?;
f.write_all(&file.data).await?;
f.flush().await?;
let stored = StoredFile {
id: id.clone(),
filename: file.filename.clone(),
content_type: file.content_type.clone(),
size: file.size(),
storage_path: file_path.to_string_lossy().to_string(),
};
let metadata_path = self.get_metadata_path(&id);
let metadata_json = serde_json::to_string_pretty(&stored)
.map_err(|e| StorageError::Other(format!("Failed to serialize metadata: {e}")))?;
fs::write(&metadata_path, metadata_json).await?;
Ok(stored)
}
async fn retrieve(&self, id: &str) -> StorageResult<Vec<u8>> {
let dir = self.get_file_directory(id);
if !dir.exists() {
return Err(StorageError::NotFound(id.to_string()));
}
let mut entries = fs::read_dir(&dir).await?;
while let Some(entry) = entries.next_entry().await? {
let file_path = entry.path();
if let Ok(metadata) = entry.metadata().await {
if metadata.is_file() {
if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
if !name.starts_with('.') {
let data = fs::read(&file_path).await?;
return Ok(data);
}
}
}
}
}
Err(StorageError::NotFound(id.to_string()))
}
async fn delete(&self, id: &str) -> StorageResult<()> {
let dir = self.get_file_directory(id);
if dir.exists() {
fs::remove_dir_all(&dir).await?;
}
Ok(())
}
async fn url(&self, id: &str) -> StorageResult<String> {
let dir = self.get_file_directory(id);
if !dir.exists() {
return Err(StorageError::NotFound(id.to_string()));
}
let mut entries = fs::read_dir(&dir).await?;
while let Some(entry) = entries.next_entry().await? {
let file_path = entry.path();
if let Ok(metadata) = entry.metadata().await {
if metadata.is_file() {
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| StorageError::InvalidPath(format!("Invalid filename in {id}")))?;
if !filename.starts_with('.') {
let prefix = &id[..2.min(id.len())];
return Ok(format!("/uploads/{prefix}/{id}/{filename}"));
}
}
}
}
Err(StorageError::NotFound(id.to_string()))
}
async fn exists(&self, id: &str) -> StorageResult<bool> {
let dir = self.get_file_directory(id);
Ok(dir.exists())
}
async fn get_metadata(&self, id: &str) -> StorageResult<StoredFile> {
let metadata_path = self.get_metadata_path(id);
if !metadata_path.exists() {
return Err(StorageError::NotFound(id.to_string()));
}
let metadata_json = fs::read_to_string(&metadata_path).await?;
let stored: StoredFile = serde_json::from_str(&metadata_json)
.map_err(|e| StorageError::Other(format!("Failed to parse metadata: {e}")))?;
Ok(stored)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_storage() -> (LocalFileStorage, TempDir) {
let temp_dir = TempDir::new().unwrap();
let storage = LocalFileStorage::new(temp_dir.path().to_path_buf()).unwrap();
(storage, temp_dir)
}
#[tokio::test]
async fn test_store_and_retrieve() {
let (storage, _temp) = create_test_storage();
let file = UploadedFile::new("test.txt", "text/plain", b"Hello, World!".to_vec());
let stored = storage.store(file).await.unwrap();
assert!(!stored.id.is_empty());
assert_eq!(stored.filename, "test.txt");
assert_eq!(stored.size, 13);
let data = storage.retrieve(&stored.id).await.unwrap();
assert_eq!(data, b"Hello, World!");
}
#[tokio::test]
async fn test_delete() {
let (storage, _temp) = create_test_storage();
let file = UploadedFile::new("test.txt", "text/plain", b"Test".to_vec());
let stored = storage.store(file).await.unwrap();
assert!(storage.exists(&stored.id).await.unwrap());
storage.delete(&stored.id).await.unwrap();
assert!(!storage.exists(&stored.id).await.unwrap());
storage.delete(&stored.id).await.unwrap();
}
#[tokio::test]
async fn test_url_generation() {
let (storage, _temp) = create_test_storage();
let file = UploadedFile::new("photo.jpg", "image/jpeg", b"fake image".to_vec());
let stored = storage.store(file).await.unwrap();
let url = storage.url(&stored.id).await.unwrap();
assert!(url.starts_with("/uploads/"));
assert!(url.contains(&stored.id));
assert!(url.ends_with("/photo.jpg"));
}
#[tokio::test]
async fn test_retrieve_nonexistent() {
let (storage, _temp) = create_test_storage();
let result = storage.retrieve("nonexistent-id").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), StorageError::NotFound(_)));
}
#[tokio::test]
async fn test_exists() {
let (storage, _temp) = create_test_storage();
assert!(!storage.exists("nonexistent-id").await.unwrap());
let file = UploadedFile::new("test.txt", "text/plain", b"Test".to_vec());
let stored = storage.store(file).await.unwrap();
assert!(storage.exists(&stored.id).await.unwrap());
}
#[tokio::test]
async fn test_directory_structure() {
let (storage, temp) = create_test_storage();
let file = UploadedFile::new("test.txt", "text/plain", b"Test".to_vec());
let stored = storage.store(file).await.unwrap();
let prefix = &stored.id[..2];
let expected_path = temp.path().join(prefix).join(&stored.id).join("test.txt");
assert!(expected_path.exists());
}
#[tokio::test]
async fn test_invalid_base_path() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("not-a-directory");
std::fs::write(&file_path, b"test").unwrap();
let result = LocalFileStorage::new(file_path);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
}
#[tokio::test]
async fn test_get_metadata() {
let (storage, _temp) = create_test_storage();
let file = UploadedFile::new("document.pdf", "application/pdf", b"fake pdf".to_vec());
let stored = storage.store(file).await.unwrap();
let metadata = storage.get_metadata(&stored.id).await.unwrap();
assert_eq!(metadata.id, stored.id);
assert_eq!(metadata.filename, "document.pdf");
assert_eq!(metadata.content_type, "application/pdf");
assert_eq!(metadata.size, 8);
}
#[tokio::test]
async fn test_get_metadata_preserves_content_type() {
let (storage, _temp) = create_test_storage();
let test_cases = vec![
("image.png", "image/png"),
("video.mp4", "video/mp4"),
("document.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
("data.json", "application/json"),
];
for (filename, content_type) in test_cases {
let file = UploadedFile::new(filename, content_type, b"test data".to_vec());
let stored = storage.store(file).await.unwrap();
let metadata = storage.get_metadata(&stored.id).await.unwrap();
assert_eq!(metadata.content_type, content_type, "Content type mismatch for {filename}");
assert_eq!(metadata.filename, filename);
}
}
#[tokio::test]
async fn test_get_metadata_nonexistent() {
let (storage, _temp) = create_test_storage();
let result = storage.get_metadata("nonexistent-id").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), StorageError::NotFound(_)));
}
#[tokio::test]
async fn test_metadata_file_created() {
let (storage, _temp) = create_test_storage();
let file = UploadedFile::new("test.txt", "text/plain", b"Hello".to_vec());
let stored = storage.store(file).await.unwrap();
let metadata_path = storage.get_metadata_path(&stored.id);
assert!(metadata_path.exists(), "Metadata file should exist");
let metadata_json = std::fs::read_to_string(&metadata_path).unwrap();
let metadata: StoredFile = serde_json::from_str(&metadata_json).unwrap();
assert_eq!(metadata.id, stored.id);
assert_eq!(metadata.filename, "test.txt");
assert_eq!(metadata.content_type, "text/plain");
}
}