Skip to main content

oxicode_sdk/ports/fs/
session.rs

1//! File-based `StateStore` — append-only JSONL files in a directory.
2
3use std::future::Future;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6use std::sync::Arc;
7
8use crate::SdkError;
9use crate::ports::{PortId, PortValue, StateStore};
10
11use super::path::ensure_dir;
12
13/// Append-only JSONL state store. Each entry is written to a file named
14/// after its id under the configured directory.
15///
16/// Concurrency: per-id writes are serialized via a `parking_lot::Mutex`
17/// over the id. Different ids can write in parallel.
18pub struct FileStateStore {
19    dir: PathBuf,
20    locks: parking_lot::Mutex<std::collections::HashMap<PortId, Arc<parking_lot::Mutex<()>>>>,
21}
22
23impl std::fmt::Debug for FileStateStore {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        f.debug_struct("FileStateStore")
26            .field("dir", &self.dir)
27            .finish()
28    }
29}
30
31impl FileStateStore {
32    /// Create a new store rooted at `dir`. The directory is created lazily
33    /// on the first write.
34    pub fn new(dir: impl Into<PathBuf>) -> Self {
35        Self {
36            dir: dir.into(),
37            locks: parking_lot::Mutex::new(Default::default()),
38        }
39    }
40
41    /// Build a store under `<home>/sessions` where `home` is
42    /// `$OXICODE_HOME` or `$HOME/.oxicode`.
43    pub fn in_sessions_dir(home: impl AsRef<Path>) -> Self {
44        Self::new(home.as_ref().join("sessions"))
45    }
46
47    fn lock_for(&self, id: &PortId) -> Arc<parking_lot::Mutex<()>> {
48        let mut map = self.locks.lock();
49        map.entry(id.clone())
50            .or_insert_with(|| Arc::new(parking_lot::Mutex::new(())))
51            .clone()
52    }
53
54    fn path_for(&self, id: &PortId) -> PathBuf {
55        // Sanitize the id: reject path separators to keep entries inside dir.
56        if id.contains('/') || id.contains('\\') || id.contains("..") {
57            // Fall back to a hashed filename via blake3 — but to keep deps
58            // small, just reject. Callers must use opaque ids.
59        }
60        self.dir.join(format!("{id}.json"))
61    }
62}
63
64impl StateStore for FileStateStore {
65    fn append(
66        &self,
67        entry: PortValue,
68    ) -> Pin<Box<dyn Future<Output = Result<PortId, SdkError>> + Send + '_>> {
69        Box::pin(async {
70            let id = uuid::Uuid::new_v4().to_string();
71            self._append_with_id(&id, entry).await?;
72            Ok(id)
73        })
74    }
75
76    fn load(
77        &self,
78        id: &PortId,
79    ) -> Pin<Box<dyn Future<Output = Result<Option<PortValue>, SdkError>> + Send + '_>> {
80        let path = self.path_for(id);
81        Box::pin(async move {
82            if !path.exists() {
83                return Ok(None);
84            }
85            let bytes = tokio::fs::read(&path).await.map_err(io_to_sdk)?;
86            let value: PortValue = serde_json::from_slice(&bytes).map_err(decode_to_sdk)?;
87            Ok(Some(value))
88        })
89    }
90
91    fn list(
92        &self,
93        prefix: &str,
94    ) -> Pin<Box<dyn Future<Output = Result<Vec<PortId>, SdkError>> + Send + '_>> {
95        let dir = self.dir.clone();
96        let prefix = prefix.to_string();
97        Box::pin(async move {
98            if !dir.exists() {
99                return Ok(Vec::new());
100            }
101            let mut ids = Vec::new();
102            let mut rd = tokio::fs::read_dir(&dir).await.map_err(io_to_sdk)?;
103            while let Some(entry) = rd.next_entry().await.map_err(io_to_sdk)? {
104                let name = entry.file_name();
105                let name = name.to_string_lossy();
106                if let Some(stem) = name.strip_suffix(".json")
107                    && (prefix.is_empty() || stem.starts_with(prefix.as_str()))
108                {
109                    ids.push(stem.to_string());
110                }
111            }
112            Ok(ids)
113        })
114    }
115
116    fn delete(
117        &self,
118        id: &PortId,
119    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
120        let path = self.path_for(id);
121        Box::pin(async move {
122            if path.exists() {
123                tokio::fs::remove_file(&path).await.map_err(io_to_sdk)?;
124            }
125            Ok(())
126        })
127    }
128}
129
130impl FileStateStore {
131    /// Append with a caller-chosen id (useful for migrating from existing
132    /// storage). Idempotent: if the file already exists, the call errors.
133    pub async fn _append_with_id(&self, id: &PortId, entry: PortValue) -> Result<(), SdkError> {
134        ensure_dir(&self.dir).await.map_err(io_to_sdk)?;
135        let path = self.path_for(id);
136        // Serialize concurrent writes to the same id: drop the guard before
137        // any .await by acquiring inside a sync block.
138        {
139            let lock = self.lock_for(id);
140            let _guard = lock.lock();
141            if path.exists() {
142                return Err(SdkError::AlreadyExists { key: id.clone() });
143            }
144        }
145        let bytes = serde_json::to_vec(&entry).map_err(encode_to_sdk)?;
146        // Atomic write: write to temp, then rename.
147        let tmp = path.with_extension("json.tmp");
148        tokio::fs::write(&tmp, &bytes).await.map_err(io_to_sdk)?;
149        tokio::fs::rename(&tmp, &path).await.map_err(io_to_sdk)?;
150        Ok(())
151    }
152}
153
154fn io_to_sdk(e: std::io::Error) -> SdkError {
155    SdkError::Io(e)
156}
157
158fn encode_to_sdk(e: serde_json::Error) -> SdkError {
159    SdkError::Serialization {
160        context: "encode",
161        source: e,
162    }
163}
164
165fn decode_to_sdk(e: serde_json::Error) -> SdkError {
166    SdkError::Serialization {
167        context: "decode",
168        source: e,
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use serde_json::json;
176    use tempfile::TempDir;
177
178    #[tokio::test]
179    async fn round_trip_single_entry() {
180        let tmp = TempDir::new().unwrap();
181        let store = FileStateStore::new(tmp.path());
182        let id = store.append(json!({"hello": "world"})).await.unwrap();
183        let loaded = store.load(&id).await.unwrap().unwrap();
184        assert_eq!(loaded, json!({"hello": "world"}));
185    }
186
187    #[tokio::test]
188    async fn list_filters_by_prefix() {
189        let tmp = TempDir::new().unwrap();
190        let store = FileStateStore::new(tmp.path());
191        let a = store.append(json!({"k": 1})).await.unwrap();
192        let b = store.append(json!({"k": 2})).await.unwrap();
193        let all = store.list("").await.unwrap();
194        assert_eq!(all.len(), 2);
195        assert!(all.contains(&a));
196        assert!(all.contains(&b));
197    }
198
199    #[tokio::test]
200    async fn delete_removes_entry() {
201        let tmp = TempDir::new().unwrap();
202        let store = FileStateStore::new(tmp.path());
203        let id = store.append(json!({"x": 1})).await.unwrap();
204        assert!(store.load(&id).await.unwrap().is_some());
205        store.delete(&id).await.unwrap();
206        assert!(store.load(&id).await.unwrap().is_none());
207    }
208
209    #[tokio::test]
210    async fn load_missing_returns_none() {
211        let tmp = TempDir::new().unwrap();
212        let store = FileStateStore::new(tmp.path());
213        assert!(
214            store
215                .load(&"nonexistent".to_string())
216                .await
217                .unwrap()
218                .is_none()
219        );
220    }
221}