1use crate::error::StorageError;
2use crate::storage::Storage;
3use async_trait::async_trait;
4use std::path::PathBuf;
5
6pub struct LocalStorage {
7 pub base_path: String,
8}
9
10impl LocalStorage {
11 pub fn new(base_path: impl Into<String>) -> Self {
12 Self {
13 base_path: base_path.into(),
14 }
15 }
16
17 pub fn full_path(&self, key: &str) -> PathBuf {
18 PathBuf::from(&self.base_path).join(key)
19 }
20}
21
22#[async_trait]
23impl Storage for LocalStorage {
24 async fn put(
25 &self,
26 key: &str,
27 data: &[u8],
28 _content_type: &str,
29 ) -> Result<String, StorageError> {
30 let path = self.full_path(key);
31 if let Some(parent) = path.parent() {
32 tokio::fs::create_dir_all(parent).await?;
33 }
34 tokio::fs::write(&path, data).await?;
35 Ok(format!("local://{}", key))
36 }
37
38 async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
39 let path = self.full_path(key);
40 if !path.exists() {
41 return Err(StorageError::NotFound(key.to_string()));
42 }
43 tokio::fs::read(&path).await.map_err(StorageError::from)
44 }
45
46 async fn delete(&self, key: &str) -> Result<(), StorageError> {
47 let path = self.full_path(key);
48 if path.exists() {
49 tokio::fs::remove_file(&path).await?;
50 }
51 Ok(())
52 }
53
54 async fn exists(&self, key: &str) -> Result<bool, StorageError> {
55 Ok(self.full_path(key).exists())
56 }
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62
63 fn temp_dir() -> std::path::PathBuf {
64 let nanos = std::time::SystemTime::now()
65 .duration_since(std::time::UNIX_EPOCH)
66 .unwrap_or_default()
67 .as_nanos();
68 std::env::temp_dir().join(format!("local_storage_test_{:x}", nanos))
69 }
70
71 #[tokio::test]
72 async fn test_local_put_and_get() {
73 let dir = temp_dir();
74 let storage = LocalStorage::new(dir.to_string_lossy());
75
76 storage
77 .put("file.txt", b"hello", "text/plain")
78 .await
79 .unwrap();
80 let data = storage.get("file.txt").await.unwrap();
81 assert_eq!(data, b"hello");
82
83 tokio::fs::remove_dir_all(&dir).await.ok();
84 }
85
86 #[tokio::test]
87 async fn test_local_get_not_found() {
88 let dir = temp_dir();
89 let storage = LocalStorage::new(dir.to_string_lossy());
90
91 let result = storage.get("missing.txt").await;
92 assert!(result.is_err());
93 assert!(matches!(result.unwrap_err(), StorageError::NotFound(_)));
94
95 tokio::fs::remove_dir_all(&dir).await.ok();
96 }
97
98 #[tokio::test]
99 async fn test_local_delete() {
100 let dir = temp_dir();
101 let storage = LocalStorage::new(dir.to_string_lossy());
102
103 storage
104 .put("delete.txt", b"data", "text/plain")
105 .await
106 .unwrap();
107 assert!(storage.exists("delete.txt").await.unwrap());
108
109 storage.delete("delete.txt").await.unwrap();
110 assert!(!storage.exists("delete.txt").await.unwrap());
111
112 tokio::fs::remove_dir_all(&dir).await.ok();
113 }
114
115 #[tokio::test]
116 async fn test_local_exists_false_for_missing() {
117 let dir = temp_dir();
118 let storage = LocalStorage::new(dir.to_string_lossy());
119 assert!(!storage.exists("nope.txt").await.unwrap());
120 tokio::fs::remove_dir_all(&dir).await.ok();
121 }
122
123 #[tokio::test]
124 async fn test_local_creates_subdirectories() {
125 let dir = temp_dir();
126 let storage = LocalStorage::new(dir.to_string_lossy());
127
128 storage
129 .put("nested/deep/file.txt", b"nested", "text/plain")
130 .await
131 .unwrap();
132 let data = storage.get("nested/deep/file.txt").await.unwrap();
133 assert_eq!(data, b"nested");
134
135 tokio::fs::remove_dir_all(&dir).await.ok();
136 }
137
138 #[tokio::test]
139 async fn test_local_put_returns_url() {
140 let dir = temp_dir();
141 let storage = LocalStorage::new(dir.to_string_lossy());
142
143 let url = storage.put("url.txt", b"data", "text/plain").await.unwrap();
144 assert!(url.starts_with("local://"));
145 assert!(url.contains("url.txt"));
146
147 tokio::fs::remove_dir_all(&dir).await.ok();
148 }
149
150 #[tokio::test]
151 async fn test_local_overwrite() {
152 let dir = temp_dir();
153 let storage = LocalStorage::new(dir.to_string_lossy());
154
155 storage
156 .put("overwrite.txt", b"v1", "text/plain")
157 .await
158 .unwrap();
159 storage
160 .put("overwrite.txt", b"v2", "text/plain")
161 .await
162 .unwrap();
163 let data = storage.get("overwrite.txt").await.unwrap();
164 assert_eq!(data, b"v2");
165
166 tokio::fs::remove_dir_all(&dir).await.ok();
167 }
168}