1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct SessionData {
7 pub id: String,
8 pub summary: String,
9 #[serde(default)]
10 pub name: String,
11 #[serde(default)]
12 pub pending_tasks: String,
13 #[serde(default)]
14 pub decisions: String,
15 #[serde(default)]
16 pub files_touched: String,
17 #[serde(default)]
18 pub constraints: String,
19 #[serde(default)]
20 pub assumptions: String,
21 #[serde(default)]
22 pub blockers: String,
23 #[serde(default)]
24 pub created_at: i64,
25 #[serde(default)]
26 pub updated_at: i64,
27}
28
29pub struct SessionStore {
30 sessions_dir: PathBuf,
31}
32
33impl SessionStore {
34 pub fn open(project_root: &Path) -> Result<Self> {
35 let sessions_dir = project_root.join(".infigraph").join("sessions");
36 std::fs::create_dir_all(&sessions_dir)?;
37 let store = Self { sessions_dir };
38 store.migrate_from_kuzu()?;
39 Ok(store)
40 }
41
42 pub fn sessions_dir(&self) -> &Path {
43 &self.sessions_dir
44 }
45
46 pub fn save(&self, session: &SessionData) -> Result<()> {
47 let path = self.session_path(&session.id);
48 let json = serde_json::to_string_pretty(session)?;
49 std::fs::write(&path, json)?;
50 Ok(())
51 }
52
53 pub fn load(&self, session_id: &str) -> Result<Option<SessionData>> {
54 let path = self.session_path(session_id);
55 if !path.exists() {
56 return Ok(None);
57 }
58 let content = std::fs::read_to_string(&path)?;
59 let session: SessionData = serde_json::from_str(&content)
60 .with_context(|| format!("failed to parse session: {}", path.display()))?;
61 Ok(Some(session))
62 }
63
64 pub fn list_all(&self) -> Result<Vec<SessionData>> {
65 let mut sessions = Vec::new();
66 for entry in std::fs::read_dir(&self.sessions_dir)? {
67 let entry = entry?;
68 let name = entry.file_name();
69 let name_str = name.to_string_lossy();
70 if name_str.starts_with("session_") && name_str.ends_with(".json") {
71 if let Ok(content) = std::fs::read_to_string(entry.path()) {
72 if let Ok(session) = serde_json::from_str::<SessionData>(&content) {
73 sessions.push(session);
74 }
75 }
76 }
77 }
78 sessions.sort_by_key(|s| std::cmp::Reverse(s.created_at));
79 Ok(sessions)
80 }
81
82 pub fn list_recent(&self, limit: usize) -> Result<Vec<SessionData>> {
83 let mut all = self.list_all()?;
84 all.truncate(limit);
85 Ok(all)
86 }
87
88 pub fn list_by_updated(&self) -> Result<Vec<SessionData>> {
89 let mut sessions = self.list_all()?;
90 sessions.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
91 Ok(sessions)
92 }
93
94 pub fn load_by_name(&self, name: &str) -> Result<Option<SessionData>> {
95 let id = format!("named_{}", name.to_lowercase().replace(' ', "_"));
96 self.load(&id)
97 }
98
99 pub fn delete(&self, session_id: &str) -> Result<()> {
100 let path = self.session_path(session_id);
101 if path.exists() {
102 std::fs::remove_file(&path)?;
103 }
104 Ok(())
105 }
106
107 fn read_kuzu_sessions(db_path: &Path) -> Vec<SessionData> {
108 let db = match kuzu::Database::new(db_path, kuzu::SystemConfig::default()) {
109 Ok(db) => db,
110 Err(_) => return Vec::new(),
111 };
112 let conn = match kuzu::Connection::new(&db) {
113 Ok(c) => c,
114 Err(_) => return Vec::new(),
115 };
116 let query = "MATCH (s:Session) RETURN s.id, s.summary, s.pending_tasks, s.decisions, \
117 s.files_touched, s.created_at, s.updated_at, s.constraints, s.assumptions, s.blockers";
118 let result = match conn.query(query) {
119 Ok(r) => r,
120 Err(_) => return Vec::new(),
121 };
122 let mut collected = Vec::new();
123 for row in result {
124 let get = |i: usize| {
125 row.get(i)
126 .map(|v| v.to_string())
127 .unwrap_or_default()
128 .trim_matches('"')
129 .to_string()
130 };
131 let id = get(0);
132 if id.is_empty() {
133 continue;
134 }
135 let created: i64 = get(5).parse().unwrap_or(0);
136 let updated: i64 = get(6).parse().unwrap_or(created);
137 collected.push(SessionData {
138 id,
139 name: String::new(),
140 summary: get(1),
141 pending_tasks: get(2),
142 decisions: get(3),
143 files_touched: get(4),
144 constraints: get(7),
145 assumptions: get(8),
146 blockers: get(9),
147 created_at: created,
148 updated_at: updated,
149 });
150 }
151 collected
152 }
153
154 fn session_path(&self, session_id: &str) -> PathBuf {
155 self.sessions_dir.join(format!("{session_id}.json"))
156 }
157
158 pub fn open_dir(sessions_dir: &Path) -> Result<Self> {
159 std::fs::create_dir_all(sessions_dir)?;
160 Ok(Self {
161 sessions_dir: sessions_dir.to_path_buf(),
162 })
163 }
164
165 fn migrate_from_kuzu(&self) -> Result<()> {
166 let db_path = self.sessions_dir.join("db");
167 if !db_path.exists() {
168 return Ok(());
169 }
170
171 let sessions = Self::read_kuzu_sessions(&db_path);
172
173 let mut count = 0u32;
174 for session in &sessions {
175 let json_path = self.session_path(&session.id);
176 if json_path.exists() {
177 continue;
178 }
179 let json = serde_json::to_string_pretty(session)?;
180 std::fs::write(&json_path, json)?;
181 count += 1;
182 }
183
184 let _ = std::fs::remove_file(&db_path);
185 let _ = std::fs::remove_file(self.sessions_dir.join(".migrated_to_json"));
186 let _ = std::fs::remove_file(self.sessions_dir.join("latest_session.json"));
187 eprintln!("Migrated {count} session(s) from KuzuDB to JSON files, removed old session DB");
188 Ok(())
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn make_session(id: &str, created_at: i64, updated_at: i64) -> SessionData {
197 SessionData {
198 id: id.to_string(),
199 name: String::new(),
200 summary: format!("work on {id}"),
201 pending_tasks: String::new(),
202 decisions: String::new(),
203 files_touched: String::new(),
204 constraints: String::new(),
205 assumptions: String::new(),
206 blockers: String::new(),
207 created_at,
208 updated_at,
209 }
210 }
211
212 #[test]
213 fn test_save_load_roundtrip() {
214 let dir = tempfile::tempdir().unwrap();
215 let store = SessionStore::open_dir(dir.path()).unwrap();
216 let s = make_session("session_2026-06-08", 1000, 2000);
217 store.save(&s).unwrap();
218 let loaded = store.load("session_2026-06-08").unwrap().unwrap();
219 assert_eq!(loaded.id, "session_2026-06-08");
220 assert_eq!(loaded.updated_at, 2000);
221 }
222
223 #[test]
224 fn test_list_all_sorted_by_created() {
225 let dir = tempfile::tempdir().unwrap();
226 let store = SessionStore::open_dir(dir.path()).unwrap();
227 store
228 .save(&make_session("session_2026-06-05", 100, 200))
229 .unwrap();
230 store
231 .save(&make_session("session_2026-06-07", 300, 400))
232 .unwrap();
233 store
234 .save(&make_session("session_2026-06-06", 200, 500))
235 .unwrap();
236
237 let all = store.list_all().unwrap();
238 assert_eq!(all.len(), 3);
239 assert_eq!(all[0].id, "session_2026-06-07");
240 assert_eq!(all[1].id, "session_2026-06-06");
241 assert_eq!(all[2].id, "session_2026-06-05");
242 }
243
244 #[test]
245 fn test_list_by_updated_sorted() {
246 let dir = tempfile::tempdir().unwrap();
247 let store = SessionStore::open_dir(dir.path()).unwrap();
248 store
249 .save(&make_session("session_2026-06-05", 100, 500))
250 .unwrap();
251 store
252 .save(&make_session("session_2026-06-07", 300, 300))
253 .unwrap();
254 store
255 .save(&make_session("session_2026-06-06", 200, 400))
256 .unwrap();
257
258 let sorted = store.list_by_updated().unwrap();
259 assert_eq!(sorted[0].id, "session_2026-06-05");
260 assert_eq!(sorted[1].id, "session_2026-06-06");
261 assert_eq!(sorted[2].id, "session_2026-06-07");
262 }
263
264 #[test]
265 fn test_list_recent_truncates() {
266 let dir = tempfile::tempdir().unwrap();
267 let store = SessionStore::open_dir(dir.path()).unwrap();
268 store
269 .save(&make_session("session_2026-06-05", 100, 100))
270 .unwrap();
271 store
272 .save(&make_session("session_2026-06-06", 200, 200))
273 .unwrap();
274 store
275 .save(&make_session("session_2026-06-07", 300, 300))
276 .unwrap();
277
278 let recent = store.list_recent(2).unwrap();
279 assert_eq!(recent.len(), 2);
280 assert_eq!(recent[0].id, "session_2026-06-07");
281 assert_eq!(recent[1].id, "session_2026-06-06");
282 }
283
284 #[test]
285 fn test_delete_session() {
286 let dir = tempfile::tempdir().unwrap();
287 let store = SessionStore::open_dir(dir.path()).unwrap();
288 store
289 .save(&make_session("session_2026-06-08", 100, 100))
290 .unwrap();
291 assert!(store.load("session_2026-06-08").unwrap().is_some());
292 store.delete("session_2026-06-08").unwrap();
293 assert!(store.load("session_2026-06-08").unwrap().is_none());
294 }
295
296 #[test]
297 fn test_load_nonexistent() {
298 let dir = tempfile::tempdir().unwrap();
299 let store = SessionStore::open_dir(dir.path()).unwrap();
300 assert!(store.load("session_nope").unwrap().is_none());
301 }
302}