Skip to main content

neuron_state_fs/
lib.rs

1#![deny(missing_docs)]
2//! Filesystem-backed implementation of layer0's StateStore trait.
3//!
4//! Each scope maps to a subdirectory under the root. Keys are
5//! URL-encoded and stored as `.json` files within the scope directory.
6//! Provides true persistence across process restarts.
7
8use async_trait::async_trait;
9use layer0::effect::Scope;
10use layer0::error::StateError;
11use layer0::state::{SearchResult, StateStore};
12use std::path::{Path, PathBuf};
13
14/// Filesystem-backed state store.
15///
16/// Directory layout:
17/// ```text
18/// root/
19///   <scope-hash>/
20///     <url-encoded-key>.json
21/// ```
22///
23/// Suitable for development, single-machine deployments, and cases
24/// where data must survive process restarts without a database.
25pub struct FsStore {
26    root: PathBuf,
27}
28
29impl FsStore {
30    /// Create a new filesystem store rooted at the given directory.
31    ///
32    /// The directory is created lazily on first write.
33    pub fn new(root: &Path) -> Self {
34        Self {
35            root: root.to_path_buf(),
36        }
37    }
38}
39
40/// Derive a safe directory name from a scope.
41fn scope_dir_name(scope: &Scope) -> String {
42    // Use a deterministic, filesystem-safe representation.
43    // We hash the JSON serialization of the scope.
44    let json = serde_json::to_string(scope).unwrap_or_else(|_| "unknown".into());
45    // Simple hash to avoid overly long directory names
46    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
53/// Encode a key into a safe filename.
54fn 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
69/// Decode a filename back to a key.
70fn 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        // Filesystem store does not support semantic search.
174        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}