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 fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<Session> {
311 let ended_at_str: Option<String> = row.get(4)?;
312 let ended_at = match ended_at_str {
313 Some(s) => Some(parse_datetime(&s)?),
314 None => None,
315 };
316
317 Ok(Session {
318 id: parse_uuid(&row.get::<_, String>(0)?)?,
319 tool: row.get(1)?,
320 tool_version: row.get(2)?,
321 started_at: parse_datetime(&row.get::<_, String>(3)?)?,
322 ended_at,
323 model: row.get(5)?,
324 working_directory: row.get(6)?,
325 git_branch: row.get(7)?,
326 source_path: row.get(8)?,
327 message_count: row.get(9)?,
328 })
329 }
330
331 pub fn insert_message(&self, message: &Message) -> Result<()> {
339 let content_json = serde_json::to_string(&message.content)?;
340
341 let rows_changed = self.conn.execute(
342 r#"
343 INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
344 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
345 ON CONFLICT(id) DO NOTHING
346 "#,
347 params![
348 message.id.to_string(),
349 message.session_id.to_string(),
350 message.parent_id.map(|u| u.to_string()),
351 message.index,
352 message.timestamp.to_rfc3339(),
353 message.role.to_string(),
354 content_json,
355 message.model,
356 message.git_branch,
357 message.cwd,
358 ],
359 )?;
360
361 if rows_changed > 0 {
363 let text_content = message.content.text();
364 if !text_content.is_empty() {
365 self.conn.execute(
366 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
367 params![message.id.to_string(), text_content],
368 )?;
369 }
370 }
371
372 Ok(())
373 }
374
375 pub fn get_messages(&self, session_id: &Uuid) -> Result<Vec<Message>> {
379 let mut stmt = self.conn.prepare(
380 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
381 FROM messages
382 WHERE session_id = ?1
383 ORDER BY idx"
384 )?;
385
386 let rows = stmt.query_map(params![session_id.to_string()], |row| {
387 let role_str: String = row.get(5)?;
388 let content_str: String = row.get(6)?;
389
390 let parent_id_str: Option<String> = row.get(2)?;
391 let parent_id = match parent_id_str {
392 Some(s) => Some(parse_uuid(&s)?),
393 None => None,
394 };
395
396 Ok(Message {
397 id: parse_uuid(&row.get::<_, String>(0)?)?,
398 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
399 parent_id,
400 index: row.get(3)?,
401 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
402 role: match role_str.as_str() {
403 "user" => MessageRole::User,
404 "assistant" => MessageRole::Assistant,
405 "system" => MessageRole::System,
406 _ => MessageRole::User,
407 },
408 content: serde_json::from_str(&content_str)
409 .unwrap_or(MessageContent::Text(content_str)),
410 model: row.get(7)?,
411 git_branch: row.get(8)?,
412 cwd: row.get(9)?,
413 })
414 })?;
415
416 rows.collect::<Result<Vec<_>, _>>()
417 .context("Failed to get messages")
418 }
419
420 pub fn insert_link(&self, link: &SessionLink) -> Result<()> {
427 self.conn.execute(
428 r#"
429 INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
430 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
431 "#,
432 params![
433 link.id.to_string(),
434 link.session_id.to_string(),
435 format!("{:?}", link.link_type).to_lowercase(),
436 link.commit_sha,
437 link.branch,
438 link.remote,
439 link.created_at.to_rfc3339(),
440 format!("{:?}", link.created_by).to_lowercase(),
441 link.confidence,
442 ],
443 )?;
444 Ok(())
445 }
446
447 pub fn get_links_by_commit(&self, commit_sha: &str) -> Result<Vec<SessionLink>> {
452 let mut stmt = self.conn.prepare(
453 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
454 FROM session_links
455 WHERE commit_sha LIKE ?1"
456 )?;
457
458 let pattern = format!("{commit_sha}%");
459 let rows = stmt.query_map(params![pattern], Self::row_to_link)?;
460
461 rows.collect::<Result<Vec<_>, _>>()
462 .context("Failed to get links")
463 }
464
465 pub fn get_links_by_session(&self, session_id: &Uuid) -> Result<Vec<SessionLink>> {
470 let mut stmt = self.conn.prepare(
471 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
472 FROM session_links
473 WHERE session_id = ?1"
474 )?;
475
476 let rows = stmt.query_map(params![session_id.to_string()], Self::row_to_link)?;
477
478 rows.collect::<Result<Vec<_>, _>>()
479 .context("Failed to get links")
480 }
481
482 fn row_to_link(row: &rusqlite::Row) -> rusqlite::Result<SessionLink> {
483 use super::models::{LinkCreator, LinkType};
484
485 let link_type_str: String = row.get(2)?;
486 let created_by_str: String = row.get(7)?;
487
488 Ok(SessionLink {
489 id: parse_uuid(&row.get::<_, String>(0)?)?,
490 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
491 link_type: match link_type_str.as_str() {
492 "commit" => LinkType::Commit,
493 "branch" => LinkType::Branch,
494 "pr" => LinkType::Pr,
495 _ => LinkType::Manual,
496 },
497 commit_sha: row.get(3)?,
498 branch: row.get(4)?,
499 remote: row.get(5)?,
500 created_at: parse_datetime(&row.get::<_, String>(6)?)?,
501 created_by: match created_by_str.as_str() {
502 "auto" => LinkCreator::Auto,
503 _ => LinkCreator::User,
504 },
505 confidence: row.get(8)?,
506 })
507 }
508
509 #[allow(dead_code)]
516 pub fn delete_link(&self, link_id: &Uuid) -> Result<bool> {
517 let rows_affected = self.conn.execute(
518 "DELETE FROM session_links WHERE id = ?1",
519 params![link_id.to_string()],
520 )?;
521 Ok(rows_affected > 0)
522 }
523
524 pub fn delete_links_by_session(&self, session_id: &Uuid) -> Result<usize> {
528 let rows_affected = self.conn.execute(
529 "DELETE FROM session_links WHERE session_id = ?1",
530 params![session_id.to_string()],
531 )?;
532 Ok(rows_affected)
533 }
534
535 pub fn delete_link_by_session_and_commit(
540 &self,
541 session_id: &Uuid,
542 commit_sha: &str,
543 ) -> Result<bool> {
544 let pattern = format!("{commit_sha}%");
545 let rows_affected = self.conn.execute(
546 "DELETE FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
547 params![session_id.to_string(), pattern],
548 )?;
549 Ok(rows_affected > 0)
550 }
551
552 #[allow(dead_code)]
566 pub fn search_messages(
567 &self,
568 query: &str,
569 limit: usize,
570 working_dir: Option<&str>,
571 since: Option<chrono::DateTime<chrono::Utc>>,
572 role: Option<&str>,
573 ) -> Result<Vec<SearchResult>> {
574 use super::models::SearchOptions;
575
576 let options = SearchOptions {
578 query: query.to_string(),
579 limit,
580 repo: working_dir.map(|s| s.to_string()),
581 since,
582 role: role.map(|s| s.to_string()),
583 ..Default::default()
584 };
585
586 self.search_with_options(&options)
587 }
588
589 pub fn search_with_options(
603 &self,
604 options: &super::models::SearchOptions,
605 ) -> Result<Vec<SearchResult>> {
606 let escaped_query = escape_fts5_query(&options.query);
608
609 let mut sql = String::from(
612 r#"
613 SELECT
614 m.session_id,
615 m.id as message_id,
616 m.role,
617 snippet(messages_fts, 1, '**', '**', '...', 32) as snippet,
618 m.timestamp,
619 s.working_directory,
620 s.tool,
621 s.git_branch,
622 s.message_count,
623 s.started_at,
624 m.idx as message_index
625 FROM messages_fts fts
626 JOIN messages m ON fts.message_id = m.id
627 JOIN sessions s ON m.session_id = s.id
628 WHERE messages_fts MATCH ?1
629 "#,
630 );
631
632 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(escaped_query.clone())];
633 let mut param_idx = 2;
634
635 if options.repo.is_some() {
637 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
638 param_idx += 1;
639 }
640 if options.tool.is_some() {
641 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
642 param_idx += 1;
643 }
644 if options.since.is_some() {
645 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
646 param_idx += 1;
647 }
648 if options.until.is_some() {
649 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
650 param_idx += 1;
651 }
652 if options.project.is_some() {
653 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
654 param_idx += 1;
655 }
656 if options.branch.is_some() {
657 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
658 param_idx += 1;
659 }
660 if options.role.is_some() {
661 sql.push_str(&format!(" AND m.role = ?{param_idx}"));
662 param_idx += 1;
663 }
664
665 if let Some(ref wd) = options.repo {
667 params_vec.push(Box::new(format!("{wd}%")));
668 }
669 if let Some(ref tool) = options.tool {
670 params_vec.push(Box::new(tool.clone()));
671 }
672 if let Some(ts) = options.since {
673 params_vec.push(Box::new(ts.to_rfc3339()));
674 }
675 if let Some(ts) = options.until {
676 params_vec.push(Box::new(ts.to_rfc3339()));
677 }
678 if let Some(ref project) = options.project {
679 params_vec.push(Box::new(format!("%{project}%")));
680 }
681 if let Some(ref branch) = options.branch {
682 params_vec.push(Box::new(format!("%{branch}%")));
683 }
684 if let Some(ref role) = options.role {
685 params_vec.push(Box::new(role.clone()));
686 }
687
688 let include_metadata_search = options.role.is_none();
692 let metadata_query_pattern = format!("%{}%", options.query);
693
694 if include_metadata_search {
695 let meta_param1 = param_idx;
697 let meta_param2 = param_idx + 1;
698 let meta_param3 = param_idx + 2;
699 param_idx += 3;
700
701 sql.push_str(&format!(
702 r#"
703 UNION
704 SELECT
705 s.id as session_id,
706 (SELECT id FROM messages WHERE session_id = s.id ORDER BY idx LIMIT 1) as message_id,
707 'user' as role,
708 substr(s.tool || ' session in ' || s.working_directory || COALESCE(' on branch ' || s.git_branch, ''), 1, 100) as snippet,
709 s.started_at as timestamp,
710 s.working_directory,
711 s.tool,
712 s.git_branch,
713 s.message_count,
714 s.started_at,
715 0 as message_index
716 FROM sessions s
717 WHERE (
718 s.tool LIKE ?{meta_param1}
719 OR s.working_directory LIKE ?{meta_param2}
720 OR s.git_branch LIKE ?{meta_param3}
721 )
722 "#
723 ));
724
725 params_vec.push(Box::new(metadata_query_pattern.clone()));
727 params_vec.push(Box::new(metadata_query_pattern.clone()));
728 params_vec.push(Box::new(metadata_query_pattern));
729
730 if options.repo.is_some() {
732 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
733 params_vec.push(Box::new(format!("{}%", options.repo.as_ref().unwrap())));
734 param_idx += 1;
735 }
736 if options.tool.is_some() {
737 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
738 params_vec.push(Box::new(options.tool.as_ref().unwrap().clone()));
739 param_idx += 1;
740 }
741 if options.since.is_some() {
742 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
743 params_vec.push(Box::new(options.since.unwrap().to_rfc3339()));
744 param_idx += 1;
745 }
746 if options.until.is_some() {
747 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
748 params_vec.push(Box::new(options.until.unwrap().to_rfc3339()));
749 param_idx += 1;
750 }
751 if options.project.is_some() {
752 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
753 params_vec.push(Box::new(format!("%{}%", options.project.as_ref().unwrap())));
754 param_idx += 1;
755 }
756 if options.branch.is_some() {
757 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
758 params_vec.push(Box::new(format!("%{}%", options.branch.as_ref().unwrap())));
759 param_idx += 1;
760 }
761 }
762
763 sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{param_idx}"));
764 params_vec.push(Box::new(options.limit as i64));
765
766 let mut stmt = self.conn.prepare(&sql)?;
768 let params_refs: Vec<&dyn rusqlite::ToSql> =
769 params_vec.iter().map(|p| p.as_ref()).collect();
770
771 let rows = stmt.query_map(params_refs.as_slice(), |row| {
772 let role_str: String = row.get(2)?;
773 let git_branch: Option<String> = row.get(7)?;
774 let started_at_str: Option<String> = row.get(9)?;
775
776 Ok(SearchResult {
777 session_id: parse_uuid(&row.get::<_, String>(0)?)?,
778 message_id: parse_uuid(&row.get::<_, String>(1)?)?,
779 role: match role_str.as_str() {
780 "user" => MessageRole::User,
781 "assistant" => MessageRole::Assistant,
782 "system" => MessageRole::System,
783 _ => MessageRole::User,
784 },
785 snippet: row.get(3)?,
786 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
787 working_directory: row.get(5)?,
788 tool: row.get(6)?,
789 git_branch,
790 session_message_count: row.get(8)?,
791 session_started_at: started_at_str.map(|s| parse_datetime(&s)).transpose()?,
792 message_index: row.get(10)?,
793 })
794 })?;
795
796 rows.collect::<Result<Vec<_>, _>>()
797 .context("Failed to search messages")
798 }
799
800 pub fn get_context_messages(
805 &self,
806 session_id: &Uuid,
807 message_index: i32,
808 context_count: usize,
809 ) -> Result<(Vec<Message>, Vec<Message>)> {
810 let mut before_stmt = self.conn.prepare(
812 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
813 FROM messages
814 WHERE session_id = ?1 AND idx < ?2
815 ORDER BY idx DESC
816 LIMIT ?3",
817 )?;
818
819 let before_rows = before_stmt.query_map(
820 params![session_id.to_string(), message_index, context_count as i64],
821 Self::row_to_message,
822 )?;
823
824 let mut before: Vec<Message> = before_rows
825 .collect::<Result<Vec<_>, _>>()
826 .context("Failed to get before messages")?;
827 before.reverse(); let mut after_stmt = self.conn.prepare(
831 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
832 FROM messages
833 WHERE session_id = ?1 AND idx > ?2
834 ORDER BY idx ASC
835 LIMIT ?3",
836 )?;
837
838 let after_rows = after_stmt.query_map(
839 params![session_id.to_string(), message_index, context_count as i64],
840 Self::row_to_message,
841 )?;
842
843 let after: Vec<Message> = after_rows
844 .collect::<Result<Vec<_>, _>>()
845 .context("Failed to get after messages")?;
846
847 Ok((before, after))
848 }
849
850 #[allow(dead_code)]
852 pub fn get_message_by_index(&self, session_id: &Uuid, index: i32) -> Result<Option<Message>> {
853 self.conn
854 .query_row(
855 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
856 FROM messages
857 WHERE session_id = ?1 AND idx = ?2",
858 params![session_id.to_string(), index],
859 Self::row_to_message,
860 )
861 .optional()
862 .context("Failed to get message by index")
863 }
864
865 fn row_to_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
866 let role_str: String = row.get(5)?;
867 let content_str: String = row.get(6)?;
868
869 let parent_id_str: Option<String> = row.get(2)?;
870 let parent_id = match parent_id_str {
871 Some(s) => Some(parse_uuid(&s)?),
872 None => None,
873 };
874
875 Ok(Message {
876 id: parse_uuid(&row.get::<_, String>(0)?)?,
877 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
878 parent_id,
879 index: row.get(3)?,
880 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
881 role: match role_str.as_str() {
882 "user" => MessageRole::User,
883 "assistant" => MessageRole::Assistant,
884 "system" => MessageRole::System,
885 _ => MessageRole::User,
886 },
887 content: serde_json::from_str(&content_str)
888 .unwrap_or(MessageContent::Text(content_str)),
889 model: row.get(7)?,
890 git_branch: row.get(8)?,
891 cwd: row.get(9)?,
892 })
893 }
894
895 pub fn rebuild_search_index(&self) -> Result<usize> {
903 self.conn.execute("DELETE FROM messages_fts", [])?;
905 self.conn.execute("DELETE FROM sessions_fts", [])?;
906
907 let mut msg_stmt = self.conn.prepare("SELECT id, content FROM messages")?;
909
910 let rows = msg_stmt.query_map([], |row| {
911 let id: String = row.get(0)?;
912 let content_json: String = row.get(1)?;
913 Ok((id, content_json))
914 })?;
915
916 let mut count = 0;
917 for row in rows {
918 let (id, content_json) = row?;
919 let content: MessageContent = serde_json::from_str(&content_json)
921 .unwrap_or(MessageContent::Text(content_json.clone()));
922 let text_content = content.text();
923
924 if !text_content.is_empty() {
925 self.conn.execute(
926 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
927 params![id, text_content],
928 )?;
929 count += 1;
930 }
931 }
932
933 let mut session_stmt = self
935 .conn
936 .prepare("SELECT id, tool, working_directory, git_branch FROM sessions")?;
937
938 let session_rows = session_stmt.query_map([], |row| {
939 let id: String = row.get(0)?;
940 let tool: String = row.get(1)?;
941 let working_directory: String = row.get(2)?;
942 let git_branch: Option<String> = row.get(3)?;
943 Ok((id, tool, working_directory, git_branch))
944 })?;
945
946 for row in session_rows {
947 let (id, tool, working_directory, git_branch) = row?;
948 self.conn.execute(
949 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
950 params![id, tool, working_directory, git_branch.unwrap_or_default()],
951 )?;
952 }
953
954 Ok(count)
955 }
956
957 pub fn search_index_needs_rebuild(&self) -> Result<bool> {
962 let message_count: i32 =
963 self.conn
964 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
965
966 let msg_fts_count: i32 =
967 self.conn
968 .query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
969
970 let session_count: i32 =
971 self.conn
972 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
973
974 let session_fts_count: i32 =
975 self.conn
976 .query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
977
978 Ok((message_count > 0 && msg_fts_count == 0)
980 || (session_count > 0 && session_fts_count == 0))
981 }
982
983 pub fn session_count(&self) -> Result<i32> {
987 let count: i32 = self
988 .conn
989 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
990 Ok(count)
991 }
992
993 pub fn message_count(&self) -> Result<i32> {
995 let count: i32 = self
996 .conn
997 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
998 Ok(count)
999 }
1000
1001 pub fn link_count(&self) -> Result<i32> {
1003 let count: i32 = self
1004 .conn
1005 .query_row("SELECT COUNT(*) FROM session_links", [], |row| row.get(0))?;
1006 Ok(count)
1007 }
1008
1009 pub fn db_path(&self) -> Option<std::path::PathBuf> {
1013 self.conn.path().map(std::path::PathBuf::from)
1014 }
1015
1016 pub fn find_sessions_near_commit_time(
1033 &self,
1034 commit_time: chrono::DateTime<chrono::Utc>,
1035 window_minutes: i64,
1036 working_dir: Option<&str>,
1037 ) -> Result<Vec<Session>> {
1038 let commit_time_str = commit_time.to_rfc3339();
1040
1041 let window = chrono::Duration::minutes(window_minutes);
1043 let window_start = (commit_time - window).to_rfc3339();
1044 let window_end = (commit_time + window).to_rfc3339();
1045
1046 let sql = if working_dir.is_some() {
1047 r#"
1048 SELECT id, tool, tool_version, started_at, ended_at, model,
1049 working_directory, git_branch, source_path, message_count
1050 FROM sessions
1051 WHERE working_directory LIKE ?1
1052 AND (
1053 -- Session started before or during the window
1054 (started_at <= ?3)
1055 AND
1056 -- Session ended after or during the window (or is still ongoing)
1057 (ended_at IS NULL OR ended_at >= ?2)
1058 )
1059 ORDER BY
1060 -- Order by how close the session end (or start) is to commit time
1061 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?4))
1062 "#
1063 } else {
1064 r#"
1065 SELECT id, tool, tool_version, started_at, ended_at, model,
1066 working_directory, git_branch, source_path, message_count
1067 FROM sessions
1068 WHERE
1069 -- Session started before or during the window
1070 (started_at <= ?2)
1071 AND
1072 -- Session ended after or during the window (or is still ongoing)
1073 (ended_at IS NULL OR ended_at >= ?1)
1074 ORDER BY
1075 -- Order by how close the session end (or start) is to commit time
1076 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?3))
1077 "#
1078 };
1079
1080 let mut stmt = self.conn.prepare(sql)?;
1081
1082 let rows = if let Some(wd) = working_dir {
1083 stmt.query_map(
1084 params![format!("{wd}%"), window_start, window_end, commit_time_str],
1085 Self::row_to_session,
1086 )?
1087 } else {
1088 stmt.query_map(
1089 params![window_start, window_end, commit_time_str],
1090 Self::row_to_session,
1091 )?
1092 };
1093
1094 rows.collect::<Result<Vec<_>, _>>()
1095 .context("Failed to find sessions near commit time")
1096 }
1097
1098 pub fn link_exists(&self, session_id: &Uuid, commit_sha: &str) -> Result<bool> {
1102 let pattern = format!("{commit_sha}%");
1103 let count: i32 = self.conn.query_row(
1104 "SELECT COUNT(*) FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1105 params![session_id.to_string(), pattern],
1106 |row| row.get(0),
1107 )?;
1108 Ok(count > 0)
1109 }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115 use crate::storage::models::{
1116 LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
1117 };
1118 use chrono::{Duration, Utc};
1119 use tempfile::tempdir;
1120
1121 fn create_test_db() -> (Database, tempfile::TempDir) {
1124 let dir = tempdir().expect("Failed to create temp directory");
1125 let db_path = dir.path().join("test.db");
1126 let db = Database::open(&db_path).expect("Failed to open test database");
1127 (db, dir)
1128 }
1129
1130 fn create_test_session(
1132 tool: &str,
1133 working_directory: &str,
1134 started_at: chrono::DateTime<Utc>,
1135 source_path: Option<&str>,
1136 ) -> Session {
1137 Session {
1138 id: Uuid::new_v4(),
1139 tool: tool.to_string(),
1140 tool_version: Some("1.0.0".to_string()),
1141 started_at,
1142 ended_at: None,
1143 model: Some("test-model".to_string()),
1144 working_directory: working_directory.to_string(),
1145 git_branch: Some("main".to_string()),
1146 source_path: source_path.map(|s| s.to_string()),
1147 message_count: 0,
1148 }
1149 }
1150
1151 fn create_test_message(
1153 session_id: Uuid,
1154 index: i32,
1155 role: MessageRole,
1156 content: &str,
1157 ) -> Message {
1158 Message {
1159 id: Uuid::new_v4(),
1160 session_id,
1161 parent_id: None,
1162 index,
1163 timestamp: Utc::now(),
1164 role,
1165 content: MessageContent::Text(content.to_string()),
1166 model: Some("test-model".to_string()),
1167 git_branch: Some("main".to_string()),
1168 cwd: Some("/test/cwd".to_string()),
1169 }
1170 }
1171
1172 fn create_test_link(
1174 session_id: Uuid,
1175 commit_sha: Option<&str>,
1176 link_type: LinkType,
1177 ) -> SessionLink {
1178 SessionLink {
1179 id: Uuid::new_v4(),
1180 session_id,
1181 link_type,
1182 commit_sha: commit_sha.map(|s| s.to_string()),
1183 branch: Some("main".to_string()),
1184 remote: Some("origin".to_string()),
1185 created_at: Utc::now(),
1186 created_by: LinkCreator::Auto,
1187 confidence: Some(0.95),
1188 }
1189 }
1190
1191 #[test]
1194 fn test_insert_and_get_session() {
1195 let (db, _dir) = create_test_db();
1196 let session = create_test_session(
1197 "claude-code",
1198 "/home/user/project",
1199 Utc::now(),
1200 Some("/path/to/source.jsonl"),
1201 );
1202
1203 db.insert_session(&session)
1204 .expect("Failed to insert session");
1205
1206 let retrieved = db
1207 .get_session(&session.id)
1208 .expect("Failed to get session")
1209 .expect("Session should exist");
1210
1211 assert_eq!(retrieved.id, session.id, "Session ID should match");
1212 assert_eq!(retrieved.tool, session.tool, "Tool should match");
1213 assert_eq!(
1214 retrieved.tool_version, session.tool_version,
1215 "Tool version should match"
1216 );
1217 assert_eq!(
1218 retrieved.working_directory, session.working_directory,
1219 "Working directory should match"
1220 );
1221 assert_eq!(
1222 retrieved.git_branch, session.git_branch,
1223 "Git branch should match"
1224 );
1225 assert_eq!(
1226 retrieved.source_path, session.source_path,
1227 "Source path should match"
1228 );
1229 }
1230
1231 #[test]
1232 fn test_list_sessions() {
1233 let (db, _dir) = create_test_db();
1234 let now = Utc::now();
1235
1236 let session1 =
1238 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
1239 let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
1240 let session3 = create_test_session("claude-code", "/project3", now, None);
1241
1242 db.insert_session(&session1)
1243 .expect("Failed to insert session1");
1244 db.insert_session(&session2)
1245 .expect("Failed to insert session2");
1246 db.insert_session(&session3)
1247 .expect("Failed to insert session3");
1248
1249 let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
1250
1251 assert_eq!(sessions.len(), 3, "Should have 3 sessions");
1252 assert_eq!(
1254 sessions[0].id, session3.id,
1255 "Most recent session should be first"
1256 );
1257 assert_eq!(
1258 sessions[1].id, session2.id,
1259 "Second most recent session should be second"
1260 );
1261 assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
1262 }
1263
1264 #[test]
1265 fn test_list_sessions_with_working_dir_filter() {
1266 let (db, _dir) = create_test_db();
1267 let now = Utc::now();
1268
1269 let session1 = create_test_session(
1270 "claude-code",
1271 "/home/user/project-a",
1272 now - Duration::hours(1),
1273 None,
1274 );
1275 let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
1276 let session3 = create_test_session("claude-code", "/other/path", now, None);
1277
1278 db.insert_session(&session1)
1279 .expect("Failed to insert session1");
1280 db.insert_session(&session2)
1281 .expect("Failed to insert session2");
1282 db.insert_session(&session3)
1283 .expect("Failed to insert session3");
1284
1285 let sessions = db
1287 .list_sessions(10, Some("/home/user"))
1288 .expect("Failed to list sessions");
1289
1290 assert_eq!(
1291 sessions.len(),
1292 2,
1293 "Should have 2 sessions matching /home/user prefix"
1294 );
1295
1296 let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
1298 assert!(ids.contains(&session1.id), "Should contain session1");
1299 assert!(ids.contains(&session2.id), "Should contain session2");
1300 assert!(!ids.contains(&session3.id), "Should not contain session3");
1301 }
1302
1303 #[test]
1304 fn test_session_exists_by_source() {
1305 let (db, _dir) = create_test_db();
1306 let source_path = "/path/to/session.jsonl";
1307
1308 let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
1309
1310 assert!(
1312 !db.session_exists_by_source(source_path)
1313 .expect("Failed to check existence"),
1314 "Session should not exist before insert"
1315 );
1316
1317 db.insert_session(&session)
1318 .expect("Failed to insert session");
1319
1320 assert!(
1322 db.session_exists_by_source(source_path)
1323 .expect("Failed to check existence"),
1324 "Session should exist after insert"
1325 );
1326
1327 assert!(
1329 !db.session_exists_by_source("/other/path.jsonl")
1330 .expect("Failed to check existence"),
1331 "Different source path should not exist"
1332 );
1333 }
1334
1335 #[test]
1336 fn test_get_nonexistent_session() {
1337 let (db, _dir) = create_test_db();
1338 let nonexistent_id = Uuid::new_v4();
1339
1340 let result = db
1341 .get_session(&nonexistent_id)
1342 .expect("Failed to query for nonexistent session");
1343
1344 assert!(
1345 result.is_none(),
1346 "Should return None for nonexistent session"
1347 );
1348 }
1349
1350 #[test]
1353 fn test_insert_and_get_messages() {
1354 let (db, _dir) = create_test_db();
1355
1356 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1357 db.insert_session(&session)
1358 .expect("Failed to insert session");
1359
1360 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
1361 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
1362
1363 db.insert_message(&msg1)
1364 .expect("Failed to insert message 1");
1365 db.insert_message(&msg2)
1366 .expect("Failed to insert message 2");
1367
1368 let messages = db
1369 .get_messages(&session.id)
1370 .expect("Failed to get messages");
1371
1372 assert_eq!(messages.len(), 2, "Should have 2 messages");
1373 assert_eq!(messages[0].id, msg1.id, "First message ID should match");
1374 assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
1375 assert_eq!(
1376 messages[0].role,
1377 MessageRole::User,
1378 "First message role should be User"
1379 );
1380 assert_eq!(
1381 messages[1].role,
1382 MessageRole::Assistant,
1383 "Second message role should be Assistant"
1384 );
1385 }
1386
1387 #[test]
1388 fn test_messages_ordered_by_index() {
1389 let (db, _dir) = create_test_db();
1390
1391 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1392 db.insert_session(&session)
1393 .expect("Failed to insert session");
1394
1395 let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
1397 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
1398 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
1399
1400 db.insert_message(&msg3)
1401 .expect("Failed to insert message 3");
1402 db.insert_message(&msg1)
1403 .expect("Failed to insert message 1");
1404 db.insert_message(&msg2)
1405 .expect("Failed to insert message 2");
1406
1407 let messages = db
1408 .get_messages(&session.id)
1409 .expect("Failed to get messages");
1410
1411 assert_eq!(messages.len(), 3, "Should have 3 messages");
1412 assert_eq!(messages[0].index, 0, "First message should have index 0");
1413 assert_eq!(messages[1].index, 1, "Second message should have index 1");
1414 assert_eq!(messages[2].index, 2, "Third message should have index 2");
1415
1416 assert_eq!(
1418 messages[0].content.text(),
1419 "First",
1420 "First message content should be 'First'"
1421 );
1422 assert_eq!(
1423 messages[1].content.text(),
1424 "Second",
1425 "Second message content should be 'Second'"
1426 );
1427 assert_eq!(
1428 messages[2].content.text(),
1429 "Third",
1430 "Third message content should be 'Third'"
1431 );
1432 }
1433
1434 #[test]
1437 fn test_insert_and_get_links_by_session() {
1438 let (db, _dir) = create_test_db();
1439
1440 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1441 db.insert_session(&session)
1442 .expect("Failed to insert session");
1443
1444 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
1445 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
1446
1447 db.insert_link(&link1).expect("Failed to insert link 1");
1448 db.insert_link(&link2).expect("Failed to insert link 2");
1449
1450 let links = db
1451 .get_links_by_session(&session.id)
1452 .expect("Failed to get links");
1453
1454 assert_eq!(links.len(), 2, "Should have 2 links");
1455
1456 let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
1457 assert!(link_ids.contains(&link1.id), "Should contain link1");
1458 assert!(link_ids.contains(&link2.id), "Should contain link2");
1459
1460 let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
1462 assert_eq!(
1463 retrieved_link.commit_sha,
1464 Some("abc123def456".to_string()),
1465 "Commit SHA should match"
1466 );
1467 assert_eq!(
1468 retrieved_link.link_type,
1469 LinkType::Commit,
1470 "Link type should be Commit"
1471 );
1472 assert_eq!(
1473 retrieved_link.created_by,
1474 LinkCreator::Auto,
1475 "Created by should be Auto"
1476 );
1477 }
1478
1479 #[test]
1480 fn test_get_links_by_commit() {
1481 let (db, _dir) = create_test_db();
1482
1483 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1484 db.insert_session(&session)
1485 .expect("Failed to insert session");
1486
1487 let full_sha = "abc123def456789012345678901234567890abcd";
1488 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
1489 db.insert_link(&link).expect("Failed to insert link");
1490
1491 let links_full = db
1493 .get_links_by_commit(full_sha)
1494 .expect("Failed to get links by full SHA");
1495 assert_eq!(links_full.len(), 1, "Should find link by full SHA");
1496 assert_eq!(links_full[0].id, link.id, "Link ID should match");
1497
1498 let links_partial = db
1500 .get_links_by_commit("abc123")
1501 .expect("Failed to get links by partial SHA");
1502 assert_eq!(
1503 links_partial.len(),
1504 1,
1505 "Should find link by partial SHA prefix"
1506 );
1507 assert_eq!(links_partial[0].id, link.id, "Link ID should match");
1508
1509 let links_none = db
1511 .get_links_by_commit("zzz999")
1512 .expect("Failed to get links by non-matching SHA");
1513 assert_eq!(
1514 links_none.len(),
1515 0,
1516 "Should not find link with non-matching SHA"
1517 );
1518 }
1519
1520 #[test]
1523 fn test_database_creation() {
1524 let dir = tempdir().expect("Failed to create temp directory");
1525 let db_path = dir.path().join("new_test.db");
1526
1527 assert!(
1529 !db_path.exists(),
1530 "Database file should not exist before creation"
1531 );
1532
1533 let db = Database::open(&db_path).expect("Failed to create database");
1534
1535 assert!(
1537 db_path.exists(),
1538 "Database file should exist after creation"
1539 );
1540
1541 let session_count = db.session_count().expect("Failed to get session count");
1543 assert_eq!(session_count, 0, "New database should have 0 sessions");
1544
1545 let message_count = db.message_count().expect("Failed to get message count");
1546 assert_eq!(message_count, 0, "New database should have 0 messages");
1547 }
1548
1549 #[test]
1550 fn test_session_count() {
1551 let (db, _dir) = create_test_db();
1552
1553 assert_eq!(
1554 db.session_count().expect("Failed to get count"),
1555 0,
1556 "Initial session count should be 0"
1557 );
1558
1559 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
1560 db.insert_session(&session1)
1561 .expect("Failed to insert session1");
1562
1563 assert_eq!(
1564 db.session_count().expect("Failed to get count"),
1565 1,
1566 "Session count should be 1 after first insert"
1567 );
1568
1569 let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
1570 db.insert_session(&session2)
1571 .expect("Failed to insert session2");
1572
1573 assert_eq!(
1574 db.session_count().expect("Failed to get count"),
1575 2,
1576 "Session count should be 2 after second insert"
1577 );
1578 }
1579
1580 #[test]
1581 fn test_message_count() {
1582 let (db, _dir) = create_test_db();
1583
1584 assert_eq!(
1585 db.message_count().expect("Failed to get count"),
1586 0,
1587 "Initial message count should be 0"
1588 );
1589
1590 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1591 db.insert_session(&session)
1592 .expect("Failed to insert session");
1593
1594 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
1595 db.insert_message(&msg1).expect("Failed to insert message1");
1596
1597 assert_eq!(
1598 db.message_count().expect("Failed to get count"),
1599 1,
1600 "Message count should be 1 after first insert"
1601 );
1602
1603 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
1604 let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
1605 db.insert_message(&msg2).expect("Failed to insert message2");
1606 db.insert_message(&msg3).expect("Failed to insert message3");
1607
1608 assert_eq!(
1609 db.message_count().expect("Failed to get count"),
1610 3,
1611 "Message count should be 3 after all inserts"
1612 );
1613 }
1614
1615 #[test]
1616 fn test_link_count() {
1617 let (db, _dir) = create_test_db();
1618
1619 assert_eq!(
1620 db.link_count().expect("Failed to get count"),
1621 0,
1622 "Initial link count should be 0"
1623 );
1624
1625 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1626 db.insert_session(&session)
1627 .expect("Failed to insert session");
1628
1629 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
1630 db.insert_link(&link1).expect("Failed to insert link1");
1631
1632 assert_eq!(
1633 db.link_count().expect("Failed to get count"),
1634 1,
1635 "Link count should be 1 after first insert"
1636 );
1637
1638 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
1639 db.insert_link(&link2).expect("Failed to insert link2");
1640
1641 assert_eq!(
1642 db.link_count().expect("Failed to get count"),
1643 2,
1644 "Link count should be 2 after second insert"
1645 );
1646 }
1647
1648 #[test]
1649 fn test_db_path() {
1650 let dir = tempdir().expect("Failed to create temp directory");
1651 let db_path = dir.path().join("test.db");
1652 let db = Database::open(&db_path).expect("Failed to open test database");
1653
1654 let retrieved_path = db.db_path();
1655 assert!(
1656 retrieved_path.is_some(),
1657 "Database path should be available"
1658 );
1659
1660 let expected = db_path.canonicalize().unwrap_or(db_path);
1662 let actual = retrieved_path.unwrap();
1663 let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
1664
1665 assert_eq!(
1666 actual_canonical, expected,
1667 "Database path should match (after canonicalization)"
1668 );
1669 }
1670
1671 #[test]
1674 fn test_search_messages_basic() {
1675 let (db, _dir) = create_test_db();
1676
1677 let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
1678 db.insert_session(&session)
1679 .expect("Failed to insert session");
1680
1681 let msg1 = create_test_message(
1682 session.id,
1683 0,
1684 MessageRole::User,
1685 "How do I implement error handling in Rust?",
1686 );
1687 let msg2 = create_test_message(
1688 session.id,
1689 1,
1690 MessageRole::Assistant,
1691 "You can use Result types for error handling. The anyhow crate is also helpful.",
1692 );
1693
1694 db.insert_message(&msg1)
1695 .expect("Failed to insert message 1");
1696 db.insert_message(&msg2)
1697 .expect("Failed to insert message 2");
1698
1699 let results = db
1701 .search_messages("error", 10, None, None, None)
1702 .expect("Failed to search");
1703
1704 assert_eq!(
1705 results.len(),
1706 2,
1707 "Should find 2 messages containing 'error'"
1708 );
1709 }
1710
1711 #[test]
1712 fn test_search_messages_no_results() {
1713 let (db, _dir) = create_test_db();
1714
1715 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1716 db.insert_session(&session)
1717 .expect("Failed to insert session");
1718
1719 let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
1720 db.insert_message(&msg).expect("Failed to insert message");
1721
1722 let results = db
1724 .search_messages("nonexistent_term_xyz", 10, None, None, None)
1725 .expect("Failed to search");
1726
1727 assert!(results.is_empty(), "Should find no results");
1728 }
1729
1730 #[test]
1731 fn test_search_messages_with_role_filter() {
1732 let (db, _dir) = create_test_db();
1733
1734 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1735 db.insert_session(&session)
1736 .expect("Failed to insert session");
1737
1738 let msg1 = create_test_message(
1739 session.id,
1740 0,
1741 MessageRole::User,
1742 "Tell me about Rust programming",
1743 );
1744 let msg2 = create_test_message(
1745 session.id,
1746 1,
1747 MessageRole::Assistant,
1748 "Rust is a systems programming language",
1749 );
1750
1751 db.insert_message(&msg1)
1752 .expect("Failed to insert message 1");
1753 db.insert_message(&msg2)
1754 .expect("Failed to insert message 2");
1755
1756 let user_results = db
1758 .search_messages("programming", 10, None, None, Some("user"))
1759 .expect("Failed to search");
1760
1761 assert_eq!(user_results.len(), 1, "Should find 1 user message");
1762 assert_eq!(
1763 user_results[0].role,
1764 MessageRole::User,
1765 "Result should be from user"
1766 );
1767
1768 let assistant_results = db
1770 .search_messages("programming", 10, None, None, Some("assistant"))
1771 .expect("Failed to search");
1772
1773 assert_eq!(
1774 assistant_results.len(),
1775 1,
1776 "Should find 1 assistant message"
1777 );
1778 assert_eq!(
1779 assistant_results[0].role,
1780 MessageRole::Assistant,
1781 "Result should be from assistant"
1782 );
1783 }
1784
1785 #[test]
1786 fn test_search_messages_with_repo_filter() {
1787 let (db, _dir) = create_test_db();
1788
1789 let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
1790 let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
1791
1792 db.insert_session(&session1).expect("insert 1");
1793 db.insert_session(&session2).expect("insert 2");
1794
1795 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
1796 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
1797
1798 db.insert_message(&msg1).expect("insert msg 1");
1799 db.insert_message(&msg2).expect("insert msg 2");
1800
1801 let results = db
1803 .search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
1804 .expect("Failed to search");
1805
1806 assert_eq!(results.len(), 1, "Should find 1 message in project-a");
1807 assert!(
1808 results[0].working_directory.contains("project-a"),
1809 "Should be from project-a"
1810 );
1811 }
1812
1813 #[test]
1814 fn test_search_messages_limit() {
1815 let (db, _dir) = create_test_db();
1816
1817 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1818 db.insert_session(&session).expect("insert session");
1819
1820 for i in 0..5 {
1822 let msg = create_test_message(
1823 session.id,
1824 i,
1825 MessageRole::User,
1826 &format!("This is test message number {i}"),
1827 );
1828 db.insert_message(&msg).expect("insert message");
1829 }
1830
1831 let results = db
1833 .search_messages("test", 3, None, None, None)
1834 .expect("Failed to search");
1835
1836 assert_eq!(results.len(), 3, "Should respect limit of 3");
1837 }
1838
1839 #[test]
1840 fn test_search_index_needs_rebuild_empty_db() {
1841 let (db, _dir) = create_test_db();
1842
1843 let needs_rebuild = db
1844 .search_index_needs_rebuild()
1845 .expect("Failed to check rebuild status");
1846
1847 assert!(!needs_rebuild, "Empty database should not need rebuild");
1848 }
1849
1850 #[test]
1851 fn test_rebuild_search_index() {
1852 let (db, _dir) = create_test_db();
1853
1854 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1855 db.insert_session(&session).expect("insert session");
1856
1857 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
1858 let msg2 = create_test_message(
1859 session.id,
1860 1,
1861 MessageRole::Assistant,
1862 "Second test response",
1863 );
1864
1865 db.insert_message(&msg1).expect("insert msg 1");
1866 db.insert_message(&msg2).expect("insert msg 2");
1867
1868 db.conn
1870 .execute("DELETE FROM messages_fts", [])
1871 .expect("clear fts");
1872
1873 assert!(
1875 db.search_index_needs_rebuild().expect("check rebuild"),
1876 "Should need rebuild after clearing FTS"
1877 );
1878
1879 let count = db.rebuild_search_index().expect("rebuild");
1881 assert_eq!(count, 2, "Should have indexed 2 messages");
1882
1883 assert!(
1885 !db.search_index_needs_rebuild().expect("check rebuild"),
1886 "Should not need rebuild after rebuilding"
1887 );
1888
1889 let results = db
1891 .search_messages("test", 10, None, None, None)
1892 .expect("search");
1893 assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
1894 }
1895
1896 #[test]
1897 fn test_search_with_block_content() {
1898 let (db, _dir) = create_test_db();
1899
1900 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1901 db.insert_session(&session).expect("insert session");
1902
1903 let block_content = MessageContent::Blocks(vec![
1905 crate::storage::models::ContentBlock::Text {
1906 text: "Let me help with your database query.".to_string(),
1907 },
1908 crate::storage::models::ContentBlock::ToolUse {
1909 id: "tool_123".to_string(),
1910 name: "Bash".to_string(),
1911 input: serde_json::json!({"command": "ls -la"}),
1912 },
1913 ]);
1914
1915 let msg = Message {
1916 id: Uuid::new_v4(),
1917 session_id: session.id,
1918 parent_id: None,
1919 index: 0,
1920 timestamp: Utc::now(),
1921 role: MessageRole::Assistant,
1922 content: block_content,
1923 model: Some("claude-opus-4".to_string()),
1924 git_branch: Some("main".to_string()),
1925 cwd: Some("/project".to_string()),
1926 };
1927
1928 db.insert_message(&msg).expect("insert message");
1929
1930 let results = db
1932 .search_messages("database", 10, None, None, None)
1933 .expect("search");
1934
1935 assert_eq!(results.len(), 1, "Should find message with block content");
1936 }
1937
1938 #[test]
1939 fn test_search_result_contains_session_info() {
1940 let (db, _dir) = create_test_db();
1941
1942 let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
1943 db.insert_session(&session).expect("insert session");
1944
1945 let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
1946 db.insert_message(&msg).expect("insert message");
1947
1948 let results = db
1949 .search_messages("Search", 10, None, None, None)
1950 .expect("search");
1951
1952 assert_eq!(results.len(), 1, "Should find 1 result");
1953 assert_eq!(results[0].session_id, session.id, "Session ID should match");
1954 assert_eq!(results[0].message_id, msg.id, "Message ID should match");
1955 assert_eq!(
1956 results[0].working_directory, "/home/user/my-project",
1957 "Working directory should match"
1958 );
1959 assert_eq!(results[0].role, MessageRole::User, "Role should match");
1960 }
1961
1962 #[test]
1965 fn test_delete_link_by_id() {
1966 let (db, _dir) = create_test_db();
1967
1968 let session = create_test_session("claude-code", "/project", Utc::now(), None);
1969 db.insert_session(&session)
1970 .expect("Failed to insert session");
1971
1972 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
1973 db.insert_link(&link).expect("Failed to insert link");
1974
1975 let links_before = db
1977 .get_links_by_session(&session.id)
1978 .expect("Failed to get links");
1979 assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
1980
1981 let deleted = db.delete_link(&link.id).expect("Failed to delete link");
1983 assert!(deleted, "Should return true when link is deleted");
1984
1985 let links_after = db
1987 .get_links_by_session(&session.id)
1988 .expect("Failed to get links");
1989 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
1990 }
1991
1992 #[test]
1993 fn test_delete_link_nonexistent() {
1994 let (db, _dir) = create_test_db();
1995
1996 let nonexistent_id = Uuid::new_v4();
1997 let deleted = db
1998 .delete_link(&nonexistent_id)
1999 .expect("Failed to call delete_link");
2000
2001 assert!(!deleted, "Should return false for nonexistent link");
2002 }
2003
2004 #[test]
2005 fn test_delete_links_by_session() {
2006 let (db, _dir) = create_test_db();
2007
2008 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2009 db.insert_session(&session)
2010 .expect("Failed to insert session");
2011
2012 let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
2014 let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
2015 let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
2016
2017 db.insert_link(&link1).expect("Failed to insert link1");
2018 db.insert_link(&link2).expect("Failed to insert link2");
2019 db.insert_link(&link3).expect("Failed to insert link3");
2020
2021 let links_before = db
2023 .get_links_by_session(&session.id)
2024 .expect("Failed to get links");
2025 assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
2026
2027 let count = db
2029 .delete_links_by_session(&session.id)
2030 .expect("Failed to delete links");
2031 assert_eq!(count, 3, "Should have deleted 3 links");
2032
2033 let links_after = db
2035 .get_links_by_session(&session.id)
2036 .expect("Failed to get links");
2037 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
2038 }
2039
2040 #[test]
2041 fn test_delete_links_by_session_no_links() {
2042 let (db, _dir) = create_test_db();
2043
2044 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2045 db.insert_session(&session)
2046 .expect("Failed to insert session");
2047
2048 let count = db
2050 .delete_links_by_session(&session.id)
2051 .expect("Failed to call delete_links_by_session");
2052 assert_eq!(count, 0, "Should return 0 when no links exist");
2053 }
2054
2055 #[test]
2056 fn test_delete_links_by_session_preserves_other_sessions() {
2057 let (db, _dir) = create_test_db();
2058
2059 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2060 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2061
2062 db.insert_session(&session1)
2063 .expect("Failed to insert session1");
2064 db.insert_session(&session2)
2065 .expect("Failed to insert session2");
2066
2067 let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2068 let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
2069
2070 db.insert_link(&link1).expect("Failed to insert link1");
2071 db.insert_link(&link2).expect("Failed to insert link2");
2072
2073 let count = db
2075 .delete_links_by_session(&session1.id)
2076 .expect("Failed to delete links");
2077 assert_eq!(count, 1, "Should have deleted 1 link");
2078
2079 let session2_links = db
2081 .get_links_by_session(&session2.id)
2082 .expect("Failed to get links");
2083 assert_eq!(
2084 session2_links.len(),
2085 1,
2086 "Session2's link should be preserved"
2087 );
2088 assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
2089 }
2090
2091 #[test]
2092 fn test_delete_link_by_session_and_commit() {
2093 let (db, _dir) = create_test_db();
2094
2095 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2096 db.insert_session(&session)
2097 .expect("Failed to insert session");
2098
2099 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2100 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2101
2102 db.insert_link(&link1).expect("Failed to insert link1");
2103 db.insert_link(&link2).expect("Failed to insert link2");
2104
2105 let deleted = db
2107 .delete_link_by_session_and_commit(&session.id, "abc123")
2108 .expect("Failed to delete link");
2109 assert!(deleted, "Should return true when link is deleted");
2110
2111 let links = db
2113 .get_links_by_session(&session.id)
2114 .expect("Failed to get links");
2115 assert_eq!(links.len(), 1, "Should have 1 link remaining");
2116 assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
2117 }
2118
2119 #[test]
2120 fn test_delete_link_by_session_and_commit_full_sha() {
2121 let (db, _dir) = create_test_db();
2122
2123 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2124 db.insert_session(&session)
2125 .expect("Failed to insert session");
2126
2127 let full_sha = "abc123def456789012345678901234567890abcd";
2128 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
2129 db.insert_link(&link).expect("Failed to insert link");
2130
2131 let deleted = db
2133 .delete_link_by_session_and_commit(&session.id, full_sha)
2134 .expect("Failed to delete link");
2135 assert!(deleted, "Should delete with full SHA");
2136
2137 let links = db
2138 .get_links_by_session(&session.id)
2139 .expect("Failed to get links");
2140 assert_eq!(links.len(), 0, "Should have 0 links after delete");
2141 }
2142
2143 #[test]
2144 fn test_delete_link_by_session_and_commit_no_match() {
2145 let (db, _dir) = create_test_db();
2146
2147 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2148 db.insert_session(&session)
2149 .expect("Failed to insert session");
2150
2151 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
2152 db.insert_link(&link).expect("Failed to insert link");
2153
2154 let deleted = db
2156 .delete_link_by_session_and_commit(&session.id, "xyz999")
2157 .expect("Failed to call delete");
2158 assert!(!deleted, "Should return false when no match");
2159
2160 let links = db
2162 .get_links_by_session(&session.id)
2163 .expect("Failed to get links");
2164 assert_eq!(links.len(), 1, "Link should be preserved");
2165 }
2166
2167 #[test]
2168 fn test_delete_link_by_session_and_commit_wrong_session() {
2169 let (db, _dir) = create_test_db();
2170
2171 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2172 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2173
2174 db.insert_session(&session1)
2175 .expect("Failed to insert session1");
2176 db.insert_session(&session2)
2177 .expect("Failed to insert session2");
2178
2179 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2180 db.insert_link(&link).expect("Failed to insert link");
2181
2182 let deleted = db
2184 .delete_link_by_session_and_commit(&session2.id, "abc123")
2185 .expect("Failed to call delete");
2186 assert!(!deleted, "Should not delete link from different session");
2187
2188 let links = db
2190 .get_links_by_session(&session1.id)
2191 .expect("Failed to get links");
2192 assert_eq!(links.len(), 1, "Link should be preserved");
2193 }
2194
2195 #[test]
2198 fn test_find_sessions_near_commit_time_basic() {
2199 let (db, _dir) = create_test_db();
2200 let now = Utc::now();
2201
2202 let mut session = create_test_session(
2204 "claude-code",
2205 "/home/user/project",
2206 now - Duration::hours(1),
2207 None,
2208 );
2209 session.ended_at = Some(now - Duration::minutes(10));
2210
2211 db.insert_session(&session).expect("insert session");
2212
2213 let found = db
2215 .find_sessions_near_commit_time(now, 30, None)
2216 .expect("find sessions");
2217
2218 assert_eq!(found.len(), 1, "Should find session within window");
2219 assert_eq!(found[0].id, session.id);
2220 }
2221
2222 #[test]
2223 fn test_find_sessions_near_commit_time_outside_window() {
2224 let (db, _dir) = create_test_db();
2225 let now = Utc::now();
2226
2227 let mut session =
2229 create_test_session("claude-code", "/project", now - Duration::hours(3), None);
2230 session.ended_at = Some(now - Duration::hours(2));
2231
2232 db.insert_session(&session).expect("insert session");
2233
2234 let found = db
2236 .find_sessions_near_commit_time(now, 30, None)
2237 .expect("find sessions");
2238
2239 assert!(found.is_empty(), "Should not find session outside window");
2240 }
2241
2242 #[test]
2243 fn test_find_sessions_near_commit_time_with_working_dir() {
2244 let (db, _dir) = create_test_db();
2245 let now = Utc::now();
2246
2247 let mut session1 = create_test_session(
2249 "claude-code",
2250 "/home/user/project-a",
2251 now - Duration::minutes(30),
2252 None,
2253 );
2254 session1.ended_at = Some(now - Duration::minutes(5));
2255
2256 let mut session2 = create_test_session(
2257 "claude-code",
2258 "/home/user/project-b",
2259 now - Duration::minutes(30),
2260 None,
2261 );
2262 session2.ended_at = Some(now - Duration::minutes(5));
2263
2264 db.insert_session(&session1).expect("insert session1");
2265 db.insert_session(&session2).expect("insert session2");
2266
2267 let found = db
2269 .find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
2270 .expect("find sessions");
2271
2272 assert_eq!(found.len(), 1, "Should find only session in project-a");
2273 assert_eq!(found[0].id, session1.id);
2274 }
2275
2276 #[test]
2277 fn test_find_sessions_near_commit_time_ongoing_session() {
2278 let (db, _dir) = create_test_db();
2279 let now = Utc::now();
2280
2281 let session =
2283 create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
2284 db.insert_session(&session).expect("insert session");
2287
2288 let found = db
2290 .find_sessions_near_commit_time(now, 30, None)
2291 .expect("find sessions");
2292
2293 assert_eq!(found.len(), 1, "Should find ongoing session");
2294 assert_eq!(found[0].id, session.id);
2295 }
2296
2297 #[test]
2298 fn test_link_exists_true() {
2299 let (db, _dir) = create_test_db();
2300
2301 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2302 db.insert_session(&session).expect("insert session");
2303
2304 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2305 db.insert_link(&link).expect("insert link");
2306
2307 assert!(
2309 db.link_exists(&session.id, "abc123def456")
2310 .expect("check exists"),
2311 "Should find link with full SHA"
2312 );
2313
2314 assert!(
2316 db.link_exists(&session.id, "abc123").expect("check exists"),
2317 "Should find link with partial SHA"
2318 );
2319 }
2320
2321 #[test]
2322 fn test_link_exists_false() {
2323 let (db, _dir) = create_test_db();
2324
2325 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2326 db.insert_session(&session).expect("insert session");
2327
2328 assert!(
2330 !db.link_exists(&session.id, "abc123").expect("check exists"),
2331 "Should not find non-existent link"
2332 );
2333 }
2334
2335 #[test]
2336 fn test_link_exists_different_session() {
2337 let (db, _dir) = create_test_db();
2338
2339 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2340 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2341
2342 db.insert_session(&session1).expect("insert session1");
2343 db.insert_session(&session2).expect("insert session2");
2344
2345 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2346 db.insert_link(&link).expect("insert link");
2347
2348 assert!(
2350 db.link_exists(&session1.id, "abc123").expect("check"),
2351 "Should find link for session1"
2352 );
2353 assert!(
2354 !db.link_exists(&session2.id, "abc123").expect("check"),
2355 "Should not find link for session2"
2356 );
2357 }
2358
2359 #[test]
2362 fn test_search_with_tool_filter() {
2363 let (db, _dir) = create_test_db();
2364
2365 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2366 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
2367
2368 db.insert_session(&session1).expect("insert session1");
2369 db.insert_session(&session2).expect("insert session2");
2370
2371 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
2372 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
2373
2374 db.insert_message(&msg1).expect("insert msg1");
2375 db.insert_message(&msg2).expect("insert msg2");
2376
2377 let options = SearchOptions {
2379 query: "Hello".to_string(),
2380 limit: 10,
2381 tool: Some("claude-code".to_string()),
2382 ..Default::default()
2383 };
2384 let results = db.search_with_options(&options).expect("search");
2385
2386 assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
2387 assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
2388 }
2389
2390 #[test]
2391 fn test_search_with_date_range() {
2392 let (db, _dir) = create_test_db();
2393
2394 let old_time = Utc::now() - chrono::Duration::days(30);
2395 let new_time = Utc::now() - chrono::Duration::days(1);
2396
2397 let session1 = create_test_session("claude-code", "/project1", old_time, None);
2398 let session2 = create_test_session("claude-code", "/project2", new_time, None);
2399
2400 db.insert_session(&session1).expect("insert session1");
2401 db.insert_session(&session2).expect("insert session2");
2402
2403 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
2404 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
2405
2406 db.insert_message(&msg1).expect("insert msg1");
2407 db.insert_message(&msg2).expect("insert msg2");
2408
2409 let since = Utc::now() - chrono::Duration::days(7);
2411 let options = SearchOptions {
2412 query: "session".to_string(),
2413 limit: 10,
2414 since: Some(since),
2415 ..Default::default()
2416 };
2417 let results = db.search_with_options(&options).expect("search");
2418
2419 assert_eq!(results.len(), 1, "Should find 1 result within date range");
2420 assert!(
2421 results[0].working_directory.contains("project2"),
2422 "Should be from newer project"
2423 );
2424 }
2425
2426 #[test]
2427 fn test_search_with_project_filter() {
2428 let (db, _dir) = create_test_db();
2429
2430 let session1 =
2431 create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
2432 let session2 =
2433 create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
2434
2435 db.insert_session(&session1).expect("insert session1");
2436 db.insert_session(&session2).expect("insert session2");
2437
2438 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
2439 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
2440
2441 db.insert_message(&msg1).expect("insert msg1");
2442 db.insert_message(&msg2).expect("insert msg2");
2443
2444 let options = SearchOptions {
2446 query: "Testing".to_string(),
2447 limit: 10,
2448 project: Some("frontend".to_string()),
2449 ..Default::default()
2450 };
2451 let results = db.search_with_options(&options).expect("search");
2452
2453 assert_eq!(results.len(), 1, "Should find 1 result with project filter");
2454 assert!(
2455 results[0].working_directory.contains("frontend"),
2456 "Should be from frontend project"
2457 );
2458 }
2459
2460 #[test]
2461 fn test_search_with_branch_filter() {
2462 let (db, _dir) = create_test_db();
2463
2464 let session1 = Session {
2465 id: Uuid::new_v4(),
2466 tool: "claude-code".to_string(),
2467 tool_version: None,
2468 started_at: Utc::now(),
2469 ended_at: None,
2470 model: None,
2471 working_directory: "/project".to_string(),
2472 git_branch: Some("feat/auth".to_string()),
2473 source_path: None,
2474 message_count: 0,
2475 };
2476 let session2 = Session {
2477 id: Uuid::new_v4(),
2478 tool: "claude-code".to_string(),
2479 tool_version: None,
2480 started_at: Utc::now(),
2481 ended_at: None,
2482 model: None,
2483 working_directory: "/project".to_string(),
2484 git_branch: Some("main".to_string()),
2485 source_path: None,
2486 message_count: 0,
2487 };
2488
2489 db.insert_session(&session1).expect("insert session1");
2490 db.insert_session(&session2).expect("insert session2");
2491
2492 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
2493 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
2494
2495 db.insert_message(&msg1).expect("insert msg1");
2496 db.insert_message(&msg2).expect("insert msg2");
2497
2498 let options = SearchOptions {
2500 query: "work".to_string(),
2501 limit: 10,
2502 branch: Some("auth".to_string()),
2503 ..Default::default()
2504 };
2505 let results = db.search_with_options(&options).expect("search");
2506
2507 assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
2508 assert_eq!(
2509 results[0].git_branch.as_deref(),
2510 Some("feat/auth"),
2511 "Should be from feat/auth branch"
2512 );
2513 }
2514
2515 #[test]
2516 fn test_search_metadata_matches_project() {
2517 let (db, _dir) = create_test_db();
2518
2519 let session =
2520 create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
2521 db.insert_session(&session).expect("insert session");
2522
2523 let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
2525 db.insert_message(&msg).expect("insert msg");
2526
2527 let options = SearchOptions {
2529 query: "redactyl".to_string(),
2530 limit: 10,
2531 ..Default::default()
2532 };
2533 let results = db.search_with_options(&options).expect("search");
2534
2535 assert_eq!(
2536 results.len(),
2537 1,
2538 "Should find session via metadata match on project name"
2539 );
2540 }
2541
2542 #[test]
2543 fn test_search_returns_extended_session_info() {
2544 let (db, _dir) = create_test_db();
2545
2546 let started_at = Utc::now();
2547 let session = Session {
2548 id: Uuid::new_v4(),
2549 tool: "claude-code".to_string(),
2550 tool_version: Some("1.0.0".to_string()),
2551 started_at,
2552 ended_at: None,
2553 model: None,
2554 working_directory: "/home/user/myapp".to_string(),
2555 git_branch: Some("develop".to_string()),
2556 source_path: None,
2557 message_count: 5,
2558 };
2559 db.insert_session(&session).expect("insert session");
2560
2561 let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
2562 db.insert_message(&msg).expect("insert msg");
2563
2564 let options = SearchOptions {
2565 query: "Test".to_string(),
2566 limit: 10,
2567 ..Default::default()
2568 };
2569 let results = db.search_with_options(&options).expect("search");
2570
2571 assert_eq!(results.len(), 1, "Should find 1 result");
2572 let result = &results[0];
2573
2574 assert_eq!(result.tool, "claude-code", "Tool should be populated");
2575 assert_eq!(
2576 result.git_branch.as_deref(),
2577 Some("develop"),
2578 "Branch should be populated"
2579 );
2580 assert!(
2581 result.session_message_count > 0,
2582 "Message count should be populated"
2583 );
2584 assert!(
2585 result.session_started_at.is_some(),
2586 "Session start time should be populated"
2587 );
2588 }
2589
2590 #[test]
2591 fn test_get_context_messages() {
2592 let (db, _dir) = create_test_db();
2593
2594 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2595 db.insert_session(&session).expect("insert session");
2596
2597 for i in 0..5 {
2599 let role = if i % 2 == 0 {
2600 MessageRole::User
2601 } else {
2602 MessageRole::Assistant
2603 };
2604 let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
2605 db.insert_message(&msg).expect("insert message");
2606 }
2607
2608 let (before, after) = db
2610 .get_context_messages(&session.id, 2, 1)
2611 .expect("get context");
2612
2613 assert_eq!(before.len(), 1, "Should have 1 message before");
2614 assert_eq!(after.len(), 1, "Should have 1 message after");
2615 assert_eq!(before[0].index, 1, "Before message should be index 1");
2616 assert_eq!(after[0].index, 3, "After message should be index 3");
2617 }
2618
2619 #[test]
2620 fn test_get_context_messages_at_start() {
2621 let (db, _dir) = create_test_db();
2622
2623 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2624 db.insert_session(&session).expect("insert session");
2625
2626 for i in 0..3 {
2627 let msg =
2628 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
2629 db.insert_message(&msg).expect("insert message");
2630 }
2631
2632 let (before, after) = db
2634 .get_context_messages(&session.id, 0, 2)
2635 .expect("get context");
2636
2637 assert!(
2638 before.is_empty(),
2639 "Should have no messages before first message"
2640 );
2641 assert_eq!(after.len(), 2, "Should have 2 messages after");
2642 }
2643
2644 #[test]
2645 fn test_get_context_messages_at_end() {
2646 let (db, _dir) = create_test_db();
2647
2648 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2649 db.insert_session(&session).expect("insert session");
2650
2651 for i in 0..3 {
2652 let msg =
2653 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
2654 db.insert_message(&msg).expect("insert message");
2655 }
2656
2657 let (before, after) = db
2659 .get_context_messages(&session.id, 2, 2)
2660 .expect("get context");
2661
2662 assert_eq!(before.len(), 2, "Should have 2 messages before");
2663 assert!(
2664 after.is_empty(),
2665 "Should have no messages after last message"
2666 );
2667 }
2668
2669 #[test]
2670 fn test_search_combined_filters() {
2671 let (db, _dir) = create_test_db();
2672
2673 let session1 = Session {
2674 id: Uuid::new_v4(),
2675 tool: "claude-code".to_string(),
2676 tool_version: None,
2677 started_at: Utc::now(),
2678 ended_at: None,
2679 model: None,
2680 working_directory: "/home/user/myapp".to_string(),
2681 git_branch: Some("feat/api".to_string()),
2682 source_path: None,
2683 message_count: 1,
2684 };
2685 let session2 = Session {
2686 id: Uuid::new_v4(),
2687 tool: "aider".to_string(),
2688 tool_version: None,
2689 started_at: Utc::now(),
2690 ended_at: None,
2691 model: None,
2692 working_directory: "/home/user/myapp".to_string(),
2693 git_branch: Some("feat/api".to_string()),
2694 source_path: None,
2695 message_count: 1,
2696 };
2697
2698 db.insert_session(&session1).expect("insert session1");
2699 db.insert_session(&session2).expect("insert session2");
2700
2701 let msg1 =
2702 create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
2703 let msg2 =
2704 create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
2705
2706 db.insert_message(&msg1).expect("insert msg1");
2707 db.insert_message(&msg2).expect("insert msg2");
2708
2709 let options = SearchOptions {
2711 query: "API".to_string(),
2712 limit: 10,
2713 tool: Some("claude-code".to_string()),
2714 branch: Some("api".to_string()),
2715 project: Some("myapp".to_string()),
2716 ..Default::default()
2717 };
2718 let results = db.search_with_options(&options).expect("search");
2719
2720 assert!(
2722 !results.is_empty(),
2723 "Should find at least 1 result matching all filters"
2724 );
2725 for result in &results {
2727 assert_eq!(
2728 result.tool, "claude-code",
2729 "All results should be from claude-code"
2730 );
2731 }
2732 }
2733}