1use anyhow::{Context, Result};
8use chrono::{DateTime, Utc};
9use rusqlite::{params, Connection, OptionalExtension};
10use std::path::PathBuf;
11use uuid::Uuid;
12
13use super::models::{
14 Annotation, Machine, Message, MessageContent, MessageRole, SearchResult, Session, SessionLink,
15 Summary, Tag,
16};
17
18fn parse_uuid(s: &str) -> rusqlite::Result<Uuid> {
22 Uuid::parse_str(s).map_err(|e| {
23 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
24 })
25}
26
27fn parse_datetime(s: &str) -> rusqlite::Result<DateTime<Utc>> {
31 chrono::DateTime::parse_from_rfc3339(s)
32 .map(|dt| dt.with_timezone(&Utc))
33 .map_err(|e| {
34 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
35 })
36}
37
38fn escape_fts5_query(query: &str) -> String {
43 query
45 .split_whitespace()
46 .map(|word| {
47 let escaped = word.replace('"', "\"\"");
48 format!("\"{escaped}\"")
49 })
50 .collect::<Vec<_>>()
51 .join(" ")
52}
53
54pub fn default_db_path() -> Result<PathBuf> {
58 let config_dir = dirs::home_dir()
59 .context("Could not find home directory. Ensure your HOME environment variable is set.")?
60 .join(".lore");
61
62 std::fs::create_dir_all(&config_dir).with_context(|| {
63 format!(
64 "Failed to create Lore data directory at {}. Check directory permissions.",
65 config_dir.display()
66 )
67 })?;
68 Ok(config_dir.join("lore.db"))
69}
70
71pub struct Database {
77 conn: Connection,
78}
79
80impl Database {
81 pub fn open(path: &PathBuf) -> Result<Self> {
85 let conn = Connection::open(path)?;
86 let db = Self { conn };
87 db.migrate()?;
88 Ok(db)
89 }
90
91 pub fn open_default() -> Result<Self> {
95 let path = default_db_path()?;
96 Self::open(&path)
97 }
98
99 fn migrate(&self) -> Result<()> {
104 self.conn.execute_batch(
105 r#"
106 CREATE TABLE IF NOT EXISTS schema_version (
107 version INTEGER PRIMARY KEY
108 );
109
110 CREATE TABLE IF NOT EXISTS sessions (
111 id TEXT PRIMARY KEY,
112 tool TEXT NOT NULL,
113 tool_version TEXT,
114 started_at TEXT NOT NULL,
115 ended_at TEXT,
116 model TEXT,
117 working_directory TEXT NOT NULL,
118 git_branch TEXT,
119 source_path TEXT,
120 message_count INTEGER NOT NULL DEFAULT 0,
121 created_at TEXT NOT NULL DEFAULT (datetime('now')),
122 machine_id TEXT
123 );
124
125 CREATE TABLE IF NOT EXISTS messages (
126 id TEXT PRIMARY KEY,
127 session_id TEXT NOT NULL,
128 parent_id TEXT,
129 idx INTEGER NOT NULL,
130 timestamp TEXT NOT NULL,
131 role TEXT NOT NULL,
132 content TEXT NOT NULL,
133 model TEXT,
134 git_branch TEXT,
135 cwd TEXT,
136 FOREIGN KEY (session_id) REFERENCES sessions(id)
137 );
138
139 CREATE TABLE IF NOT EXISTS session_links (
140 id TEXT PRIMARY KEY,
141 session_id TEXT NOT NULL,
142 link_type TEXT NOT NULL,
143 commit_sha TEXT,
144 branch TEXT,
145 remote TEXT,
146 created_at TEXT NOT NULL,
147 created_by TEXT NOT NULL,
148 confidence REAL,
149 FOREIGN KEY (session_id) REFERENCES sessions(id)
150 );
151
152 CREATE TABLE IF NOT EXISTS repositories (
153 id TEXT PRIMARY KEY,
154 path TEXT NOT NULL UNIQUE,
155 name TEXT NOT NULL,
156 remote_url TEXT,
157 created_at TEXT NOT NULL DEFAULT (datetime('now')),
158 last_session_at TEXT
159 );
160
161 CREATE TABLE IF NOT EXISTS annotations (
162 id TEXT PRIMARY KEY,
163 session_id TEXT NOT NULL,
164 content TEXT NOT NULL,
165 created_at TEXT NOT NULL,
166 FOREIGN KEY (session_id) REFERENCES sessions(id)
167 );
168
169 CREATE TABLE IF NOT EXISTS tags (
170 id TEXT PRIMARY KEY,
171 session_id TEXT NOT NULL,
172 label TEXT NOT NULL,
173 created_at TEXT NOT NULL,
174 FOREIGN KEY (session_id) REFERENCES sessions(id),
175 UNIQUE(session_id, label)
176 );
177
178 CREATE TABLE IF NOT EXISTS summaries (
179 id TEXT PRIMARY KEY,
180 session_id TEXT NOT NULL UNIQUE,
181 content TEXT NOT NULL,
182 generated_at TEXT NOT NULL,
183 FOREIGN KEY (session_id) REFERENCES sessions(id)
184 );
185
186 CREATE TABLE IF NOT EXISTS machines (
187 id TEXT PRIMARY KEY,
188 name TEXT NOT NULL,
189 created_at TEXT NOT NULL
190 );
191
192 -- Indexes for common queries
193 CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
194 CREATE INDEX IF NOT EXISTS idx_sessions_working_directory ON sessions(working_directory);
195 CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
196 CREATE INDEX IF NOT EXISTS idx_session_links_session_id ON session_links(session_id);
197 CREATE INDEX IF NOT EXISTS idx_session_links_commit_sha ON session_links(commit_sha);
198 CREATE INDEX IF NOT EXISTS idx_annotations_session_id ON annotations(session_id);
199 CREATE INDEX IF NOT EXISTS idx_tags_session_id ON tags(session_id);
200 CREATE INDEX IF NOT EXISTS idx_tags_label ON tags(label);
201 "#,
202 )?;
203
204 self.conn.execute_batch(
209 r#"
210 CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
211 message_id,
212 text_content,
213 tokenize='porter unicode61'
214 );
215 "#,
216 )?;
217
218 self.conn.execute_batch(
221 r#"
222 CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
223 session_id,
224 tool,
225 working_directory,
226 git_branch,
227 tokenize='porter unicode61'
228 );
229 "#,
230 )?;
231
232 self.migrate_add_machine_id()?;
235
236 Ok(())
237 }
238
239 fn migrate_add_machine_id(&self) -> Result<()> {
247 let columns: Vec<String> = self
249 .conn
250 .prepare("PRAGMA table_info(sessions)")?
251 .query_map([], |row| row.get::<_, String>(1))?
252 .collect::<Result<Vec<_>, _>>()?;
253
254 if !columns.iter().any(|c| c == "machine_id") {
255 self.conn
256 .execute("ALTER TABLE sessions ADD COLUMN machine_id TEXT", [])?;
257 }
258
259 if let Some(machine_uuid) = super::get_machine_id() {
261 self.conn.execute(
262 "UPDATE sessions SET machine_id = ?1 WHERE machine_id IS NULL",
263 [&machine_uuid],
264 )?;
265
266 if let Some(hostname) = hostname::get().ok().and_then(|h| h.into_string().ok()) {
271 self.conn.execute(
272 "UPDATE sessions SET machine_id = ?1 WHERE machine_id = ?2",
273 [&machine_uuid, &hostname],
274 )?;
275 }
276 }
277
278 Ok(())
279 }
280
281 pub fn insert_session(&self, session: &Session) -> Result<()> {
289 let rows_changed = self.conn.execute(
290 r#"
291 INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id)
292 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
293 ON CONFLICT(id) DO UPDATE SET
294 ended_at = ?5,
295 message_count = ?10
296 "#,
297 params![
298 session.id.to_string(),
299 session.tool,
300 session.tool_version,
301 session.started_at.to_rfc3339(),
302 session.ended_at.map(|t| t.to_rfc3339()),
303 session.model,
304 session.working_directory,
305 session.git_branch,
306 session.source_path,
307 session.message_count,
308 session.machine_id,
309 ],
310 )?;
311
312 if rows_changed > 0 {
314 let fts_count: i32 = self.conn.query_row(
316 "SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
317 params![session.id.to_string()],
318 |row| row.get(0),
319 )?;
320
321 if fts_count == 0 {
322 self.conn.execute(
323 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
324 params![
325 session.id.to_string(),
326 session.tool,
327 session.working_directory,
328 session.git_branch.as_deref().unwrap_or(""),
329 ],
330 )?;
331 }
332 }
333
334 Ok(())
335 }
336
337 pub fn get_session(&self, id: &Uuid) -> Result<Option<Session>> {
341 self.conn
342 .query_row(
343 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id FROM sessions WHERE id = ?1",
344 params![id.to_string()],
345 Self::row_to_session,
346 )
347 .optional()
348 .context("Failed to get session")
349 }
350
351 pub fn list_sessions(&self, limit: usize, working_dir: Option<&str>) -> Result<Vec<Session>> {
356 let mut stmt = if working_dir.is_some() {
357 self.conn.prepare(
358 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
359 FROM sessions
360 WHERE working_directory LIKE ?1
361 ORDER BY started_at DESC
362 LIMIT ?2"
363 )?
364 } else {
365 self.conn.prepare(
366 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
367 FROM sessions
368 ORDER BY started_at DESC
369 LIMIT ?1"
370 )?
371 };
372
373 let rows = if let Some(wd) = working_dir {
374 stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
375 } else {
376 stmt.query_map(params![limit], Self::row_to_session)?
377 };
378
379 rows.collect::<Result<Vec<_>, _>>()
380 .context("Failed to list sessions")
381 }
382
383 pub fn session_exists_by_source(&self, source_path: &str) -> Result<bool> {
387 let count: i32 = self.conn.query_row(
388 "SELECT COUNT(*) FROM sessions WHERE source_path = ?1",
389 params![source_path],
390 |row| row.get(0),
391 )?;
392 Ok(count > 0)
393 }
394
395 pub fn find_session_by_id_prefix(&self, prefix: &str) -> Result<Option<Session>> {
411 if let Ok(uuid) = Uuid::parse_str(prefix) {
413 return self.get_session(&uuid);
414 }
415
416 let pattern = format!("{prefix}%");
418
419 let count: i32 = self.conn.query_row(
421 "SELECT COUNT(*) FROM sessions WHERE id LIKE ?1",
422 params![pattern],
423 |row| row.get(0),
424 )?;
425
426 match count {
427 0 => Ok(None),
428 1 => {
429 self.conn
431 .query_row(
432 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
433 FROM sessions
434 WHERE id LIKE ?1",
435 params![pattern],
436 Self::row_to_session,
437 )
438 .optional()
439 .context("Failed to find session by prefix")
440 }
441 n => {
442 anyhow::bail!(
444 "Ambiguous session ID prefix '{prefix}' matches {n} sessions. Use a longer prefix."
445 )
446 }
447 }
448 }
449
450 pub fn update_session_branch(&self, session_id: Uuid, new_branch: &str) -> Result<usize> {
458 let rows_changed = self.conn.execute(
459 "UPDATE sessions SET git_branch = ?1 WHERE id = ?2",
460 params![new_branch, session_id.to_string()],
461 )?;
462
463 if rows_changed > 0 {
465 self.conn.execute(
466 "UPDATE sessions_fts SET git_branch = ?1 WHERE session_id = ?2",
467 params![new_branch, session_id.to_string()],
468 )?;
469 }
470
471 Ok(rows_changed)
472 }
473
474 fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<Session> {
475 let ended_at_str: Option<String> = row.get(4)?;
476 let ended_at = match ended_at_str {
477 Some(s) => Some(parse_datetime(&s)?),
478 None => None,
479 };
480
481 Ok(Session {
482 id: parse_uuid(&row.get::<_, String>(0)?)?,
483 tool: row.get(1)?,
484 tool_version: row.get(2)?,
485 started_at: parse_datetime(&row.get::<_, String>(3)?)?,
486 ended_at,
487 model: row.get(5)?,
488 working_directory: row.get(6)?,
489 git_branch: row.get(7)?,
490 source_path: row.get(8)?,
491 message_count: row.get(9)?,
492 machine_id: row.get(10)?,
493 })
494 }
495
496 pub fn insert_message(&self, message: &Message) -> Result<()> {
504 let content_json = serde_json::to_string(&message.content)?;
505
506 let rows_changed = self.conn.execute(
507 r#"
508 INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
509 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
510 ON CONFLICT(id) DO NOTHING
511 "#,
512 params![
513 message.id.to_string(),
514 message.session_id.to_string(),
515 message.parent_id.map(|u| u.to_string()),
516 message.index,
517 message.timestamp.to_rfc3339(),
518 message.role.to_string(),
519 content_json,
520 message.model,
521 message.git_branch,
522 message.cwd,
523 ],
524 )?;
525
526 if rows_changed > 0 {
528 let text_content = message.content.text();
529 if !text_content.is_empty() {
530 self.conn.execute(
531 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
532 params![message.id.to_string(), text_content],
533 )?;
534 }
535 }
536
537 Ok(())
538 }
539
540 pub fn get_messages(&self, session_id: &Uuid) -> Result<Vec<Message>> {
544 let mut stmt = self.conn.prepare(
545 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
546 FROM messages
547 WHERE session_id = ?1
548 ORDER BY idx"
549 )?;
550
551 let rows = stmt.query_map(params![session_id.to_string()], |row| {
552 let role_str: String = row.get(5)?;
553 let content_str: String = row.get(6)?;
554
555 let parent_id_str: Option<String> = row.get(2)?;
556 let parent_id = match parent_id_str {
557 Some(s) => Some(parse_uuid(&s)?),
558 None => None,
559 };
560
561 Ok(Message {
562 id: parse_uuid(&row.get::<_, String>(0)?)?,
563 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
564 parent_id,
565 index: row.get(3)?,
566 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
567 role: match role_str.as_str() {
568 "user" => MessageRole::User,
569 "assistant" => MessageRole::Assistant,
570 "system" => MessageRole::System,
571 _ => MessageRole::User,
572 },
573 content: serde_json::from_str(&content_str)
574 .unwrap_or(MessageContent::Text(content_str)),
575 model: row.get(7)?,
576 git_branch: row.get(8)?,
577 cwd: row.get(9)?,
578 })
579 })?;
580
581 rows.collect::<Result<Vec<_>, _>>()
582 .context("Failed to get messages")
583 }
584
585 pub fn get_session_branch_history(&self, session_id: Uuid) -> Result<Vec<String>> {
594 let mut stmt = self
595 .conn
596 .prepare("SELECT git_branch FROM messages WHERE session_id = ?1 ORDER BY idx")?;
597
598 let rows = stmt.query_map(params![session_id.to_string()], |row| {
599 let branch: Option<String> = row.get(0)?;
600 Ok(branch)
601 })?;
602
603 let mut branches: Vec<String> = Vec::new();
605 for row in rows {
606 if let Some(branch) = row? {
607 if branches.last() != Some(&branch) {
609 branches.push(branch);
610 }
611 }
612 }
613
614 Ok(branches)
615 }
616
617 pub fn insert_link(&self, link: &SessionLink) -> Result<()> {
624 self.conn.execute(
625 r#"
626 INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
627 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
628 "#,
629 params![
630 link.id.to_string(),
631 link.session_id.to_string(),
632 format!("{:?}", link.link_type).to_lowercase(),
633 link.commit_sha,
634 link.branch,
635 link.remote,
636 link.created_at.to_rfc3339(),
637 format!("{:?}", link.created_by).to_lowercase(),
638 link.confidence,
639 ],
640 )?;
641 Ok(())
642 }
643
644 pub fn get_links_by_commit(&self, commit_sha: &str) -> Result<Vec<SessionLink>> {
649 let mut stmt = self.conn.prepare(
650 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
651 FROM session_links
652 WHERE commit_sha LIKE ?1"
653 )?;
654
655 let pattern = format!("{commit_sha}%");
656 let rows = stmt.query_map(params![pattern], Self::row_to_link)?;
657
658 rows.collect::<Result<Vec<_>, _>>()
659 .context("Failed to get links")
660 }
661
662 pub fn get_links_by_session(&self, session_id: &Uuid) -> Result<Vec<SessionLink>> {
667 let mut stmt = self.conn.prepare(
668 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
669 FROM session_links
670 WHERE session_id = ?1"
671 )?;
672
673 let rows = stmt.query_map(params![session_id.to_string()], Self::row_to_link)?;
674
675 rows.collect::<Result<Vec<_>, _>>()
676 .context("Failed to get links")
677 }
678
679 fn row_to_link(row: &rusqlite::Row) -> rusqlite::Result<SessionLink> {
680 use super::models::{LinkCreator, LinkType};
681
682 let link_type_str: String = row.get(2)?;
683 let created_by_str: String = row.get(7)?;
684
685 Ok(SessionLink {
686 id: parse_uuid(&row.get::<_, String>(0)?)?,
687 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
688 link_type: match link_type_str.as_str() {
689 "commit" => LinkType::Commit,
690 "branch" => LinkType::Branch,
691 "pr" => LinkType::Pr,
692 _ => LinkType::Manual,
693 },
694 commit_sha: row.get(3)?,
695 branch: row.get(4)?,
696 remote: row.get(5)?,
697 created_at: parse_datetime(&row.get::<_, String>(6)?)?,
698 created_by: match created_by_str.as_str() {
699 "auto" => LinkCreator::Auto,
700 _ => LinkCreator::User,
701 },
702 confidence: row.get(8)?,
703 })
704 }
705
706 #[allow(dead_code)]
713 pub fn delete_link(&self, link_id: &Uuid) -> Result<bool> {
714 let rows_affected = self.conn.execute(
715 "DELETE FROM session_links WHERE id = ?1",
716 params![link_id.to_string()],
717 )?;
718 Ok(rows_affected > 0)
719 }
720
721 pub fn delete_links_by_session(&self, session_id: &Uuid) -> Result<usize> {
725 let rows_affected = self.conn.execute(
726 "DELETE FROM session_links WHERE session_id = ?1",
727 params![session_id.to_string()],
728 )?;
729 Ok(rows_affected)
730 }
731
732 pub fn delete_link_by_session_and_commit(
737 &self,
738 session_id: &Uuid,
739 commit_sha: &str,
740 ) -> Result<bool> {
741 let pattern = format!("{commit_sha}%");
742 let rows_affected = self.conn.execute(
743 "DELETE FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
744 params![session_id.to_string(), pattern],
745 )?;
746 Ok(rows_affected > 0)
747 }
748
749 #[allow(dead_code)]
763 pub fn search_messages(
764 &self,
765 query: &str,
766 limit: usize,
767 working_dir: Option<&str>,
768 since: Option<chrono::DateTime<chrono::Utc>>,
769 role: Option<&str>,
770 ) -> Result<Vec<SearchResult>> {
771 use super::models::SearchOptions;
772
773 let options = SearchOptions {
775 query: query.to_string(),
776 limit,
777 repo: working_dir.map(|s| s.to_string()),
778 since,
779 role: role.map(|s| s.to_string()),
780 ..Default::default()
781 };
782
783 self.search_with_options(&options)
784 }
785
786 pub fn search_with_options(
800 &self,
801 options: &super::models::SearchOptions,
802 ) -> Result<Vec<SearchResult>> {
803 let escaped_query = escape_fts5_query(&options.query);
805
806 let mut sql = String::from(
809 r#"
810 SELECT
811 m.session_id,
812 m.id as message_id,
813 m.role,
814 snippet(messages_fts, 1, '**', '**', '...', 32) as snippet,
815 m.timestamp,
816 s.working_directory,
817 s.tool,
818 s.git_branch,
819 s.message_count,
820 s.started_at,
821 m.idx as message_index
822 FROM messages_fts fts
823 JOIN messages m ON fts.message_id = m.id
824 JOIN sessions s ON m.session_id = s.id
825 WHERE messages_fts MATCH ?1
826 "#,
827 );
828
829 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(escaped_query.clone())];
830 let mut param_idx = 2;
831
832 if options.repo.is_some() {
834 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
835 param_idx += 1;
836 }
837 if options.tool.is_some() {
838 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
839 param_idx += 1;
840 }
841 if options.since.is_some() {
842 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
843 param_idx += 1;
844 }
845 if options.until.is_some() {
846 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
847 param_idx += 1;
848 }
849 if options.project.is_some() {
850 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
851 param_idx += 1;
852 }
853 if options.branch.is_some() {
854 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
855 param_idx += 1;
856 }
857 if options.role.is_some() {
858 sql.push_str(&format!(" AND m.role = ?{param_idx}"));
859 param_idx += 1;
860 }
861
862 if let Some(ref wd) = options.repo {
864 params_vec.push(Box::new(format!("{wd}%")));
865 }
866 if let Some(ref tool) = options.tool {
867 params_vec.push(Box::new(tool.clone()));
868 }
869 if let Some(ts) = options.since {
870 params_vec.push(Box::new(ts.to_rfc3339()));
871 }
872 if let Some(ts) = options.until {
873 params_vec.push(Box::new(ts.to_rfc3339()));
874 }
875 if let Some(ref project) = options.project {
876 params_vec.push(Box::new(format!("%{project}%")));
877 }
878 if let Some(ref branch) = options.branch {
879 params_vec.push(Box::new(format!("%{branch}%")));
880 }
881 if let Some(ref role) = options.role {
882 params_vec.push(Box::new(role.clone()));
883 }
884
885 let include_metadata_search = options.role.is_none();
889 let metadata_query_pattern = format!("%{}%", options.query);
890
891 if include_metadata_search {
892 let meta_param1 = param_idx;
894 let meta_param2 = param_idx + 1;
895 let meta_param3 = param_idx + 2;
896 param_idx += 3;
897
898 sql.push_str(&format!(
899 r#"
900 UNION
901 SELECT
902 s.id as session_id,
903 (SELECT id FROM messages WHERE session_id = s.id ORDER BY idx LIMIT 1) as message_id,
904 'user' as role,
905 substr(s.tool || ' session in ' || s.working_directory || COALESCE(' on branch ' || s.git_branch, ''), 1, 100) as snippet,
906 s.started_at as timestamp,
907 s.working_directory,
908 s.tool,
909 s.git_branch,
910 s.message_count,
911 s.started_at,
912 0 as message_index
913 FROM sessions s
914 WHERE (
915 s.tool LIKE ?{meta_param1}
916 OR s.working_directory LIKE ?{meta_param2}
917 OR s.git_branch LIKE ?{meta_param3}
918 )
919 "#
920 ));
921
922 params_vec.push(Box::new(metadata_query_pattern.clone()));
924 params_vec.push(Box::new(metadata_query_pattern.clone()));
925 params_vec.push(Box::new(metadata_query_pattern));
926
927 if options.repo.is_some() {
929 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
930 params_vec.push(Box::new(format!("{}%", options.repo.as_ref().unwrap())));
931 param_idx += 1;
932 }
933 if options.tool.is_some() {
934 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
935 params_vec.push(Box::new(options.tool.as_ref().unwrap().clone()));
936 param_idx += 1;
937 }
938 if options.since.is_some() {
939 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
940 params_vec.push(Box::new(options.since.unwrap().to_rfc3339()));
941 param_idx += 1;
942 }
943 if options.until.is_some() {
944 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
945 params_vec.push(Box::new(options.until.unwrap().to_rfc3339()));
946 param_idx += 1;
947 }
948 if options.project.is_some() {
949 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
950 params_vec.push(Box::new(format!("%{}%", options.project.as_ref().unwrap())));
951 param_idx += 1;
952 }
953 if options.branch.is_some() {
954 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
955 params_vec.push(Box::new(format!("%{}%", options.branch.as_ref().unwrap())));
956 param_idx += 1;
957 }
958 }
959
960 sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{param_idx}"));
961 params_vec.push(Box::new(options.limit as i64));
962
963 let mut stmt = self.conn.prepare(&sql)?;
965 let params_refs: Vec<&dyn rusqlite::ToSql> =
966 params_vec.iter().map(|p| p.as_ref()).collect();
967
968 let rows = stmt.query_map(params_refs.as_slice(), |row| {
969 let role_str: String = row.get(2)?;
970 let git_branch: Option<String> = row.get(7)?;
971 let started_at_str: Option<String> = row.get(9)?;
972
973 Ok(SearchResult {
974 session_id: parse_uuid(&row.get::<_, String>(0)?)?,
975 message_id: parse_uuid(&row.get::<_, String>(1)?)?,
976 role: match role_str.as_str() {
977 "user" => MessageRole::User,
978 "assistant" => MessageRole::Assistant,
979 "system" => MessageRole::System,
980 _ => MessageRole::User,
981 },
982 snippet: row.get(3)?,
983 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
984 working_directory: row.get(5)?,
985 tool: row.get(6)?,
986 git_branch,
987 session_message_count: row.get(8)?,
988 session_started_at: started_at_str.map(|s| parse_datetime(&s)).transpose()?,
989 message_index: row.get(10)?,
990 })
991 })?;
992
993 rows.collect::<Result<Vec<_>, _>>()
994 .context("Failed to search messages")
995 }
996
997 pub fn get_context_messages(
1002 &self,
1003 session_id: &Uuid,
1004 message_index: i32,
1005 context_count: usize,
1006 ) -> Result<(Vec<Message>, Vec<Message>)> {
1007 let mut before_stmt = self.conn.prepare(
1009 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1010 FROM messages
1011 WHERE session_id = ?1 AND idx < ?2
1012 ORDER BY idx DESC
1013 LIMIT ?3",
1014 )?;
1015
1016 let before_rows = before_stmt.query_map(
1017 params![session_id.to_string(), message_index, context_count as i64],
1018 Self::row_to_message,
1019 )?;
1020
1021 let mut before: Vec<Message> = before_rows
1022 .collect::<Result<Vec<_>, _>>()
1023 .context("Failed to get before messages")?;
1024 before.reverse(); let mut after_stmt = self.conn.prepare(
1028 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1029 FROM messages
1030 WHERE session_id = ?1 AND idx > ?2
1031 ORDER BY idx ASC
1032 LIMIT ?3",
1033 )?;
1034
1035 let after_rows = after_stmt.query_map(
1036 params![session_id.to_string(), message_index, context_count as i64],
1037 Self::row_to_message,
1038 )?;
1039
1040 let after: Vec<Message> = after_rows
1041 .collect::<Result<Vec<_>, _>>()
1042 .context("Failed to get after messages")?;
1043
1044 Ok((before, after))
1045 }
1046
1047 #[allow(dead_code)]
1049 pub fn get_message_by_index(&self, session_id: &Uuid, index: i32) -> Result<Option<Message>> {
1050 self.conn
1051 .query_row(
1052 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1053 FROM messages
1054 WHERE session_id = ?1 AND idx = ?2",
1055 params![session_id.to_string(), index],
1056 Self::row_to_message,
1057 )
1058 .optional()
1059 .context("Failed to get message by index")
1060 }
1061
1062 fn row_to_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
1063 let role_str: String = row.get(5)?;
1064 let content_str: String = row.get(6)?;
1065
1066 let parent_id_str: Option<String> = row.get(2)?;
1067 let parent_id = match parent_id_str {
1068 Some(s) => Some(parse_uuid(&s)?),
1069 None => None,
1070 };
1071
1072 Ok(Message {
1073 id: parse_uuid(&row.get::<_, String>(0)?)?,
1074 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1075 parent_id,
1076 index: row.get(3)?,
1077 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1078 role: match role_str.as_str() {
1079 "user" => MessageRole::User,
1080 "assistant" => MessageRole::Assistant,
1081 "system" => MessageRole::System,
1082 _ => MessageRole::User,
1083 },
1084 content: serde_json::from_str(&content_str)
1085 .unwrap_or(MessageContent::Text(content_str)),
1086 model: row.get(7)?,
1087 git_branch: row.get(8)?,
1088 cwd: row.get(9)?,
1089 })
1090 }
1091
1092 pub fn rebuild_search_index(&self) -> Result<usize> {
1100 self.conn.execute("DELETE FROM messages_fts", [])?;
1102 self.conn.execute("DELETE FROM sessions_fts", [])?;
1103
1104 let mut msg_stmt = self.conn.prepare("SELECT id, content FROM messages")?;
1106
1107 let rows = msg_stmt.query_map([], |row| {
1108 let id: String = row.get(0)?;
1109 let content_json: String = row.get(1)?;
1110 Ok((id, content_json))
1111 })?;
1112
1113 let mut count = 0;
1114 for row in rows {
1115 let (id, content_json) = row?;
1116 let content: MessageContent = serde_json::from_str(&content_json)
1118 .unwrap_or(MessageContent::Text(content_json.clone()));
1119 let text_content = content.text();
1120
1121 if !text_content.is_empty() {
1122 self.conn.execute(
1123 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
1124 params![id, text_content],
1125 )?;
1126 count += 1;
1127 }
1128 }
1129
1130 let mut session_stmt = self
1132 .conn
1133 .prepare("SELECT id, tool, working_directory, git_branch FROM sessions")?;
1134
1135 let session_rows = session_stmt.query_map([], |row| {
1136 let id: String = row.get(0)?;
1137 let tool: String = row.get(1)?;
1138 let working_directory: String = row.get(2)?;
1139 let git_branch: Option<String> = row.get(3)?;
1140 Ok((id, tool, working_directory, git_branch))
1141 })?;
1142
1143 for row in session_rows {
1144 let (id, tool, working_directory, git_branch) = row?;
1145 self.conn.execute(
1146 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
1147 params![id, tool, working_directory, git_branch.unwrap_or_default()],
1148 )?;
1149 }
1150
1151 Ok(count)
1152 }
1153
1154 pub fn search_index_needs_rebuild(&self) -> Result<bool> {
1159 let message_count: i32 =
1160 self.conn
1161 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1162
1163 let msg_fts_count: i32 =
1164 self.conn
1165 .query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
1166
1167 let session_count: i32 =
1168 self.conn
1169 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1170
1171 let session_fts_count: i32 =
1172 self.conn
1173 .query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
1174
1175 Ok((message_count > 0 && msg_fts_count == 0)
1177 || (session_count > 0 && session_fts_count == 0))
1178 }
1179
1180 pub fn session_count(&self) -> Result<i32> {
1184 let count: i32 = self
1185 .conn
1186 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1187 Ok(count)
1188 }
1189
1190 pub fn message_count(&self) -> Result<i32> {
1192 let count: i32 = self
1193 .conn
1194 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1195 Ok(count)
1196 }
1197
1198 pub fn link_count(&self) -> Result<i32> {
1200 let count: i32 = self
1201 .conn
1202 .query_row("SELECT COUNT(*) FROM session_links", [], |row| row.get(0))?;
1203 Ok(count)
1204 }
1205
1206 pub fn db_path(&self) -> Option<std::path::PathBuf> {
1210 self.conn.path().map(std::path::PathBuf::from)
1211 }
1212
1213 pub fn find_sessions_near_commit_time(
1230 &self,
1231 commit_time: chrono::DateTime<chrono::Utc>,
1232 window_minutes: i64,
1233 working_dir: Option<&str>,
1234 ) -> Result<Vec<Session>> {
1235 let commit_time_str = commit_time.to_rfc3339();
1237
1238 let window = chrono::Duration::minutes(window_minutes);
1240 let window_start = (commit_time - window).to_rfc3339();
1241 let window_end = (commit_time + window).to_rfc3339();
1242
1243 let sql = if working_dir.is_some() {
1244 r#"
1245 SELECT id, tool, tool_version, started_at, ended_at, model,
1246 working_directory, git_branch, source_path, message_count, machine_id
1247 FROM sessions
1248 WHERE working_directory LIKE ?1
1249 AND (
1250 -- Session started before or during the window
1251 (started_at <= ?3)
1252 AND
1253 -- Session ended after or during the window (or is still ongoing)
1254 (ended_at IS NULL OR ended_at >= ?2)
1255 )
1256 ORDER BY
1257 -- Order by how close the session end (or start) is to commit time
1258 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?4))
1259 "#
1260 } else {
1261 r#"
1262 SELECT id, tool, tool_version, started_at, ended_at, model,
1263 working_directory, git_branch, source_path, message_count, machine_id
1264 FROM sessions
1265 WHERE
1266 -- Session started before or during the window
1267 (started_at <= ?2)
1268 AND
1269 -- Session ended after or during the window (or is still ongoing)
1270 (ended_at IS NULL OR ended_at >= ?1)
1271 ORDER BY
1272 -- Order by how close the session end (or start) is to commit time
1273 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?3))
1274 "#
1275 };
1276
1277 let mut stmt = self.conn.prepare(sql)?;
1278
1279 let rows = if let Some(wd) = working_dir {
1280 stmt.query_map(
1281 params![format!("{wd}%"), window_start, window_end, commit_time_str],
1282 Self::row_to_session,
1283 )?
1284 } else {
1285 stmt.query_map(
1286 params![window_start, window_end, commit_time_str],
1287 Self::row_to_session,
1288 )?
1289 };
1290
1291 rows.collect::<Result<Vec<_>, _>>()
1292 .context("Failed to find sessions near commit time")
1293 }
1294
1295 pub fn link_exists(&self, session_id: &Uuid, commit_sha: &str) -> Result<bool> {
1299 let pattern = format!("{commit_sha}%");
1300 let count: i32 = self.conn.query_row(
1301 "SELECT COUNT(*) FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1302 params![session_id.to_string(), pattern],
1303 |row| row.get(0),
1304 )?;
1305 Ok(count > 0)
1306 }
1307
1308 pub fn delete_session(&self, session_id: &Uuid) -> Result<(usize, usize)> {
1319 let session_id_str = session_id.to_string();
1320
1321 self.conn.execute(
1323 "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1324 params![session_id_str],
1325 )?;
1326
1327 let messages_deleted = self.conn.execute(
1329 "DELETE FROM messages WHERE session_id = ?1",
1330 params![session_id_str],
1331 )?;
1332
1333 let links_deleted = self.conn.execute(
1335 "DELETE FROM session_links WHERE session_id = ?1",
1336 params![session_id_str],
1337 )?;
1338
1339 self.conn.execute(
1341 "DELETE FROM annotations WHERE session_id = ?1",
1342 params![session_id_str],
1343 )?;
1344
1345 self.conn.execute(
1347 "DELETE FROM tags WHERE session_id = ?1",
1348 params![session_id_str],
1349 )?;
1350
1351 self.conn.execute(
1353 "DELETE FROM summaries WHERE session_id = ?1",
1354 params![session_id_str],
1355 )?;
1356
1357 self.conn.execute(
1359 "DELETE FROM sessions_fts WHERE session_id = ?1",
1360 params![session_id_str],
1361 )?;
1362
1363 self.conn.execute(
1365 "DELETE FROM sessions WHERE id = ?1",
1366 params![session_id_str],
1367 )?;
1368
1369 Ok((messages_deleted, links_deleted))
1370 }
1371
1372 pub fn insert_annotation(&self, annotation: &Annotation) -> Result<()> {
1378 self.conn.execute(
1379 r#"
1380 INSERT INTO annotations (id, session_id, content, created_at)
1381 VALUES (?1, ?2, ?3, ?4)
1382 "#,
1383 params![
1384 annotation.id.to_string(),
1385 annotation.session_id.to_string(),
1386 annotation.content,
1387 annotation.created_at.to_rfc3339(),
1388 ],
1389 )?;
1390 Ok(())
1391 }
1392
1393 #[allow(dead_code)]
1397 pub fn get_annotations(&self, session_id: &Uuid) -> Result<Vec<Annotation>> {
1398 let mut stmt = self.conn.prepare(
1399 "SELECT id, session_id, content, created_at
1400 FROM annotations
1401 WHERE session_id = ?1
1402 ORDER BY created_at ASC",
1403 )?;
1404
1405 let rows = stmt.query_map(params![session_id.to_string()], |row| {
1406 Ok(Annotation {
1407 id: parse_uuid(&row.get::<_, String>(0)?)?,
1408 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1409 content: row.get(2)?,
1410 created_at: parse_datetime(&row.get::<_, String>(3)?)?,
1411 })
1412 })?;
1413
1414 rows.collect::<Result<Vec<_>, _>>()
1415 .context("Failed to get annotations")
1416 }
1417
1418 #[allow(dead_code)]
1422 pub fn delete_annotation(&self, annotation_id: &Uuid) -> Result<bool> {
1423 let rows_affected = self.conn.execute(
1424 "DELETE FROM annotations WHERE id = ?1",
1425 params![annotation_id.to_string()],
1426 )?;
1427 Ok(rows_affected > 0)
1428 }
1429
1430 #[allow(dead_code)]
1434 pub fn delete_annotations_by_session(&self, session_id: &Uuid) -> Result<usize> {
1435 let rows_affected = self.conn.execute(
1436 "DELETE FROM annotations WHERE session_id = ?1",
1437 params![session_id.to_string()],
1438 )?;
1439 Ok(rows_affected)
1440 }
1441
1442 pub fn insert_tag(&self, tag: &Tag) -> Result<()> {
1449 self.conn.execute(
1450 r#"
1451 INSERT INTO tags (id, session_id, label, created_at)
1452 VALUES (?1, ?2, ?3, ?4)
1453 "#,
1454 params![
1455 tag.id.to_string(),
1456 tag.session_id.to_string(),
1457 tag.label,
1458 tag.created_at.to_rfc3339(),
1459 ],
1460 )?;
1461 Ok(())
1462 }
1463
1464 pub fn get_tags(&self, session_id: &Uuid) -> Result<Vec<Tag>> {
1468 let mut stmt = self.conn.prepare(
1469 "SELECT id, session_id, label, created_at
1470 FROM tags
1471 WHERE session_id = ?1
1472 ORDER BY label ASC",
1473 )?;
1474
1475 let rows = stmt.query_map(params![session_id.to_string()], |row| {
1476 Ok(Tag {
1477 id: parse_uuid(&row.get::<_, String>(0)?)?,
1478 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1479 label: row.get(2)?,
1480 created_at: parse_datetime(&row.get::<_, String>(3)?)?,
1481 })
1482 })?;
1483
1484 rows.collect::<Result<Vec<_>, _>>()
1485 .context("Failed to get tags")
1486 }
1487
1488 pub fn tag_exists(&self, session_id: &Uuid, label: &str) -> Result<bool> {
1490 let count: i32 = self.conn.query_row(
1491 "SELECT COUNT(*) FROM tags WHERE session_id = ?1 AND label = ?2",
1492 params![session_id.to_string(), label],
1493 |row| row.get(0),
1494 )?;
1495 Ok(count > 0)
1496 }
1497
1498 pub fn delete_tag(&self, session_id: &Uuid, label: &str) -> Result<bool> {
1502 let rows_affected = self.conn.execute(
1503 "DELETE FROM tags WHERE session_id = ?1 AND label = ?2",
1504 params![session_id.to_string(), label],
1505 )?;
1506 Ok(rows_affected > 0)
1507 }
1508
1509 #[allow(dead_code)]
1513 pub fn delete_tags_by_session(&self, session_id: &Uuid) -> Result<usize> {
1514 let rows_affected = self.conn.execute(
1515 "DELETE FROM tags WHERE session_id = ?1",
1516 params![session_id.to_string()],
1517 )?;
1518 Ok(rows_affected)
1519 }
1520
1521 pub fn list_sessions_with_tag(&self, label: &str, limit: usize) -> Result<Vec<Session>> {
1525 let mut stmt = self.conn.prepare(
1526 "SELECT s.id, s.tool, s.tool_version, s.started_at, s.ended_at, s.model,
1527 s.working_directory, s.git_branch, s.source_path, s.message_count, s.machine_id
1528 FROM sessions s
1529 INNER JOIN tags t ON s.id = t.session_id
1530 WHERE t.label = ?1
1531 ORDER BY s.started_at DESC
1532 LIMIT ?2",
1533 )?;
1534
1535 let rows = stmt.query_map(params![label, limit], Self::row_to_session)?;
1536
1537 rows.collect::<Result<Vec<_>, _>>()
1538 .context("Failed to list sessions with tag")
1539 }
1540
1541 pub fn insert_summary(&self, summary: &Summary) -> Result<()> {
1548 self.conn.execute(
1549 r#"
1550 INSERT INTO summaries (id, session_id, content, generated_at)
1551 VALUES (?1, ?2, ?3, ?4)
1552 "#,
1553 params![
1554 summary.id.to_string(),
1555 summary.session_id.to_string(),
1556 summary.content,
1557 summary.generated_at.to_rfc3339(),
1558 ],
1559 )?;
1560 Ok(())
1561 }
1562
1563 pub fn get_summary(&self, session_id: &Uuid) -> Result<Option<Summary>> {
1565 self.conn
1566 .query_row(
1567 "SELECT id, session_id, content, generated_at
1568 FROM summaries
1569 WHERE session_id = ?1",
1570 params![session_id.to_string()],
1571 |row| {
1572 Ok(Summary {
1573 id: parse_uuid(&row.get::<_, String>(0)?)?,
1574 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1575 content: row.get(2)?,
1576 generated_at: parse_datetime(&row.get::<_, String>(3)?)?,
1577 })
1578 },
1579 )
1580 .optional()
1581 .context("Failed to get summary")
1582 }
1583
1584 pub fn update_summary(&self, session_id: &Uuid, content: &str) -> Result<bool> {
1589 let now = chrono::Utc::now().to_rfc3339();
1590 let rows_affected = self.conn.execute(
1591 "UPDATE summaries SET content = ?1, generated_at = ?2 WHERE session_id = ?3",
1592 params![content, now, session_id.to_string()],
1593 )?;
1594 Ok(rows_affected > 0)
1595 }
1596
1597 #[allow(dead_code)]
1601 pub fn delete_summary(&self, session_id: &Uuid) -> Result<bool> {
1602 let rows_affected = self.conn.execute(
1603 "DELETE FROM summaries WHERE session_id = ?1",
1604 params![session_id.to_string()],
1605 )?;
1606 Ok(rows_affected > 0)
1607 }
1608
1609 pub fn upsert_machine(&self, machine: &Machine) -> Result<()> {
1616 self.conn.execute(
1617 r#"
1618 INSERT INTO machines (id, name, created_at)
1619 VALUES (?1, ?2, ?3)
1620 ON CONFLICT(id) DO UPDATE SET
1621 name = ?2
1622 "#,
1623 params![machine.id, machine.name, machine.created_at],
1624 )?;
1625 Ok(())
1626 }
1627
1628 #[allow(dead_code)]
1632 pub fn get_machine(&self, id: &str) -> Result<Option<Machine>> {
1633 self.conn
1634 .query_row(
1635 "SELECT id, name, created_at FROM machines WHERE id = ?1",
1636 params![id],
1637 |row| {
1638 Ok(Machine {
1639 id: row.get(0)?,
1640 name: row.get(1)?,
1641 created_at: row.get(2)?,
1642 })
1643 },
1644 )
1645 .optional()
1646 .context("Failed to get machine")
1647 }
1648
1649 #[allow(dead_code)]
1654 pub fn get_machine_name(&self, id: &str) -> Result<String> {
1655 if let Some(machine) = self.get_machine(id)? {
1656 Ok(machine.name)
1657 } else {
1658 if id.len() > 8 {
1660 Ok(id[..8].to_string())
1661 } else {
1662 Ok(id.to_string())
1663 }
1664 }
1665 }
1666
1667 #[allow(dead_code)]
1671 pub fn list_machines(&self) -> Result<Vec<Machine>> {
1672 let mut stmt = self
1673 .conn
1674 .prepare("SELECT id, name, created_at FROM machines ORDER BY created_at ASC")?;
1675
1676 let rows = stmt.query_map([], |row| {
1677 Ok(Machine {
1678 id: row.get(0)?,
1679 name: row.get(1)?,
1680 created_at: row.get(2)?,
1681 })
1682 })?;
1683
1684 rows.collect::<Result<Vec<_>, _>>()
1685 .context("Failed to list machines")
1686 }
1687
1688 pub fn get_most_recent_session_for_directory(
1693 &self,
1694 working_dir: &str,
1695 ) -> Result<Option<Session>> {
1696 self.conn
1697 .query_row(
1698 "SELECT id, tool, tool_version, started_at, ended_at, model,
1699 working_directory, git_branch, source_path, message_count, machine_id
1700 FROM sessions
1701 WHERE working_directory LIKE ?1
1702 ORDER BY started_at DESC
1703 LIMIT 1",
1704 params![format!("{working_dir}%")],
1705 Self::row_to_session,
1706 )
1707 .optional()
1708 .context("Failed to get most recent session for directory")
1709 }
1710
1711 pub fn vacuum(&self) -> Result<()> {
1718 self.conn.execute("VACUUM", [])?;
1719 Ok(())
1720 }
1721
1722 pub fn file_size(&self) -> Result<Option<u64>> {
1726 if let Some(path) = self.db_path() {
1727 let metadata = std::fs::metadata(&path)?;
1728 Ok(Some(metadata.len()))
1729 } else {
1730 Ok(None)
1731 }
1732 }
1733
1734 pub fn delete_sessions_older_than(&self, before: DateTime<Utc>) -> Result<usize> {
1746 let before_str = before.to_rfc3339();
1747
1748 let mut stmt = self
1750 .conn
1751 .prepare("SELECT id FROM sessions WHERE started_at < ?1")?;
1752 let session_ids: Vec<String> = stmt
1753 .query_map(params![before_str], |row| row.get(0))?
1754 .collect::<Result<Vec<_>, _>>()?;
1755
1756 if session_ids.is_empty() {
1757 return Ok(0);
1758 }
1759
1760 let count = session_ids.len();
1761
1762 for session_id_str in &session_ids {
1764 self.conn.execute(
1766 "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1767 params![session_id_str],
1768 )?;
1769
1770 self.conn.execute(
1772 "DELETE FROM messages WHERE session_id = ?1",
1773 params![session_id_str],
1774 )?;
1775
1776 self.conn.execute(
1778 "DELETE FROM session_links WHERE session_id = ?1",
1779 params![session_id_str],
1780 )?;
1781
1782 self.conn.execute(
1784 "DELETE FROM annotations WHERE session_id = ?1",
1785 params![session_id_str],
1786 )?;
1787
1788 self.conn.execute(
1790 "DELETE FROM tags WHERE session_id = ?1",
1791 params![session_id_str],
1792 )?;
1793
1794 self.conn.execute(
1796 "DELETE FROM summaries WHERE session_id = ?1",
1797 params![session_id_str],
1798 )?;
1799
1800 self.conn.execute(
1802 "DELETE FROM sessions_fts WHERE session_id = ?1",
1803 params![session_id_str],
1804 )?;
1805 }
1806
1807 self.conn.execute(
1809 "DELETE FROM sessions WHERE started_at < ?1",
1810 params![before_str],
1811 )?;
1812
1813 Ok(count)
1814 }
1815
1816 pub fn count_sessions_older_than(&self, before: DateTime<Utc>) -> Result<i32> {
1826 let before_str = before.to_rfc3339();
1827 let count: i32 = self.conn.query_row(
1828 "SELECT COUNT(*) FROM sessions WHERE started_at < ?1",
1829 params![before_str],
1830 |row| row.get(0),
1831 )?;
1832 Ok(count)
1833 }
1834
1835 pub fn get_sessions_older_than(&self, before: DateTime<Utc>) -> Result<Vec<Session>> {
1845 let before_str = before.to_rfc3339();
1846 let mut stmt = self.conn.prepare(
1847 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
1848 FROM sessions
1849 WHERE started_at < ?1
1850 ORDER BY started_at ASC",
1851 )?;
1852
1853 let rows = stmt.query_map(params![before_str], Self::row_to_session)?;
1854
1855 rows.collect::<Result<Vec<_>, _>>()
1856 .context("Failed to get sessions older than cutoff")
1857 }
1858
1859 pub fn stats(&self) -> Result<DatabaseStats> {
1866 let session_count = self.session_count()?;
1867 let message_count = self.message_count()?;
1868 let link_count = self.link_count()?;
1869
1870 let oldest: Option<String> = self
1872 .conn
1873 .query_row("SELECT MIN(started_at) FROM sessions", [], |row| row.get(0))
1874 .optional()?
1875 .flatten();
1876
1877 let newest: Option<String> = self
1878 .conn
1879 .query_row("SELECT MAX(started_at) FROM sessions", [], |row| row.get(0))
1880 .optional()?
1881 .flatten();
1882
1883 let oldest_session = oldest
1884 .map(|s| parse_datetime(&s))
1885 .transpose()
1886 .unwrap_or(None);
1887 let newest_session = newest
1888 .map(|s| parse_datetime(&s))
1889 .transpose()
1890 .unwrap_or(None);
1891
1892 let mut stmt = self
1894 .conn
1895 .prepare("SELECT tool, COUNT(*) FROM sessions GROUP BY tool ORDER BY COUNT(*) DESC")?;
1896 let sessions_by_tool: Vec<(String, i32)> = stmt
1897 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1898 .collect::<Result<Vec<_>, _>>()?;
1899
1900 Ok(DatabaseStats {
1901 session_count,
1902 message_count,
1903 link_count,
1904 oldest_session,
1905 newest_session,
1906 sessions_by_tool,
1907 })
1908 }
1909}
1910
1911#[derive(Debug, Clone)]
1913pub struct DatabaseStats {
1914 pub session_count: i32,
1916 pub message_count: i32,
1918 pub link_count: i32,
1920 pub oldest_session: Option<DateTime<Utc>>,
1922 pub newest_session: Option<DateTime<Utc>>,
1924 pub sessions_by_tool: Vec<(String, i32)>,
1926}
1927
1928#[cfg(test)]
1929mod tests {
1930 use super::*;
1931 use crate::storage::models::{
1932 LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
1933 };
1934 use chrono::{Duration, Utc};
1935 use tempfile::tempdir;
1936
1937 fn create_test_db() -> (Database, tempfile::TempDir) {
1940 let dir = tempdir().expect("Failed to create temp directory");
1941 let db_path = dir.path().join("test.db");
1942 let db = Database::open(&db_path).expect("Failed to open test database");
1943 (db, dir)
1944 }
1945
1946 fn create_test_session(
1948 tool: &str,
1949 working_directory: &str,
1950 started_at: chrono::DateTime<Utc>,
1951 source_path: Option<&str>,
1952 ) -> Session {
1953 Session {
1954 id: Uuid::new_v4(),
1955 tool: tool.to_string(),
1956 tool_version: Some("1.0.0".to_string()),
1957 started_at,
1958 ended_at: None,
1959 model: Some("test-model".to_string()),
1960 working_directory: working_directory.to_string(),
1961 git_branch: Some("main".to_string()),
1962 source_path: source_path.map(|s| s.to_string()),
1963 message_count: 0,
1964 machine_id: Some("test-machine".to_string()),
1965 }
1966 }
1967
1968 fn create_test_message(
1970 session_id: Uuid,
1971 index: i32,
1972 role: MessageRole,
1973 content: &str,
1974 ) -> Message {
1975 Message {
1976 id: Uuid::new_v4(),
1977 session_id,
1978 parent_id: None,
1979 index,
1980 timestamp: Utc::now(),
1981 role,
1982 content: MessageContent::Text(content.to_string()),
1983 model: Some("test-model".to_string()),
1984 git_branch: Some("main".to_string()),
1985 cwd: Some("/test/cwd".to_string()),
1986 }
1987 }
1988
1989 fn create_test_link(
1991 session_id: Uuid,
1992 commit_sha: Option<&str>,
1993 link_type: LinkType,
1994 ) -> SessionLink {
1995 SessionLink {
1996 id: Uuid::new_v4(),
1997 session_id,
1998 link_type,
1999 commit_sha: commit_sha.map(|s| s.to_string()),
2000 branch: Some("main".to_string()),
2001 remote: Some("origin".to_string()),
2002 created_at: Utc::now(),
2003 created_by: LinkCreator::Auto,
2004 confidence: Some(0.95),
2005 }
2006 }
2007
2008 #[test]
2011 fn test_insert_and_get_session() {
2012 let (db, _dir) = create_test_db();
2013 let session = create_test_session(
2014 "claude-code",
2015 "/home/user/project",
2016 Utc::now(),
2017 Some("/path/to/source.jsonl"),
2018 );
2019
2020 db.insert_session(&session)
2021 .expect("Failed to insert session");
2022
2023 let retrieved = db
2024 .get_session(&session.id)
2025 .expect("Failed to get session")
2026 .expect("Session should exist");
2027
2028 assert_eq!(retrieved.id, session.id, "Session ID should match");
2029 assert_eq!(retrieved.tool, session.tool, "Tool should match");
2030 assert_eq!(
2031 retrieved.tool_version, session.tool_version,
2032 "Tool version should match"
2033 );
2034 assert_eq!(
2035 retrieved.working_directory, session.working_directory,
2036 "Working directory should match"
2037 );
2038 assert_eq!(
2039 retrieved.git_branch, session.git_branch,
2040 "Git branch should match"
2041 );
2042 assert_eq!(
2043 retrieved.source_path, session.source_path,
2044 "Source path should match"
2045 );
2046 }
2047
2048 #[test]
2049 fn test_list_sessions() {
2050 let (db, _dir) = create_test_db();
2051 let now = Utc::now();
2052
2053 let session1 =
2055 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
2056 let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
2057 let session3 = create_test_session("claude-code", "/project3", now, None);
2058
2059 db.insert_session(&session1)
2060 .expect("Failed to insert session1");
2061 db.insert_session(&session2)
2062 .expect("Failed to insert session2");
2063 db.insert_session(&session3)
2064 .expect("Failed to insert session3");
2065
2066 let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
2067
2068 assert_eq!(sessions.len(), 3, "Should have 3 sessions");
2069 assert_eq!(
2071 sessions[0].id, session3.id,
2072 "Most recent session should be first"
2073 );
2074 assert_eq!(
2075 sessions[1].id, session2.id,
2076 "Second most recent session should be second"
2077 );
2078 assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
2079 }
2080
2081 #[test]
2082 fn test_list_sessions_with_working_dir_filter() {
2083 let (db, _dir) = create_test_db();
2084 let now = Utc::now();
2085
2086 let session1 = create_test_session(
2087 "claude-code",
2088 "/home/user/project-a",
2089 now - Duration::hours(1),
2090 None,
2091 );
2092 let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
2093 let session3 = create_test_session("claude-code", "/other/path", now, None);
2094
2095 db.insert_session(&session1)
2096 .expect("Failed to insert session1");
2097 db.insert_session(&session2)
2098 .expect("Failed to insert session2");
2099 db.insert_session(&session3)
2100 .expect("Failed to insert session3");
2101
2102 let sessions = db
2104 .list_sessions(10, Some("/home/user"))
2105 .expect("Failed to list sessions");
2106
2107 assert_eq!(
2108 sessions.len(),
2109 2,
2110 "Should have 2 sessions matching /home/user prefix"
2111 );
2112
2113 let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
2115 assert!(ids.contains(&session1.id), "Should contain session1");
2116 assert!(ids.contains(&session2.id), "Should contain session2");
2117 assert!(!ids.contains(&session3.id), "Should not contain session3");
2118 }
2119
2120 #[test]
2121 fn test_session_exists_by_source() {
2122 let (db, _dir) = create_test_db();
2123 let source_path = "/path/to/session.jsonl";
2124
2125 let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
2126
2127 assert!(
2129 !db.session_exists_by_source(source_path)
2130 .expect("Failed to check existence"),
2131 "Session should not exist before insert"
2132 );
2133
2134 db.insert_session(&session)
2135 .expect("Failed to insert session");
2136
2137 assert!(
2139 db.session_exists_by_source(source_path)
2140 .expect("Failed to check existence"),
2141 "Session should exist after insert"
2142 );
2143
2144 assert!(
2146 !db.session_exists_by_source("/other/path.jsonl")
2147 .expect("Failed to check existence"),
2148 "Different source path should not exist"
2149 );
2150 }
2151
2152 #[test]
2153 fn test_update_session_branch() {
2154 let (db, _dir) = create_test_db();
2155 let now = Utc::now();
2156
2157 let mut session = create_test_session("claude-code", "/project", now, None);
2159 session.git_branch = Some("main".to_string());
2160
2161 db.insert_session(&session)
2162 .expect("Failed to insert session");
2163
2164 let fetched = db
2166 .get_session(&session.id)
2167 .expect("Failed to get session")
2168 .expect("Session should exist");
2169 assert_eq!(fetched.git_branch, Some("main".to_string()));
2170
2171 let rows = db
2173 .update_session_branch(session.id, "feature-branch")
2174 .expect("Failed to update branch");
2175 assert_eq!(rows, 1, "Should update exactly one row");
2176
2177 let fetched = db
2179 .get_session(&session.id)
2180 .expect("Failed to get session")
2181 .expect("Session should exist");
2182 assert_eq!(fetched.git_branch, Some("feature-branch".to_string()));
2183 }
2184
2185 #[test]
2186 fn test_update_session_branch_nonexistent() {
2187 let (db, _dir) = create_test_db();
2188 let nonexistent_id = Uuid::new_v4();
2189
2190 let rows = db
2192 .update_session_branch(nonexistent_id, "some-branch")
2193 .expect("Failed to update branch");
2194 assert_eq!(
2195 rows, 0,
2196 "Should not update any rows for nonexistent session"
2197 );
2198 }
2199
2200 #[test]
2201 fn test_update_session_branch_from_none() {
2202 let (db, _dir) = create_test_db();
2203 let now = Utc::now();
2204
2205 let mut session = create_test_session("claude-code", "/project", now, None);
2207 session.git_branch = None; db.insert_session(&session)
2210 .expect("Failed to insert session");
2211
2212 let fetched = db
2214 .get_session(&session.id)
2215 .expect("Failed to get session")
2216 .expect("Session should exist");
2217 assert_eq!(fetched.git_branch, None);
2218
2219 let rows = db
2221 .update_session_branch(session.id, "new-branch")
2222 .expect("Failed to update branch");
2223 assert_eq!(rows, 1, "Should update exactly one row");
2224
2225 let fetched = db
2227 .get_session(&session.id)
2228 .expect("Failed to get session")
2229 .expect("Session should exist");
2230 assert_eq!(fetched.git_branch, Some("new-branch".to_string()));
2231 }
2232
2233 #[test]
2234 fn test_get_nonexistent_session() {
2235 let (db, _dir) = create_test_db();
2236 let nonexistent_id = Uuid::new_v4();
2237
2238 let result = db
2239 .get_session(&nonexistent_id)
2240 .expect("Failed to query for nonexistent session");
2241
2242 assert!(
2243 result.is_none(),
2244 "Should return None for nonexistent session"
2245 );
2246 }
2247
2248 #[test]
2251 fn test_insert_and_get_messages() {
2252 let (db, _dir) = create_test_db();
2253
2254 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2255 db.insert_session(&session)
2256 .expect("Failed to insert session");
2257
2258 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
2259 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
2260
2261 db.insert_message(&msg1)
2262 .expect("Failed to insert message 1");
2263 db.insert_message(&msg2)
2264 .expect("Failed to insert message 2");
2265
2266 let messages = db
2267 .get_messages(&session.id)
2268 .expect("Failed to get messages");
2269
2270 assert_eq!(messages.len(), 2, "Should have 2 messages");
2271 assert_eq!(messages[0].id, msg1.id, "First message ID should match");
2272 assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
2273 assert_eq!(
2274 messages[0].role,
2275 MessageRole::User,
2276 "First message role should be User"
2277 );
2278 assert_eq!(
2279 messages[1].role,
2280 MessageRole::Assistant,
2281 "Second message role should be Assistant"
2282 );
2283 }
2284
2285 #[test]
2286 fn test_messages_ordered_by_index() {
2287 let (db, _dir) = create_test_db();
2288
2289 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2290 db.insert_session(&session)
2291 .expect("Failed to insert session");
2292
2293 let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
2295 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
2296 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
2297
2298 db.insert_message(&msg3)
2299 .expect("Failed to insert message 3");
2300 db.insert_message(&msg1)
2301 .expect("Failed to insert message 1");
2302 db.insert_message(&msg2)
2303 .expect("Failed to insert message 2");
2304
2305 let messages = db
2306 .get_messages(&session.id)
2307 .expect("Failed to get messages");
2308
2309 assert_eq!(messages.len(), 3, "Should have 3 messages");
2310 assert_eq!(messages[0].index, 0, "First message should have index 0");
2311 assert_eq!(messages[1].index, 1, "Second message should have index 1");
2312 assert_eq!(messages[2].index, 2, "Third message should have index 2");
2313
2314 assert_eq!(
2316 messages[0].content.text(),
2317 "First",
2318 "First message content should be 'First'"
2319 );
2320 assert_eq!(
2321 messages[1].content.text(),
2322 "Second",
2323 "Second message content should be 'Second'"
2324 );
2325 assert_eq!(
2326 messages[2].content.text(),
2327 "Third",
2328 "Third message content should be 'Third'"
2329 );
2330 }
2331
2332 #[test]
2335 fn test_insert_and_get_links_by_session() {
2336 let (db, _dir) = create_test_db();
2337
2338 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2339 db.insert_session(&session)
2340 .expect("Failed to insert session");
2341
2342 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2343 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2344
2345 db.insert_link(&link1).expect("Failed to insert link 1");
2346 db.insert_link(&link2).expect("Failed to insert link 2");
2347
2348 let links = db
2349 .get_links_by_session(&session.id)
2350 .expect("Failed to get links");
2351
2352 assert_eq!(links.len(), 2, "Should have 2 links");
2353
2354 let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
2355 assert!(link_ids.contains(&link1.id), "Should contain link1");
2356 assert!(link_ids.contains(&link2.id), "Should contain link2");
2357
2358 let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
2360 assert_eq!(
2361 retrieved_link.commit_sha,
2362 Some("abc123def456".to_string()),
2363 "Commit SHA should match"
2364 );
2365 assert_eq!(
2366 retrieved_link.link_type,
2367 LinkType::Commit,
2368 "Link type should be Commit"
2369 );
2370 assert_eq!(
2371 retrieved_link.created_by,
2372 LinkCreator::Auto,
2373 "Created by should be Auto"
2374 );
2375 }
2376
2377 #[test]
2378 fn test_get_links_by_commit() {
2379 let (db, _dir) = create_test_db();
2380
2381 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2382 db.insert_session(&session)
2383 .expect("Failed to insert session");
2384
2385 let full_sha = "abc123def456789012345678901234567890abcd";
2386 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
2387 db.insert_link(&link).expect("Failed to insert link");
2388
2389 let links_full = db
2391 .get_links_by_commit(full_sha)
2392 .expect("Failed to get links by full SHA");
2393 assert_eq!(links_full.len(), 1, "Should find link by full SHA");
2394 assert_eq!(links_full[0].id, link.id, "Link ID should match");
2395
2396 let links_partial = db
2398 .get_links_by_commit("abc123")
2399 .expect("Failed to get links by partial SHA");
2400 assert_eq!(
2401 links_partial.len(),
2402 1,
2403 "Should find link by partial SHA prefix"
2404 );
2405 assert_eq!(links_partial[0].id, link.id, "Link ID should match");
2406
2407 let links_none = db
2409 .get_links_by_commit("zzz999")
2410 .expect("Failed to get links by non-matching SHA");
2411 assert_eq!(
2412 links_none.len(),
2413 0,
2414 "Should not find link with non-matching SHA"
2415 );
2416 }
2417
2418 #[test]
2421 fn test_database_creation() {
2422 let dir = tempdir().expect("Failed to create temp directory");
2423 let db_path = dir.path().join("new_test.db");
2424
2425 assert!(
2427 !db_path.exists(),
2428 "Database file should not exist before creation"
2429 );
2430
2431 let db = Database::open(&db_path).expect("Failed to create database");
2432
2433 assert!(
2435 db_path.exists(),
2436 "Database file should exist after creation"
2437 );
2438
2439 let session_count = db.session_count().expect("Failed to get session count");
2441 assert_eq!(session_count, 0, "New database should have 0 sessions");
2442
2443 let message_count = db.message_count().expect("Failed to get message count");
2444 assert_eq!(message_count, 0, "New database should have 0 messages");
2445 }
2446
2447 #[test]
2448 fn test_session_count() {
2449 let (db, _dir) = create_test_db();
2450
2451 assert_eq!(
2452 db.session_count().expect("Failed to get count"),
2453 0,
2454 "Initial session count should be 0"
2455 );
2456
2457 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2458 db.insert_session(&session1)
2459 .expect("Failed to insert session1");
2460
2461 assert_eq!(
2462 db.session_count().expect("Failed to get count"),
2463 1,
2464 "Session count should be 1 after first insert"
2465 );
2466
2467 let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
2468 db.insert_session(&session2)
2469 .expect("Failed to insert session2");
2470
2471 assert_eq!(
2472 db.session_count().expect("Failed to get count"),
2473 2,
2474 "Session count should be 2 after second insert"
2475 );
2476 }
2477
2478 #[test]
2479 fn test_message_count() {
2480 let (db, _dir) = create_test_db();
2481
2482 assert_eq!(
2483 db.message_count().expect("Failed to get count"),
2484 0,
2485 "Initial message count should be 0"
2486 );
2487
2488 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2489 db.insert_session(&session)
2490 .expect("Failed to insert session");
2491
2492 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
2493 db.insert_message(&msg1).expect("Failed to insert message1");
2494
2495 assert_eq!(
2496 db.message_count().expect("Failed to get count"),
2497 1,
2498 "Message count should be 1 after first insert"
2499 );
2500
2501 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
2502 let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
2503 db.insert_message(&msg2).expect("Failed to insert message2");
2504 db.insert_message(&msg3).expect("Failed to insert message3");
2505
2506 assert_eq!(
2507 db.message_count().expect("Failed to get count"),
2508 3,
2509 "Message count should be 3 after all inserts"
2510 );
2511 }
2512
2513 #[test]
2514 fn test_link_count() {
2515 let (db, _dir) = create_test_db();
2516
2517 assert_eq!(
2518 db.link_count().expect("Failed to get count"),
2519 0,
2520 "Initial link count should be 0"
2521 );
2522
2523 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2524 db.insert_session(&session)
2525 .expect("Failed to insert session");
2526
2527 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2528 db.insert_link(&link1).expect("Failed to insert link1");
2529
2530 assert_eq!(
2531 db.link_count().expect("Failed to get count"),
2532 1,
2533 "Link count should be 1 after first insert"
2534 );
2535
2536 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2537 db.insert_link(&link2).expect("Failed to insert link2");
2538
2539 assert_eq!(
2540 db.link_count().expect("Failed to get count"),
2541 2,
2542 "Link count should be 2 after second insert"
2543 );
2544 }
2545
2546 #[test]
2547 fn test_db_path() {
2548 let dir = tempdir().expect("Failed to create temp directory");
2549 let db_path = dir.path().join("test.db");
2550 let db = Database::open(&db_path).expect("Failed to open test database");
2551
2552 let retrieved_path = db.db_path();
2553 assert!(
2554 retrieved_path.is_some(),
2555 "Database path should be available"
2556 );
2557
2558 let expected = db_path.canonicalize().unwrap_or(db_path);
2560 let actual = retrieved_path.unwrap();
2561 let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
2562
2563 assert_eq!(
2564 actual_canonical, expected,
2565 "Database path should match (after canonicalization)"
2566 );
2567 }
2568
2569 #[test]
2572 fn test_search_messages_basic() {
2573 let (db, _dir) = create_test_db();
2574
2575 let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
2576 db.insert_session(&session)
2577 .expect("Failed to insert session");
2578
2579 let msg1 = create_test_message(
2580 session.id,
2581 0,
2582 MessageRole::User,
2583 "How do I implement error handling in Rust?",
2584 );
2585 let msg2 = create_test_message(
2586 session.id,
2587 1,
2588 MessageRole::Assistant,
2589 "You can use Result types for error handling. The anyhow crate is also helpful.",
2590 );
2591
2592 db.insert_message(&msg1)
2593 .expect("Failed to insert message 1");
2594 db.insert_message(&msg2)
2595 .expect("Failed to insert message 2");
2596
2597 let results = db
2599 .search_messages("error", 10, None, None, None)
2600 .expect("Failed to search");
2601
2602 assert_eq!(
2603 results.len(),
2604 2,
2605 "Should find 2 messages containing 'error'"
2606 );
2607 }
2608
2609 #[test]
2610 fn test_search_messages_no_results() {
2611 let (db, _dir) = create_test_db();
2612
2613 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2614 db.insert_session(&session)
2615 .expect("Failed to insert session");
2616
2617 let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
2618 db.insert_message(&msg).expect("Failed to insert message");
2619
2620 let results = db
2622 .search_messages("nonexistent_term_xyz", 10, None, None, None)
2623 .expect("Failed to search");
2624
2625 assert!(results.is_empty(), "Should find no results");
2626 }
2627
2628 #[test]
2629 fn test_search_messages_with_role_filter() {
2630 let (db, _dir) = create_test_db();
2631
2632 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2633 db.insert_session(&session)
2634 .expect("Failed to insert session");
2635
2636 let msg1 = create_test_message(
2637 session.id,
2638 0,
2639 MessageRole::User,
2640 "Tell me about Rust programming",
2641 );
2642 let msg2 = create_test_message(
2643 session.id,
2644 1,
2645 MessageRole::Assistant,
2646 "Rust is a systems programming language",
2647 );
2648
2649 db.insert_message(&msg1)
2650 .expect("Failed to insert message 1");
2651 db.insert_message(&msg2)
2652 .expect("Failed to insert message 2");
2653
2654 let user_results = db
2656 .search_messages("programming", 10, None, None, Some("user"))
2657 .expect("Failed to search");
2658
2659 assert_eq!(user_results.len(), 1, "Should find 1 user message");
2660 assert_eq!(
2661 user_results[0].role,
2662 MessageRole::User,
2663 "Result should be from user"
2664 );
2665
2666 let assistant_results = db
2668 .search_messages("programming", 10, None, None, Some("assistant"))
2669 .expect("Failed to search");
2670
2671 assert_eq!(
2672 assistant_results.len(),
2673 1,
2674 "Should find 1 assistant message"
2675 );
2676 assert_eq!(
2677 assistant_results[0].role,
2678 MessageRole::Assistant,
2679 "Result should be from assistant"
2680 );
2681 }
2682
2683 #[test]
2684 fn test_search_messages_with_repo_filter() {
2685 let (db, _dir) = create_test_db();
2686
2687 let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
2688 let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
2689
2690 db.insert_session(&session1).expect("insert 1");
2691 db.insert_session(&session2).expect("insert 2");
2692
2693 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
2694 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
2695
2696 db.insert_message(&msg1).expect("insert msg 1");
2697 db.insert_message(&msg2).expect("insert msg 2");
2698
2699 let results = db
2701 .search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
2702 .expect("Failed to search");
2703
2704 assert_eq!(results.len(), 1, "Should find 1 message in project-a");
2705 assert!(
2706 results[0].working_directory.contains("project-a"),
2707 "Should be from project-a"
2708 );
2709 }
2710
2711 #[test]
2712 fn test_search_messages_limit() {
2713 let (db, _dir) = create_test_db();
2714
2715 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2716 db.insert_session(&session).expect("insert session");
2717
2718 for i in 0..5 {
2720 let msg = create_test_message(
2721 session.id,
2722 i,
2723 MessageRole::User,
2724 &format!("This is test message number {i}"),
2725 );
2726 db.insert_message(&msg).expect("insert message");
2727 }
2728
2729 let results = db
2731 .search_messages("test", 3, None, None, None)
2732 .expect("Failed to search");
2733
2734 assert_eq!(results.len(), 3, "Should respect limit of 3");
2735 }
2736
2737 #[test]
2738 fn test_search_index_needs_rebuild_empty_db() {
2739 let (db, _dir) = create_test_db();
2740
2741 let needs_rebuild = db
2742 .search_index_needs_rebuild()
2743 .expect("Failed to check rebuild status");
2744
2745 assert!(!needs_rebuild, "Empty database should not need rebuild");
2746 }
2747
2748 #[test]
2749 fn test_rebuild_search_index() {
2750 let (db, _dir) = create_test_db();
2751
2752 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2753 db.insert_session(&session).expect("insert session");
2754
2755 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
2756 let msg2 = create_test_message(
2757 session.id,
2758 1,
2759 MessageRole::Assistant,
2760 "Second test response",
2761 );
2762
2763 db.insert_message(&msg1).expect("insert msg 1");
2764 db.insert_message(&msg2).expect("insert msg 2");
2765
2766 db.conn
2768 .execute("DELETE FROM messages_fts", [])
2769 .expect("clear fts");
2770
2771 assert!(
2773 db.search_index_needs_rebuild().expect("check rebuild"),
2774 "Should need rebuild after clearing FTS"
2775 );
2776
2777 let count = db.rebuild_search_index().expect("rebuild");
2779 assert_eq!(count, 2, "Should have indexed 2 messages");
2780
2781 assert!(
2783 !db.search_index_needs_rebuild().expect("check rebuild"),
2784 "Should not need rebuild after rebuilding"
2785 );
2786
2787 let results = db
2789 .search_messages("test", 10, None, None, None)
2790 .expect("search");
2791 assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
2792 }
2793
2794 #[test]
2795 fn test_search_with_block_content() {
2796 let (db, _dir) = create_test_db();
2797
2798 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2799 db.insert_session(&session).expect("insert session");
2800
2801 let block_content = MessageContent::Blocks(vec![
2803 crate::storage::models::ContentBlock::Text {
2804 text: "Let me help with your database query.".to_string(),
2805 },
2806 crate::storage::models::ContentBlock::ToolUse {
2807 id: "tool_123".to_string(),
2808 name: "Bash".to_string(),
2809 input: serde_json::json!({"command": "ls -la"}),
2810 },
2811 ]);
2812
2813 let msg = Message {
2814 id: Uuid::new_v4(),
2815 session_id: session.id,
2816 parent_id: None,
2817 index: 0,
2818 timestamp: Utc::now(),
2819 role: MessageRole::Assistant,
2820 content: block_content,
2821 model: Some("claude-opus-4".to_string()),
2822 git_branch: Some("main".to_string()),
2823 cwd: Some("/project".to_string()),
2824 };
2825
2826 db.insert_message(&msg).expect("insert message");
2827
2828 let results = db
2830 .search_messages("database", 10, None, None, None)
2831 .expect("search");
2832
2833 assert_eq!(results.len(), 1, "Should find message with block content");
2834 }
2835
2836 #[test]
2837 fn test_search_result_contains_session_info() {
2838 let (db, _dir) = create_test_db();
2839
2840 let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
2841 db.insert_session(&session).expect("insert session");
2842
2843 let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
2844 db.insert_message(&msg).expect("insert message");
2845
2846 let results = db
2847 .search_messages("Search", 10, None, None, None)
2848 .expect("search");
2849
2850 assert_eq!(results.len(), 1, "Should find 1 result");
2851 assert_eq!(results[0].session_id, session.id, "Session ID should match");
2852 assert_eq!(results[0].message_id, msg.id, "Message ID should match");
2853 assert_eq!(
2854 results[0].working_directory, "/home/user/my-project",
2855 "Working directory should match"
2856 );
2857 assert_eq!(results[0].role, MessageRole::User, "Role should match");
2858 }
2859
2860 #[test]
2863 fn test_delete_link_by_id() {
2864 let (db, _dir) = create_test_db();
2865
2866 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2867 db.insert_session(&session)
2868 .expect("Failed to insert session");
2869
2870 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2871 db.insert_link(&link).expect("Failed to insert link");
2872
2873 let links_before = db
2875 .get_links_by_session(&session.id)
2876 .expect("Failed to get links");
2877 assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
2878
2879 let deleted = db.delete_link(&link.id).expect("Failed to delete link");
2881 assert!(deleted, "Should return true when link is deleted");
2882
2883 let links_after = db
2885 .get_links_by_session(&session.id)
2886 .expect("Failed to get links");
2887 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
2888 }
2889
2890 #[test]
2891 fn test_delete_link_nonexistent() {
2892 let (db, _dir) = create_test_db();
2893
2894 let nonexistent_id = Uuid::new_v4();
2895 let deleted = db
2896 .delete_link(&nonexistent_id)
2897 .expect("Failed to call delete_link");
2898
2899 assert!(!deleted, "Should return false for nonexistent link");
2900 }
2901
2902 #[test]
2903 fn test_delete_links_by_session() {
2904 let (db, _dir) = create_test_db();
2905
2906 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2907 db.insert_session(&session)
2908 .expect("Failed to insert session");
2909
2910 let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
2912 let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
2913 let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
2914
2915 db.insert_link(&link1).expect("Failed to insert link1");
2916 db.insert_link(&link2).expect("Failed to insert link2");
2917 db.insert_link(&link3).expect("Failed to insert link3");
2918
2919 let links_before = db
2921 .get_links_by_session(&session.id)
2922 .expect("Failed to get links");
2923 assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
2924
2925 let count = db
2927 .delete_links_by_session(&session.id)
2928 .expect("Failed to delete links");
2929 assert_eq!(count, 3, "Should have deleted 3 links");
2930
2931 let links_after = db
2933 .get_links_by_session(&session.id)
2934 .expect("Failed to get links");
2935 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
2936 }
2937
2938 #[test]
2939 fn test_delete_links_by_session_no_links() {
2940 let (db, _dir) = create_test_db();
2941
2942 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2943 db.insert_session(&session)
2944 .expect("Failed to insert session");
2945
2946 let count = db
2948 .delete_links_by_session(&session.id)
2949 .expect("Failed to call delete_links_by_session");
2950 assert_eq!(count, 0, "Should return 0 when no links exist");
2951 }
2952
2953 #[test]
2954 fn test_delete_links_by_session_preserves_other_sessions() {
2955 let (db, _dir) = create_test_db();
2956
2957 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2958 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2959
2960 db.insert_session(&session1)
2961 .expect("Failed to insert session1");
2962 db.insert_session(&session2)
2963 .expect("Failed to insert session2");
2964
2965 let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2966 let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
2967
2968 db.insert_link(&link1).expect("Failed to insert link1");
2969 db.insert_link(&link2).expect("Failed to insert link2");
2970
2971 let count = db
2973 .delete_links_by_session(&session1.id)
2974 .expect("Failed to delete links");
2975 assert_eq!(count, 1, "Should have deleted 1 link");
2976
2977 let session2_links = db
2979 .get_links_by_session(&session2.id)
2980 .expect("Failed to get links");
2981 assert_eq!(
2982 session2_links.len(),
2983 1,
2984 "Session2's link should be preserved"
2985 );
2986 assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
2987 }
2988
2989 #[test]
2990 fn test_delete_link_by_session_and_commit() {
2991 let (db, _dir) = create_test_db();
2992
2993 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2994 db.insert_session(&session)
2995 .expect("Failed to insert session");
2996
2997 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2998 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2999
3000 db.insert_link(&link1).expect("Failed to insert link1");
3001 db.insert_link(&link2).expect("Failed to insert link2");
3002
3003 let deleted = db
3005 .delete_link_by_session_and_commit(&session.id, "abc123")
3006 .expect("Failed to delete link");
3007 assert!(deleted, "Should return true when link is deleted");
3008
3009 let links = db
3011 .get_links_by_session(&session.id)
3012 .expect("Failed to get links");
3013 assert_eq!(links.len(), 1, "Should have 1 link remaining");
3014 assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
3015 }
3016
3017 #[test]
3018 fn test_delete_link_by_session_and_commit_full_sha() {
3019 let (db, _dir) = create_test_db();
3020
3021 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3022 db.insert_session(&session)
3023 .expect("Failed to insert session");
3024
3025 let full_sha = "abc123def456789012345678901234567890abcd";
3026 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
3027 db.insert_link(&link).expect("Failed to insert link");
3028
3029 let deleted = db
3031 .delete_link_by_session_and_commit(&session.id, full_sha)
3032 .expect("Failed to delete link");
3033 assert!(deleted, "Should delete with full SHA");
3034
3035 let links = db
3036 .get_links_by_session(&session.id)
3037 .expect("Failed to get links");
3038 assert_eq!(links.len(), 0, "Should have 0 links after delete");
3039 }
3040
3041 #[test]
3042 fn test_delete_link_by_session_and_commit_no_match() {
3043 let (db, _dir) = create_test_db();
3044
3045 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3046 db.insert_session(&session)
3047 .expect("Failed to insert session");
3048
3049 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3050 db.insert_link(&link).expect("Failed to insert link");
3051
3052 let deleted = db
3054 .delete_link_by_session_and_commit(&session.id, "xyz999")
3055 .expect("Failed to call delete");
3056 assert!(!deleted, "Should return false when no match");
3057
3058 let links = db
3060 .get_links_by_session(&session.id)
3061 .expect("Failed to get links");
3062 assert_eq!(links.len(), 1, "Link should be preserved");
3063 }
3064
3065 #[test]
3066 fn test_delete_link_by_session_and_commit_wrong_session() {
3067 let (db, _dir) = create_test_db();
3068
3069 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3070 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3071
3072 db.insert_session(&session1)
3073 .expect("Failed to insert session1");
3074 db.insert_session(&session2)
3075 .expect("Failed to insert session2");
3076
3077 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3078 db.insert_link(&link).expect("Failed to insert link");
3079
3080 let deleted = db
3082 .delete_link_by_session_and_commit(&session2.id, "abc123")
3083 .expect("Failed to call delete");
3084 assert!(!deleted, "Should not delete link from different session");
3085
3086 let links = db
3088 .get_links_by_session(&session1.id)
3089 .expect("Failed to get links");
3090 assert_eq!(links.len(), 1, "Link should be preserved");
3091 }
3092
3093 #[test]
3096 fn test_find_sessions_near_commit_time_basic() {
3097 let (db, _dir) = create_test_db();
3098 let now = Utc::now();
3099
3100 let mut session = create_test_session(
3102 "claude-code",
3103 "/home/user/project",
3104 now - Duration::hours(1),
3105 None,
3106 );
3107 session.ended_at = Some(now - Duration::minutes(10));
3108
3109 db.insert_session(&session).expect("insert session");
3110
3111 let found = db
3113 .find_sessions_near_commit_time(now, 30, None)
3114 .expect("find sessions");
3115
3116 assert_eq!(found.len(), 1, "Should find session within window");
3117 assert_eq!(found[0].id, session.id);
3118 }
3119
3120 #[test]
3121 fn test_find_sessions_near_commit_time_outside_window() {
3122 let (db, _dir) = create_test_db();
3123 let now = Utc::now();
3124
3125 let mut session =
3127 create_test_session("claude-code", "/project", now - Duration::hours(3), None);
3128 session.ended_at = Some(now - Duration::hours(2));
3129
3130 db.insert_session(&session).expect("insert session");
3131
3132 let found = db
3134 .find_sessions_near_commit_time(now, 30, None)
3135 .expect("find sessions");
3136
3137 assert!(found.is_empty(), "Should not find session outside window");
3138 }
3139
3140 #[test]
3141 fn test_find_sessions_near_commit_time_with_working_dir() {
3142 let (db, _dir) = create_test_db();
3143 let now = Utc::now();
3144
3145 let mut session1 = create_test_session(
3147 "claude-code",
3148 "/home/user/project-a",
3149 now - Duration::minutes(30),
3150 None,
3151 );
3152 session1.ended_at = Some(now - Duration::minutes(5));
3153
3154 let mut session2 = create_test_session(
3155 "claude-code",
3156 "/home/user/project-b",
3157 now - Duration::minutes(30),
3158 None,
3159 );
3160 session2.ended_at = Some(now - Duration::minutes(5));
3161
3162 db.insert_session(&session1).expect("insert session1");
3163 db.insert_session(&session2).expect("insert session2");
3164
3165 let found = db
3167 .find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
3168 .expect("find sessions");
3169
3170 assert_eq!(found.len(), 1, "Should find only session in project-a");
3171 assert_eq!(found[0].id, session1.id);
3172 }
3173
3174 #[test]
3175 fn test_find_sessions_near_commit_time_ongoing_session() {
3176 let (db, _dir) = create_test_db();
3177 let now = Utc::now();
3178
3179 let session =
3181 create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
3182 db.insert_session(&session).expect("insert session");
3185
3186 let found = db
3188 .find_sessions_near_commit_time(now, 30, None)
3189 .expect("find sessions");
3190
3191 assert_eq!(found.len(), 1, "Should find ongoing session");
3192 assert_eq!(found[0].id, session.id);
3193 }
3194
3195 #[test]
3196 fn test_link_exists_true() {
3197 let (db, _dir) = create_test_db();
3198
3199 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3200 db.insert_session(&session).expect("insert session");
3201
3202 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3203 db.insert_link(&link).expect("insert link");
3204
3205 assert!(
3207 db.link_exists(&session.id, "abc123def456")
3208 .expect("check exists"),
3209 "Should find link with full SHA"
3210 );
3211
3212 assert!(
3214 db.link_exists(&session.id, "abc123").expect("check exists"),
3215 "Should find link with partial SHA"
3216 );
3217 }
3218
3219 #[test]
3220 fn test_link_exists_false() {
3221 let (db, _dir) = create_test_db();
3222
3223 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3224 db.insert_session(&session).expect("insert session");
3225
3226 assert!(
3228 !db.link_exists(&session.id, "abc123").expect("check exists"),
3229 "Should not find non-existent link"
3230 );
3231 }
3232
3233 #[test]
3234 fn test_link_exists_different_session() {
3235 let (db, _dir) = create_test_db();
3236
3237 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3238 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3239
3240 db.insert_session(&session1).expect("insert session1");
3241 db.insert_session(&session2).expect("insert session2");
3242
3243 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3244 db.insert_link(&link).expect("insert link");
3245
3246 assert!(
3248 db.link_exists(&session1.id, "abc123").expect("check"),
3249 "Should find link for session1"
3250 );
3251 assert!(
3252 !db.link_exists(&session2.id, "abc123").expect("check"),
3253 "Should not find link for session2"
3254 );
3255 }
3256
3257 #[test]
3260 fn test_search_with_tool_filter() {
3261 let (db, _dir) = create_test_db();
3262
3263 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3264 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
3265
3266 db.insert_session(&session1).expect("insert session1");
3267 db.insert_session(&session2).expect("insert session2");
3268
3269 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
3270 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
3271
3272 db.insert_message(&msg1).expect("insert msg1");
3273 db.insert_message(&msg2).expect("insert msg2");
3274
3275 let options = SearchOptions {
3277 query: "Hello".to_string(),
3278 limit: 10,
3279 tool: Some("claude-code".to_string()),
3280 ..Default::default()
3281 };
3282 let results = db.search_with_options(&options).expect("search");
3283
3284 assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
3285 assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
3286 }
3287
3288 #[test]
3289 fn test_search_with_date_range() {
3290 let (db, _dir) = create_test_db();
3291
3292 let old_time = Utc::now() - chrono::Duration::days(30);
3293 let new_time = Utc::now() - chrono::Duration::days(1);
3294
3295 let session1 = create_test_session("claude-code", "/project1", old_time, None);
3296 let session2 = create_test_session("claude-code", "/project2", new_time, None);
3297
3298 db.insert_session(&session1).expect("insert session1");
3299 db.insert_session(&session2).expect("insert session2");
3300
3301 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
3302 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
3303
3304 db.insert_message(&msg1).expect("insert msg1");
3305 db.insert_message(&msg2).expect("insert msg2");
3306
3307 let since = Utc::now() - chrono::Duration::days(7);
3309 let options = SearchOptions {
3310 query: "session".to_string(),
3311 limit: 10,
3312 since: Some(since),
3313 ..Default::default()
3314 };
3315 let results = db.search_with_options(&options).expect("search");
3316
3317 assert_eq!(results.len(), 1, "Should find 1 result within date range");
3318 assert!(
3319 results[0].working_directory.contains("project2"),
3320 "Should be from newer project"
3321 );
3322 }
3323
3324 #[test]
3325 fn test_search_with_project_filter() {
3326 let (db, _dir) = create_test_db();
3327
3328 let session1 =
3329 create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
3330 let session2 =
3331 create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
3332
3333 db.insert_session(&session1).expect("insert session1");
3334 db.insert_session(&session2).expect("insert session2");
3335
3336 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
3337 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
3338
3339 db.insert_message(&msg1).expect("insert msg1");
3340 db.insert_message(&msg2).expect("insert msg2");
3341
3342 let options = SearchOptions {
3344 query: "Testing".to_string(),
3345 limit: 10,
3346 project: Some("frontend".to_string()),
3347 ..Default::default()
3348 };
3349 let results = db.search_with_options(&options).expect("search");
3350
3351 assert_eq!(results.len(), 1, "Should find 1 result with project filter");
3352 assert!(
3353 results[0].working_directory.contains("frontend"),
3354 "Should be from frontend project"
3355 );
3356 }
3357
3358 #[test]
3359 fn test_search_with_branch_filter() {
3360 let (db, _dir) = create_test_db();
3361
3362 let session1 = Session {
3363 id: Uuid::new_v4(),
3364 tool: "claude-code".to_string(),
3365 tool_version: None,
3366 started_at: Utc::now(),
3367 ended_at: None,
3368 model: None,
3369 working_directory: "/project".to_string(),
3370 git_branch: Some("feat/auth".to_string()),
3371 source_path: None,
3372 message_count: 0,
3373 machine_id: None,
3374 };
3375 let session2 = Session {
3376 id: Uuid::new_v4(),
3377 tool: "claude-code".to_string(),
3378 tool_version: None,
3379 started_at: Utc::now(),
3380 ended_at: None,
3381 model: None,
3382 working_directory: "/project".to_string(),
3383 git_branch: Some("main".to_string()),
3384 source_path: None,
3385 message_count: 0,
3386 machine_id: None,
3387 };
3388
3389 db.insert_session(&session1).expect("insert session1");
3390 db.insert_session(&session2).expect("insert session2");
3391
3392 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
3393 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
3394
3395 db.insert_message(&msg1).expect("insert msg1");
3396 db.insert_message(&msg2).expect("insert msg2");
3397
3398 let options = SearchOptions {
3400 query: "work".to_string(),
3401 limit: 10,
3402 branch: Some("auth".to_string()),
3403 ..Default::default()
3404 };
3405 let results = db.search_with_options(&options).expect("search");
3406
3407 assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
3408 assert_eq!(
3409 results[0].git_branch.as_deref(),
3410 Some("feat/auth"),
3411 "Should be from feat/auth branch"
3412 );
3413 }
3414
3415 #[test]
3416 fn test_search_metadata_matches_project() {
3417 let (db, _dir) = create_test_db();
3418
3419 let session =
3420 create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
3421 db.insert_session(&session).expect("insert session");
3422
3423 let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
3425 db.insert_message(&msg).expect("insert msg");
3426
3427 let options = SearchOptions {
3429 query: "redactyl".to_string(),
3430 limit: 10,
3431 ..Default::default()
3432 };
3433 let results = db.search_with_options(&options).expect("search");
3434
3435 assert_eq!(
3436 results.len(),
3437 1,
3438 "Should find session via metadata match on project name"
3439 );
3440 }
3441
3442 #[test]
3443 fn test_search_returns_extended_session_info() {
3444 let (db, _dir) = create_test_db();
3445
3446 let started_at = Utc::now();
3447 let session = Session {
3448 id: Uuid::new_v4(),
3449 tool: "claude-code".to_string(),
3450 tool_version: Some("1.0.0".to_string()),
3451 started_at,
3452 ended_at: None,
3453 model: None,
3454 working_directory: "/home/user/myapp".to_string(),
3455 git_branch: Some("develop".to_string()),
3456 source_path: None,
3457 message_count: 5,
3458 machine_id: None,
3459 };
3460 db.insert_session(&session).expect("insert session");
3461
3462 let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
3463 db.insert_message(&msg).expect("insert msg");
3464
3465 let options = SearchOptions {
3466 query: "Test".to_string(),
3467 limit: 10,
3468 ..Default::default()
3469 };
3470 let results = db.search_with_options(&options).expect("search");
3471
3472 assert_eq!(results.len(), 1, "Should find 1 result");
3473 let result = &results[0];
3474
3475 assert_eq!(result.tool, "claude-code", "Tool should be populated");
3476 assert_eq!(
3477 result.git_branch.as_deref(),
3478 Some("develop"),
3479 "Branch should be populated"
3480 );
3481 assert!(
3482 result.session_message_count > 0,
3483 "Message count should be populated"
3484 );
3485 assert!(
3486 result.session_started_at.is_some(),
3487 "Session start time should be populated"
3488 );
3489 }
3490
3491 #[test]
3492 fn test_get_context_messages() {
3493 let (db, _dir) = create_test_db();
3494
3495 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3496 db.insert_session(&session).expect("insert session");
3497
3498 for i in 0..5 {
3500 let role = if i % 2 == 0 {
3501 MessageRole::User
3502 } else {
3503 MessageRole::Assistant
3504 };
3505 let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
3506 db.insert_message(&msg).expect("insert message");
3507 }
3508
3509 let (before, after) = db
3511 .get_context_messages(&session.id, 2, 1)
3512 .expect("get context");
3513
3514 assert_eq!(before.len(), 1, "Should have 1 message before");
3515 assert_eq!(after.len(), 1, "Should have 1 message after");
3516 assert_eq!(before[0].index, 1, "Before message should be index 1");
3517 assert_eq!(after[0].index, 3, "After message should be index 3");
3518 }
3519
3520 #[test]
3521 fn test_get_context_messages_at_start() {
3522 let (db, _dir) = create_test_db();
3523
3524 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3525 db.insert_session(&session).expect("insert session");
3526
3527 for i in 0..3 {
3528 let msg =
3529 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
3530 db.insert_message(&msg).expect("insert message");
3531 }
3532
3533 let (before, after) = db
3535 .get_context_messages(&session.id, 0, 2)
3536 .expect("get context");
3537
3538 assert!(
3539 before.is_empty(),
3540 "Should have no messages before first message"
3541 );
3542 assert_eq!(after.len(), 2, "Should have 2 messages after");
3543 }
3544
3545 #[test]
3546 fn test_get_context_messages_at_end() {
3547 let (db, _dir) = create_test_db();
3548
3549 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3550 db.insert_session(&session).expect("insert session");
3551
3552 for i in 0..3 {
3553 let msg =
3554 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
3555 db.insert_message(&msg).expect("insert message");
3556 }
3557
3558 let (before, after) = db
3560 .get_context_messages(&session.id, 2, 2)
3561 .expect("get context");
3562
3563 assert_eq!(before.len(), 2, "Should have 2 messages before");
3564 assert!(
3565 after.is_empty(),
3566 "Should have no messages after last message"
3567 );
3568 }
3569
3570 #[test]
3571 fn test_search_combined_filters() {
3572 let (db, _dir) = create_test_db();
3573
3574 let session1 = Session {
3575 id: Uuid::new_v4(),
3576 tool: "claude-code".to_string(),
3577 tool_version: None,
3578 started_at: Utc::now(),
3579 ended_at: None,
3580 model: None,
3581 working_directory: "/home/user/myapp".to_string(),
3582 git_branch: Some("feat/api".to_string()),
3583 source_path: None,
3584 message_count: 1,
3585 machine_id: None,
3586 };
3587 let session2 = Session {
3588 id: Uuid::new_v4(),
3589 tool: "aider".to_string(),
3590 tool_version: None,
3591 started_at: Utc::now(),
3592 ended_at: None,
3593 model: None,
3594 working_directory: "/home/user/myapp".to_string(),
3595 git_branch: Some("feat/api".to_string()),
3596 source_path: None,
3597 message_count: 1,
3598 machine_id: None,
3599 };
3600
3601 db.insert_session(&session1).expect("insert session1");
3602 db.insert_session(&session2).expect("insert session2");
3603
3604 let msg1 =
3605 create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
3606 let msg2 =
3607 create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
3608
3609 db.insert_message(&msg1).expect("insert msg1");
3610 db.insert_message(&msg2).expect("insert msg2");
3611
3612 let options = SearchOptions {
3614 query: "API".to_string(),
3615 limit: 10,
3616 tool: Some("claude-code".to_string()),
3617 branch: Some("api".to_string()),
3618 project: Some("myapp".to_string()),
3619 ..Default::default()
3620 };
3621 let results = db.search_with_options(&options).expect("search");
3622
3623 assert!(
3625 !results.is_empty(),
3626 "Should find at least 1 result matching all filters"
3627 );
3628 for result in &results {
3630 assert_eq!(
3631 result.tool, "claude-code",
3632 "All results should be from claude-code"
3633 );
3634 }
3635 }
3636
3637 #[test]
3640 fn test_delete_session_removes_all_data() {
3641 let (db, _dir) = create_test_db();
3642
3643 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3644 db.insert_session(&session).expect("insert session");
3645
3646 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
3648 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there");
3649 db.insert_message(&msg1).expect("insert msg1");
3650 db.insert_message(&msg2).expect("insert msg2");
3651
3652 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3654 db.insert_link(&link).expect("insert link");
3655
3656 assert_eq!(db.session_count().expect("count"), 1);
3658 assert_eq!(db.message_count().expect("count"), 2);
3659 assert_eq!(db.link_count().expect("count"), 1);
3660
3661 let (msgs_deleted, links_deleted) = db.delete_session(&session.id).expect("delete");
3663 assert_eq!(msgs_deleted, 2, "Should delete 2 messages");
3664 assert_eq!(links_deleted, 1, "Should delete 1 link");
3665
3666 assert_eq!(db.session_count().expect("count"), 0);
3668 assert_eq!(db.message_count().expect("count"), 0);
3669 assert_eq!(db.link_count().expect("count"), 0);
3670 assert!(db.get_session(&session.id).expect("get").is_none());
3671 }
3672
3673 #[test]
3674 fn test_delete_session_preserves_other_sessions() {
3675 let (db, _dir) = create_test_db();
3676
3677 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3678 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
3679
3680 db.insert_session(&session1).expect("insert session1");
3681 db.insert_session(&session2).expect("insert session2");
3682
3683 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello 1");
3684 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello 2");
3685 db.insert_message(&msg1).expect("insert msg1");
3686 db.insert_message(&msg2).expect("insert msg2");
3687
3688 db.delete_session(&session1.id).expect("delete");
3690
3691 assert_eq!(db.session_count().expect("count"), 1);
3693 assert_eq!(db.message_count().expect("count"), 1);
3694 assert!(db.get_session(&session2.id).expect("get").is_some());
3695 }
3696
3697 #[test]
3700 fn test_file_size() {
3701 let (db, _dir) = create_test_db();
3702
3703 let size = db.file_size().expect("get size");
3704 assert!(size.is_some(), "Should have file size for file-based db");
3705 assert!(size.unwrap() > 0, "Database file should have size > 0");
3706 }
3707
3708 #[test]
3709 fn test_vacuum() {
3710 let (db, _dir) = create_test_db();
3711
3712 db.vacuum().expect("vacuum should succeed");
3714 }
3715
3716 #[test]
3717 fn test_count_sessions_older_than() {
3718 let (db, _dir) = create_test_db();
3719 let now = Utc::now();
3720
3721 let old_session =
3723 create_test_session("claude-code", "/project1", now - Duration::days(100), None);
3724 let recent_session =
3725 create_test_session("claude-code", "/project2", now - Duration::days(10), None);
3726
3727 db.insert_session(&old_session).expect("insert old");
3728 db.insert_session(&recent_session).expect("insert recent");
3729
3730 let cutoff = now - Duration::days(30);
3732 let count = db.count_sessions_older_than(cutoff).expect("count");
3733 assert_eq!(count, 1, "Should find 1 session older than 30 days");
3734
3735 let old_cutoff = now - Duration::days(200);
3737 let old_count = db.count_sessions_older_than(old_cutoff).expect("count");
3738 assert_eq!(old_count, 0, "Should find 0 sessions older than 200 days");
3739 }
3740
3741 #[test]
3742 fn test_delete_sessions_older_than() {
3743 let (db, _dir) = create_test_db();
3744 let now = Utc::now();
3745
3746 let old_session =
3748 create_test_session("claude-code", "/project1", now - Duration::days(100), None);
3749 let recent_session =
3750 create_test_session("claude-code", "/project2", now - Duration::days(10), None);
3751
3752 db.insert_session(&old_session).expect("insert old");
3753 db.insert_session(&recent_session).expect("insert recent");
3754
3755 let msg1 = create_test_message(old_session.id, 0, MessageRole::User, "Old message");
3757 let msg2 = create_test_message(recent_session.id, 0, MessageRole::User, "Recent message");
3758 db.insert_message(&msg1).expect("insert msg1");
3759 db.insert_message(&msg2).expect("insert msg2");
3760
3761 let cutoff = now - Duration::days(30);
3763 let deleted = db.delete_sessions_older_than(cutoff).expect("delete");
3764 assert_eq!(deleted, 1, "Should delete 1 session");
3765
3766 assert_eq!(db.session_count().expect("count"), 1);
3768 assert!(db.get_session(&recent_session.id).expect("get").is_some());
3769 assert!(db.get_session(&old_session.id).expect("get").is_none());
3770
3771 assert_eq!(db.message_count().expect("count"), 1);
3773 }
3774
3775 #[test]
3776 fn test_get_sessions_older_than() {
3777 let (db, _dir) = create_test_db();
3778 let now = Utc::now();
3779
3780 let old_session = create_test_session(
3782 "claude-code",
3783 "/project/old",
3784 now - Duration::days(100),
3785 None,
3786 );
3787 let medium_session =
3788 create_test_session("aider", "/project/medium", now - Duration::days(50), None);
3789 let recent_session =
3790 create_test_session("gemini", "/project/recent", now - Duration::days(10), None);
3791
3792 db.insert_session(&old_session).expect("insert old");
3793 db.insert_session(&medium_session).expect("insert medium");
3794 db.insert_session(&recent_session).expect("insert recent");
3795
3796 let cutoff = now - Duration::days(30);
3798 let sessions = db.get_sessions_older_than(cutoff).expect("get sessions");
3799 assert_eq!(
3800 sessions.len(),
3801 2,
3802 "Should find 2 sessions older than 30 days"
3803 );
3804
3805 assert_eq!(sessions[0].id, old_session.id);
3807 assert_eq!(sessions[1].id, medium_session.id);
3808
3809 assert_eq!(sessions[0].tool, "claude-code");
3811 assert_eq!(sessions[0].working_directory, "/project/old");
3812 assert_eq!(sessions[1].tool, "aider");
3813 assert_eq!(sessions[1].working_directory, "/project/medium");
3814
3815 let old_cutoff = now - Duration::days(200);
3817 let old_sessions = db
3818 .get_sessions_older_than(old_cutoff)
3819 .expect("get old sessions");
3820 assert_eq!(
3821 old_sessions.len(),
3822 0,
3823 "Should find 0 sessions older than 200 days"
3824 );
3825 }
3826
3827 #[test]
3828 fn test_stats() {
3829 let (db, _dir) = create_test_db();
3830 let now = Utc::now();
3831
3832 let empty_stats = db.stats().expect("stats");
3834 assert_eq!(empty_stats.session_count, 0);
3835 assert_eq!(empty_stats.message_count, 0);
3836 assert_eq!(empty_stats.link_count, 0);
3837 assert!(empty_stats.oldest_session.is_none());
3838 assert!(empty_stats.newest_session.is_none());
3839 assert!(empty_stats.sessions_by_tool.is_empty());
3840
3841 let session1 =
3843 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
3844 let session2 = create_test_session("aider", "/project2", now - Duration::hours(1), None);
3845 let session3 = create_test_session("claude-code", "/project3", now, None);
3846
3847 db.insert_session(&session1).expect("insert 1");
3848 db.insert_session(&session2).expect("insert 2");
3849 db.insert_session(&session3).expect("insert 3");
3850
3851 let msg = create_test_message(session1.id, 0, MessageRole::User, "Hello");
3852 db.insert_message(&msg).expect("insert msg");
3853
3854 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3855 db.insert_link(&link).expect("insert link");
3856
3857 let stats = db.stats().expect("stats");
3859 assert_eq!(stats.session_count, 3);
3860 assert_eq!(stats.message_count, 1);
3861 assert_eq!(stats.link_count, 1);
3862 assert!(stats.oldest_session.is_some());
3863 assert!(stats.newest_session.is_some());
3864
3865 assert_eq!(stats.sessions_by_tool.len(), 2);
3867 assert_eq!(stats.sessions_by_tool[0].0, "claude-code");
3869 assert_eq!(stats.sessions_by_tool[0].1, 2);
3870 assert_eq!(stats.sessions_by_tool[1].0, "aider");
3871 assert_eq!(stats.sessions_by_tool[1].1, 1);
3872 }
3873
3874 #[test]
3877 fn test_get_session_branch_history_no_messages() {
3878 let (db, _dir) = create_test_db();
3879 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3880 db.insert_session(&session)
3881 .expect("Failed to insert session");
3882
3883 let branches = db
3884 .get_session_branch_history(session.id)
3885 .expect("Failed to get branch history");
3886
3887 assert!(branches.is_empty(), "Empty session should have no branches");
3888 }
3889
3890 #[test]
3891 fn test_get_session_branch_history_single_branch() {
3892 let (db, _dir) = create_test_db();
3893 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3894 db.insert_session(&session)
3895 .expect("Failed to insert session");
3896
3897 for i in 0..3 {
3899 let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
3900 msg.git_branch = Some("main".to_string());
3901 db.insert_message(&msg).expect("Failed to insert message");
3902 }
3903
3904 let branches = db
3905 .get_session_branch_history(session.id)
3906 .expect("Failed to get branch history");
3907
3908 assert_eq!(branches, vec!["main"], "Should have single branch");
3909 }
3910
3911 #[test]
3912 fn test_get_session_branch_history_multiple_branches() {
3913 let (db, _dir) = create_test_db();
3914 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3915 db.insert_session(&session)
3916 .expect("Failed to insert session");
3917
3918 let branch_sequence = ["main", "main", "feat/auth", "feat/auth", "main"];
3920 for (i, branch) in branch_sequence.iter().enumerate() {
3921 let mut msg = create_test_message(session.id, i as i32, MessageRole::User, "test");
3922 msg.git_branch = Some(branch.to_string());
3923 db.insert_message(&msg).expect("Failed to insert message");
3924 }
3925
3926 let branches = db
3927 .get_session_branch_history(session.id)
3928 .expect("Failed to get branch history");
3929
3930 assert_eq!(
3931 branches,
3932 vec!["main", "feat/auth", "main"],
3933 "Should show branch transitions without consecutive duplicates"
3934 );
3935 }
3936
3937 #[test]
3938 fn test_get_session_branch_history_with_none_branches() {
3939 let (db, _dir) = create_test_db();
3940 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3941 db.insert_session(&session)
3942 .expect("Failed to insert session");
3943
3944 let mut msg1 = create_test_message(session.id, 0, MessageRole::User, "test");
3946 msg1.git_branch = Some("main".to_string());
3947 db.insert_message(&msg1).expect("Failed to insert message");
3948
3949 let mut msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "test");
3950 msg2.git_branch = None; db.insert_message(&msg2).expect("Failed to insert message");
3952
3953 let mut msg3 = create_test_message(session.id, 2, MessageRole::User, "test");
3954 msg3.git_branch = Some("feat/new".to_string());
3955 db.insert_message(&msg3).expect("Failed to insert message");
3956
3957 let branches = db
3958 .get_session_branch_history(session.id)
3959 .expect("Failed to get branch history");
3960
3961 assert_eq!(
3962 branches,
3963 vec!["main", "feat/new"],
3964 "Should skip None branches and show transitions"
3965 );
3966 }
3967
3968 #[test]
3969 fn test_get_session_branch_history_all_none_branches() {
3970 let (db, _dir) = create_test_db();
3971 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3972 db.insert_session(&session)
3973 .expect("Failed to insert session");
3974
3975 for i in 0..3 {
3977 let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
3978 msg.git_branch = None;
3979 db.insert_message(&msg).expect("Failed to insert message");
3980 }
3981
3982 let branches = db
3983 .get_session_branch_history(session.id)
3984 .expect("Failed to get branch history");
3985
3986 assert!(
3987 branches.is_empty(),
3988 "Session with all None branches should return empty"
3989 );
3990 }
3991
3992 #[test]
3995 fn test_session_stores_machine_id() {
3996 let (db, _dir) = create_test_db();
3997 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3998
3999 db.insert_session(&session)
4000 .expect("Failed to insert session");
4001
4002 let retrieved = db
4003 .get_session(&session.id)
4004 .expect("Failed to get session")
4005 .expect("Session should exist");
4006
4007 assert_eq!(
4008 retrieved.machine_id,
4009 Some("test-machine".to_string()),
4010 "Machine ID should be preserved"
4011 );
4012 }
4013
4014 #[test]
4015 fn test_session_with_none_machine_id() {
4016 let (db, _dir) = create_test_db();
4017 let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
4018 session.machine_id = None;
4019
4020 db.insert_session(&session)
4021 .expect("Failed to insert session");
4022
4023 let retrieved = db
4024 .get_session(&session.id)
4025 .expect("Failed to get session")
4026 .expect("Session should exist");
4027
4028 assert!(
4029 retrieved.machine_id.is_none(),
4030 "Session with None machine_id should preserve None"
4031 );
4032 }
4033
4034 #[test]
4035 fn test_migration_adds_machine_id_column() {
4036 let (db, _dir) = create_test_db();
4038
4039 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4041 db.insert_session(&session)
4042 .expect("Should insert session with machine_id column");
4043
4044 let retrieved = db
4046 .get_session(&session.id)
4047 .expect("Failed to get session")
4048 .expect("Session should exist");
4049
4050 assert_eq!(
4051 retrieved.machine_id,
4052 Some("test-machine".to_string()),
4053 "Machine ID should be stored and retrieved"
4054 );
4055 }
4056
4057 #[test]
4058 fn test_list_sessions_includes_machine_id() {
4059 let (db, _dir) = create_test_db();
4060 let now = Utc::now();
4061
4062 let mut session1 = create_test_session("claude-code", "/project1", now, None);
4063 session1.machine_id = Some("machine-a".to_string());
4064
4065 let mut session2 = create_test_session("claude-code", "/project2", now, None);
4066 session2.machine_id = Some("machine-b".to_string());
4067
4068 db.insert_session(&session1).expect("insert");
4069 db.insert_session(&session2).expect("insert");
4070
4071 let sessions = db.list_sessions(10, None).expect("list");
4072
4073 assert_eq!(sessions.len(), 2);
4074 let machine_ids: Vec<Option<String>> =
4075 sessions.iter().map(|s| s.machine_id.clone()).collect();
4076 assert!(machine_ids.contains(&Some("machine-a".to_string())));
4077 assert!(machine_ids.contains(&Some("machine-b".to_string())));
4078 }
4079
4080 #[test]
4083 fn test_insert_and_get_annotations() {
4084 let (db, _dir) = create_test_db();
4085 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4086 db.insert_session(&session).expect("insert session");
4087
4088 let annotation = Annotation {
4089 id: Uuid::new_v4(),
4090 session_id: session.id,
4091 content: "This is a test note".to_string(),
4092 created_at: Utc::now(),
4093 };
4094 db.insert_annotation(&annotation)
4095 .expect("insert annotation");
4096
4097 let annotations = db.get_annotations(&session.id).expect("get annotations");
4098 assert_eq!(annotations.len(), 1);
4099 assert_eq!(annotations[0].content, "This is a test note");
4100 assert_eq!(annotations[0].session_id, session.id);
4101 }
4102
4103 #[test]
4104 fn test_delete_annotation() {
4105 let (db, _dir) = create_test_db();
4106 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4107 db.insert_session(&session).expect("insert session");
4108
4109 let annotation = Annotation {
4110 id: Uuid::new_v4(),
4111 session_id: session.id,
4112 content: "Test annotation".to_string(),
4113 created_at: Utc::now(),
4114 };
4115 db.insert_annotation(&annotation).expect("insert");
4116
4117 let deleted = db.delete_annotation(&annotation.id).expect("delete");
4118 assert!(deleted);
4119
4120 let annotations = db.get_annotations(&session.id).expect("get");
4121 assert!(annotations.is_empty());
4122 }
4123
4124 #[test]
4125 fn test_delete_annotations_by_session() {
4126 let (db, _dir) = create_test_db();
4127 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4128 db.insert_session(&session).expect("insert session");
4129
4130 for i in 0..3 {
4131 let annotation = Annotation {
4132 id: Uuid::new_v4(),
4133 session_id: session.id,
4134 content: format!("Annotation {i}"),
4135 created_at: Utc::now(),
4136 };
4137 db.insert_annotation(&annotation).expect("insert");
4138 }
4139
4140 let count = db
4141 .delete_annotations_by_session(&session.id)
4142 .expect("delete all");
4143 assert_eq!(count, 3);
4144
4145 let annotations = db.get_annotations(&session.id).expect("get");
4146 assert!(annotations.is_empty());
4147 }
4148
4149 #[test]
4152 fn test_insert_and_get_tags() {
4153 let (db, _dir) = create_test_db();
4154 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4155 db.insert_session(&session).expect("insert session");
4156
4157 let tag = Tag {
4158 id: Uuid::new_v4(),
4159 session_id: session.id,
4160 label: "bug-fix".to_string(),
4161 created_at: Utc::now(),
4162 };
4163 db.insert_tag(&tag).expect("insert tag");
4164
4165 let tags = db.get_tags(&session.id).expect("get tags");
4166 assert_eq!(tags.len(), 1);
4167 assert_eq!(tags[0].label, "bug-fix");
4168 }
4169
4170 #[test]
4171 fn test_tag_exists() {
4172 let (db, _dir) = create_test_db();
4173 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4174 db.insert_session(&session).expect("insert session");
4175
4176 assert!(!db.tag_exists(&session.id, "bug-fix").expect("check"));
4177
4178 let tag = Tag {
4179 id: Uuid::new_v4(),
4180 session_id: session.id,
4181 label: "bug-fix".to_string(),
4182 created_at: Utc::now(),
4183 };
4184 db.insert_tag(&tag).expect("insert tag");
4185
4186 assert!(db.tag_exists(&session.id, "bug-fix").expect("check"));
4187 assert!(!db.tag_exists(&session.id, "feature").expect("check other"));
4188 }
4189
4190 #[test]
4191 fn test_delete_tag() {
4192 let (db, _dir) = create_test_db();
4193 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4194 db.insert_session(&session).expect("insert session");
4195
4196 let tag = Tag {
4197 id: Uuid::new_v4(),
4198 session_id: session.id,
4199 label: "wip".to_string(),
4200 created_at: Utc::now(),
4201 };
4202 db.insert_tag(&tag).expect("insert tag");
4203
4204 let deleted = db.delete_tag(&session.id, "wip").expect("delete");
4205 assert!(deleted);
4206
4207 let deleted_again = db.delete_tag(&session.id, "wip").expect("delete again");
4208 assert!(!deleted_again);
4209 }
4210
4211 #[test]
4212 fn test_list_sessions_with_tag() {
4213 let (db, _dir) = create_test_db();
4214 let now = Utc::now();
4215
4216 let session1 = create_test_session("claude-code", "/project1", now, None);
4217 let session2 =
4218 create_test_session("claude-code", "/project2", now - Duration::minutes(5), None);
4219 let session3 = create_test_session(
4220 "claude-code",
4221 "/project3",
4222 now - Duration::minutes(10),
4223 None,
4224 );
4225
4226 db.insert_session(&session1).expect("insert");
4227 db.insert_session(&session2).expect("insert");
4228 db.insert_session(&session3).expect("insert");
4229
4230 let tag1 = Tag {
4232 id: Uuid::new_v4(),
4233 session_id: session1.id,
4234 label: "feature".to_string(),
4235 created_at: Utc::now(),
4236 };
4237 let tag3 = Tag {
4238 id: Uuid::new_v4(),
4239 session_id: session3.id,
4240 label: "feature".to_string(),
4241 created_at: Utc::now(),
4242 };
4243 db.insert_tag(&tag1).expect("insert tag");
4244 db.insert_tag(&tag3).expect("insert tag");
4245
4246 let sessions = db.list_sessions_with_tag("feature", 10).expect("list");
4247 assert_eq!(sessions.len(), 2);
4248 assert_eq!(sessions[0].id, session1.id);
4250 assert_eq!(sessions[1].id, session3.id);
4251
4252 let sessions = db.list_sessions_with_tag("nonexistent", 10).expect("list");
4253 assert!(sessions.is_empty());
4254 }
4255
4256 #[test]
4257 fn test_get_most_recent_session_for_directory() {
4258 let (db, _dir) = create_test_db();
4259 let now = Utc::now();
4260
4261 let session1 = create_test_session(
4262 "claude-code",
4263 "/home/user/project",
4264 now - Duration::hours(1),
4265 None,
4266 );
4267 let session2 = create_test_session("claude-code", "/home/user/project", now, None);
4268 let session3 = create_test_session("claude-code", "/home/user/other", now, None);
4269
4270 db.insert_session(&session1).expect("insert");
4271 db.insert_session(&session2).expect("insert");
4272 db.insert_session(&session3).expect("insert");
4273
4274 let result = db
4275 .get_most_recent_session_for_directory("/home/user/project")
4276 .expect("get");
4277 assert!(result.is_some());
4278 assert_eq!(result.unwrap().id, session2.id);
4279
4280 let result = db
4281 .get_most_recent_session_for_directory("/home/user/nonexistent")
4282 .expect("get");
4283 assert!(result.is_none());
4284 }
4285
4286 #[test]
4287 fn test_session_deletion_removes_annotations_and_tags() {
4288 let (db, _dir) = create_test_db();
4289 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4290 db.insert_session(&session).expect("insert session");
4291
4292 let annotation = Annotation {
4294 id: Uuid::new_v4(),
4295 session_id: session.id,
4296 content: "Test annotation".to_string(),
4297 created_at: Utc::now(),
4298 };
4299 db.insert_annotation(&annotation).expect("insert");
4300
4301 let tag = Tag {
4303 id: Uuid::new_v4(),
4304 session_id: session.id,
4305 label: "test-tag".to_string(),
4306 created_at: Utc::now(),
4307 };
4308 db.insert_tag(&tag).expect("insert");
4309
4310 db.delete_session(&session.id).expect("delete");
4312
4313 let annotations = db.get_annotations(&session.id).expect("get");
4315 assert!(annotations.is_empty());
4316
4317 let tags = db.get_tags(&session.id).expect("get");
4318 assert!(tags.is_empty());
4319 }
4320
4321 #[test]
4322 fn test_insert_and_get_summary() {
4323 let (db, _dir) = create_test_db();
4324 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4325 db.insert_session(&session).expect("insert session");
4326
4327 let summary = Summary {
4328 id: Uuid::new_v4(),
4329 session_id: session.id,
4330 content: "Test summary content".to_string(),
4331 generated_at: Utc::now(),
4332 };
4333 db.insert_summary(&summary).expect("insert summary");
4334
4335 let retrieved = db.get_summary(&session.id).expect("get summary");
4336 assert!(retrieved.is_some());
4337 let retrieved = retrieved.unwrap();
4338 assert_eq!(retrieved.content, "Test summary content");
4339 assert_eq!(retrieved.session_id, session.id);
4340 }
4341
4342 #[test]
4343 fn test_get_summary_nonexistent() {
4344 let (db, _dir) = create_test_db();
4345 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4346 db.insert_session(&session).expect("insert session");
4347
4348 let retrieved = db.get_summary(&session.id).expect("get summary");
4349 assert!(retrieved.is_none());
4350 }
4351
4352 #[test]
4353 fn test_update_summary() {
4354 let (db, _dir) = create_test_db();
4355 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4356 db.insert_session(&session).expect("insert session");
4357
4358 let summary = Summary {
4359 id: Uuid::new_v4(),
4360 session_id: session.id,
4361 content: "Original content".to_string(),
4362 generated_at: Utc::now(),
4363 };
4364 db.insert_summary(&summary).expect("insert summary");
4365
4366 let updated = db
4368 .update_summary(&session.id, "Updated content")
4369 .expect("update summary");
4370 assert!(updated);
4371
4372 let retrieved = db.get_summary(&session.id).expect("get summary");
4373 assert!(retrieved.is_some());
4374 assert_eq!(retrieved.unwrap().content, "Updated content");
4375 }
4376
4377 #[test]
4378 fn test_update_summary_nonexistent() {
4379 let (db, _dir) = create_test_db();
4380 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4381 db.insert_session(&session).expect("insert session");
4382
4383 let updated = db
4385 .update_summary(&session.id, "New content")
4386 .expect("update summary");
4387 assert!(!updated);
4388 }
4389
4390 #[test]
4391 fn test_delete_summary() {
4392 let (db, _dir) = create_test_db();
4393 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4394 db.insert_session(&session).expect("insert session");
4395
4396 let summary = Summary {
4397 id: Uuid::new_v4(),
4398 session_id: session.id,
4399 content: "To be deleted".to_string(),
4400 generated_at: Utc::now(),
4401 };
4402 db.insert_summary(&summary).expect("insert summary");
4403
4404 let deleted = db.delete_summary(&session.id).expect("delete summary");
4406 assert!(deleted);
4407
4408 let retrieved = db.get_summary(&session.id).expect("get summary");
4410 assert!(retrieved.is_none());
4411 }
4412
4413 #[test]
4414 fn test_delete_session_removes_summary() {
4415 let (db, _dir) = create_test_db();
4416 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4417 db.insert_session(&session).expect("insert session");
4418
4419 let summary = Summary {
4420 id: Uuid::new_v4(),
4421 session_id: session.id,
4422 content: "Session summary".to_string(),
4423 generated_at: Utc::now(),
4424 };
4425 db.insert_summary(&summary).expect("insert summary");
4426
4427 db.delete_session(&session.id).expect("delete session");
4429
4430 let retrieved = db.get_summary(&session.id).expect("get summary");
4432 assert!(retrieved.is_none());
4433 }
4434
4435 #[test]
4438 fn test_upsert_machine_insert() {
4439 let (db, _dir) = create_test_db();
4440
4441 let machine = Machine {
4442 id: "test-uuid-1234".to_string(),
4443 name: "my-laptop".to_string(),
4444 created_at: Utc::now().to_rfc3339(),
4445 };
4446
4447 db.upsert_machine(&machine)
4448 .expect("Failed to upsert machine");
4449
4450 let retrieved = db
4451 .get_machine("test-uuid-1234")
4452 .expect("Failed to get machine")
4453 .expect("Machine should exist");
4454
4455 assert_eq!(retrieved.id, "test-uuid-1234");
4456 assert_eq!(retrieved.name, "my-laptop");
4457 }
4458
4459 #[test]
4460 fn test_upsert_machine_update() {
4461 let (db, _dir) = create_test_db();
4462
4463 let machine1 = Machine {
4465 id: "test-uuid-5678".to_string(),
4466 name: "old-name".to_string(),
4467 created_at: Utc::now().to_rfc3339(),
4468 };
4469 db.upsert_machine(&machine1)
4470 .expect("Failed to upsert machine");
4471
4472 let machine2 = Machine {
4474 id: "test-uuid-5678".to_string(),
4475 name: "new-name".to_string(),
4476 created_at: Utc::now().to_rfc3339(),
4477 };
4478 db.upsert_machine(&machine2)
4479 .expect("Failed to upsert machine");
4480
4481 let retrieved = db
4483 .get_machine("test-uuid-5678")
4484 .expect("Failed to get machine")
4485 .expect("Machine should exist");
4486
4487 assert_eq!(retrieved.name, "new-name");
4488 }
4489
4490 #[test]
4491 fn test_get_machine() {
4492 let (db, _dir) = create_test_db();
4493
4494 let not_found = db.get_machine("nonexistent-uuid").expect("Failed to query");
4496 assert!(not_found.is_none(), "Machine should not exist");
4497
4498 let machine = Machine {
4500 id: "existing-uuid".to_string(),
4501 name: "test-machine".to_string(),
4502 created_at: Utc::now().to_rfc3339(),
4503 };
4504 db.upsert_machine(&machine).expect("Failed to upsert");
4505
4506 let found = db
4508 .get_machine("existing-uuid")
4509 .expect("Failed to query")
4510 .expect("Machine should exist");
4511
4512 assert_eq!(found.id, "existing-uuid");
4513 assert_eq!(found.name, "test-machine");
4514 }
4515
4516 #[test]
4517 fn test_get_machine_name_found() {
4518 let (db, _dir) = create_test_db();
4519
4520 let machine = Machine {
4521 id: "uuid-for-name-test".to_string(),
4522 name: "my-workstation".to_string(),
4523 created_at: Utc::now().to_rfc3339(),
4524 };
4525 db.upsert_machine(&machine).expect("Failed to upsert");
4526
4527 let name = db
4528 .get_machine_name("uuid-for-name-test")
4529 .expect("Failed to get name");
4530
4531 assert_eq!(name, "my-workstation");
4532 }
4533
4534 #[test]
4535 fn test_get_machine_name_not_found() {
4536 let (db, _dir) = create_test_db();
4537
4538 let name = db
4540 .get_machine_name("abc123def456789")
4541 .expect("Failed to get name");
4542
4543 assert_eq!(name, "abc123de", "Should return first 8 characters");
4544
4545 let short_name = db.get_machine_name("short").expect("Failed to get name");
4547
4548 assert_eq!(
4549 short_name, "short",
4550 "Should return full ID if shorter than 8 chars"
4551 );
4552 }
4553
4554 #[test]
4555 fn test_list_machines() {
4556 let (db, _dir) = create_test_db();
4557
4558 let machines = db.list_machines().expect("Failed to list");
4560 assert!(machines.is_empty(), "Should have no machines initially");
4561
4562 let machine1 = Machine {
4564 id: "uuid-1".to_string(),
4565 name: "machine-1".to_string(),
4566 created_at: "2024-01-01T00:00:00Z".to_string(),
4567 };
4568 let machine2 = Machine {
4569 id: "uuid-2".to_string(),
4570 name: "machine-2".to_string(),
4571 created_at: "2024-01-02T00:00:00Z".to_string(),
4572 };
4573
4574 db.upsert_machine(&machine1).expect("Failed to upsert");
4575 db.upsert_machine(&machine2).expect("Failed to upsert");
4576
4577 let machines = db.list_machines().expect("Failed to list");
4579 assert_eq!(machines.len(), 2, "Should have 2 machines");
4580
4581 assert_eq!(machines[0].id, "uuid-1");
4583 assert_eq!(machines[1].id, "uuid-2");
4584 }
4585
4586 #[test]
4589 fn test_find_session_by_id_prefix_full_uuid() {
4590 let (db, _dir) = create_test_db();
4591 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4592 db.insert_session(&session).expect("insert session");
4593
4594 let found = db
4596 .find_session_by_id_prefix(&session.id.to_string())
4597 .expect("find session")
4598 .expect("session should exist");
4599
4600 assert_eq!(found.id, session.id, "Should find session by full UUID");
4601 }
4602
4603 #[test]
4604 fn test_find_session_by_id_prefix_short_prefix() {
4605 let (db, _dir) = create_test_db();
4606 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4607 db.insert_session(&session).expect("insert session");
4608
4609 let prefix = &session.id.to_string()[..8];
4611
4612 let found = db
4613 .find_session_by_id_prefix(prefix)
4614 .expect("find session")
4615 .expect("session should exist");
4616
4617 assert_eq!(found.id, session.id, "Should find session by short prefix");
4618 }
4619
4620 #[test]
4621 fn test_find_session_by_id_prefix_very_short_prefix() {
4622 let (db, _dir) = create_test_db();
4623 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4624 db.insert_session(&session).expect("insert session");
4625
4626 let prefix = &session.id.to_string()[..4];
4628
4629 let found = db
4630 .find_session_by_id_prefix(prefix)
4631 .expect("find session")
4632 .expect("session should exist");
4633
4634 assert_eq!(
4635 found.id, session.id,
4636 "Should find session by very short prefix"
4637 );
4638 }
4639
4640 #[test]
4641 fn test_find_session_by_id_prefix_not_found() {
4642 let (db, _dir) = create_test_db();
4643 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4644 db.insert_session(&session).expect("insert session");
4645
4646 let found = db
4648 .find_session_by_id_prefix("zzz999")
4649 .expect("find session");
4650
4651 assert!(
4652 found.is_none(),
4653 "Should return None for non-matching prefix"
4654 );
4655 }
4656
4657 #[test]
4658 fn test_find_session_by_id_prefix_empty_db() {
4659 let (db, _dir) = create_test_db();
4660
4661 let found = db
4662 .find_session_by_id_prefix("abc123")
4663 .expect("find session");
4664
4665 assert!(found.is_none(), "Should return None for empty database");
4666 }
4667
4668 #[test]
4669 fn test_find_session_by_id_prefix_ambiguous() {
4670 let (db, _dir) = create_test_db();
4671
4672 let mut sessions = Vec::new();
4674 for _ in 0..100 {
4675 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4676 db.insert_session(&session).expect("insert session");
4677 sessions.push(session);
4678 }
4679
4680 let first_session = &sessions[0];
4682 let first_char = first_session.id.to_string().chars().next().unwrap();
4683
4684 let matching_count = sessions
4686 .iter()
4687 .filter(|s| s.id.to_string().starts_with(first_char))
4688 .count();
4689
4690 if matching_count > 1 {
4691 let result = db.find_session_by_id_prefix(&first_char.to_string());
4694 assert!(
4695 result.is_err(),
4696 "Should return error for ambiguous single-character prefix"
4697 );
4698 let error_msg = result.unwrap_err().to_string();
4699 assert!(
4700 error_msg.contains("Ambiguous"),
4701 "Error should mention ambiguity"
4702 );
4703 }
4704 }
4705
4706 #[test]
4707 fn test_find_session_by_id_prefix_returns_correct_session_data() {
4708 let (db, _dir) = create_test_db();
4709
4710 let mut session =
4711 create_test_session("claude-code", "/home/user/myproject", Utc::now(), None);
4712 session.tool_version = Some("2.0.0".to_string());
4713 session.model = Some("claude-opus-4".to_string());
4714 session.git_branch = Some("feature/test".to_string());
4715 session.message_count = 42;
4716 db.insert_session(&session).expect("insert session");
4717
4718 let prefix = &session.id.to_string()[..8];
4720 let found = db
4721 .find_session_by_id_prefix(prefix)
4722 .expect("find session")
4723 .expect("session should exist");
4724
4725 assert_eq!(found.id, session.id);
4727 assert_eq!(found.tool, "claude-code");
4728 assert_eq!(found.tool_version, Some("2.0.0".to_string()));
4729 assert_eq!(found.model, Some("claude-opus-4".to_string()));
4730 assert_eq!(found.working_directory, "/home/user/myproject");
4731 assert_eq!(found.git_branch, Some("feature/test".to_string()));
4732 assert_eq!(found.message_count, 42);
4733 }
4734
4735 #[test]
4736 fn test_find_session_by_id_prefix_many_sessions() {
4737 let (db, _dir) = create_test_db();
4738
4739 let mut target_session = None;
4741 for i in 0..200 {
4742 let session =
4743 create_test_session("claude-code", &format!("/project/{i}"), Utc::now(), None);
4744 db.insert_session(&session).expect("insert session");
4745 if i == 150 {
4747 target_session = Some(session);
4748 }
4749 }
4750
4751 let target = target_session.expect("should have target session");
4752 let prefix = &target.id.to_string()[..8];
4753
4754 let found = db
4756 .find_session_by_id_prefix(prefix)
4757 .expect("find session")
4758 .expect("session should exist");
4759
4760 assert_eq!(
4761 found.id, target.id,
4762 "Should find correct session among many"
4763 );
4764 assert_eq!(found.working_directory, "/project/150");
4765 }
4766}