Skip to main content

rab/agent/
session_repo.rs

1use crate::agent::session::{
2    SessionInfo, delete_session as delete_session_file, fork_session, load_session_info,
3};
4use std::path::{Path, PathBuf};
5
6/// Session lifecycle management: create, open, list, delete, fork.
7///
8/// Default implementation uses JSONL files on disk.
9pub trait SessionRepo {
10    /// List sessions in a directory, optionally filtered by cwd.
11    /// `progress` receives `(loaded_count, total_count)` for UI updates.
12    fn list(
13        &self,
14        session_dir: &Path,
15        filter_cwd: Option<&Path>,
16        progress: Option<&dyn Fn(usize, usize)>,
17    ) -> Vec<SessionInfo>;
18
19    /// List sessions across all project directories under `~/.rab/sessions/`.
20    fn list_all(&self, progress: Option<&dyn Fn(usize, usize)>) -> Vec<SessionInfo>;
21
22    /// Delete a session file.
23    fn delete(&self, path: &Path) -> std::io::Result<()>;
24
25    /// Fork a session: create a new session file containing entries up to (and including)
26    /// the given entry_id, or all entries if entry_id is None.
27    fn fork(
28        &self,
29        source_path: &Path,
30        target_dir: &Path,
31        entry_id: Option<&str>,
32        position: Option<&str>,
33    ) -> std::io::Result<String>;
34
35    /// Load metadata for a single session file.
36    fn load_info(&self, path: &Path) -> Option<SessionInfo>;
37}
38
39// ── Default JSONL-based repo ───────────────────────────────────────
40
41/// Default session repo backed by JSONL files.
42pub struct DefaultSessionRepo;
43
44impl Default for DefaultSessionRepo {
45    fn default() -> Self {
46        Self
47    }
48}
49
50impl DefaultSessionRepo {
51    pub fn new() -> Self {
52        Self
53    }
54}
55
56impl SessionRepo for DefaultSessionRepo {
57    fn list(
58        &self,
59        session_dir: &Path,
60        filter_cwd: Option<&Path>,
61        progress: Option<&dyn Fn(usize, usize)>,
62    ) -> Vec<SessionInfo> {
63        list_sessions_with_progress(session_dir, filter_cwd, progress, 1)
64    }
65
66    fn list_all(&self, progress: Option<&dyn Fn(usize, usize)>) -> Vec<SessionInfo> {
67        let dir = directories::BaseDirs::new()
68            .map(|d| d.home_dir().join(".rab").join("sessions"))
69            .unwrap_or_else(|| PathBuf::from("/tmp/.rab/sessions"));
70
71        let mut all_sessions: Vec<SessionInfo> = Vec::new();
72
73        // Collect all session dirs + root
74        let mut dirs = vec![dir.clone()];
75        if let Ok(read_dir) = std::fs::read_dir(&dir) {
76            for entry in read_dir.flatten() {
77                let path = entry.path();
78                if path.is_dir() {
79                    dirs.push(path);
80                }
81            }
82        }
83
84        let total_dirs = dirs.len();
85        let mut loaded = 0;
86
87        for session_dir in &dirs {
88            let sessions = list_sessions_with_progress(session_dir, None, progress, 1);
89            loaded += 1;
90            if let Some(ref cb) = progress {
91                cb(loaded, total_dirs);
92            }
93            all_sessions.extend(sessions);
94        }
95
96        all_sessions.sort_by_key(|b| std::cmp::Reverse(b.created));
97        all_sessions
98    }
99
100    fn delete(&self, path: &Path) -> std::io::Result<()> {
101        delete_session_file(path)
102    }
103
104    fn fork(
105        &self,
106        source_path: &Path,
107        target_dir: &Path,
108        entry_id: Option<&str>,
109        position: Option<&str>,
110    ) -> std::io::Result<String> {
111        fork_session(source_path, target_dir, entry_id, position)
112    }
113
114    fn load_info(&self, path: &Path) -> Option<SessionInfo> {
115        load_session_info(path)
116    }
117}
118
119// ── Progress-aware listing ─────────────────────────────────────────
120
121/// List session files in a directory with optional cwd filtering and progress callback.
122/// Uses concurrent loading for better performance on large directories.
123fn list_sessions_with_progress(
124    session_dir: &Path,
125    filter_cwd: Option<&Path>,
126    progress: Option<&dyn Fn(usize, usize)>,
127    _concurrency: usize,
128) -> Vec<SessionInfo> {
129    let dir = match std::fs::read_dir(session_dir) {
130        Ok(d) => d,
131        Err(_) => return vec![],
132    };
133
134    // Collect all jsonl file paths first
135    let file_paths: Vec<PathBuf> = dir
136        .flatten()
137        .filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
138        .map(|e| e.path())
139        .collect();
140
141    let total = file_paths.len();
142    let mut sessions: Vec<SessionInfo> = Vec::with_capacity(total);
143    let mut loaded = 0;
144
145    // Sequential loading for now; concurrent loading can be added later
146    // using rayon or tokio::task::spawn_blocking.
147    for path in &file_paths {
148        // Parse header first for cwd filtering (cheap)
149        let header = crate::agent::session::read_session_header(path);
150        if let Some(ref h) = header
151            && let Some(filter) = filter_cwd
152            && h.cwd != filter.to_string_lossy().as_ref()
153        {
154            loaded += 1;
155            if let Some(ref cb) = progress {
156                cb(loaded, total);
157            }
158            continue;
159        }
160
161        // Load full session info
162        if let Some(info) = load_session_info(path) {
163            sessions.push(info);
164        }
165        loaded += 1;
166        if let Some(ref cb) = progress {
167            cb(loaded, total);
168        }
169    }
170
171    sessions.sort_by_key(|b| std::cmp::Reverse(b.created));
172    sessions
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use tempfile::TempDir;
179
180    #[test]
181    fn test_list_empty_dir() {
182        let repo = DefaultSessionRepo::new();
183        let tmp = TempDir::new().unwrap();
184        let sessions = repo.list(tmp.path(), None, None);
185        assert!(sessions.is_empty());
186    }
187}