orcs_runtime/session/
local.rs1use super::{SessionAsset, SessionMeta, SessionStore, StorageError, SyncStatus};
13use std::path::PathBuf;
14use tokio::fs;
15
16#[derive(Debug, Clone)]
46pub struct LocalFileStore {
47 base_path: PathBuf,
49}
50
51impl LocalFileStore {
52 pub fn new(base_path: PathBuf) -> Result<Self, StorageError> {
60 let expanded = expand_tilde(&base_path);
62
63 if !expanded.exists() {
65 std::fs::create_dir_all(&expanded)
66 .map_err(|e| StorageError::directory_creation(&expanded, e))?;
67 }
68
69 Ok(Self {
70 base_path: expanded,
71 })
72 }
73
74 #[must_use]
76 pub fn base_path(&self) -> &PathBuf {
77 &self.base_path
78 }
79
80 fn session_path(&self, id: &str) -> PathBuf {
82 self.base_path.join(format!("{id}.json"))
83 }
84
85 fn temp_path(&self, id: &str) -> PathBuf {
87 self.base_path.join(format!(".{id}.json.tmp"))
88 }
89}
90
91impl SessionStore for LocalFileStore {
92 async fn save(&self, asset: &SessionAsset) -> Result<(), StorageError> {
93 let json = asset.to_json()?;
94 let path = self.session_path(&asset.id);
95 let temp_path = self.temp_path(&asset.id);
96
97 fs::write(&temp_path, &json).await?;
99
100 fs::rename(&temp_path, &path).await?;
102
103 Ok(())
104 }
105
106 async fn load(&self, id: &str) -> Result<SessionAsset, StorageError> {
107 let path = self.session_path(id);
108
109 if !path.exists() {
110 return Err(StorageError::not_found(id));
111 }
112
113 let json = fs::read_to_string(&path).await?;
114 let asset = SessionAsset::from_json(&json)?;
115
116 Ok(asset)
117 }
118
119 async fn list(&self) -> Result<Vec<SessionMeta>, StorageError> {
120 let mut sessions = Vec::new();
121 let mut entries = fs::read_dir(&self.base_path).await?;
122
123 while let Some(entry) = entries.next_entry().await? {
124 let path = entry.path();
125
126 if path.extension() != Some(std::ffi::OsStr::new("json")) {
128 continue;
129 }
130 if path
131 .file_name()
132 .and_then(|n| n.to_str())
133 .is_some_and(|n| n.starts_with('.'))
134 {
135 continue;
136 }
137
138 if let Ok(json) = fs::read_to_string(&path).await {
140 if let Ok(asset) = SessionAsset::from_json(&json) {
141 sessions.push(SessionMeta::from_asset(&asset));
142 }
143 }
144 }
145
146 sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
148
149 Ok(sessions)
150 }
151
152 async fn delete(&self, id: &str) -> Result<(), StorageError> {
153 let path = self.session_path(id);
154
155 if !path.exists() {
156 return Err(StorageError::not_found(id));
157 }
158
159 fs::remove_file(&path).await?;
160 Ok(())
161 }
162
163 async fn exists(&self, id: &str) -> Result<bool, StorageError> {
164 let path = self.session_path(id);
165 Ok(path.exists())
166 }
167
168 fn sync_status(&self) -> SyncStatus {
169 SyncStatus::offline()
170 }
171}
172
173fn expand_tilde(path: &std::path::Path) -> PathBuf {
175 if let Some(path_str) = path.to_str() {
176 if let Some(rest) = path_str.strip_prefix("~/") {
177 if let Some(home) = dirs::home_dir() {
178 return home.join(rest);
179 }
180 }
181 }
182 path.to_path_buf()
183}
184
185#[must_use]
187pub fn default_session_path() -> PathBuf {
188 dirs::home_dir()
189 .unwrap_or_else(|| PathBuf::from("."))
190 .join(".orcs")
191 .join("sessions")
192}
193
194#[cfg(test)]
195mod tests {
196 use super::super::SyncMode;
197 use super::*;
198 use crate::WorkDir;
199
200 async fn test_store() -> (LocalFileStore, WorkDir) {
201 let wd = WorkDir::temporary().expect("should create temp WorkDir for store test");
202 let store =
203 LocalFileStore::new(wd.path().to_path_buf()).expect("should create local file store");
204 (store, wd)
205 }
206
207 #[tokio::test]
208 async fn save_and_load() {
209 let (store, _wd) = test_store().await;
210
211 let mut asset = SessionAsset::new();
212 asset.add_turn(super::super::ConversationTurn::user("test"));
213
214 store
216 .save(&asset)
217 .await
218 .expect("save session should succeed");
219
220 let loaded = store
222 .load(&asset.id)
223 .await
224 .expect("load session should succeed");
225 assert_eq!(loaded.id, asset.id);
226 assert_eq!(loaded.history.len(), 1);
227 }
228
229 #[tokio::test]
230 async fn load_not_found() {
231 let (store, _wd) = test_store().await;
232
233 let result = store.load("nonexistent").await;
234 assert!(matches!(result, Err(StorageError::NotFound(_))));
235 }
236
237 #[tokio::test]
238 async fn list_sessions() {
239 let (store, _wd) = test_store().await;
240
241 for i in 0..3 {
243 let mut asset = SessionAsset::new();
244 asset.project_context.name = Some(format!("project-{i}"));
245 store
246 .save(&asset)
247 .await
248 .expect("save session should succeed");
249 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
250 }
251
252 let sessions = store.list().await.expect("list sessions should succeed");
253 assert_eq!(sessions.len(), 3);
254
255 assert!(sessions[0].updated_at >= sessions[1].updated_at);
257 }
258
259 #[tokio::test]
260 async fn delete_session() {
261 let (store, _wd) = test_store().await;
262
263 let asset = SessionAsset::new();
264 store
265 .save(&asset)
266 .await
267 .expect("save session should succeed");
268
269 assert!(store
270 .exists(&asset.id)
271 .await
272 .expect("exists check should succeed"));
273
274 store
275 .delete(&asset.id)
276 .await
277 .expect("delete session should succeed");
278
279 assert!(!store
280 .exists(&asset.id)
281 .await
282 .expect("exists check after delete should succeed"));
283 }
284
285 #[tokio::test]
286 async fn delete_not_found() {
287 let (store, _wd) = test_store().await;
288
289 let result = store.delete("nonexistent").await;
290 assert!(matches!(result, Err(StorageError::NotFound(_))));
291 }
292
293 #[tokio::test]
294 async fn exists() {
295 let (store, _wd) = test_store().await;
296
297 let asset = SessionAsset::new();
298 assert!(!store
299 .exists(&asset.id)
300 .await
301 .expect("exists check before save should succeed"));
302
303 store
304 .save(&asset)
305 .await
306 .expect("save session should succeed");
307 assert!(store
308 .exists(&asset.id)
309 .await
310 .expect("exists check after save should succeed"));
311 }
312
313 #[tokio::test]
314 async fn sync_status_is_offline() {
315 let (store, _wd) = test_store().await;
316 let status = store.sync_status();
317 assert_eq!(status.mode, SyncMode::Offline);
318 }
319
320 #[test]
321 fn expand_tilde_with_home() {
322 let path = PathBuf::from("~/test/path");
323 let expanded = expand_tilde(&path);
324
325 if dirs::home_dir().is_some() {
326 assert!(!expanded
327 .to_str()
328 .expect("expanded path should be valid UTF-8")
329 .starts_with("~/"));
330 }
331 }
332
333 #[test]
334 fn expand_tilde_without_tilde() {
335 let path = PathBuf::from("/absolute/path");
336 let expanded = expand_tilde(&path);
337 assert_eq!(expanded, path);
338 }
339}