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 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 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 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 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(×tamp).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(×tamp).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}