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 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}