1#![deny(missing_docs)]
2use async_trait::async_trait;
9use layer0::effect::Scope;
10use layer0::error::StateError;
11use layer0::state::{SearchResult, StateStore};
12use std::path::{Path, PathBuf};
13
14pub struct FsStore {
26 root: PathBuf,
27}
28
29impl FsStore {
30 pub fn new(root: &Path) -> Self {
34 Self {
35 root: root.to_path_buf(),
36 }
37 }
38}
39
40fn scope_dir_name(scope: &Scope) -> String {
42 let json = serde_json::to_string(scope).unwrap_or_else(|_| "unknown".into());
45 let mut hash: u64 = 5381;
47 for byte in json.as_bytes() {
48 hash = hash.wrapping_mul(33).wrapping_add(*byte as u64);
49 }
50 format!("scope-{hash:016x}")
51}
52
53fn key_to_filename(key: &str) -> String {
55 let mut encoded = String::new();
56 for ch in key.chars() {
57 match ch {
58 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => encoded.push(ch),
59 _ => {
60 for byte in ch.to_string().as_bytes() {
61 encoded.push_str(&format!("%{byte:02X}"));
62 }
63 }
64 }
65 }
66 format!("{encoded}.json")
67}
68
69fn filename_to_key(filename: &str) -> Option<String> {
71 let name = filename.strip_suffix(".json")?;
72 let mut result = Vec::new();
73 let bytes = name.as_bytes();
74 let mut i = 0;
75 while i < bytes.len() {
76 if bytes[i] == b'%' && i + 2 < bytes.len() {
77 let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?;
78 let byte = u8::from_str_radix(hex, 16).ok()?;
79 result.push(byte);
80 i += 3;
81 } else {
82 result.push(bytes[i]);
83 i += 1;
84 }
85 }
86 String::from_utf8(result).ok()
87}
88
89#[async_trait]
90impl StateStore for FsStore {
91 async fn read(
92 &self,
93 scope: &Scope,
94 key: &str,
95 ) -> Result<Option<serde_json::Value>, StateError> {
96 let path = self
97 .root
98 .join(scope_dir_name(scope))
99 .join(key_to_filename(key));
100 match tokio::fs::read_to_string(&path).await {
101 Ok(contents) => {
102 let value: serde_json::Value = serde_json::from_str(&contents)
103 .map_err(|e| StateError::Serialization(e.to_string()))?;
104 Ok(Some(value))
105 }
106 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
107 Err(e) => Err(StateError::WriteFailed(e.to_string())),
108 }
109 }
110
111 async fn write(
112 &self,
113 scope: &Scope,
114 key: &str,
115 value: serde_json::Value,
116 ) -> Result<(), StateError> {
117 let dir = self.root.join(scope_dir_name(scope));
118 tokio::fs::create_dir_all(&dir)
119 .await
120 .map_err(|e| StateError::WriteFailed(e.to_string()))?;
121
122 let path = dir.join(key_to_filename(key));
123 let contents = serde_json::to_string_pretty(&value)
124 .map_err(|e| StateError::Serialization(e.to_string()))?;
125 tokio::fs::write(&path, contents)
126 .await
127 .map_err(|e| StateError::WriteFailed(e.to_string()))?;
128 Ok(())
129 }
130
131 async fn delete(&self, scope: &Scope, key: &str) -> Result<(), StateError> {
132 let path = self
133 .root
134 .join(scope_dir_name(scope))
135 .join(key_to_filename(key));
136 match tokio::fs::remove_file(&path).await {
137 Ok(()) => Ok(()),
138 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
139 Err(e) => Err(StateError::WriteFailed(e.to_string())),
140 }
141 }
142
143 async fn list(&self, scope: &Scope, prefix: &str) -> Result<Vec<String>, StateError> {
144 let dir = self.root.join(scope_dir_name(scope));
145 let mut entries = match tokio::fs::read_dir(&dir).await {
146 Ok(entries) => entries,
147 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
148 Err(e) => return Err(StateError::WriteFailed(e.to_string())),
149 };
150
151 let mut keys = Vec::new();
152 while let Some(entry) = entries
153 .next_entry()
154 .await
155 .map_err(|e| StateError::WriteFailed(e.to_string()))?
156 {
157 if let Some(filename) = entry.file_name().to_str()
158 && let Some(key) = filename_to_key(filename)
159 && key.starts_with(prefix)
160 {
161 keys.push(key);
162 }
163 }
164 Ok(keys)
165 }
166
167 async fn search(
168 &self,
169 _scope: &Scope,
170 _query: &str,
171 _limit: usize,
172 ) -> Result<Vec<SearchResult>, StateError> {
173 Ok(vec![])
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use serde_json::json;
182
183 #[test]
184 fn key_encoding_roundtrip() {
185 let keys = [
186 "simple",
187 "user:name",
188 "path/to/key",
189 "has spaces",
190 "emoji🎉",
191 ];
192 for key in &keys {
193 let filename = key_to_filename(key);
194 let decoded = filename_to_key(&filename).unwrap();
195 assert_eq!(*key, decoded, "roundtrip failed for {key}");
196 }
197 }
198
199 #[test]
200 fn scope_dir_name_is_deterministic() {
201 let scope = Scope::Global;
202 let dir1 = scope_dir_name(&scope);
203 let dir2 = scope_dir_name(&scope);
204 assert_eq!(dir1, dir2);
205 }
206
207 #[test]
208 fn different_scopes_get_different_dirs() {
209 let global = scope_dir_name(&Scope::Global);
210 let session = scope_dir_name(&Scope::Session(layer0::SessionId::new("s1")));
211 assert_ne!(global, session);
212 }
213
214 #[test]
215 fn key_to_filename_produces_json_extension() {
216 let filename = key_to_filename("test");
217 assert!(filename.ends_with(".json"));
218 }
219
220 #[test]
221 fn filename_to_key_rejects_non_json() {
222 let result = filename_to_key("test.txt");
223 assert!(result.is_none());
224 }
225
226 #[tokio::test]
227 async fn write_and_read_roundtrip() {
228 let dir = tempfile::tempdir().unwrap();
229 let store = FsStore::new(dir.path());
230 let scope = Scope::Global;
231
232 store.write(&scope, "key1", json!("hello")).await.unwrap();
233 let val = store.read(&scope, "key1").await.unwrap();
234 assert_eq!(val, Some(json!("hello")));
235 }
236
237 #[tokio::test]
238 async fn read_nonexistent_returns_none() {
239 let dir = tempfile::tempdir().unwrap();
240 let store = FsStore::new(dir.path());
241 let scope = Scope::Global;
242
243 let val = store.read(&scope, "missing").await.unwrap();
244 assert_eq!(val, None);
245 }
246
247 #[tokio::test]
248 async fn delete_removes_file() {
249 let dir = tempfile::tempdir().unwrap();
250 let store = FsStore::new(dir.path());
251 let scope = Scope::Global;
252
253 store.write(&scope, "key1", json!("hello")).await.unwrap();
254 store.delete(&scope, "key1").await.unwrap();
255 let val = store.read(&scope, "key1").await.unwrap();
256 assert_eq!(val, None);
257 }
258
259 #[tokio::test]
260 async fn delete_nonexistent_is_ok() {
261 let dir = tempfile::tempdir().unwrap();
262 let store = FsStore::new(dir.path());
263 let scope = Scope::Global;
264
265 let result = store.delete(&scope, "missing").await;
266 assert!(result.is_ok());
267 }
268
269 #[tokio::test]
270 async fn list_keys_with_prefix() {
271 let dir = tempfile::tempdir().unwrap();
272 let store = FsStore::new(dir.path());
273 let scope = Scope::Global;
274
275 store
276 .write(&scope, "user:name", json!("Alice"))
277 .await
278 .unwrap();
279 store.write(&scope, "user:age", json!(30)).await.unwrap();
280 store
281 .write(&scope, "system:version", json!("1.0"))
282 .await
283 .unwrap();
284
285 let mut keys = store.list(&scope, "user:").await.unwrap();
286 keys.sort();
287 assert_eq!(keys, vec!["user:age", "user:name"]);
288 }
289
290 #[tokio::test]
291 async fn list_nonexistent_dir_returns_empty() {
292 let dir = tempfile::tempdir().unwrap();
293 let store = FsStore::new(dir.path());
294 let scope = Scope::Global;
295
296 let keys = store.list(&scope, "").await.unwrap();
297 assert!(keys.is_empty());
298 }
299
300 #[tokio::test]
301 async fn scopes_are_isolated() {
302 let dir = tempfile::tempdir().unwrap();
303 let store = FsStore::new(dir.path());
304 let global = Scope::Global;
305 let session = Scope::Session(layer0::SessionId::new("s1"));
306
307 store
308 .write(&global, "key", json!("global_val"))
309 .await
310 .unwrap();
311 store
312 .write(&session, "key", json!("session_val"))
313 .await
314 .unwrap();
315
316 let global_val = store.read(&global, "key").await.unwrap();
317 let session_val = store.read(&session, "key").await.unwrap();
318
319 assert_eq!(global_val, Some(json!("global_val")));
320 assert_eq!(session_val, Some(json!("session_val")));
321 }
322
323 #[tokio::test]
324 async fn search_returns_empty() {
325 let dir = tempfile::tempdir().unwrap();
326 let store = FsStore::new(dir.path());
327 let scope = Scope::Global;
328
329 let results = store.search(&scope, "query", 10).await.unwrap();
330 assert!(results.is_empty());
331 }
332
333 #[test]
334 fn fs_store_implements_state_store() {
335 fn _assert_state_store<T: StateStore>() {}
336 _assert_state_store::<FsStore>();
337 }
338}