Skip to main content

orcs_runtime/session/
local.rs

1//! Local file-based session storage.
2//!
3//! Sessions are stored as JSON files in a configurable directory:
4//!
5//! ```text
6//! ~/.orcs/sessions/
7//! ├── 550e8400-e29b-41d4-a716-446655440000.json
8//! ├── 6ba7b810-9dad-11d1-80b4-00c04fd430c8.json
9//! └── ...
10//! ```
11
12use super::{SessionAsset, SessionMeta, SessionStore, StorageError, SyncStatus};
13use std::path::PathBuf;
14use tokio::fs;
15
16/// Local file-based session store.
17///
18/// This is the default storage backend, suitable for single-machine use.
19///
20/// # Features
21///
22/// - Sessions stored as pretty-printed JSON
23/// - Atomic writes (write to temp, then rename)
24/// - Automatic directory creation
25///
26/// # Example
27///
28/// ```no_run
29/// use orcs_runtime::session::{LocalFileStore, SessionStore, SessionAsset};
30/// use std::path::PathBuf;
31///
32/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
33/// let store = LocalFileStore::new(PathBuf::from("~/.orcs/sessions"))?;
34///
35/// // Save a session
36/// let asset = SessionAsset::new();
37/// store.save(&asset).await?;
38///
39/// // List all sessions
40/// let sessions = store.list().await?;
41/// println!("Found {} sessions", sessions.len());
42/// # Ok(())
43/// # }
44/// ```
45#[derive(Debug, Clone)]
46pub struct LocalFileStore {
47    /// Base directory for session files.
48    base_path: PathBuf,
49}
50
51impl LocalFileStore {
52    /// Creates a new local file store.
53    ///
54    /// The directory will be created if it doesn't exist.
55    ///
56    /// # Errors
57    ///
58    /// Returns `StorageError::DirectoryCreation` if the directory cannot be created.
59    pub fn new(base_path: PathBuf) -> Result<Self, StorageError> {
60        // Expand ~ to home directory
61        let expanded = expand_tilde(&base_path);
62
63        // Create directory if needed (synchronously for constructor)
64        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    /// Returns the base path.
75    #[must_use]
76    pub fn base_path(&self) -> &PathBuf {
77        &self.base_path
78    }
79
80    /// Returns the file path for a session ID.
81    fn session_path(&self, id: &str) -> PathBuf {
82        self.base_path.join(format!("{id}.json"))
83    }
84
85    /// Returns a temporary file path for atomic writes.
86    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        // Write to temp file first (atomic write pattern)
98        fs::write(&temp_path, &json).await?;
99
100        // Rename to final path (atomic on most filesystems)
101        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            // Skip non-JSON files and temp files
127            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            // Try to load the session
139            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        // Sort by updated_at descending (most recent first)
147        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
173/// Expands `~` to the user's home directory.
174fn 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/// Returns the default session storage path.
186#[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 tempfile::TempDir;
199
200    async fn test_store() -> (LocalFileStore, TempDir) {
201        let temp = TempDir::new().expect("should create temp dir");
202        let store =
203            LocalFileStore::new(temp.path().to_path_buf()).expect("should create local file store");
204        (store, temp)
205    }
206
207    #[tokio::test]
208    async fn save_and_load() {
209        let (store, _temp) = test_store().await;
210
211        let mut asset = SessionAsset::new();
212        asset.add_turn(super::super::ConversationTurn::user("test"));
213
214        // Save
215        store
216            .save(&asset)
217            .await
218            .expect("save session should succeed");
219
220        // Load
221        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, _temp) = 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, _temp) = test_store().await;
240
241        // Create multiple sessions
242        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        // Should be sorted by updated_at descending
256        assert!(sessions[0].updated_at >= sessions[1].updated_at);
257    }
258
259    #[tokio::test]
260    async fn delete_session() {
261        let (store, _temp) = 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, _temp) = 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, _temp) = 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, _temp) = 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}