gestura_core_sessions/agent_sessions/
store.rs

1//! File-backed agent session store.
2
3use chrono::{Datelike, Local, NaiveDate};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use gestura_core_foundation::AppError;
8
9use super::types::AgentSession;
10
11/// Result type for agent session store operations.
12pub type AgentSessionResult<T> = Result<T, AppError>;
13
14/// Filter for listing sessions.
15#[derive(Debug, Clone, Default)]
16pub enum SessionFilter {
17    /// Return all sessions.
18    #[default]
19    All,
20    /// Sessions created today (local time).
21    Today,
22    /// Sessions created within the current week (local time).
23    ThisWeek,
24    /// Sessions created within the current month (local time).
25    ThisMonth,
26    /// Sessions created within an optional inclusive date range.
27    DateRange {
28        /// Inclusive start date.
29        from: Option<NaiveDate>,
30        /// Inclusive end date.
31        to: Option<NaiveDate>,
32    },
33}
34
35/// Minimal session info used for list UIs.
36#[derive(Debug, Clone)]
37pub struct SessionInfo {
38    /// Session id.
39    pub id: String,
40    /// Title.
41    pub title: String,
42    /// Creation time.
43    pub created_at: chrono::DateTime<chrono::Utc>,
44    /// Last activity time.
45    pub last_active: chrono::DateTime<chrono::Utc>,
46    /// Message count.
47    pub message_count: usize,
48    /// Optional model.
49    pub model: Option<String>,
50}
51
52/// A storage abstraction for agent sessions.
53pub trait AgentSessionStore {
54    /// Save a session.
55    fn save(&self, session: &AgentSession) -> AgentSessionResult<()>;
56
57    /// Load a session by id.
58    fn load(&self, id: &str) -> AgentSessionResult<AgentSession>;
59
60    /// Delete a session by id.
61    fn delete(&self, id: &str) -> AgentSessionResult<bool>;
62
63    /// List sessions matching a filter.
64    fn list(&self, filter: SessionFilter) -> AgentSessionResult<Vec<SessionInfo>>;
65
66    /// Load the most recently active session.
67    fn load_last(&self) -> AgentSessionResult<Option<AgentSession>>;
68
69    /// Find a session id by prefix (used for CLI convenience).
70    fn find_by_prefix(&self, prefix: &str) -> AgentSessionResult<Option<String>>;
71}
72
73/// Returns the Gestura data directory (`~/.gestura/`).
74///
75/// This mirrors `AppConfig::data_dir()` while keeping the sessions crate
76/// independent from the config module.
77fn gestura_data_dir() -> PathBuf {
78    super::gestura_home_dir().join(".gestura")
79}
80
81/// Default directory for persisted agent sessions.
82///
83/// This is intentionally **separate** from `session_workspace::get_sessions_base_dir()`
84/// to keep persistence outside sandbox workspaces.
85pub fn default_agent_sessions_dir() -> PathBuf {
86    gestura_data_dir().join("agent_sessions")
87}
88
89/// File-backed session store (one JSON file per session).
90#[derive(Debug, Clone)]
91pub struct FileAgentSessionStore {
92    dir: PathBuf,
93}
94
95impl FileAgentSessionStore {
96    /// Create a store rooted at a custom directory.
97    pub fn new(dir: PathBuf) -> Self {
98        Self { dir }
99    }
100
101    /// Create a store using the default directory.
102    pub fn new_default() -> Self {
103        Self::new(default_agent_sessions_dir())
104    }
105
106    fn ensure_dir(&self) -> AgentSessionResult<()> {
107        fs::create_dir_all(&self.dir)?;
108        Ok(())
109    }
110
111    fn validate_session_id(&self, id: &str) -> AgentSessionResult<()> {
112        if id.is_empty() {
113            return Err(AppError::InvalidInput("session id is empty".to_string()));
114        }
115        if id.len() > 128 {
116            return Err(AppError::InvalidInput("session id too long".to_string()));
117        }
118        if id.contains(['/', '\\']) || id.contains("..") {
119            return Err(AppError::InvalidInput("invalid session id".to_string()));
120        }
121        Ok(())
122    }
123
124    fn path_for(&self, id: &str) -> AgentSessionResult<PathBuf> {
125        self.validate_session_id(id)?;
126        Ok(self.dir.join(format!("{id}.json")))
127    }
128
129    fn matches_filter(&self, session: &AgentSession, filter: &SessionFilter) -> bool {
130        match filter {
131            SessionFilter::All => true,
132            SessionFilter::Today => {
133                let created = session.created_at.with_timezone(&Local).date_naive();
134                created == Local::now().date_naive()
135            }
136            SessionFilter::ThisWeek => {
137                let created = session.created_at.with_timezone(&Local).date_naive();
138                let now = Local::now().date_naive();
139                let created_week = created.iso_week();
140                let now_week = now.iso_week();
141                created_week.week() == now_week.week() && created_week.year() == now_week.year()
142            }
143            SessionFilter::ThisMonth => {
144                let created = session.created_at.with_timezone(&Local);
145                let now = Local::now();
146                created.year() == now.year() && created.month() == now.month()
147            }
148            SessionFilter::DateRange { from, to } => {
149                let created = session.created_at.with_timezone(&Local).date_naive();
150                if let Some(from) = from
151                    && created < *from
152                {
153                    return false;
154                }
155                if let Some(to) = to
156                    && created > *to
157                {
158                    return false;
159                }
160                true
161            }
162        }
163    }
164
165    fn load_from_path(&self, path: &Path) -> AgentSessionResult<AgentSession> {
166        let json = fs::read_to_string(path)?;
167        Ok(serde_json::from_str(&json)?)
168    }
169}
170
171impl Default for FileAgentSessionStore {
172    fn default() -> Self {
173        Self::new_default()
174    }
175}
176
177impl AgentSessionStore for FileAgentSessionStore {
178    fn save(&self, session: &AgentSession) -> AgentSessionResult<()> {
179        self.ensure_dir()?;
180        let path = self.path_for(&session.id)?;
181        let json = serde_json::to_string_pretty(session)?;
182        fs::write(path, json)?;
183        Ok(())
184    }
185
186    fn load(&self, id: &str) -> AgentSessionResult<AgentSession> {
187        let path = self.path_for(id)?;
188        if !path.exists() {
189            return Err(AppError::NotFound(format!("session '{id}' not found")));
190        }
191        self.load_from_path(&path)
192    }
193
194    fn delete(&self, id: &str) -> AgentSessionResult<bool> {
195        let path = self.path_for(id)?;
196        if path.exists() {
197            fs::remove_file(path)?;
198            Ok(true)
199        } else {
200            Ok(false)
201        }
202    }
203
204    fn list(&self, filter: SessionFilter) -> AgentSessionResult<Vec<SessionInfo>> {
205        if !self.dir.exists() {
206            return Ok(Vec::new());
207        }
208
209        let mut sessions = Vec::new();
210        for entry in fs::read_dir(&self.dir)? {
211            let entry = entry?;
212            let path = entry.path();
213            if path
214                .extension()
215                .is_some_and(|ext| ext == std::ffi::OsStr::new("json"))
216            {
217                let session = match self.load_from_path(&path) {
218                    Ok(s) => s,
219                    Err(_) => continue,
220                };
221                if self.matches_filter(&session, &filter) {
222                    let message_count = session.message_count();
223                    sessions.push(SessionInfo {
224                        id: session.id,
225                        title: session.title,
226                        created_at: session.created_at,
227                        last_active: session.last_active,
228                        message_count,
229                        model: session.model,
230                    });
231                }
232            }
233        }
234
235        sessions.sort_by(|a, b| b.last_active.cmp(&a.last_active));
236        Ok(sessions)
237    }
238
239    fn load_last(&self) -> AgentSessionResult<Option<AgentSession>> {
240        let infos = self.list(SessionFilter::All)?;
241        if let Some(info) = infos.first() {
242            return Ok(Some(self.load(&info.id)?));
243        }
244        Ok(None)
245    }
246
247    fn find_by_prefix(&self, prefix: &str) -> AgentSessionResult<Option<String>> {
248        let infos = self.list(SessionFilter::All)?;
249        for info in infos {
250            if info.id.starts_with(prefix) {
251                return Ok(Some(info.id));
252            }
253        }
254        Ok(None)
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::agent_sessions::MessageSource;
262    use tempfile::tempdir;
263
264    #[test]
265    fn roundtrip_save_load() {
266        let temp = tempdir().unwrap();
267        let store = FileAgentSessionStore::new(temp.path().to_path_buf());
268
269        let workspace_dir = temp.path().join("workspace");
270        std::fs::create_dir_all(&workspace_dir).unwrap();
271
272        let mut session =
273            AgentSession::new_with_workspace(workspace_dir, Some("test-model".to_string()))
274                .unwrap();
275        session.add_user_message("hello", MessageSource::Text);
276        store.save(&session).unwrap();
277
278        let loaded = store.load(&session.id).unwrap();
279        assert_eq!(loaded.id, session.id);
280        assert_eq!(loaded.model, session.model);
281        assert_eq!(loaded.message_count(), 1);
282    }
283
284    #[test]
285    fn list_and_find_by_prefix() {
286        let temp = tempdir().unwrap();
287        let store = FileAgentSessionStore::new(temp.path().to_path_buf());
288
289        let workspace_dir = temp.path().join("workspace2");
290        std::fs::create_dir_all(&workspace_dir).unwrap();
291        let session = AgentSession::new_with_workspace(workspace_dir, None).unwrap();
292        store.save(&session).unwrap();
293
294        let infos = store.list(SessionFilter::All).unwrap();
295        assert_eq!(infos.len(), 1);
296        let prefix = &session.id[..8];
297        let found = store.find_by_prefix(prefix).unwrap();
298        assert_eq!(found, Some(session.id));
299    }
300}