rustrails-storage 0.1.2

File storage (ActiveStorage equivalent)
Documentation
//! Filesystem-backed storage service.

use std::{
    path::{Path, PathBuf},
    time::Duration,
};

use async_trait::async_trait;
use bytes::Bytes;
use tokio::fs;
use url::Url;

use super::{StorageError, StorageService, checked_key};

/// Stores objects on the local filesystem.
#[derive(Debug, Clone)]
pub struct DiskService {
    name: String,
    root: PathBuf,
    base_url: Url,
}

impl DiskService {
    /// Creates a disk service rooted at the given path.
    ///
    /// # Errors
    ///
    /// Returns an error when the default base URL cannot be constructed.
    pub fn new(name: impl Into<String>, root: impl Into<PathBuf>) -> Result<Self, StorageError> {
        let base_url = Url::parse("http://disk.local/")
            .map_err(|error| StorageError::InvalidUrl(error.to_string()))?;
        Ok(Self {
            name: name.into(),
            root: root.into(),
            base_url,
        })
    }

    /// Returns a copy with a custom public base URL.
    #[must_use]
    pub fn with_base_url(mut self, base_url: Url) -> Self {
        self.base_url = base_url;
        self
    }

    /// Returns the configured root path.
    #[must_use]
    pub fn root(&self) -> &Path {
        &self.root
    }

    fn path_for(&self, key: &str) -> Result<PathBuf, StorageError> {
        let key = checked_key(key)?;
        let mut path = self.root.clone();
        for segment in key.split('/') {
            path.push(segment);
        }
        Ok(path)
    }

    async fn ensure_parent(&self, path: &Path) -> Result<(), StorageError> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .await
                .map_err(|source| StorageError::Io {
                    path: parent.display().to_string(),
                    source,
                })?;
        }
        Ok(())
    }
}

#[async_trait]
impl StorageService for DiskService {
    fn name(&self) -> &str {
        &self.name
    }

    async fn upload(&self, key: &str, data: Bytes) -> Result<(), StorageError> {
        let path = self.path_for(key)?;
        if fs::try_exists(&path)
            .await
            .map_err(|source| StorageError::Io {
                path: path.display().to_string(),
                source,
            })?
        {
            return Err(StorageError::DuplicateKey(key.to_owned()));
        }
        self.ensure_parent(&path).await?;
        fs::write(&path, data)
            .await
            .map_err(|source| StorageError::Io {
                path: path.display().to_string(),
                source,
            })
    }

    async fn download(&self, key: &str) -> Result<Bytes, StorageError> {
        let path = self.path_for(key)?;
        match fs::read(&path).await {
            Ok(bytes) => Ok(Bytes::from(bytes)),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                Err(StorageError::NotFound(key.to_owned()))
            }
            Err(source) => Err(StorageError::Io {
                path: path.display().to_string(),
                source,
            }),
        }
    }

    async fn delete(&self, key: &str) -> Result<(), StorageError> {
        let path = self.path_for(key)?;
        match fs::remove_file(&path).await {
            Ok(()) => Ok(()),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(source) => Err(StorageError::Io {
                path: path.display().to_string(),
                source,
            }),
        }
    }

    async fn exists(&self, key: &str) -> Result<bool, StorageError> {
        let path = self.path_for(key)?;
        fs::try_exists(&path)
            .await
            .map_err(|source| StorageError::Io {
                path: path.display().to_string(),
                source,
            })
    }

    async fn url(&self, key: &str, expires_in: Duration) -> Result<Url, StorageError> {
        let key = checked_key(key)?;
        let mut url = self.base_url.clone();
        url.set_path(key);
        url.query_pairs_mut()
            .append_pair("service", &self.name)
            .append_pair("expires_in", &expires_in.as_secs().to_string());
        Ok(url)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn test_root(name: &str) -> PathBuf {
        let mut path = std::env::temp_dir();
        path.push(format!("rustrails-storage-{name}-{}", uuid::Uuid::now_v7()));
        path
    }

    async fn service(name: &str) -> DiskService {
        let root = test_root(name);
        DiskService::new(name, root).expect("service should build")
    }

    #[tokio::test]
    async fn test_upload_and_download_round_trip() {
        let service = service("round-trip").await;
        service
            .upload("a.txt", Bytes::from_static(b"hello"))
            .await
            .expect("upload should succeed");
        let bytes = service
            .download("a.txt")
            .await
            .expect("download should succeed");
        assert_eq!(bytes, Bytes::from_static(b"hello"));
    }

    #[tokio::test]
    async fn test_upload_rejects_duplicate_keys() {
        let service = service("duplicate").await;
        service
            .upload("a.txt", Bytes::from_static(b"one"))
            .await
            .expect("upload should succeed");
        let error = service
            .upload("a.txt", Bytes::from_static(b"two"))
            .await
            .expect_err("duplicate upload should fail");
        assert!(matches!(error, StorageError::DuplicateKey(key) if key == "a.txt"));
    }

    #[tokio::test]
    async fn test_download_missing_key_returns_not_found() {
        let service = service("missing").await;
        let error = service
            .download("missing.txt")
            .await
            .expect_err("download should fail");
        assert!(matches!(error, StorageError::NotFound(key) if key == "missing.txt"));
    }

    #[tokio::test]
    async fn test_delete_removes_existing_file() {
        let service = service("delete").await;
        service
            .upload("a.txt", Bytes::from_static(b"hello"))
            .await
            .expect("upload should succeed");
        service
            .delete("a.txt")
            .await
            .expect("delete should succeed");
        assert!(
            !service
                .exists("a.txt")
                .await
                .expect("exists should succeed")
        );
    }

    #[tokio::test]
    async fn test_delete_missing_file_is_a_noop() {
        let service = service("delete-missing").await;
        service
            .delete("missing.txt")
            .await
            .expect("delete should succeed");
    }

    #[tokio::test]
    async fn test_exists_returns_true_for_uploaded_file() {
        let service = service("exists-true").await;
        service
            .upload("a.txt", Bytes::from_static(b"hello"))
            .await
            .expect("upload should succeed");
        assert!(
            service
                .exists("a.txt")
                .await
                .expect("exists should succeed")
        );
    }

    #[tokio::test]
    async fn test_exists_returns_false_for_missing_file() {
        let service = service("exists-false").await;
        assert!(
            !service
                .exists("missing.txt")
                .await
                .expect("exists should succeed")
        );
    }

    #[tokio::test]
    async fn test_upload_creates_nested_directories() {
        let service = service("nested").await;
        service
            .upload("avatars/user-1/photo.jpg", Bytes::from_static(b"hello"))
            .await
            .expect("upload should succeed");
        assert!(service.root().join("avatars/user-1/photo.jpg").exists());
    }

    #[tokio::test]
    async fn test_zero_byte_upload_is_supported() {
        let service = service("zero-byte").await;
        service
            .upload("empty.txt", Bytes::new())
            .await
            .expect("upload should succeed");
        let bytes = service
            .download("empty.txt")
            .await
            .expect("download should succeed");
        assert!(bytes.is_empty());
    }

    #[tokio::test]
    async fn test_url_contains_service_and_expiry() {
        let service = service("url").await;
        let url = service
            .url("file.txt", Duration::from_secs(120))
            .await
            .expect("url should build");
        assert_eq!(url.path(), "/file.txt");
        assert!(
            url.query()
                .expect("query should exist")
                .contains("expires_in=120")
        );
        assert!(
            url.query()
                .expect("query should exist")
                .contains("service=url")
        );
    }

    #[tokio::test]
    async fn test_custom_base_url_is_used() {
        let service = DiskService::new("disk", test_root("custom-base"))
            .expect("service should build")
            .with_base_url(Url::parse("https://cdn.example/storage/").expect("url should parse"));
        let url = service
            .url("file.txt", Duration::from_secs(60))
            .await
            .expect("url should build");
        assert_eq!(
            url.as_str(),
            "https://cdn.example/file.txt?service=disk&expires_in=60"
        );
    }

    #[tokio::test]
    async fn test_service_name_is_reported() {
        let service = service("service-name").await;
        assert_eq!(service.name(), "service-name");
    }

    #[tokio::test]
    async fn test_uploads_are_isolated_by_root() {
        let service_a = service("isolated-a").await;
        let service_b = service("isolated-b").await;
        service_a
            .upload("same.txt", Bytes::from_static(b"a"))
            .await
            .expect("upload should succeed");
        service_b
            .upload("same.txt", Bytes::from_static(b"b"))
            .await
            .expect("upload should succeed");
        assert_eq!(
            service_a
                .download("same.txt")
                .await
                .expect("download should succeed"),
            Bytes::from_static(b"a")
        );
        assert_eq!(
            service_b
                .download("same.txt")
                .await
                .expect("download should succeed"),
            Bytes::from_static(b"b")
        );
    }

    #[tokio::test]
    async fn test_delete_keeps_parent_directory_stable() {
        let service = service("parent-dir").await;
        service
            .upload("nested/file.txt", Bytes::from_static(b"hello"))
            .await
            .expect("upload should succeed");
        service
            .delete("nested/file.txt")
            .await
            .expect("delete should succeed");
        assert!(service.root().join("nested").exists());
    }

    #[tokio::test]
    async fn test_invalid_empty_key_rejected_for_upload() {
        let service = service("invalid-key").await;
        let error = service
            .upload("   ", Bytes::from_static(b"hello"))
            .await
            .expect_err("upload should fail");
        assert!(matches!(error, StorageError::InvalidUrl(_)));
    }

    #[tokio::test]
    async fn test_download_after_delete_returns_not_found() {
        let service = service("download-after-delete").await;
        service
            .upload("a.txt", Bytes::from_static(b"hello"))
            .await
            .expect("upload should succeed");
        service
            .delete("a.txt")
            .await
            .expect("delete should succeed");
        let error = service
            .download("a.txt")
            .await
            .expect_err("download should fail");
        assert!(matches!(error, StorageError::NotFound(key) if key == "a.txt"));
    }
}