shaperail_runtime/storage/
local.rs1use super::backend::{FileMetadata, StorageError};
2use std::path::PathBuf;
3
4pub struct LocalStorage {
8 root: PathBuf,
9}
10
11impl LocalStorage {
12 pub fn new(root: PathBuf) -> Self {
14 Self { root }
15 }
16
17 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 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}