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