1use anyhow::{Context, Result};
8use chrono::{DateTime, Utc};
9use rusqlite::{params, Connection, OptionalExtension};
10use std::path::PathBuf;
11use uuid::Uuid;
12
13use super::models::{Message, MessageContent, MessageRole, SearchResult, Session, SessionLink};
14
15fn parse_uuid(s: &str) -> rusqlite::Result<Uuid> {
19 Uuid::parse_str(s).map_err(|e| {
20 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
21 })
22}
23
24fn parse_datetime(s: &str) -> rusqlite::Result<DateTime<Utc>> {
28 chrono::DateTime::parse_from_rfc3339(s)
29 .map(|dt| dt.with_timezone(&Utc))
30 .map_err(|e| {
31 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
32 })
33}
34
35fn escape_fts5_query(query: &str) -> String {
40 query
42 .split_whitespace()
43 .map(|word| {
44 let escaped = word.replace('"', "\"\"");
45 format!("\"{escaped}\"")
46 })
47 .collect::<Vec<_>>()
48 .join(" ")
49}
50
51pub fn default_db_path() -> Result<PathBuf> {
55 let config_dir = dirs::home_dir()
56 .context("Could not find home directory. Ensure your HOME environment variable is set.")?
57 .join(".lore");
58
59 std::fs::create_dir_all(&config_dir).with_context(|| {
60 format!(
61 "Failed to create Lore data directory at {}. Check directory permissions.",
62 config_dir.display()
63 )
64 })?;
65 Ok(config_dir.join("lore.db"))
66}
67
68pub struct Database {
74 conn: Connection,
75}
76
77impl Database {
78 pub fn open(path: &PathBuf) -> Result<Self> {
82 let conn = Connection::open(path)?;
83 let db = Self { conn };
84 db.migrate()?;
85 Ok(db)
86 }
87
88 pub fn open_default() -> Result<Self> {
92 let path = default_db_path()?;
93 Self::open(&path)
94 }
95
96 fn migrate(&self) -> Result<()> {
101 self.conn.execute_batch(
102 r#"
103 CREATE TABLE IF NOT EXISTS schema_version (
104 version INTEGER PRIMARY KEY
105 );
106
107 CREATE TABLE IF NOT EXISTS sessions (
108 id TEXT PRIMARY KEY,
109 tool TEXT NOT NULL,
110 tool_version TEXT,
111 started_at TEXT NOT NULL,
112 ended_at TEXT,
113 model TEXT,
114 working_directory TEXT NOT NULL,
115 git_branch TEXT,
116 source_path TEXT,
117 message_count INTEGER NOT NULL DEFAULT 0,
118 created_at TEXT NOT NULL DEFAULT (datetime('now'))
119 );
120
121 CREATE TABLE IF NOT EXISTS messages (
122 id TEXT PRIMARY KEY,
123 session_id TEXT NOT NULL,
124 parent_id TEXT,
125 idx INTEGER NOT NULL,
126 timestamp TEXT NOT NULL,
127 role TEXT NOT NULL,
128 content TEXT NOT NULL,
129 model TEXT,
130 git_branch TEXT,
131 cwd TEXT,
132 FOREIGN KEY (session_id) REFERENCES sessions(id)
133 );
134
135 CREATE TABLE IF NOT EXISTS session_links (
136 id TEXT PRIMARY KEY,
137 session_id TEXT NOT NULL,
138 link_type TEXT NOT NULL,
139 commit_sha TEXT,
140 branch TEXT,
141 remote TEXT,
142 created_at TEXT NOT NULL,
143 created_by TEXT NOT NULL,
144 confidence REAL,
145 FOREIGN KEY (session_id) REFERENCES sessions(id)
146 );
147
148 CREATE TABLE IF NOT EXISTS repositories (
149 id TEXT PRIMARY KEY,
150 path TEXT NOT NULL UNIQUE,
151 name TEXT NOT NULL,
152 remote_url TEXT,
153 created_at TEXT NOT NULL DEFAULT (datetime('now')),
154 last_session_at TEXT
155 );
156
157 -- Indexes for common queries
158 CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
159 CREATE INDEX IF NOT EXISTS idx_sessions_working_directory ON sessions(working_directory);
160 CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
161 CREATE INDEX IF NOT EXISTS idx_session_links_session_id ON session_links(session_id);
162 CREATE INDEX IF NOT EXISTS idx_session_links_commit_sha ON session_links(commit_sha);
163 "#,
164 )?;
165
166 self.conn.execute_batch(
171 r#"
172 CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
173 message_id,
174 text_content,
175 tokenize='porter unicode61'
176 );
177 "#,
178 )?;
179
180 self.conn.execute_batch(
183 r#"
184 CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
185 session_id,
186 tool,
187 working_directory,
188 git_branch,
189 tokenize='porter unicode61'
190 );
191 "#,
192 )?;
193
194 Ok(())
195 }
196
197 pub fn insert_session(&self, session: &Session) -> Result<()> {
205 let rows_changed = self.conn.execute(
206 r#"
207 INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count)
208 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
209 ON CONFLICT(id) DO UPDATE SET
210 ended_at = ?5,
211 message_count = ?10
212 "#,
213 params![
214 session.id.to_string(),
215 session.tool,
216 session.tool_version,
217 session.started_at.to_rfc3339(),
218 session.ended_at.map(|t| t.to_rfc3339()),
219 session.model,
220 session.working_directory,
221 session.git_branch,
222 session.source_path,
223 session.message_count,
224 ],
225 )?;
226
227 if rows_changed > 0 {
229 let fts_count: i32 = self.conn.query_row(
231 "SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
232 params![session.id.to_string()],
233 |row| row.get(0),
234 )?;
235
236 if fts_count == 0 {
237 self.conn.execute(
238 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
239 params![
240 session.id.to_string(),
241 session.tool,
242 session.working_directory,
243 session.git_branch.as_deref().unwrap_or(""),
244 ],
245 )?;
246 }
247 }
248
249 Ok(())
250 }
251
252 pub fn get_session(&self, id: &Uuid) -> Result<Option<Session>> {
256 self.conn
257 .query_row(
258 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count FROM sessions WHERE id = ?1",
259 params![id.to_string()],
260 Self::row_to_session,
261 )
262 .optional()
263 .context("Failed to get session")
264 }
265
266 pub fn list_sessions(&self, limit: usize, working_dir: Option<&str>) -> Result<Vec<Session>> {
271 let mut stmt = if working_dir.is_some() {
272 self.conn.prepare(
273 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count
274 FROM sessions
275 WHERE working_directory LIKE ?1
276 ORDER BY started_at DESC
277 LIMIT ?2"
278 )?
279 } else {
280 self.conn.prepare(
281 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count
282 FROM sessions
283 ORDER BY started_at DESC
284 LIMIT ?1"
285 )?
286 };
287
288 let rows = if let Some(wd) = working_dir {
289 stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
290 } else {
291 stmt.query_map(params![limit], Self::row_to_session)?
292 };
293
294 rows.collect::<Result<Vec<_>, _>>()
295 .context("Failed to list sessions")
296 }
297
298 pub fn session_exists_by_source(&self, source_path: &str) -> Result<bool> {
302 let count: i32 = self.conn.query_row(
303 "SELECT COUNT(*) FROM sessions WHERE source_path = ?1",
304 params![source_path],
305 |row| row.get(0),
306 )?;
307 Ok(count > 0)
308 }
309
310 pub fn update_session_branch(&self, session_id: Uuid, new_branch: &str) -> Result<usize> {
318 let rows_changed = self.conn.execute(
319 "UPDATE sessions SET git_branch = ?1 WHERE id = ?2",
320 params![new_branch, session_id.to_string()],
321 )?;
322
323 if rows_changed > 0 {
325 self.conn.execute(
326 "UPDATE sessions_fts SET git_branch = ?1 WHERE session_id = ?2",
327 params![new_branch, session_id.to_string()],
328 )?;
329 }
330
331 Ok(rows_changed)
332 }
333
334 fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<Session> {
335 let ended_at_str: Option<String> = row.get(4)?;
336 let ended_at = match ended_at_str {
337 Some(s) => Some(parse_datetime(&s)?),
338 None => None,
339 };
340
341 Ok(Session {
342 id: parse_uuid(&row.get::<_, String>(0)?)?,
343 tool: row.get(1)?,
344 tool_version: row.get(2)?,
345 started_at: parse_datetime(&row.get::<_, String>(3)?)?,
346 ended_at,
347 model: row.get(5)?,
348 working_directory: row.get(6)?,
349 git_branch: row.get(7)?,
350 source_path: row.get(8)?,
351 message_count: row.get(9)?,
352 })
353 }
354
355 pub fn insert_message(&self, message: &Message) -> Result<()> {
363 let content_json = serde_json::to_string(&message.content)?;
364
365 let rows_changed = self.conn.execute(
366 r#"
367 INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
368 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
369 ON CONFLICT(id) DO NOTHING
370 "#,
371 params![
372 message.id.to_string(),
373 message.session_id.to_string(),
374 message.parent_id.map(|u| u.to_string()),
375 message.index,
376 message.timestamp.to_rfc3339(),
377 message.role.to_string(),
378 content_json,
379 message.model,
380 message.git_branch,
381 message.cwd,
382 ],
383 )?;
384
385 if rows_changed > 0 {
387 let text_content = message.content.text();
388 if !text_content.is_empty() {
389 self.conn.execute(
390 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
391 params![message.id.to_string(), text_content],
392 )?;
393 }
394 }
395
396 Ok(())
397 }
398
399 pub fn get_messages(&self, session_id: &Uuid) -> Result<Vec<Message>> {
403 let mut stmt = self.conn.prepare(
404 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
405 FROM messages
406 WHERE session_id = ?1
407 ORDER BY idx"
408 )?;
409
410 let rows = stmt.query_map(params![session_id.to_string()], |row| {
411 let role_str: String = row.get(5)?;
412 let content_str: String = row.get(6)?;
413
414 let parent_id_str: Option<String> = row.get(2)?;
415 let parent_id = match parent_id_str {
416 Some(s) => Some(parse_uuid(&s)?),
417 None => None,
418 };
419
420 Ok(Message {
421 id: parse_uuid(&row.get::<_, String>(0)?)?,
422 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
423 parent_id,
424 index: row.get(3)?,
425 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
426 role: match role_str.as_str() {
427 "user" => MessageRole::User,
428 "assistant" => MessageRole::Assistant,
429 "system" => MessageRole::System,
430 _ => MessageRole::User,
431 },
432 content: serde_json::from_str(&content_str)
433 .unwrap_or(MessageContent::Text(content_str)),
434 model: row.get(7)?,
435 git_branch: row.get(8)?,
436 cwd: row.get(9)?,
437 })
438 })?;
439
440 rows.collect::<Result<Vec<_>, _>>()
441 .context("Failed to get messages")
442 }
443
444 pub fn get_session_branch_history(&self, session_id: Uuid) -> Result<Vec<String>> {
453 let mut stmt = self
454 .conn
455 .prepare("SELECT git_branch FROM messages WHERE session_id = ?1 ORDER BY idx")?;
456
457 let rows = stmt.query_map(params![session_id.to_string()], |row| {
458 let branch: Option<String> = row.get(0)?;
459 Ok(branch)
460 })?;
461
462 let mut branches: Vec<String> = Vec::new();
464 for row in rows {
465 if let Some(branch) = row? {
466 if branches.last() != Some(&branch) {
468 branches.push(branch);
469 }
470 }
471 }
472
473 Ok(branches)
474 }
475
476 pub fn insert_link(&self, link: &SessionLink) -> Result<()> {
483 self.conn.execute(
484 r#"
485 INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
486 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
487 "#,
488 params![
489 link.id.to_string(),
490 link.session_id.to_string(),
491 format!("{:?}", link.link_type).to_lowercase(),
492 link.commit_sha,
493 link.branch,
494 link.remote,
495 link.created_at.to_rfc3339(),
496 format!("{:?}", link.created_by).to_lowercase(),
497 link.confidence,
498 ],
499 )?;
500 Ok(())
501 }
502
503 pub fn get_links_by_commit(&self, commit_sha: &str) -> Result<Vec<SessionLink>> {
508 let mut stmt = self.conn.prepare(
509 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
510 FROM session_links
511 WHERE commit_sha LIKE ?1"
512 )?;
513
514 let pattern = format!("{commit_sha}%");
515 let rows = stmt.query_map(params![pattern], Self::row_to_link)?;
516
517 rows.collect::<Result<Vec<_>, _>>()
518 .context("Failed to get links")
519 }
520
521 pub fn get_links_by_session(&self, session_id: &Uuid) -> Result<Vec<SessionLink>> {
526 let mut stmt = self.conn.prepare(
527 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
528 FROM session_links
529 WHERE session_id = ?1"
530 )?;
531
532 let rows = stmt.query_map(params![session_id.to_string()], Self::row_to_link)?;
533
534 rows.collect::<Result<Vec<_>, _>>()
535 .context("Failed to get links")
536 }
537
538 fn row_to_link(row: &rusqlite::Row) -> rusqlite::Result<SessionLink> {
539 use super::models::{LinkCreator, LinkType};
540
541 let link_type_str: String = row.get(2)?;
542 let created_by_str: String = row.get(7)?;
543
544 Ok(SessionLink {
545 id: parse_uuid(&row.get::<_, String>(0)?)?,
546 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
547 link_type: match link_type_str.as_str() {
548 "commit" => LinkType::Commit,
549 "branch" => LinkType::Branch,
550 "pr" => LinkType::Pr,
551 _ => LinkType::Manual,
552 },
553 commit_sha: row.get(3)?,
554 branch: row.get(4)?,
555 remote: row.get(5)?,
556 created_at: parse_datetime(&row.get::<_, String>(6)?)?,
557 created_by: match created_by_str.as_str() {
558 "auto" => LinkCreator::Auto,
559 _ => LinkCreator::User,
560 },
561 confidence: row.get(8)?,
562 })
563 }
564
565 #[allow(dead_code)]
572 pub fn delete_link(&self, link_id: &Uuid) -> Result<bool> {
573 let rows_affected = self.conn.execute(
574 "DELETE FROM session_links WHERE id = ?1",
575 params![link_id.to_string()],
576 )?;
577 Ok(rows_affected > 0)
578 }
579
580 pub fn delete_links_by_session(&self, session_id: &Uuid) -> Result<usize> {
584 let rows_affected = self.conn.execute(
585 "DELETE FROM session_links WHERE session_id = ?1",
586 params![session_id.to_string()],
587 )?;
588 Ok(rows_affected)
589 }
590
591 pub fn delete_link_by_session_and_commit(
596 &self,
597 session_id: &Uuid,
598 commit_sha: &str,
599 ) -> Result<bool> {
600 let pattern = format!("{commit_sha}%");
601 let rows_affected = self.conn.execute(
602 "DELETE FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
603 params![session_id.to_string(), pattern],
604 )?;
605 Ok(rows_affected > 0)
606 }
607
608 #[allow(dead_code)]
622 pub fn search_messages(
623 &self,
624 query: &str,
625 limit: usize,
626 working_dir: Option<&str>,
627 since: Option<chrono::DateTime<chrono::Utc>>,
628 role: Option<&str>,
629 ) -> Result<Vec<SearchResult>> {
630 use super::models::SearchOptions;
631
632 let options = SearchOptions {
634 query: query.to_string(),
635 limit,
636 repo: working_dir.map(|s| s.to_string()),
637 since,
638 role: role.map(|s| s.to_string()),
639 ..Default::default()
640 };
641
642 self.search_with_options(&options)
643 }
644
645 pub fn search_with_options(
659 &self,
660 options: &super::models::SearchOptions,
661 ) -> Result<Vec<SearchResult>> {
662 let escaped_query = escape_fts5_query(&options.query);
664
665 let mut sql = String::from(
668 r#"
669 SELECT
670 m.session_id,
671 m.id as message_id,
672 m.role,
673 snippet(messages_fts, 1, '**', '**', '...', 32) as snippet,
674 m.timestamp,
675 s.working_directory,
676 s.tool,
677 s.git_branch,
678 s.message_count,
679 s.started_at,
680 m.idx as message_index
681 FROM messages_fts fts
682 JOIN messages m ON fts.message_id = m.id
683 JOIN sessions s ON m.session_id = s.id
684 WHERE messages_fts MATCH ?1
685 "#,
686 );
687
688 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(escaped_query.clone())];
689 let mut param_idx = 2;
690
691 if options.repo.is_some() {
693 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
694 param_idx += 1;
695 }
696 if options.tool.is_some() {
697 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
698 param_idx += 1;
699 }
700 if options.since.is_some() {
701 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
702 param_idx += 1;
703 }
704 if options.until.is_some() {
705 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
706 param_idx += 1;
707 }
708 if options.project.is_some() {
709 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
710 param_idx += 1;
711 }
712 if options.branch.is_some() {
713 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
714 param_idx += 1;
715 }
716 if options.role.is_some() {
717 sql.push_str(&format!(" AND m.role = ?{param_idx}"));
718 param_idx += 1;
719 }
720
721 if let Some(ref wd) = options.repo {
723 params_vec.push(Box::new(format!("{wd}%")));
724 }
725 if let Some(ref tool) = options.tool {
726 params_vec.push(Box::new(tool.clone()));
727 }
728 if let Some(ts) = options.since {
729 params_vec.push(Box::new(ts.to_rfc3339()));
730 }
731 if let Some(ts) = options.until {
732 params_vec.push(Box::new(ts.to_rfc3339()));
733 }
734 if let Some(ref project) = options.project {
735 params_vec.push(Box::new(format!("%{project}%")));
736 }
737 if let Some(ref branch) = options.branch {
738 params_vec.push(Box::new(format!("%{branch}%")));
739 }
740 if let Some(ref role) = options.role {
741 params_vec.push(Box::new(role.clone()));
742 }
743
744 let include_metadata_search = options.role.is_none();
748 let metadata_query_pattern = format!("%{}%", options.query);
749
750 if include_metadata_search {
751 let meta_param1 = param_idx;
753 let meta_param2 = param_idx + 1;
754 let meta_param3 = param_idx + 2;
755 param_idx += 3;
756
757 sql.push_str(&format!(
758 r#"
759 UNION
760 SELECT
761 s.id as session_id,
762 (SELECT id FROM messages WHERE session_id = s.id ORDER BY idx LIMIT 1) as message_id,
763 'user' as role,
764 substr(s.tool || ' session in ' || s.working_directory || COALESCE(' on branch ' || s.git_branch, ''), 1, 100) as snippet,
765 s.started_at as timestamp,
766 s.working_directory,
767 s.tool,
768 s.git_branch,
769 s.message_count,
770 s.started_at,
771 0 as message_index
772 FROM sessions s
773 WHERE (
774 s.tool LIKE ?{meta_param1}
775 OR s.working_directory LIKE ?{meta_param2}
776 OR s.git_branch LIKE ?{meta_param3}
777 )
778 "#
779 ));
780
781 params_vec.push(Box::new(metadata_query_pattern.clone()));
783 params_vec.push(Box::new(metadata_query_pattern.clone()));
784 params_vec.push(Box::new(metadata_query_pattern));
785
786 if options.repo.is_some() {
788 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
789 params_vec.push(Box::new(format!("{}%", options.repo.as_ref().unwrap())));
790 param_idx += 1;
791 }
792 if options.tool.is_some() {
793 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
794 params_vec.push(Box::new(options.tool.as_ref().unwrap().clone()));
795 param_idx += 1;
796 }
797 if options.since.is_some() {
798 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
799 params_vec.push(Box::new(options.since.unwrap().to_rfc3339()));
800 param_idx += 1;
801 }
802 if options.until.is_some() {
803 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
804 params_vec.push(Box::new(options.until.unwrap().to_rfc3339()));
805 param_idx += 1;
806 }
807 if options.project.is_some() {
808 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
809 params_vec.push(Box::new(format!("%{}%", options.project.as_ref().unwrap())));
810 param_idx += 1;
811 }
812 if options.branch.is_some() {
813 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
814 params_vec.push(Box::new(format!("%{}%", options.branch.as_ref().unwrap())));
815 param_idx += 1;
816 }
817 }
818
819 sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{param_idx}"));
820 params_vec.push(Box::new(options.limit as i64));
821
822 let mut stmt = self.conn.prepare(&sql)?;
824 let params_refs: Vec<&dyn rusqlite::ToSql> =
825 params_vec.iter().map(|p| p.as_ref()).collect();
826
827 let rows = stmt.query_map(params_refs.as_slice(), |row| {
828 let role_str: String = row.get(2)?;
829 let git_branch: Option<String> = row.get(7)?;
830 let started_at_str: Option<String> = row.get(9)?;
831
832 Ok(SearchResult {
833 session_id: parse_uuid(&row.get::<_, String>(0)?)?,
834 message_id: parse_uuid(&row.get::<_, String>(1)?)?,
835 role: match role_str.as_str() {
836 "user" => MessageRole::User,
837 "assistant" => MessageRole::Assistant,
838 "system" => MessageRole::System,
839 _ => MessageRole::User,
840 },
841 snippet: row.get(3)?,
842 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
843 working_directory: row.get(5)?,
844 tool: row.get(6)?,
845 git_branch,
846 session_message_count: row.get(8)?,
847 session_started_at: started_at_str.map(|s| parse_datetime(&s)).transpose()?,
848 message_index: row.get(10)?,
849 })
850 })?;
851
852 rows.collect::<Result<Vec<_>, _>>()
853 .context("Failed to search messages")
854 }
855
856 pub fn get_context_messages(
861 &self,
862 session_id: &Uuid,
863 message_index: i32,
864 context_count: usize,
865 ) -> Result<(Vec<Message>, Vec<Message>)> {
866 let mut before_stmt = self.conn.prepare(
868 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
869 FROM messages
870 WHERE session_id = ?1 AND idx < ?2
871 ORDER BY idx DESC
872 LIMIT ?3",
873 )?;
874
875 let before_rows = before_stmt.query_map(
876 params![session_id.to_string(), message_index, context_count as i64],
877 Self::row_to_message,
878 )?;
879
880 let mut before: Vec<Message> = before_rows
881 .collect::<Result<Vec<_>, _>>()
882 .context("Failed to get before messages")?;
883 before.reverse(); let mut after_stmt = self.conn.prepare(
887 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
888 FROM messages
889 WHERE session_id = ?1 AND idx > ?2
890 ORDER BY idx ASC
891 LIMIT ?3",
892 )?;
893
894 let after_rows = after_stmt.query_map(
895 params![session_id.to_string(), message_index, context_count as i64],
896 Self::row_to_message,
897 )?;
898
899 let after: Vec<Message> = after_rows
900 .collect::<Result<Vec<_>, _>>()
901 .context("Failed to get after messages")?;
902
903 Ok((before, after))
904 }
905
906 #[allow(dead_code)]
908 pub fn get_message_by_index(&self, session_id: &Uuid, index: i32) -> Result<Option<Message>> {
909 self.conn
910 .query_row(
911 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
912 FROM messages
913 WHERE session_id = ?1 AND idx = ?2",
914 params![session_id.to_string(), index],
915 Self::row_to_message,
916 )
917 .optional()
918 .context("Failed to get message by index")
919 }
920
921 fn row_to_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
922 let role_str: String = row.get(5)?;
923 let content_str: String = row.get(6)?;
924
925 let parent_id_str: Option<String> = row.get(2)?;
926 let parent_id = match parent_id_str {
927 Some(s) => Some(parse_uuid(&s)?),
928 None => None,
929 };
930
931 Ok(Message {
932 id: parse_uuid(&row.get::<_, String>(0)?)?,
933 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
934 parent_id,
935 index: row.get(3)?,
936 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
937 role: match role_str.as_str() {
938 "user" => MessageRole::User,
939 "assistant" => MessageRole::Assistant,
940 "system" => MessageRole::System,
941 _ => MessageRole::User,
942 },
943 content: serde_json::from_str(&content_str)
944 .unwrap_or(MessageContent::Text(content_str)),
945 model: row.get(7)?,
946 git_branch: row.get(8)?,
947 cwd: row.get(9)?,
948 })
949 }
950
951 pub fn rebuild_search_index(&self) -> Result<usize> {
959 self.conn.execute("DELETE FROM messages_fts", [])?;
961 self.conn.execute("DELETE FROM sessions_fts", [])?;
962
963 let mut msg_stmt = self.conn.prepare("SELECT id, content FROM messages")?;
965
966 let rows = msg_stmt.query_map([], |row| {
967 let id: String = row.get(0)?;
968 let content_json: String = row.get(1)?;
969 Ok((id, content_json))
970 })?;
971
972 let mut count = 0;
973 for row in rows {
974 let (id, content_json) = row?;
975 let content: MessageContent = serde_json::from_str(&content_json)
977 .unwrap_or(MessageContent::Text(content_json.clone()));
978 let text_content = content.text();
979
980 if !text_content.is_empty() {
981 self.conn.execute(
982 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
983 params![id, text_content],
984 )?;
985 count += 1;
986 }
987 }
988
989 let mut session_stmt = self
991 .conn
992 .prepare("SELECT id, tool, working_directory, git_branch FROM sessions")?;
993
994 let session_rows = session_stmt.query_map([], |row| {
995 let id: String = row.get(0)?;
996 let tool: String = row.get(1)?;
997 let working_directory: String = row.get(2)?;
998 let git_branch: Option<String> = row.get(3)?;
999 Ok((id, tool, working_directory, git_branch))
1000 })?;
1001
1002 for row in session_rows {
1003 let (id, tool, working_directory, git_branch) = row?;
1004 self.conn.execute(
1005 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
1006 params![id, tool, working_directory, git_branch.unwrap_or_default()],
1007 )?;
1008 }
1009
1010 Ok(count)
1011 }
1012
1013 pub fn search_index_needs_rebuild(&self) -> Result<bool> {
1018 let message_count: i32 =
1019 self.conn
1020 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1021
1022 let msg_fts_count: i32 =
1023 self.conn
1024 .query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
1025
1026 let session_count: i32 =
1027 self.conn
1028 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1029
1030 let session_fts_count: i32 =
1031 self.conn
1032 .query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
1033
1034 Ok((message_count > 0 && msg_fts_count == 0)
1036 || (session_count > 0 && session_fts_count == 0))
1037 }
1038
1039 pub fn session_count(&self) -> Result<i32> {
1043 let count: i32 = self
1044 .conn
1045 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1046 Ok(count)
1047 }
1048
1049 pub fn message_count(&self) -> Result<i32> {
1051 let count: i32 = self
1052 .conn
1053 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1054 Ok(count)
1055 }
1056
1057 pub fn link_count(&self) -> Result<i32> {
1059 let count: i32 = self
1060 .conn
1061 .query_row("SELECT COUNT(*) FROM session_links", [], |row| row.get(0))?;
1062 Ok(count)
1063 }
1064
1065 pub fn db_path(&self) -> Option<std::path::PathBuf> {
1069 self.conn.path().map(std::path::PathBuf::from)
1070 }
1071
1072 pub fn find_sessions_near_commit_time(
1089 &self,
1090 commit_time: chrono::DateTime<chrono::Utc>,
1091 window_minutes: i64,
1092 working_dir: Option<&str>,
1093 ) -> Result<Vec<Session>> {
1094 let commit_time_str = commit_time.to_rfc3339();
1096
1097 let window = chrono::Duration::minutes(window_minutes);
1099 let window_start = (commit_time - window).to_rfc3339();
1100 let window_end = (commit_time + window).to_rfc3339();
1101
1102 let sql = if working_dir.is_some() {
1103 r#"
1104 SELECT id, tool, tool_version, started_at, ended_at, model,
1105 working_directory, git_branch, source_path, message_count
1106 FROM sessions
1107 WHERE working_directory LIKE ?1
1108 AND (
1109 -- Session started before or during the window
1110 (started_at <= ?3)
1111 AND
1112 -- Session ended after or during the window (or is still ongoing)
1113 (ended_at IS NULL OR ended_at >= ?2)
1114 )
1115 ORDER BY
1116 -- Order by how close the session end (or start) is to commit time
1117 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?4))
1118 "#
1119 } else {
1120 r#"
1121 SELECT id, tool, tool_version, started_at, ended_at, model,
1122 working_directory, git_branch, source_path, message_count
1123 FROM sessions
1124 WHERE
1125 -- Session started before or during the window
1126 (started_at <= ?2)
1127 AND
1128 -- Session ended after or during the window (or is still ongoing)
1129 (ended_at IS NULL OR ended_at >= ?1)
1130 ORDER BY
1131 -- Order by how close the session end (or start) is to commit time
1132 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?3))
1133 "#
1134 };
1135
1136 let mut stmt = self.conn.prepare(sql)?;
1137
1138 let rows = if let Some(wd) = working_dir {
1139 stmt.query_map(
1140 params![format!("{wd}%"), window_start, window_end, commit_time_str],
1141 Self::row_to_session,
1142 )?
1143 } else {
1144 stmt.query_map(
1145 params![window_start, window_end, commit_time_str],
1146 Self::row_to_session,
1147 )?
1148 };
1149
1150 rows.collect::<Result<Vec<_>, _>>()
1151 .context("Failed to find sessions near commit time")
1152 }
1153
1154 pub fn link_exists(&self, session_id: &Uuid, commit_sha: &str) -> Result<bool> {
1158 let pattern = format!("{commit_sha}%");
1159 let count: i32 = self.conn.query_row(
1160 "SELECT COUNT(*) FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1161 params![session_id.to_string(), pattern],
1162 |row| row.get(0),
1163 )?;
1164 Ok(count > 0)
1165 }
1166
1167 pub fn delete_session(&self, session_id: &Uuid) -> Result<(usize, usize)> {
1178 let session_id_str = session_id.to_string();
1179
1180 self.conn.execute(
1182 "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1183 params![session_id_str],
1184 )?;
1185
1186 let messages_deleted = self.conn.execute(
1188 "DELETE FROM messages WHERE session_id = ?1",
1189 params![session_id_str],
1190 )?;
1191
1192 let links_deleted = self.conn.execute(
1194 "DELETE FROM session_links WHERE session_id = ?1",
1195 params![session_id_str],
1196 )?;
1197
1198 self.conn.execute(
1200 "DELETE FROM sessions_fts WHERE session_id = ?1",
1201 params![session_id_str],
1202 )?;
1203
1204 self.conn.execute(
1206 "DELETE FROM sessions WHERE id = ?1",
1207 params![session_id_str],
1208 )?;
1209
1210 Ok((messages_deleted, links_deleted))
1211 }
1212
1213 pub fn vacuum(&self) -> Result<()> {
1220 self.conn.execute("VACUUM", [])?;
1221 Ok(())
1222 }
1223
1224 pub fn file_size(&self) -> Result<Option<u64>> {
1228 if let Some(path) = self.db_path() {
1229 let metadata = std::fs::metadata(&path)?;
1230 Ok(Some(metadata.len()))
1231 } else {
1232 Ok(None)
1233 }
1234 }
1235
1236 pub fn delete_sessions_older_than(&self, before: DateTime<Utc>) -> Result<usize> {
1248 let before_str = before.to_rfc3339();
1249
1250 let mut stmt = self
1252 .conn
1253 .prepare("SELECT id FROM sessions WHERE started_at < ?1")?;
1254 let session_ids: Vec<String> = stmt
1255 .query_map(params![before_str], |row| row.get(0))?
1256 .collect::<Result<Vec<_>, _>>()?;
1257
1258 if session_ids.is_empty() {
1259 return Ok(0);
1260 }
1261
1262 let count = session_ids.len();
1263
1264 for session_id_str in &session_ids {
1266 self.conn.execute(
1268 "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1269 params![session_id_str],
1270 )?;
1271
1272 self.conn.execute(
1274 "DELETE FROM messages WHERE session_id = ?1",
1275 params![session_id_str],
1276 )?;
1277
1278 self.conn.execute(
1280 "DELETE FROM session_links WHERE session_id = ?1",
1281 params![session_id_str],
1282 )?;
1283
1284 self.conn.execute(
1286 "DELETE FROM sessions_fts WHERE session_id = ?1",
1287 params![session_id_str],
1288 )?;
1289 }
1290
1291 self.conn.execute(
1293 "DELETE FROM sessions WHERE started_at < ?1",
1294 params![before_str],
1295 )?;
1296
1297 Ok(count)
1298 }
1299
1300 pub fn count_sessions_older_than(&self, before: DateTime<Utc>) -> Result<i32> {
1310 let before_str = before.to_rfc3339();
1311 let count: i32 = self.conn.query_row(
1312 "SELECT COUNT(*) FROM sessions WHERE started_at < ?1",
1313 params![before_str],
1314 |row| row.get(0),
1315 )?;
1316 Ok(count)
1317 }
1318
1319 pub fn get_sessions_older_than(&self, before: DateTime<Utc>) -> Result<Vec<Session>> {
1329 let before_str = before.to_rfc3339();
1330 let mut stmt = self.conn.prepare(
1331 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count
1332 FROM sessions
1333 WHERE started_at < ?1
1334 ORDER BY started_at ASC",
1335 )?;
1336
1337 let rows = stmt.query_map(params![before_str], Self::row_to_session)?;
1338
1339 rows.collect::<Result<Vec<_>, _>>()
1340 .context("Failed to get sessions older than cutoff")
1341 }
1342
1343 pub fn stats(&self) -> Result<DatabaseStats> {
1350 let session_count = self.session_count()?;
1351 let message_count = self.message_count()?;
1352 let link_count = self.link_count()?;
1353
1354 let oldest: Option<String> = self
1356 .conn
1357 .query_row("SELECT MIN(started_at) FROM sessions", [], |row| row.get(0))
1358 .optional()?
1359 .flatten();
1360
1361 let newest: Option<String> = self
1362 .conn
1363 .query_row("SELECT MAX(started_at) FROM sessions", [], |row| row.get(0))
1364 .optional()?
1365 .flatten();
1366
1367 let oldest_session = oldest
1368 .map(|s| parse_datetime(&s))
1369 .transpose()
1370 .unwrap_or(None);
1371 let newest_session = newest
1372 .map(|s| parse_datetime(&s))
1373 .transpose()
1374 .unwrap_or(None);
1375
1376 let mut stmt = self
1378 .conn
1379 .prepare("SELECT tool, COUNT(*) FROM sessions GROUP BY tool ORDER BY COUNT(*) DESC")?;
1380 let sessions_by_tool: Vec<(String, i32)> = stmt
1381 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1382 .collect::<Result<Vec<_>, _>>()?;
1383
1384 Ok(DatabaseStats {
1385 session_count,
1386 message_count,
1387 link_count,
1388 oldest_session,
1389 newest_session,
1390 sessions_by_tool,
1391 })
1392 }
1393}
1394
1395#[derive(Debug, Clone)]
1397pub struct DatabaseStats {
1398 pub session_count: i32,
1400 pub message_count: i32,
1402 pub link_count: i32,
1404 pub oldest_session: Option<DateTime<Utc>>,
1406 pub newest_session: Option<DateTime<Utc>>,
1408 pub sessions_by_tool: Vec<(String, i32)>,
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414 use super::*;
1415 use crate::storage::models::{
1416 LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
1417 };
1418 use chrono::{Duration, Utc};
1419 use tempfile::tempdir;
1420
1421 fn create_test_db() -> (Database, tempfile::TempDir) {
1424 let dir = tempdir().expect("Failed to create temp directory");
1425 let db_path = dir.path().join("test.db");
1426 let db = Database::open(&db_path).expect("Failed to open test database");
1427 (db, dir)
1428 }
1429
1430 fn create_test_session(
1432 tool: &str,
1433 working_directory: &str,
1434 started_at: chrono::DateTime<Utc>,
1435 source_path: Option<&str>,
1436 ) -> Session {
1437 Session {
1438 id: Uuid::new_v4(),
1439 tool: tool.to_string(),
1440 tool_version: Some("1.0.0".to_string()),
1441 started_at,
1442 ended_at: None,
1443 model: Some("test-model".to_string()),
1444 working_directory: working_directory.to_string(),
1445 git_branch: Some("main".to_string()),
1446 source_path: source_path.map(|s| s.to_string()),
1447 message_count: 0,
1448 }
1449 }
1450
1451 fn create_test_message(
1453 session_id: Uuid,
1454 index: i32,
1455 role: MessageRole,
1456 content: &str,
1457 ) -> Message {
1458 Message {
1459 id: Uuid::new_v4(),
1460 session_id,
1461 parent_id: None,
1462 index,
1463 timestamp: Utc::now(),
1464 role,
1465 content: MessageContent::Text(content.to_string()),
1466 model: Some("test-model".to_string()),
1467 git_branch: Some("main".to_string()),
1468 cwd: Some("/test/cwd".to_string()),
1469 }
1470 }
1471
1472 fn create_test_link(
1474 session_id: Uuid,
1475 commit_sha: Option<&str>,
1476 link_type: LinkType,
1477 ) -> SessionLink {
1478 SessionLink {
1479 id: Uuid::new_v4(),
1480 session_id,
1481 link_type,
1482 commit_sha: commit_sha.map(|s| s.to_string()),
1483 branch: Some("main".to_string()),
1484 remote: Some("origin".to_string()),
1485 created_at: Utc::now(),
1486 created_by: LinkCreator::Auto,
1487 confidence: Some(0.95),
1488 }
1489 }
1490
1491 #[test]
1494 fn test_insert_and_get_session() {
1495 let (db, _dir) = create_test_db();
1496 let session = create_test_session(
1497 "claude-code",
1498 "/home/user/project",
1499 Utc::now(),
1500 Some("/path/to/source.jsonl"),
1501 );
1502
1503 db.insert_session(&session)
1504 .expect("Failed to insert session");
1505
1506 let retrieved = db
1507 .get_session(&session.id)
1508 .expect("Failed to get session")
1509 .expect("Session should exist");
1510
1511 assert_eq!(retrieved.id, session.id, "Session ID should match");
1512 assert_eq!(retrieved.tool, session.tool, "Tool should match");
1513 assert_eq!(
1514 retrieved.tool_version, session.tool_version,
1515 "Tool version should match"
1516 );
1517 assert_eq!(
1518 retrieved.working_directory, session.working_directory,
1519 "Working directory should match"
1520 );
1521 assert_eq!(
1522 retrieved.git_branch, session.git_branch,
1523 "Git branch should match"
1524 );
1525 assert_eq!(
1526 retrieved.source_path, session.source_path,
1527 "Source path should match"
1528 );
1529 }
1530
1531 #[test]
1532 fn test_list_sessions() {
1533 let (db, _dir) = create_test_db();
1534 let now = Utc::now();
1535
1536 let session1 =
1538 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
1539 let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
1540 let session3 = create_test_session("claude-code", "/project3", now, None);
1541
1542 db.insert_session(&session1)
1543 .expect("Failed to insert session1");
1544 db.insert_session(&session2)
1545 .expect("Failed to insert session2");
1546 db.insert_session(&session3)
1547 .expect("Failed to insert session3");
1548
1549 let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
1550
1551 assert_eq!(sessions.len(), 3, "Should have 3 sessions");
1552 assert_eq!(
1554 sessions[0].id, session3.id,
1555 "Most recent session should be first"
1556 );
1557 assert_eq!(
1558 sessions[1].id, session2.id,
1559 "Second most recent session should be second"
1560 );
1561 assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
1562 }
1563
1564 #[test]
1565 fn test_list_sessions_with_working_dir_filter() {
1566 let (db, _dir) = create_test_db();
1567 let now = Utc::now();
1568
1569 let session1 = create_test_session(
1570 "claude-code",
1571 "/home/user/project-a",
1572 now - Duration::hours(1),
1573 None,
1574 );
1575 let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
1576 let session3 = create_test_session("claude-code", "/other/path", now, None);
1577
1578 db.insert_session(&session1)
1579 .expect("Failed to insert session1");
1580 db.insert_session(&session2)
1581 .expect("Failed to insert session2");
1582 db.insert_session(&session3)
1583 .expect("Failed to insert session3");
1584
1585 let sessions = db
1587 .list_sessions(10, Some("/home/user"))
1588 .expect("Failed to list sessions");
1589
1590 assert_eq!(
1591 sessions.len(),
1592 2,
1593 "Should have 2 sessions matching /home/user prefix"
1594 );
1595
1596 let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
1598 assert!(ids.contains(&session1.id), "Should contain session1");
1599 assert!(ids.contains(&session2.id), "Should contain session2");
1600 assert!(!ids.contains(&session3.id), "Should not contain session3");
1601 }
1602
1603 #[test]
1604 fn test_session_exists_by_source() {
1605 let (db, _dir) = create_test_db();
1606 let source_path = "/path/to/session.jsonl";
1607
1608 let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
1609
1610 assert!(
1612 !db.session_exists_by_source(source_path)
1613 .expect("Failed to check existence"),
1614 "Session should not exist before insert"
1615 );
1616
1617 db.insert_session(&session)
1618 .expect("Failed to insert session");
1619
1620 assert!(
1622 db.session_exists_by_source(source_path)
1623 .expect("Failed to check existence"),
1624 "Session should exist after insert"
1625 );
1626
1627 assert!(
1629 !db.session_exists_by_source("/other/path.jsonl")
1630 .expect("Failed to check existence"),
1631 "Different source path should not exist"
1632 );
1633 }
1634
1635 #[test]
1636 fn test_update_session_branch() {
1637 let (db, _dir) = create_test_db();
1638 let now = Utc::now();
1639
1640 let mut session = create_test_session("claude-code", "/project", now, None);
1642 session.git_branch = Some("main".to_string());
1643
1644 db.insert_session(&session)
1645 .expect("Failed to insert session");
1646
1647 let fetched = db
1649 .get_session(&session.id)
1650 .expect("Failed to get session")
1651 .expect("Session should exist");
1652 assert_eq!(fetched.git_branch, Some("main".to_string()));
1653
1654 let rows = db
1656 .update_session_branch(session.id, "feature-branch")
1657 .expect("Failed to update branch");
1658 assert_eq!(rows, 1, "Should update exactly one row");
1659
1660 let fetched = db
1662 .get_session(&session.id)
1663 .expect("Failed to get session")
1664 .expect("Session should exist");
1665 assert_eq!(fetched.git_branch, Some("feature-branch".to_string()));
1666 }
1667
1668 #[test]
1669 fn test_update_session_branch_nonexistent() {
1670 let (db, _dir) = create_test_db();
1671 let nonexistent_id = Uuid::new_v4();
1672
1673 let rows = db
1675 .update_session_branch(nonexistent_id, "some-branch")
1676 .expect("Failed to update branch");
1677 assert_eq!(
1678 rows, 0,
1679 "Should not update any rows for nonexistent session"
1680 );
1681 }
1682
1683 #[test]
1684 fn test_update_session_branch_from_none() {
1685 let (db, _dir) = create_test_db();
1686 let now = Utc::now();
1687
1688 let mut session = create_test_session("claude-code", "/project", now, None);
1690 session.git_branch = None; db.insert_session(&session)
1693 .expect("Failed to insert session");
1694
1695 let fetched = db
1697 .get_session(&session.id)
1698 .expect("Failed to get session")
1699 .expect("Session should exist");
1700 assert_eq!(fetched.git_branch, None);
1701
1702 let rows = db
1704 .update_session_branch(session.id, "new-branch")
1705 .expect("Failed to update branch");
1706 assert_eq!(rows, 1, "Should update exactly one row");
1707
1708 let fetched = db
1710 .get_session(&session.id)
1711 .expect("Failed to get session")
1712 .expect("Session should exist");
1713 assert_eq!(fetched.git_branch, Some("new-branch".to_string()));
1714 }
1715
1716 #[test]
1717 fn test_get_nonexistent_session() {
1718 let (db, _dir) = create_test_db();
1719 let nonexistent_id = Uuid::new_v4();
1720
1721 let result = db
1722 .get_session(&nonexistent_id)
1723 .expect("Failed to query for nonexistent session");
1724
1725 assert!(
1726 result.is_none(),
1727 "Should return None for nonexistent session"
1728 );
1729 }
1730
1731 #[test]
1734 fn test_insert_and_get_messages() {
1735 let (db, _dir) = create_test_db();
1736
1737 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1738 db.insert_session(&session)
1739 .expect("Failed to insert session");
1740
1741 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
1742 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
1743
1744 db.insert_message(&msg1)
1745 .expect("Failed to insert message 1");
1746 db.insert_message(&msg2)
1747 .expect("Failed to insert message 2");
1748
1749 let messages = db
1750 .get_messages(&session.id)
1751 .expect("Failed to get messages");
1752
1753 assert_eq!(messages.len(), 2, "Should have 2 messages");
1754 assert_eq!(messages[0].id, msg1.id, "First message ID should match");
1755 assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
1756 assert_eq!(
1757 messages[0].role,
1758 MessageRole::User,
1759 "First message role should be User"
1760 );
1761 assert_eq!(
1762 messages[1].role,
1763 MessageRole::Assistant,
1764 "Second message role should be Assistant"
1765 );
1766 }
1767
1768 #[test]
1769 fn test_messages_ordered_by_index() {
1770 let (db, _dir) = create_test_db();
1771
1772 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1773 db.insert_session(&session)
1774 .expect("Failed to insert session");
1775
1776 let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
1778 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
1779 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
1780
1781 db.insert_message(&msg3)
1782 .expect("Failed to insert message 3");
1783 db.insert_message(&msg1)
1784 .expect("Failed to insert message 1");
1785 db.insert_message(&msg2)
1786 .expect("Failed to insert message 2");
1787
1788 let messages = db
1789 .get_messages(&session.id)
1790 .expect("Failed to get messages");
1791
1792 assert_eq!(messages.len(), 3, "Should have 3 messages");
1793 assert_eq!(messages[0].index, 0, "First message should have index 0");
1794 assert_eq!(messages[1].index, 1, "Second message should have index 1");
1795 assert_eq!(messages[2].index, 2, "Third message should have index 2");
1796
1797 assert_eq!(
1799 messages[0].content.text(),
1800 "First",
1801 "First message content should be 'First'"
1802 );
1803 assert_eq!(
1804 messages[1].content.text(),
1805 "Second",
1806 "Second message content should be 'Second'"
1807 );
1808 assert_eq!(
1809 messages[2].content.text(),
1810 "Third",
1811 "Third message content should be 'Third'"
1812 );
1813 }
1814
1815 #[test]
1818 fn test_insert_and_get_links_by_session() {
1819 let (db, _dir) = create_test_db();
1820
1821 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1822 db.insert_session(&session)
1823 .expect("Failed to insert session");
1824
1825 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
1826 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
1827
1828 db.insert_link(&link1).expect("Failed to insert link 1");
1829 db.insert_link(&link2).expect("Failed to insert link 2");
1830
1831 let links = db
1832 .get_links_by_session(&session.id)
1833 .expect("Failed to get links");
1834
1835 assert_eq!(links.len(), 2, "Should have 2 links");
1836
1837 let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
1838 assert!(link_ids.contains(&link1.id), "Should contain link1");
1839 assert!(link_ids.contains(&link2.id), "Should contain link2");
1840
1841 let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
1843 assert_eq!(
1844 retrieved_link.commit_sha,
1845 Some("abc123def456".to_string()),
1846 "Commit SHA should match"
1847 );
1848 assert_eq!(
1849 retrieved_link.link_type,
1850 LinkType::Commit,
1851 "Link type should be Commit"
1852 );
1853 assert_eq!(
1854 retrieved_link.created_by,
1855 LinkCreator::Auto,
1856 "Created by should be Auto"
1857 );
1858 }
1859
1860 #[test]
1861 fn test_get_links_by_commit() {
1862 let (db, _dir) = create_test_db();
1863
1864 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1865 db.insert_session(&session)
1866 .expect("Failed to insert session");
1867
1868 let full_sha = "abc123def456789012345678901234567890abcd";
1869 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
1870 db.insert_link(&link).expect("Failed to insert link");
1871
1872 let links_full = db
1874 .get_links_by_commit(full_sha)
1875 .expect("Failed to get links by full SHA");
1876 assert_eq!(links_full.len(), 1, "Should find link by full SHA");
1877 assert_eq!(links_full[0].id, link.id, "Link ID should match");
1878
1879 let links_partial = db
1881 .get_links_by_commit("abc123")
1882 .expect("Failed to get links by partial SHA");
1883 assert_eq!(
1884 links_partial.len(),
1885 1,
1886 "Should find link by partial SHA prefix"
1887 );
1888 assert_eq!(links_partial[0].id, link.id, "Link ID should match");
1889
1890 let links_none = db
1892 .get_links_by_commit("zzz999")
1893 .expect("Failed to get links by non-matching SHA");
1894 assert_eq!(
1895 links_none.len(),
1896 0,
1897 "Should not find link with non-matching SHA"
1898 );
1899 }
1900
1901 #[test]
1904 fn test_database_creation() {
1905 let dir = tempdir().expect("Failed to create temp directory");
1906 let db_path = dir.path().join("new_test.db");
1907
1908 assert!(
1910 !db_path.exists(),
1911 "Database file should not exist before creation"
1912 );
1913
1914 let db = Database::open(&db_path).expect("Failed to create database");
1915
1916 assert!(
1918 db_path.exists(),
1919 "Database file should exist after creation"
1920 );
1921
1922 let session_count = db.session_count().expect("Failed to get session count");
1924 assert_eq!(session_count, 0, "New database should have 0 sessions");
1925
1926 let message_count = db.message_count().expect("Failed to get message count");
1927 assert_eq!(message_count, 0, "New database should have 0 messages");
1928 }
1929
1930 #[test]
1931 fn test_session_count() {
1932 let (db, _dir) = create_test_db();
1933
1934 assert_eq!(
1935 db.session_count().expect("Failed to get count"),
1936 0,
1937 "Initial session count should be 0"
1938 );
1939
1940 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
1941 db.insert_session(&session1)
1942 .expect("Failed to insert session1");
1943
1944 assert_eq!(
1945 db.session_count().expect("Failed to get count"),
1946 1,
1947 "Session count should be 1 after first insert"
1948 );
1949
1950 let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
1951 db.insert_session(&session2)
1952 .expect("Failed to insert session2");
1953
1954 assert_eq!(
1955 db.session_count().expect("Failed to get count"),
1956 2,
1957 "Session count should be 2 after second insert"
1958 );
1959 }
1960
1961 #[test]
1962 fn test_message_count() {
1963 let (db, _dir) = create_test_db();
1964
1965 assert_eq!(
1966 db.message_count().expect("Failed to get count"),
1967 0,
1968 "Initial message count should be 0"
1969 );
1970
1971 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1972 db.insert_session(&session)
1973 .expect("Failed to insert session");
1974
1975 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
1976 db.insert_message(&msg1).expect("Failed to insert message1");
1977
1978 assert_eq!(
1979 db.message_count().expect("Failed to get count"),
1980 1,
1981 "Message count should be 1 after first insert"
1982 );
1983
1984 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
1985 let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
1986 db.insert_message(&msg2).expect("Failed to insert message2");
1987 db.insert_message(&msg3).expect("Failed to insert message3");
1988
1989 assert_eq!(
1990 db.message_count().expect("Failed to get count"),
1991 3,
1992 "Message count should be 3 after all inserts"
1993 );
1994 }
1995
1996 #[test]
1997 fn test_link_count() {
1998 let (db, _dir) = create_test_db();
1999
2000 assert_eq!(
2001 db.link_count().expect("Failed to get count"),
2002 0,
2003 "Initial link count should be 0"
2004 );
2005
2006 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2007 db.insert_session(&session)
2008 .expect("Failed to insert session");
2009
2010 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2011 db.insert_link(&link1).expect("Failed to insert link1");
2012
2013 assert_eq!(
2014 db.link_count().expect("Failed to get count"),
2015 1,
2016 "Link count should be 1 after first insert"
2017 );
2018
2019 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2020 db.insert_link(&link2).expect("Failed to insert link2");
2021
2022 assert_eq!(
2023 db.link_count().expect("Failed to get count"),
2024 2,
2025 "Link count should be 2 after second insert"
2026 );
2027 }
2028
2029 #[test]
2030 fn test_db_path() {
2031 let dir = tempdir().expect("Failed to create temp directory");
2032 let db_path = dir.path().join("test.db");
2033 let db = Database::open(&db_path).expect("Failed to open test database");
2034
2035 let retrieved_path = db.db_path();
2036 assert!(
2037 retrieved_path.is_some(),
2038 "Database path should be available"
2039 );
2040
2041 let expected = db_path.canonicalize().unwrap_or(db_path);
2043 let actual = retrieved_path.unwrap();
2044 let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
2045
2046 assert_eq!(
2047 actual_canonical, expected,
2048 "Database path should match (after canonicalization)"
2049 );
2050 }
2051
2052 #[test]
2055 fn test_search_messages_basic() {
2056 let (db, _dir) = create_test_db();
2057
2058 let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
2059 db.insert_session(&session)
2060 .expect("Failed to insert session");
2061
2062 let msg1 = create_test_message(
2063 session.id,
2064 0,
2065 MessageRole::User,
2066 "How do I implement error handling in Rust?",
2067 );
2068 let msg2 = create_test_message(
2069 session.id,
2070 1,
2071 MessageRole::Assistant,
2072 "You can use Result types for error handling. The anyhow crate is also helpful.",
2073 );
2074
2075 db.insert_message(&msg1)
2076 .expect("Failed to insert message 1");
2077 db.insert_message(&msg2)
2078 .expect("Failed to insert message 2");
2079
2080 let results = db
2082 .search_messages("error", 10, None, None, None)
2083 .expect("Failed to search");
2084
2085 assert_eq!(
2086 results.len(),
2087 2,
2088 "Should find 2 messages containing 'error'"
2089 );
2090 }
2091
2092 #[test]
2093 fn test_search_messages_no_results() {
2094 let (db, _dir) = create_test_db();
2095
2096 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2097 db.insert_session(&session)
2098 .expect("Failed to insert session");
2099
2100 let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
2101 db.insert_message(&msg).expect("Failed to insert message");
2102
2103 let results = db
2105 .search_messages("nonexistent_term_xyz", 10, None, None, None)
2106 .expect("Failed to search");
2107
2108 assert!(results.is_empty(), "Should find no results");
2109 }
2110
2111 #[test]
2112 fn test_search_messages_with_role_filter() {
2113 let (db, _dir) = create_test_db();
2114
2115 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2116 db.insert_session(&session)
2117 .expect("Failed to insert session");
2118
2119 let msg1 = create_test_message(
2120 session.id,
2121 0,
2122 MessageRole::User,
2123 "Tell me about Rust programming",
2124 );
2125 let msg2 = create_test_message(
2126 session.id,
2127 1,
2128 MessageRole::Assistant,
2129 "Rust is a systems programming language",
2130 );
2131
2132 db.insert_message(&msg1)
2133 .expect("Failed to insert message 1");
2134 db.insert_message(&msg2)
2135 .expect("Failed to insert message 2");
2136
2137 let user_results = db
2139 .search_messages("programming", 10, None, None, Some("user"))
2140 .expect("Failed to search");
2141
2142 assert_eq!(user_results.len(), 1, "Should find 1 user message");
2143 assert_eq!(
2144 user_results[0].role,
2145 MessageRole::User,
2146 "Result should be from user"
2147 );
2148
2149 let assistant_results = db
2151 .search_messages("programming", 10, None, None, Some("assistant"))
2152 .expect("Failed to search");
2153
2154 assert_eq!(
2155 assistant_results.len(),
2156 1,
2157 "Should find 1 assistant message"
2158 );
2159 assert_eq!(
2160 assistant_results[0].role,
2161 MessageRole::Assistant,
2162 "Result should be from assistant"
2163 );
2164 }
2165
2166 #[test]
2167 fn test_search_messages_with_repo_filter() {
2168 let (db, _dir) = create_test_db();
2169
2170 let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
2171 let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
2172
2173 db.insert_session(&session1).expect("insert 1");
2174 db.insert_session(&session2).expect("insert 2");
2175
2176 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
2177 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
2178
2179 db.insert_message(&msg1).expect("insert msg 1");
2180 db.insert_message(&msg2).expect("insert msg 2");
2181
2182 let results = db
2184 .search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
2185 .expect("Failed to search");
2186
2187 assert_eq!(results.len(), 1, "Should find 1 message in project-a");
2188 assert!(
2189 results[0].working_directory.contains("project-a"),
2190 "Should be from project-a"
2191 );
2192 }
2193
2194 #[test]
2195 fn test_search_messages_limit() {
2196 let (db, _dir) = create_test_db();
2197
2198 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2199 db.insert_session(&session).expect("insert session");
2200
2201 for i in 0..5 {
2203 let msg = create_test_message(
2204 session.id,
2205 i,
2206 MessageRole::User,
2207 &format!("This is test message number {i}"),
2208 );
2209 db.insert_message(&msg).expect("insert message");
2210 }
2211
2212 let results = db
2214 .search_messages("test", 3, None, None, None)
2215 .expect("Failed to search");
2216
2217 assert_eq!(results.len(), 3, "Should respect limit of 3");
2218 }
2219
2220 #[test]
2221 fn test_search_index_needs_rebuild_empty_db() {
2222 let (db, _dir) = create_test_db();
2223
2224 let needs_rebuild = db
2225 .search_index_needs_rebuild()
2226 .expect("Failed to check rebuild status");
2227
2228 assert!(!needs_rebuild, "Empty database should not need rebuild");
2229 }
2230
2231 #[test]
2232 fn test_rebuild_search_index() {
2233 let (db, _dir) = create_test_db();
2234
2235 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2236 db.insert_session(&session).expect("insert session");
2237
2238 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
2239 let msg2 = create_test_message(
2240 session.id,
2241 1,
2242 MessageRole::Assistant,
2243 "Second test response",
2244 );
2245
2246 db.insert_message(&msg1).expect("insert msg 1");
2247 db.insert_message(&msg2).expect("insert msg 2");
2248
2249 db.conn
2251 .execute("DELETE FROM messages_fts", [])
2252 .expect("clear fts");
2253
2254 assert!(
2256 db.search_index_needs_rebuild().expect("check rebuild"),
2257 "Should need rebuild after clearing FTS"
2258 );
2259
2260 let count = db.rebuild_search_index().expect("rebuild");
2262 assert_eq!(count, 2, "Should have indexed 2 messages");
2263
2264 assert!(
2266 !db.search_index_needs_rebuild().expect("check rebuild"),
2267 "Should not need rebuild after rebuilding"
2268 );
2269
2270 let results = db
2272 .search_messages("test", 10, None, None, None)
2273 .expect("search");
2274 assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
2275 }
2276
2277 #[test]
2278 fn test_search_with_block_content() {
2279 let (db, _dir) = create_test_db();
2280
2281 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2282 db.insert_session(&session).expect("insert session");
2283
2284 let block_content = MessageContent::Blocks(vec![
2286 crate::storage::models::ContentBlock::Text {
2287 text: "Let me help with your database query.".to_string(),
2288 },
2289 crate::storage::models::ContentBlock::ToolUse {
2290 id: "tool_123".to_string(),
2291 name: "Bash".to_string(),
2292 input: serde_json::json!({"command": "ls -la"}),
2293 },
2294 ]);
2295
2296 let msg = Message {
2297 id: Uuid::new_v4(),
2298 session_id: session.id,
2299 parent_id: None,
2300 index: 0,
2301 timestamp: Utc::now(),
2302 role: MessageRole::Assistant,
2303 content: block_content,
2304 model: Some("claude-opus-4".to_string()),
2305 git_branch: Some("main".to_string()),
2306 cwd: Some("/project".to_string()),
2307 };
2308
2309 db.insert_message(&msg).expect("insert message");
2310
2311 let results = db
2313 .search_messages("database", 10, None, None, None)
2314 .expect("search");
2315
2316 assert_eq!(results.len(), 1, "Should find message with block content");
2317 }
2318
2319 #[test]
2320 fn test_search_result_contains_session_info() {
2321 let (db, _dir) = create_test_db();
2322
2323 let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
2324 db.insert_session(&session).expect("insert session");
2325
2326 let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
2327 db.insert_message(&msg).expect("insert message");
2328
2329 let results = db
2330 .search_messages("Search", 10, None, None, None)
2331 .expect("search");
2332
2333 assert_eq!(results.len(), 1, "Should find 1 result");
2334 assert_eq!(results[0].session_id, session.id, "Session ID should match");
2335 assert_eq!(results[0].message_id, msg.id, "Message ID should match");
2336 assert_eq!(
2337 results[0].working_directory, "/home/user/my-project",
2338 "Working directory should match"
2339 );
2340 assert_eq!(results[0].role, MessageRole::User, "Role should match");
2341 }
2342
2343 #[test]
2346 fn test_delete_link_by_id() {
2347 let (db, _dir) = create_test_db();
2348
2349 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2350 db.insert_session(&session)
2351 .expect("Failed to insert session");
2352
2353 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2354 db.insert_link(&link).expect("Failed to insert link");
2355
2356 let links_before = db
2358 .get_links_by_session(&session.id)
2359 .expect("Failed to get links");
2360 assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
2361
2362 let deleted = db.delete_link(&link.id).expect("Failed to delete link");
2364 assert!(deleted, "Should return true when link is deleted");
2365
2366 let links_after = db
2368 .get_links_by_session(&session.id)
2369 .expect("Failed to get links");
2370 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
2371 }
2372
2373 #[test]
2374 fn test_delete_link_nonexistent() {
2375 let (db, _dir) = create_test_db();
2376
2377 let nonexistent_id = Uuid::new_v4();
2378 let deleted = db
2379 .delete_link(&nonexistent_id)
2380 .expect("Failed to call delete_link");
2381
2382 assert!(!deleted, "Should return false for nonexistent link");
2383 }
2384
2385 #[test]
2386 fn test_delete_links_by_session() {
2387 let (db, _dir) = create_test_db();
2388
2389 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2390 db.insert_session(&session)
2391 .expect("Failed to insert session");
2392
2393 let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
2395 let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
2396 let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
2397
2398 db.insert_link(&link1).expect("Failed to insert link1");
2399 db.insert_link(&link2).expect("Failed to insert link2");
2400 db.insert_link(&link3).expect("Failed to insert link3");
2401
2402 let links_before = db
2404 .get_links_by_session(&session.id)
2405 .expect("Failed to get links");
2406 assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
2407
2408 let count = db
2410 .delete_links_by_session(&session.id)
2411 .expect("Failed to delete links");
2412 assert_eq!(count, 3, "Should have deleted 3 links");
2413
2414 let links_after = db
2416 .get_links_by_session(&session.id)
2417 .expect("Failed to get links");
2418 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
2419 }
2420
2421 #[test]
2422 fn test_delete_links_by_session_no_links() {
2423 let (db, _dir) = create_test_db();
2424
2425 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2426 db.insert_session(&session)
2427 .expect("Failed to insert session");
2428
2429 let count = db
2431 .delete_links_by_session(&session.id)
2432 .expect("Failed to call delete_links_by_session");
2433 assert_eq!(count, 0, "Should return 0 when no links exist");
2434 }
2435
2436 #[test]
2437 fn test_delete_links_by_session_preserves_other_sessions() {
2438 let (db, _dir) = create_test_db();
2439
2440 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2441 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2442
2443 db.insert_session(&session1)
2444 .expect("Failed to insert session1");
2445 db.insert_session(&session2)
2446 .expect("Failed to insert session2");
2447
2448 let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2449 let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
2450
2451 db.insert_link(&link1).expect("Failed to insert link1");
2452 db.insert_link(&link2).expect("Failed to insert link2");
2453
2454 let count = db
2456 .delete_links_by_session(&session1.id)
2457 .expect("Failed to delete links");
2458 assert_eq!(count, 1, "Should have deleted 1 link");
2459
2460 let session2_links = db
2462 .get_links_by_session(&session2.id)
2463 .expect("Failed to get links");
2464 assert_eq!(
2465 session2_links.len(),
2466 1,
2467 "Session2's link should be preserved"
2468 );
2469 assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
2470 }
2471
2472 #[test]
2473 fn test_delete_link_by_session_and_commit() {
2474 let (db, _dir) = create_test_db();
2475
2476 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2477 db.insert_session(&session)
2478 .expect("Failed to insert session");
2479
2480 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2481 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2482
2483 db.insert_link(&link1).expect("Failed to insert link1");
2484 db.insert_link(&link2).expect("Failed to insert link2");
2485
2486 let deleted = db
2488 .delete_link_by_session_and_commit(&session.id, "abc123")
2489 .expect("Failed to delete link");
2490 assert!(deleted, "Should return true when link is deleted");
2491
2492 let links = db
2494 .get_links_by_session(&session.id)
2495 .expect("Failed to get links");
2496 assert_eq!(links.len(), 1, "Should have 1 link remaining");
2497 assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
2498 }
2499
2500 #[test]
2501 fn test_delete_link_by_session_and_commit_full_sha() {
2502 let (db, _dir) = create_test_db();
2503
2504 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2505 db.insert_session(&session)
2506 .expect("Failed to insert session");
2507
2508 let full_sha = "abc123def456789012345678901234567890abcd";
2509 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
2510 db.insert_link(&link).expect("Failed to insert link");
2511
2512 let deleted = db
2514 .delete_link_by_session_and_commit(&session.id, full_sha)
2515 .expect("Failed to delete link");
2516 assert!(deleted, "Should delete with full SHA");
2517
2518 let links = db
2519 .get_links_by_session(&session.id)
2520 .expect("Failed to get links");
2521 assert_eq!(links.len(), 0, "Should have 0 links after delete");
2522 }
2523
2524 #[test]
2525 fn test_delete_link_by_session_and_commit_no_match() {
2526 let (db, _dir) = create_test_db();
2527
2528 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2529 db.insert_session(&session)
2530 .expect("Failed to insert session");
2531
2532 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
2533 db.insert_link(&link).expect("Failed to insert link");
2534
2535 let deleted = db
2537 .delete_link_by_session_and_commit(&session.id, "xyz999")
2538 .expect("Failed to call delete");
2539 assert!(!deleted, "Should return false when no match");
2540
2541 let links = db
2543 .get_links_by_session(&session.id)
2544 .expect("Failed to get links");
2545 assert_eq!(links.len(), 1, "Link should be preserved");
2546 }
2547
2548 #[test]
2549 fn test_delete_link_by_session_and_commit_wrong_session() {
2550 let (db, _dir) = create_test_db();
2551
2552 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2553 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2554
2555 db.insert_session(&session1)
2556 .expect("Failed to insert session1");
2557 db.insert_session(&session2)
2558 .expect("Failed to insert session2");
2559
2560 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2561 db.insert_link(&link).expect("Failed to insert link");
2562
2563 let deleted = db
2565 .delete_link_by_session_and_commit(&session2.id, "abc123")
2566 .expect("Failed to call delete");
2567 assert!(!deleted, "Should not delete link from different session");
2568
2569 let links = db
2571 .get_links_by_session(&session1.id)
2572 .expect("Failed to get links");
2573 assert_eq!(links.len(), 1, "Link should be preserved");
2574 }
2575
2576 #[test]
2579 fn test_find_sessions_near_commit_time_basic() {
2580 let (db, _dir) = create_test_db();
2581 let now = Utc::now();
2582
2583 let mut session = create_test_session(
2585 "claude-code",
2586 "/home/user/project",
2587 now - Duration::hours(1),
2588 None,
2589 );
2590 session.ended_at = Some(now - Duration::minutes(10));
2591
2592 db.insert_session(&session).expect("insert session");
2593
2594 let found = db
2596 .find_sessions_near_commit_time(now, 30, None)
2597 .expect("find sessions");
2598
2599 assert_eq!(found.len(), 1, "Should find session within window");
2600 assert_eq!(found[0].id, session.id);
2601 }
2602
2603 #[test]
2604 fn test_find_sessions_near_commit_time_outside_window() {
2605 let (db, _dir) = create_test_db();
2606 let now = Utc::now();
2607
2608 let mut session =
2610 create_test_session("claude-code", "/project", now - Duration::hours(3), None);
2611 session.ended_at = Some(now - Duration::hours(2));
2612
2613 db.insert_session(&session).expect("insert session");
2614
2615 let found = db
2617 .find_sessions_near_commit_time(now, 30, None)
2618 .expect("find sessions");
2619
2620 assert!(found.is_empty(), "Should not find session outside window");
2621 }
2622
2623 #[test]
2624 fn test_find_sessions_near_commit_time_with_working_dir() {
2625 let (db, _dir) = create_test_db();
2626 let now = Utc::now();
2627
2628 let mut session1 = create_test_session(
2630 "claude-code",
2631 "/home/user/project-a",
2632 now - Duration::minutes(30),
2633 None,
2634 );
2635 session1.ended_at = Some(now - Duration::minutes(5));
2636
2637 let mut session2 = create_test_session(
2638 "claude-code",
2639 "/home/user/project-b",
2640 now - Duration::minutes(30),
2641 None,
2642 );
2643 session2.ended_at = Some(now - Duration::minutes(5));
2644
2645 db.insert_session(&session1).expect("insert session1");
2646 db.insert_session(&session2).expect("insert session2");
2647
2648 let found = db
2650 .find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
2651 .expect("find sessions");
2652
2653 assert_eq!(found.len(), 1, "Should find only session in project-a");
2654 assert_eq!(found[0].id, session1.id);
2655 }
2656
2657 #[test]
2658 fn test_find_sessions_near_commit_time_ongoing_session() {
2659 let (db, _dir) = create_test_db();
2660 let now = Utc::now();
2661
2662 let session =
2664 create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
2665 db.insert_session(&session).expect("insert session");
2668
2669 let found = db
2671 .find_sessions_near_commit_time(now, 30, None)
2672 .expect("find sessions");
2673
2674 assert_eq!(found.len(), 1, "Should find ongoing session");
2675 assert_eq!(found[0].id, session.id);
2676 }
2677
2678 #[test]
2679 fn test_link_exists_true() {
2680 let (db, _dir) = create_test_db();
2681
2682 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2683 db.insert_session(&session).expect("insert session");
2684
2685 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2686 db.insert_link(&link).expect("insert link");
2687
2688 assert!(
2690 db.link_exists(&session.id, "abc123def456")
2691 .expect("check exists"),
2692 "Should find link with full SHA"
2693 );
2694
2695 assert!(
2697 db.link_exists(&session.id, "abc123").expect("check exists"),
2698 "Should find link with partial SHA"
2699 );
2700 }
2701
2702 #[test]
2703 fn test_link_exists_false() {
2704 let (db, _dir) = create_test_db();
2705
2706 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2707 db.insert_session(&session).expect("insert session");
2708
2709 assert!(
2711 !db.link_exists(&session.id, "abc123").expect("check exists"),
2712 "Should not find non-existent link"
2713 );
2714 }
2715
2716 #[test]
2717 fn test_link_exists_different_session() {
2718 let (db, _dir) = create_test_db();
2719
2720 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2721 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2722
2723 db.insert_session(&session1).expect("insert session1");
2724 db.insert_session(&session2).expect("insert session2");
2725
2726 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2727 db.insert_link(&link).expect("insert link");
2728
2729 assert!(
2731 db.link_exists(&session1.id, "abc123").expect("check"),
2732 "Should find link for session1"
2733 );
2734 assert!(
2735 !db.link_exists(&session2.id, "abc123").expect("check"),
2736 "Should not find link for session2"
2737 );
2738 }
2739
2740 #[test]
2743 fn test_search_with_tool_filter() {
2744 let (db, _dir) = create_test_db();
2745
2746 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2747 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
2748
2749 db.insert_session(&session1).expect("insert session1");
2750 db.insert_session(&session2).expect("insert session2");
2751
2752 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
2753 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
2754
2755 db.insert_message(&msg1).expect("insert msg1");
2756 db.insert_message(&msg2).expect("insert msg2");
2757
2758 let options = SearchOptions {
2760 query: "Hello".to_string(),
2761 limit: 10,
2762 tool: Some("claude-code".to_string()),
2763 ..Default::default()
2764 };
2765 let results = db.search_with_options(&options).expect("search");
2766
2767 assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
2768 assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
2769 }
2770
2771 #[test]
2772 fn test_search_with_date_range() {
2773 let (db, _dir) = create_test_db();
2774
2775 let old_time = Utc::now() - chrono::Duration::days(30);
2776 let new_time = Utc::now() - chrono::Duration::days(1);
2777
2778 let session1 = create_test_session("claude-code", "/project1", old_time, None);
2779 let session2 = create_test_session("claude-code", "/project2", new_time, None);
2780
2781 db.insert_session(&session1).expect("insert session1");
2782 db.insert_session(&session2).expect("insert session2");
2783
2784 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
2785 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
2786
2787 db.insert_message(&msg1).expect("insert msg1");
2788 db.insert_message(&msg2).expect("insert msg2");
2789
2790 let since = Utc::now() - chrono::Duration::days(7);
2792 let options = SearchOptions {
2793 query: "session".to_string(),
2794 limit: 10,
2795 since: Some(since),
2796 ..Default::default()
2797 };
2798 let results = db.search_with_options(&options).expect("search");
2799
2800 assert_eq!(results.len(), 1, "Should find 1 result within date range");
2801 assert!(
2802 results[0].working_directory.contains("project2"),
2803 "Should be from newer project"
2804 );
2805 }
2806
2807 #[test]
2808 fn test_search_with_project_filter() {
2809 let (db, _dir) = create_test_db();
2810
2811 let session1 =
2812 create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
2813 let session2 =
2814 create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
2815
2816 db.insert_session(&session1).expect("insert session1");
2817 db.insert_session(&session2).expect("insert session2");
2818
2819 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
2820 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
2821
2822 db.insert_message(&msg1).expect("insert msg1");
2823 db.insert_message(&msg2).expect("insert msg2");
2824
2825 let options = SearchOptions {
2827 query: "Testing".to_string(),
2828 limit: 10,
2829 project: Some("frontend".to_string()),
2830 ..Default::default()
2831 };
2832 let results = db.search_with_options(&options).expect("search");
2833
2834 assert_eq!(results.len(), 1, "Should find 1 result with project filter");
2835 assert!(
2836 results[0].working_directory.contains("frontend"),
2837 "Should be from frontend project"
2838 );
2839 }
2840
2841 #[test]
2842 fn test_search_with_branch_filter() {
2843 let (db, _dir) = create_test_db();
2844
2845 let session1 = Session {
2846 id: Uuid::new_v4(),
2847 tool: "claude-code".to_string(),
2848 tool_version: None,
2849 started_at: Utc::now(),
2850 ended_at: None,
2851 model: None,
2852 working_directory: "/project".to_string(),
2853 git_branch: Some("feat/auth".to_string()),
2854 source_path: None,
2855 message_count: 0,
2856 };
2857 let session2 = Session {
2858 id: Uuid::new_v4(),
2859 tool: "claude-code".to_string(),
2860 tool_version: None,
2861 started_at: Utc::now(),
2862 ended_at: None,
2863 model: None,
2864 working_directory: "/project".to_string(),
2865 git_branch: Some("main".to_string()),
2866 source_path: None,
2867 message_count: 0,
2868 };
2869
2870 db.insert_session(&session1).expect("insert session1");
2871 db.insert_session(&session2).expect("insert session2");
2872
2873 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
2874 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
2875
2876 db.insert_message(&msg1).expect("insert msg1");
2877 db.insert_message(&msg2).expect("insert msg2");
2878
2879 let options = SearchOptions {
2881 query: "work".to_string(),
2882 limit: 10,
2883 branch: Some("auth".to_string()),
2884 ..Default::default()
2885 };
2886 let results = db.search_with_options(&options).expect("search");
2887
2888 assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
2889 assert_eq!(
2890 results[0].git_branch.as_deref(),
2891 Some("feat/auth"),
2892 "Should be from feat/auth branch"
2893 );
2894 }
2895
2896 #[test]
2897 fn test_search_metadata_matches_project() {
2898 let (db, _dir) = create_test_db();
2899
2900 let session =
2901 create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
2902 db.insert_session(&session).expect("insert session");
2903
2904 let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
2906 db.insert_message(&msg).expect("insert msg");
2907
2908 let options = SearchOptions {
2910 query: "redactyl".to_string(),
2911 limit: 10,
2912 ..Default::default()
2913 };
2914 let results = db.search_with_options(&options).expect("search");
2915
2916 assert_eq!(
2917 results.len(),
2918 1,
2919 "Should find session via metadata match on project name"
2920 );
2921 }
2922
2923 #[test]
2924 fn test_search_returns_extended_session_info() {
2925 let (db, _dir) = create_test_db();
2926
2927 let started_at = Utc::now();
2928 let session = Session {
2929 id: Uuid::new_v4(),
2930 tool: "claude-code".to_string(),
2931 tool_version: Some("1.0.0".to_string()),
2932 started_at,
2933 ended_at: None,
2934 model: None,
2935 working_directory: "/home/user/myapp".to_string(),
2936 git_branch: Some("develop".to_string()),
2937 source_path: None,
2938 message_count: 5,
2939 };
2940 db.insert_session(&session).expect("insert session");
2941
2942 let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
2943 db.insert_message(&msg).expect("insert msg");
2944
2945 let options = SearchOptions {
2946 query: "Test".to_string(),
2947 limit: 10,
2948 ..Default::default()
2949 };
2950 let results = db.search_with_options(&options).expect("search");
2951
2952 assert_eq!(results.len(), 1, "Should find 1 result");
2953 let result = &results[0];
2954
2955 assert_eq!(result.tool, "claude-code", "Tool should be populated");
2956 assert_eq!(
2957 result.git_branch.as_deref(),
2958 Some("develop"),
2959 "Branch should be populated"
2960 );
2961 assert!(
2962 result.session_message_count > 0,
2963 "Message count should be populated"
2964 );
2965 assert!(
2966 result.session_started_at.is_some(),
2967 "Session start time should be populated"
2968 );
2969 }
2970
2971 #[test]
2972 fn test_get_context_messages() {
2973 let (db, _dir) = create_test_db();
2974
2975 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2976 db.insert_session(&session).expect("insert session");
2977
2978 for i in 0..5 {
2980 let role = if i % 2 == 0 {
2981 MessageRole::User
2982 } else {
2983 MessageRole::Assistant
2984 };
2985 let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
2986 db.insert_message(&msg).expect("insert message");
2987 }
2988
2989 let (before, after) = db
2991 .get_context_messages(&session.id, 2, 1)
2992 .expect("get context");
2993
2994 assert_eq!(before.len(), 1, "Should have 1 message before");
2995 assert_eq!(after.len(), 1, "Should have 1 message after");
2996 assert_eq!(before[0].index, 1, "Before message should be index 1");
2997 assert_eq!(after[0].index, 3, "After message should be index 3");
2998 }
2999
3000 #[test]
3001 fn test_get_context_messages_at_start() {
3002 let (db, _dir) = create_test_db();
3003
3004 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3005 db.insert_session(&session).expect("insert session");
3006
3007 for i in 0..3 {
3008 let msg =
3009 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
3010 db.insert_message(&msg).expect("insert message");
3011 }
3012
3013 let (before, after) = db
3015 .get_context_messages(&session.id, 0, 2)
3016 .expect("get context");
3017
3018 assert!(
3019 before.is_empty(),
3020 "Should have no messages before first message"
3021 );
3022 assert_eq!(after.len(), 2, "Should have 2 messages after");
3023 }
3024
3025 #[test]
3026 fn test_get_context_messages_at_end() {
3027 let (db, _dir) = create_test_db();
3028
3029 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3030 db.insert_session(&session).expect("insert session");
3031
3032 for i in 0..3 {
3033 let msg =
3034 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
3035 db.insert_message(&msg).expect("insert message");
3036 }
3037
3038 let (before, after) = db
3040 .get_context_messages(&session.id, 2, 2)
3041 .expect("get context");
3042
3043 assert_eq!(before.len(), 2, "Should have 2 messages before");
3044 assert!(
3045 after.is_empty(),
3046 "Should have no messages after last message"
3047 );
3048 }
3049
3050 #[test]
3051 fn test_search_combined_filters() {
3052 let (db, _dir) = create_test_db();
3053
3054 let session1 = Session {
3055 id: Uuid::new_v4(),
3056 tool: "claude-code".to_string(),
3057 tool_version: None,
3058 started_at: Utc::now(),
3059 ended_at: None,
3060 model: None,
3061 working_directory: "/home/user/myapp".to_string(),
3062 git_branch: Some("feat/api".to_string()),
3063 source_path: None,
3064 message_count: 1,
3065 };
3066 let session2 = Session {
3067 id: Uuid::new_v4(),
3068 tool: "aider".to_string(),
3069 tool_version: None,
3070 started_at: Utc::now(),
3071 ended_at: None,
3072 model: None,
3073 working_directory: "/home/user/myapp".to_string(),
3074 git_branch: Some("feat/api".to_string()),
3075 source_path: None,
3076 message_count: 1,
3077 };
3078
3079 db.insert_session(&session1).expect("insert session1");
3080 db.insert_session(&session2).expect("insert session2");
3081
3082 let msg1 =
3083 create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
3084 let msg2 =
3085 create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
3086
3087 db.insert_message(&msg1).expect("insert msg1");
3088 db.insert_message(&msg2).expect("insert msg2");
3089
3090 let options = SearchOptions {
3092 query: "API".to_string(),
3093 limit: 10,
3094 tool: Some("claude-code".to_string()),
3095 branch: Some("api".to_string()),
3096 project: Some("myapp".to_string()),
3097 ..Default::default()
3098 };
3099 let results = db.search_with_options(&options).expect("search");
3100
3101 assert!(
3103 !results.is_empty(),
3104 "Should find at least 1 result matching all filters"
3105 );
3106 for result in &results {
3108 assert_eq!(
3109 result.tool, "claude-code",
3110 "All results should be from claude-code"
3111 );
3112 }
3113 }
3114
3115 #[test]
3118 fn test_delete_session_removes_all_data() {
3119 let (db, _dir) = create_test_db();
3120
3121 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3122 db.insert_session(&session).expect("insert session");
3123
3124 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
3126 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there");
3127 db.insert_message(&msg1).expect("insert msg1");
3128 db.insert_message(&msg2).expect("insert msg2");
3129
3130 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3132 db.insert_link(&link).expect("insert link");
3133
3134 assert_eq!(db.session_count().expect("count"), 1);
3136 assert_eq!(db.message_count().expect("count"), 2);
3137 assert_eq!(db.link_count().expect("count"), 1);
3138
3139 let (msgs_deleted, links_deleted) = db.delete_session(&session.id).expect("delete");
3141 assert_eq!(msgs_deleted, 2, "Should delete 2 messages");
3142 assert_eq!(links_deleted, 1, "Should delete 1 link");
3143
3144 assert_eq!(db.session_count().expect("count"), 0);
3146 assert_eq!(db.message_count().expect("count"), 0);
3147 assert_eq!(db.link_count().expect("count"), 0);
3148 assert!(db.get_session(&session.id).expect("get").is_none());
3149 }
3150
3151 #[test]
3152 fn test_delete_session_preserves_other_sessions() {
3153 let (db, _dir) = create_test_db();
3154
3155 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3156 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
3157
3158 db.insert_session(&session1).expect("insert session1");
3159 db.insert_session(&session2).expect("insert session2");
3160
3161 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello 1");
3162 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello 2");
3163 db.insert_message(&msg1).expect("insert msg1");
3164 db.insert_message(&msg2).expect("insert msg2");
3165
3166 db.delete_session(&session1.id).expect("delete");
3168
3169 assert_eq!(db.session_count().expect("count"), 1);
3171 assert_eq!(db.message_count().expect("count"), 1);
3172 assert!(db.get_session(&session2.id).expect("get").is_some());
3173 }
3174
3175 #[test]
3178 fn test_file_size() {
3179 let (db, _dir) = create_test_db();
3180
3181 let size = db.file_size().expect("get size");
3182 assert!(size.is_some(), "Should have file size for file-based db");
3183 assert!(size.unwrap() > 0, "Database file should have size > 0");
3184 }
3185
3186 #[test]
3187 fn test_vacuum() {
3188 let (db, _dir) = create_test_db();
3189
3190 db.vacuum().expect("vacuum should succeed");
3192 }
3193
3194 #[test]
3195 fn test_count_sessions_older_than() {
3196 let (db, _dir) = create_test_db();
3197 let now = Utc::now();
3198
3199 let old_session =
3201 create_test_session("claude-code", "/project1", now - Duration::days(100), None);
3202 let recent_session =
3203 create_test_session("claude-code", "/project2", now - Duration::days(10), None);
3204
3205 db.insert_session(&old_session).expect("insert old");
3206 db.insert_session(&recent_session).expect("insert recent");
3207
3208 let cutoff = now - Duration::days(30);
3210 let count = db.count_sessions_older_than(cutoff).expect("count");
3211 assert_eq!(count, 1, "Should find 1 session older than 30 days");
3212
3213 let old_cutoff = now - Duration::days(200);
3215 let old_count = db.count_sessions_older_than(old_cutoff).expect("count");
3216 assert_eq!(old_count, 0, "Should find 0 sessions older than 200 days");
3217 }
3218
3219 #[test]
3220 fn test_delete_sessions_older_than() {
3221 let (db, _dir) = create_test_db();
3222 let now = Utc::now();
3223
3224 let old_session =
3226 create_test_session("claude-code", "/project1", now - Duration::days(100), None);
3227 let recent_session =
3228 create_test_session("claude-code", "/project2", now - Duration::days(10), None);
3229
3230 db.insert_session(&old_session).expect("insert old");
3231 db.insert_session(&recent_session).expect("insert recent");
3232
3233 let msg1 = create_test_message(old_session.id, 0, MessageRole::User, "Old message");
3235 let msg2 = create_test_message(recent_session.id, 0, MessageRole::User, "Recent message");
3236 db.insert_message(&msg1).expect("insert msg1");
3237 db.insert_message(&msg2).expect("insert msg2");
3238
3239 let cutoff = now - Duration::days(30);
3241 let deleted = db.delete_sessions_older_than(cutoff).expect("delete");
3242 assert_eq!(deleted, 1, "Should delete 1 session");
3243
3244 assert_eq!(db.session_count().expect("count"), 1);
3246 assert!(db.get_session(&recent_session.id).expect("get").is_some());
3247 assert!(db.get_session(&old_session.id).expect("get").is_none());
3248
3249 assert_eq!(db.message_count().expect("count"), 1);
3251 }
3252
3253 #[test]
3254 fn test_get_sessions_older_than() {
3255 let (db, _dir) = create_test_db();
3256 let now = Utc::now();
3257
3258 let old_session = create_test_session(
3260 "claude-code",
3261 "/project/old",
3262 now - Duration::days(100),
3263 None,
3264 );
3265 let medium_session =
3266 create_test_session("aider", "/project/medium", now - Duration::days(50), None);
3267 let recent_session =
3268 create_test_session("gemini", "/project/recent", now - Duration::days(10), None);
3269
3270 db.insert_session(&old_session).expect("insert old");
3271 db.insert_session(&medium_session).expect("insert medium");
3272 db.insert_session(&recent_session).expect("insert recent");
3273
3274 let cutoff = now - Duration::days(30);
3276 let sessions = db.get_sessions_older_than(cutoff).expect("get sessions");
3277 assert_eq!(
3278 sessions.len(),
3279 2,
3280 "Should find 2 sessions older than 30 days"
3281 );
3282
3283 assert_eq!(sessions[0].id, old_session.id);
3285 assert_eq!(sessions[1].id, medium_session.id);
3286
3287 assert_eq!(sessions[0].tool, "claude-code");
3289 assert_eq!(sessions[0].working_directory, "/project/old");
3290 assert_eq!(sessions[1].tool, "aider");
3291 assert_eq!(sessions[1].working_directory, "/project/medium");
3292
3293 let old_cutoff = now - Duration::days(200);
3295 let old_sessions = db
3296 .get_sessions_older_than(old_cutoff)
3297 .expect("get old sessions");
3298 assert_eq!(
3299 old_sessions.len(),
3300 0,
3301 "Should find 0 sessions older than 200 days"
3302 );
3303 }
3304
3305 #[test]
3306 fn test_stats() {
3307 let (db, _dir) = create_test_db();
3308 let now = Utc::now();
3309
3310 let empty_stats = db.stats().expect("stats");
3312 assert_eq!(empty_stats.session_count, 0);
3313 assert_eq!(empty_stats.message_count, 0);
3314 assert_eq!(empty_stats.link_count, 0);
3315 assert!(empty_stats.oldest_session.is_none());
3316 assert!(empty_stats.newest_session.is_none());
3317 assert!(empty_stats.sessions_by_tool.is_empty());
3318
3319 let session1 =
3321 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
3322 let session2 = create_test_session("aider", "/project2", now - Duration::hours(1), None);
3323 let session3 = create_test_session("claude-code", "/project3", now, None);
3324
3325 db.insert_session(&session1).expect("insert 1");
3326 db.insert_session(&session2).expect("insert 2");
3327 db.insert_session(&session3).expect("insert 3");
3328
3329 let msg = create_test_message(session1.id, 0, MessageRole::User, "Hello");
3330 db.insert_message(&msg).expect("insert msg");
3331
3332 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3333 db.insert_link(&link).expect("insert link");
3334
3335 let stats = db.stats().expect("stats");
3337 assert_eq!(stats.session_count, 3);
3338 assert_eq!(stats.message_count, 1);
3339 assert_eq!(stats.link_count, 1);
3340 assert!(stats.oldest_session.is_some());
3341 assert!(stats.newest_session.is_some());
3342
3343 assert_eq!(stats.sessions_by_tool.len(), 2);
3345 assert_eq!(stats.sessions_by_tool[0].0, "claude-code");
3347 assert_eq!(stats.sessions_by_tool[0].1, 2);
3348 assert_eq!(stats.sessions_by_tool[1].0, "aider");
3349 assert_eq!(stats.sessions_by_tool[1].1, 1);
3350 }
3351
3352 #[test]
3355 fn test_get_session_branch_history_no_messages() {
3356 let (db, _dir) = create_test_db();
3357 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3358 db.insert_session(&session)
3359 .expect("Failed to insert session");
3360
3361 let branches = db
3362 .get_session_branch_history(session.id)
3363 .expect("Failed to get branch history");
3364
3365 assert!(branches.is_empty(), "Empty session should have no branches");
3366 }
3367
3368 #[test]
3369 fn test_get_session_branch_history_single_branch() {
3370 let (db, _dir) = create_test_db();
3371 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3372 db.insert_session(&session)
3373 .expect("Failed to insert session");
3374
3375 for i in 0..3 {
3377 let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
3378 msg.git_branch = Some("main".to_string());
3379 db.insert_message(&msg).expect("Failed to insert message");
3380 }
3381
3382 let branches = db
3383 .get_session_branch_history(session.id)
3384 .expect("Failed to get branch history");
3385
3386 assert_eq!(branches, vec!["main"], "Should have single branch");
3387 }
3388
3389 #[test]
3390 fn test_get_session_branch_history_multiple_branches() {
3391 let (db, _dir) = create_test_db();
3392 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3393 db.insert_session(&session)
3394 .expect("Failed to insert session");
3395
3396 let branch_sequence = ["main", "main", "feat/auth", "feat/auth", "main"];
3398 for (i, branch) in branch_sequence.iter().enumerate() {
3399 let mut msg = create_test_message(session.id, i as i32, MessageRole::User, "test");
3400 msg.git_branch = Some(branch.to_string());
3401 db.insert_message(&msg).expect("Failed to insert message");
3402 }
3403
3404 let branches = db
3405 .get_session_branch_history(session.id)
3406 .expect("Failed to get branch history");
3407
3408 assert_eq!(
3409 branches,
3410 vec!["main", "feat/auth", "main"],
3411 "Should show branch transitions without consecutive duplicates"
3412 );
3413 }
3414
3415 #[test]
3416 fn test_get_session_branch_history_with_none_branches() {
3417 let (db, _dir) = create_test_db();
3418 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3419 db.insert_session(&session)
3420 .expect("Failed to insert session");
3421
3422 let mut msg1 = create_test_message(session.id, 0, MessageRole::User, "test");
3424 msg1.git_branch = Some("main".to_string());
3425 db.insert_message(&msg1).expect("Failed to insert message");
3426
3427 let mut msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "test");
3428 msg2.git_branch = None; db.insert_message(&msg2).expect("Failed to insert message");
3430
3431 let mut msg3 = create_test_message(session.id, 2, MessageRole::User, "test");
3432 msg3.git_branch = Some("feat/new".to_string());
3433 db.insert_message(&msg3).expect("Failed to insert message");
3434
3435 let branches = db
3436 .get_session_branch_history(session.id)
3437 .expect("Failed to get branch history");
3438
3439 assert_eq!(
3440 branches,
3441 vec!["main", "feat/new"],
3442 "Should skip None branches and show transitions"
3443 );
3444 }
3445
3446 #[test]
3447 fn test_get_session_branch_history_all_none_branches() {
3448 let (db, _dir) = create_test_db();
3449 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3450 db.insert_session(&session)
3451 .expect("Failed to insert session");
3452
3453 for i in 0..3 {
3455 let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
3456 msg.git_branch = None;
3457 db.insert_message(&msg).expect("Failed to insert message");
3458 }
3459
3460 let branches = db
3461 .get_session_branch_history(session.id)
3462 .expect("Failed to get branch history");
3463
3464 assert!(
3465 branches.is_empty(),
3466 "Session with all None branches should return empty"
3467 );
3468 }
3469}