Skip to main content

shaperail_runtime/storage/
local.rs

1use super::backend::{FileMetadata, StorageError};
2use std::path::PathBuf;
3
4/// Local filesystem storage backend (default for development).
5///
6/// Files are stored under `SHAPERAIL_STORAGE_LOCAL_DIR` (default: `./uploads`).
7pub struct LocalStorage {
8    root: PathBuf,
9}
10
11impl LocalStorage {
12    /// Create a new local storage backend at the given root directory.
13    pub fn new(root: PathBuf) -> Self {
14        Self { root }
15    }
16
17    /// Create from env var `SHAPERAIL_STORAGE_LOCAL_DIR`, defaulting to `./uploads`.
18    pub fn from_env() -> Self {
19        let dir = std::env::var("SHAPERAIL_STORAGE_LOCAL_DIR")
20            .unwrap_or_else(|_| "./uploads".to_string());
21        Self::new(PathBuf::from(dir))
22    }
23
24    fn full_path(&self, path: &str) -> PathBuf {
25        self.root.join(path)
26    }
27
28    pub async fn upload(
29        &self,
30        path: &str,
31        data: &[u8],
32        mime_type: &str,
33    ) -> Result<FileMetadata, StorageError> {
34        let full = self.full_path(path);
35        if let Some(parent) = full.parent() {
36            tokio::fs::create_dir_all(parent)
37                .await
38                .map_err(|e| StorageError::Backend(format!("Failed to create directory: {e}")))?;
39        }
40        tokio::fs::write(&full, data)
41            .await
42            .map_err(|e| StorageError::Backend(format!("Failed to write file: {e}")))?;
43
44        let filename = full
45            .file_name()
46            .map(|n| n.to_string_lossy().to_string())
47            .unwrap_or_default();
48
49        Ok(FileMetadata {
50            path: path.to_string(),
51            filename,
52            mime_type: mime_type.to_string(),
53            size: data.len() as u64,
54        })
55    }
56
57    pub async fn download(&self, path: &str) -> Result<Vec<u8>, StorageError> {
58        let full = self.full_path(path);
59        tokio::fs::read(&full).await.map_err(|e| {
60            if e.kind() == std::io::ErrorKind::NotFound {
61                StorageError::NotFound(path.to_string())
62            } else {
63                StorageError::Backend(format!("Failed to read file: {e}"))
64            }
65        })
66    }
67
68    pub async fn delete(&self, path: &str) -> Result<(), StorageError> {
69        let full = self.full_path(path);
70        tokio::fs::remove_file(&full).await.map_err(|e| {
71            if e.kind() == std::io::ErrorKind::NotFound {
72                StorageError::NotFound(path.to_string())
73            } else {
74                StorageError::Backend(format!("Failed to delete file: {e}"))
75            }
76        })
77    }
78
79    /// For local storage, signed URLs return a `file://` path.
80    /// In production, use S3/GCS/Azure for proper signed URLs.
81    pub async fn signed_url(&self, path: &str, _expires_secs: u64) -> Result<String, StorageError> {
82        let full = self.full_path(path);
83        if !full.exists() {
84            return Err(StorageError::NotFound(path.to_string()));
85        }
86        Ok(format!("file://{}", full.display()))
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use tempfile::TempDir;
94
95    fn test_storage() -> (LocalStorage, TempDir) {
96        let dir = TempDir::new().unwrap();
97        let storage = LocalStorage::new(dir.path().to_path_buf());
98        (storage, dir)
99    }
100
101    #[tokio::test]
102    async fn upload_and_download() {
103        let (storage, _dir) = test_storage();
104        let data = b"hello world";
105
106        let meta = storage
107            .upload("test/file.txt", data, "text/plain")
108            .await
109            .unwrap();
110
111        assert_eq!(meta.path, "test/file.txt");
112        assert_eq!(meta.filename, "file.txt");
113        assert_eq!(meta.mime_type, "text/plain");
114        assert_eq!(meta.size, 11);
115
116        let downloaded = storage.download("test/file.txt").await.unwrap();
117        assert_eq!(downloaded, data);
118    }
119
120    #[tokio::test]
121    async fn delete_file() {
122        let (storage, _dir) = test_storage();
123
124        storage
125            .upload("to_delete.txt", b"data", "text/plain")
126            .await
127            .unwrap();
128
129        storage.delete("to_delete.txt").await.unwrap();
130
131        let result = storage.download("to_delete.txt").await;
132        assert!(matches!(result, Err(StorageError::NotFound(_))));
133    }
134
135    #[tokio::test]
136    async fn download_not_found() {
137        let (storage, _dir) = test_storage();
138        let result = storage.download("nonexistent.txt").await;
139        assert!(matches!(result, Err(StorageError::NotFound(_))));
140    }
141
142    #[tokio::test]
143    async fn signed_url_local() {
144        let (storage, _dir) = test_storage();
145
146        storage
147            .upload("signed.txt", b"data", "text/plain")
148            .await
149            .unwrap();
150
151        let url = storage.signed_url("signed.txt", 3600).await.unwrap();
152        assert!(url.starts_with("file://"));
153    }
154
155    #[tokio::test]
156    async fn signed_url_not_found() {
157        let (storage, _dir) = test_storage();
158        let result = storage.signed_url("missing.txt", 3600).await;
159        assert!(matches!(result, Err(StorageError::NotFound(_))));
160    }
161}