#![deny(missing_docs)]
use async_trait::async_trait;
use layer0::effect::Scope;
use layer0::error::StateError;
use layer0::state::{SearchResult, StateStore};
use std::path::{Path, PathBuf};
pub struct FsStore {
root: PathBuf,
}
impl FsStore {
pub fn new(root: &Path) -> Self {
Self {
root: root.to_path_buf(),
}
}
}
fn scope_dir_name(scope: &Scope) -> String {
let json = serde_json::to_string(scope).unwrap_or_else(|_| "unknown".into());
let mut hash: u64 = 5381;
for byte in json.as_bytes() {
hash = hash.wrapping_mul(33).wrapping_add(*byte as u64);
}
format!("scope-{hash:016x}")
}
fn key_to_filename(key: &str) -> String {
let mut encoded = String::new();
for ch in key.chars() {
match ch {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => encoded.push(ch),
_ => {
for byte in ch.to_string().as_bytes() {
encoded.push_str(&format!("%{byte:02X}"));
}
}
}
}
format!("{encoded}.json")
}
fn filename_to_key(filename: &str) -> Option<String> {
let name = filename.strip_suffix(".json")?;
let mut result = Vec::new();
let bytes = name.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?;
let byte = u8::from_str_radix(hex, 16).ok()?;
result.push(byte);
i += 3;
} else {
result.push(bytes[i]);
i += 1;
}
}
String::from_utf8(result).ok()
}
#[async_trait]
impl StateStore for FsStore {
async fn read(
&self,
scope: &Scope,
key: &str,
) -> Result<Option<serde_json::Value>, StateError> {
let path = self
.root
.join(scope_dir_name(scope))
.join(key_to_filename(key));
match tokio::fs::read_to_string(&path).await {
Ok(contents) => {
let value: serde_json::Value = serde_json::from_str(&contents)
.map_err(|e| StateError::Serialization(e.to_string()))?;
Ok(Some(value))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(StateError::WriteFailed(e.to_string())),
}
}
async fn write(
&self,
scope: &Scope,
key: &str,
value: serde_json::Value,
) -> Result<(), StateError> {
let dir = self.root.join(scope_dir_name(scope));
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| StateError::WriteFailed(e.to_string()))?;
let path = dir.join(key_to_filename(key));
let contents = serde_json::to_string_pretty(&value)
.map_err(|e| StateError::Serialization(e.to_string()))?;
tokio::fs::write(&path, contents)
.await
.map_err(|e| StateError::WriteFailed(e.to_string()))?;
Ok(())
}
async fn delete(&self, scope: &Scope, key: &str) -> Result<(), StateError> {
let path = self
.root
.join(scope_dir_name(scope))
.join(key_to_filename(key));
match tokio::fs::remove_file(&path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(StateError::WriteFailed(e.to_string())),
}
}
async fn list(&self, scope: &Scope, prefix: &str) -> Result<Vec<String>, StateError> {
let dir = self.root.join(scope_dir_name(scope));
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
Err(e) => return Err(StateError::WriteFailed(e.to_string())),
};
let mut keys = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| StateError::WriteFailed(e.to_string()))?
{
if let Some(filename) = entry.file_name().to_str()
&& let Some(key) = filename_to_key(filename)
&& key.starts_with(prefix)
{
keys.push(key);
}
}
Ok(keys)
}
async fn search(
&self,
_scope: &Scope,
_query: &str,
_limit: usize,
) -> Result<Vec<SearchResult>, StateError> {
Ok(vec![])
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn key_encoding_roundtrip() {
let keys = [
"simple",
"user:name",
"path/to/key",
"has spaces",
"emoji🎉",
];
for key in &keys {
let filename = key_to_filename(key);
let decoded = filename_to_key(&filename).unwrap();
assert_eq!(*key, decoded, "roundtrip failed for {key}");
}
}
#[test]
fn scope_dir_name_is_deterministic() {
let scope = Scope::Global;
let dir1 = scope_dir_name(&scope);
let dir2 = scope_dir_name(&scope);
assert_eq!(dir1, dir2);
}
#[test]
fn different_scopes_get_different_dirs() {
let global = scope_dir_name(&Scope::Global);
let session = scope_dir_name(&Scope::Session(layer0::SessionId::new("s1")));
assert_ne!(global, session);
}
#[test]
fn key_to_filename_produces_json_extension() {
let filename = key_to_filename("test");
assert!(filename.ends_with(".json"));
}
#[test]
fn filename_to_key_rejects_non_json() {
let result = filename_to_key("test.txt");
assert!(result.is_none());
}
#[tokio::test]
async fn write_and_read_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path());
let scope = Scope::Global;
store.write(&scope, "key1", json!("hello")).await.unwrap();
let val = store.read(&scope, "key1").await.unwrap();
assert_eq!(val, Some(json!("hello")));
}
#[tokio::test]
async fn read_nonexistent_returns_none() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path());
let scope = Scope::Global;
let val = store.read(&scope, "missing").await.unwrap();
assert_eq!(val, None);
}
#[tokio::test]
async fn delete_removes_file() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path());
let scope = Scope::Global;
store.write(&scope, "key1", json!("hello")).await.unwrap();
store.delete(&scope, "key1").await.unwrap();
let val = store.read(&scope, "key1").await.unwrap();
assert_eq!(val, None);
}
#[tokio::test]
async fn delete_nonexistent_is_ok() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path());
let scope = Scope::Global;
let result = store.delete(&scope, "missing").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn list_keys_with_prefix() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path());
let scope = Scope::Global;
store
.write(&scope, "user:name", json!("Alice"))
.await
.unwrap();
store.write(&scope, "user:age", json!(30)).await.unwrap();
store
.write(&scope, "system:version", json!("1.0"))
.await
.unwrap();
let mut keys = store.list(&scope, "user:").await.unwrap();
keys.sort();
assert_eq!(keys, vec!["user:age", "user:name"]);
}
#[tokio::test]
async fn list_nonexistent_dir_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path());
let scope = Scope::Global;
let keys = store.list(&scope, "").await.unwrap();
assert!(keys.is_empty());
}
#[tokio::test]
async fn scopes_are_isolated() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path());
let global = Scope::Global;
let session = Scope::Session(layer0::SessionId::new("s1"));
store
.write(&global, "key", json!("global_val"))
.await
.unwrap();
store
.write(&session, "key", json!("session_val"))
.await
.unwrap();
let global_val = store.read(&global, "key").await.unwrap();
let session_val = store.read(&session, "key").await.unwrap();
assert_eq!(global_val, Some(json!("global_val")));
assert_eq!(session_val, Some(json!("session_val")));
}
#[tokio::test]
async fn search_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path());
let scope = Scope::Global;
let results = store.search(&scope, "query", 10).await.unwrap();
assert!(results.is_empty());
}
#[test]
fn fs_store_implements_state_store() {
fn _assert_state_store<T: StateStore>() {}
_assert_state_store::<FsStore>();
}
}