Skip to main content

sova_storage/
local.rs

1//! Local filesystem [`BlobStore`].
2
3use crate::{normalize_key, normalize_prefix, BlobStore, BoxFuture, PutOpts, StorageError};
4use bytes::Bytes;
5use std::path::{Path, PathBuf};
6
7#[derive(Clone)]
8pub struct LocalStore {
9    root: PathBuf,
10}
11
12impl LocalStore {
13    pub fn new(root: impl Into<PathBuf>) -> Self {
14        Self { root: root.into() }
15    }
16
17    fn path_for(&self, key: &str) -> Result<PathBuf, StorageError> {
18        let key = normalize_key(key)?;
19        Ok(self.root.join(key))
20    }
21}
22
23fn collect_files(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), StorageError> {
24    let entries = match std::fs::read_dir(dir) {
25        Ok(e) => e,
26        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
27        Err(e) => return Err(e.into()),
28    };
29    for entry in entries {
30        let entry = entry?;
31        let path = entry.path();
32        if path.is_dir() {
33            collect_files(root, &path, out)?;
34        } else if path.is_file() {
35            let rel = path
36                .strip_prefix(root)
37                .map_err(|e| StorageError::Msg(format!("list strip_prefix: {e}")))?;
38            out.push(rel.to_string_lossy().replace('\\', "/"));
39        }
40    }
41    Ok(())
42}
43
44impl BlobStore for LocalStore {
45    fn put<'a>(
46        &'a self,
47        key: &'a str,
48        data: Bytes,
49        _opts: PutOpts,
50    ) -> BoxFuture<'a, Result<(), StorageError>> {
51        Box::pin(async move {
52            let path = self.path_for(key)?;
53            if let Some(parent) = path.parent() {
54                if !parent.as_os_str().is_empty() {
55                    tokio::fs::create_dir_all(parent).await?;
56                }
57            }
58            tokio::fs::write(&path, &data).await?;
59            Ok(())
60        })
61    }
62
63    fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<Option<Bytes>, StorageError>> {
64        Box::pin(async move {
65            let path = self.path_for(key)?;
66            match tokio::fs::read(&path).await {
67                Ok(bytes) => Ok(Some(Bytes::from(bytes))),
68                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
69                Err(e) => Err(e.into()),
70            }
71        })
72    }
73
74    fn delete<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<(), StorageError>> {
75        Box::pin(async move {
76            let path = self.path_for(key)?;
77            match tokio::fs::remove_file(&path).await {
78                Ok(()) => Ok(()),
79                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
80                Err(e) => Err(e.into()),
81            }
82        })
83    }
84
85    fn exists<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<bool, StorageError>> {
86        Box::pin(async move {
87            let path = self.path_for(key)?;
88            Ok(Path::new(&path).exists())
89        })
90    }
91
92    fn list<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result<Vec<String>, StorageError>> {
93        Box::pin(async move {
94            let prefix = normalize_prefix(prefix)?;
95            let start = if prefix.is_empty() {
96                self.root.clone()
97            } else {
98                self.root.join(&prefix)
99            };
100            let mut keys = Vec::new();
101            if start.is_file() {
102                keys.push(prefix);
103                return Ok(keys);
104            }
105            collect_files(&self.root, &start, &mut keys)?;
106            // When prefix is a dir without trailing content yet, also accept keys under prefix/
107            if !prefix.is_empty() && start.is_dir() {
108                // already collected under start
109            } else if !prefix.is_empty() && !start.exists() {
110                // prefix may be "a" matching files "a/b" — walk root and filter
111                keys.clear();
112                collect_files(&self.root, &self.root, &mut keys)?;
113                keys.retain(|k| k == &prefix || k.starts_with(&format!("{prefix}/")));
114            }
115            keys.sort();
116            Ok(keys)
117        })
118    }
119}