gitent_core/
storage.rs

1use crate::error::{Error, Result};
2use crate::models::{Change, ChangeType, Commit, CommitInfo, Session};
3use chrono::DateTime;
4use rusqlite::{params, Connection, OptionalExtension, Row};
5use std::path::{Path, PathBuf};
6use uuid::Uuid;
7
8const SCHEMA_VERSION: i32 = 1;
9
10pub struct Storage {
11    conn: Connection,
12}
13
14impl Storage {
15    pub fn new<P: AsRef<Path>>(db_path: P) -> Result<Self> {
16        let conn = Connection::open(db_path)?;
17        let mut storage = Self { conn };
18        storage.initialize()?;
19        Ok(storage)
20    }
21
22    pub fn in_memory() -> Result<Self> {
23        let conn = Connection::open_in_memory()?;
24        let mut storage = Self { conn };
25        storage.initialize()?;
26        Ok(storage)
27    }
28
29    fn initialize(&mut self) -> Result<()> {
30        self.conn.execute_batch(
31            r#"
32            CREATE TABLE IF NOT EXISTS schema_version (
33                version INTEGER PRIMARY KEY
34            );
35
36            CREATE TABLE IF NOT EXISTS sessions (
37                id TEXT PRIMARY KEY,
38                root_path TEXT NOT NULL,
39                started TEXT NOT NULL,
40                ended TEXT,
41                active INTEGER NOT NULL,
42                ignore_patterns TEXT NOT NULL
43            );
44
45            CREATE TABLE IF NOT EXISTS changes (
46                id TEXT PRIMARY KEY,
47                session_id TEXT NOT NULL,
48                timestamp TEXT NOT NULL,
49                change_type TEXT NOT NULL,
50                path TEXT NOT NULL,
51                old_path TEXT,
52                content_before BLOB,
53                content_after BLOB,
54                content_hash_before TEXT,
55                content_hash_after TEXT,
56                agent_id TEXT,
57                metadata TEXT NOT NULL,
58                FOREIGN KEY (session_id) REFERENCES sessions(id)
59            );
60
61            CREATE TABLE IF NOT EXISTS commits (
62                id TEXT PRIMARY KEY,
63                session_id TEXT NOT NULL,
64                parent TEXT,
65                timestamp TEXT NOT NULL,
66                message TEXT NOT NULL,
67                agent_id TEXT NOT NULL,
68                metadata TEXT NOT NULL,
69                FOREIGN KEY (session_id) REFERENCES sessions(id),
70                FOREIGN KEY (parent) REFERENCES commits(id)
71            );
72
73            CREATE TABLE IF NOT EXISTS commit_changes (
74                commit_id TEXT NOT NULL,
75                change_id TEXT NOT NULL,
76                PRIMARY KEY (commit_id, change_id),
77                FOREIGN KEY (commit_id) REFERENCES commits(id),
78                FOREIGN KEY (change_id) REFERENCES changes(id)
79            );
80
81            CREATE INDEX IF NOT EXISTS idx_changes_session ON changes(session_id);
82            CREATE INDEX IF NOT EXISTS idx_changes_timestamp ON changes(timestamp);
83            CREATE INDEX IF NOT EXISTS idx_commits_session ON commits(session_id);
84            CREATE INDEX IF NOT EXISTS idx_commits_timestamp ON commits(timestamp);
85            CREATE INDEX IF NOT EXISTS idx_commits_parent ON commits(parent);
86            "#,
87        )?;
88
89        let version: Option<i32> = self
90            .conn
91            .query_row("SELECT version FROM schema_version", [], |row| row.get(0))
92            .optional()?;
93
94        if version.is_none() {
95            self.conn.execute(
96                "INSERT INTO schema_version (version) VALUES (?1)",
97                params![SCHEMA_VERSION],
98            )?;
99        }
100
101        Ok(())
102    }
103
104    // Session operations
105    pub fn create_session(&self, session: &Session) -> Result<()> {
106        let ignore_patterns = serde_json::to_string(&session.ignore_patterns)?;
107
108        self.conn.execute(
109            "INSERT INTO sessions (id, root_path, started, ended, active, ignore_patterns)
110             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
111            params![
112                session.id.to_string(),
113                session.root_path.to_string_lossy().as_ref(),
114                session.started.to_rfc3339(),
115                session.ended.map(|dt| dt.to_rfc3339()),
116                session.active as i32,
117                ignore_patterns,
118            ],
119        )?;
120
121        Ok(())
122    }
123
124    pub fn get_session(&self, id: &Uuid) -> Result<Session> {
125        self.conn
126            .query_row(
127                "SELECT id, root_path, started, ended, active, ignore_patterns FROM sessions WHERE id = ?1",
128                params![id.to_string()],
129                |row| self.session_from_row(row),
130            )
131            .map_err(|_| Error::SessionNotFound(id.to_string()))
132    }
133
134    pub fn get_active_session(&self) -> Result<Session> {
135        self.conn
136            .query_row(
137                "SELECT id, root_path, started, ended, active, ignore_patterns FROM sessions WHERE active = 1 LIMIT 1",
138                [],
139                |row| self.session_from_row(row),
140            )
141            .map_err(|_| Error::NoActiveSession)
142    }
143
144    pub fn update_session(&self, session: &Session) -> Result<()> {
145        let ignore_patterns = serde_json::to_string(&session.ignore_patterns)?;
146
147        self.conn.execute(
148            "UPDATE sessions SET ended = ?1, active = ?2, ignore_patterns = ?3 WHERE id = ?4",
149            params![
150                session.ended.map(|dt| dt.to_rfc3339()),
151                session.active as i32,
152                ignore_patterns,
153                session.id.to_string(),
154            ],
155        )?;
156
157        Ok(())
158    }
159
160    // Change operations
161    pub fn create_change(&self, change: &Change) -> Result<()> {
162        let metadata = serde_json::to_string(&change.metadata)?;
163
164        self.conn.execute(
165            "INSERT INTO changes (id, session_id, timestamp, change_type, path, old_path,
166                                  content_before, content_after, content_hash_before, content_hash_after,
167                                  agent_id, metadata)
168             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
169            params![
170                change.id.to_string(),
171                change.session_id.to_string(),
172                change.timestamp.to_rfc3339(),
173                change.change_type.as_str(),
174                change.path.to_string_lossy().as_ref(),
175                change.old_path.as_ref().map(|p| p.to_string_lossy().to_string()),
176                change.content_before.as_ref(),
177                change.content_after.as_ref(),
178                change.content_hash_before.as_ref(),
179                change.content_hash_after.as_ref(),
180                change.agent_id.as_ref(),
181                metadata,
182            ],
183        )?;
184
185        Ok(())
186    }
187
188    pub fn get_change(&self, id: &Uuid) -> Result<Change> {
189        self.conn
190            .query_row(
191                "SELECT id, session_id, timestamp, change_type, path, old_path,
192                        content_before, content_after, content_hash_before, content_hash_after,
193                        agent_id, metadata FROM changes WHERE id = ?1",
194                params![id.to_string()],
195                |row| self.change_from_row(row),
196            )
197            .map_err(|_| Error::ChangeNotFound(id.to_string()))
198    }
199
200    pub fn get_uncommitted_changes(&self, session_id: &Uuid) -> Result<Vec<Change>> {
201        let mut stmt = self.conn.prepare(
202            "SELECT c.id, c.session_id, c.timestamp, c.change_type, c.path, c.old_path,
203                    c.content_before, c.content_after, c.content_hash_before, c.content_hash_after,
204                    c.agent_id, c.metadata
205             FROM changes c
206             WHERE c.session_id = ?1 AND c.id NOT IN (
207                 SELECT change_id FROM commit_changes
208             )
209             ORDER BY c.timestamp DESC",
210        )?;
211
212        let changes = stmt
213            .query_map(params![session_id.to_string()], |row| {
214                self.change_from_row(row)
215            })?
216            .collect::<rusqlite::Result<Vec<Change>>>()?;
217
218        Ok(changes)
219    }
220
221    // Commit operations
222    pub fn create_commit(&self, commit: &Commit) -> Result<()> {
223        let metadata = serde_json::to_string(&commit.metadata)?;
224
225        self.conn.execute(
226            "INSERT INTO commits (id, session_id, parent, timestamp, message, agent_id, metadata)
227             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
228            params![
229                commit.id.to_string(),
230                commit.session_id.to_string(),
231                commit.parent.as_ref().map(|p| p.to_string()),
232                commit.timestamp.to_rfc3339(),
233                commit.message,
234                commit.agent_id,
235                metadata,
236            ],
237        )?;
238
239        for change_id in &commit.changes {
240            self.conn.execute(
241                "INSERT INTO commit_changes (commit_id, change_id) VALUES (?1, ?2)",
242                params![commit.id.to_string(), change_id.to_string()],
243            )?;
244        }
245
246        Ok(())
247    }
248
249    pub fn get_commit(&self, id: &Uuid) -> Result<Commit> {
250        let commit = self
251            .conn
252            .query_row(
253                "SELECT id, session_id, parent, timestamp, message, agent_id, metadata
254                 FROM commits WHERE id = ?1",
255                params![id.to_string()],
256                |row| self.commit_from_row(row),
257            )
258            .map_err(|_| Error::CommitNotFound(id.to_string()))?;
259
260        Ok(commit)
261    }
262
263    pub fn get_commits_for_session(&self, session_id: &Uuid) -> Result<Vec<CommitInfo>> {
264        let mut stmt = self.conn.prepare(
265            "SELECT id, session_id, parent, timestamp, message, agent_id, metadata
266             FROM commits WHERE session_id = ?1 ORDER BY timestamp DESC",
267        )?;
268
269        let mut commits = Vec::new();
270        let rows = stmt.query_map(params![session_id.to_string()], |row| {
271            self.commit_from_row(row)
272        })?;
273
274        for commit_result in rows {
275            let commit = commit_result?;
276            let info = self.get_commit_info(&commit)?;
277            commits.push(info);
278        }
279
280        Ok(commits)
281    }
282
283    fn get_commit_info(&self, commit: &Commit) -> Result<CommitInfo> {
284        let changes: Vec<Change> = commit
285            .changes
286            .iter()
287            .filter_map(|id| self.get_change(id).ok())
288            .collect();
289
290        let files_affected: Vec<PathBuf> = changes.iter().map(|c| c.path.clone()).collect();
291
292        Ok(CommitInfo {
293            commit: commit.clone(),
294            change_count: changes.len(),
295            files_affected,
296        })
297    }
298
299    // Helper methods
300    fn session_from_row(&self, row: &Row) -> rusqlite::Result<Session> {
301        let id: String = row.get(0)?;
302        let root_path: String = row.get(1)?;
303        let started: String = row.get(2)?;
304        let ended: Option<String> = row.get(3)?;
305        let active: i32 = row.get(4)?;
306        let ignore_patterns: String = row.get(5)?;
307
308        Ok(Session {
309            id: Uuid::parse_str(&id).unwrap(),
310            root_path: PathBuf::from(root_path),
311            started: DateTime::parse_from_rfc3339(&started).unwrap().into(),
312            ended: ended.and_then(|s| DateTime::parse_from_rfc3339(&s).ok().map(|dt| dt.into())),
313            active: active != 0,
314            ignore_patterns: serde_json::from_str(&ignore_patterns).unwrap_or_default(),
315        })
316    }
317
318    fn change_from_row(&self, row: &Row) -> rusqlite::Result<Change> {
319        let id: String = row.get(0)?;
320        let session_id: String = row.get(1)?;
321        let timestamp: String = row.get(2)?;
322        let change_type: String = row.get(3)?;
323        let path: String = row.get(4)?;
324        let old_path: Option<String> = row.get(5)?;
325        let content_before: Option<Vec<u8>> = row.get(6)?;
326        let content_after: Option<Vec<u8>> = row.get(7)?;
327        let content_hash_before: Option<String> = row.get(8)?;
328        let content_hash_after: Option<String> = row.get(9)?;
329        let agent_id: Option<String> = row.get(10)?;
330        let metadata: String = row.get(11)?;
331
332        Ok(Change {
333            id: Uuid::parse_str(&id).unwrap(),
334            timestamp: DateTime::parse_from_rfc3339(&timestamp).unwrap().into(),
335            change_type: ChangeType::parse(&change_type).unwrap(),
336            path: PathBuf::from(path),
337            old_path: old_path.map(PathBuf::from),
338            content_before,
339            content_after,
340            content_hash_before,
341            content_hash_after,
342            agent_id,
343            metadata: serde_json::from_str(&metadata).unwrap_or_default(),
344            session_id: Uuid::parse_str(&session_id).unwrap(),
345        })
346    }
347
348    fn commit_from_row(&self, row: &Row) -> rusqlite::Result<Commit> {
349        let id: String = row.get(0)?;
350        let session_id: String = row.get(1)?;
351        let parent: Option<String> = row.get(2)?;
352        let timestamp: String = row.get(3)?;
353        let message: String = row.get(4)?;
354        let agent_id: String = row.get(5)?;
355        let metadata: String = row.get(6)?;
356
357        let changes = self.get_changes_for_commit(&id)?;
358
359        Ok(Commit {
360            id: Uuid::parse_str(&id).unwrap(),
361            parent: parent.and_then(|p| Uuid::parse_str(&p).ok()),
362            timestamp: DateTime::parse_from_rfc3339(&timestamp).unwrap().into(),
363            message,
364            agent_id,
365            changes,
366            session_id: Uuid::parse_str(&session_id).unwrap(),
367            metadata: serde_json::from_str(&metadata).unwrap_or_default(),
368        })
369    }
370
371    fn get_changes_for_commit(&self, commit_id: &str) -> rusqlite::Result<Vec<Uuid>> {
372        let mut stmt = self
373            .conn
374            .prepare("SELECT change_id FROM commit_changes WHERE commit_id = ?1")?;
375
376        let changes = stmt
377            .query_map(params![commit_id], |row| {
378                let id: String = row.get(0)?;
379                Ok(Uuid::parse_str(&id).unwrap())
380            })?
381            .collect::<rusqlite::Result<Vec<Uuid>>>()?;
382
383        Ok(changes)
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_storage_initialization() {
393        let storage = Storage::in_memory().unwrap();
394        assert!(storage.conn.is_autocommit());
395    }
396
397    #[test]
398    fn test_session_crud() {
399        let storage = Storage::in_memory().unwrap();
400        let session = Session::new(PathBuf::from("/test"));
401
402        storage.create_session(&session).unwrap();
403        let retrieved = storage.get_session(&session.id).unwrap();
404
405        assert_eq!(session.id, retrieved.id);
406        assert_eq!(session.root_path, retrieved.root_path);
407        assert!(retrieved.active);
408    }
409
410    #[test]
411    fn test_change_creation() {
412        let storage = Storage::in_memory().unwrap();
413        let session = Session::new(PathBuf::from("/test"));
414        storage.create_session(&session).unwrap();
415
416        let change = Change::new(ChangeType::Create, PathBuf::from("test.txt"), session.id)
417            .with_content_after(b"Hello".to_vec());
418
419        storage.create_change(&change).unwrap();
420        let retrieved = storage.get_change(&change.id).unwrap();
421
422        assert_eq!(change.id, retrieved.id);
423        assert_eq!(change.path, retrieved.path);
424        assert_eq!(change.change_type, retrieved.change_type);
425    }
426
427    #[test]
428    fn test_commit_with_changes() {
429        let storage = Storage::in_memory().unwrap();
430        let session = Session::new(PathBuf::from("/test"));
431        storage.create_session(&session).unwrap();
432
433        let change1 = Change::new(ChangeType::Create, PathBuf::from("file1.txt"), session.id);
434        let change2 = Change::new(ChangeType::Create, PathBuf::from("file2.txt"), session.id);
435
436        storage.create_change(&change1).unwrap();
437        storage.create_change(&change2).unwrap();
438
439        let commit = Commit::new(
440            "Test commit".to_string(),
441            "test-agent".to_string(),
442            vec![change1.id, change2.id],
443            session.id,
444        );
445
446        storage.create_commit(&commit).unwrap();
447        let retrieved = storage.get_commit(&commit.id).unwrap();
448
449        assert_eq!(commit.id, retrieved.id);
450        assert_eq!(commit.message, retrieved.message);
451        assert_eq!(2, retrieved.changes.len());
452    }
453}