Skip to main content

infigraph_core/graph/
session_store.rs

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}