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