Skip to main content

roma_core/
session_manager.rs

1//! Session persistence manager.
2//!
3//! Manages sessions under `~/.roma/sessions/`. Each session is stored as:
4//! - `{session_id}/session.json` — full [`ChatSession`]
5//! - `{session_id}/metadata.json` — [`SessionMeta`]
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::ClassifiedError;
12use crate::session::ChatSession;
13use crate::types::Role;
14
15/// Lightweight metadata for a saved session.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SessionMeta {
18    /// Session unique identifier (UUID v4).
19    pub id: String,
20    /// Human-readable title.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub title: Option<String>,
23    /// Where the session originated: "repl", "tui", "task", "jsonl".
24    pub source: String,
25    /// Provider name.
26    pub provider: String,
27    /// Model name.
28    pub model: String,
29    /// ISO 8601 creation timestamp.
30    pub created_at: String,
31    /// ISO 8601 last-updated timestamp.
32    pub updated_at: String,
33    /// Parent session id (for forked sessions).
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub parent_session_id: Option<String>,
36    /// Number of messages in the session.
37    #[serde(default)]
38    pub message_count: usize,
39    /// Number of user prompts processed.
40    #[serde(default)]
41    pub prompt_count: u32,
42    /// First user message (for search indexing).
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub first_user_message: Option<String>,
45}
46
47/// Manages the session store at `~/.roma/sessions/`.
48pub struct SessionManager {
49    home: PathBuf,
50}
51
52impl SessionManager {
53    /// Create a new session manager rooted at `config_home/sessions/`.
54    pub fn new(config_home: &Path) -> Self {
55        Self {
56            home: config_home.join("sessions"),
57        }
58    }
59
60    /// Create a session manager rooted at a specific path (for testing).
61    #[must_use]
62    pub fn with_home(home: PathBuf) -> Self {
63        Self { home }
64    }
65
66    /// Return the session store root.
67    #[must_use]
68    pub fn root(&self) -> &Path {
69        &self.home
70    }
71
72    /// Ensure the sessions directory exists.
73    fn ensure_home(&self) -> Result<(), ClassifiedError> {
74        std::fs::create_dir_all(&self.home)?;
75        Ok(())
76    }
77
78    /// Return the directory for a specific session id.
79    #[must_use]
80    pub fn session_dir(&self, id: &str) -> PathBuf {
81        self.home.join(id)
82    }
83
84    /// Save a session and its metadata.
85    ///
86    /// Returns an error if `meta.id` does not match `session.session_id`.
87    /// If `meta.first_user_message` is `None`, it is auto-populated from the
88    /// first user message in the session (truncated to 200 chars).
89    pub fn save(&self, session: &ChatSession, meta: &SessionMeta) -> Result<(), ClassifiedError> {
90        if meta.id != session.session_id.to_string() {
91            return Err(ClassifiedError::Config(format!(
92                "session ID mismatch: meta.id={}, session.session_id={}",
93                meta.id, session.session_id
94            )));
95        }
96        self.ensure_home()?;
97        let dir = self.session_dir(&meta.id);
98        std::fs::create_dir_all(&dir)?;
99
100        let session_path = dir.join("session.json");
101        let json = serde_json::to_string_pretty(session)
102            .map_err(|e| ClassifiedError::Parse(format!("save session: {e}")))?;
103        atomic_write(&session_path, &json)?;
104
105        // Auto-populate first_user_message if missing.
106        let meta = if meta.first_user_message.is_none() {
107            let first_msg = session
108                .messages
109                .iter()
110                .find(|m| m.role == Role::User)
111                .map(|m| {
112                    let text = m.text_content();
113                    let end = crate::floor_char_boundary(&text, text.len().min(200));
114                    text[..end].to_string()
115                });
116            SessionMeta {
117                first_user_message: first_msg,
118                ..meta.clone()
119            }
120        } else {
121            meta.clone()
122        };
123
124        let meta_path = dir.join("metadata.json");
125        let json = serde_json::to_string_pretty(&meta)
126            .map_err(|e| ClassifiedError::Parse(format!("save metadata: {e}")))?;
127        atomic_write(&meta_path, &json)?;
128
129        Ok(())
130    }
131
132    /// Load a session by id.
133    pub fn load(&self, id: &str) -> Result<ChatSession, ClassifiedError> {
134        let path = self.session_dir(id).join("session.json");
135        ChatSession::load(&path)
136    }
137
138    /// Load session metadata by id.
139    pub fn load_meta(&self, id: &str) -> Result<SessionMeta, ClassifiedError> {
140        let path = self.session_dir(id).join("metadata.json");
141        let text = std::fs::read_to_string(&path)?;
142        serde_json::from_str(&text)
143            .map_err(|e| ClassifiedError::Parse(format!("load metadata: {e}")))
144    }
145
146    /// List all saved sessions, sorted by updated_at descending.
147    pub fn list(&self) -> Result<Vec<SessionMeta>, ClassifiedError> {
148        if !self.home.exists() {
149            return Ok(Vec::new());
150        }
151        let mut metas: Vec<SessionMeta> = Vec::new();
152        for entry in std::fs::read_dir(&self.home)? {
153            let entry = entry?;
154            if !entry.file_type()?.is_dir() {
155                continue;
156            }
157            let meta_path = entry.path().join("metadata.json");
158            if !meta_path.exists() {
159                continue;
160            }
161            match Self::read_meta_file(&meta_path) {
162                Ok(meta) => metas.push(meta),
163                Err(e) => {
164                    tracing::warn!(path = %meta_path.display(), "skipping corrupt session metadata: {e}");
165                }
166            }
167        }
168        metas.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
169        Ok(metas)
170    }
171
172    /// Delete a session by id.
173    ///
174    /// The `id` must be a bare UUID string with no path separators or `..`
175    /// components to prevent directory traversal.
176    pub fn delete(&self, id: &str) -> Result<(), ClassifiedError> {
177        if id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
178            return Err(ClassifiedError::Config(format!("invalid session id: {id}")));
179        }
180        let dir = self.session_dir(id);
181        if dir.exists() {
182            std::fs::remove_dir_all(&dir)?;
183        }
184        Ok(())
185    }
186
187    /// Check if a session exists.
188    #[must_use]
189    pub fn exists(&self, id: &str) -> bool {
190        self.session_dir(id).join("session.json").exists()
191    }
192
193    /// Search sessions by keyword. Matches against title, id, and
194    /// first_user_message fields in metadata. Case-insensitive.
195    pub fn search(&self, query: &str) -> Result<Vec<SessionMeta>, ClassifiedError> {
196        let lower = query.to_lowercase();
197        let all = self.list()?;
198        let results: Vec<SessionMeta> = all
199            .into_iter()
200            .filter(|m| {
201                let haystack = format!(
202                    "{} {} {}",
203                    m.title.as_deref().unwrap_or(""),
204                    m.id,
205                    m.first_user_message.as_deref().unwrap_or("")
206                )
207                .to_lowercase();
208                haystack.contains(&lower)
209            })
210            .collect();
211        Ok(results)
212    }
213
214    fn read_meta_file(path: &Path) -> Result<SessionMeta, ClassifiedError> {
215        let text = std::fs::read_to_string(path)?;
216        serde_json::from_str(&text)
217            .map_err(|e| ClassifiedError::Parse(format!("read metadata: {e}")))
218    }
219}
220
221/// Generate an ISO 8601 timestamp for the current UTC time.
222pub fn timestamp_now() -> String {
223    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
224}
225
226/// Write data to a file atomically via temp-file-then-rename.
227fn atomic_write(path: &Path, data: &str) -> Result<(), ClassifiedError> {
228    let tmp = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4()));
229    std::fs::write(&tmp, data)?;
230    std::fs::rename(&tmp, path)?;
231    Ok(())
232}
233
234#[cfg(test)]
235#[allow(clippy::unwrap_used)]
236mod tests {
237    use super::*;
238    use crate::session::ChatSession;
239
240    #[test]
241    fn save_and_load_roundtrip() {
242        let dir = tempfile::tempdir().unwrap();
243        let mgr = SessionManager::with_home(dir.path().join("sessions"));
244
245        let mut session = ChatSession::new();
246        session.push_user("hello");
247        let id = session.session_id.to_string();
248
249        let meta = SessionMeta {
250            id: id.clone(),
251            title: Some("test session".into()),
252            source: "repl".into(),
253            provider: "mock".into(),
254            model: "mock-model".into(),
255            created_at: timestamp_now(),
256            updated_at: timestamp_now(),
257            parent_session_id: None,
258            message_count: session.len(),
259            prompt_count: 1,
260            first_user_message: None,
261        };
262
263        mgr.save(&session, &meta).unwrap();
264        assert!(mgr.exists(&id));
265
266        let loaded = mgr.load(&id).unwrap();
267        assert_eq!(loaded.session_id.to_string(), id);
268        assert_eq!(loaded.len(), 1);
269
270        let loaded_meta = mgr.load_meta(&id).unwrap();
271        assert_eq!(loaded_meta.title.as_deref(), Some("test session"));
272        assert_eq!(loaded_meta.source, "repl");
273    }
274
275    #[test]
276    fn list_sessions_sorted_by_updated_at() {
277        let dir = tempfile::tempdir().unwrap();
278        let mgr = SessionManager::with_home(dir.path().join("sessions"));
279
280        for i in 0..3 {
281            let mut session = ChatSession::new();
282            session.push_user(format!("msg {i}"));
283            let id = session.session_id.to_string();
284            let ts = format!("2025-01-0{}T00:00:00Z", i + 1);
285            let meta = SessionMeta {
286                id: id.clone(),
287                title: None,
288                source: "repl".into(),
289                provider: "mock".into(),
290                model: "m".into(),
291                created_at: ts.clone(),
292                updated_at: ts,
293                parent_session_id: None,
294                message_count: 1,
295                prompt_count: 1,
296                first_user_message: None,
297            };
298            mgr.save(&session, &meta).unwrap();
299        }
300
301        let list = mgr.list().unwrap();
302        assert_eq!(list.len(), 3);
303        // Most recent first.
304        assert!(list[0].updated_at > list[2].updated_at);
305    }
306
307    #[test]
308    fn delete_removes_session() {
309        let dir = tempfile::tempdir().unwrap();
310        let mgr = SessionManager::with_home(dir.path().join("sessions"));
311
312        let session = ChatSession::new();
313        let id = session.session_id.to_string();
314        let meta = SessionMeta {
315            id: id.clone(),
316            title: None,
317            source: "repl".into(),
318            provider: "mock".into(),
319            model: "m".into(),
320            created_at: timestamp_now(),
321            updated_at: timestamp_now(),
322            parent_session_id: None,
323            message_count: 0,
324            prompt_count: 0,
325            first_user_message: None,
326        };
327        mgr.save(&session, &meta).unwrap();
328        assert!(mgr.exists(&id));
329
330        mgr.delete(&id).unwrap();
331        assert!(!mgr.exists(&id));
332    }
333
334    #[test]
335    fn list_empty_when_no_sessions() {
336        let dir = tempfile::tempdir().unwrap();
337        let mgr = SessionManager::with_home(dir.path().join("sessions"));
338        let list = mgr.list().unwrap();
339        assert!(list.is_empty());
340    }
341
342    #[test]
343    fn search_finds_by_title() {
344        let dir = tempfile::tempdir().unwrap();
345        let mgr = SessionManager::with_home(dir.path().join("sessions"));
346
347        let mut session = ChatSession::new();
348        session.push_user("hello");
349        let id = session.session_id.to_string();
350        let meta = SessionMeta {
351            id: id.clone(),
352            title: Some("Rust debugging".into()),
353            source: "repl".into(),
354            provider: "mock".into(),
355            model: "m".into(),
356            created_at: timestamp_now(),
357            updated_at: timestamp_now(),
358            parent_session_id: None,
359            message_count: 1,
360            prompt_count: 1,
361            first_user_message: None,
362        };
363        mgr.save(&session, &meta).unwrap();
364
365        let results = mgr.search("rust").unwrap();
366        assert_eq!(results.len(), 1);
367        assert_eq!(results[0].id, id);
368    }
369
370    #[test]
371    fn search_finds_by_first_user_message() {
372        let dir = tempfile::tempdir().unwrap();
373        let mgr = SessionManager::with_home(dir.path().join("sessions"));
374
375        let mut session = ChatSession::new();
376        session.push_user("debug the parser error");
377        let id = session.session_id.to_string();
378        let meta = SessionMeta {
379            id: id.clone(),
380            title: None,
381            source: "repl".into(),
382            provider: "mock".into(),
383            model: "m".into(),
384            created_at: timestamp_now(),
385            updated_at: timestamp_now(),
386            parent_session_id: None,
387            message_count: 1,
388            prompt_count: 1,
389            first_user_message: None,
390        };
391        mgr.save(&session, &meta).unwrap();
392
393        // first_user_message should have been auto-populated by save.
394        let loaded = mgr.load_meta(&id).unwrap();
395        assert!(loaded.first_user_message.is_some());
396
397        let results = mgr.search("parser").unwrap();
398        assert_eq!(results.len(), 1);
399    }
400
401    #[test]
402    fn search_is_case_insensitive() {
403        let dir = tempfile::tempdir().unwrap();
404        let mgr = SessionManager::with_home(dir.path().join("sessions"));
405
406        let mut session = ChatSession::new();
407        session.push_user("test");
408        let id = session.session_id.to_string();
409        let meta = SessionMeta {
410            id: id.clone(),
411            title: Some("Rust Debugging".into()),
412            source: "repl".into(),
413            provider: "mock".into(),
414            model: "m".into(),
415            created_at: timestamp_now(),
416            updated_at: timestamp_now(),
417            parent_session_id: None,
418            message_count: 1,
419            prompt_count: 1,
420            first_user_message: None,
421        };
422        mgr.save(&session, &meta).unwrap();
423
424        let results = mgr.search("RUST").unwrap();
425        assert_eq!(results.len(), 1);
426    }
427
428    #[test]
429    fn search_returns_empty_when_no_match() {
430        let dir = tempfile::tempdir().unwrap();
431        let mgr = SessionManager::with_home(dir.path().join("sessions"));
432
433        let mut session = ChatSession::new();
434        session.push_user("hello");
435        let id = session.session_id.to_string();
436        let meta = SessionMeta {
437            id,
438            title: Some("Python scripting".into()),
439            source: "repl".into(),
440            provider: "mock".into(),
441            model: "m".into(),
442            created_at: timestamp_now(),
443            updated_at: timestamp_now(),
444            parent_session_id: None,
445            message_count: 1,
446            prompt_count: 1,
447            first_user_message: None,
448        };
449        mgr.save(&session, &meta).unwrap();
450
451        let results = mgr.search("rust").unwrap();
452        assert!(results.is_empty());
453    }
454
455    #[test]
456    fn save_resume_save_preserves_full_conversation() {
457        let dir = tempfile::tempdir().unwrap();
458        let mgr = SessionManager::with_home(dir.path().join("sessions"));
459
460        let mut session = ChatSession::new();
461        session.push_user("first question");
462        let id = session.session_id.to_string();
463        let meta1 = SessionMeta {
464            id: id.clone(),
465            title: Some("debugging".into()),
466            source: "repl".into(),
467            provider: "mock".into(),
468            model: "m".into(),
469            created_at: timestamp_now(),
470            updated_at: timestamp_now(),
471            parent_session_id: None,
472            message_count: session.len(),
473            prompt_count: 1,
474            first_user_message: None,
475        };
476        mgr.save(&session, &meta1).unwrap();
477
478        // Resume: load, add more turns, save again.
479        let mut loaded = mgr.load(&id).unwrap();
480        loaded.push_user("follow-up question");
481        let meta2 = SessionMeta {
482            id: id.clone(),
483            title: Some("debugging".into()),
484            source: "repl".into(),
485            provider: "mock".into(),
486            model: "m".into(),
487            created_at: meta1.created_at.clone(),
488            updated_at: timestamp_now(),
489            parent_session_id: None,
490            message_count: loaded.len(),
491            prompt_count: 2,
492            first_user_message: None,
493        };
494        mgr.save(&loaded, &meta2).unwrap();
495
496        // Reload and verify full history.
497        let final_session = mgr.load(&id).unwrap();
498        assert_eq!(final_session.messages.len(), 2);
499        assert_eq!(final_session.messages[0].text_content(), "first question");
500        assert_eq!(
501            final_session.messages[1].text_content(),
502            "follow-up question"
503        );
504
505        let final_meta = mgr.load_meta(&id).unwrap();
506        assert_eq!(final_meta.message_count, 2);
507        assert_eq!(final_meta.prompt_count, 2);
508    }
509
510    #[test]
511    fn auto_populated_first_user_message_on_load() {
512        let dir = tempfile::tempdir().unwrap();
513        let mgr = SessionManager::with_home(dir.path().join("sessions"));
514
515        let mut session = ChatSession::new();
516        session.push_user("debug the rust build error");
517        let id = session.session_id.to_string();
518        let meta = SessionMeta {
519            id,
520            title: None,
521            source: "repl".into(),
522            provider: "mock".into(),
523            model: "m".into(),
524            created_at: timestamp_now(),
525            updated_at: timestamp_now(),
526            parent_session_id: None,
527            message_count: 1,
528            prompt_count: 1,
529            first_user_message: None,
530        };
531        mgr.save(&session, &meta).unwrap();
532
533        let loaded_meta = mgr.load_meta(&session.session_id.to_string()).unwrap();
534        assert_eq!(
535            loaded_meta.first_user_message.as_deref(),
536            Some("debug the rust build error")
537        );
538    }
539
540    #[test]
541    fn save_rejects_id_mismatch() {
542        let dir = tempfile::tempdir().unwrap();
543        let mgr = SessionManager::with_home(dir.path().join("sessions"));
544
545        let session = ChatSession::new();
546        let bad_meta = SessionMeta {
547            id: "wrong-id".into(),
548            title: None,
549            source: "repl".into(),
550            provider: "mock".into(),
551            model: "m".into(),
552            created_at: timestamp_now(),
553            updated_at: timestamp_now(),
554            parent_session_id: None,
555            message_count: 0,
556            prompt_count: 0,
557            first_user_message: None,
558        };
559        let err = mgr.save(&session, &bad_meta).unwrap_err();
560        assert!(
561            err.to_string().contains("mismatch"),
562            "expected ID mismatch error, got: {err}"
563        );
564    }
565}