Skip to main content

ferro_storage/drivers/
local.rs

1//! Local filesystem storage driver.
2
3use crate::storage::{FileMetadata, PutOptions, StorageDriver};
4use crate::Error;
5use async_trait::async_trait;
6use bytes::Bytes;
7use std::path::{Path, PathBuf};
8use tokio::fs;
9use tracing::debug;
10
11/// Local filesystem storage driver.
12pub struct LocalDriver {
13    /// Base path for storage.
14    root: PathBuf,
15    /// Base URL for public files.
16    url_base: Option<String>,
17}
18
19impl LocalDriver {
20    /// Create a new local driver.
21    pub fn new(root: impl AsRef<Path>) -> Self {
22        Self {
23            root: root.as_ref().to_path_buf(),
24            url_base: None,
25        }
26    }
27
28    /// Set the base URL for public files.
29    pub fn with_url_base(mut self, url: impl Into<String>) -> Self {
30        self.url_base = Some(url.into());
31        self
32    }
33
34    /// Get the full path for a relative path.
35    fn full_path(&self, path: &str) -> PathBuf {
36        self.root.join(path)
37    }
38
39    /// Ensure parent directory exists.
40    async fn ensure_directory(&self, path: &Path) -> Result<(), Error> {
41        if let Some(parent) = path.parent() {
42            fs::create_dir_all(parent).await?;
43        }
44        Ok(())
45    }
46}
47
48#[async_trait]
49impl StorageDriver for LocalDriver {
50    async fn exists(&self, path: &str) -> Result<bool, Error> {
51        let full_path = self.full_path(path);
52        Ok(full_path.exists())
53    }
54
55    async fn get(&self, path: &str) -> Result<Bytes, Error> {
56        let full_path = self.full_path(path);
57        debug!(path = %full_path.display(), "Reading file");
58
59        let contents = fs::read(&full_path).await.map_err(|e| {
60            if e.kind() == std::io::ErrorKind::NotFound {
61                Error::not_found(path)
62            } else {
63                Error::from(e)
64            }
65        })?;
66
67        Ok(Bytes::from(contents))
68    }
69
70    async fn put(&self, path: &str, contents: Bytes, _options: PutOptions) -> Result<(), Error> {
71        let full_path = self.full_path(path);
72        debug!(path = %full_path.display(), "Writing file");
73
74        self.ensure_directory(&full_path).await?;
75        fs::write(&full_path, &contents).await?;
76
77        Ok(())
78    }
79
80    async fn delete(&self, path: &str) -> Result<(), Error> {
81        let full_path = self.full_path(path);
82        debug!(path = %full_path.display(), "Deleting file");
83
84        fs::remove_file(&full_path).await.map_err(|e| {
85            if e.kind() == std::io::ErrorKind::NotFound {
86                Error::not_found(path)
87            } else {
88                Error::from(e)
89            }
90        })
91    }
92
93    async fn copy(&self, from: &str, to: &str) -> Result<(), Error> {
94        let from_path = self.full_path(from);
95        let to_path = self.full_path(to);
96
97        debug!(from = %from_path.display(), to = %to_path.display(), "Copying file");
98
99        self.ensure_directory(&to_path).await?;
100        fs::copy(&from_path, &to_path).await.map_err(|e| {
101            if e.kind() == std::io::ErrorKind::NotFound {
102                Error::not_found(from)
103            } else {
104                Error::from(e)
105            }
106        })?;
107
108        Ok(())
109    }
110
111    async fn size(&self, path: &str) -> Result<u64, Error> {
112        let full_path = self.full_path(path);
113        let metadata = fs::metadata(&full_path).await.map_err(|e| {
114            if e.kind() == std::io::ErrorKind::NotFound {
115                Error::not_found(path)
116            } else {
117                Error::from(e)
118            }
119        })?;
120
121        Ok(metadata.len())
122    }
123
124    async fn metadata(&self, path: &str) -> Result<FileMetadata, Error> {
125        let full_path = self.full_path(path);
126        let fs_meta = fs::metadata(&full_path).await.map_err(|e| {
127            if e.kind() == std::io::ErrorKind::NotFound {
128                Error::not_found(path)
129            } else {
130                Error::from(e)
131            }
132        })?;
133
134        let mime_type = mime_guess::from_path(&full_path)
135            .first()
136            .map(|m| m.to_string());
137
138        let mut meta = FileMetadata::new(path, fs_meta.len());
139
140        if let Ok(modified) = fs_meta.modified() {
141            meta = meta.with_last_modified(modified);
142        }
143
144        if let Some(mime) = mime_type {
145            meta = meta.with_mime_type(mime);
146        }
147
148        Ok(meta)
149    }
150
151    async fn url(&self, path: &str) -> Result<String, Error> {
152        match &self.url_base {
153            Some(base) => Ok(format!("{}/{}", base.trim_end_matches('/'), path)),
154            None => Ok(self.full_path(path).to_string_lossy().to_string()),
155        }
156    }
157
158    async fn temporary_url(
159        &self,
160        path: &str,
161        _expiration: std::time::Duration,
162    ) -> Result<String, Error> {
163        // Local storage doesn't support temporary URLs, just return the regular URL
164        self.url(path).await
165    }
166
167    async fn files(&self, directory: &str) -> Result<Vec<String>, Error> {
168        let full_path = self.full_path(directory);
169        let mut files = Vec::new();
170
171        if !full_path.exists() {
172            return Ok(files);
173        }
174
175        let mut entries = fs::read_dir(&full_path).await?;
176        while let Some(entry) = entries.next_entry().await? {
177            let path = entry.path();
178            if path.is_file() {
179                if let Some(name) = path.file_name() {
180                    files.push(name.to_string_lossy().to_string());
181                }
182            }
183        }
184
185        Ok(files)
186    }
187
188    async fn all_files(&self, directory: &str) -> Result<Vec<String>, Error> {
189        let full_path = self.full_path(directory);
190        let mut files = Vec::new();
191
192        if !full_path.exists() {
193            return Ok(files);
194        }
195
196        self.collect_files_recursive(&full_path, &full_path, &mut files)
197            .await?;
198        Ok(files)
199    }
200
201    async fn directories(&self, directory: &str) -> Result<Vec<String>, Error> {
202        let full_path = self.full_path(directory);
203        let mut dirs = Vec::new();
204
205        if !full_path.exists() {
206            return Ok(dirs);
207        }
208
209        let mut entries = fs::read_dir(&full_path).await?;
210        while let Some(entry) = entries.next_entry().await? {
211            let path = entry.path();
212            if path.is_dir() {
213                if let Some(name) = path.file_name() {
214                    dirs.push(name.to_string_lossy().to_string());
215                }
216            }
217        }
218
219        Ok(dirs)
220    }
221
222    async fn make_directory(&self, path: &str) -> Result<(), Error> {
223        let full_path = self.full_path(path);
224        fs::create_dir_all(&full_path).await?;
225        Ok(())
226    }
227
228    async fn delete_directory(&self, path: &str) -> Result<(), Error> {
229        let full_path = self.full_path(path);
230        fs::remove_dir_all(&full_path).await.map_err(|e| {
231            if e.kind() == std::io::ErrorKind::NotFound {
232                Error::not_found(path)
233            } else {
234                Error::from(e)
235            }
236        })
237    }
238}
239
240impl LocalDriver {
241    #[allow(clippy::only_used_in_recursion)]
242    fn collect_files_recursive<'a>(
243        &'a self,
244        base: &'a Path,
245        current: &'a Path,
246        files: &'a mut Vec<String>,
247    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + 'a>> {
248        Box::pin(async move {
249            let mut entries = fs::read_dir(current).await?;
250            while let Some(entry) = entries.next_entry().await? {
251                let path = entry.path();
252                if path.is_file() {
253                    if let Ok(relative) = path.strip_prefix(base) {
254                        files.push(relative.to_string_lossy().to_string());
255                    }
256                } else if path.is_dir() {
257                    self.collect_files_recursive(base, &path, files).await?;
258                }
259            }
260            Ok(())
261        })
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[tokio::test]
270    async fn test_local_driver_put_get() {
271        let temp_dir = tempfile::tempdir().unwrap();
272        let driver = LocalDriver::new(temp_dir.path());
273
274        driver
275            .put("test.txt", Bytes::from("hello world"), PutOptions::new())
276            .await
277            .unwrap();
278
279        let contents = driver.get("test.txt").await.unwrap();
280        assert_eq!(contents, Bytes::from("hello world"));
281    }
282
283    #[tokio::test]
284    async fn test_local_driver_exists() {
285        let temp_dir = tempfile::tempdir().unwrap();
286        let driver = LocalDriver::new(temp_dir.path());
287
288        assert!(!driver.exists("missing.txt").await.unwrap());
289
290        driver
291            .put("exists.txt", Bytes::from("data"), PutOptions::new())
292            .await
293            .unwrap();
294
295        assert!(driver.exists("exists.txt").await.unwrap());
296    }
297
298    #[tokio::test]
299    async fn test_local_driver_delete() {
300        let temp_dir = tempfile::tempdir().unwrap();
301        let driver = LocalDriver::new(temp_dir.path());
302
303        driver
304            .put("to_delete.txt", Bytes::from("data"), PutOptions::new())
305            .await
306            .unwrap();
307
308        driver.delete("to_delete.txt").await.unwrap();
309        assert!(!driver.exists("to_delete.txt").await.unwrap());
310    }
311
312    #[tokio::test]
313    async fn test_local_driver_copy() {
314        let temp_dir = tempfile::tempdir().unwrap();
315        let driver = LocalDriver::new(temp_dir.path());
316
317        driver
318            .put(
319                "original.txt",
320                Bytes::from("original content"),
321                PutOptions::new(),
322            )
323            .await
324            .unwrap();
325
326        driver.copy("original.txt", "copy.txt").await.unwrap();
327
328        let contents = driver.get("copy.txt").await.unwrap();
329        assert_eq!(contents, Bytes::from("original content"));
330    }
331
332    #[tokio::test]
333    async fn test_local_driver_nested_directories() {
334        let temp_dir = tempfile::tempdir().unwrap();
335        let driver = LocalDriver::new(temp_dir.path());
336
337        driver
338            .put(
339                "a/b/c/deep.txt",
340                Bytes::from("deep content"),
341                PutOptions::new(),
342            )
343            .await
344            .unwrap();
345
346        let contents = driver.get("a/b/c/deep.txt").await.unwrap();
347        assert_eq!(contents, Bytes::from("deep content"));
348    }
349
350    #[tokio::test]
351    async fn test_local_driver_url() {
352        let temp_dir = tempfile::tempdir().unwrap();
353        let driver = LocalDriver::new(temp_dir.path()).with_url_base("https://example.com/storage");
354
355        let url = driver.url("images/photo.jpg").await.unwrap();
356        assert_eq!(url, "https://example.com/storage/images/photo.jpg");
357    }
358}