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 list_ended_sessions(
387 &self,
388 limit: usize,
389 working_dir: Option<&str>,
390 ) -> Result<Vec<Session>> {
391 let mut stmt = if working_dir.is_some() {
392 self.conn.prepare(
393 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
394 FROM sessions
395 WHERE ended_at IS NOT NULL
396 AND working_directory LIKE ?1
397 ORDER BY started_at DESC
398 LIMIT ?2",
399 )?
400 } else {
401 self.conn.prepare(
402 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
403 FROM sessions
404 WHERE ended_at IS NOT NULL
405 ORDER BY started_at DESC
406 LIMIT ?1",
407 )?
408 };
409
410 let rows = if let Some(wd) = working_dir {
411 stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
412 } else {
413 stmt.query_map(params![limit], Self::row_to_session)?
414 };
415
416 rows.collect::<Result<Vec<_>, _>>()
417 .context("Failed to list ended sessions")
418 }
419
420 pub fn session_exists_by_source(&self, source_path: &str) -> Result<bool> {
424 let count: i32 = self.conn.query_row(
425 "SELECT COUNT(*) FROM sessions WHERE source_path = ?1",
426 params![source_path],
427 |row| row.get(0),
428 )?;
429 Ok(count > 0)
430 }
431
432 pub fn get_session_by_source(&self, source_path: &str) -> Result<Option<Session>> {
437 self.conn
438 .query_row(
439 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id FROM sessions WHERE source_path = ?1",
440 params![source_path],
441 Self::row_to_session,
442 )
443 .optional()
444 .context("Failed to get session by source path")
445 }
446
447 pub fn find_session_by_id_prefix(&self, prefix: &str) -> Result<Option<Session>> {
463 if let Ok(uuid) = Uuid::parse_str(prefix) {
465 return self.get_session(&uuid);
466 }
467
468 let pattern = format!("{prefix}%");
470
471 let count: i32 = self.conn.query_row(
473 "SELECT COUNT(*) FROM sessions WHERE id LIKE ?1",
474 params![pattern],
475 |row| row.get(0),
476 )?;
477
478 match count {
479 0 => Ok(None),
480 1 => {
481 self.conn
483 .query_row(
484 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
485 FROM sessions
486 WHERE id LIKE ?1",
487 params![pattern],
488 Self::row_to_session,
489 )
490 .optional()
491 .context("Failed to find session by prefix")
492 }
493 n => {
494 anyhow::bail!(
496 "Ambiguous session ID prefix '{prefix}' matches {n} sessions. Use a longer prefix."
497 )
498 }
499 }
500 }
501
502 pub fn update_session_branch(&self, session_id: Uuid, new_branch: &str) -> Result<usize> {
510 let rows_changed = self.conn.execute(
511 "UPDATE sessions SET git_branch = ?1 WHERE id = ?2",
512 params![new_branch, session_id.to_string()],
513 )?;
514
515 if rows_changed > 0 {
517 self.conn.execute(
518 "UPDATE sessions_fts SET git_branch = ?1 WHERE session_id = ?2",
519 params![new_branch, session_id.to_string()],
520 )?;
521 }
522
523 Ok(rows_changed)
524 }
525
526 fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<Session> {
527 let ended_at_str: Option<String> = row.get(4)?;
528 let ended_at = match ended_at_str {
529 Some(s) => Some(parse_datetime(&s)?),
530 None => None,
531 };
532
533 Ok(Session {
534 id: parse_uuid(&row.get::<_, String>(0)?)?,
535 tool: row.get(1)?,
536 tool_version: row.get(2)?,
537 started_at: parse_datetime(&row.get::<_, String>(3)?)?,
538 ended_at,
539 model: row.get(5)?,
540 working_directory: row.get(6)?,
541 git_branch: row.get(7)?,
542 source_path: row.get(8)?,
543 message_count: row.get(9)?,
544 machine_id: row.get(10)?,
545 })
546 }
547
548 pub fn insert_message(&self, message: &Message) -> Result<()> {
556 let content_json = serde_json::to_string(&message.content)?;
557
558 let rows_changed = self.conn.execute(
559 r#"
560 INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
561 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
562 ON CONFLICT(id) DO NOTHING
563 "#,
564 params![
565 message.id.to_string(),
566 message.session_id.to_string(),
567 message.parent_id.map(|u| u.to_string()),
568 message.index,
569 message.timestamp.to_rfc3339(),
570 message.role.to_string(),
571 content_json,
572 message.model,
573 message.git_branch,
574 message.cwd,
575 ],
576 )?;
577
578 if rows_changed > 0 {
580 let text_content = message.content.text();
581 if !text_content.is_empty() {
582 self.conn.execute(
583 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
584 params![message.id.to_string(), text_content],
585 )?;
586 }
587 }
588
589 Ok(())
590 }
591
592 pub fn get_messages(&self, session_id: &Uuid) -> Result<Vec<Message>> {
596 let mut stmt = self.conn.prepare(
597 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
598 FROM messages
599 WHERE session_id = ?1
600 ORDER BY idx"
601 )?;
602
603 let rows = stmt.query_map(params![session_id.to_string()], |row| {
604 let role_str: String = row.get(5)?;
605 let content_str: String = row.get(6)?;
606
607 let parent_id_str: Option<String> = row.get(2)?;
608 let parent_id = match parent_id_str {
609 Some(s) => Some(parse_uuid(&s)?),
610 None => None,
611 };
612
613 Ok(Message {
614 id: parse_uuid(&row.get::<_, String>(0)?)?,
615 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
616 parent_id,
617 index: row.get(3)?,
618 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
619 role: match role_str.as_str() {
620 "user" => MessageRole::User,
621 "assistant" => MessageRole::Assistant,
622 "system" => MessageRole::System,
623 _ => MessageRole::User,
624 },
625 content: serde_json::from_str(&content_str)
626 .unwrap_or(MessageContent::Text(content_str)),
627 model: row.get(7)?,
628 git_branch: row.get(8)?,
629 cwd: row.get(9)?,
630 })
631 })?;
632
633 rows.collect::<Result<Vec<_>, _>>()
634 .context("Failed to get messages")
635 }
636
637 pub fn get_session_branch_history(&self, session_id: Uuid) -> Result<Vec<String>> {
646 let mut stmt = self
647 .conn
648 .prepare("SELECT git_branch FROM messages WHERE session_id = ?1 ORDER BY idx")?;
649
650 let rows = stmt.query_map(params![session_id.to_string()], |row| {
651 let branch: Option<String> = row.get(0)?;
652 Ok(branch)
653 })?;
654
655 let mut branches: Vec<String> = Vec::new();
657 for row in rows {
658 if let Some(branch) = row? {
659 if branches.last() != Some(&branch) {
661 branches.push(branch);
662 }
663 }
664 }
665
666 Ok(branches)
667 }
668
669 pub fn insert_link(&self, link: &SessionLink) -> Result<()> {
676 self.conn.execute(
677 r#"
678 INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
679 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
680 "#,
681 params![
682 link.id.to_string(),
683 link.session_id.to_string(),
684 format!("{:?}", link.link_type).to_lowercase(),
685 link.commit_sha,
686 link.branch,
687 link.remote,
688 link.created_at.to_rfc3339(),
689 format!("{:?}", link.created_by).to_lowercase(),
690 link.confidence,
691 ],
692 )?;
693 Ok(())
694 }
695
696 pub fn get_links_by_commit(&self, commit_sha: &str) -> Result<Vec<SessionLink>> {
701 let mut stmt = self.conn.prepare(
702 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
703 FROM session_links
704 WHERE commit_sha LIKE ?1"
705 )?;
706
707 let pattern = format!("{commit_sha}%");
708 let rows = stmt.query_map(params![pattern], Self::row_to_link)?;
709
710 rows.collect::<Result<Vec<_>, _>>()
711 .context("Failed to get links")
712 }
713
714 pub fn get_links_by_session(&self, session_id: &Uuid) -> Result<Vec<SessionLink>> {
719 let mut stmt = self.conn.prepare(
720 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
721 FROM session_links
722 WHERE session_id = ?1"
723 )?;
724
725 let rows = stmt.query_map(params![session_id.to_string()], Self::row_to_link)?;
726
727 rows.collect::<Result<Vec<_>, _>>()
728 .context("Failed to get links")
729 }
730
731 fn row_to_link(row: &rusqlite::Row) -> rusqlite::Result<SessionLink> {
732 use super::models::{LinkCreator, LinkType};
733
734 let link_type_str: String = row.get(2)?;
735 let created_by_str: String = row.get(7)?;
736
737 Ok(SessionLink {
738 id: parse_uuid(&row.get::<_, String>(0)?)?,
739 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
740 link_type: match link_type_str.as_str() {
741 "commit" => LinkType::Commit,
742 "branch" => LinkType::Branch,
743 "pr" => LinkType::Pr,
744 _ => LinkType::Manual,
745 },
746 commit_sha: row.get(3)?,
747 branch: row.get(4)?,
748 remote: row.get(5)?,
749 created_at: parse_datetime(&row.get::<_, String>(6)?)?,
750 created_by: match created_by_str.as_str() {
751 "auto" => LinkCreator::Auto,
752 _ => LinkCreator::User,
753 },
754 confidence: row.get(8)?,
755 })
756 }
757
758 #[allow(dead_code)]
765 pub fn delete_link(&self, link_id: &Uuid) -> Result<bool> {
766 let rows_affected = self.conn.execute(
767 "DELETE FROM session_links WHERE id = ?1",
768 params![link_id.to_string()],
769 )?;
770 Ok(rows_affected > 0)
771 }
772
773 pub fn delete_links_by_session(&self, session_id: &Uuid) -> Result<usize> {
777 let rows_affected = self.conn.execute(
778 "DELETE FROM session_links WHERE session_id = ?1",
779 params![session_id.to_string()],
780 )?;
781 Ok(rows_affected)
782 }
783
784 pub fn delete_link_by_session_and_commit(
789 &self,
790 session_id: &Uuid,
791 commit_sha: &str,
792 ) -> Result<bool> {
793 let pattern = format!("{commit_sha}%");
794 let rows_affected = self.conn.execute(
795 "DELETE FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
796 params![session_id.to_string(), pattern],
797 )?;
798 Ok(rows_affected > 0)
799 }
800
801 #[allow(dead_code)]
815 pub fn search_messages(
816 &self,
817 query: &str,
818 limit: usize,
819 working_dir: Option<&str>,
820 since: Option<chrono::DateTime<chrono::Utc>>,
821 role: Option<&str>,
822 ) -> Result<Vec<SearchResult>> {
823 use super::models::SearchOptions;
824
825 let options = SearchOptions {
827 query: query.to_string(),
828 limit,
829 repo: working_dir.map(|s| s.to_string()),
830 since,
831 role: role.map(|s| s.to_string()),
832 ..Default::default()
833 };
834
835 self.search_with_options(&options)
836 }
837
838 pub fn search_with_options(
852 &self,
853 options: &super::models::SearchOptions,
854 ) -> Result<Vec<SearchResult>> {
855 let escaped_query = escape_fts5_query(&options.query);
857
858 let mut sql = String::from(
861 r#"
862 SELECT
863 m.session_id,
864 m.id as message_id,
865 m.role,
866 snippet(messages_fts, 1, '**', '**', '...', 32) as snippet,
867 m.timestamp,
868 s.working_directory,
869 s.tool,
870 s.git_branch,
871 s.message_count,
872 s.started_at,
873 m.idx as message_index
874 FROM messages_fts fts
875 JOIN messages m ON fts.message_id = m.id
876 JOIN sessions s ON m.session_id = s.id
877 WHERE messages_fts MATCH ?1
878 "#,
879 );
880
881 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(escaped_query.clone())];
882 let mut param_idx = 2;
883
884 if options.repo.is_some() {
886 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
887 param_idx += 1;
888 }
889 if options.tool.is_some() {
890 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
891 param_idx += 1;
892 }
893 if options.since.is_some() {
894 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
895 param_idx += 1;
896 }
897 if options.until.is_some() {
898 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
899 param_idx += 1;
900 }
901 if options.project.is_some() {
902 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
903 param_idx += 1;
904 }
905 if options.branch.is_some() {
906 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
907 param_idx += 1;
908 }
909 if options.role.is_some() {
910 sql.push_str(&format!(" AND m.role = ?{param_idx}"));
911 param_idx += 1;
912 }
913
914 if let Some(ref wd) = options.repo {
916 params_vec.push(Box::new(format!("{wd}%")));
917 }
918 if let Some(ref tool) = options.tool {
919 params_vec.push(Box::new(tool.clone()));
920 }
921 if let Some(ts) = options.since {
922 params_vec.push(Box::new(ts.to_rfc3339()));
923 }
924 if let Some(ts) = options.until {
925 params_vec.push(Box::new(ts.to_rfc3339()));
926 }
927 if let Some(ref project) = options.project {
928 params_vec.push(Box::new(format!("%{project}%")));
929 }
930 if let Some(ref branch) = options.branch {
931 params_vec.push(Box::new(format!("%{branch}%")));
932 }
933 if let Some(ref role) = options.role {
934 params_vec.push(Box::new(role.clone()));
935 }
936
937 let include_metadata_search = options.role.is_none();
941 let metadata_query_pattern = format!("%{}%", options.query);
942
943 if include_metadata_search {
944 let meta_param1 = param_idx;
946 let meta_param2 = param_idx + 1;
947 let meta_param3 = param_idx + 2;
948 param_idx += 3;
949
950 sql.push_str(&format!(
951 r#"
952 UNION
953 SELECT
954 s.id as session_id,
955 (SELECT id FROM messages WHERE session_id = s.id ORDER BY idx LIMIT 1) as message_id,
956 'user' as role,
957 substr(s.tool || ' session in ' || s.working_directory || COALESCE(' on branch ' || s.git_branch, ''), 1, 100) as snippet,
958 s.started_at as timestamp,
959 s.working_directory,
960 s.tool,
961 s.git_branch,
962 s.message_count,
963 s.started_at,
964 0 as message_index
965 FROM sessions s
966 WHERE (
967 s.tool LIKE ?{meta_param1}
968 OR s.working_directory LIKE ?{meta_param2}
969 OR s.git_branch LIKE ?{meta_param3}
970 )
971 "#
972 ));
973
974 params_vec.push(Box::new(metadata_query_pattern.clone()));
976 params_vec.push(Box::new(metadata_query_pattern.clone()));
977 params_vec.push(Box::new(metadata_query_pattern));
978
979 if options.repo.is_some() {
981 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
982 params_vec.push(Box::new(format!("{}%", options.repo.as_ref().unwrap())));
983 param_idx += 1;
984 }
985 if options.tool.is_some() {
986 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
987 params_vec.push(Box::new(options.tool.as_ref().unwrap().clone()));
988 param_idx += 1;
989 }
990 if options.since.is_some() {
991 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
992 params_vec.push(Box::new(options.since.unwrap().to_rfc3339()));
993 param_idx += 1;
994 }
995 if options.until.is_some() {
996 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
997 params_vec.push(Box::new(options.until.unwrap().to_rfc3339()));
998 param_idx += 1;
999 }
1000 if options.project.is_some() {
1001 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1002 params_vec.push(Box::new(format!("%{}%", options.project.as_ref().unwrap())));
1003 param_idx += 1;
1004 }
1005 if options.branch.is_some() {
1006 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
1007 params_vec.push(Box::new(format!("%{}%", options.branch.as_ref().unwrap())));
1008 param_idx += 1;
1009 }
1010 }
1011
1012 sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{param_idx}"));
1013 params_vec.push(Box::new(options.limit as i64));
1014
1015 let mut stmt = self.conn.prepare(&sql)?;
1017 let params_refs: Vec<&dyn rusqlite::ToSql> =
1018 params_vec.iter().map(|p| p.as_ref()).collect();
1019
1020 let rows = stmt.query_map(params_refs.as_slice(), |row| {
1021 let role_str: String = row.get(2)?;
1022 let git_branch: Option<String> = row.get(7)?;
1023 let started_at_str: Option<String> = row.get(9)?;
1024
1025 Ok(SearchResult {
1026 session_id: parse_uuid(&row.get::<_, String>(0)?)?,
1027 message_id: parse_uuid(&row.get::<_, String>(1)?)?,
1028 role: match role_str.as_str() {
1029 "user" => MessageRole::User,
1030 "assistant" => MessageRole::Assistant,
1031 "system" => MessageRole::System,
1032 _ => MessageRole::User,
1033 },
1034 snippet: row.get(3)?,
1035 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1036 working_directory: row.get(5)?,
1037 tool: row.get(6)?,
1038 git_branch,
1039 session_message_count: row.get(8)?,
1040 session_started_at: started_at_str.map(|s| parse_datetime(&s)).transpose()?,
1041 message_index: row.get(10)?,
1042 })
1043 })?;
1044
1045 rows.collect::<Result<Vec<_>, _>>()
1046 .context("Failed to search messages")
1047 }
1048
1049 pub fn get_context_messages(
1054 &self,
1055 session_id: &Uuid,
1056 message_index: i32,
1057 context_count: usize,
1058 ) -> Result<(Vec<Message>, Vec<Message>)> {
1059 let mut before_stmt = self.conn.prepare(
1061 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1062 FROM messages
1063 WHERE session_id = ?1 AND idx < ?2
1064 ORDER BY idx DESC
1065 LIMIT ?3",
1066 )?;
1067
1068 let before_rows = before_stmt.query_map(
1069 params![session_id.to_string(), message_index, context_count as i64],
1070 Self::row_to_message,
1071 )?;
1072
1073 let mut before: Vec<Message> = before_rows
1074 .collect::<Result<Vec<_>, _>>()
1075 .context("Failed to get before messages")?;
1076 before.reverse(); let mut after_stmt = self.conn.prepare(
1080 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1081 FROM messages
1082 WHERE session_id = ?1 AND idx > ?2
1083 ORDER BY idx ASC
1084 LIMIT ?3",
1085 )?;
1086
1087 let after_rows = after_stmt.query_map(
1088 params![session_id.to_string(), message_index, context_count as i64],
1089 Self::row_to_message,
1090 )?;
1091
1092 let after: Vec<Message> = after_rows
1093 .collect::<Result<Vec<_>, _>>()
1094 .context("Failed to get after messages")?;
1095
1096 Ok((before, after))
1097 }
1098
1099 #[allow(dead_code)]
1101 pub fn get_message_by_index(&self, session_id: &Uuid, index: i32) -> Result<Option<Message>> {
1102 self.conn
1103 .query_row(
1104 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1105 FROM messages
1106 WHERE session_id = ?1 AND idx = ?2",
1107 params![session_id.to_string(), index],
1108 Self::row_to_message,
1109 )
1110 .optional()
1111 .context("Failed to get message by index")
1112 }
1113
1114 fn row_to_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
1115 let role_str: String = row.get(5)?;
1116 let content_str: String = row.get(6)?;
1117
1118 let parent_id_str: Option<String> = row.get(2)?;
1119 let parent_id = match parent_id_str {
1120 Some(s) => Some(parse_uuid(&s)?),
1121 None => None,
1122 };
1123
1124 Ok(Message {
1125 id: parse_uuid(&row.get::<_, String>(0)?)?,
1126 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1127 parent_id,
1128 index: row.get(3)?,
1129 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1130 role: match role_str.as_str() {
1131 "user" => MessageRole::User,
1132 "assistant" => MessageRole::Assistant,
1133 "system" => MessageRole::System,
1134 _ => MessageRole::User,
1135 },
1136 content: serde_json::from_str(&content_str)
1137 .unwrap_or(MessageContent::Text(content_str)),
1138 model: row.get(7)?,
1139 git_branch: row.get(8)?,
1140 cwd: row.get(9)?,
1141 })
1142 }
1143
1144 pub fn rebuild_search_index(&self) -> Result<usize> {
1152 self.conn.execute("DELETE FROM messages_fts", [])?;
1154 self.conn.execute("DELETE FROM sessions_fts", [])?;
1155
1156 let mut msg_stmt = self.conn.prepare("SELECT id, content FROM messages")?;
1158
1159 let rows = msg_stmt.query_map([], |row| {
1160 let id: String = row.get(0)?;
1161 let content_json: String = row.get(1)?;
1162 Ok((id, content_json))
1163 })?;
1164
1165 let mut count = 0;
1166 for row in rows {
1167 let (id, content_json) = row?;
1168 let content: MessageContent = serde_json::from_str(&content_json)
1170 .unwrap_or(MessageContent::Text(content_json.clone()));
1171 let text_content = content.text();
1172
1173 if !text_content.is_empty() {
1174 self.conn.execute(
1175 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
1176 params![id, text_content],
1177 )?;
1178 count += 1;
1179 }
1180 }
1181
1182 let mut session_stmt = self
1184 .conn
1185 .prepare("SELECT id, tool, working_directory, git_branch FROM sessions")?;
1186
1187 let session_rows = session_stmt.query_map([], |row| {
1188 let id: String = row.get(0)?;
1189 let tool: String = row.get(1)?;
1190 let working_directory: String = row.get(2)?;
1191 let git_branch: Option<String> = row.get(3)?;
1192 Ok((id, tool, working_directory, git_branch))
1193 })?;
1194
1195 for row in session_rows {
1196 let (id, tool, working_directory, git_branch) = row?;
1197 self.conn.execute(
1198 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
1199 params![id, tool, working_directory, git_branch.unwrap_or_default()],
1200 )?;
1201 }
1202
1203 Ok(count)
1204 }
1205
1206 pub fn search_index_needs_rebuild(&self) -> Result<bool> {
1211 let message_count: i32 =
1212 self.conn
1213 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1214
1215 let msg_fts_count: i32 =
1216 self.conn
1217 .query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
1218
1219 let session_count: i32 =
1220 self.conn
1221 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1222
1223 let session_fts_count: i32 =
1224 self.conn
1225 .query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
1226
1227 Ok((message_count > 0 && msg_fts_count == 0)
1229 || (session_count > 0 && session_fts_count == 0))
1230 }
1231
1232 pub fn session_count(&self) -> Result<i32> {
1236 let count: i32 = self
1237 .conn
1238 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1239 Ok(count)
1240 }
1241
1242 pub fn message_count(&self) -> Result<i32> {
1244 let count: i32 = self
1245 .conn
1246 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1247 Ok(count)
1248 }
1249
1250 pub fn link_count(&self) -> Result<i32> {
1252 let count: i32 = self
1253 .conn
1254 .query_row("SELECT COUNT(*) FROM session_links", [], |row| row.get(0))?;
1255 Ok(count)
1256 }
1257
1258 pub fn db_path(&self) -> Option<std::path::PathBuf> {
1262 self.conn.path().map(std::path::PathBuf::from)
1263 }
1264
1265 pub fn find_sessions_near_commit_time(
1282 &self,
1283 commit_time: chrono::DateTime<chrono::Utc>,
1284 window_minutes: i64,
1285 working_dir: Option<&str>,
1286 ) -> Result<Vec<Session>> {
1287 let commit_time_str = commit_time.to_rfc3339();
1289
1290 let window = chrono::Duration::minutes(window_minutes);
1292 let window_start = (commit_time - window).to_rfc3339();
1293 let window_end = (commit_time + window).to_rfc3339();
1294
1295 let sql = if working_dir.is_some() {
1296 r#"
1297 SELECT id, tool, tool_version, started_at, ended_at, model,
1298 working_directory, git_branch, source_path, message_count, machine_id
1299 FROM sessions
1300 WHERE working_directory LIKE ?1
1301 AND (
1302 -- Session started before or during the window
1303 (started_at <= ?3)
1304 AND
1305 -- Session ended after or during the window (or is still ongoing)
1306 (ended_at IS NULL OR ended_at >= ?2)
1307 )
1308 ORDER BY
1309 -- Order by how close the session end (or start) is to commit time
1310 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?4))
1311 "#
1312 } else {
1313 r#"
1314 SELECT id, tool, tool_version, started_at, ended_at, model,
1315 working_directory, git_branch, source_path, message_count, machine_id
1316 FROM sessions
1317 WHERE
1318 -- Session started before or during the window
1319 (started_at <= ?2)
1320 AND
1321 -- Session ended after or during the window (or is still ongoing)
1322 (ended_at IS NULL OR ended_at >= ?1)
1323 ORDER BY
1324 -- Order by how close the session end (or start) is to commit time
1325 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?3))
1326 "#
1327 };
1328
1329 let mut stmt = self.conn.prepare(sql)?;
1330
1331 let rows = if let Some(wd) = working_dir {
1332 stmt.query_map(
1333 params![format!("{wd}%"), window_start, window_end, commit_time_str],
1334 Self::row_to_session,
1335 )?
1336 } else {
1337 stmt.query_map(
1338 params![window_start, window_end, commit_time_str],
1339 Self::row_to_session,
1340 )?
1341 };
1342
1343 rows.collect::<Result<Vec<_>, _>>()
1344 .context("Failed to find sessions near commit time")
1345 }
1346
1347 pub fn link_exists(&self, session_id: &Uuid, commit_sha: &str) -> Result<bool> {
1351 let pattern = format!("{commit_sha}%");
1352 let count: i32 = self.conn.query_row(
1353 "SELECT COUNT(*) FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1354 params![session_id.to_string(), pattern],
1355 |row| row.get(0),
1356 )?;
1357 Ok(count > 0)
1358 }
1359
1360 pub fn find_active_sessions_for_directory(
1379 &self,
1380 directory: &str,
1381 recent_minutes: Option<i64>,
1382 ) -> Result<Vec<Session>> {
1383 fn escape_like(input: &str) -> String {
1384 let mut escaped = String::with_capacity(input.len());
1385 for ch in input.chars() {
1386 match ch {
1387 '|' => escaped.push_str("||"),
1388 '%' => escaped.push_str("|%"),
1389 '_' => escaped.push_str("|_"),
1390 _ => escaped.push(ch),
1391 }
1392 }
1393 escaped
1394 }
1395
1396 let minutes = recent_minutes.unwrap_or(5);
1397 let cutoff = (chrono::Utc::now() - chrono::Duration::minutes(minutes)).to_rfc3339();
1398 let separator = std::path::MAIN_SEPARATOR.to_string();
1399 let mut normalized = directory
1400 .trim_end_matches(std::path::MAIN_SEPARATOR)
1401 .to_string();
1402 if normalized.is_empty() {
1403 normalized = separator.clone();
1404 }
1405 let trailing = if normalized == separator {
1406 normalized.clone()
1407 } else {
1408 format!("{normalized}{separator}")
1409 };
1410 let like_pattern = format!("{}%", escape_like(&trailing));
1411
1412 let sql = r#"
1413 SELECT id, tool, tool_version, started_at, ended_at, model,
1414 working_directory, git_branch, source_path, message_count, machine_id
1415 FROM sessions
1416 WHERE (working_directory = ?1
1417 OR working_directory = ?2
1418 OR working_directory LIKE ?3 ESCAPE '|')
1419 AND (ended_at IS NULL OR ended_at >= ?4)
1420 ORDER BY started_at DESC
1421 "#;
1422
1423 let mut stmt = self.conn.prepare(sql)?;
1424 let rows = stmt.query_map(
1425 params![normalized, trailing, like_pattern, cutoff],
1426 Self::row_to_session,
1427 )?;
1428
1429 rows.collect::<Result<Vec<_>, _>>()
1430 .context("Failed to find active sessions for directory")
1431 }
1432
1433 pub fn delete_session(&self, session_id: &Uuid) -> Result<(usize, usize)> {
1444 let session_id_str = session_id.to_string();
1445
1446 self.conn.execute(
1448 "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1449 params![session_id_str],
1450 )?;
1451
1452 let messages_deleted = self.conn.execute(
1454 "DELETE FROM messages WHERE session_id = ?1",
1455 params![session_id_str],
1456 )?;
1457
1458 let links_deleted = self.conn.execute(
1460 "DELETE FROM session_links WHERE session_id = ?1",
1461 params![session_id_str],
1462 )?;
1463
1464 self.conn.execute(
1466 "DELETE FROM annotations WHERE session_id = ?1",
1467 params![session_id_str],
1468 )?;
1469
1470 self.conn.execute(
1472 "DELETE FROM tags WHERE session_id = ?1",
1473 params![session_id_str],
1474 )?;
1475
1476 self.conn.execute(
1478 "DELETE FROM summaries WHERE session_id = ?1",
1479 params![session_id_str],
1480 )?;
1481
1482 self.conn.execute(
1484 "DELETE FROM sessions_fts WHERE session_id = ?1",
1485 params![session_id_str],
1486 )?;
1487
1488 self.conn.execute(
1490 "DELETE FROM sessions WHERE id = ?1",
1491 params![session_id_str],
1492 )?;
1493
1494 Ok((messages_deleted, links_deleted))
1495 }
1496
1497 pub fn insert_annotation(&self, annotation: &Annotation) -> Result<()> {
1503 self.conn.execute(
1504 r#"
1505 INSERT INTO annotations (id, session_id, content, created_at)
1506 VALUES (?1, ?2, ?3, ?4)
1507 "#,
1508 params![
1509 annotation.id.to_string(),
1510 annotation.session_id.to_string(),
1511 annotation.content,
1512 annotation.created_at.to_rfc3339(),
1513 ],
1514 )?;
1515 Ok(())
1516 }
1517
1518 #[allow(dead_code)]
1522 pub fn get_annotations(&self, session_id: &Uuid) -> Result<Vec<Annotation>> {
1523 let mut stmt = self.conn.prepare(
1524 "SELECT id, session_id, content, created_at
1525 FROM annotations
1526 WHERE session_id = ?1
1527 ORDER BY created_at ASC",
1528 )?;
1529
1530 let rows = stmt.query_map(params![session_id.to_string()], |row| {
1531 Ok(Annotation {
1532 id: parse_uuid(&row.get::<_, String>(0)?)?,
1533 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1534 content: row.get(2)?,
1535 created_at: parse_datetime(&row.get::<_, String>(3)?)?,
1536 })
1537 })?;
1538
1539 rows.collect::<Result<Vec<_>, _>>()
1540 .context("Failed to get annotations")
1541 }
1542
1543 #[allow(dead_code)]
1547 pub fn delete_annotation(&self, annotation_id: &Uuid) -> Result<bool> {
1548 let rows_affected = self.conn.execute(
1549 "DELETE FROM annotations WHERE id = ?1",
1550 params![annotation_id.to_string()],
1551 )?;
1552 Ok(rows_affected > 0)
1553 }
1554
1555 #[allow(dead_code)]
1559 pub fn delete_annotations_by_session(&self, session_id: &Uuid) -> Result<usize> {
1560 let rows_affected = self.conn.execute(
1561 "DELETE FROM annotations WHERE session_id = ?1",
1562 params![session_id.to_string()],
1563 )?;
1564 Ok(rows_affected)
1565 }
1566
1567 pub fn insert_tag(&self, tag: &Tag) -> Result<()> {
1574 self.conn.execute(
1575 r#"
1576 INSERT INTO tags (id, session_id, label, created_at)
1577 VALUES (?1, ?2, ?3, ?4)
1578 "#,
1579 params![
1580 tag.id.to_string(),
1581 tag.session_id.to_string(),
1582 tag.label,
1583 tag.created_at.to_rfc3339(),
1584 ],
1585 )?;
1586 Ok(())
1587 }
1588
1589 pub fn get_tags(&self, session_id: &Uuid) -> Result<Vec<Tag>> {
1593 let mut stmt = self.conn.prepare(
1594 "SELECT id, session_id, label, created_at
1595 FROM tags
1596 WHERE session_id = ?1
1597 ORDER BY label ASC",
1598 )?;
1599
1600 let rows = stmt.query_map(params![session_id.to_string()], |row| {
1601 Ok(Tag {
1602 id: parse_uuid(&row.get::<_, String>(0)?)?,
1603 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1604 label: row.get(2)?,
1605 created_at: parse_datetime(&row.get::<_, String>(3)?)?,
1606 })
1607 })?;
1608
1609 rows.collect::<Result<Vec<_>, _>>()
1610 .context("Failed to get tags")
1611 }
1612
1613 pub fn tag_exists(&self, session_id: &Uuid, label: &str) -> Result<bool> {
1615 let count: i32 = self.conn.query_row(
1616 "SELECT COUNT(*) FROM tags WHERE session_id = ?1 AND label = ?2",
1617 params![session_id.to_string(), label],
1618 |row| row.get(0),
1619 )?;
1620 Ok(count > 0)
1621 }
1622
1623 pub fn delete_tag(&self, session_id: &Uuid, label: &str) -> Result<bool> {
1627 let rows_affected = self.conn.execute(
1628 "DELETE FROM tags WHERE session_id = ?1 AND label = ?2",
1629 params![session_id.to_string(), label],
1630 )?;
1631 Ok(rows_affected > 0)
1632 }
1633
1634 #[allow(dead_code)]
1638 pub fn delete_tags_by_session(&self, session_id: &Uuid) -> Result<usize> {
1639 let rows_affected = self.conn.execute(
1640 "DELETE FROM tags WHERE session_id = ?1",
1641 params![session_id.to_string()],
1642 )?;
1643 Ok(rows_affected)
1644 }
1645
1646 pub fn list_sessions_with_tag(&self, label: &str, limit: usize) -> Result<Vec<Session>> {
1650 let mut stmt = self.conn.prepare(
1651 "SELECT s.id, s.tool, s.tool_version, s.started_at, s.ended_at, s.model,
1652 s.working_directory, s.git_branch, s.source_path, s.message_count, s.machine_id
1653 FROM sessions s
1654 INNER JOIN tags t ON s.id = t.session_id
1655 WHERE t.label = ?1
1656 ORDER BY s.started_at DESC
1657 LIMIT ?2",
1658 )?;
1659
1660 let rows = stmt.query_map(params![label, limit], Self::row_to_session)?;
1661
1662 rows.collect::<Result<Vec<_>, _>>()
1663 .context("Failed to list sessions with tag")
1664 }
1665
1666 pub fn insert_summary(&self, summary: &Summary) -> Result<()> {
1673 self.conn.execute(
1674 r#"
1675 INSERT INTO summaries (id, session_id, content, generated_at)
1676 VALUES (?1, ?2, ?3, ?4)
1677 "#,
1678 params![
1679 summary.id.to_string(),
1680 summary.session_id.to_string(),
1681 summary.content,
1682 summary.generated_at.to_rfc3339(),
1683 ],
1684 )?;
1685 Ok(())
1686 }
1687
1688 pub fn get_summary(&self, session_id: &Uuid) -> Result<Option<Summary>> {
1690 self.conn
1691 .query_row(
1692 "SELECT id, session_id, content, generated_at
1693 FROM summaries
1694 WHERE session_id = ?1",
1695 params![session_id.to_string()],
1696 |row| {
1697 Ok(Summary {
1698 id: parse_uuid(&row.get::<_, String>(0)?)?,
1699 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1700 content: row.get(2)?,
1701 generated_at: parse_datetime(&row.get::<_, String>(3)?)?,
1702 })
1703 },
1704 )
1705 .optional()
1706 .context("Failed to get summary")
1707 }
1708
1709 pub fn update_summary(&self, session_id: &Uuid, content: &str) -> Result<bool> {
1714 let now = chrono::Utc::now().to_rfc3339();
1715 let rows_affected = self.conn.execute(
1716 "UPDATE summaries SET content = ?1, generated_at = ?2 WHERE session_id = ?3",
1717 params![content, now, session_id.to_string()],
1718 )?;
1719 Ok(rows_affected > 0)
1720 }
1721
1722 #[allow(dead_code)]
1726 pub fn delete_summary(&self, session_id: &Uuid) -> Result<bool> {
1727 let rows_affected = self.conn.execute(
1728 "DELETE FROM summaries WHERE session_id = ?1",
1729 params![session_id.to_string()],
1730 )?;
1731 Ok(rows_affected > 0)
1732 }
1733
1734 pub fn upsert_machine(&self, machine: &Machine) -> Result<()> {
1741 self.conn.execute(
1742 r#"
1743 INSERT INTO machines (id, name, created_at)
1744 VALUES (?1, ?2, ?3)
1745 ON CONFLICT(id) DO UPDATE SET
1746 name = ?2
1747 "#,
1748 params![machine.id, machine.name, machine.created_at],
1749 )?;
1750 Ok(())
1751 }
1752
1753 #[allow(dead_code)]
1757 pub fn get_machine(&self, id: &str) -> Result<Option<Machine>> {
1758 self.conn
1759 .query_row(
1760 "SELECT id, name, created_at FROM machines WHERE id = ?1",
1761 params![id],
1762 |row| {
1763 Ok(Machine {
1764 id: row.get(0)?,
1765 name: row.get(1)?,
1766 created_at: row.get(2)?,
1767 })
1768 },
1769 )
1770 .optional()
1771 .context("Failed to get machine")
1772 }
1773
1774 #[allow(dead_code)]
1779 pub fn get_machine_name(&self, id: &str) -> Result<String> {
1780 if let Some(machine) = self.get_machine(id)? {
1781 Ok(machine.name)
1782 } else {
1783 if id.len() > 8 {
1785 Ok(id[..8].to_string())
1786 } else {
1787 Ok(id.to_string())
1788 }
1789 }
1790 }
1791
1792 #[allow(dead_code)]
1796 pub fn list_machines(&self) -> Result<Vec<Machine>> {
1797 let mut stmt = self
1798 .conn
1799 .prepare("SELECT id, name, created_at FROM machines ORDER BY created_at ASC")?;
1800
1801 let rows = stmt.query_map([], |row| {
1802 Ok(Machine {
1803 id: row.get(0)?,
1804 name: row.get(1)?,
1805 created_at: row.get(2)?,
1806 })
1807 })?;
1808
1809 rows.collect::<Result<Vec<_>, _>>()
1810 .context("Failed to list machines")
1811 }
1812
1813 pub fn get_most_recent_session_for_directory(
1818 &self,
1819 working_dir: &str,
1820 ) -> Result<Option<Session>> {
1821 self.conn
1822 .query_row(
1823 "SELECT id, tool, tool_version, started_at, ended_at, model,
1824 working_directory, git_branch, source_path, message_count, machine_id
1825 FROM sessions
1826 WHERE working_directory LIKE ?1
1827 ORDER BY started_at DESC
1828 LIMIT 1",
1829 params![format!("{working_dir}%")],
1830 Self::row_to_session,
1831 )
1832 .optional()
1833 .context("Failed to get most recent session for directory")
1834 }
1835
1836 pub fn vacuum(&self) -> Result<()> {
1843 self.conn.execute("VACUUM", [])?;
1844 Ok(())
1845 }
1846
1847 pub fn file_size(&self) -> Result<Option<u64>> {
1851 if let Some(path) = self.db_path() {
1852 let metadata = std::fs::metadata(&path)?;
1853 Ok(Some(metadata.len()))
1854 } else {
1855 Ok(None)
1856 }
1857 }
1858
1859 pub fn delete_sessions_older_than(&self, before: DateTime<Utc>) -> Result<usize> {
1871 let before_str = before.to_rfc3339();
1872
1873 let mut stmt = self
1875 .conn
1876 .prepare("SELECT id FROM sessions WHERE started_at < ?1")?;
1877 let session_ids: Vec<String> = stmt
1878 .query_map(params![before_str], |row| row.get(0))?
1879 .collect::<Result<Vec<_>, _>>()?;
1880
1881 if session_ids.is_empty() {
1882 return Ok(0);
1883 }
1884
1885 let count = session_ids.len();
1886
1887 for session_id_str in &session_ids {
1889 self.conn.execute(
1891 "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1892 params![session_id_str],
1893 )?;
1894
1895 self.conn.execute(
1897 "DELETE FROM messages WHERE session_id = ?1",
1898 params![session_id_str],
1899 )?;
1900
1901 self.conn.execute(
1903 "DELETE FROM session_links WHERE session_id = ?1",
1904 params![session_id_str],
1905 )?;
1906
1907 self.conn.execute(
1909 "DELETE FROM annotations WHERE session_id = ?1",
1910 params![session_id_str],
1911 )?;
1912
1913 self.conn.execute(
1915 "DELETE FROM tags WHERE session_id = ?1",
1916 params![session_id_str],
1917 )?;
1918
1919 self.conn.execute(
1921 "DELETE FROM summaries WHERE session_id = ?1",
1922 params![session_id_str],
1923 )?;
1924
1925 self.conn.execute(
1927 "DELETE FROM sessions_fts WHERE session_id = ?1",
1928 params![session_id_str],
1929 )?;
1930 }
1931
1932 self.conn.execute(
1934 "DELETE FROM sessions WHERE started_at < ?1",
1935 params![before_str],
1936 )?;
1937
1938 Ok(count)
1939 }
1940
1941 pub fn count_sessions_older_than(&self, before: DateTime<Utc>) -> Result<i32> {
1951 let before_str = before.to_rfc3339();
1952 let count: i32 = self.conn.query_row(
1953 "SELECT COUNT(*) FROM sessions WHERE started_at < ?1",
1954 params![before_str],
1955 |row| row.get(0),
1956 )?;
1957 Ok(count)
1958 }
1959
1960 pub fn get_sessions_older_than(&self, before: DateTime<Utc>) -> Result<Vec<Session>> {
1970 let before_str = before.to_rfc3339();
1971 let mut stmt = self.conn.prepare(
1972 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
1973 FROM sessions
1974 WHERE started_at < ?1
1975 ORDER BY started_at ASC",
1976 )?;
1977
1978 let rows = stmt.query_map(params![before_str], Self::row_to_session)?;
1979
1980 rows.collect::<Result<Vec<_>, _>>()
1981 .context("Failed to get sessions older than cutoff")
1982 }
1983
1984 pub fn stats(&self) -> Result<DatabaseStats> {
1991 let session_count = self.session_count()?;
1992 let message_count = self.message_count()?;
1993 let link_count = self.link_count()?;
1994
1995 let oldest: Option<String> = self
1997 .conn
1998 .query_row("SELECT MIN(started_at) FROM sessions", [], |row| row.get(0))
1999 .optional()?
2000 .flatten();
2001
2002 let newest: Option<String> = self
2003 .conn
2004 .query_row("SELECT MAX(started_at) FROM sessions", [], |row| row.get(0))
2005 .optional()?
2006 .flatten();
2007
2008 let oldest_session = oldest
2009 .map(|s| parse_datetime(&s))
2010 .transpose()
2011 .unwrap_or(None);
2012 let newest_session = newest
2013 .map(|s| parse_datetime(&s))
2014 .transpose()
2015 .unwrap_or(None);
2016
2017 let mut stmt = self
2019 .conn
2020 .prepare("SELECT tool, COUNT(*) FROM sessions GROUP BY tool ORDER BY COUNT(*) DESC")?;
2021 let sessions_by_tool: Vec<(String, i32)> = stmt
2022 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
2023 .collect::<Result<Vec<_>, _>>()?;
2024
2025 Ok(DatabaseStats {
2026 session_count,
2027 message_count,
2028 link_count,
2029 oldest_session,
2030 newest_session,
2031 sessions_by_tool,
2032 })
2033 }
2034}
2035
2036#[derive(Debug, Clone)]
2038pub struct DatabaseStats {
2039 pub session_count: i32,
2041 pub message_count: i32,
2043 pub link_count: i32,
2045 pub oldest_session: Option<DateTime<Utc>>,
2047 pub newest_session: Option<DateTime<Utc>>,
2049 pub sessions_by_tool: Vec<(String, i32)>,
2051}
2052
2053#[cfg(test)]
2054mod tests {
2055 use super::*;
2056 use crate::storage::models::{
2057 LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
2058 };
2059 use chrono::{Duration, Utc};
2060 use tempfile::tempdir;
2061
2062 fn create_test_db() -> (Database, tempfile::TempDir) {
2065 let dir = tempdir().expect("Failed to create temp directory");
2066 let db_path = dir.path().join("test.db");
2067 let db = Database::open(&db_path).expect("Failed to open test database");
2068 (db, dir)
2069 }
2070
2071 fn create_test_session(
2073 tool: &str,
2074 working_directory: &str,
2075 started_at: chrono::DateTime<Utc>,
2076 source_path: Option<&str>,
2077 ) -> Session {
2078 Session {
2079 id: Uuid::new_v4(),
2080 tool: tool.to_string(),
2081 tool_version: Some("1.0.0".to_string()),
2082 started_at,
2083 ended_at: None,
2084 model: Some("test-model".to_string()),
2085 working_directory: working_directory.to_string(),
2086 git_branch: Some("main".to_string()),
2087 source_path: source_path.map(|s| s.to_string()),
2088 message_count: 0,
2089 machine_id: Some("test-machine".to_string()),
2090 }
2091 }
2092
2093 fn create_test_message(
2095 session_id: Uuid,
2096 index: i32,
2097 role: MessageRole,
2098 content: &str,
2099 ) -> Message {
2100 Message {
2101 id: Uuid::new_v4(),
2102 session_id,
2103 parent_id: None,
2104 index,
2105 timestamp: Utc::now(),
2106 role,
2107 content: MessageContent::Text(content.to_string()),
2108 model: Some("test-model".to_string()),
2109 git_branch: Some("main".to_string()),
2110 cwd: Some("/test/cwd".to_string()),
2111 }
2112 }
2113
2114 fn create_test_link(
2116 session_id: Uuid,
2117 commit_sha: Option<&str>,
2118 link_type: LinkType,
2119 ) -> SessionLink {
2120 SessionLink {
2121 id: Uuid::new_v4(),
2122 session_id,
2123 link_type,
2124 commit_sha: commit_sha.map(|s| s.to_string()),
2125 branch: Some("main".to_string()),
2126 remote: Some("origin".to_string()),
2127 created_at: Utc::now(),
2128 created_by: LinkCreator::Auto,
2129 confidence: Some(0.95),
2130 }
2131 }
2132
2133 #[test]
2136 fn test_insert_and_get_session() {
2137 let (db, _dir) = create_test_db();
2138 let session = create_test_session(
2139 "claude-code",
2140 "/home/user/project",
2141 Utc::now(),
2142 Some("/path/to/source.jsonl"),
2143 );
2144
2145 db.insert_session(&session)
2146 .expect("Failed to insert session");
2147
2148 let retrieved = db
2149 .get_session(&session.id)
2150 .expect("Failed to get session")
2151 .expect("Session should exist");
2152
2153 assert_eq!(retrieved.id, session.id, "Session ID should match");
2154 assert_eq!(retrieved.tool, session.tool, "Tool should match");
2155 assert_eq!(
2156 retrieved.tool_version, session.tool_version,
2157 "Tool version should match"
2158 );
2159 assert_eq!(
2160 retrieved.working_directory, session.working_directory,
2161 "Working directory should match"
2162 );
2163 assert_eq!(
2164 retrieved.git_branch, session.git_branch,
2165 "Git branch should match"
2166 );
2167 assert_eq!(
2168 retrieved.source_path, session.source_path,
2169 "Source path should match"
2170 );
2171 }
2172
2173 #[test]
2174 fn test_list_sessions() {
2175 let (db, _dir) = create_test_db();
2176 let now = Utc::now();
2177
2178 let session1 =
2180 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
2181 let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
2182 let session3 = create_test_session("claude-code", "/project3", now, None);
2183
2184 db.insert_session(&session1)
2185 .expect("Failed to insert session1");
2186 db.insert_session(&session2)
2187 .expect("Failed to insert session2");
2188 db.insert_session(&session3)
2189 .expect("Failed to insert session3");
2190
2191 let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
2192
2193 assert_eq!(sessions.len(), 3, "Should have 3 sessions");
2194 assert_eq!(
2196 sessions[0].id, session3.id,
2197 "Most recent session should be first"
2198 );
2199 assert_eq!(
2200 sessions[1].id, session2.id,
2201 "Second most recent session should be second"
2202 );
2203 assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
2204 }
2205
2206 #[test]
2207 fn test_list_ended_sessions() {
2208 let (db, _dir) = create_test_db();
2209 let now = Utc::now();
2210
2211 let mut ended = create_test_session(
2212 "claude-code",
2213 "/home/user/project",
2214 now - Duration::minutes(60),
2215 None,
2216 );
2217 ended.ended_at = Some(now - Duration::minutes(30));
2218
2219 let ongoing = create_test_session(
2220 "claude-code",
2221 "/home/user/project",
2222 now - Duration::minutes(10),
2223 None,
2224 );
2225
2226 db.insert_session(&ended).expect("insert ended session");
2227 db.insert_session(&ongoing).expect("insert ongoing session");
2228
2229 let sessions = db
2230 .list_ended_sessions(100, None)
2231 .expect("Failed to list ended sessions");
2232
2233 assert_eq!(sessions.len(), 1);
2234 assert_eq!(sessions[0].id, ended.id);
2235 }
2236
2237 #[test]
2238 fn test_list_sessions_with_working_dir_filter() {
2239 let (db, _dir) = create_test_db();
2240 let now = Utc::now();
2241
2242 let session1 = create_test_session(
2243 "claude-code",
2244 "/home/user/project-a",
2245 now - Duration::hours(1),
2246 None,
2247 );
2248 let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
2249 let session3 = create_test_session("claude-code", "/other/path", now, None);
2250
2251 db.insert_session(&session1)
2252 .expect("Failed to insert session1");
2253 db.insert_session(&session2)
2254 .expect("Failed to insert session2");
2255 db.insert_session(&session3)
2256 .expect("Failed to insert session3");
2257
2258 let sessions = db
2260 .list_sessions(10, Some("/home/user"))
2261 .expect("Failed to list sessions");
2262
2263 assert_eq!(
2264 sessions.len(),
2265 2,
2266 "Should have 2 sessions matching /home/user prefix"
2267 );
2268
2269 let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
2271 assert!(ids.contains(&session1.id), "Should contain session1");
2272 assert!(ids.contains(&session2.id), "Should contain session2");
2273 assert!(!ids.contains(&session3.id), "Should not contain session3");
2274 }
2275
2276 #[test]
2277 fn test_session_exists_by_source() {
2278 let (db, _dir) = create_test_db();
2279 let source_path = "/path/to/session.jsonl";
2280
2281 let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
2282
2283 assert!(
2285 !db.session_exists_by_source(source_path)
2286 .expect("Failed to check existence"),
2287 "Session should not exist before insert"
2288 );
2289
2290 db.insert_session(&session)
2291 .expect("Failed to insert session");
2292
2293 assert!(
2295 db.session_exists_by_source(source_path)
2296 .expect("Failed to check existence"),
2297 "Session should exist after insert"
2298 );
2299
2300 assert!(
2302 !db.session_exists_by_source("/other/path.jsonl")
2303 .expect("Failed to check existence"),
2304 "Different source path should not exist"
2305 );
2306 }
2307
2308 #[test]
2309 fn test_get_session_by_source() {
2310 let (db, _dir) = create_test_db();
2311 let source_path = "/path/to/session.jsonl";
2312
2313 let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
2314
2315 assert!(
2317 db.get_session_by_source(source_path)
2318 .expect("Failed to get session")
2319 .is_none(),
2320 "Session should not exist before insert"
2321 );
2322
2323 db.insert_session(&session)
2324 .expect("Failed to insert session");
2325
2326 let retrieved = db
2328 .get_session_by_source(source_path)
2329 .expect("Failed to get session")
2330 .expect("Session should exist after insert");
2331
2332 assert_eq!(retrieved.id, session.id, "Session ID should match");
2333 assert_eq!(
2334 retrieved.source_path,
2335 Some(source_path.to_string()),
2336 "Source path should match"
2337 );
2338
2339 assert!(
2341 db.get_session_by_source("/other/path.jsonl")
2342 .expect("Failed to get session")
2343 .is_none(),
2344 "Different source path should return None"
2345 );
2346 }
2347
2348 #[test]
2349 fn test_update_session_branch() {
2350 let (db, _dir) = create_test_db();
2351 let now = Utc::now();
2352
2353 let mut session = create_test_session("claude-code", "/project", now, None);
2355 session.git_branch = Some("main".to_string());
2356
2357 db.insert_session(&session)
2358 .expect("Failed to insert session");
2359
2360 let fetched = db
2362 .get_session(&session.id)
2363 .expect("Failed to get session")
2364 .expect("Session should exist");
2365 assert_eq!(fetched.git_branch, Some("main".to_string()));
2366
2367 let rows = db
2369 .update_session_branch(session.id, "feature-branch")
2370 .expect("Failed to update branch");
2371 assert_eq!(rows, 1, "Should update exactly one row");
2372
2373 let fetched = db
2375 .get_session(&session.id)
2376 .expect("Failed to get session")
2377 .expect("Session should exist");
2378 assert_eq!(fetched.git_branch, Some("feature-branch".to_string()));
2379 }
2380
2381 #[test]
2382 fn test_update_session_branch_nonexistent() {
2383 let (db, _dir) = create_test_db();
2384 let nonexistent_id = Uuid::new_v4();
2385
2386 let rows = db
2388 .update_session_branch(nonexistent_id, "some-branch")
2389 .expect("Failed to update branch");
2390 assert_eq!(
2391 rows, 0,
2392 "Should not update any rows for nonexistent session"
2393 );
2394 }
2395
2396 #[test]
2397 fn test_update_session_branch_from_none() {
2398 let (db, _dir) = create_test_db();
2399 let now = Utc::now();
2400
2401 let mut session = create_test_session("claude-code", "/project", now, None);
2403 session.git_branch = None; db.insert_session(&session)
2406 .expect("Failed to insert session");
2407
2408 let fetched = db
2410 .get_session(&session.id)
2411 .expect("Failed to get session")
2412 .expect("Session should exist");
2413 assert_eq!(fetched.git_branch, None);
2414
2415 let rows = db
2417 .update_session_branch(session.id, "new-branch")
2418 .expect("Failed to update branch");
2419 assert_eq!(rows, 1, "Should update exactly one row");
2420
2421 let fetched = db
2423 .get_session(&session.id)
2424 .expect("Failed to get session")
2425 .expect("Session should exist");
2426 assert_eq!(fetched.git_branch, Some("new-branch".to_string()));
2427 }
2428
2429 #[test]
2430 fn test_get_nonexistent_session() {
2431 let (db, _dir) = create_test_db();
2432 let nonexistent_id = Uuid::new_v4();
2433
2434 let result = db
2435 .get_session(&nonexistent_id)
2436 .expect("Failed to query for nonexistent session");
2437
2438 assert!(
2439 result.is_none(),
2440 "Should return None for nonexistent session"
2441 );
2442 }
2443
2444 #[test]
2447 fn test_insert_and_get_messages() {
2448 let (db, _dir) = create_test_db();
2449
2450 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2451 db.insert_session(&session)
2452 .expect("Failed to insert session");
2453
2454 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
2455 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
2456
2457 db.insert_message(&msg1)
2458 .expect("Failed to insert message 1");
2459 db.insert_message(&msg2)
2460 .expect("Failed to insert message 2");
2461
2462 let messages = db
2463 .get_messages(&session.id)
2464 .expect("Failed to get messages");
2465
2466 assert_eq!(messages.len(), 2, "Should have 2 messages");
2467 assert_eq!(messages[0].id, msg1.id, "First message ID should match");
2468 assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
2469 assert_eq!(
2470 messages[0].role,
2471 MessageRole::User,
2472 "First message role should be User"
2473 );
2474 assert_eq!(
2475 messages[1].role,
2476 MessageRole::Assistant,
2477 "Second message role should be Assistant"
2478 );
2479 }
2480
2481 #[test]
2482 fn test_messages_ordered_by_index() {
2483 let (db, _dir) = create_test_db();
2484
2485 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2486 db.insert_session(&session)
2487 .expect("Failed to insert session");
2488
2489 let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
2491 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
2492 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
2493
2494 db.insert_message(&msg3)
2495 .expect("Failed to insert message 3");
2496 db.insert_message(&msg1)
2497 .expect("Failed to insert message 1");
2498 db.insert_message(&msg2)
2499 .expect("Failed to insert message 2");
2500
2501 let messages = db
2502 .get_messages(&session.id)
2503 .expect("Failed to get messages");
2504
2505 assert_eq!(messages.len(), 3, "Should have 3 messages");
2506 assert_eq!(messages[0].index, 0, "First message should have index 0");
2507 assert_eq!(messages[1].index, 1, "Second message should have index 1");
2508 assert_eq!(messages[2].index, 2, "Third message should have index 2");
2509
2510 assert_eq!(
2512 messages[0].content.text(),
2513 "First",
2514 "First message content should be 'First'"
2515 );
2516 assert_eq!(
2517 messages[1].content.text(),
2518 "Second",
2519 "Second message content should be 'Second'"
2520 );
2521 assert_eq!(
2522 messages[2].content.text(),
2523 "Third",
2524 "Third message content should be 'Third'"
2525 );
2526 }
2527
2528 #[test]
2531 fn test_insert_and_get_links_by_session() {
2532 let (db, _dir) = create_test_db();
2533
2534 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2535 db.insert_session(&session)
2536 .expect("Failed to insert session");
2537
2538 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2539 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2540
2541 db.insert_link(&link1).expect("Failed to insert link 1");
2542 db.insert_link(&link2).expect("Failed to insert link 2");
2543
2544 let links = db
2545 .get_links_by_session(&session.id)
2546 .expect("Failed to get links");
2547
2548 assert_eq!(links.len(), 2, "Should have 2 links");
2549
2550 let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
2551 assert!(link_ids.contains(&link1.id), "Should contain link1");
2552 assert!(link_ids.contains(&link2.id), "Should contain link2");
2553
2554 let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
2556 assert_eq!(
2557 retrieved_link.commit_sha,
2558 Some("abc123def456".to_string()),
2559 "Commit SHA should match"
2560 );
2561 assert_eq!(
2562 retrieved_link.link_type,
2563 LinkType::Commit,
2564 "Link type should be Commit"
2565 );
2566 assert_eq!(
2567 retrieved_link.created_by,
2568 LinkCreator::Auto,
2569 "Created by should be Auto"
2570 );
2571 }
2572
2573 #[test]
2574 fn test_get_links_by_commit() {
2575 let (db, _dir) = create_test_db();
2576
2577 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2578 db.insert_session(&session)
2579 .expect("Failed to insert session");
2580
2581 let full_sha = "abc123def456789012345678901234567890abcd";
2582 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
2583 db.insert_link(&link).expect("Failed to insert link");
2584
2585 let links_full = db
2587 .get_links_by_commit(full_sha)
2588 .expect("Failed to get links by full SHA");
2589 assert_eq!(links_full.len(), 1, "Should find link by full SHA");
2590 assert_eq!(links_full[0].id, link.id, "Link ID should match");
2591
2592 let links_partial = db
2594 .get_links_by_commit("abc123")
2595 .expect("Failed to get links by partial SHA");
2596 assert_eq!(
2597 links_partial.len(),
2598 1,
2599 "Should find link by partial SHA prefix"
2600 );
2601 assert_eq!(links_partial[0].id, link.id, "Link ID should match");
2602
2603 let links_none = db
2605 .get_links_by_commit("zzz999")
2606 .expect("Failed to get links by non-matching SHA");
2607 assert_eq!(
2608 links_none.len(),
2609 0,
2610 "Should not find link with non-matching SHA"
2611 );
2612 }
2613
2614 #[test]
2617 fn test_database_creation() {
2618 let dir = tempdir().expect("Failed to create temp directory");
2619 let db_path = dir.path().join("new_test.db");
2620
2621 assert!(
2623 !db_path.exists(),
2624 "Database file should not exist before creation"
2625 );
2626
2627 let db = Database::open(&db_path).expect("Failed to create database");
2628
2629 assert!(
2631 db_path.exists(),
2632 "Database file should exist after creation"
2633 );
2634
2635 let session_count = db.session_count().expect("Failed to get session count");
2637 assert_eq!(session_count, 0, "New database should have 0 sessions");
2638
2639 let message_count = db.message_count().expect("Failed to get message count");
2640 assert_eq!(message_count, 0, "New database should have 0 messages");
2641 }
2642
2643 #[test]
2644 fn test_session_count() {
2645 let (db, _dir) = create_test_db();
2646
2647 assert_eq!(
2648 db.session_count().expect("Failed to get count"),
2649 0,
2650 "Initial session count should be 0"
2651 );
2652
2653 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2654 db.insert_session(&session1)
2655 .expect("Failed to insert session1");
2656
2657 assert_eq!(
2658 db.session_count().expect("Failed to get count"),
2659 1,
2660 "Session count should be 1 after first insert"
2661 );
2662
2663 let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
2664 db.insert_session(&session2)
2665 .expect("Failed to insert session2");
2666
2667 assert_eq!(
2668 db.session_count().expect("Failed to get count"),
2669 2,
2670 "Session count should be 2 after second insert"
2671 );
2672 }
2673
2674 #[test]
2675 fn test_message_count() {
2676 let (db, _dir) = create_test_db();
2677
2678 assert_eq!(
2679 db.message_count().expect("Failed to get count"),
2680 0,
2681 "Initial message count should be 0"
2682 );
2683
2684 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2685 db.insert_session(&session)
2686 .expect("Failed to insert session");
2687
2688 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
2689 db.insert_message(&msg1).expect("Failed to insert message1");
2690
2691 assert_eq!(
2692 db.message_count().expect("Failed to get count"),
2693 1,
2694 "Message count should be 1 after first insert"
2695 );
2696
2697 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
2698 let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
2699 db.insert_message(&msg2).expect("Failed to insert message2");
2700 db.insert_message(&msg3).expect("Failed to insert message3");
2701
2702 assert_eq!(
2703 db.message_count().expect("Failed to get count"),
2704 3,
2705 "Message count should be 3 after all inserts"
2706 );
2707 }
2708
2709 #[test]
2710 fn test_link_count() {
2711 let (db, _dir) = create_test_db();
2712
2713 assert_eq!(
2714 db.link_count().expect("Failed to get count"),
2715 0,
2716 "Initial link count should be 0"
2717 );
2718
2719 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2720 db.insert_session(&session)
2721 .expect("Failed to insert session");
2722
2723 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2724 db.insert_link(&link1).expect("Failed to insert link1");
2725
2726 assert_eq!(
2727 db.link_count().expect("Failed to get count"),
2728 1,
2729 "Link count should be 1 after first insert"
2730 );
2731
2732 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2733 db.insert_link(&link2).expect("Failed to insert link2");
2734
2735 assert_eq!(
2736 db.link_count().expect("Failed to get count"),
2737 2,
2738 "Link count should be 2 after second insert"
2739 );
2740 }
2741
2742 #[test]
2743 fn test_db_path() {
2744 let dir = tempdir().expect("Failed to create temp directory");
2745 let db_path = dir.path().join("test.db");
2746 let db = Database::open(&db_path).expect("Failed to open test database");
2747
2748 let retrieved_path = db.db_path();
2749 assert!(
2750 retrieved_path.is_some(),
2751 "Database path should be available"
2752 );
2753
2754 let expected = db_path.canonicalize().unwrap_or(db_path);
2756 let actual = retrieved_path.unwrap();
2757 let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
2758
2759 assert_eq!(
2760 actual_canonical, expected,
2761 "Database path should match (after canonicalization)"
2762 );
2763 }
2764
2765 #[test]
2768 fn test_search_messages_basic() {
2769 let (db, _dir) = create_test_db();
2770
2771 let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
2772 db.insert_session(&session)
2773 .expect("Failed to insert session");
2774
2775 let msg1 = create_test_message(
2776 session.id,
2777 0,
2778 MessageRole::User,
2779 "How do I implement error handling in Rust?",
2780 );
2781 let msg2 = create_test_message(
2782 session.id,
2783 1,
2784 MessageRole::Assistant,
2785 "You can use Result types for error handling. The anyhow crate is also helpful.",
2786 );
2787
2788 db.insert_message(&msg1)
2789 .expect("Failed to insert message 1");
2790 db.insert_message(&msg2)
2791 .expect("Failed to insert message 2");
2792
2793 let results = db
2795 .search_messages("error", 10, None, None, None)
2796 .expect("Failed to search");
2797
2798 assert_eq!(
2799 results.len(),
2800 2,
2801 "Should find 2 messages containing 'error'"
2802 );
2803 }
2804
2805 #[test]
2806 fn test_search_messages_no_results() {
2807 let (db, _dir) = create_test_db();
2808
2809 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2810 db.insert_session(&session)
2811 .expect("Failed to insert session");
2812
2813 let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
2814 db.insert_message(&msg).expect("Failed to insert message");
2815
2816 let results = db
2818 .search_messages("nonexistent_term_xyz", 10, None, None, None)
2819 .expect("Failed to search");
2820
2821 assert!(results.is_empty(), "Should find no results");
2822 }
2823
2824 #[test]
2825 fn test_search_messages_with_role_filter() {
2826 let (db, _dir) = create_test_db();
2827
2828 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2829 db.insert_session(&session)
2830 .expect("Failed to insert session");
2831
2832 let msg1 = create_test_message(
2833 session.id,
2834 0,
2835 MessageRole::User,
2836 "Tell me about Rust programming",
2837 );
2838 let msg2 = create_test_message(
2839 session.id,
2840 1,
2841 MessageRole::Assistant,
2842 "Rust is a systems programming language",
2843 );
2844
2845 db.insert_message(&msg1)
2846 .expect("Failed to insert message 1");
2847 db.insert_message(&msg2)
2848 .expect("Failed to insert message 2");
2849
2850 let user_results = db
2852 .search_messages("programming", 10, None, None, Some("user"))
2853 .expect("Failed to search");
2854
2855 assert_eq!(user_results.len(), 1, "Should find 1 user message");
2856 assert_eq!(
2857 user_results[0].role,
2858 MessageRole::User,
2859 "Result should be from user"
2860 );
2861
2862 let assistant_results = db
2864 .search_messages("programming", 10, None, None, Some("assistant"))
2865 .expect("Failed to search");
2866
2867 assert_eq!(
2868 assistant_results.len(),
2869 1,
2870 "Should find 1 assistant message"
2871 );
2872 assert_eq!(
2873 assistant_results[0].role,
2874 MessageRole::Assistant,
2875 "Result should be from assistant"
2876 );
2877 }
2878
2879 #[test]
2880 fn test_search_messages_with_repo_filter() {
2881 let (db, _dir) = create_test_db();
2882
2883 let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
2884 let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
2885
2886 db.insert_session(&session1).expect("insert 1");
2887 db.insert_session(&session2).expect("insert 2");
2888
2889 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
2890 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
2891
2892 db.insert_message(&msg1).expect("insert msg 1");
2893 db.insert_message(&msg2).expect("insert msg 2");
2894
2895 let results = db
2897 .search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
2898 .expect("Failed to search");
2899
2900 assert_eq!(results.len(), 1, "Should find 1 message in project-a");
2901 assert!(
2902 results[0].working_directory.contains("project-a"),
2903 "Should be from project-a"
2904 );
2905 }
2906
2907 #[test]
2908 fn test_search_messages_limit() {
2909 let (db, _dir) = create_test_db();
2910
2911 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2912 db.insert_session(&session).expect("insert session");
2913
2914 for i in 0..5 {
2916 let msg = create_test_message(
2917 session.id,
2918 i,
2919 MessageRole::User,
2920 &format!("This is test message number {i}"),
2921 );
2922 db.insert_message(&msg).expect("insert message");
2923 }
2924
2925 let results = db
2927 .search_messages("test", 3, None, None, None)
2928 .expect("Failed to search");
2929
2930 assert_eq!(results.len(), 3, "Should respect limit of 3");
2931 }
2932
2933 #[test]
2934 fn test_search_index_needs_rebuild_empty_db() {
2935 let (db, _dir) = create_test_db();
2936
2937 let needs_rebuild = db
2938 .search_index_needs_rebuild()
2939 .expect("Failed to check rebuild status");
2940
2941 assert!(!needs_rebuild, "Empty database should not need rebuild");
2942 }
2943
2944 #[test]
2945 fn test_rebuild_search_index() {
2946 let (db, _dir) = create_test_db();
2947
2948 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2949 db.insert_session(&session).expect("insert session");
2950
2951 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
2952 let msg2 = create_test_message(
2953 session.id,
2954 1,
2955 MessageRole::Assistant,
2956 "Second test response",
2957 );
2958
2959 db.insert_message(&msg1).expect("insert msg 1");
2960 db.insert_message(&msg2).expect("insert msg 2");
2961
2962 db.conn
2964 .execute("DELETE FROM messages_fts", [])
2965 .expect("clear fts");
2966
2967 assert!(
2969 db.search_index_needs_rebuild().expect("check rebuild"),
2970 "Should need rebuild after clearing FTS"
2971 );
2972
2973 let count = db.rebuild_search_index().expect("rebuild");
2975 assert_eq!(count, 2, "Should have indexed 2 messages");
2976
2977 assert!(
2979 !db.search_index_needs_rebuild().expect("check rebuild"),
2980 "Should not need rebuild after rebuilding"
2981 );
2982
2983 let results = db
2985 .search_messages("test", 10, None, None, None)
2986 .expect("search");
2987 assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
2988 }
2989
2990 #[test]
2991 fn test_search_with_block_content() {
2992 let (db, _dir) = create_test_db();
2993
2994 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2995 db.insert_session(&session).expect("insert session");
2996
2997 let block_content = MessageContent::Blocks(vec![
2999 crate::storage::models::ContentBlock::Text {
3000 text: "Let me help with your database query.".to_string(),
3001 },
3002 crate::storage::models::ContentBlock::ToolUse {
3003 id: "tool_123".to_string(),
3004 name: "Bash".to_string(),
3005 input: serde_json::json!({"command": "ls -la"}),
3006 },
3007 ]);
3008
3009 let msg = Message {
3010 id: Uuid::new_v4(),
3011 session_id: session.id,
3012 parent_id: None,
3013 index: 0,
3014 timestamp: Utc::now(),
3015 role: MessageRole::Assistant,
3016 content: block_content,
3017 model: Some("claude-opus-4".to_string()),
3018 git_branch: Some("main".to_string()),
3019 cwd: Some("/project".to_string()),
3020 };
3021
3022 db.insert_message(&msg).expect("insert message");
3023
3024 let results = db
3026 .search_messages("database", 10, None, None, None)
3027 .expect("search");
3028
3029 assert_eq!(results.len(), 1, "Should find message with block content");
3030 }
3031
3032 #[test]
3033 fn test_search_result_contains_session_info() {
3034 let (db, _dir) = create_test_db();
3035
3036 let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
3037 db.insert_session(&session).expect("insert session");
3038
3039 let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
3040 db.insert_message(&msg).expect("insert message");
3041
3042 let results = db
3043 .search_messages("Search", 10, None, None, None)
3044 .expect("search");
3045
3046 assert_eq!(results.len(), 1, "Should find 1 result");
3047 assert_eq!(results[0].session_id, session.id, "Session ID should match");
3048 assert_eq!(results[0].message_id, msg.id, "Message ID should match");
3049 assert_eq!(
3050 results[0].working_directory, "/home/user/my-project",
3051 "Working directory should match"
3052 );
3053 assert_eq!(results[0].role, MessageRole::User, "Role should match");
3054 }
3055
3056 #[test]
3059 fn test_delete_link_by_id() {
3060 let (db, _dir) = create_test_db();
3061
3062 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3063 db.insert_session(&session)
3064 .expect("Failed to insert session");
3065
3066 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3067 db.insert_link(&link).expect("Failed to insert link");
3068
3069 let links_before = db
3071 .get_links_by_session(&session.id)
3072 .expect("Failed to get links");
3073 assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
3074
3075 let deleted = db.delete_link(&link.id).expect("Failed to delete link");
3077 assert!(deleted, "Should return true when link is deleted");
3078
3079 let links_after = db
3081 .get_links_by_session(&session.id)
3082 .expect("Failed to get links");
3083 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
3084 }
3085
3086 #[test]
3087 fn test_delete_link_nonexistent() {
3088 let (db, _dir) = create_test_db();
3089
3090 let nonexistent_id = Uuid::new_v4();
3091 let deleted = db
3092 .delete_link(&nonexistent_id)
3093 .expect("Failed to call delete_link");
3094
3095 assert!(!deleted, "Should return false for nonexistent link");
3096 }
3097
3098 #[test]
3099 fn test_delete_links_by_session() {
3100 let (db, _dir) = create_test_db();
3101
3102 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3103 db.insert_session(&session)
3104 .expect("Failed to insert session");
3105
3106 let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3108 let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
3109 let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
3110
3111 db.insert_link(&link1).expect("Failed to insert link1");
3112 db.insert_link(&link2).expect("Failed to insert link2");
3113 db.insert_link(&link3).expect("Failed to insert link3");
3114
3115 let links_before = db
3117 .get_links_by_session(&session.id)
3118 .expect("Failed to get links");
3119 assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
3120
3121 let count = db
3123 .delete_links_by_session(&session.id)
3124 .expect("Failed to delete links");
3125 assert_eq!(count, 3, "Should have deleted 3 links");
3126
3127 let links_after = db
3129 .get_links_by_session(&session.id)
3130 .expect("Failed to get links");
3131 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
3132 }
3133
3134 #[test]
3135 fn test_delete_links_by_session_no_links() {
3136 let (db, _dir) = create_test_db();
3137
3138 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3139 db.insert_session(&session)
3140 .expect("Failed to insert session");
3141
3142 let count = db
3144 .delete_links_by_session(&session.id)
3145 .expect("Failed to call delete_links_by_session");
3146 assert_eq!(count, 0, "Should return 0 when no links exist");
3147 }
3148
3149 #[test]
3150 fn test_delete_links_by_session_preserves_other_sessions() {
3151 let (db, _dir) = create_test_db();
3152
3153 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3154 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3155
3156 db.insert_session(&session1)
3157 .expect("Failed to insert session1");
3158 db.insert_session(&session2)
3159 .expect("Failed to insert session2");
3160
3161 let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3162 let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
3163
3164 db.insert_link(&link1).expect("Failed to insert link1");
3165 db.insert_link(&link2).expect("Failed to insert link2");
3166
3167 let count = db
3169 .delete_links_by_session(&session1.id)
3170 .expect("Failed to delete links");
3171 assert_eq!(count, 1, "Should have deleted 1 link");
3172
3173 let session2_links = db
3175 .get_links_by_session(&session2.id)
3176 .expect("Failed to get links");
3177 assert_eq!(
3178 session2_links.len(),
3179 1,
3180 "Session2's link should be preserved"
3181 );
3182 assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
3183 }
3184
3185 #[test]
3186 fn test_delete_link_by_session_and_commit() {
3187 let (db, _dir) = create_test_db();
3188
3189 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3190 db.insert_session(&session)
3191 .expect("Failed to insert session");
3192
3193 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3194 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
3195
3196 db.insert_link(&link1).expect("Failed to insert link1");
3197 db.insert_link(&link2).expect("Failed to insert link2");
3198
3199 let deleted = db
3201 .delete_link_by_session_and_commit(&session.id, "abc123")
3202 .expect("Failed to delete link");
3203 assert!(deleted, "Should return true when link is deleted");
3204
3205 let links = db
3207 .get_links_by_session(&session.id)
3208 .expect("Failed to get links");
3209 assert_eq!(links.len(), 1, "Should have 1 link remaining");
3210 assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
3211 }
3212
3213 #[test]
3214 fn test_delete_link_by_session_and_commit_full_sha() {
3215 let (db, _dir) = create_test_db();
3216
3217 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3218 db.insert_session(&session)
3219 .expect("Failed to insert session");
3220
3221 let full_sha = "abc123def456789012345678901234567890abcd";
3222 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
3223 db.insert_link(&link).expect("Failed to insert link");
3224
3225 let deleted = db
3227 .delete_link_by_session_and_commit(&session.id, full_sha)
3228 .expect("Failed to delete link");
3229 assert!(deleted, "Should delete with full SHA");
3230
3231 let links = db
3232 .get_links_by_session(&session.id)
3233 .expect("Failed to get links");
3234 assert_eq!(links.len(), 0, "Should have 0 links after delete");
3235 }
3236
3237 #[test]
3238 fn test_delete_link_by_session_and_commit_no_match() {
3239 let (db, _dir) = create_test_db();
3240
3241 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3242 db.insert_session(&session)
3243 .expect("Failed to insert session");
3244
3245 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3246 db.insert_link(&link).expect("Failed to insert link");
3247
3248 let deleted = db
3250 .delete_link_by_session_and_commit(&session.id, "xyz999")
3251 .expect("Failed to call delete");
3252 assert!(!deleted, "Should return false when no match");
3253
3254 let links = db
3256 .get_links_by_session(&session.id)
3257 .expect("Failed to get links");
3258 assert_eq!(links.len(), 1, "Link should be preserved");
3259 }
3260
3261 #[test]
3262 fn test_delete_link_by_session_and_commit_wrong_session() {
3263 let (db, _dir) = create_test_db();
3264
3265 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3266 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3267
3268 db.insert_session(&session1)
3269 .expect("Failed to insert session1");
3270 db.insert_session(&session2)
3271 .expect("Failed to insert session2");
3272
3273 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3274 db.insert_link(&link).expect("Failed to insert link");
3275
3276 let deleted = db
3278 .delete_link_by_session_and_commit(&session2.id, "abc123")
3279 .expect("Failed to call delete");
3280 assert!(!deleted, "Should not delete link from different session");
3281
3282 let links = db
3284 .get_links_by_session(&session1.id)
3285 .expect("Failed to get links");
3286 assert_eq!(links.len(), 1, "Link should be preserved");
3287 }
3288
3289 #[test]
3292 fn test_find_sessions_near_commit_time_basic() {
3293 let (db, _dir) = create_test_db();
3294 let now = Utc::now();
3295
3296 let mut session = create_test_session(
3298 "claude-code",
3299 "/home/user/project",
3300 now - Duration::hours(1),
3301 None,
3302 );
3303 session.ended_at = Some(now - Duration::minutes(10));
3304
3305 db.insert_session(&session).expect("insert session");
3306
3307 let found = db
3309 .find_sessions_near_commit_time(now, 30, None)
3310 .expect("find sessions");
3311
3312 assert_eq!(found.len(), 1, "Should find session within window");
3313 assert_eq!(found[0].id, session.id);
3314 }
3315
3316 #[test]
3317 fn test_find_sessions_near_commit_time_outside_window() {
3318 let (db, _dir) = create_test_db();
3319 let now = Utc::now();
3320
3321 let mut session =
3323 create_test_session("claude-code", "/project", now - Duration::hours(3), None);
3324 session.ended_at = Some(now - Duration::hours(2));
3325
3326 db.insert_session(&session).expect("insert session");
3327
3328 let found = db
3330 .find_sessions_near_commit_time(now, 30, None)
3331 .expect("find sessions");
3332
3333 assert!(found.is_empty(), "Should not find session outside window");
3334 }
3335
3336 #[test]
3337 fn test_find_sessions_near_commit_time_with_working_dir() {
3338 let (db, _dir) = create_test_db();
3339 let now = Utc::now();
3340
3341 let mut session1 = create_test_session(
3343 "claude-code",
3344 "/home/user/project-a",
3345 now - Duration::minutes(30),
3346 None,
3347 );
3348 session1.ended_at = Some(now - Duration::minutes(5));
3349
3350 let mut session2 = create_test_session(
3351 "claude-code",
3352 "/home/user/project-b",
3353 now - Duration::minutes(30),
3354 None,
3355 );
3356 session2.ended_at = Some(now - Duration::minutes(5));
3357
3358 db.insert_session(&session1).expect("insert session1");
3359 db.insert_session(&session2).expect("insert session2");
3360
3361 let found = db
3363 .find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
3364 .expect("find sessions");
3365
3366 assert_eq!(found.len(), 1, "Should find only session in project-a");
3367 assert_eq!(found[0].id, session1.id);
3368 }
3369
3370 #[test]
3371 fn test_find_sessions_near_commit_time_ongoing_session() {
3372 let (db, _dir) = create_test_db();
3373 let now = Utc::now();
3374
3375 let session =
3377 create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
3378 db.insert_session(&session).expect("insert session");
3381
3382 let found = db
3384 .find_sessions_near_commit_time(now, 30, None)
3385 .expect("find sessions");
3386
3387 assert_eq!(found.len(), 1, "Should find ongoing session");
3388 assert_eq!(found[0].id, session.id);
3389 }
3390
3391 #[test]
3392 fn test_link_exists_true() {
3393 let (db, _dir) = create_test_db();
3394
3395 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3396 db.insert_session(&session).expect("insert session");
3397
3398 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3399 db.insert_link(&link).expect("insert link");
3400
3401 assert!(
3403 db.link_exists(&session.id, "abc123def456")
3404 .expect("check exists"),
3405 "Should find link with full SHA"
3406 );
3407
3408 assert!(
3410 db.link_exists(&session.id, "abc123").expect("check exists"),
3411 "Should find link with partial SHA"
3412 );
3413 }
3414
3415 #[test]
3416 fn test_link_exists_false() {
3417 let (db, _dir) = create_test_db();
3418
3419 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3420 db.insert_session(&session).expect("insert session");
3421
3422 assert!(
3424 !db.link_exists(&session.id, "abc123").expect("check exists"),
3425 "Should not find non-existent link"
3426 );
3427 }
3428
3429 #[test]
3430 fn test_link_exists_different_session() {
3431 let (db, _dir) = create_test_db();
3432
3433 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3434 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3435
3436 db.insert_session(&session1).expect("insert session1");
3437 db.insert_session(&session2).expect("insert session2");
3438
3439 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3440 db.insert_link(&link).expect("insert link");
3441
3442 assert!(
3444 db.link_exists(&session1.id, "abc123").expect("check"),
3445 "Should find link for session1"
3446 );
3447 assert!(
3448 !db.link_exists(&session2.id, "abc123").expect("check"),
3449 "Should not find link for session2"
3450 );
3451 }
3452
3453 #[test]
3456 fn test_find_active_sessions_for_directory_ongoing() {
3457 let (db, _dir) = create_test_db();
3458 let now = Utc::now();
3459
3460 let session = create_test_session(
3462 "claude-code",
3463 "/home/user/project",
3464 now - Duration::minutes(30),
3465 None,
3466 );
3467 db.insert_session(&session).expect("insert session");
3470
3471 let found = db
3473 .find_active_sessions_for_directory("/home/user/project", None)
3474 .expect("find active sessions");
3475
3476 assert_eq!(found.len(), 1, "Should find ongoing session");
3477 assert_eq!(found[0].id, session.id);
3478 }
3479
3480 #[test]
3481 fn test_find_active_sessions_for_directory_recently_ended() {
3482 let (db, _dir) = create_test_db();
3483 let now = Utc::now();
3484
3485 let mut session = create_test_session(
3487 "claude-code",
3488 "/home/user/project",
3489 now - Duration::minutes(30),
3490 None,
3491 );
3492 session.ended_at = Some(now - Duration::minutes(2));
3493
3494 db.insert_session(&session).expect("insert session");
3495
3496 let found = db
3498 .find_active_sessions_for_directory("/home/user/project", None)
3499 .expect("find active sessions");
3500
3501 assert_eq!(found.len(), 1, "Should find recently ended session");
3502 assert_eq!(found[0].id, session.id);
3503 }
3504
3505 #[test]
3506 fn test_find_active_sessions_for_directory_old_session() {
3507 let (db, _dir) = create_test_db();
3508 let now = Utc::now();
3509
3510 let mut session = create_test_session(
3512 "claude-code",
3513 "/home/user/project",
3514 now - Duration::minutes(60),
3515 None,
3516 );
3517 session.ended_at = Some(now - Duration::minutes(10));
3518
3519 db.insert_session(&session).expect("insert session");
3520
3521 let found = db
3523 .find_active_sessions_for_directory("/home/user/project", None)
3524 .expect("find active sessions");
3525
3526 assert!(found.is_empty(), "Should not find old session");
3527 }
3528
3529 #[test]
3530 fn test_find_active_sessions_for_directory_filters_by_path() {
3531 let (db, _dir) = create_test_db();
3532 let now = Utc::now();
3533
3534 let session1 = create_test_session(
3536 "claude-code",
3537 "/home/user/project-a",
3538 now - Duration::minutes(10),
3539 None,
3540 );
3541 let session2 = create_test_session(
3542 "claude-code",
3543 "/home/user/project-b",
3544 now - Duration::minutes(10),
3545 None,
3546 );
3547
3548 db.insert_session(&session1).expect("insert session1");
3549 db.insert_session(&session2).expect("insert session2");
3550
3551 let found = db
3553 .find_active_sessions_for_directory("/home/user/project-a", None)
3554 .expect("find active sessions");
3555
3556 assert_eq!(found.len(), 1, "Should find only session in project-a");
3557 assert_eq!(found[0].id, session1.id);
3558 }
3559
3560 #[test]
3561 fn test_find_active_sessions_for_directory_trailing_slash_matches() {
3562 let (db, _dir) = create_test_db();
3563 let now = Utc::now();
3564
3565 let session = create_test_session(
3566 "claude-code",
3567 "/home/user/project",
3568 now - Duration::minutes(10),
3569 None,
3570 );
3571 db.insert_session(&session).expect("insert session");
3572
3573 let found = db
3574 .find_active_sessions_for_directory("/home/user/project/", None)
3575 .expect("find active sessions");
3576
3577 assert_eq!(found.len(), 1, "Should match even with trailing slash");
3578 assert_eq!(found[0].id, session.id);
3579 }
3580
3581 #[test]
3582 fn test_find_active_sessions_for_directory_does_not_match_prefix_siblings() {
3583 let (db, _dir) = create_test_db();
3584 let now = Utc::now();
3585
3586 let session_root = create_test_session(
3587 "claude-code",
3588 "/home/user/project",
3589 now - Duration::minutes(10),
3590 None,
3591 );
3592 let session_subdir = create_test_session(
3593 "claude-code",
3594 "/home/user/project/src",
3595 now - Duration::minutes(10),
3596 None,
3597 );
3598 let session_sibling = create_test_session(
3599 "claude-code",
3600 "/home/user/project-old",
3601 now - Duration::minutes(10),
3602 None,
3603 );
3604
3605 db.insert_session(&session_root)
3606 .expect("insert session_root");
3607 db.insert_session(&session_subdir)
3608 .expect("insert session_subdir");
3609 db.insert_session(&session_sibling)
3610 .expect("insert session_sibling");
3611
3612 let found = db
3613 .find_active_sessions_for_directory("/home/user/project", None)
3614 .expect("find active sessions");
3615
3616 let found_ids: std::collections::HashSet<Uuid> =
3617 found.iter().map(|session| session.id).collect();
3618 assert!(found_ids.contains(&session_root.id));
3619 assert!(found_ids.contains(&session_subdir.id));
3620 assert!(!found_ids.contains(&session_sibling.id));
3621 }
3622
3623 #[test]
3624 fn test_find_active_sessions_for_directory_custom_window() {
3625 let (db, _dir) = create_test_db();
3626 let now = Utc::now();
3627
3628 let mut session = create_test_session(
3630 "claude-code",
3631 "/home/user/project",
3632 now - Duration::minutes(30),
3633 None,
3634 );
3635 session.ended_at = Some(now - Duration::minutes(8));
3636
3637 db.insert_session(&session).expect("insert session");
3638
3639 let found = db
3641 .find_active_sessions_for_directory("/home/user/project", None)
3642 .expect("find with default window");
3643 assert!(found.is_empty(), "Should not find with 5 minute window");
3644
3645 let found = db
3647 .find_active_sessions_for_directory("/home/user/project", Some(10))
3648 .expect("find with 10 minute window");
3649 assert_eq!(found.len(), 1, "Should find with 10 minute window");
3650 }
3651
3652 #[test]
3655 fn test_search_with_tool_filter() {
3656 let (db, _dir) = create_test_db();
3657
3658 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3659 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
3660
3661 db.insert_session(&session1).expect("insert session1");
3662 db.insert_session(&session2).expect("insert session2");
3663
3664 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
3665 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
3666
3667 db.insert_message(&msg1).expect("insert msg1");
3668 db.insert_message(&msg2).expect("insert msg2");
3669
3670 let options = SearchOptions {
3672 query: "Hello".to_string(),
3673 limit: 10,
3674 tool: Some("claude-code".to_string()),
3675 ..Default::default()
3676 };
3677 let results = db.search_with_options(&options).expect("search");
3678
3679 assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
3680 assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
3681 }
3682
3683 #[test]
3684 fn test_search_with_date_range() {
3685 let (db, _dir) = create_test_db();
3686
3687 let old_time = Utc::now() - chrono::Duration::days(30);
3688 let new_time = Utc::now() - chrono::Duration::days(1);
3689
3690 let session1 = create_test_session("claude-code", "/project1", old_time, None);
3691 let session2 = create_test_session("claude-code", "/project2", new_time, None);
3692
3693 db.insert_session(&session1).expect("insert session1");
3694 db.insert_session(&session2).expect("insert session2");
3695
3696 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
3697 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
3698
3699 db.insert_message(&msg1).expect("insert msg1");
3700 db.insert_message(&msg2).expect("insert msg2");
3701
3702 let since = Utc::now() - chrono::Duration::days(7);
3704 let options = SearchOptions {
3705 query: "session".to_string(),
3706 limit: 10,
3707 since: Some(since),
3708 ..Default::default()
3709 };
3710 let results = db.search_with_options(&options).expect("search");
3711
3712 assert_eq!(results.len(), 1, "Should find 1 result within date range");
3713 assert!(
3714 results[0].working_directory.contains("project2"),
3715 "Should be from newer project"
3716 );
3717 }
3718
3719 #[test]
3720 fn test_search_with_project_filter() {
3721 let (db, _dir) = create_test_db();
3722
3723 let session1 =
3724 create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
3725 let session2 =
3726 create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
3727
3728 db.insert_session(&session1).expect("insert session1");
3729 db.insert_session(&session2).expect("insert session2");
3730
3731 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
3732 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
3733
3734 db.insert_message(&msg1).expect("insert msg1");
3735 db.insert_message(&msg2).expect("insert msg2");
3736
3737 let options = SearchOptions {
3739 query: "Testing".to_string(),
3740 limit: 10,
3741 project: Some("frontend".to_string()),
3742 ..Default::default()
3743 };
3744 let results = db.search_with_options(&options).expect("search");
3745
3746 assert_eq!(results.len(), 1, "Should find 1 result with project filter");
3747 assert!(
3748 results[0].working_directory.contains("frontend"),
3749 "Should be from frontend project"
3750 );
3751 }
3752
3753 #[test]
3754 fn test_search_with_branch_filter() {
3755 let (db, _dir) = create_test_db();
3756
3757 let session1 = Session {
3758 id: Uuid::new_v4(),
3759 tool: "claude-code".to_string(),
3760 tool_version: None,
3761 started_at: Utc::now(),
3762 ended_at: None,
3763 model: None,
3764 working_directory: "/project".to_string(),
3765 git_branch: Some("feat/auth".to_string()),
3766 source_path: None,
3767 message_count: 0,
3768 machine_id: None,
3769 };
3770 let session2 = Session {
3771 id: Uuid::new_v4(),
3772 tool: "claude-code".to_string(),
3773 tool_version: None,
3774 started_at: Utc::now(),
3775 ended_at: None,
3776 model: None,
3777 working_directory: "/project".to_string(),
3778 git_branch: Some("main".to_string()),
3779 source_path: None,
3780 message_count: 0,
3781 machine_id: None,
3782 };
3783
3784 db.insert_session(&session1).expect("insert session1");
3785 db.insert_session(&session2).expect("insert session2");
3786
3787 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
3788 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
3789
3790 db.insert_message(&msg1).expect("insert msg1");
3791 db.insert_message(&msg2).expect("insert msg2");
3792
3793 let options = SearchOptions {
3795 query: "work".to_string(),
3796 limit: 10,
3797 branch: Some("auth".to_string()),
3798 ..Default::default()
3799 };
3800 let results = db.search_with_options(&options).expect("search");
3801
3802 assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
3803 assert_eq!(
3804 results[0].git_branch.as_deref(),
3805 Some("feat/auth"),
3806 "Should be from feat/auth branch"
3807 );
3808 }
3809
3810 #[test]
3811 fn test_search_metadata_matches_project() {
3812 let (db, _dir) = create_test_db();
3813
3814 let session =
3815 create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
3816 db.insert_session(&session).expect("insert session");
3817
3818 let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
3820 db.insert_message(&msg).expect("insert msg");
3821
3822 let options = SearchOptions {
3824 query: "redactyl".to_string(),
3825 limit: 10,
3826 ..Default::default()
3827 };
3828 let results = db.search_with_options(&options).expect("search");
3829
3830 assert_eq!(
3831 results.len(),
3832 1,
3833 "Should find session via metadata match on project name"
3834 );
3835 }
3836
3837 #[test]
3838 fn test_search_returns_extended_session_info() {
3839 let (db, _dir) = create_test_db();
3840
3841 let started_at = Utc::now();
3842 let session = Session {
3843 id: Uuid::new_v4(),
3844 tool: "claude-code".to_string(),
3845 tool_version: Some("1.0.0".to_string()),
3846 started_at,
3847 ended_at: None,
3848 model: None,
3849 working_directory: "/home/user/myapp".to_string(),
3850 git_branch: Some("develop".to_string()),
3851 source_path: None,
3852 message_count: 5,
3853 machine_id: None,
3854 };
3855 db.insert_session(&session).expect("insert session");
3856
3857 let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
3858 db.insert_message(&msg).expect("insert msg");
3859
3860 let options = SearchOptions {
3861 query: "Test".to_string(),
3862 limit: 10,
3863 ..Default::default()
3864 };
3865 let results = db.search_with_options(&options).expect("search");
3866
3867 assert_eq!(results.len(), 1, "Should find 1 result");
3868 let result = &results[0];
3869
3870 assert_eq!(result.tool, "claude-code", "Tool should be populated");
3871 assert_eq!(
3872 result.git_branch.as_deref(),
3873 Some("develop"),
3874 "Branch should be populated"
3875 );
3876 assert!(
3877 result.session_message_count > 0,
3878 "Message count should be populated"
3879 );
3880 assert!(
3881 result.session_started_at.is_some(),
3882 "Session start time should be populated"
3883 );
3884 }
3885
3886 #[test]
3887 fn test_get_context_messages() {
3888 let (db, _dir) = create_test_db();
3889
3890 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3891 db.insert_session(&session).expect("insert session");
3892
3893 for i in 0..5 {
3895 let role = if i % 2 == 0 {
3896 MessageRole::User
3897 } else {
3898 MessageRole::Assistant
3899 };
3900 let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
3901 db.insert_message(&msg).expect("insert message");
3902 }
3903
3904 let (before, after) = db
3906 .get_context_messages(&session.id, 2, 1)
3907 .expect("get context");
3908
3909 assert_eq!(before.len(), 1, "Should have 1 message before");
3910 assert_eq!(after.len(), 1, "Should have 1 message after");
3911 assert_eq!(before[0].index, 1, "Before message should be index 1");
3912 assert_eq!(after[0].index, 3, "After message should be index 3");
3913 }
3914
3915 #[test]
3916 fn test_get_context_messages_at_start() {
3917 let (db, _dir) = create_test_db();
3918
3919 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3920 db.insert_session(&session).expect("insert session");
3921
3922 for i in 0..3 {
3923 let msg =
3924 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
3925 db.insert_message(&msg).expect("insert message");
3926 }
3927
3928 let (before, after) = db
3930 .get_context_messages(&session.id, 0, 2)
3931 .expect("get context");
3932
3933 assert!(
3934 before.is_empty(),
3935 "Should have no messages before first message"
3936 );
3937 assert_eq!(after.len(), 2, "Should have 2 messages after");
3938 }
3939
3940 #[test]
3941 fn test_get_context_messages_at_end() {
3942 let (db, _dir) = create_test_db();
3943
3944 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3945 db.insert_session(&session).expect("insert session");
3946
3947 for i in 0..3 {
3948 let msg =
3949 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
3950 db.insert_message(&msg).expect("insert message");
3951 }
3952
3953 let (before, after) = db
3955 .get_context_messages(&session.id, 2, 2)
3956 .expect("get context");
3957
3958 assert_eq!(before.len(), 2, "Should have 2 messages before");
3959 assert!(
3960 after.is_empty(),
3961 "Should have no messages after last message"
3962 );
3963 }
3964
3965 #[test]
3966 fn test_search_combined_filters() {
3967 let (db, _dir) = create_test_db();
3968
3969 let session1 = Session {
3970 id: Uuid::new_v4(),
3971 tool: "claude-code".to_string(),
3972 tool_version: None,
3973 started_at: Utc::now(),
3974 ended_at: None,
3975 model: None,
3976 working_directory: "/home/user/myapp".to_string(),
3977 git_branch: Some("feat/api".to_string()),
3978 source_path: None,
3979 message_count: 1,
3980 machine_id: None,
3981 };
3982 let session2 = Session {
3983 id: Uuid::new_v4(),
3984 tool: "aider".to_string(),
3985 tool_version: None,
3986 started_at: Utc::now(),
3987 ended_at: None,
3988 model: None,
3989 working_directory: "/home/user/myapp".to_string(),
3990 git_branch: Some("feat/api".to_string()),
3991 source_path: None,
3992 message_count: 1,
3993 machine_id: None,
3994 };
3995
3996 db.insert_session(&session1).expect("insert session1");
3997 db.insert_session(&session2).expect("insert session2");
3998
3999 let msg1 =
4000 create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
4001 let msg2 =
4002 create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
4003
4004 db.insert_message(&msg1).expect("insert msg1");
4005 db.insert_message(&msg2).expect("insert msg2");
4006
4007 let options = SearchOptions {
4009 query: "API".to_string(),
4010 limit: 10,
4011 tool: Some("claude-code".to_string()),
4012 branch: Some("api".to_string()),
4013 project: Some("myapp".to_string()),
4014 ..Default::default()
4015 };
4016 let results = db.search_with_options(&options).expect("search");
4017
4018 assert!(
4020 !results.is_empty(),
4021 "Should find at least 1 result matching all filters"
4022 );
4023 for result in &results {
4025 assert_eq!(
4026 result.tool, "claude-code",
4027 "All results should be from claude-code"
4028 );
4029 }
4030 }
4031
4032 #[test]
4035 fn test_delete_session_removes_all_data() {
4036 let (db, _dir) = create_test_db();
4037
4038 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4039 db.insert_session(&session).expect("insert session");
4040
4041 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
4043 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there");
4044 db.insert_message(&msg1).expect("insert msg1");
4045 db.insert_message(&msg2).expect("insert msg2");
4046
4047 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
4049 db.insert_link(&link).expect("insert link");
4050
4051 assert_eq!(db.session_count().expect("count"), 1);
4053 assert_eq!(db.message_count().expect("count"), 2);
4054 assert_eq!(db.link_count().expect("count"), 1);
4055
4056 let (msgs_deleted, links_deleted) = db.delete_session(&session.id).expect("delete");
4058 assert_eq!(msgs_deleted, 2, "Should delete 2 messages");
4059 assert_eq!(links_deleted, 1, "Should delete 1 link");
4060
4061 assert_eq!(db.session_count().expect("count"), 0);
4063 assert_eq!(db.message_count().expect("count"), 0);
4064 assert_eq!(db.link_count().expect("count"), 0);
4065 assert!(db.get_session(&session.id).expect("get").is_none());
4066 }
4067
4068 #[test]
4069 fn test_delete_session_preserves_other_sessions() {
4070 let (db, _dir) = create_test_db();
4071
4072 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
4073 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
4074
4075 db.insert_session(&session1).expect("insert session1");
4076 db.insert_session(&session2).expect("insert session2");
4077
4078 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello 1");
4079 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello 2");
4080 db.insert_message(&msg1).expect("insert msg1");
4081 db.insert_message(&msg2).expect("insert msg2");
4082
4083 db.delete_session(&session1.id).expect("delete");
4085
4086 assert_eq!(db.session_count().expect("count"), 1);
4088 assert_eq!(db.message_count().expect("count"), 1);
4089 assert!(db.get_session(&session2.id).expect("get").is_some());
4090 }
4091
4092 #[test]
4095 fn test_file_size() {
4096 let (db, _dir) = create_test_db();
4097
4098 let size = db.file_size().expect("get size");
4099 assert!(size.is_some(), "Should have file size for file-based db");
4100 assert!(size.unwrap() > 0, "Database file should have size > 0");
4101 }
4102
4103 #[test]
4104 fn test_vacuum() {
4105 let (db, _dir) = create_test_db();
4106
4107 db.vacuum().expect("vacuum should succeed");
4109 }
4110
4111 #[test]
4112 fn test_count_sessions_older_than() {
4113 let (db, _dir) = create_test_db();
4114 let now = Utc::now();
4115
4116 let old_session =
4118 create_test_session("claude-code", "/project1", now - Duration::days(100), None);
4119 let recent_session =
4120 create_test_session("claude-code", "/project2", now - Duration::days(10), None);
4121
4122 db.insert_session(&old_session).expect("insert old");
4123 db.insert_session(&recent_session).expect("insert recent");
4124
4125 let cutoff = now - Duration::days(30);
4127 let count = db.count_sessions_older_than(cutoff).expect("count");
4128 assert_eq!(count, 1, "Should find 1 session older than 30 days");
4129
4130 let old_cutoff = now - Duration::days(200);
4132 let old_count = db.count_sessions_older_than(old_cutoff).expect("count");
4133 assert_eq!(old_count, 0, "Should find 0 sessions older than 200 days");
4134 }
4135
4136 #[test]
4137 fn test_delete_sessions_older_than() {
4138 let (db, _dir) = create_test_db();
4139 let now = Utc::now();
4140
4141 let old_session =
4143 create_test_session("claude-code", "/project1", now - Duration::days(100), None);
4144 let recent_session =
4145 create_test_session("claude-code", "/project2", now - Duration::days(10), None);
4146
4147 db.insert_session(&old_session).expect("insert old");
4148 db.insert_session(&recent_session).expect("insert recent");
4149
4150 let msg1 = create_test_message(old_session.id, 0, MessageRole::User, "Old message");
4152 let msg2 = create_test_message(recent_session.id, 0, MessageRole::User, "Recent message");
4153 db.insert_message(&msg1).expect("insert msg1");
4154 db.insert_message(&msg2).expect("insert msg2");
4155
4156 let cutoff = now - Duration::days(30);
4158 let deleted = db.delete_sessions_older_than(cutoff).expect("delete");
4159 assert_eq!(deleted, 1, "Should delete 1 session");
4160
4161 assert_eq!(db.session_count().expect("count"), 1);
4163 assert!(db.get_session(&recent_session.id).expect("get").is_some());
4164 assert!(db.get_session(&old_session.id).expect("get").is_none());
4165
4166 assert_eq!(db.message_count().expect("count"), 1);
4168 }
4169
4170 #[test]
4171 fn test_get_sessions_older_than() {
4172 let (db, _dir) = create_test_db();
4173 let now = Utc::now();
4174
4175 let old_session = create_test_session(
4177 "claude-code",
4178 "/project/old",
4179 now - Duration::days(100),
4180 None,
4181 );
4182 let medium_session =
4183 create_test_session("aider", "/project/medium", now - Duration::days(50), None);
4184 let recent_session =
4185 create_test_session("gemini", "/project/recent", now - Duration::days(10), None);
4186
4187 db.insert_session(&old_session).expect("insert old");
4188 db.insert_session(&medium_session).expect("insert medium");
4189 db.insert_session(&recent_session).expect("insert recent");
4190
4191 let cutoff = now - Duration::days(30);
4193 let sessions = db.get_sessions_older_than(cutoff).expect("get sessions");
4194 assert_eq!(
4195 sessions.len(),
4196 2,
4197 "Should find 2 sessions older than 30 days"
4198 );
4199
4200 assert_eq!(sessions[0].id, old_session.id);
4202 assert_eq!(sessions[1].id, medium_session.id);
4203
4204 assert_eq!(sessions[0].tool, "claude-code");
4206 assert_eq!(sessions[0].working_directory, "/project/old");
4207 assert_eq!(sessions[1].tool, "aider");
4208 assert_eq!(sessions[1].working_directory, "/project/medium");
4209
4210 let old_cutoff = now - Duration::days(200);
4212 let old_sessions = db
4213 .get_sessions_older_than(old_cutoff)
4214 .expect("get old sessions");
4215 assert_eq!(
4216 old_sessions.len(),
4217 0,
4218 "Should find 0 sessions older than 200 days"
4219 );
4220 }
4221
4222 #[test]
4223 fn test_stats() {
4224 let (db, _dir) = create_test_db();
4225 let now = Utc::now();
4226
4227 let empty_stats = db.stats().expect("stats");
4229 assert_eq!(empty_stats.session_count, 0);
4230 assert_eq!(empty_stats.message_count, 0);
4231 assert_eq!(empty_stats.link_count, 0);
4232 assert!(empty_stats.oldest_session.is_none());
4233 assert!(empty_stats.newest_session.is_none());
4234 assert!(empty_stats.sessions_by_tool.is_empty());
4235
4236 let session1 =
4238 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
4239 let session2 = create_test_session("aider", "/project2", now - Duration::hours(1), None);
4240 let session3 = create_test_session("claude-code", "/project3", now, None);
4241
4242 db.insert_session(&session1).expect("insert 1");
4243 db.insert_session(&session2).expect("insert 2");
4244 db.insert_session(&session3).expect("insert 3");
4245
4246 let msg = create_test_message(session1.id, 0, MessageRole::User, "Hello");
4247 db.insert_message(&msg).expect("insert msg");
4248
4249 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
4250 db.insert_link(&link).expect("insert link");
4251
4252 let stats = db.stats().expect("stats");
4254 assert_eq!(stats.session_count, 3);
4255 assert_eq!(stats.message_count, 1);
4256 assert_eq!(stats.link_count, 1);
4257 assert!(stats.oldest_session.is_some());
4258 assert!(stats.newest_session.is_some());
4259
4260 assert_eq!(stats.sessions_by_tool.len(), 2);
4262 assert_eq!(stats.sessions_by_tool[0].0, "claude-code");
4264 assert_eq!(stats.sessions_by_tool[0].1, 2);
4265 assert_eq!(stats.sessions_by_tool[1].0, "aider");
4266 assert_eq!(stats.sessions_by_tool[1].1, 1);
4267 }
4268
4269 #[test]
4272 fn test_get_session_branch_history_no_messages() {
4273 let (db, _dir) = create_test_db();
4274 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4275 db.insert_session(&session)
4276 .expect("Failed to insert session");
4277
4278 let branches = db
4279 .get_session_branch_history(session.id)
4280 .expect("Failed to get branch history");
4281
4282 assert!(branches.is_empty(), "Empty session should have no branches");
4283 }
4284
4285 #[test]
4286 fn test_get_session_branch_history_single_branch() {
4287 let (db, _dir) = create_test_db();
4288 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4289 db.insert_session(&session)
4290 .expect("Failed to insert session");
4291
4292 for i in 0..3 {
4294 let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
4295 msg.git_branch = Some("main".to_string());
4296 db.insert_message(&msg).expect("Failed to insert message");
4297 }
4298
4299 let branches = db
4300 .get_session_branch_history(session.id)
4301 .expect("Failed to get branch history");
4302
4303 assert_eq!(branches, vec!["main"], "Should have single branch");
4304 }
4305
4306 #[test]
4307 fn test_get_session_branch_history_multiple_branches() {
4308 let (db, _dir) = create_test_db();
4309 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4310 db.insert_session(&session)
4311 .expect("Failed to insert session");
4312
4313 let branch_sequence = ["main", "main", "feat/auth", "feat/auth", "main"];
4315 for (i, branch) in branch_sequence.iter().enumerate() {
4316 let mut msg = create_test_message(session.id, i as i32, MessageRole::User, "test");
4317 msg.git_branch = Some(branch.to_string());
4318 db.insert_message(&msg).expect("Failed to insert message");
4319 }
4320
4321 let branches = db
4322 .get_session_branch_history(session.id)
4323 .expect("Failed to get branch history");
4324
4325 assert_eq!(
4326 branches,
4327 vec!["main", "feat/auth", "main"],
4328 "Should show branch transitions without consecutive duplicates"
4329 );
4330 }
4331
4332 #[test]
4333 fn test_get_session_branch_history_with_none_branches() {
4334 let (db, _dir) = create_test_db();
4335 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4336 db.insert_session(&session)
4337 .expect("Failed to insert session");
4338
4339 let mut msg1 = create_test_message(session.id, 0, MessageRole::User, "test");
4341 msg1.git_branch = Some("main".to_string());
4342 db.insert_message(&msg1).expect("Failed to insert message");
4343
4344 let mut msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "test");
4345 msg2.git_branch = None; db.insert_message(&msg2).expect("Failed to insert message");
4347
4348 let mut msg3 = create_test_message(session.id, 2, MessageRole::User, "test");
4349 msg3.git_branch = Some("feat/new".to_string());
4350 db.insert_message(&msg3).expect("Failed to insert message");
4351
4352 let branches = db
4353 .get_session_branch_history(session.id)
4354 .expect("Failed to get branch history");
4355
4356 assert_eq!(
4357 branches,
4358 vec!["main", "feat/new"],
4359 "Should skip None branches and show transitions"
4360 );
4361 }
4362
4363 #[test]
4364 fn test_get_session_branch_history_all_none_branches() {
4365 let (db, _dir) = create_test_db();
4366 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4367 db.insert_session(&session)
4368 .expect("Failed to insert session");
4369
4370 for i in 0..3 {
4372 let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
4373 msg.git_branch = None;
4374 db.insert_message(&msg).expect("Failed to insert message");
4375 }
4376
4377 let branches = db
4378 .get_session_branch_history(session.id)
4379 .expect("Failed to get branch history");
4380
4381 assert!(
4382 branches.is_empty(),
4383 "Session with all None branches should return empty"
4384 );
4385 }
4386
4387 #[test]
4390 fn test_session_stores_machine_id() {
4391 let (db, _dir) = create_test_db();
4392 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4393
4394 db.insert_session(&session)
4395 .expect("Failed to insert session");
4396
4397 let retrieved = db
4398 .get_session(&session.id)
4399 .expect("Failed to get session")
4400 .expect("Session should exist");
4401
4402 assert_eq!(
4403 retrieved.machine_id,
4404 Some("test-machine".to_string()),
4405 "Machine ID should be preserved"
4406 );
4407 }
4408
4409 #[test]
4410 fn test_session_with_none_machine_id() {
4411 let (db, _dir) = create_test_db();
4412 let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
4413 session.machine_id = None;
4414
4415 db.insert_session(&session)
4416 .expect("Failed to insert session");
4417
4418 let retrieved = db
4419 .get_session(&session.id)
4420 .expect("Failed to get session")
4421 .expect("Session should exist");
4422
4423 assert!(
4424 retrieved.machine_id.is_none(),
4425 "Session with None machine_id should preserve None"
4426 );
4427 }
4428
4429 #[test]
4430 fn test_migration_adds_machine_id_column() {
4431 let (db, _dir) = create_test_db();
4433
4434 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4436 db.insert_session(&session)
4437 .expect("Should insert session with machine_id column");
4438
4439 let retrieved = db
4441 .get_session(&session.id)
4442 .expect("Failed to get session")
4443 .expect("Session should exist");
4444
4445 assert_eq!(
4446 retrieved.machine_id,
4447 Some("test-machine".to_string()),
4448 "Machine ID should be stored and retrieved"
4449 );
4450 }
4451
4452 #[test]
4453 fn test_list_sessions_includes_machine_id() {
4454 let (db, _dir) = create_test_db();
4455 let now = Utc::now();
4456
4457 let mut session1 = create_test_session("claude-code", "/project1", now, None);
4458 session1.machine_id = Some("machine-a".to_string());
4459
4460 let mut session2 = create_test_session("claude-code", "/project2", now, None);
4461 session2.machine_id = Some("machine-b".to_string());
4462
4463 db.insert_session(&session1).expect("insert");
4464 db.insert_session(&session2).expect("insert");
4465
4466 let sessions = db.list_sessions(10, None).expect("list");
4467
4468 assert_eq!(sessions.len(), 2);
4469 let machine_ids: Vec<Option<String>> =
4470 sessions.iter().map(|s| s.machine_id.clone()).collect();
4471 assert!(machine_ids.contains(&Some("machine-a".to_string())));
4472 assert!(machine_ids.contains(&Some("machine-b".to_string())));
4473 }
4474
4475 #[test]
4478 fn test_insert_and_get_annotations() {
4479 let (db, _dir) = create_test_db();
4480 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4481 db.insert_session(&session).expect("insert session");
4482
4483 let annotation = Annotation {
4484 id: Uuid::new_v4(),
4485 session_id: session.id,
4486 content: "This is a test note".to_string(),
4487 created_at: Utc::now(),
4488 };
4489 db.insert_annotation(&annotation)
4490 .expect("insert annotation");
4491
4492 let annotations = db.get_annotations(&session.id).expect("get annotations");
4493 assert_eq!(annotations.len(), 1);
4494 assert_eq!(annotations[0].content, "This is a test note");
4495 assert_eq!(annotations[0].session_id, session.id);
4496 }
4497
4498 #[test]
4499 fn test_delete_annotation() {
4500 let (db, _dir) = create_test_db();
4501 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4502 db.insert_session(&session).expect("insert session");
4503
4504 let annotation = Annotation {
4505 id: Uuid::new_v4(),
4506 session_id: session.id,
4507 content: "Test annotation".to_string(),
4508 created_at: Utc::now(),
4509 };
4510 db.insert_annotation(&annotation).expect("insert");
4511
4512 let deleted = db.delete_annotation(&annotation.id).expect("delete");
4513 assert!(deleted);
4514
4515 let annotations = db.get_annotations(&session.id).expect("get");
4516 assert!(annotations.is_empty());
4517 }
4518
4519 #[test]
4520 fn test_delete_annotations_by_session() {
4521 let (db, _dir) = create_test_db();
4522 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4523 db.insert_session(&session).expect("insert session");
4524
4525 for i in 0..3 {
4526 let annotation = Annotation {
4527 id: Uuid::new_v4(),
4528 session_id: session.id,
4529 content: format!("Annotation {i}"),
4530 created_at: Utc::now(),
4531 };
4532 db.insert_annotation(&annotation).expect("insert");
4533 }
4534
4535 let count = db
4536 .delete_annotations_by_session(&session.id)
4537 .expect("delete all");
4538 assert_eq!(count, 3);
4539
4540 let annotations = db.get_annotations(&session.id).expect("get");
4541 assert!(annotations.is_empty());
4542 }
4543
4544 #[test]
4547 fn test_insert_and_get_tags() {
4548 let (db, _dir) = create_test_db();
4549 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4550 db.insert_session(&session).expect("insert session");
4551
4552 let tag = Tag {
4553 id: Uuid::new_v4(),
4554 session_id: session.id,
4555 label: "bug-fix".to_string(),
4556 created_at: Utc::now(),
4557 };
4558 db.insert_tag(&tag).expect("insert tag");
4559
4560 let tags = db.get_tags(&session.id).expect("get tags");
4561 assert_eq!(tags.len(), 1);
4562 assert_eq!(tags[0].label, "bug-fix");
4563 }
4564
4565 #[test]
4566 fn test_tag_exists() {
4567 let (db, _dir) = create_test_db();
4568 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4569 db.insert_session(&session).expect("insert session");
4570
4571 assert!(!db.tag_exists(&session.id, "bug-fix").expect("check"));
4572
4573 let tag = Tag {
4574 id: Uuid::new_v4(),
4575 session_id: session.id,
4576 label: "bug-fix".to_string(),
4577 created_at: Utc::now(),
4578 };
4579 db.insert_tag(&tag).expect("insert tag");
4580
4581 assert!(db.tag_exists(&session.id, "bug-fix").expect("check"));
4582 assert!(!db.tag_exists(&session.id, "feature").expect("check other"));
4583 }
4584
4585 #[test]
4586 fn test_delete_tag() {
4587 let (db, _dir) = create_test_db();
4588 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4589 db.insert_session(&session).expect("insert session");
4590
4591 let tag = Tag {
4592 id: Uuid::new_v4(),
4593 session_id: session.id,
4594 label: "wip".to_string(),
4595 created_at: Utc::now(),
4596 };
4597 db.insert_tag(&tag).expect("insert tag");
4598
4599 let deleted = db.delete_tag(&session.id, "wip").expect("delete");
4600 assert!(deleted);
4601
4602 let deleted_again = db.delete_tag(&session.id, "wip").expect("delete again");
4603 assert!(!deleted_again);
4604 }
4605
4606 #[test]
4607 fn test_list_sessions_with_tag() {
4608 let (db, _dir) = create_test_db();
4609 let now = Utc::now();
4610
4611 let session1 = create_test_session("claude-code", "/project1", now, None);
4612 let session2 =
4613 create_test_session("claude-code", "/project2", now - Duration::minutes(5), None);
4614 let session3 = create_test_session(
4615 "claude-code",
4616 "/project3",
4617 now - Duration::minutes(10),
4618 None,
4619 );
4620
4621 db.insert_session(&session1).expect("insert");
4622 db.insert_session(&session2).expect("insert");
4623 db.insert_session(&session3).expect("insert");
4624
4625 let tag1 = Tag {
4627 id: Uuid::new_v4(),
4628 session_id: session1.id,
4629 label: "feature".to_string(),
4630 created_at: Utc::now(),
4631 };
4632 let tag3 = Tag {
4633 id: Uuid::new_v4(),
4634 session_id: session3.id,
4635 label: "feature".to_string(),
4636 created_at: Utc::now(),
4637 };
4638 db.insert_tag(&tag1).expect("insert tag");
4639 db.insert_tag(&tag3).expect("insert tag");
4640
4641 let sessions = db.list_sessions_with_tag("feature", 10).expect("list");
4642 assert_eq!(sessions.len(), 2);
4643 assert_eq!(sessions[0].id, session1.id);
4645 assert_eq!(sessions[1].id, session3.id);
4646
4647 let sessions = db.list_sessions_with_tag("nonexistent", 10).expect("list");
4648 assert!(sessions.is_empty());
4649 }
4650
4651 #[test]
4652 fn test_get_most_recent_session_for_directory() {
4653 let (db, _dir) = create_test_db();
4654 let now = Utc::now();
4655
4656 let session1 = create_test_session(
4657 "claude-code",
4658 "/home/user/project",
4659 now - Duration::hours(1),
4660 None,
4661 );
4662 let session2 = create_test_session("claude-code", "/home/user/project", now, None);
4663 let session3 = create_test_session("claude-code", "/home/user/other", now, None);
4664
4665 db.insert_session(&session1).expect("insert");
4666 db.insert_session(&session2).expect("insert");
4667 db.insert_session(&session3).expect("insert");
4668
4669 let result = db
4670 .get_most_recent_session_for_directory("/home/user/project")
4671 .expect("get");
4672 assert!(result.is_some());
4673 assert_eq!(result.unwrap().id, session2.id);
4674
4675 let result = db
4676 .get_most_recent_session_for_directory("/home/user/nonexistent")
4677 .expect("get");
4678 assert!(result.is_none());
4679 }
4680
4681 #[test]
4682 fn test_session_deletion_removes_annotations_and_tags() {
4683 let (db, _dir) = create_test_db();
4684 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4685 db.insert_session(&session).expect("insert session");
4686
4687 let annotation = Annotation {
4689 id: Uuid::new_v4(),
4690 session_id: session.id,
4691 content: "Test annotation".to_string(),
4692 created_at: Utc::now(),
4693 };
4694 db.insert_annotation(&annotation).expect("insert");
4695
4696 let tag = Tag {
4698 id: Uuid::new_v4(),
4699 session_id: session.id,
4700 label: "test-tag".to_string(),
4701 created_at: Utc::now(),
4702 };
4703 db.insert_tag(&tag).expect("insert");
4704
4705 db.delete_session(&session.id).expect("delete");
4707
4708 let annotations = db.get_annotations(&session.id).expect("get");
4710 assert!(annotations.is_empty());
4711
4712 let tags = db.get_tags(&session.id).expect("get");
4713 assert!(tags.is_empty());
4714 }
4715
4716 #[test]
4717 fn test_insert_and_get_summary() {
4718 let (db, _dir) = create_test_db();
4719 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4720 db.insert_session(&session).expect("insert session");
4721
4722 let summary = Summary {
4723 id: Uuid::new_v4(),
4724 session_id: session.id,
4725 content: "Test summary content".to_string(),
4726 generated_at: Utc::now(),
4727 };
4728 db.insert_summary(&summary).expect("insert summary");
4729
4730 let retrieved = db.get_summary(&session.id).expect("get summary");
4731 assert!(retrieved.is_some());
4732 let retrieved = retrieved.unwrap();
4733 assert_eq!(retrieved.content, "Test summary content");
4734 assert_eq!(retrieved.session_id, session.id);
4735 }
4736
4737 #[test]
4738 fn test_get_summary_nonexistent() {
4739 let (db, _dir) = create_test_db();
4740 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4741 db.insert_session(&session).expect("insert session");
4742
4743 let retrieved = db.get_summary(&session.id).expect("get summary");
4744 assert!(retrieved.is_none());
4745 }
4746
4747 #[test]
4748 fn test_update_summary() {
4749 let (db, _dir) = create_test_db();
4750 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4751 db.insert_session(&session).expect("insert session");
4752
4753 let summary = Summary {
4754 id: Uuid::new_v4(),
4755 session_id: session.id,
4756 content: "Original content".to_string(),
4757 generated_at: Utc::now(),
4758 };
4759 db.insert_summary(&summary).expect("insert summary");
4760
4761 let updated = db
4763 .update_summary(&session.id, "Updated content")
4764 .expect("update summary");
4765 assert!(updated);
4766
4767 let retrieved = db.get_summary(&session.id).expect("get summary");
4768 assert!(retrieved.is_some());
4769 assert_eq!(retrieved.unwrap().content, "Updated content");
4770 }
4771
4772 #[test]
4773 fn test_update_summary_nonexistent() {
4774 let (db, _dir) = create_test_db();
4775 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4776 db.insert_session(&session).expect("insert session");
4777
4778 let updated = db
4780 .update_summary(&session.id, "New content")
4781 .expect("update summary");
4782 assert!(!updated);
4783 }
4784
4785 #[test]
4786 fn test_delete_summary() {
4787 let (db, _dir) = create_test_db();
4788 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4789 db.insert_session(&session).expect("insert session");
4790
4791 let summary = Summary {
4792 id: Uuid::new_v4(),
4793 session_id: session.id,
4794 content: "To be deleted".to_string(),
4795 generated_at: Utc::now(),
4796 };
4797 db.insert_summary(&summary).expect("insert summary");
4798
4799 let deleted = db.delete_summary(&session.id).expect("delete summary");
4801 assert!(deleted);
4802
4803 let retrieved = db.get_summary(&session.id).expect("get summary");
4805 assert!(retrieved.is_none());
4806 }
4807
4808 #[test]
4809 fn test_delete_session_removes_summary() {
4810 let (db, _dir) = create_test_db();
4811 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4812 db.insert_session(&session).expect("insert session");
4813
4814 let summary = Summary {
4815 id: Uuid::new_v4(),
4816 session_id: session.id,
4817 content: "Session summary".to_string(),
4818 generated_at: Utc::now(),
4819 };
4820 db.insert_summary(&summary).expect("insert summary");
4821
4822 db.delete_session(&session.id).expect("delete session");
4824
4825 let retrieved = db.get_summary(&session.id).expect("get summary");
4827 assert!(retrieved.is_none());
4828 }
4829
4830 #[test]
4833 fn test_upsert_machine_insert() {
4834 let (db, _dir) = create_test_db();
4835
4836 let machine = Machine {
4837 id: "test-uuid-1234".to_string(),
4838 name: "my-laptop".to_string(),
4839 created_at: Utc::now().to_rfc3339(),
4840 };
4841
4842 db.upsert_machine(&machine)
4843 .expect("Failed to upsert machine");
4844
4845 let retrieved = db
4846 .get_machine("test-uuid-1234")
4847 .expect("Failed to get machine")
4848 .expect("Machine should exist");
4849
4850 assert_eq!(retrieved.id, "test-uuid-1234");
4851 assert_eq!(retrieved.name, "my-laptop");
4852 }
4853
4854 #[test]
4855 fn test_upsert_machine_update() {
4856 let (db, _dir) = create_test_db();
4857
4858 let machine1 = Machine {
4860 id: "test-uuid-5678".to_string(),
4861 name: "old-name".to_string(),
4862 created_at: Utc::now().to_rfc3339(),
4863 };
4864 db.upsert_machine(&machine1)
4865 .expect("Failed to upsert machine");
4866
4867 let machine2 = Machine {
4869 id: "test-uuid-5678".to_string(),
4870 name: "new-name".to_string(),
4871 created_at: Utc::now().to_rfc3339(),
4872 };
4873 db.upsert_machine(&machine2)
4874 .expect("Failed to upsert machine");
4875
4876 let retrieved = db
4878 .get_machine("test-uuid-5678")
4879 .expect("Failed to get machine")
4880 .expect("Machine should exist");
4881
4882 assert_eq!(retrieved.name, "new-name");
4883 }
4884
4885 #[test]
4886 fn test_get_machine() {
4887 let (db, _dir) = create_test_db();
4888
4889 let not_found = db.get_machine("nonexistent-uuid").expect("Failed to query");
4891 assert!(not_found.is_none(), "Machine should not exist");
4892
4893 let machine = Machine {
4895 id: "existing-uuid".to_string(),
4896 name: "test-machine".to_string(),
4897 created_at: Utc::now().to_rfc3339(),
4898 };
4899 db.upsert_machine(&machine).expect("Failed to upsert");
4900
4901 let found = db
4903 .get_machine("existing-uuid")
4904 .expect("Failed to query")
4905 .expect("Machine should exist");
4906
4907 assert_eq!(found.id, "existing-uuid");
4908 assert_eq!(found.name, "test-machine");
4909 }
4910
4911 #[test]
4912 fn test_get_machine_name_found() {
4913 let (db, _dir) = create_test_db();
4914
4915 let machine = Machine {
4916 id: "uuid-for-name-test".to_string(),
4917 name: "my-workstation".to_string(),
4918 created_at: Utc::now().to_rfc3339(),
4919 };
4920 db.upsert_machine(&machine).expect("Failed to upsert");
4921
4922 let name = db
4923 .get_machine_name("uuid-for-name-test")
4924 .expect("Failed to get name");
4925
4926 assert_eq!(name, "my-workstation");
4927 }
4928
4929 #[test]
4930 fn test_get_machine_name_not_found() {
4931 let (db, _dir) = create_test_db();
4932
4933 let name = db
4935 .get_machine_name("abc123def456789")
4936 .expect("Failed to get name");
4937
4938 assert_eq!(name, "abc123de", "Should return first 8 characters");
4939
4940 let short_name = db.get_machine_name("short").expect("Failed to get name");
4942
4943 assert_eq!(
4944 short_name, "short",
4945 "Should return full ID if shorter than 8 chars"
4946 );
4947 }
4948
4949 #[test]
4950 fn test_list_machines() {
4951 let (db, _dir) = create_test_db();
4952
4953 let machines = db.list_machines().expect("Failed to list");
4955 assert!(machines.is_empty(), "Should have no machines initially");
4956
4957 let machine1 = Machine {
4959 id: "uuid-1".to_string(),
4960 name: "machine-1".to_string(),
4961 created_at: "2024-01-01T00:00:00Z".to_string(),
4962 };
4963 let machine2 = Machine {
4964 id: "uuid-2".to_string(),
4965 name: "machine-2".to_string(),
4966 created_at: "2024-01-02T00:00:00Z".to_string(),
4967 };
4968
4969 db.upsert_machine(&machine1).expect("Failed to upsert");
4970 db.upsert_machine(&machine2).expect("Failed to upsert");
4971
4972 let machines = db.list_machines().expect("Failed to list");
4974 assert_eq!(machines.len(), 2, "Should have 2 machines");
4975
4976 assert_eq!(machines[0].id, "uuid-1");
4978 assert_eq!(machines[1].id, "uuid-2");
4979 }
4980
4981 #[test]
4984 fn test_find_session_by_id_prefix_full_uuid() {
4985 let (db, _dir) = create_test_db();
4986 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4987 db.insert_session(&session).expect("insert session");
4988
4989 let found = db
4991 .find_session_by_id_prefix(&session.id.to_string())
4992 .expect("find session")
4993 .expect("session should exist");
4994
4995 assert_eq!(found.id, session.id, "Should find session by full UUID");
4996 }
4997
4998 #[test]
4999 fn test_find_session_by_id_prefix_short_prefix() {
5000 let (db, _dir) = create_test_db();
5001 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5002 db.insert_session(&session).expect("insert session");
5003
5004 let prefix = &session.id.to_string()[..8];
5006
5007 let found = db
5008 .find_session_by_id_prefix(prefix)
5009 .expect("find session")
5010 .expect("session should exist");
5011
5012 assert_eq!(found.id, session.id, "Should find session by short prefix");
5013 }
5014
5015 #[test]
5016 fn test_find_session_by_id_prefix_very_short_prefix() {
5017 let (db, _dir) = create_test_db();
5018 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5019 db.insert_session(&session).expect("insert session");
5020
5021 let prefix = &session.id.to_string()[..4];
5023
5024 let found = db
5025 .find_session_by_id_prefix(prefix)
5026 .expect("find session")
5027 .expect("session should exist");
5028
5029 assert_eq!(
5030 found.id, session.id,
5031 "Should find session by very short prefix"
5032 );
5033 }
5034
5035 #[test]
5036 fn test_find_session_by_id_prefix_not_found() {
5037 let (db, _dir) = create_test_db();
5038 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5039 db.insert_session(&session).expect("insert session");
5040
5041 let found = db
5043 .find_session_by_id_prefix("zzz999")
5044 .expect("find session");
5045
5046 assert!(
5047 found.is_none(),
5048 "Should return None for non-matching prefix"
5049 );
5050 }
5051
5052 #[test]
5053 fn test_find_session_by_id_prefix_empty_db() {
5054 let (db, _dir) = create_test_db();
5055
5056 let found = db
5057 .find_session_by_id_prefix("abc123")
5058 .expect("find session");
5059
5060 assert!(found.is_none(), "Should return None for empty database");
5061 }
5062
5063 #[test]
5064 fn test_find_session_by_id_prefix_ambiguous() {
5065 let (db, _dir) = create_test_db();
5066
5067 let mut sessions = Vec::new();
5069 for _ in 0..100 {
5070 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5071 db.insert_session(&session).expect("insert session");
5072 sessions.push(session);
5073 }
5074
5075 let first_session = &sessions[0];
5077 let first_char = first_session.id.to_string().chars().next().unwrap();
5078
5079 let matching_count = sessions
5081 .iter()
5082 .filter(|s| s.id.to_string().starts_with(first_char))
5083 .count();
5084
5085 if matching_count > 1 {
5086 let result = db.find_session_by_id_prefix(&first_char.to_string());
5089 assert!(
5090 result.is_err(),
5091 "Should return error for ambiguous single-character prefix"
5092 );
5093 let error_msg = result.unwrap_err().to_string();
5094 assert!(
5095 error_msg.contains("Ambiguous"),
5096 "Error should mention ambiguity"
5097 );
5098 }
5099 }
5100
5101 #[test]
5102 fn test_find_session_by_id_prefix_returns_correct_session_data() {
5103 let (db, _dir) = create_test_db();
5104
5105 let mut session =
5106 create_test_session("claude-code", "/home/user/myproject", Utc::now(), None);
5107 session.tool_version = Some("2.0.0".to_string());
5108 session.model = Some("claude-opus-4".to_string());
5109 session.git_branch = Some("feature/test".to_string());
5110 session.message_count = 42;
5111 db.insert_session(&session).expect("insert session");
5112
5113 let prefix = &session.id.to_string()[..8];
5115 let found = db
5116 .find_session_by_id_prefix(prefix)
5117 .expect("find session")
5118 .expect("session should exist");
5119
5120 assert_eq!(found.id, session.id);
5122 assert_eq!(found.tool, "claude-code");
5123 assert_eq!(found.tool_version, Some("2.0.0".to_string()));
5124 assert_eq!(found.model, Some("claude-opus-4".to_string()));
5125 assert_eq!(found.working_directory, "/home/user/myproject");
5126 assert_eq!(found.git_branch, Some("feature/test".to_string()));
5127 assert_eq!(found.message_count, 42);
5128 }
5129
5130 #[test]
5131 fn test_find_session_by_id_prefix_many_sessions() {
5132 let (db, _dir) = create_test_db();
5133
5134 let mut target_session = None;
5136 for i in 0..200 {
5137 let session =
5138 create_test_session("claude-code", &format!("/project/{i}"), Utc::now(), None);
5139 db.insert_session(&session).expect("insert session");
5140 if i == 150 {
5142 target_session = Some(session);
5143 }
5144 }
5145
5146 let target = target_session.expect("should have target session");
5147 let prefix = &target.id.to_string()[..8];
5148
5149 let found = db
5151 .find_session_by_id_prefix(prefix)
5152 .expect("find session")
5153 .expect("session should exist");
5154
5155 assert_eq!(
5156 found.id, target.id,
5157 "Should find correct session among many"
5158 );
5159 assert_eq!(found.working_directory, "/project/150");
5160 }
5161}