fraiseql_storage/backend/
local.rs1use std::{path::PathBuf, time::Duration};
4
5use fraiseql_error::{FileError, FraiseQLError, Result};
6
7use super::{
8 types::{ListResult, ObjectInfo},
9 validate_key,
10};
11
12pub struct LocalBackend {
14 root: PathBuf,
15}
16
17impl LocalBackend {
18 #[must_use]
20 pub fn new(root: &str) -> Self {
21 Self {
22 root: PathBuf::from(root),
23 }
24 }
25
26 fn key_path(&self, key: &str) -> Result<PathBuf> {
27 validate_key(key)?;
28 Ok(self.root.join(key))
29 }
30
31 pub async fn upload(&self, key: &str, data: &[u8], _content_type: &str) -> Result<String> {
37 let path = self.key_path(key)?;
38 if let Some(parent) = path.parent() {
39 tokio::fs::create_dir_all(parent).await.map_err(|e| {
40 FraiseQLError::File(FileError::IoError {
41 message: format!("Failed to create directory: {e}"),
42 source: Some(Box::new(e)),
43 })
44 })?;
45 }
46 tokio::fs::write(&path, data).await.map_err(|e| {
47 FraiseQLError::File(FileError::IoError {
48 message: format!("Failed to write file: {e}"),
49 source: Some(Box::new(e)),
50 })
51 })?;
52 Ok(key.to_string())
53 }
54
55 pub async fn download(&self, key: &str) -> Result<Vec<u8>> {
62 let path = self.key_path(key)?;
63 tokio::fs::read(&path).await.map_err(|e| {
64 if e.kind() == std::io::ErrorKind::NotFound {
65 FraiseQLError::File(FileError::NotFound {
66 id: key.to_string(),
67 })
68 } else {
69 FraiseQLError::File(FileError::IoError {
70 message: format!("Failed to read file: {e}"),
71 source: Some(Box::new(e)),
72 })
73 }
74 })
75 }
76
77 pub async fn delete(&self, key: &str) -> Result<()> {
83 let path = self.key_path(key)?;
84 tokio::fs::remove_file(&path).await.map_err(|e| {
85 if e.kind() == std::io::ErrorKind::NotFound {
86 FraiseQLError::File(FileError::NotFound {
87 id: key.to_string(),
88 })
89 } else {
90 FraiseQLError::File(FileError::IoError {
91 message: format!("Failed to delete file: {e}"),
92 source: Some(Box::new(e)),
93 })
94 }
95 })
96 }
97
98 pub async fn exists(&self, key: &str) -> Result<bool> {
104 let path = self.key_path(key)?;
105 match tokio::fs::metadata(&path).await {
106 Ok(_) => Ok(true),
107 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
108 Err(e) => Err(FraiseQLError::File(FileError::IoError {
109 message: format!("Failed to check file existence: {e}"),
110 source: Some(Box::new(e)),
111 })),
112 }
113 }
114
115 pub async fn presigned_url(&self, _key: &str, _expiry: Duration) -> Result<String> {
122 Err(FraiseQLError::File(FileError::Unsupported {
123 message: "Presigned URLs are not supported for local storage".to_string(),
124 }))
125 }
126
127 pub async fn list(
133 &self,
134 prefix: &str,
135 cursor: Option<&str>,
136 limit: usize,
137 ) -> Result<ListResult> {
138 let mut objects = Vec::new();
140 let prefix_path = self.root.join(prefix);
141
142 if !prefix_path.exists() {
144 return Ok(ListResult {
145 objects: Vec::new(),
146 next_cursor: None,
147 });
148 }
149
150 for entry in walkdir::WalkDir::new(&prefix_path)
152 .into_iter()
153 .filter_map(|e| e.ok())
154 .filter(|e| e.file_type().is_file())
155 {
156 let full_path = entry.path();
157 let relative_path = full_path
158 .strip_prefix(&self.root)
159 .map_err(|_| {
160 FraiseQLError::File(FileError::IoError {
161 message: "Failed to compute relative path".to_string(),
162 source: None,
163 })
164 })?
165 .to_string_lossy()
166 .into_owned();
167
168 let key = relative_path.replace('\\', "/");
170
171 let metadata = tokio::fs::metadata(full_path).await.map_err(|e| {
173 FraiseQLError::File(FileError::IoError {
174 message: format!("Failed to read file metadata: {e}"),
175 source: Some(Box::new(e)),
176 })
177 })?;
178
179 let size = metadata.len();
180 let last_modified = metadata
181 .modified()
182 .ok()
183 .and_then(|t| {
184 let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
185 #[allow(clippy::cast_possible_wrap)]
188 let secs = duration.as_secs() as i64;
189 chrono::DateTime::from_timestamp(secs, duration.subsec_nanos())
190 })
191 .map_or_else(|| chrono::Utc::now().to_rfc3339(), |dt| dt.to_rfc3339());
192
193 let etag = format!("{:x}", fnv1a_hash(&format!("{size}-{last_modified}")));
195
196 objects.push((
197 key.clone(),
198 ObjectInfo {
199 key,
200 size,
201 content_type: "application/octet-stream".to_string(), etag,
204 last_modified,
205 },
206 ));
207 }
208
209 objects.sort_by(|a, b| a.0.cmp(&b.0));
211
212 let start_idx = if let Some(c) = cursor {
214 objects.iter().position(|(k, _)| k == c).map_or(0, |i| i + 1)
215 } else {
216 0
217 };
218
219 let end_idx = (start_idx + limit).min(objects.len());
220 let page: Vec<ObjectInfo> = objects
225 .get(start_idx..end_idx)
226 .unwrap_or(&[])
227 .iter()
228 .map(|(_, info)| info.clone())
229 .collect();
230
231 let next_cursor = if end_idx < objects.len() {
232 page.last().map(|o| o.key.clone())
233 } else {
234 None
235 };
236
237 Ok(ListResult {
238 objects: page,
239 next_cursor,
240 })
241 }
242}
243
244fn fnv1a_hash(data: &str) -> u64 {
246 const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
247 const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
248
249 let mut hash = FNV_OFFSET_BASIS;
250 for byte in data.bytes() {
251 hash ^= u64::from(byte);
252 hash = hash.wrapping_mul(FNV_PRIME);
253 }
254 hash
255}