gestura_core_sessions/agent_sessions/
store.rs1use chrono::{Datelike, Local, NaiveDate};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use gestura_core_foundation::AppError;
8
9use super::types::AgentSession;
10
11pub type AgentSessionResult<T> = Result<T, AppError>;
13
14#[derive(Debug, Clone, Default)]
16pub enum SessionFilter {
17 #[default]
19 All,
20 Today,
22 ThisWeek,
24 ThisMonth,
26 DateRange {
28 from: Option<NaiveDate>,
30 to: Option<NaiveDate>,
32 },
33}
34
35#[derive(Debug, Clone)]
37pub struct SessionInfo {
38 pub id: String,
40 pub title: String,
42 pub created_at: chrono::DateTime<chrono::Utc>,
44 pub last_active: chrono::DateTime<chrono::Utc>,
46 pub message_count: usize,
48 pub model: Option<String>,
50}
51
52pub trait AgentSessionStore {
54 fn save(&self, session: &AgentSession) -> AgentSessionResult<()>;
56
57 fn load(&self, id: &str) -> AgentSessionResult<AgentSession>;
59
60 fn delete(&self, id: &str) -> AgentSessionResult<bool>;
62
63 fn list(&self, filter: SessionFilter) -> AgentSessionResult<Vec<SessionInfo>>;
65
66 fn load_last(&self) -> AgentSessionResult<Option<AgentSession>>;
68
69 fn find_by_prefix(&self, prefix: &str) -> AgentSessionResult<Option<String>>;
71}
72
73fn gestura_data_dir() -> PathBuf {
78 super::gestura_home_dir().join(".gestura")
79}
80
81pub fn default_agent_sessions_dir() -> PathBuf {
86 gestura_data_dir().join("agent_sessions")
87}
88
89#[derive(Debug, Clone)]
91pub struct FileAgentSessionStore {
92 dir: PathBuf,
93}
94
95impl FileAgentSessionStore {
96 pub fn new(dir: PathBuf) -> Self {
98 Self { dir }
99 }
100
101 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}