oxicode_sdk/ports/fs/
session.rs1use 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
13pub 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 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 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 if id.contains('/') || id.contains('\\') || id.contains("..") {
57 }
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 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 {
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 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}