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 self.migrate_add_synced_at()?;
238
239 Ok(())
240 }
241
242 fn migrate_add_machine_id(&self) -> Result<()> {
250 let columns: Vec<String> = self
252 .conn
253 .prepare("PRAGMA table_info(sessions)")?
254 .query_map([], |row| row.get::<_, String>(1))?
255 .collect::<Result<Vec<_>, _>>()?;
256
257 if !columns.iter().any(|c| c == "machine_id") {
258 self.conn
259 .execute("ALTER TABLE sessions ADD COLUMN machine_id TEXT", [])?;
260 }
261
262 if let Some(machine_uuid) = super::get_machine_id() {
264 self.conn.execute(
265 "UPDATE sessions SET machine_id = ?1 WHERE machine_id IS NULL",
266 [&machine_uuid],
267 )?;
268
269 if let Some(hostname) = hostname::get().ok().and_then(|h| h.into_string().ok()) {
274 self.conn.execute(
275 "UPDATE sessions SET machine_id = ?1 WHERE machine_id = ?2",
276 [&machine_uuid, &hostname],
277 )?;
278 }
279 }
280
281 Ok(())
282 }
283
284 fn migrate_add_synced_at(&self) -> Result<()> {
289 let columns: Vec<String> = self
290 .conn
291 .prepare("PRAGMA table_info(sessions)")?
292 .query_map([], |row| row.get::<_, String>(1))?
293 .collect::<Result<Vec<_>, _>>()?;
294
295 if !columns.iter().any(|c| c == "synced_at") {
296 self.conn
297 .execute("ALTER TABLE sessions ADD COLUMN synced_at TEXT", [])?;
298 }
299
300 Ok(())
301 }
302
303 pub fn insert_session(&self, session: &Session) -> Result<()> {
312 let rows_changed = self.conn.execute(
313 r#"
314 INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id)
315 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
316 ON CONFLICT(id) DO UPDATE SET
317 ended_at = ?5,
318 message_count = ?10,
319 synced_at = CASE
320 WHEN message_count != ?10 THEN NULL
321 WHEN (ended_at IS NULL AND ?5 IS NOT NULL) THEN NULL
322 WHEN (ended_at IS NOT NULL AND ?5 IS NULL) THEN NULL
323 WHEN ended_at != ?5 THEN NULL
324 ELSE synced_at
325 END
326 "#,
327 params![
328 session.id.to_string(),
329 session.tool,
330 session.tool_version,
331 session.started_at.to_rfc3339(),
332 session.ended_at.map(|t| t.to_rfc3339()),
333 session.model,
334 session.working_directory,
335 session.git_branch,
336 session.source_path,
337 session.message_count,
338 session.machine_id,
339 ],
340 )?;
341
342 if rows_changed > 0 {
344 let fts_count: i32 = self.conn.query_row(
346 "SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
347 params![session.id.to_string()],
348 |row| row.get(0),
349 )?;
350
351 if fts_count == 0 {
352 self.conn.execute(
353 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
354 params![
355 session.id.to_string(),
356 session.tool,
357 session.working_directory,
358 session.git_branch.as_deref().unwrap_or(""),
359 ],
360 )?;
361 }
362 }
363
364 Ok(())
365 }
366
367 pub fn get_session(&self, id: &Uuid) -> Result<Option<Session>> {
371 self.conn
372 .query_row(
373 "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",
374 params![id.to_string()],
375 Self::row_to_session,
376 )
377 .optional()
378 .context("Failed to get session")
379 }
380
381 pub fn list_sessions(&self, limit: usize, working_dir: Option<&str>) -> Result<Vec<Session>> {
386 let mut stmt = if working_dir.is_some() {
387 self.conn.prepare(
388 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
389 FROM sessions
390 WHERE working_directory LIKE ?1
391 ORDER BY started_at DESC
392 LIMIT ?2"
393 )?
394 } else {
395 self.conn.prepare(
396 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
397 FROM sessions
398 ORDER BY started_at DESC
399 LIMIT ?1"
400 )?
401 };
402
403 let rows = if let Some(wd) = working_dir {
404 stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
405 } else {
406 stmt.query_map(params![limit], Self::row_to_session)?
407 };
408
409 rows.collect::<Result<Vec<_>, _>>()
410 .context("Failed to list sessions")
411 }
412
413 pub fn list_ended_sessions(
417 &self,
418 limit: usize,
419 working_dir: Option<&str>,
420 ) -> Result<Vec<Session>> {
421 let mut stmt = if working_dir.is_some() {
422 self.conn.prepare(
423 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
424 FROM sessions
425 WHERE ended_at IS NOT NULL
426 AND working_directory LIKE ?1
427 ORDER BY started_at DESC
428 LIMIT ?2",
429 )?
430 } else {
431 self.conn.prepare(
432 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
433 FROM sessions
434 WHERE ended_at IS NOT NULL
435 ORDER BY started_at DESC
436 LIMIT ?1",
437 )?
438 };
439
440 let rows = if let Some(wd) = working_dir {
441 stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
442 } else {
443 stmt.query_map(params![limit], Self::row_to_session)?
444 };
445
446 rows.collect::<Result<Vec<_>, _>>()
447 .context("Failed to list ended sessions")
448 }
449
450 pub fn session_exists_by_source(&self, source_path: &str) -> Result<bool> {
454 let count: i32 = self.conn.query_row(
455 "SELECT COUNT(*) FROM sessions WHERE source_path = ?1",
456 params![source_path],
457 |row| row.get(0),
458 )?;
459 Ok(count > 0)
460 }
461
462 pub fn get_session_by_source(&self, source_path: &str) -> Result<Option<Session>> {
467 self.conn
468 .query_row(
469 "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",
470 params![source_path],
471 Self::row_to_session,
472 )
473 .optional()
474 .context("Failed to get session by source path")
475 }
476
477 pub fn find_session_by_id_prefix(&self, prefix: &str) -> Result<Option<Session>> {
493 if let Ok(uuid) = Uuid::parse_str(prefix) {
495 return self.get_session(&uuid);
496 }
497
498 let pattern = format!("{prefix}%");
500
501 let count: i32 = self.conn.query_row(
503 "SELECT COUNT(*) FROM sessions WHERE id LIKE ?1",
504 params![pattern],
505 |row| row.get(0),
506 )?;
507
508 match count {
509 0 => Ok(None),
510 1 => {
511 self.conn
513 .query_row(
514 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
515 FROM sessions
516 WHERE id LIKE ?1",
517 params![pattern],
518 Self::row_to_session,
519 )
520 .optional()
521 .context("Failed to find session by prefix")
522 }
523 n => {
524 anyhow::bail!(
526 "Ambiguous session ID prefix '{prefix}' matches {n} sessions. Use a longer prefix."
527 )
528 }
529 }
530 }
531
532 pub fn update_session_branch(&self, session_id: Uuid, new_branch: &str) -> Result<usize> {
540 let rows_changed = self.conn.execute(
541 "UPDATE sessions SET git_branch = ?1 WHERE id = ?2",
542 params![new_branch, session_id.to_string()],
543 )?;
544
545 if rows_changed > 0 {
547 self.conn.execute(
548 "UPDATE sessions_fts SET git_branch = ?1 WHERE session_id = ?2",
549 params![new_branch, session_id.to_string()],
550 )?;
551 }
552
553 Ok(rows_changed)
554 }
555
556 fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<Session> {
557 let ended_at_str: Option<String> = row.get(4)?;
558 let ended_at = match ended_at_str {
559 Some(s) => Some(parse_datetime(&s)?),
560 None => None,
561 };
562
563 Ok(Session {
564 id: parse_uuid(&row.get::<_, String>(0)?)?,
565 tool: row.get(1)?,
566 tool_version: row.get(2)?,
567 started_at: parse_datetime(&row.get::<_, String>(3)?)?,
568 ended_at,
569 model: row.get(5)?,
570 working_directory: row.get(6)?,
571 git_branch: row.get(7)?,
572 source_path: row.get(8)?,
573 message_count: row.get(9)?,
574 machine_id: row.get(10)?,
575 })
576 }
577
578 pub fn insert_message(&self, message: &Message) -> Result<()> {
586 let content_json = serde_json::to_string(&message.content)?;
587
588 let rows_changed = self.conn.execute(
589 r#"
590 INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
591 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
592 ON CONFLICT(id) DO NOTHING
593 "#,
594 params![
595 message.id.to_string(),
596 message.session_id.to_string(),
597 message.parent_id.map(|u| u.to_string()),
598 message.index,
599 message.timestamp.to_rfc3339(),
600 message.role.to_string(),
601 content_json,
602 message.model,
603 message.git_branch,
604 message.cwd,
605 ],
606 )?;
607
608 if rows_changed > 0 {
610 let text_content = message.content.text();
611 if !text_content.is_empty() {
612 self.conn.execute(
613 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
614 params![message.id.to_string(), text_content],
615 )?;
616 }
617 }
618
619 Ok(())
620 }
621
622 pub fn import_session_with_messages(
628 &mut self,
629 session: &Session,
630 messages: &[Message],
631 synced_at: Option<DateTime<Utc>>,
632 ) -> Result<()> {
633 let tx = self.conn.transaction()?;
634
635 tx.execute(
637 r#"
638 INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id, synced_at)
639 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
640 ON CONFLICT(id) DO UPDATE SET
641 ended_at = ?5,
642 message_count = ?10,
643 synced_at = COALESCE(?12, synced_at)
644 "#,
645 params![
646 session.id.to_string(),
647 session.tool,
648 session.tool_version,
649 session.started_at.to_rfc3339(),
650 session.ended_at.map(|t| t.to_rfc3339()),
651 session.model,
652 session.working_directory,
653 session.git_branch,
654 session.source_path,
655 session.message_count,
656 session.machine_id,
657 synced_at.map(|t| t.to_rfc3339()),
658 ],
659 )?;
660
661 let fts_count: i32 = tx.query_row(
663 "SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
664 params![session.id.to_string()],
665 |row| row.get(0),
666 )?;
667 if fts_count == 0 {
668 tx.execute(
669 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
670 params![
671 session.id.to_string(),
672 session.tool,
673 session.working_directory,
674 session.git_branch.as_deref().unwrap_or(""),
675 ],
676 )?;
677 }
678
679 for message in messages {
681 let content_json = serde_json::to_string(&message.content)?;
682
683 let rows_changed = tx.execute(
684 r#"
685 INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
686 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
687 ON CONFLICT(id) DO NOTHING
688 "#,
689 params![
690 message.id.to_string(),
691 message.session_id.to_string(),
692 message.parent_id.map(|u| u.to_string()),
693 message.index,
694 message.timestamp.to_rfc3339(),
695 message.role.to_string(),
696 content_json,
697 message.model,
698 message.git_branch,
699 message.cwd,
700 ],
701 )?;
702
703 if rows_changed > 0 {
705 let text_content = message.content.text();
706 if !text_content.is_empty() {
707 tx.execute(
708 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
709 params![message.id.to_string(), text_content],
710 )?;
711 }
712 }
713 }
714
715 tx.commit()?;
716 Ok(())
717 }
718
719 pub fn get_messages(&self, session_id: &Uuid) -> Result<Vec<Message>> {
723 let mut stmt = self.conn.prepare(
724 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
725 FROM messages
726 WHERE session_id = ?1
727 ORDER BY idx"
728 )?;
729
730 let rows = stmt.query_map(params![session_id.to_string()], |row| {
731 let role_str: String = row.get(5)?;
732 let content_str: String = row.get(6)?;
733
734 let parent_id_str: Option<String> = row.get(2)?;
735 let parent_id = match parent_id_str {
736 Some(s) => Some(parse_uuid(&s)?),
737 None => None,
738 };
739
740 Ok(Message {
741 id: parse_uuid(&row.get::<_, String>(0)?)?,
742 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
743 parent_id,
744 index: row.get(3)?,
745 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
746 role: match role_str.as_str() {
747 "user" => MessageRole::User,
748 "assistant" => MessageRole::Assistant,
749 "system" => MessageRole::System,
750 _ => MessageRole::User,
751 },
752 content: serde_json::from_str(&content_str)
753 .unwrap_or(MessageContent::Text(content_str)),
754 model: row.get(7)?,
755 git_branch: row.get(8)?,
756 cwd: row.get(9)?,
757 })
758 })?;
759
760 rows.collect::<Result<Vec<_>, _>>()
761 .context("Failed to get messages")
762 }
763
764 pub fn get_session_branch_history(&self, session_id: Uuid) -> Result<Vec<String>> {
773 let mut stmt = self
774 .conn
775 .prepare("SELECT git_branch FROM messages WHERE session_id = ?1 ORDER BY idx")?;
776
777 let rows = stmt.query_map(params![session_id.to_string()], |row| {
778 let branch: Option<String> = row.get(0)?;
779 Ok(branch)
780 })?;
781
782 let mut branches: Vec<String> = Vec::new();
784 for row in rows {
785 if let Some(branch) = row? {
786 if branches.last() != Some(&branch) {
788 branches.push(branch);
789 }
790 }
791 }
792
793 Ok(branches)
794 }
795
796 pub fn insert_link(&self, link: &SessionLink) -> Result<()> {
803 self.conn.execute(
804 r#"
805 INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
806 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
807 "#,
808 params![
809 link.id.to_string(),
810 link.session_id.to_string(),
811 format!("{:?}", link.link_type).to_lowercase(),
812 link.commit_sha,
813 link.branch,
814 link.remote,
815 link.created_at.to_rfc3339(),
816 format!("{:?}", link.created_by).to_lowercase(),
817 link.confidence,
818 ],
819 )?;
820 Ok(())
821 }
822
823 pub fn get_links_by_commit(&self, commit_sha: &str) -> Result<Vec<SessionLink>> {
828 let mut stmt = self.conn.prepare(
829 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
830 FROM session_links
831 WHERE commit_sha LIKE ?1"
832 )?;
833
834 let pattern = format!("{commit_sha}%");
835 let rows = stmt.query_map(params![pattern], Self::row_to_link)?;
836
837 rows.collect::<Result<Vec<_>, _>>()
838 .context("Failed to get links")
839 }
840
841 pub fn get_links_by_session(&self, session_id: &Uuid) -> Result<Vec<SessionLink>> {
846 let mut stmt = self.conn.prepare(
847 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
848 FROM session_links
849 WHERE session_id = ?1"
850 )?;
851
852 let rows = stmt.query_map(params![session_id.to_string()], Self::row_to_link)?;
853
854 rows.collect::<Result<Vec<_>, _>>()
855 .context("Failed to get links")
856 }
857
858 fn row_to_link(row: &rusqlite::Row) -> rusqlite::Result<SessionLink> {
859 use super::models::{LinkCreator, LinkType};
860
861 let link_type_str: String = row.get(2)?;
862 let created_by_str: String = row.get(7)?;
863
864 Ok(SessionLink {
865 id: parse_uuid(&row.get::<_, String>(0)?)?,
866 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
867 link_type: match link_type_str.as_str() {
868 "commit" => LinkType::Commit,
869 "branch" => LinkType::Branch,
870 "pr" => LinkType::Pr,
871 _ => LinkType::Manual,
872 },
873 commit_sha: row.get(3)?,
874 branch: row.get(4)?,
875 remote: row.get(5)?,
876 created_at: parse_datetime(&row.get::<_, String>(6)?)?,
877 created_by: match created_by_str.as_str() {
878 "auto" => LinkCreator::Auto,
879 _ => LinkCreator::User,
880 },
881 confidence: row.get(8)?,
882 })
883 }
884
885 #[allow(dead_code)]
892 pub fn delete_link(&self, link_id: &Uuid) -> Result<bool> {
893 let rows_affected = self.conn.execute(
894 "DELETE FROM session_links WHERE id = ?1",
895 params![link_id.to_string()],
896 )?;
897 Ok(rows_affected > 0)
898 }
899
900 pub fn delete_links_by_session(&self, session_id: &Uuid) -> Result<usize> {
904 let rows_affected = self.conn.execute(
905 "DELETE FROM session_links WHERE session_id = ?1",
906 params![session_id.to_string()],
907 )?;
908 Ok(rows_affected)
909 }
910
911 pub fn delete_link_by_session_and_commit(
916 &self,
917 session_id: &Uuid,
918 commit_sha: &str,
919 ) -> Result<bool> {
920 let pattern = format!("{commit_sha}%");
921 let rows_affected = self.conn.execute(
922 "DELETE FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
923 params![session_id.to_string(), pattern],
924 )?;
925 Ok(rows_affected > 0)
926 }
927
928 #[allow(dead_code)]
942 pub fn search_messages(
943 &self,
944 query: &str,
945 limit: usize,
946 working_dir: Option<&str>,
947 since: Option<chrono::DateTime<chrono::Utc>>,
948 role: Option<&str>,
949 ) -> Result<Vec<SearchResult>> {
950 use super::models::SearchOptions;
951
952 let options = SearchOptions {
954 query: query.to_string(),
955 limit,
956 repo: working_dir.map(|s| s.to_string()),
957 since,
958 role: role.map(|s| s.to_string()),
959 ..Default::default()
960 };
961
962 self.search_with_options(&options)
963 }
964
965 pub fn search_with_options(
979 &self,
980 options: &super::models::SearchOptions,
981 ) -> Result<Vec<SearchResult>> {
982 let escaped_query = escape_fts5_query(&options.query);
984
985 let mut sql = String::from(
988 r#"
989 SELECT
990 m.session_id,
991 m.id as message_id,
992 m.role,
993 snippet(messages_fts, 1, '**', '**', '...', 32) as snippet,
994 m.timestamp,
995 s.working_directory,
996 s.tool,
997 s.git_branch,
998 s.message_count,
999 s.started_at,
1000 m.idx as message_index
1001 FROM messages_fts fts
1002 JOIN messages m ON fts.message_id = m.id
1003 JOIN sessions s ON m.session_id = s.id
1004 WHERE messages_fts MATCH ?1
1005 "#,
1006 );
1007
1008 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(escaped_query.clone())];
1009 let mut param_idx = 2;
1010
1011 if options.repo.is_some() {
1013 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1014 param_idx += 1;
1015 }
1016 if options.tool.is_some() {
1017 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
1018 param_idx += 1;
1019 }
1020 if options.since.is_some() {
1021 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
1022 param_idx += 1;
1023 }
1024 if options.until.is_some() {
1025 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
1026 param_idx += 1;
1027 }
1028 if options.project.is_some() {
1029 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1030 param_idx += 1;
1031 }
1032 if options.branch.is_some() {
1033 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
1034 param_idx += 1;
1035 }
1036 if options.role.is_some() {
1037 sql.push_str(&format!(" AND m.role = ?{param_idx}"));
1038 param_idx += 1;
1039 }
1040
1041 if let Some(ref wd) = options.repo {
1043 params_vec.push(Box::new(format!("{wd}%")));
1044 }
1045 if let Some(ref tool) = options.tool {
1046 params_vec.push(Box::new(tool.clone()));
1047 }
1048 if let Some(ts) = options.since {
1049 params_vec.push(Box::new(ts.to_rfc3339()));
1050 }
1051 if let Some(ts) = options.until {
1052 params_vec.push(Box::new(ts.to_rfc3339()));
1053 }
1054 if let Some(ref project) = options.project {
1055 params_vec.push(Box::new(format!("%{project}%")));
1056 }
1057 if let Some(ref branch) = options.branch {
1058 params_vec.push(Box::new(format!("%{branch}%")));
1059 }
1060 if let Some(ref role) = options.role {
1061 params_vec.push(Box::new(role.clone()));
1062 }
1063
1064 let include_metadata_search = options.role.is_none();
1068 let metadata_query_pattern = format!("%{}%", options.query);
1069
1070 if include_metadata_search {
1071 let meta_param1 = param_idx;
1073 let meta_param2 = param_idx + 1;
1074 let meta_param3 = param_idx + 2;
1075 param_idx += 3;
1076
1077 sql.push_str(&format!(
1078 r#"
1079 UNION
1080 SELECT
1081 s.id as session_id,
1082 (SELECT id FROM messages WHERE session_id = s.id ORDER BY idx LIMIT 1) as message_id,
1083 'user' as role,
1084 substr(s.tool || ' session in ' || s.working_directory || COALESCE(' on branch ' || s.git_branch, ''), 1, 100) as snippet,
1085 s.started_at as timestamp,
1086 s.working_directory,
1087 s.tool,
1088 s.git_branch,
1089 s.message_count,
1090 s.started_at,
1091 0 as message_index
1092 FROM sessions s
1093 WHERE (
1094 s.tool LIKE ?{meta_param1}
1095 OR s.working_directory LIKE ?{meta_param2}
1096 OR s.git_branch LIKE ?{meta_param3}
1097 )
1098 "#
1099 ));
1100
1101 params_vec.push(Box::new(metadata_query_pattern.clone()));
1103 params_vec.push(Box::new(metadata_query_pattern.clone()));
1104 params_vec.push(Box::new(metadata_query_pattern));
1105
1106 if let Some(repo) = &options.repo {
1108 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1109 params_vec.push(Box::new(format!("{}%", repo)));
1110 param_idx += 1;
1111 }
1112 if let Some(tool) = &options.tool {
1113 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
1114 params_vec.push(Box::new(tool.clone()));
1115 param_idx += 1;
1116 }
1117 if let Some(since) = options.since {
1118 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
1119 params_vec.push(Box::new(since.to_rfc3339()));
1120 param_idx += 1;
1121 }
1122 if let Some(until) = options.until {
1123 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
1124 params_vec.push(Box::new(until.to_rfc3339()));
1125 param_idx += 1;
1126 }
1127 if let Some(project) = &options.project {
1128 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1129 params_vec.push(Box::new(format!("%{}%", project)));
1130 param_idx += 1;
1131 }
1132 if let Some(branch) = &options.branch {
1133 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
1134 params_vec.push(Box::new(format!("%{}%", branch)));
1135 param_idx += 1;
1136 }
1137 }
1138
1139 sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{param_idx}"));
1140 params_vec.push(Box::new(options.limit as i64));
1141
1142 let mut stmt = self.conn.prepare(&sql)?;
1144 let params_refs: Vec<&dyn rusqlite::ToSql> =
1145 params_vec.iter().map(|p| p.as_ref()).collect();
1146
1147 let rows = stmt.query_map(params_refs.as_slice(), |row| {
1148 let role_str: String = row.get(2)?;
1149 let git_branch: Option<String> = row.get(7)?;
1150 let started_at_str: Option<String> = row.get(9)?;
1151
1152 Ok(SearchResult {
1153 session_id: parse_uuid(&row.get::<_, String>(0)?)?,
1154 message_id: parse_uuid(&row.get::<_, String>(1)?)?,
1155 role: match role_str.as_str() {
1156 "user" => MessageRole::User,
1157 "assistant" => MessageRole::Assistant,
1158 "system" => MessageRole::System,
1159 _ => MessageRole::User,
1160 },
1161 snippet: row.get(3)?,
1162 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1163 working_directory: row.get(5)?,
1164 tool: row.get(6)?,
1165 git_branch,
1166 session_message_count: row.get(8)?,
1167 session_started_at: started_at_str.map(|s| parse_datetime(&s)).transpose()?,
1168 message_index: row.get(10)?,
1169 })
1170 })?;
1171
1172 rows.collect::<Result<Vec<_>, _>>()
1173 .context("Failed to search messages")
1174 }
1175
1176 pub fn get_context_messages(
1181 &self,
1182 session_id: &Uuid,
1183 message_index: i32,
1184 context_count: usize,
1185 ) -> Result<(Vec<Message>, Vec<Message>)> {
1186 let mut before_stmt = self.conn.prepare(
1188 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1189 FROM messages
1190 WHERE session_id = ?1 AND idx < ?2
1191 ORDER BY idx DESC
1192 LIMIT ?3",
1193 )?;
1194
1195 let before_rows = before_stmt.query_map(
1196 params![session_id.to_string(), message_index, context_count as i64],
1197 Self::row_to_message,
1198 )?;
1199
1200 let mut before: Vec<Message> = before_rows
1201 .collect::<Result<Vec<_>, _>>()
1202 .context("Failed to get before messages")?;
1203 before.reverse(); let mut after_stmt = self.conn.prepare(
1207 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1208 FROM messages
1209 WHERE session_id = ?1 AND idx > ?2
1210 ORDER BY idx ASC
1211 LIMIT ?3",
1212 )?;
1213
1214 let after_rows = after_stmt.query_map(
1215 params![session_id.to_string(), message_index, context_count as i64],
1216 Self::row_to_message,
1217 )?;
1218
1219 let after: Vec<Message> = after_rows
1220 .collect::<Result<Vec<_>, _>>()
1221 .context("Failed to get after messages")?;
1222
1223 Ok((before, after))
1224 }
1225
1226 #[allow(dead_code)]
1228 pub fn get_message_by_index(&self, session_id: &Uuid, index: i32) -> Result<Option<Message>> {
1229 self.conn
1230 .query_row(
1231 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1232 FROM messages
1233 WHERE session_id = ?1 AND idx = ?2",
1234 params![session_id.to_string(), index],
1235 Self::row_to_message,
1236 )
1237 .optional()
1238 .context("Failed to get message by index")
1239 }
1240
1241 fn row_to_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
1242 let role_str: String = row.get(5)?;
1243 let content_str: String = row.get(6)?;
1244
1245 let parent_id_str: Option<String> = row.get(2)?;
1246 let parent_id = match parent_id_str {
1247 Some(s) => Some(parse_uuid(&s)?),
1248 None => None,
1249 };
1250
1251 Ok(Message {
1252 id: parse_uuid(&row.get::<_, String>(0)?)?,
1253 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1254 parent_id,
1255 index: row.get(3)?,
1256 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1257 role: match role_str.as_str() {
1258 "user" => MessageRole::User,
1259 "assistant" => MessageRole::Assistant,
1260 "system" => MessageRole::System,
1261 _ => MessageRole::User,
1262 },
1263 content: serde_json::from_str(&content_str)
1264 .unwrap_or(MessageContent::Text(content_str)),
1265 model: row.get(7)?,
1266 git_branch: row.get(8)?,
1267 cwd: row.get(9)?,
1268 })
1269 }
1270
1271 pub fn rebuild_search_index(&self) -> Result<usize> {
1279 self.conn.execute("DELETE FROM messages_fts", [])?;
1281 self.conn.execute("DELETE FROM sessions_fts", [])?;
1282
1283 let mut msg_stmt = self.conn.prepare("SELECT id, content FROM messages")?;
1285
1286 let rows = msg_stmt.query_map([], |row| {
1287 let id: String = row.get(0)?;
1288 let content_json: String = row.get(1)?;
1289 Ok((id, content_json))
1290 })?;
1291
1292 let mut count = 0;
1293 for row in rows {
1294 let (id, content_json) = row?;
1295 let content: MessageContent = serde_json::from_str(&content_json)
1297 .unwrap_or(MessageContent::Text(content_json.clone()));
1298 let text_content = content.text();
1299
1300 if !text_content.is_empty() {
1301 self.conn.execute(
1302 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
1303 params![id, text_content],
1304 )?;
1305 count += 1;
1306 }
1307 }
1308
1309 let mut session_stmt = self
1311 .conn
1312 .prepare("SELECT id, tool, working_directory, git_branch FROM sessions")?;
1313
1314 let session_rows = session_stmt.query_map([], |row| {
1315 let id: String = row.get(0)?;
1316 let tool: String = row.get(1)?;
1317 let working_directory: String = row.get(2)?;
1318 let git_branch: Option<String> = row.get(3)?;
1319 Ok((id, tool, working_directory, git_branch))
1320 })?;
1321
1322 for row in session_rows {
1323 let (id, tool, working_directory, git_branch) = row?;
1324 self.conn.execute(
1325 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
1326 params![id, tool, working_directory, git_branch.unwrap_or_default()],
1327 )?;
1328 }
1329
1330 Ok(count)
1331 }
1332
1333 pub fn search_index_needs_rebuild(&self) -> Result<bool> {
1338 let message_count: i32 =
1339 self.conn
1340 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1341
1342 let msg_fts_count: i32 =
1343 self.conn
1344 .query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
1345
1346 let session_count: i32 =
1347 self.conn
1348 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1349
1350 let session_fts_count: i32 =
1351 self.conn
1352 .query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
1353
1354 Ok((message_count > 0 && msg_fts_count == 0)
1356 || (session_count > 0 && session_fts_count == 0))
1357 }
1358
1359 pub fn get_unsynced_sessions(&self) -> Result<Vec<Session>> {
1366 let mut stmt = self.conn.prepare(
1367 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
1368 FROM sessions
1369 WHERE synced_at IS NULL
1370 ORDER BY started_at ASC"
1371 )?;
1372
1373 let rows = stmt.query_map([], Self::row_to_session)?;
1374
1375 rows.collect::<Result<Vec<_>, _>>()
1376 .context("Failed to get unsynced sessions")
1377 }
1378
1379 pub fn unsynced_session_count(&self) -> Result<i32> {
1381 let count: i32 = self.conn.query_row(
1382 "SELECT COUNT(*) FROM sessions WHERE synced_at IS NULL",
1383 [],
1384 |row| row.get(0),
1385 )?;
1386 Ok(count)
1387 }
1388
1389 pub fn mark_sessions_synced(
1393 &self,
1394 session_ids: &[Uuid],
1395 synced_at: DateTime<Utc>,
1396 ) -> Result<usize> {
1397 if session_ids.is_empty() {
1398 return Ok(0);
1399 }
1400
1401 let synced_at_str = synced_at.to_rfc3339();
1402 let mut total_updated = 0;
1403
1404 for id in session_ids {
1405 let updated = self.conn.execute(
1406 "UPDATE sessions SET synced_at = ?1 WHERE id = ?2",
1407 params![synced_at_str, id.to_string()],
1408 )?;
1409 total_updated += updated;
1410 }
1411
1412 Ok(total_updated)
1413 }
1414
1415 pub fn last_sync_time(&self) -> Result<Option<DateTime<Utc>>> {
1419 let result: Option<String> = self
1420 .conn
1421 .query_row(
1422 "SELECT MAX(synced_at) FROM sessions WHERE synced_at IS NOT NULL",
1423 [],
1424 |row| row.get(0),
1425 )
1426 .optional()?
1427 .flatten();
1428
1429 match result {
1430 Some(s) => Ok(Some(parse_datetime(&s)?)),
1431 None => Ok(None),
1432 }
1433 }
1434
1435 pub fn clear_sync_status(&self) -> Result<usize> {
1440 let updated = self
1441 .conn
1442 .execute("UPDATE sessions SET synced_at = NULL", [])?;
1443 Ok(updated)
1444 }
1445
1446 pub fn clear_sync_status_for_sessions(&self, session_ids: &[Uuid]) -> Result<usize> {
1459 if session_ids.is_empty() {
1460 return Ok(0);
1461 }
1462
1463 let mut total_updated = 0;
1464
1465 for id in session_ids {
1466 let updated = self.conn.execute(
1467 "UPDATE sessions SET synced_at = NULL WHERE id = ?1",
1468 params![id.to_string()],
1469 )?;
1470 total_updated += updated;
1471 }
1472
1473 Ok(total_updated)
1474 }
1475
1476 pub fn session_count(&self) -> Result<i32> {
1480 let count: i32 = self
1481 .conn
1482 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1483 Ok(count)
1484 }
1485
1486 pub fn message_count(&self) -> Result<i32> {
1488 let count: i32 = self
1489 .conn
1490 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1491 Ok(count)
1492 }
1493
1494 pub fn link_count(&self) -> Result<i32> {
1496 let count: i32 = self
1497 .conn
1498 .query_row("SELECT COUNT(*) FROM session_links", [], |row| row.get(0))?;
1499 Ok(count)
1500 }
1501
1502 pub fn db_path(&self) -> Option<std::path::PathBuf> {
1506 self.conn.path().map(std::path::PathBuf::from)
1507 }
1508
1509 pub fn find_sessions_near_commit_time(
1526 &self,
1527 commit_time: chrono::DateTime<chrono::Utc>,
1528 window_minutes: i64,
1529 working_dir: Option<&str>,
1530 ) -> Result<Vec<Session>> {
1531 let commit_time_str = commit_time.to_rfc3339();
1533
1534 let window = chrono::Duration::minutes(window_minutes);
1536 let window_start = (commit_time - window).to_rfc3339();
1537 let window_end = (commit_time + window).to_rfc3339();
1538
1539 let sql = if working_dir.is_some() {
1540 r#"
1541 SELECT id, tool, tool_version, started_at, ended_at, model,
1542 working_directory, git_branch, source_path, message_count, machine_id
1543 FROM sessions
1544 WHERE working_directory LIKE ?1
1545 AND (
1546 -- Session started before or during the window
1547 (started_at <= ?3)
1548 AND
1549 -- Session ended after or during the window (or is still ongoing)
1550 (ended_at IS NULL OR ended_at >= ?2)
1551 )
1552 ORDER BY
1553 -- Order by how close the session end (or start) is to commit time
1554 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?4))
1555 "#
1556 } else {
1557 r#"
1558 SELECT id, tool, tool_version, started_at, ended_at, model,
1559 working_directory, git_branch, source_path, message_count, machine_id
1560 FROM sessions
1561 WHERE
1562 -- Session started before or during the window
1563 (started_at <= ?2)
1564 AND
1565 -- Session ended after or during the window (or is still ongoing)
1566 (ended_at IS NULL OR ended_at >= ?1)
1567 ORDER BY
1568 -- Order by how close the session end (or start) is to commit time
1569 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?3))
1570 "#
1571 };
1572
1573 let mut stmt = self.conn.prepare(sql)?;
1574
1575 let rows = if let Some(wd) = working_dir {
1576 stmt.query_map(
1577 params![format!("{wd}%"), window_start, window_end, commit_time_str],
1578 Self::row_to_session,
1579 )?
1580 } else {
1581 stmt.query_map(
1582 params![window_start, window_end, commit_time_str],
1583 Self::row_to_session,
1584 )?
1585 };
1586
1587 rows.collect::<Result<Vec<_>, _>>()
1588 .context("Failed to find sessions near commit time")
1589 }
1590
1591 pub fn link_exists(&self, session_id: &Uuid, commit_sha: &str) -> Result<bool> {
1595 let pattern = format!("{commit_sha}%");
1596 let count: i32 = self.conn.query_row(
1597 "SELECT COUNT(*) FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1598 params![session_id.to_string(), pattern],
1599 |row| row.get(0),
1600 )?;
1601 Ok(count > 0)
1602 }
1603
1604 pub fn find_active_sessions_for_directory(
1623 &self,
1624 directory: &str,
1625 recent_minutes: Option<i64>,
1626 ) -> Result<Vec<Session>> {
1627 fn escape_like(input: &str) -> String {
1628 let mut escaped = String::with_capacity(input.len());
1629 for ch in input.chars() {
1630 match ch {
1631 '|' => escaped.push_str("||"),
1632 '%' => escaped.push_str("|%"),
1633 '_' => escaped.push_str("|_"),
1634 _ => escaped.push(ch),
1635 }
1636 }
1637 escaped
1638 }
1639
1640 let minutes = recent_minutes.unwrap_or(5);
1641 let cutoff = (chrono::Utc::now() - chrono::Duration::minutes(minutes)).to_rfc3339();
1642 let separator = std::path::MAIN_SEPARATOR.to_string();
1643 let mut normalized = directory
1644 .trim_end_matches(std::path::MAIN_SEPARATOR)
1645 .to_string();
1646 if normalized.is_empty() {
1647 normalized = separator.clone();
1648 }
1649 let trailing = if normalized == separator {
1650 normalized.clone()
1651 } else {
1652 format!("{normalized}{separator}")
1653 };
1654 let like_pattern = format!("{}%", escape_like(&trailing));
1655
1656 let sql = r#"
1657 SELECT id, tool, tool_version, started_at, ended_at, model,
1658 working_directory, git_branch, source_path, message_count, machine_id
1659 FROM sessions
1660 WHERE (working_directory = ?1
1661 OR working_directory = ?2
1662 OR working_directory LIKE ?3 ESCAPE '|')
1663 AND (ended_at IS NULL OR ended_at >= ?4)
1664 ORDER BY started_at DESC
1665 "#;
1666
1667 let mut stmt = self.conn.prepare(sql)?;
1668 let rows = stmt.query_map(
1669 params![normalized, trailing, like_pattern, cutoff],
1670 Self::row_to_session,
1671 )?;
1672
1673 rows.collect::<Result<Vec<_>, _>>()
1674 .context("Failed to find active sessions for directory")
1675 }
1676
1677 pub fn delete_session(&self, session_id: &Uuid) -> Result<(usize, usize)> {
1688 let session_id_str = session_id.to_string();
1689
1690 self.conn.execute(
1692 "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1693 params![session_id_str],
1694 )?;
1695
1696 let messages_deleted = self.conn.execute(
1698 "DELETE FROM messages WHERE session_id = ?1",
1699 params![session_id_str],
1700 )?;
1701
1702 let links_deleted = self.conn.execute(
1704 "DELETE FROM session_links WHERE session_id = ?1",
1705 params![session_id_str],
1706 )?;
1707
1708 self.conn.execute(
1710 "DELETE FROM annotations WHERE session_id = ?1",
1711 params![session_id_str],
1712 )?;
1713
1714 self.conn.execute(
1716 "DELETE FROM tags WHERE session_id = ?1",
1717 params![session_id_str],
1718 )?;
1719
1720 self.conn.execute(
1722 "DELETE FROM summaries WHERE session_id = ?1",
1723 params![session_id_str],
1724 )?;
1725
1726 self.conn.execute(
1728 "DELETE FROM sessions_fts WHERE session_id = ?1",
1729 params![session_id_str],
1730 )?;
1731
1732 self.conn.execute(
1734 "DELETE FROM sessions WHERE id = ?1",
1735 params![session_id_str],
1736 )?;
1737
1738 Ok((messages_deleted, links_deleted))
1739 }
1740
1741 pub fn insert_annotation(&self, annotation: &Annotation) -> Result<()> {
1747 self.conn.execute(
1748 r#"
1749 INSERT INTO annotations (id, session_id, content, created_at)
1750 VALUES (?1, ?2, ?3, ?4)
1751 "#,
1752 params![
1753 annotation.id.to_string(),
1754 annotation.session_id.to_string(),
1755 annotation.content,
1756 annotation.created_at.to_rfc3339(),
1757 ],
1758 )?;
1759 Ok(())
1760 }
1761
1762 #[allow(dead_code)]
1766 pub fn get_annotations(&self, session_id: &Uuid) -> Result<Vec<Annotation>> {
1767 let mut stmt = self.conn.prepare(
1768 "SELECT id, session_id, content, created_at
1769 FROM annotations
1770 WHERE session_id = ?1
1771 ORDER BY created_at ASC",
1772 )?;
1773
1774 let rows = stmt.query_map(params![session_id.to_string()], |row| {
1775 Ok(Annotation {
1776 id: parse_uuid(&row.get::<_, String>(0)?)?,
1777 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1778 content: row.get(2)?,
1779 created_at: parse_datetime(&row.get::<_, String>(3)?)?,
1780 })
1781 })?;
1782
1783 rows.collect::<Result<Vec<_>, _>>()
1784 .context("Failed to get annotations")
1785 }
1786
1787 #[allow(dead_code)]
1791 pub fn delete_annotation(&self, annotation_id: &Uuid) -> Result<bool> {
1792 let rows_affected = self.conn.execute(
1793 "DELETE FROM annotations WHERE id = ?1",
1794 params![annotation_id.to_string()],
1795 )?;
1796 Ok(rows_affected > 0)
1797 }
1798
1799 #[allow(dead_code)]
1803 pub fn delete_annotations_by_session(&self, session_id: &Uuid) -> Result<usize> {
1804 let rows_affected = self.conn.execute(
1805 "DELETE FROM annotations WHERE session_id = ?1",
1806 params![session_id.to_string()],
1807 )?;
1808 Ok(rows_affected)
1809 }
1810
1811 pub fn insert_tag(&self, tag: &Tag) -> Result<()> {
1818 self.conn.execute(
1819 r#"
1820 INSERT INTO tags (id, session_id, label, created_at)
1821 VALUES (?1, ?2, ?3, ?4)
1822 "#,
1823 params![
1824 tag.id.to_string(),
1825 tag.session_id.to_string(),
1826 tag.label,
1827 tag.created_at.to_rfc3339(),
1828 ],
1829 )?;
1830 Ok(())
1831 }
1832
1833 pub fn get_tags(&self, session_id: &Uuid) -> Result<Vec<Tag>> {
1837 let mut stmt = self.conn.prepare(
1838 "SELECT id, session_id, label, created_at
1839 FROM tags
1840 WHERE session_id = ?1
1841 ORDER BY label ASC",
1842 )?;
1843
1844 let rows = stmt.query_map(params![session_id.to_string()], |row| {
1845 Ok(Tag {
1846 id: parse_uuid(&row.get::<_, String>(0)?)?,
1847 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1848 label: row.get(2)?,
1849 created_at: parse_datetime(&row.get::<_, String>(3)?)?,
1850 })
1851 })?;
1852
1853 rows.collect::<Result<Vec<_>, _>>()
1854 .context("Failed to get tags")
1855 }
1856
1857 pub fn tag_exists(&self, session_id: &Uuid, label: &str) -> Result<bool> {
1859 let count: i32 = self.conn.query_row(
1860 "SELECT COUNT(*) FROM tags WHERE session_id = ?1 AND label = ?2",
1861 params![session_id.to_string(), label],
1862 |row| row.get(0),
1863 )?;
1864 Ok(count > 0)
1865 }
1866
1867 pub fn delete_tag(&self, session_id: &Uuid, label: &str) -> Result<bool> {
1871 let rows_affected = self.conn.execute(
1872 "DELETE FROM tags WHERE session_id = ?1 AND label = ?2",
1873 params![session_id.to_string(), label],
1874 )?;
1875 Ok(rows_affected > 0)
1876 }
1877
1878 #[allow(dead_code)]
1882 pub fn delete_tags_by_session(&self, session_id: &Uuid) -> Result<usize> {
1883 let rows_affected = self.conn.execute(
1884 "DELETE FROM tags WHERE session_id = ?1",
1885 params![session_id.to_string()],
1886 )?;
1887 Ok(rows_affected)
1888 }
1889
1890 pub fn list_sessions_with_tag(&self, label: &str, limit: usize) -> Result<Vec<Session>> {
1894 let mut stmt = self.conn.prepare(
1895 "SELECT s.id, s.tool, s.tool_version, s.started_at, s.ended_at, s.model,
1896 s.working_directory, s.git_branch, s.source_path, s.message_count, s.machine_id
1897 FROM sessions s
1898 INNER JOIN tags t ON s.id = t.session_id
1899 WHERE t.label = ?1
1900 ORDER BY s.started_at DESC
1901 LIMIT ?2",
1902 )?;
1903
1904 let rows = stmt.query_map(params![label, limit], Self::row_to_session)?;
1905
1906 rows.collect::<Result<Vec<_>, _>>()
1907 .context("Failed to list sessions with tag")
1908 }
1909
1910 pub fn insert_summary(&self, summary: &Summary) -> Result<()> {
1917 self.conn.execute(
1918 r#"
1919 INSERT INTO summaries (id, session_id, content, generated_at)
1920 VALUES (?1, ?2, ?3, ?4)
1921 "#,
1922 params![
1923 summary.id.to_string(),
1924 summary.session_id.to_string(),
1925 summary.content,
1926 summary.generated_at.to_rfc3339(),
1927 ],
1928 )?;
1929 Ok(())
1930 }
1931
1932 pub fn get_summary(&self, session_id: &Uuid) -> Result<Option<Summary>> {
1934 self.conn
1935 .query_row(
1936 "SELECT id, session_id, content, generated_at
1937 FROM summaries
1938 WHERE session_id = ?1",
1939 params![session_id.to_string()],
1940 |row| {
1941 Ok(Summary {
1942 id: parse_uuid(&row.get::<_, String>(0)?)?,
1943 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1944 content: row.get(2)?,
1945 generated_at: parse_datetime(&row.get::<_, String>(3)?)?,
1946 })
1947 },
1948 )
1949 .optional()
1950 .context("Failed to get summary")
1951 }
1952
1953 pub fn update_summary(&self, session_id: &Uuid, content: &str) -> Result<bool> {
1958 let now = chrono::Utc::now().to_rfc3339();
1959 let rows_affected = self.conn.execute(
1960 "UPDATE summaries SET content = ?1, generated_at = ?2 WHERE session_id = ?3",
1961 params![content, now, session_id.to_string()],
1962 )?;
1963 Ok(rows_affected > 0)
1964 }
1965
1966 #[allow(dead_code)]
1970 pub fn delete_summary(&self, session_id: &Uuid) -> Result<bool> {
1971 let rows_affected = self.conn.execute(
1972 "DELETE FROM summaries WHERE session_id = ?1",
1973 params![session_id.to_string()],
1974 )?;
1975 Ok(rows_affected > 0)
1976 }
1977
1978 pub fn upsert_machine(&self, machine: &Machine) -> Result<()> {
1985 self.conn.execute(
1986 r#"
1987 INSERT INTO machines (id, name, created_at)
1988 VALUES (?1, ?2, ?3)
1989 ON CONFLICT(id) DO UPDATE SET
1990 name = ?2
1991 "#,
1992 params![machine.id, machine.name, machine.created_at],
1993 )?;
1994 Ok(())
1995 }
1996
1997 #[allow(dead_code)]
2001 pub fn get_machine(&self, id: &str) -> Result<Option<Machine>> {
2002 self.conn
2003 .query_row(
2004 "SELECT id, name, created_at FROM machines WHERE id = ?1",
2005 params![id],
2006 |row| {
2007 Ok(Machine {
2008 id: row.get(0)?,
2009 name: row.get(1)?,
2010 created_at: row.get(2)?,
2011 })
2012 },
2013 )
2014 .optional()
2015 .context("Failed to get machine")
2016 }
2017
2018 #[allow(dead_code)]
2023 pub fn get_machine_name(&self, id: &str) -> Result<String> {
2024 if let Some(machine) = self.get_machine(id)? {
2025 Ok(machine.name)
2026 } else {
2027 if id.len() > 8 {
2029 Ok(id[..8].to_string())
2030 } else {
2031 Ok(id.to_string())
2032 }
2033 }
2034 }
2035
2036 #[allow(dead_code)]
2040 pub fn list_machines(&self) -> Result<Vec<Machine>> {
2041 let mut stmt = self
2042 .conn
2043 .prepare("SELECT id, name, created_at FROM machines ORDER BY created_at ASC")?;
2044
2045 let rows = stmt.query_map([], |row| {
2046 Ok(Machine {
2047 id: row.get(0)?,
2048 name: row.get(1)?,
2049 created_at: row.get(2)?,
2050 })
2051 })?;
2052
2053 rows.collect::<Result<Vec<_>, _>>()
2054 .context("Failed to list machines")
2055 }
2056
2057 pub fn get_most_recent_session_for_directory(
2062 &self,
2063 working_dir: &str,
2064 ) -> Result<Option<Session>> {
2065 self.conn
2066 .query_row(
2067 "SELECT id, tool, tool_version, started_at, ended_at, model,
2068 working_directory, git_branch, source_path, message_count, machine_id
2069 FROM sessions
2070 WHERE working_directory LIKE ?1
2071 ORDER BY started_at DESC
2072 LIMIT 1",
2073 params![format!("{working_dir}%")],
2074 Self::row_to_session,
2075 )
2076 .optional()
2077 .context("Failed to get most recent session for directory")
2078 }
2079
2080 pub fn vacuum(&self) -> Result<()> {
2087 self.conn.execute("VACUUM", [])?;
2088 Ok(())
2089 }
2090
2091 pub fn file_size(&self) -> Result<Option<u64>> {
2095 if let Some(path) = self.db_path() {
2096 let metadata = std::fs::metadata(&path)?;
2097 Ok(Some(metadata.len()))
2098 } else {
2099 Ok(None)
2100 }
2101 }
2102
2103 pub fn delete_sessions_older_than(&self, before: DateTime<Utc>) -> Result<usize> {
2115 let before_str = before.to_rfc3339();
2116
2117 let mut stmt = self
2119 .conn
2120 .prepare("SELECT id FROM sessions WHERE started_at < ?1")?;
2121 let session_ids: Vec<String> = stmt
2122 .query_map(params![before_str], |row| row.get(0))?
2123 .collect::<Result<Vec<_>, _>>()?;
2124
2125 if session_ids.is_empty() {
2126 return Ok(0);
2127 }
2128
2129 let count = session_ids.len();
2130
2131 for session_id_str in &session_ids {
2133 self.conn.execute(
2135 "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
2136 params![session_id_str],
2137 )?;
2138
2139 self.conn.execute(
2141 "DELETE FROM messages WHERE session_id = ?1",
2142 params![session_id_str],
2143 )?;
2144
2145 self.conn.execute(
2147 "DELETE FROM session_links WHERE session_id = ?1",
2148 params![session_id_str],
2149 )?;
2150
2151 self.conn.execute(
2153 "DELETE FROM annotations WHERE session_id = ?1",
2154 params![session_id_str],
2155 )?;
2156
2157 self.conn.execute(
2159 "DELETE FROM tags WHERE session_id = ?1",
2160 params![session_id_str],
2161 )?;
2162
2163 self.conn.execute(
2165 "DELETE FROM summaries WHERE session_id = ?1",
2166 params![session_id_str],
2167 )?;
2168
2169 self.conn.execute(
2171 "DELETE FROM sessions_fts WHERE session_id = ?1",
2172 params![session_id_str],
2173 )?;
2174 }
2175
2176 self.conn.execute(
2178 "DELETE FROM sessions WHERE started_at < ?1",
2179 params![before_str],
2180 )?;
2181
2182 Ok(count)
2183 }
2184
2185 pub fn count_sessions_older_than(&self, before: DateTime<Utc>) -> Result<i32> {
2195 let before_str = before.to_rfc3339();
2196 let count: i32 = self.conn.query_row(
2197 "SELECT COUNT(*) FROM sessions WHERE started_at < ?1",
2198 params![before_str],
2199 |row| row.get(0),
2200 )?;
2201 Ok(count)
2202 }
2203
2204 pub fn get_sessions_older_than(&self, before: DateTime<Utc>) -> Result<Vec<Session>> {
2214 let before_str = before.to_rfc3339();
2215 let mut stmt = self.conn.prepare(
2216 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
2217 FROM sessions
2218 WHERE started_at < ?1
2219 ORDER BY started_at ASC",
2220 )?;
2221
2222 let rows = stmt.query_map(params![before_str], Self::row_to_session)?;
2223
2224 rows.collect::<Result<Vec<_>, _>>()
2225 .context("Failed to get sessions older than cutoff")
2226 }
2227
2228 pub fn stats(&self) -> Result<DatabaseStats> {
2235 let session_count = self.session_count()?;
2236 let message_count = self.message_count()?;
2237 let link_count = self.link_count()?;
2238
2239 let oldest: Option<String> = self
2241 .conn
2242 .query_row("SELECT MIN(started_at) FROM sessions", [], |row| row.get(0))
2243 .optional()?
2244 .flatten();
2245
2246 let newest: Option<String> = self
2247 .conn
2248 .query_row("SELECT MAX(started_at) FROM sessions", [], |row| row.get(0))
2249 .optional()?
2250 .flatten();
2251
2252 let oldest_session = oldest
2253 .map(|s| parse_datetime(&s))
2254 .transpose()
2255 .unwrap_or(None);
2256 let newest_session = newest
2257 .map(|s| parse_datetime(&s))
2258 .transpose()
2259 .unwrap_or(None);
2260
2261 let mut stmt = self
2263 .conn
2264 .prepare("SELECT tool, COUNT(*) FROM sessions GROUP BY tool ORDER BY COUNT(*) DESC")?;
2265 let sessions_by_tool: Vec<(String, i32)> = stmt
2266 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
2267 .collect::<Result<Vec<_>, _>>()?;
2268
2269 Ok(DatabaseStats {
2270 session_count,
2271 message_count,
2272 link_count,
2273 oldest_session,
2274 newest_session,
2275 sessions_by_tool,
2276 })
2277 }
2278
2279 pub fn sessions_in_date_range(
2287 &self,
2288 since: Option<DateTime<Utc>>,
2289 until: Option<DateTime<Utc>>,
2290 working_dir: Option<&str>,
2291 ) -> Result<Vec<Session>> {
2292 let mut conditions = Vec::new();
2293 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
2294
2295 if let Some(since) = since {
2296 conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
2297 param_values.push(Box::new(since.to_rfc3339()));
2298 }
2299 if let Some(until) = until {
2300 conditions.push(format!("started_at <= ?{}", param_values.len() + 1));
2301 param_values.push(Box::new(until.to_rfc3339()));
2302 }
2303 if let Some(wd) = working_dir {
2304 conditions.push(format!(
2305 "working_directory LIKE ?{}",
2306 param_values.len() + 1
2307 ));
2308 param_values.push(Box::new(format!("{}%", wd)));
2309 }
2310
2311 let where_clause = if conditions.is_empty() {
2312 String::new()
2313 } else {
2314 format!(" WHERE {}", conditions.join(" AND "))
2315 };
2316
2317 let sql = format!(
2318 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
2319 FROM sessions{}
2320 ORDER BY started_at DESC",
2321 where_clause
2322 );
2323
2324 let mut stmt = self.conn.prepare(&sql)?;
2325 let params = rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref()));
2326 let rows = stmt.query_map(params, Self::row_to_session)?;
2327
2328 rows.collect::<Result<Vec<_>, _>>()
2329 .context("Failed to query sessions in date range")
2330 }
2331
2332 pub fn average_session_duration_minutes(
2337 &self,
2338 since: Option<DateTime<Utc>>,
2339 working_dir: Option<&str>,
2340 ) -> Result<Option<f64>> {
2341 let mut conditions = vec!["ended_at IS NOT NULL".to_string()];
2342 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
2343
2344 if let Some(since) = since {
2345 conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
2346 param_values.push(Box::new(since.to_rfc3339()));
2347 }
2348 if let Some(wd) = working_dir {
2349 conditions.push(format!(
2350 "working_directory LIKE ?{}",
2351 param_values.len() + 1
2352 ));
2353 param_values.push(Box::new(format!("{}%", wd)));
2354 }
2355
2356 let where_clause = format!(" WHERE {}", conditions.join(" AND "));
2357
2358 let sql = format!(
2359 "SELECT AVG((julianday(ended_at) - julianday(started_at)) * 24 * 60) FROM sessions{}",
2360 where_clause
2361 );
2362
2363 let avg: Option<f64> = self
2364 .conn
2365 .query_row(
2366 &sql,
2367 rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref())),
2368 |row| row.get(0),
2369 )
2370 .optional()?
2371 .flatten();
2372
2373 Ok(avg)
2374 }
2375
2376 pub fn sessions_by_tool_in_range(
2381 &self,
2382 since: Option<DateTime<Utc>>,
2383 working_dir: Option<&str>,
2384 ) -> Result<Vec<(String, i32)>> {
2385 let mut conditions = Vec::new();
2386 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
2387
2388 if let Some(since) = since {
2389 conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
2390 param_values.push(Box::new(since.to_rfc3339()));
2391 }
2392 if let Some(wd) = working_dir {
2393 conditions.push(format!(
2394 "working_directory LIKE ?{}",
2395 param_values.len() + 1
2396 ));
2397 param_values.push(Box::new(format!("{}%", wd)));
2398 }
2399
2400 let where_clause = if conditions.is_empty() {
2401 String::new()
2402 } else {
2403 format!(" WHERE {}", conditions.join(" AND "))
2404 };
2405
2406 let sql = format!(
2407 "SELECT tool, COUNT(*) FROM sessions{} GROUP BY tool ORDER BY COUNT(*) DESC",
2408 where_clause
2409 );
2410
2411 let mut stmt = self.conn.prepare(&sql)?;
2412 let params = rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref()));
2413 let rows = stmt.query_map(params, |row| Ok((row.get(0)?, row.get(1)?)))?;
2414
2415 rows.collect::<Result<Vec<_>, _>>()
2416 .context("Failed to query sessions by tool")
2417 }
2418
2419 pub fn sessions_by_weekday(
2426 &self,
2427 since: Option<DateTime<Utc>>,
2428 working_dir: Option<&str>,
2429 ) -> Result<Vec<(i32, i32)>> {
2430 let mut conditions = Vec::new();
2431 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
2432
2433 if let Some(since) = since {
2434 conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
2435 param_values.push(Box::new(since.to_rfc3339()));
2436 }
2437 if let Some(wd) = working_dir {
2438 conditions.push(format!(
2439 "working_directory LIKE ?{}",
2440 param_values.len() + 1
2441 ));
2442 param_values.push(Box::new(format!("{}%", wd)));
2443 }
2444
2445 let where_clause = if conditions.is_empty() {
2446 String::new()
2447 } else {
2448 format!(" WHERE {}", conditions.join(" AND "))
2449 };
2450
2451 let sql = format!(
2452 "SELECT CAST(strftime('%w', started_at) AS INTEGER), COUNT(*) FROM sessions{} GROUP BY strftime('%w', started_at) ORDER BY strftime('%w', started_at)",
2453 where_clause
2454 );
2455
2456 let mut stmt = self.conn.prepare(&sql)?;
2457 let params = rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref()));
2458 let rows = stmt.query_map(params, |row| Ok((row.get(0)?, row.get(1)?)))?;
2459
2460 rows.collect::<Result<Vec<_>, _>>()
2461 .context("Failed to query sessions by weekday")
2462 }
2463
2464 pub fn average_message_count(
2468 &self,
2469 since: Option<DateTime<Utc>>,
2470 working_dir: Option<&str>,
2471 ) -> Result<Option<f64>> {
2472 let mut conditions = Vec::new();
2473 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
2474
2475 if let Some(since) = since {
2476 conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
2477 param_values.push(Box::new(since.to_rfc3339()));
2478 }
2479 if let Some(wd) = working_dir {
2480 conditions.push(format!(
2481 "working_directory LIKE ?{}",
2482 param_values.len() + 1
2483 ));
2484 param_values.push(Box::new(format!("{}%", wd)));
2485 }
2486
2487 let where_clause = if conditions.is_empty() {
2488 String::new()
2489 } else {
2490 format!(" WHERE {}", conditions.join(" AND "))
2491 };
2492
2493 let sql = format!("SELECT AVG(message_count) FROM sessions{}", where_clause);
2494
2495 let avg: Option<f64> = self
2496 .conn
2497 .query_row(
2498 &sql,
2499 rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref())),
2500 |row| row.get(0),
2501 )
2502 .optional()?
2503 .flatten();
2504
2505 Ok(avg)
2506 }
2507}
2508
2509#[derive(Debug, Clone)]
2511pub struct DatabaseStats {
2512 pub session_count: i32,
2514 pub message_count: i32,
2516 pub link_count: i32,
2518 pub oldest_session: Option<DateTime<Utc>>,
2520 pub newest_session: Option<DateTime<Utc>>,
2522 pub sessions_by_tool: Vec<(String, i32)>,
2524}
2525
2526#[cfg(test)]
2527mod tests {
2528 use super::*;
2529 use crate::storage::models::{
2530 LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
2531 };
2532 use chrono::{Duration, Utc};
2533 use tempfile::tempdir;
2534
2535 fn create_test_db() -> (Database, tempfile::TempDir) {
2538 let dir = tempdir().expect("Failed to create temp directory");
2539 let db_path = dir.path().join("test.db");
2540 let db = Database::open(&db_path).expect("Failed to open test database");
2541 (db, dir)
2542 }
2543
2544 fn create_test_session(
2546 tool: &str,
2547 working_directory: &str,
2548 started_at: chrono::DateTime<Utc>,
2549 source_path: Option<&str>,
2550 ) -> Session {
2551 Session {
2552 id: Uuid::new_v4(),
2553 tool: tool.to_string(),
2554 tool_version: Some("1.0.0".to_string()),
2555 started_at,
2556 ended_at: None,
2557 model: Some("test-model".to_string()),
2558 working_directory: working_directory.to_string(),
2559 git_branch: Some("main".to_string()),
2560 source_path: source_path.map(|s| s.to_string()),
2561 message_count: 0,
2562 machine_id: Some("test-machine".to_string()),
2563 }
2564 }
2565
2566 fn create_test_message(
2568 session_id: Uuid,
2569 index: i32,
2570 role: MessageRole,
2571 content: &str,
2572 ) -> Message {
2573 Message {
2574 id: Uuid::new_v4(),
2575 session_id,
2576 parent_id: None,
2577 index,
2578 timestamp: Utc::now(),
2579 role,
2580 content: MessageContent::Text(content.to_string()),
2581 model: Some("test-model".to_string()),
2582 git_branch: Some("main".to_string()),
2583 cwd: Some("/test/cwd".to_string()),
2584 }
2585 }
2586
2587 fn create_test_link(
2589 session_id: Uuid,
2590 commit_sha: Option<&str>,
2591 link_type: LinkType,
2592 ) -> SessionLink {
2593 SessionLink {
2594 id: Uuid::new_v4(),
2595 session_id,
2596 link_type,
2597 commit_sha: commit_sha.map(|s| s.to_string()),
2598 branch: Some("main".to_string()),
2599 remote: Some("origin".to_string()),
2600 created_at: Utc::now(),
2601 created_by: LinkCreator::Auto,
2602 confidence: Some(0.95),
2603 }
2604 }
2605
2606 #[test]
2609 fn test_insert_and_get_session() {
2610 let (db, _dir) = create_test_db();
2611 let session = create_test_session(
2612 "claude-code",
2613 "/home/user/project",
2614 Utc::now(),
2615 Some("/path/to/source.jsonl"),
2616 );
2617
2618 db.insert_session(&session)
2619 .expect("Failed to insert session");
2620
2621 let retrieved = db
2622 .get_session(&session.id)
2623 .expect("Failed to get session")
2624 .expect("Session should exist");
2625
2626 assert_eq!(retrieved.id, session.id, "Session ID should match");
2627 assert_eq!(retrieved.tool, session.tool, "Tool should match");
2628 assert_eq!(
2629 retrieved.tool_version, session.tool_version,
2630 "Tool version should match"
2631 );
2632 assert_eq!(
2633 retrieved.working_directory, session.working_directory,
2634 "Working directory should match"
2635 );
2636 assert_eq!(
2637 retrieved.git_branch, session.git_branch,
2638 "Git branch should match"
2639 );
2640 assert_eq!(
2641 retrieved.source_path, session.source_path,
2642 "Source path should match"
2643 );
2644 }
2645
2646 #[test]
2647 fn test_list_sessions() {
2648 let (db, _dir) = create_test_db();
2649 let now = Utc::now();
2650
2651 let session1 =
2653 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
2654 let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
2655 let session3 = create_test_session("claude-code", "/project3", now, None);
2656
2657 db.insert_session(&session1)
2658 .expect("Failed to insert session1");
2659 db.insert_session(&session2)
2660 .expect("Failed to insert session2");
2661 db.insert_session(&session3)
2662 .expect("Failed to insert session3");
2663
2664 let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
2665
2666 assert_eq!(sessions.len(), 3, "Should have 3 sessions");
2667 assert_eq!(
2669 sessions[0].id, session3.id,
2670 "Most recent session should be first"
2671 );
2672 assert_eq!(
2673 sessions[1].id, session2.id,
2674 "Second most recent session should be second"
2675 );
2676 assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
2677 }
2678
2679 #[test]
2680 fn test_list_ended_sessions() {
2681 let (db, _dir) = create_test_db();
2682 let now = Utc::now();
2683
2684 let mut ended = create_test_session(
2685 "claude-code",
2686 "/home/user/project",
2687 now - Duration::minutes(60),
2688 None,
2689 );
2690 ended.ended_at = Some(now - Duration::minutes(30));
2691
2692 let ongoing = create_test_session(
2693 "claude-code",
2694 "/home/user/project",
2695 now - Duration::minutes(10),
2696 None,
2697 );
2698
2699 db.insert_session(&ended).expect("insert ended session");
2700 db.insert_session(&ongoing).expect("insert ongoing session");
2701
2702 let sessions = db
2703 .list_ended_sessions(100, None)
2704 .expect("Failed to list ended sessions");
2705
2706 assert_eq!(sessions.len(), 1);
2707 assert_eq!(sessions[0].id, ended.id);
2708 }
2709
2710 #[test]
2711 fn test_list_sessions_with_working_dir_filter() {
2712 let (db, _dir) = create_test_db();
2713 let now = Utc::now();
2714
2715 let session1 = create_test_session(
2716 "claude-code",
2717 "/home/user/project-a",
2718 now - Duration::hours(1),
2719 None,
2720 );
2721 let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
2722 let session3 = create_test_session("claude-code", "/other/path", now, None);
2723
2724 db.insert_session(&session1)
2725 .expect("Failed to insert session1");
2726 db.insert_session(&session2)
2727 .expect("Failed to insert session2");
2728 db.insert_session(&session3)
2729 .expect("Failed to insert session3");
2730
2731 let sessions = db
2733 .list_sessions(10, Some("/home/user"))
2734 .expect("Failed to list sessions");
2735
2736 assert_eq!(
2737 sessions.len(),
2738 2,
2739 "Should have 2 sessions matching /home/user prefix"
2740 );
2741
2742 let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
2744 assert!(ids.contains(&session1.id), "Should contain session1");
2745 assert!(ids.contains(&session2.id), "Should contain session2");
2746 assert!(!ids.contains(&session3.id), "Should not contain session3");
2747 }
2748
2749 #[test]
2750 fn test_session_exists_by_source() {
2751 let (db, _dir) = create_test_db();
2752 let source_path = "/path/to/session.jsonl";
2753
2754 let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
2755
2756 assert!(
2758 !db.session_exists_by_source(source_path)
2759 .expect("Failed to check existence"),
2760 "Session should not exist before insert"
2761 );
2762
2763 db.insert_session(&session)
2764 .expect("Failed to insert session");
2765
2766 assert!(
2768 db.session_exists_by_source(source_path)
2769 .expect("Failed to check existence"),
2770 "Session should exist after insert"
2771 );
2772
2773 assert!(
2775 !db.session_exists_by_source("/other/path.jsonl")
2776 .expect("Failed to check existence"),
2777 "Different source path should not exist"
2778 );
2779 }
2780
2781 #[test]
2782 fn test_get_session_by_source() {
2783 let (db, _dir) = create_test_db();
2784 let source_path = "/path/to/session.jsonl";
2785
2786 let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
2787
2788 assert!(
2790 db.get_session_by_source(source_path)
2791 .expect("Failed to get session")
2792 .is_none(),
2793 "Session should not exist before insert"
2794 );
2795
2796 db.insert_session(&session)
2797 .expect("Failed to insert session");
2798
2799 let retrieved = db
2801 .get_session_by_source(source_path)
2802 .expect("Failed to get session")
2803 .expect("Session should exist after insert");
2804
2805 assert_eq!(retrieved.id, session.id, "Session ID should match");
2806 assert_eq!(
2807 retrieved.source_path,
2808 Some(source_path.to_string()),
2809 "Source path should match"
2810 );
2811
2812 assert!(
2814 db.get_session_by_source("/other/path.jsonl")
2815 .expect("Failed to get session")
2816 .is_none(),
2817 "Different source path should return None"
2818 );
2819 }
2820
2821 #[test]
2822 fn test_update_session_branch() {
2823 let (db, _dir) = create_test_db();
2824 let now = Utc::now();
2825
2826 let mut session = create_test_session("claude-code", "/project", now, None);
2828 session.git_branch = Some("main".to_string());
2829
2830 db.insert_session(&session)
2831 .expect("Failed to insert session");
2832
2833 let fetched = db
2835 .get_session(&session.id)
2836 .expect("Failed to get session")
2837 .expect("Session should exist");
2838 assert_eq!(fetched.git_branch, Some("main".to_string()));
2839
2840 let rows = db
2842 .update_session_branch(session.id, "feature-branch")
2843 .expect("Failed to update branch");
2844 assert_eq!(rows, 1, "Should update exactly one row");
2845
2846 let fetched = db
2848 .get_session(&session.id)
2849 .expect("Failed to get session")
2850 .expect("Session should exist");
2851 assert_eq!(fetched.git_branch, Some("feature-branch".to_string()));
2852 }
2853
2854 #[test]
2855 fn test_update_session_branch_nonexistent() {
2856 let (db, _dir) = create_test_db();
2857 let nonexistent_id = Uuid::new_v4();
2858
2859 let rows = db
2861 .update_session_branch(nonexistent_id, "some-branch")
2862 .expect("Failed to update branch");
2863 assert_eq!(
2864 rows, 0,
2865 "Should not update any rows for nonexistent session"
2866 );
2867 }
2868
2869 #[test]
2870 fn test_update_session_branch_from_none() {
2871 let (db, _dir) = create_test_db();
2872 let now = Utc::now();
2873
2874 let mut session = create_test_session("claude-code", "/project", now, None);
2876 session.git_branch = None; db.insert_session(&session)
2879 .expect("Failed to insert session");
2880
2881 let fetched = db
2883 .get_session(&session.id)
2884 .expect("Failed to get session")
2885 .expect("Session should exist");
2886 assert_eq!(fetched.git_branch, None);
2887
2888 let rows = db
2890 .update_session_branch(session.id, "new-branch")
2891 .expect("Failed to update branch");
2892 assert_eq!(rows, 1, "Should update exactly one row");
2893
2894 let fetched = db
2896 .get_session(&session.id)
2897 .expect("Failed to get session")
2898 .expect("Session should exist");
2899 assert_eq!(fetched.git_branch, Some("new-branch".to_string()));
2900 }
2901
2902 #[test]
2903 fn test_get_nonexistent_session() {
2904 let (db, _dir) = create_test_db();
2905 let nonexistent_id = Uuid::new_v4();
2906
2907 let result = db
2908 .get_session(&nonexistent_id)
2909 .expect("Failed to query for nonexistent session");
2910
2911 assert!(
2912 result.is_none(),
2913 "Should return None for nonexistent session"
2914 );
2915 }
2916
2917 #[test]
2920 fn test_insert_and_get_messages() {
2921 let (db, _dir) = create_test_db();
2922
2923 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2924 db.insert_session(&session)
2925 .expect("Failed to insert session");
2926
2927 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
2928 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
2929
2930 db.insert_message(&msg1)
2931 .expect("Failed to insert message 1");
2932 db.insert_message(&msg2)
2933 .expect("Failed to insert message 2");
2934
2935 let messages = db
2936 .get_messages(&session.id)
2937 .expect("Failed to get messages");
2938
2939 assert_eq!(messages.len(), 2, "Should have 2 messages");
2940 assert_eq!(messages[0].id, msg1.id, "First message ID should match");
2941 assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
2942 assert_eq!(
2943 messages[0].role,
2944 MessageRole::User,
2945 "First message role should be User"
2946 );
2947 assert_eq!(
2948 messages[1].role,
2949 MessageRole::Assistant,
2950 "Second message role should be Assistant"
2951 );
2952 }
2953
2954 #[test]
2955 fn test_messages_ordered_by_index() {
2956 let (db, _dir) = create_test_db();
2957
2958 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2959 db.insert_session(&session)
2960 .expect("Failed to insert session");
2961
2962 let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
2964 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
2965 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
2966
2967 db.insert_message(&msg3)
2968 .expect("Failed to insert message 3");
2969 db.insert_message(&msg1)
2970 .expect("Failed to insert message 1");
2971 db.insert_message(&msg2)
2972 .expect("Failed to insert message 2");
2973
2974 let messages = db
2975 .get_messages(&session.id)
2976 .expect("Failed to get messages");
2977
2978 assert_eq!(messages.len(), 3, "Should have 3 messages");
2979 assert_eq!(messages[0].index, 0, "First message should have index 0");
2980 assert_eq!(messages[1].index, 1, "Second message should have index 1");
2981 assert_eq!(messages[2].index, 2, "Third message should have index 2");
2982
2983 assert_eq!(
2985 messages[0].content.text(),
2986 "First",
2987 "First message content should be 'First'"
2988 );
2989 assert_eq!(
2990 messages[1].content.text(),
2991 "Second",
2992 "Second message content should be 'Second'"
2993 );
2994 assert_eq!(
2995 messages[2].content.text(),
2996 "Third",
2997 "Third message content should be 'Third'"
2998 );
2999 }
3000
3001 #[test]
3004 fn test_insert_and_get_links_by_session() {
3005 let (db, _dir) = create_test_db();
3006
3007 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3008 db.insert_session(&session)
3009 .expect("Failed to insert session");
3010
3011 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3012 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
3013
3014 db.insert_link(&link1).expect("Failed to insert link 1");
3015 db.insert_link(&link2).expect("Failed to insert link 2");
3016
3017 let links = db
3018 .get_links_by_session(&session.id)
3019 .expect("Failed to get links");
3020
3021 assert_eq!(links.len(), 2, "Should have 2 links");
3022
3023 let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
3024 assert!(link_ids.contains(&link1.id), "Should contain link1");
3025 assert!(link_ids.contains(&link2.id), "Should contain link2");
3026
3027 let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
3029 assert_eq!(
3030 retrieved_link.commit_sha,
3031 Some("abc123def456".to_string()),
3032 "Commit SHA should match"
3033 );
3034 assert_eq!(
3035 retrieved_link.link_type,
3036 LinkType::Commit,
3037 "Link type should be Commit"
3038 );
3039 assert_eq!(
3040 retrieved_link.created_by,
3041 LinkCreator::Auto,
3042 "Created by should be Auto"
3043 );
3044 }
3045
3046 #[test]
3047 fn test_get_links_by_commit() {
3048 let (db, _dir) = create_test_db();
3049
3050 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3051 db.insert_session(&session)
3052 .expect("Failed to insert session");
3053
3054 let full_sha = "abc123def456789012345678901234567890abcd";
3055 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
3056 db.insert_link(&link).expect("Failed to insert link");
3057
3058 let links_full = db
3060 .get_links_by_commit(full_sha)
3061 .expect("Failed to get links by full SHA");
3062 assert_eq!(links_full.len(), 1, "Should find link by full SHA");
3063 assert_eq!(links_full[0].id, link.id, "Link ID should match");
3064
3065 let links_partial = db
3067 .get_links_by_commit("abc123")
3068 .expect("Failed to get links by partial SHA");
3069 assert_eq!(
3070 links_partial.len(),
3071 1,
3072 "Should find link by partial SHA prefix"
3073 );
3074 assert_eq!(links_partial[0].id, link.id, "Link ID should match");
3075
3076 let links_none = db
3078 .get_links_by_commit("zzz999")
3079 .expect("Failed to get links by non-matching SHA");
3080 assert_eq!(
3081 links_none.len(),
3082 0,
3083 "Should not find link with non-matching SHA"
3084 );
3085 }
3086
3087 #[test]
3090 fn test_database_creation() {
3091 let dir = tempdir().expect("Failed to create temp directory");
3092 let db_path = dir.path().join("new_test.db");
3093
3094 assert!(
3096 !db_path.exists(),
3097 "Database file should not exist before creation"
3098 );
3099
3100 let db = Database::open(&db_path).expect("Failed to create database");
3101
3102 assert!(
3104 db_path.exists(),
3105 "Database file should exist after creation"
3106 );
3107
3108 let session_count = db.session_count().expect("Failed to get session count");
3110 assert_eq!(session_count, 0, "New database should have 0 sessions");
3111
3112 let message_count = db.message_count().expect("Failed to get message count");
3113 assert_eq!(message_count, 0, "New database should have 0 messages");
3114 }
3115
3116 #[test]
3117 fn test_session_count() {
3118 let (db, _dir) = create_test_db();
3119
3120 assert_eq!(
3121 db.session_count().expect("Failed to get count"),
3122 0,
3123 "Initial session count should be 0"
3124 );
3125
3126 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3127 db.insert_session(&session1)
3128 .expect("Failed to insert session1");
3129
3130 assert_eq!(
3131 db.session_count().expect("Failed to get count"),
3132 1,
3133 "Session count should be 1 after first insert"
3134 );
3135
3136 let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
3137 db.insert_session(&session2)
3138 .expect("Failed to insert session2");
3139
3140 assert_eq!(
3141 db.session_count().expect("Failed to get count"),
3142 2,
3143 "Session count should be 2 after second insert"
3144 );
3145 }
3146
3147 #[test]
3148 fn test_message_count() {
3149 let (db, _dir) = create_test_db();
3150
3151 assert_eq!(
3152 db.message_count().expect("Failed to get count"),
3153 0,
3154 "Initial message count should be 0"
3155 );
3156
3157 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3158 db.insert_session(&session)
3159 .expect("Failed to insert session");
3160
3161 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
3162 db.insert_message(&msg1).expect("Failed to insert message1");
3163
3164 assert_eq!(
3165 db.message_count().expect("Failed to get count"),
3166 1,
3167 "Message count should be 1 after first insert"
3168 );
3169
3170 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
3171 let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
3172 db.insert_message(&msg2).expect("Failed to insert message2");
3173 db.insert_message(&msg3).expect("Failed to insert message3");
3174
3175 assert_eq!(
3176 db.message_count().expect("Failed to get count"),
3177 3,
3178 "Message count should be 3 after all inserts"
3179 );
3180 }
3181
3182 #[test]
3183 fn test_link_count() {
3184 let (db, _dir) = create_test_db();
3185
3186 assert_eq!(
3187 db.link_count().expect("Failed to get count"),
3188 0,
3189 "Initial link count should be 0"
3190 );
3191
3192 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3193 db.insert_session(&session)
3194 .expect("Failed to insert session");
3195
3196 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3197 db.insert_link(&link1).expect("Failed to insert link1");
3198
3199 assert_eq!(
3200 db.link_count().expect("Failed to get count"),
3201 1,
3202 "Link count should be 1 after first insert"
3203 );
3204
3205 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
3206 db.insert_link(&link2).expect("Failed to insert link2");
3207
3208 assert_eq!(
3209 db.link_count().expect("Failed to get count"),
3210 2,
3211 "Link count should be 2 after second insert"
3212 );
3213 }
3214
3215 #[test]
3216 fn test_db_path() {
3217 let dir = tempdir().expect("Failed to create temp directory");
3218 let db_path = dir.path().join("test.db");
3219 let db = Database::open(&db_path).expect("Failed to open test database");
3220
3221 let retrieved_path = db.db_path();
3222 assert!(
3223 retrieved_path.is_some(),
3224 "Database path should be available"
3225 );
3226
3227 let expected = db_path.canonicalize().unwrap_or(db_path);
3229 let actual = retrieved_path.unwrap();
3230 let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
3231
3232 assert_eq!(
3233 actual_canonical, expected,
3234 "Database path should match (after canonicalization)"
3235 );
3236 }
3237
3238 #[test]
3241 fn test_search_messages_basic() {
3242 let (db, _dir) = create_test_db();
3243
3244 let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
3245 db.insert_session(&session)
3246 .expect("Failed to insert session");
3247
3248 let msg1 = create_test_message(
3249 session.id,
3250 0,
3251 MessageRole::User,
3252 "How do I implement error handling in Rust?",
3253 );
3254 let msg2 = create_test_message(
3255 session.id,
3256 1,
3257 MessageRole::Assistant,
3258 "You can use Result types for error handling. The anyhow crate is also helpful.",
3259 );
3260
3261 db.insert_message(&msg1)
3262 .expect("Failed to insert message 1");
3263 db.insert_message(&msg2)
3264 .expect("Failed to insert message 2");
3265
3266 let results = db
3268 .search_messages("error", 10, None, None, None)
3269 .expect("Failed to search");
3270
3271 assert_eq!(
3272 results.len(),
3273 2,
3274 "Should find 2 messages containing 'error'"
3275 );
3276 }
3277
3278 #[test]
3279 fn test_search_messages_no_results() {
3280 let (db, _dir) = create_test_db();
3281
3282 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3283 db.insert_session(&session)
3284 .expect("Failed to insert session");
3285
3286 let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
3287 db.insert_message(&msg).expect("Failed to insert message");
3288
3289 let results = db
3291 .search_messages("nonexistent_term_xyz", 10, None, None, None)
3292 .expect("Failed to search");
3293
3294 assert!(results.is_empty(), "Should find no results");
3295 }
3296
3297 #[test]
3298 fn test_search_messages_with_role_filter() {
3299 let (db, _dir) = create_test_db();
3300
3301 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3302 db.insert_session(&session)
3303 .expect("Failed to insert session");
3304
3305 let msg1 = create_test_message(
3306 session.id,
3307 0,
3308 MessageRole::User,
3309 "Tell me about Rust programming",
3310 );
3311 let msg2 = create_test_message(
3312 session.id,
3313 1,
3314 MessageRole::Assistant,
3315 "Rust is a systems programming language",
3316 );
3317
3318 db.insert_message(&msg1)
3319 .expect("Failed to insert message 1");
3320 db.insert_message(&msg2)
3321 .expect("Failed to insert message 2");
3322
3323 let user_results = db
3325 .search_messages("programming", 10, None, None, Some("user"))
3326 .expect("Failed to search");
3327
3328 assert_eq!(user_results.len(), 1, "Should find 1 user message");
3329 assert_eq!(
3330 user_results[0].role,
3331 MessageRole::User,
3332 "Result should be from user"
3333 );
3334
3335 let assistant_results = db
3337 .search_messages("programming", 10, None, None, Some("assistant"))
3338 .expect("Failed to search");
3339
3340 assert_eq!(
3341 assistant_results.len(),
3342 1,
3343 "Should find 1 assistant message"
3344 );
3345 assert_eq!(
3346 assistant_results[0].role,
3347 MessageRole::Assistant,
3348 "Result should be from assistant"
3349 );
3350 }
3351
3352 #[test]
3353 fn test_search_messages_with_repo_filter() {
3354 let (db, _dir) = create_test_db();
3355
3356 let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
3357 let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
3358
3359 db.insert_session(&session1).expect("insert 1");
3360 db.insert_session(&session2).expect("insert 2");
3361
3362 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
3363 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
3364
3365 db.insert_message(&msg1).expect("insert msg 1");
3366 db.insert_message(&msg2).expect("insert msg 2");
3367
3368 let results = db
3370 .search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
3371 .expect("Failed to search");
3372
3373 assert_eq!(results.len(), 1, "Should find 1 message in project-a");
3374 assert!(
3375 results[0].working_directory.contains("project-a"),
3376 "Should be from project-a"
3377 );
3378 }
3379
3380 #[test]
3381 fn test_search_messages_limit() {
3382 let (db, _dir) = create_test_db();
3383
3384 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3385 db.insert_session(&session).expect("insert session");
3386
3387 for i in 0..5 {
3389 let msg = create_test_message(
3390 session.id,
3391 i,
3392 MessageRole::User,
3393 &format!("This is test message number {i}"),
3394 );
3395 db.insert_message(&msg).expect("insert message");
3396 }
3397
3398 let results = db
3400 .search_messages("test", 3, None, None, None)
3401 .expect("Failed to search");
3402
3403 assert_eq!(results.len(), 3, "Should respect limit of 3");
3404 }
3405
3406 #[test]
3407 fn test_search_index_needs_rebuild_empty_db() {
3408 let (db, _dir) = create_test_db();
3409
3410 let needs_rebuild = db
3411 .search_index_needs_rebuild()
3412 .expect("Failed to check rebuild status");
3413
3414 assert!(!needs_rebuild, "Empty database should not need rebuild");
3415 }
3416
3417 #[test]
3418 fn test_rebuild_search_index() {
3419 let (db, _dir) = create_test_db();
3420
3421 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3422 db.insert_session(&session).expect("insert session");
3423
3424 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
3425 let msg2 = create_test_message(
3426 session.id,
3427 1,
3428 MessageRole::Assistant,
3429 "Second test response",
3430 );
3431
3432 db.insert_message(&msg1).expect("insert msg 1");
3433 db.insert_message(&msg2).expect("insert msg 2");
3434
3435 db.conn
3437 .execute("DELETE FROM messages_fts", [])
3438 .expect("clear fts");
3439
3440 assert!(
3442 db.search_index_needs_rebuild().expect("check rebuild"),
3443 "Should need rebuild after clearing FTS"
3444 );
3445
3446 let count = db.rebuild_search_index().expect("rebuild");
3448 assert_eq!(count, 2, "Should have indexed 2 messages");
3449
3450 assert!(
3452 !db.search_index_needs_rebuild().expect("check rebuild"),
3453 "Should not need rebuild after rebuilding"
3454 );
3455
3456 let results = db
3458 .search_messages("test", 10, None, None, None)
3459 .expect("search");
3460 assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
3461 }
3462
3463 #[test]
3464 fn test_search_with_block_content() {
3465 let (db, _dir) = create_test_db();
3466
3467 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3468 db.insert_session(&session).expect("insert session");
3469
3470 let block_content = MessageContent::Blocks(vec![
3472 crate::storage::models::ContentBlock::Text {
3473 text: "Let me help with your database query.".to_string(),
3474 },
3475 crate::storage::models::ContentBlock::ToolUse {
3476 id: "tool_123".to_string(),
3477 name: "Bash".to_string(),
3478 input: serde_json::json!({"command": "ls -la"}),
3479 },
3480 ]);
3481
3482 let msg = Message {
3483 id: Uuid::new_v4(),
3484 session_id: session.id,
3485 parent_id: None,
3486 index: 0,
3487 timestamp: Utc::now(),
3488 role: MessageRole::Assistant,
3489 content: block_content,
3490 model: Some("claude-opus-4".to_string()),
3491 git_branch: Some("main".to_string()),
3492 cwd: Some("/project".to_string()),
3493 };
3494
3495 db.insert_message(&msg).expect("insert message");
3496
3497 let results = db
3499 .search_messages("database", 10, None, None, None)
3500 .expect("search");
3501
3502 assert_eq!(results.len(), 1, "Should find message with block content");
3503 }
3504
3505 #[test]
3506 fn test_search_result_contains_session_info() {
3507 let (db, _dir) = create_test_db();
3508
3509 let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
3510 db.insert_session(&session).expect("insert session");
3511
3512 let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
3513 db.insert_message(&msg).expect("insert message");
3514
3515 let results = db
3516 .search_messages("Search", 10, None, None, None)
3517 .expect("search");
3518
3519 assert_eq!(results.len(), 1, "Should find 1 result");
3520 assert_eq!(results[0].session_id, session.id, "Session ID should match");
3521 assert_eq!(results[0].message_id, msg.id, "Message ID should match");
3522 assert_eq!(
3523 results[0].working_directory, "/home/user/my-project",
3524 "Working directory should match"
3525 );
3526 assert_eq!(results[0].role, MessageRole::User, "Role should match");
3527 }
3528
3529 #[test]
3532 fn test_delete_link_by_id() {
3533 let (db, _dir) = create_test_db();
3534
3535 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3536 db.insert_session(&session)
3537 .expect("Failed to insert session");
3538
3539 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3540 db.insert_link(&link).expect("Failed to insert link");
3541
3542 let links_before = db
3544 .get_links_by_session(&session.id)
3545 .expect("Failed to get links");
3546 assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
3547
3548 let deleted = db.delete_link(&link.id).expect("Failed to delete link");
3550 assert!(deleted, "Should return true when link is deleted");
3551
3552 let links_after = db
3554 .get_links_by_session(&session.id)
3555 .expect("Failed to get links");
3556 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
3557 }
3558
3559 #[test]
3560 fn test_delete_link_nonexistent() {
3561 let (db, _dir) = create_test_db();
3562
3563 let nonexistent_id = Uuid::new_v4();
3564 let deleted = db
3565 .delete_link(&nonexistent_id)
3566 .expect("Failed to call delete_link");
3567
3568 assert!(!deleted, "Should return false for nonexistent link");
3569 }
3570
3571 #[test]
3572 fn test_delete_links_by_session() {
3573 let (db, _dir) = create_test_db();
3574
3575 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3576 db.insert_session(&session)
3577 .expect("Failed to insert session");
3578
3579 let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3581 let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
3582 let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
3583
3584 db.insert_link(&link1).expect("Failed to insert link1");
3585 db.insert_link(&link2).expect("Failed to insert link2");
3586 db.insert_link(&link3).expect("Failed to insert link3");
3587
3588 let links_before = db
3590 .get_links_by_session(&session.id)
3591 .expect("Failed to get links");
3592 assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
3593
3594 let count = db
3596 .delete_links_by_session(&session.id)
3597 .expect("Failed to delete links");
3598 assert_eq!(count, 3, "Should have deleted 3 links");
3599
3600 let links_after = db
3602 .get_links_by_session(&session.id)
3603 .expect("Failed to get links");
3604 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
3605 }
3606
3607 #[test]
3608 fn test_delete_links_by_session_no_links() {
3609 let (db, _dir) = create_test_db();
3610
3611 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3612 db.insert_session(&session)
3613 .expect("Failed to insert session");
3614
3615 let count = db
3617 .delete_links_by_session(&session.id)
3618 .expect("Failed to call delete_links_by_session");
3619 assert_eq!(count, 0, "Should return 0 when no links exist");
3620 }
3621
3622 #[test]
3623 fn test_delete_links_by_session_preserves_other_sessions() {
3624 let (db, _dir) = create_test_db();
3625
3626 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3627 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3628
3629 db.insert_session(&session1)
3630 .expect("Failed to insert session1");
3631 db.insert_session(&session2)
3632 .expect("Failed to insert session2");
3633
3634 let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3635 let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
3636
3637 db.insert_link(&link1).expect("Failed to insert link1");
3638 db.insert_link(&link2).expect("Failed to insert link2");
3639
3640 let count = db
3642 .delete_links_by_session(&session1.id)
3643 .expect("Failed to delete links");
3644 assert_eq!(count, 1, "Should have deleted 1 link");
3645
3646 let session2_links = db
3648 .get_links_by_session(&session2.id)
3649 .expect("Failed to get links");
3650 assert_eq!(
3651 session2_links.len(),
3652 1,
3653 "Session2's link should be preserved"
3654 );
3655 assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
3656 }
3657
3658 #[test]
3659 fn test_delete_link_by_session_and_commit() {
3660 let (db, _dir) = create_test_db();
3661
3662 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3663 db.insert_session(&session)
3664 .expect("Failed to insert session");
3665
3666 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3667 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
3668
3669 db.insert_link(&link1).expect("Failed to insert link1");
3670 db.insert_link(&link2).expect("Failed to insert link2");
3671
3672 let deleted = db
3674 .delete_link_by_session_and_commit(&session.id, "abc123")
3675 .expect("Failed to delete link");
3676 assert!(deleted, "Should return true when link is deleted");
3677
3678 let links = db
3680 .get_links_by_session(&session.id)
3681 .expect("Failed to get links");
3682 assert_eq!(links.len(), 1, "Should have 1 link remaining");
3683 assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
3684 }
3685
3686 #[test]
3687 fn test_delete_link_by_session_and_commit_full_sha() {
3688 let (db, _dir) = create_test_db();
3689
3690 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3691 db.insert_session(&session)
3692 .expect("Failed to insert session");
3693
3694 let full_sha = "abc123def456789012345678901234567890abcd";
3695 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
3696 db.insert_link(&link).expect("Failed to insert link");
3697
3698 let deleted = db
3700 .delete_link_by_session_and_commit(&session.id, full_sha)
3701 .expect("Failed to delete link");
3702 assert!(deleted, "Should delete with full SHA");
3703
3704 let links = db
3705 .get_links_by_session(&session.id)
3706 .expect("Failed to get links");
3707 assert_eq!(links.len(), 0, "Should have 0 links after delete");
3708 }
3709
3710 #[test]
3711 fn test_delete_link_by_session_and_commit_no_match() {
3712 let (db, _dir) = create_test_db();
3713
3714 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3715 db.insert_session(&session)
3716 .expect("Failed to insert session");
3717
3718 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3719 db.insert_link(&link).expect("Failed to insert link");
3720
3721 let deleted = db
3723 .delete_link_by_session_and_commit(&session.id, "xyz999")
3724 .expect("Failed to call delete");
3725 assert!(!deleted, "Should return false when no match");
3726
3727 let links = db
3729 .get_links_by_session(&session.id)
3730 .expect("Failed to get links");
3731 assert_eq!(links.len(), 1, "Link should be preserved");
3732 }
3733
3734 #[test]
3735 fn test_delete_link_by_session_and_commit_wrong_session() {
3736 let (db, _dir) = create_test_db();
3737
3738 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3739 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3740
3741 db.insert_session(&session1)
3742 .expect("Failed to insert session1");
3743 db.insert_session(&session2)
3744 .expect("Failed to insert session2");
3745
3746 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3747 db.insert_link(&link).expect("Failed to insert link");
3748
3749 let deleted = db
3751 .delete_link_by_session_and_commit(&session2.id, "abc123")
3752 .expect("Failed to call delete");
3753 assert!(!deleted, "Should not delete link from different session");
3754
3755 let links = db
3757 .get_links_by_session(&session1.id)
3758 .expect("Failed to get links");
3759 assert_eq!(links.len(), 1, "Link should be preserved");
3760 }
3761
3762 #[test]
3765 fn test_find_sessions_near_commit_time_basic() {
3766 let (db, _dir) = create_test_db();
3767 let now = Utc::now();
3768
3769 let mut session = create_test_session(
3771 "claude-code",
3772 "/home/user/project",
3773 now - Duration::hours(1),
3774 None,
3775 );
3776 session.ended_at = Some(now - Duration::minutes(10));
3777
3778 db.insert_session(&session).expect("insert session");
3779
3780 let found = db
3782 .find_sessions_near_commit_time(now, 30, None)
3783 .expect("find sessions");
3784
3785 assert_eq!(found.len(), 1, "Should find session within window");
3786 assert_eq!(found[0].id, session.id);
3787 }
3788
3789 #[test]
3790 fn test_find_sessions_near_commit_time_outside_window() {
3791 let (db, _dir) = create_test_db();
3792 let now = Utc::now();
3793
3794 let mut session =
3796 create_test_session("claude-code", "/project", now - Duration::hours(3), None);
3797 session.ended_at = Some(now - Duration::hours(2));
3798
3799 db.insert_session(&session).expect("insert session");
3800
3801 let found = db
3803 .find_sessions_near_commit_time(now, 30, None)
3804 .expect("find sessions");
3805
3806 assert!(found.is_empty(), "Should not find session outside window");
3807 }
3808
3809 #[test]
3810 fn test_find_sessions_near_commit_time_with_working_dir() {
3811 let (db, _dir) = create_test_db();
3812 let now = Utc::now();
3813
3814 let mut session1 = create_test_session(
3816 "claude-code",
3817 "/home/user/project-a",
3818 now - Duration::minutes(30),
3819 None,
3820 );
3821 session1.ended_at = Some(now - Duration::minutes(5));
3822
3823 let mut session2 = create_test_session(
3824 "claude-code",
3825 "/home/user/project-b",
3826 now - Duration::minutes(30),
3827 None,
3828 );
3829 session2.ended_at = Some(now - Duration::minutes(5));
3830
3831 db.insert_session(&session1).expect("insert session1");
3832 db.insert_session(&session2).expect("insert session2");
3833
3834 let found = db
3836 .find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
3837 .expect("find sessions");
3838
3839 assert_eq!(found.len(), 1, "Should find only session in project-a");
3840 assert_eq!(found[0].id, session1.id);
3841 }
3842
3843 #[test]
3844 fn test_find_sessions_near_commit_time_ongoing_session() {
3845 let (db, _dir) = create_test_db();
3846 let now = Utc::now();
3847
3848 let session =
3850 create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
3851 db.insert_session(&session).expect("insert session");
3854
3855 let found = db
3857 .find_sessions_near_commit_time(now, 30, None)
3858 .expect("find sessions");
3859
3860 assert_eq!(found.len(), 1, "Should find ongoing session");
3861 assert_eq!(found[0].id, session.id);
3862 }
3863
3864 #[test]
3865 fn test_link_exists_true() {
3866 let (db, _dir) = create_test_db();
3867
3868 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3869 db.insert_session(&session).expect("insert session");
3870
3871 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3872 db.insert_link(&link).expect("insert link");
3873
3874 assert!(
3876 db.link_exists(&session.id, "abc123def456")
3877 .expect("check exists"),
3878 "Should find link with full SHA"
3879 );
3880
3881 assert!(
3883 db.link_exists(&session.id, "abc123").expect("check exists"),
3884 "Should find link with partial SHA"
3885 );
3886 }
3887
3888 #[test]
3889 fn test_link_exists_false() {
3890 let (db, _dir) = create_test_db();
3891
3892 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3893 db.insert_session(&session).expect("insert session");
3894
3895 assert!(
3897 !db.link_exists(&session.id, "abc123").expect("check exists"),
3898 "Should not find non-existent link"
3899 );
3900 }
3901
3902 #[test]
3903 fn test_link_exists_different_session() {
3904 let (db, _dir) = create_test_db();
3905
3906 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3907 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3908
3909 db.insert_session(&session1).expect("insert session1");
3910 db.insert_session(&session2).expect("insert session2");
3911
3912 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3913 db.insert_link(&link).expect("insert link");
3914
3915 assert!(
3917 db.link_exists(&session1.id, "abc123").expect("check"),
3918 "Should find link for session1"
3919 );
3920 assert!(
3921 !db.link_exists(&session2.id, "abc123").expect("check"),
3922 "Should not find link for session2"
3923 );
3924 }
3925
3926 #[test]
3929 fn test_find_active_sessions_for_directory_ongoing() {
3930 let (db, _dir) = create_test_db();
3931 let now = Utc::now();
3932
3933 let session = create_test_session(
3935 "claude-code",
3936 "/home/user/project",
3937 now - Duration::minutes(30),
3938 None,
3939 );
3940 db.insert_session(&session).expect("insert session");
3943
3944 let found = db
3946 .find_active_sessions_for_directory("/home/user/project", None)
3947 .expect("find active sessions");
3948
3949 assert_eq!(found.len(), 1, "Should find ongoing session");
3950 assert_eq!(found[0].id, session.id);
3951 }
3952
3953 #[test]
3954 fn test_find_active_sessions_for_directory_recently_ended() {
3955 let (db, _dir) = create_test_db();
3956 let now = Utc::now();
3957
3958 let mut session = create_test_session(
3960 "claude-code",
3961 "/home/user/project",
3962 now - Duration::minutes(30),
3963 None,
3964 );
3965 session.ended_at = Some(now - Duration::minutes(2));
3966
3967 db.insert_session(&session).expect("insert session");
3968
3969 let found = db
3971 .find_active_sessions_for_directory("/home/user/project", None)
3972 .expect("find active sessions");
3973
3974 assert_eq!(found.len(), 1, "Should find recently ended session");
3975 assert_eq!(found[0].id, session.id);
3976 }
3977
3978 #[test]
3979 fn test_find_active_sessions_for_directory_old_session() {
3980 let (db, _dir) = create_test_db();
3981 let now = Utc::now();
3982
3983 let mut session = create_test_session(
3985 "claude-code",
3986 "/home/user/project",
3987 now - Duration::minutes(60),
3988 None,
3989 );
3990 session.ended_at = Some(now - Duration::minutes(10));
3991
3992 db.insert_session(&session).expect("insert session");
3993
3994 let found = db
3996 .find_active_sessions_for_directory("/home/user/project", None)
3997 .expect("find active sessions");
3998
3999 assert!(found.is_empty(), "Should not find old session");
4000 }
4001
4002 #[test]
4003 fn test_find_active_sessions_for_directory_filters_by_path() {
4004 let (db, _dir) = create_test_db();
4005 let now = Utc::now();
4006
4007 let session1 = create_test_session(
4009 "claude-code",
4010 "/home/user/project-a",
4011 now - Duration::minutes(10),
4012 None,
4013 );
4014 let session2 = create_test_session(
4015 "claude-code",
4016 "/home/user/project-b",
4017 now - Duration::minutes(10),
4018 None,
4019 );
4020
4021 db.insert_session(&session1).expect("insert session1");
4022 db.insert_session(&session2).expect("insert session2");
4023
4024 let found = db
4026 .find_active_sessions_for_directory("/home/user/project-a", None)
4027 .expect("find active sessions");
4028
4029 assert_eq!(found.len(), 1, "Should find only session in project-a");
4030 assert_eq!(found[0].id, session1.id);
4031 }
4032
4033 #[test]
4034 fn test_find_active_sessions_for_directory_trailing_slash_matches() {
4035 let (db, _dir) = create_test_db();
4036 let now = Utc::now();
4037
4038 let session = create_test_session(
4039 "claude-code",
4040 "/home/user/project",
4041 now - Duration::minutes(10),
4042 None,
4043 );
4044 db.insert_session(&session).expect("insert session");
4045
4046 let found = db
4047 .find_active_sessions_for_directory("/home/user/project/", None)
4048 .expect("find active sessions");
4049
4050 assert_eq!(found.len(), 1, "Should match even with trailing slash");
4051 assert_eq!(found[0].id, session.id);
4052 }
4053
4054 #[test]
4055 fn test_find_active_sessions_for_directory_does_not_match_prefix_siblings() {
4056 let (db, _dir) = create_test_db();
4057 let now = Utc::now();
4058
4059 let session_root = create_test_session(
4060 "claude-code",
4061 "/home/user/project",
4062 now - Duration::minutes(10),
4063 None,
4064 );
4065 let session_subdir = create_test_session(
4066 "claude-code",
4067 "/home/user/project/src",
4068 now - Duration::minutes(10),
4069 None,
4070 );
4071 let session_sibling = create_test_session(
4072 "claude-code",
4073 "/home/user/project-old",
4074 now - Duration::minutes(10),
4075 None,
4076 );
4077
4078 db.insert_session(&session_root)
4079 .expect("insert session_root");
4080 db.insert_session(&session_subdir)
4081 .expect("insert session_subdir");
4082 db.insert_session(&session_sibling)
4083 .expect("insert session_sibling");
4084
4085 let found = db
4086 .find_active_sessions_for_directory("/home/user/project", None)
4087 .expect("find active sessions");
4088
4089 let found_ids: std::collections::HashSet<Uuid> =
4090 found.iter().map(|session| session.id).collect();
4091 assert!(found_ids.contains(&session_root.id));
4092 assert!(found_ids.contains(&session_subdir.id));
4093 assert!(!found_ids.contains(&session_sibling.id));
4094 }
4095
4096 #[test]
4097 fn test_find_active_sessions_for_directory_custom_window() {
4098 let (db, _dir) = create_test_db();
4099 let now = Utc::now();
4100
4101 let mut session = create_test_session(
4103 "claude-code",
4104 "/home/user/project",
4105 now - Duration::minutes(30),
4106 None,
4107 );
4108 session.ended_at = Some(now - Duration::minutes(8));
4109
4110 db.insert_session(&session).expect("insert session");
4111
4112 let found = db
4114 .find_active_sessions_for_directory("/home/user/project", None)
4115 .expect("find with default window");
4116 assert!(found.is_empty(), "Should not find with 5 minute window");
4117
4118 let found = db
4120 .find_active_sessions_for_directory("/home/user/project", Some(10))
4121 .expect("find with 10 minute window");
4122 assert_eq!(found.len(), 1, "Should find with 10 minute window");
4123 }
4124
4125 #[test]
4128 fn test_search_with_tool_filter() {
4129 let (db, _dir) = create_test_db();
4130
4131 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
4132 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
4133
4134 db.insert_session(&session1).expect("insert session1");
4135 db.insert_session(&session2).expect("insert session2");
4136
4137 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
4138 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
4139
4140 db.insert_message(&msg1).expect("insert msg1");
4141 db.insert_message(&msg2).expect("insert msg2");
4142
4143 let options = SearchOptions {
4145 query: "Hello".to_string(),
4146 limit: 10,
4147 tool: Some("claude-code".to_string()),
4148 ..Default::default()
4149 };
4150 let results = db.search_with_options(&options).expect("search");
4151
4152 assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
4153 assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
4154 }
4155
4156 #[test]
4157 fn test_search_with_date_range() {
4158 let (db, _dir) = create_test_db();
4159
4160 let old_time = Utc::now() - chrono::Duration::days(30);
4161 let new_time = Utc::now() - chrono::Duration::days(1);
4162
4163 let session1 = create_test_session("claude-code", "/project1", old_time, None);
4164 let session2 = create_test_session("claude-code", "/project2", new_time, None);
4165
4166 db.insert_session(&session1).expect("insert session1");
4167 db.insert_session(&session2).expect("insert session2");
4168
4169 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
4170 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
4171
4172 db.insert_message(&msg1).expect("insert msg1");
4173 db.insert_message(&msg2).expect("insert msg2");
4174
4175 let since = Utc::now() - chrono::Duration::days(7);
4177 let options = SearchOptions {
4178 query: "session".to_string(),
4179 limit: 10,
4180 since: Some(since),
4181 ..Default::default()
4182 };
4183 let results = db.search_with_options(&options).expect("search");
4184
4185 assert_eq!(results.len(), 1, "Should find 1 result within date range");
4186 assert!(
4187 results[0].working_directory.contains("project2"),
4188 "Should be from newer project"
4189 );
4190 }
4191
4192 #[test]
4193 fn test_search_with_project_filter() {
4194 let (db, _dir) = create_test_db();
4195
4196 let session1 =
4197 create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
4198 let session2 =
4199 create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
4200
4201 db.insert_session(&session1).expect("insert session1");
4202 db.insert_session(&session2).expect("insert session2");
4203
4204 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
4205 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
4206
4207 db.insert_message(&msg1).expect("insert msg1");
4208 db.insert_message(&msg2).expect("insert msg2");
4209
4210 let options = SearchOptions {
4212 query: "Testing".to_string(),
4213 limit: 10,
4214 project: Some("frontend".to_string()),
4215 ..Default::default()
4216 };
4217 let results = db.search_with_options(&options).expect("search");
4218
4219 assert_eq!(results.len(), 1, "Should find 1 result with project filter");
4220 assert!(
4221 results[0].working_directory.contains("frontend"),
4222 "Should be from frontend project"
4223 );
4224 }
4225
4226 #[test]
4227 fn test_search_with_branch_filter() {
4228 let (db, _dir) = create_test_db();
4229
4230 let session1 = Session {
4231 id: Uuid::new_v4(),
4232 tool: "claude-code".to_string(),
4233 tool_version: None,
4234 started_at: Utc::now(),
4235 ended_at: None,
4236 model: None,
4237 working_directory: "/project".to_string(),
4238 git_branch: Some("feat/auth".to_string()),
4239 source_path: None,
4240 message_count: 0,
4241 machine_id: None,
4242 };
4243 let session2 = Session {
4244 id: Uuid::new_v4(),
4245 tool: "claude-code".to_string(),
4246 tool_version: None,
4247 started_at: Utc::now(),
4248 ended_at: None,
4249 model: None,
4250 working_directory: "/project".to_string(),
4251 git_branch: Some("main".to_string()),
4252 source_path: None,
4253 message_count: 0,
4254 machine_id: None,
4255 };
4256
4257 db.insert_session(&session1).expect("insert session1");
4258 db.insert_session(&session2).expect("insert session2");
4259
4260 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
4261 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
4262
4263 db.insert_message(&msg1).expect("insert msg1");
4264 db.insert_message(&msg2).expect("insert msg2");
4265
4266 let options = SearchOptions {
4268 query: "work".to_string(),
4269 limit: 10,
4270 branch: Some("auth".to_string()),
4271 ..Default::default()
4272 };
4273 let results = db.search_with_options(&options).expect("search");
4274
4275 assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
4276 assert_eq!(
4277 results[0].git_branch.as_deref(),
4278 Some("feat/auth"),
4279 "Should be from feat/auth branch"
4280 );
4281 }
4282
4283 #[test]
4284 fn test_search_metadata_matches_project() {
4285 let (db, _dir) = create_test_db();
4286
4287 let session =
4288 create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
4289 db.insert_session(&session).expect("insert session");
4290
4291 let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
4293 db.insert_message(&msg).expect("insert msg");
4294
4295 let options = SearchOptions {
4297 query: "redactyl".to_string(),
4298 limit: 10,
4299 ..Default::default()
4300 };
4301 let results = db.search_with_options(&options).expect("search");
4302
4303 assert_eq!(
4304 results.len(),
4305 1,
4306 "Should find session via metadata match on project name"
4307 );
4308 }
4309
4310 #[test]
4311 fn test_search_returns_extended_session_info() {
4312 let (db, _dir) = create_test_db();
4313
4314 let started_at = Utc::now();
4315 let session = Session {
4316 id: Uuid::new_v4(),
4317 tool: "claude-code".to_string(),
4318 tool_version: Some("1.0.0".to_string()),
4319 started_at,
4320 ended_at: None,
4321 model: None,
4322 working_directory: "/home/user/myapp".to_string(),
4323 git_branch: Some("develop".to_string()),
4324 source_path: None,
4325 message_count: 5,
4326 machine_id: None,
4327 };
4328 db.insert_session(&session).expect("insert session");
4329
4330 let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
4331 db.insert_message(&msg).expect("insert msg");
4332
4333 let options = SearchOptions {
4334 query: "Test".to_string(),
4335 limit: 10,
4336 ..Default::default()
4337 };
4338 let results = db.search_with_options(&options).expect("search");
4339
4340 assert_eq!(results.len(), 1, "Should find 1 result");
4341 let result = &results[0];
4342
4343 assert_eq!(result.tool, "claude-code", "Tool should be populated");
4344 assert_eq!(
4345 result.git_branch.as_deref(),
4346 Some("develop"),
4347 "Branch should be populated"
4348 );
4349 assert!(
4350 result.session_message_count > 0,
4351 "Message count should be populated"
4352 );
4353 assert!(
4354 result.session_started_at.is_some(),
4355 "Session start time should be populated"
4356 );
4357 }
4358
4359 #[test]
4360 fn test_get_context_messages() {
4361 let (db, _dir) = create_test_db();
4362
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..5 {
4368 let role = if i % 2 == 0 {
4369 MessageRole::User
4370 } else {
4371 MessageRole::Assistant
4372 };
4373 let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
4374 db.insert_message(&msg).expect("insert message");
4375 }
4376
4377 let (before, after) = db
4379 .get_context_messages(&session.id, 2, 1)
4380 .expect("get context");
4381
4382 assert_eq!(before.len(), 1, "Should have 1 message before");
4383 assert_eq!(after.len(), 1, "Should have 1 message after");
4384 assert_eq!(before[0].index, 1, "Before message should be index 1");
4385 assert_eq!(after[0].index, 3, "After message should be index 3");
4386 }
4387
4388 #[test]
4389 fn test_get_context_messages_at_start() {
4390 let (db, _dir) = create_test_db();
4391
4392 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4393 db.insert_session(&session).expect("insert session");
4394
4395 for i in 0..3 {
4396 let msg =
4397 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
4398 db.insert_message(&msg).expect("insert message");
4399 }
4400
4401 let (before, after) = db
4403 .get_context_messages(&session.id, 0, 2)
4404 .expect("get context");
4405
4406 assert!(
4407 before.is_empty(),
4408 "Should have no messages before first message"
4409 );
4410 assert_eq!(after.len(), 2, "Should have 2 messages after");
4411 }
4412
4413 #[test]
4414 fn test_get_context_messages_at_end() {
4415 let (db, _dir) = create_test_db();
4416
4417 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4418 db.insert_session(&session).expect("insert session");
4419
4420 for i in 0..3 {
4421 let msg =
4422 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
4423 db.insert_message(&msg).expect("insert message");
4424 }
4425
4426 let (before, after) = db
4428 .get_context_messages(&session.id, 2, 2)
4429 .expect("get context");
4430
4431 assert_eq!(before.len(), 2, "Should have 2 messages before");
4432 assert!(
4433 after.is_empty(),
4434 "Should have no messages after last message"
4435 );
4436 }
4437
4438 #[test]
4439 fn test_search_combined_filters() {
4440 let (db, _dir) = create_test_db();
4441
4442 let session1 = Session {
4443 id: Uuid::new_v4(),
4444 tool: "claude-code".to_string(),
4445 tool_version: None,
4446 started_at: Utc::now(),
4447 ended_at: None,
4448 model: None,
4449 working_directory: "/home/user/myapp".to_string(),
4450 git_branch: Some("feat/api".to_string()),
4451 source_path: None,
4452 message_count: 1,
4453 machine_id: None,
4454 };
4455 let session2 = Session {
4456 id: Uuid::new_v4(),
4457 tool: "aider".to_string(),
4458 tool_version: None,
4459 started_at: Utc::now(),
4460 ended_at: None,
4461 model: None,
4462 working_directory: "/home/user/myapp".to_string(),
4463 git_branch: Some("feat/api".to_string()),
4464 source_path: None,
4465 message_count: 1,
4466 machine_id: None,
4467 };
4468
4469 db.insert_session(&session1).expect("insert session1");
4470 db.insert_session(&session2).expect("insert session2");
4471
4472 let msg1 =
4473 create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
4474 let msg2 =
4475 create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
4476
4477 db.insert_message(&msg1).expect("insert msg1");
4478 db.insert_message(&msg2).expect("insert msg2");
4479
4480 let options = SearchOptions {
4482 query: "API".to_string(),
4483 limit: 10,
4484 tool: Some("claude-code".to_string()),
4485 branch: Some("api".to_string()),
4486 project: Some("myapp".to_string()),
4487 ..Default::default()
4488 };
4489 let results = db.search_with_options(&options).expect("search");
4490
4491 assert!(
4493 !results.is_empty(),
4494 "Should find at least 1 result matching all filters"
4495 );
4496 for result in &results {
4498 assert_eq!(
4499 result.tool, "claude-code",
4500 "All results should be from claude-code"
4501 );
4502 }
4503 }
4504
4505 #[test]
4508 fn test_delete_session_removes_all_data() {
4509 let (db, _dir) = create_test_db();
4510
4511 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4512 db.insert_session(&session).expect("insert session");
4513
4514 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
4516 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there");
4517 db.insert_message(&msg1).expect("insert msg1");
4518 db.insert_message(&msg2).expect("insert msg2");
4519
4520 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
4522 db.insert_link(&link).expect("insert link");
4523
4524 assert_eq!(db.session_count().expect("count"), 1);
4526 assert_eq!(db.message_count().expect("count"), 2);
4527 assert_eq!(db.link_count().expect("count"), 1);
4528
4529 let (msgs_deleted, links_deleted) = db.delete_session(&session.id).expect("delete");
4531 assert_eq!(msgs_deleted, 2, "Should delete 2 messages");
4532 assert_eq!(links_deleted, 1, "Should delete 1 link");
4533
4534 assert_eq!(db.session_count().expect("count"), 0);
4536 assert_eq!(db.message_count().expect("count"), 0);
4537 assert_eq!(db.link_count().expect("count"), 0);
4538 assert!(db.get_session(&session.id).expect("get").is_none());
4539 }
4540
4541 #[test]
4542 fn test_delete_session_preserves_other_sessions() {
4543 let (db, _dir) = create_test_db();
4544
4545 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
4546 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
4547
4548 db.insert_session(&session1).expect("insert session1");
4549 db.insert_session(&session2).expect("insert session2");
4550
4551 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello 1");
4552 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello 2");
4553 db.insert_message(&msg1).expect("insert msg1");
4554 db.insert_message(&msg2).expect("insert msg2");
4555
4556 db.delete_session(&session1.id).expect("delete");
4558
4559 assert_eq!(db.session_count().expect("count"), 1);
4561 assert_eq!(db.message_count().expect("count"), 1);
4562 assert!(db.get_session(&session2.id).expect("get").is_some());
4563 }
4564
4565 #[test]
4568 fn test_file_size() {
4569 let (db, _dir) = create_test_db();
4570
4571 let size = db.file_size().expect("get size");
4572 assert!(size.is_some(), "Should have file size for file-based db");
4573 assert!(size.unwrap() > 0, "Database file should have size > 0");
4574 }
4575
4576 #[test]
4577 fn test_vacuum() {
4578 let (db, _dir) = create_test_db();
4579
4580 db.vacuum().expect("vacuum should succeed");
4582 }
4583
4584 #[test]
4585 fn test_count_sessions_older_than() {
4586 let (db, _dir) = create_test_db();
4587 let now = Utc::now();
4588
4589 let old_session =
4591 create_test_session("claude-code", "/project1", now - Duration::days(100), None);
4592 let recent_session =
4593 create_test_session("claude-code", "/project2", now - Duration::days(10), None);
4594
4595 db.insert_session(&old_session).expect("insert old");
4596 db.insert_session(&recent_session).expect("insert recent");
4597
4598 let cutoff = now - Duration::days(30);
4600 let count = db.count_sessions_older_than(cutoff).expect("count");
4601 assert_eq!(count, 1, "Should find 1 session older than 30 days");
4602
4603 let old_cutoff = now - Duration::days(200);
4605 let old_count = db.count_sessions_older_than(old_cutoff).expect("count");
4606 assert_eq!(old_count, 0, "Should find 0 sessions older than 200 days");
4607 }
4608
4609 #[test]
4610 fn test_delete_sessions_older_than() {
4611 let (db, _dir) = create_test_db();
4612 let now = Utc::now();
4613
4614 let old_session =
4616 create_test_session("claude-code", "/project1", now - Duration::days(100), None);
4617 let recent_session =
4618 create_test_session("claude-code", "/project2", now - Duration::days(10), None);
4619
4620 db.insert_session(&old_session).expect("insert old");
4621 db.insert_session(&recent_session).expect("insert recent");
4622
4623 let msg1 = create_test_message(old_session.id, 0, MessageRole::User, "Old message");
4625 let msg2 = create_test_message(recent_session.id, 0, MessageRole::User, "Recent message");
4626 db.insert_message(&msg1).expect("insert msg1");
4627 db.insert_message(&msg2).expect("insert msg2");
4628
4629 let cutoff = now - Duration::days(30);
4631 let deleted = db.delete_sessions_older_than(cutoff).expect("delete");
4632 assert_eq!(deleted, 1, "Should delete 1 session");
4633
4634 assert_eq!(db.session_count().expect("count"), 1);
4636 assert!(db.get_session(&recent_session.id).expect("get").is_some());
4637 assert!(db.get_session(&old_session.id).expect("get").is_none());
4638
4639 assert_eq!(db.message_count().expect("count"), 1);
4641 }
4642
4643 #[test]
4644 fn test_get_sessions_older_than() {
4645 let (db, _dir) = create_test_db();
4646 let now = Utc::now();
4647
4648 let old_session = create_test_session(
4650 "claude-code",
4651 "/project/old",
4652 now - Duration::days(100),
4653 None,
4654 );
4655 let medium_session =
4656 create_test_session("aider", "/project/medium", now - Duration::days(50), None);
4657 let recent_session =
4658 create_test_session("gemini", "/project/recent", now - Duration::days(10), None);
4659
4660 db.insert_session(&old_session).expect("insert old");
4661 db.insert_session(&medium_session).expect("insert medium");
4662 db.insert_session(&recent_session).expect("insert recent");
4663
4664 let cutoff = now - Duration::days(30);
4666 let sessions = db.get_sessions_older_than(cutoff).expect("get sessions");
4667 assert_eq!(
4668 sessions.len(),
4669 2,
4670 "Should find 2 sessions older than 30 days"
4671 );
4672
4673 assert_eq!(sessions[0].id, old_session.id);
4675 assert_eq!(sessions[1].id, medium_session.id);
4676
4677 assert_eq!(sessions[0].tool, "claude-code");
4679 assert_eq!(sessions[0].working_directory, "/project/old");
4680 assert_eq!(sessions[1].tool, "aider");
4681 assert_eq!(sessions[1].working_directory, "/project/medium");
4682
4683 let old_cutoff = now - Duration::days(200);
4685 let old_sessions = db
4686 .get_sessions_older_than(old_cutoff)
4687 .expect("get old sessions");
4688 assert_eq!(
4689 old_sessions.len(),
4690 0,
4691 "Should find 0 sessions older than 200 days"
4692 );
4693 }
4694
4695 #[test]
4696 fn test_stats() {
4697 let (db, _dir) = create_test_db();
4698 let now = Utc::now();
4699
4700 let empty_stats = db.stats().expect("stats");
4702 assert_eq!(empty_stats.session_count, 0);
4703 assert_eq!(empty_stats.message_count, 0);
4704 assert_eq!(empty_stats.link_count, 0);
4705 assert!(empty_stats.oldest_session.is_none());
4706 assert!(empty_stats.newest_session.is_none());
4707 assert!(empty_stats.sessions_by_tool.is_empty());
4708
4709 let session1 =
4711 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
4712 let session2 = create_test_session("aider", "/project2", now - Duration::hours(1), None);
4713 let session3 = create_test_session("claude-code", "/project3", now, None);
4714
4715 db.insert_session(&session1).expect("insert 1");
4716 db.insert_session(&session2).expect("insert 2");
4717 db.insert_session(&session3).expect("insert 3");
4718
4719 let msg = create_test_message(session1.id, 0, MessageRole::User, "Hello");
4720 db.insert_message(&msg).expect("insert msg");
4721
4722 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
4723 db.insert_link(&link).expect("insert link");
4724
4725 let stats = db.stats().expect("stats");
4727 assert_eq!(stats.session_count, 3);
4728 assert_eq!(stats.message_count, 1);
4729 assert_eq!(stats.link_count, 1);
4730 assert!(stats.oldest_session.is_some());
4731 assert!(stats.newest_session.is_some());
4732
4733 assert_eq!(stats.sessions_by_tool.len(), 2);
4735 assert_eq!(stats.sessions_by_tool[0].0, "claude-code");
4737 assert_eq!(stats.sessions_by_tool[0].1, 2);
4738 assert_eq!(stats.sessions_by_tool[1].0, "aider");
4739 assert_eq!(stats.sessions_by_tool[1].1, 1);
4740 }
4741
4742 #[test]
4745 fn test_get_session_branch_history_no_messages() {
4746 let (db, _dir) = create_test_db();
4747 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4748 db.insert_session(&session)
4749 .expect("Failed to insert session");
4750
4751 let branches = db
4752 .get_session_branch_history(session.id)
4753 .expect("Failed to get branch history");
4754
4755 assert!(branches.is_empty(), "Empty session should have no branches");
4756 }
4757
4758 #[test]
4759 fn test_get_session_branch_history_single_branch() {
4760 let (db, _dir) = create_test_db();
4761 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4762 db.insert_session(&session)
4763 .expect("Failed to insert session");
4764
4765 for i in 0..3 {
4767 let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
4768 msg.git_branch = Some("main".to_string());
4769 db.insert_message(&msg).expect("Failed to insert message");
4770 }
4771
4772 let branches = db
4773 .get_session_branch_history(session.id)
4774 .expect("Failed to get branch history");
4775
4776 assert_eq!(branches, vec!["main"], "Should have single branch");
4777 }
4778
4779 #[test]
4780 fn test_get_session_branch_history_multiple_branches() {
4781 let (db, _dir) = create_test_db();
4782 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4783 db.insert_session(&session)
4784 .expect("Failed to insert session");
4785
4786 let branch_sequence = ["main", "main", "feat/auth", "feat/auth", "main"];
4788 for (i, branch) in branch_sequence.iter().enumerate() {
4789 let mut msg = create_test_message(session.id, i as i32, MessageRole::User, "test");
4790 msg.git_branch = Some(branch.to_string());
4791 db.insert_message(&msg).expect("Failed to insert message");
4792 }
4793
4794 let branches = db
4795 .get_session_branch_history(session.id)
4796 .expect("Failed to get branch history");
4797
4798 assert_eq!(
4799 branches,
4800 vec!["main", "feat/auth", "main"],
4801 "Should show branch transitions without consecutive duplicates"
4802 );
4803 }
4804
4805 #[test]
4806 fn test_get_session_branch_history_with_none_branches() {
4807 let (db, _dir) = create_test_db();
4808 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4809 db.insert_session(&session)
4810 .expect("Failed to insert session");
4811
4812 let mut msg1 = create_test_message(session.id, 0, MessageRole::User, "test");
4814 msg1.git_branch = Some("main".to_string());
4815 db.insert_message(&msg1).expect("Failed to insert message");
4816
4817 let mut msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "test");
4818 msg2.git_branch = None; db.insert_message(&msg2).expect("Failed to insert message");
4820
4821 let mut msg3 = create_test_message(session.id, 2, MessageRole::User, "test");
4822 msg3.git_branch = Some("feat/new".to_string());
4823 db.insert_message(&msg3).expect("Failed to insert message");
4824
4825 let branches = db
4826 .get_session_branch_history(session.id)
4827 .expect("Failed to get branch history");
4828
4829 assert_eq!(
4830 branches,
4831 vec!["main", "feat/new"],
4832 "Should skip None branches and show transitions"
4833 );
4834 }
4835
4836 #[test]
4837 fn test_get_session_branch_history_all_none_branches() {
4838 let (db, _dir) = create_test_db();
4839 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4840 db.insert_session(&session)
4841 .expect("Failed to insert session");
4842
4843 for i in 0..3 {
4845 let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
4846 msg.git_branch = None;
4847 db.insert_message(&msg).expect("Failed to insert message");
4848 }
4849
4850 let branches = db
4851 .get_session_branch_history(session.id)
4852 .expect("Failed to get branch history");
4853
4854 assert!(
4855 branches.is_empty(),
4856 "Session with all None branches should return empty"
4857 );
4858 }
4859
4860 #[test]
4863 fn test_session_stores_machine_id() {
4864 let (db, _dir) = create_test_db();
4865 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4866
4867 db.insert_session(&session)
4868 .expect("Failed to insert session");
4869
4870 let retrieved = db
4871 .get_session(&session.id)
4872 .expect("Failed to get session")
4873 .expect("Session should exist");
4874
4875 assert_eq!(
4876 retrieved.machine_id,
4877 Some("test-machine".to_string()),
4878 "Machine ID should be preserved"
4879 );
4880 }
4881
4882 #[test]
4883 fn test_session_with_none_machine_id() {
4884 let (db, _dir) = create_test_db();
4885 let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
4886 session.machine_id = None;
4887
4888 db.insert_session(&session)
4889 .expect("Failed to insert session");
4890
4891 let retrieved = db
4892 .get_session(&session.id)
4893 .expect("Failed to get session")
4894 .expect("Session should exist");
4895
4896 assert!(
4897 retrieved.machine_id.is_none(),
4898 "Session with None machine_id should preserve None"
4899 );
4900 }
4901
4902 #[test]
4903 fn test_migration_adds_machine_id_column() {
4904 let (db, _dir) = create_test_db();
4906
4907 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4909 db.insert_session(&session)
4910 .expect("Should insert session with machine_id column");
4911
4912 let retrieved = db
4914 .get_session(&session.id)
4915 .expect("Failed to get session")
4916 .expect("Session should exist");
4917
4918 assert_eq!(
4919 retrieved.machine_id,
4920 Some("test-machine".to_string()),
4921 "Machine ID should be stored and retrieved"
4922 );
4923 }
4924
4925 #[test]
4926 fn test_list_sessions_includes_machine_id() {
4927 let (db, _dir) = create_test_db();
4928 let now = Utc::now();
4929
4930 let mut session1 = create_test_session("claude-code", "/project1", now, None);
4931 session1.machine_id = Some("machine-a".to_string());
4932
4933 let mut session2 = create_test_session("claude-code", "/project2", now, None);
4934 session2.machine_id = Some("machine-b".to_string());
4935
4936 db.insert_session(&session1).expect("insert");
4937 db.insert_session(&session2).expect("insert");
4938
4939 let sessions = db.list_sessions(10, None).expect("list");
4940
4941 assert_eq!(sessions.len(), 2);
4942 let machine_ids: Vec<Option<String>> =
4943 sessions.iter().map(|s| s.machine_id.clone()).collect();
4944 assert!(machine_ids.contains(&Some("machine-a".to_string())));
4945 assert!(machine_ids.contains(&Some("machine-b".to_string())));
4946 }
4947
4948 #[test]
4951 fn test_insert_and_get_annotations() {
4952 let (db, _dir) = create_test_db();
4953 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4954 db.insert_session(&session).expect("insert session");
4955
4956 let annotation = Annotation {
4957 id: Uuid::new_v4(),
4958 session_id: session.id,
4959 content: "This is a test note".to_string(),
4960 created_at: Utc::now(),
4961 };
4962 db.insert_annotation(&annotation)
4963 .expect("insert annotation");
4964
4965 let annotations = db.get_annotations(&session.id).expect("get annotations");
4966 assert_eq!(annotations.len(), 1);
4967 assert_eq!(annotations[0].content, "This is a test note");
4968 assert_eq!(annotations[0].session_id, session.id);
4969 }
4970
4971 #[test]
4972 fn test_delete_annotation() {
4973 let (db, _dir) = create_test_db();
4974 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4975 db.insert_session(&session).expect("insert session");
4976
4977 let annotation = Annotation {
4978 id: Uuid::new_v4(),
4979 session_id: session.id,
4980 content: "Test annotation".to_string(),
4981 created_at: Utc::now(),
4982 };
4983 db.insert_annotation(&annotation).expect("insert");
4984
4985 let deleted = db.delete_annotation(&annotation.id).expect("delete");
4986 assert!(deleted);
4987
4988 let annotations = db.get_annotations(&session.id).expect("get");
4989 assert!(annotations.is_empty());
4990 }
4991
4992 #[test]
4993 fn test_delete_annotations_by_session() {
4994 let (db, _dir) = create_test_db();
4995 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4996 db.insert_session(&session).expect("insert session");
4997
4998 for i in 0..3 {
4999 let annotation = Annotation {
5000 id: Uuid::new_v4(),
5001 session_id: session.id,
5002 content: format!("Annotation {i}"),
5003 created_at: Utc::now(),
5004 };
5005 db.insert_annotation(&annotation).expect("insert");
5006 }
5007
5008 let count = db
5009 .delete_annotations_by_session(&session.id)
5010 .expect("delete all");
5011 assert_eq!(count, 3);
5012
5013 let annotations = db.get_annotations(&session.id).expect("get");
5014 assert!(annotations.is_empty());
5015 }
5016
5017 #[test]
5020 fn test_insert_and_get_tags() {
5021 let (db, _dir) = create_test_db();
5022 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5023 db.insert_session(&session).expect("insert session");
5024
5025 let tag = Tag {
5026 id: Uuid::new_v4(),
5027 session_id: session.id,
5028 label: "bug-fix".to_string(),
5029 created_at: Utc::now(),
5030 };
5031 db.insert_tag(&tag).expect("insert tag");
5032
5033 let tags = db.get_tags(&session.id).expect("get tags");
5034 assert_eq!(tags.len(), 1);
5035 assert_eq!(tags[0].label, "bug-fix");
5036 }
5037
5038 #[test]
5039 fn test_tag_exists() {
5040 let (db, _dir) = create_test_db();
5041 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5042 db.insert_session(&session).expect("insert session");
5043
5044 assert!(!db.tag_exists(&session.id, "bug-fix").expect("check"));
5045
5046 let tag = Tag {
5047 id: Uuid::new_v4(),
5048 session_id: session.id,
5049 label: "bug-fix".to_string(),
5050 created_at: Utc::now(),
5051 };
5052 db.insert_tag(&tag).expect("insert tag");
5053
5054 assert!(db.tag_exists(&session.id, "bug-fix").expect("check"));
5055 assert!(!db.tag_exists(&session.id, "feature").expect("check other"));
5056 }
5057
5058 #[test]
5059 fn test_delete_tag() {
5060 let (db, _dir) = create_test_db();
5061 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5062 db.insert_session(&session).expect("insert session");
5063
5064 let tag = Tag {
5065 id: Uuid::new_v4(),
5066 session_id: session.id,
5067 label: "wip".to_string(),
5068 created_at: Utc::now(),
5069 };
5070 db.insert_tag(&tag).expect("insert tag");
5071
5072 let deleted = db.delete_tag(&session.id, "wip").expect("delete");
5073 assert!(deleted);
5074
5075 let deleted_again = db.delete_tag(&session.id, "wip").expect("delete again");
5076 assert!(!deleted_again);
5077 }
5078
5079 #[test]
5080 fn test_list_sessions_with_tag() {
5081 let (db, _dir) = create_test_db();
5082 let now = Utc::now();
5083
5084 let session1 = create_test_session("claude-code", "/project1", now, None);
5085 let session2 =
5086 create_test_session("claude-code", "/project2", now - Duration::minutes(5), None);
5087 let session3 = create_test_session(
5088 "claude-code",
5089 "/project3",
5090 now - Duration::minutes(10),
5091 None,
5092 );
5093
5094 db.insert_session(&session1).expect("insert");
5095 db.insert_session(&session2).expect("insert");
5096 db.insert_session(&session3).expect("insert");
5097
5098 let tag1 = Tag {
5100 id: Uuid::new_v4(),
5101 session_id: session1.id,
5102 label: "feature".to_string(),
5103 created_at: Utc::now(),
5104 };
5105 let tag3 = Tag {
5106 id: Uuid::new_v4(),
5107 session_id: session3.id,
5108 label: "feature".to_string(),
5109 created_at: Utc::now(),
5110 };
5111 db.insert_tag(&tag1).expect("insert tag");
5112 db.insert_tag(&tag3).expect("insert tag");
5113
5114 let sessions = db.list_sessions_with_tag("feature", 10).expect("list");
5115 assert_eq!(sessions.len(), 2);
5116 assert_eq!(sessions[0].id, session1.id);
5118 assert_eq!(sessions[1].id, session3.id);
5119
5120 let sessions = db.list_sessions_with_tag("nonexistent", 10).expect("list");
5121 assert!(sessions.is_empty());
5122 }
5123
5124 #[test]
5125 fn test_get_most_recent_session_for_directory() {
5126 let (db, _dir) = create_test_db();
5127 let now = Utc::now();
5128
5129 let session1 = create_test_session(
5130 "claude-code",
5131 "/home/user/project",
5132 now - Duration::hours(1),
5133 None,
5134 );
5135 let session2 = create_test_session("claude-code", "/home/user/project", now, None);
5136 let session3 = create_test_session("claude-code", "/home/user/other", now, None);
5137
5138 db.insert_session(&session1).expect("insert");
5139 db.insert_session(&session2).expect("insert");
5140 db.insert_session(&session3).expect("insert");
5141
5142 let result = db
5143 .get_most_recent_session_for_directory("/home/user/project")
5144 .expect("get");
5145 assert!(result.is_some());
5146 assert_eq!(result.unwrap().id, session2.id);
5147
5148 let result = db
5149 .get_most_recent_session_for_directory("/home/user/nonexistent")
5150 .expect("get");
5151 assert!(result.is_none());
5152 }
5153
5154 #[test]
5155 fn test_session_deletion_removes_annotations_and_tags() {
5156 let (db, _dir) = create_test_db();
5157 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5158 db.insert_session(&session).expect("insert session");
5159
5160 let annotation = Annotation {
5162 id: Uuid::new_v4(),
5163 session_id: session.id,
5164 content: "Test annotation".to_string(),
5165 created_at: Utc::now(),
5166 };
5167 db.insert_annotation(&annotation).expect("insert");
5168
5169 let tag = Tag {
5171 id: Uuid::new_v4(),
5172 session_id: session.id,
5173 label: "test-tag".to_string(),
5174 created_at: Utc::now(),
5175 };
5176 db.insert_tag(&tag).expect("insert");
5177
5178 db.delete_session(&session.id).expect("delete");
5180
5181 let annotations = db.get_annotations(&session.id).expect("get");
5183 assert!(annotations.is_empty());
5184
5185 let tags = db.get_tags(&session.id).expect("get");
5186 assert!(tags.is_empty());
5187 }
5188
5189 #[test]
5190 fn test_insert_and_get_summary() {
5191 let (db, _dir) = create_test_db();
5192 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
5193 db.insert_session(&session).expect("insert session");
5194
5195 let summary = Summary {
5196 id: Uuid::new_v4(),
5197 session_id: session.id,
5198 content: "Test summary content".to_string(),
5199 generated_at: Utc::now(),
5200 };
5201 db.insert_summary(&summary).expect("insert summary");
5202
5203 let retrieved = db.get_summary(&session.id).expect("get summary");
5204 assert!(retrieved.is_some());
5205 let retrieved = retrieved.unwrap();
5206 assert_eq!(retrieved.content, "Test summary content");
5207 assert_eq!(retrieved.session_id, session.id);
5208 }
5209
5210 #[test]
5211 fn test_get_summary_nonexistent() {
5212 let (db, _dir) = create_test_db();
5213 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
5214 db.insert_session(&session).expect("insert session");
5215
5216 let retrieved = db.get_summary(&session.id).expect("get summary");
5217 assert!(retrieved.is_none());
5218 }
5219
5220 #[test]
5221 fn test_update_summary() {
5222 let (db, _dir) = create_test_db();
5223 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
5224 db.insert_session(&session).expect("insert session");
5225
5226 let summary = Summary {
5227 id: Uuid::new_v4(),
5228 session_id: session.id,
5229 content: "Original content".to_string(),
5230 generated_at: Utc::now(),
5231 };
5232 db.insert_summary(&summary).expect("insert summary");
5233
5234 let updated = db
5236 .update_summary(&session.id, "Updated content")
5237 .expect("update summary");
5238 assert!(updated);
5239
5240 let retrieved = db.get_summary(&session.id).expect("get summary");
5241 assert!(retrieved.is_some());
5242 assert_eq!(retrieved.unwrap().content, "Updated content");
5243 }
5244
5245 #[test]
5246 fn test_update_summary_nonexistent() {
5247 let (db, _dir) = create_test_db();
5248 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
5249 db.insert_session(&session).expect("insert session");
5250
5251 let updated = db
5253 .update_summary(&session.id, "New content")
5254 .expect("update summary");
5255 assert!(!updated);
5256 }
5257
5258 #[test]
5259 fn test_delete_summary() {
5260 let (db, _dir) = create_test_db();
5261 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
5262 db.insert_session(&session).expect("insert session");
5263
5264 let summary = Summary {
5265 id: Uuid::new_v4(),
5266 session_id: session.id,
5267 content: "To be deleted".to_string(),
5268 generated_at: Utc::now(),
5269 };
5270 db.insert_summary(&summary).expect("insert summary");
5271
5272 let deleted = db.delete_summary(&session.id).expect("delete summary");
5274 assert!(deleted);
5275
5276 let retrieved = db.get_summary(&session.id).expect("get summary");
5278 assert!(retrieved.is_none());
5279 }
5280
5281 #[test]
5282 fn test_delete_session_removes_summary() {
5283 let (db, _dir) = create_test_db();
5284 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
5285 db.insert_session(&session).expect("insert session");
5286
5287 let summary = Summary {
5288 id: Uuid::new_v4(),
5289 session_id: session.id,
5290 content: "Session summary".to_string(),
5291 generated_at: Utc::now(),
5292 };
5293 db.insert_summary(&summary).expect("insert summary");
5294
5295 db.delete_session(&session.id).expect("delete session");
5297
5298 let retrieved = db.get_summary(&session.id).expect("get summary");
5300 assert!(retrieved.is_none());
5301 }
5302
5303 #[test]
5306 fn test_upsert_machine_insert() {
5307 let (db, _dir) = create_test_db();
5308
5309 let machine = Machine {
5310 id: "test-uuid-1234".to_string(),
5311 name: "my-laptop".to_string(),
5312 created_at: Utc::now().to_rfc3339(),
5313 };
5314
5315 db.upsert_machine(&machine)
5316 .expect("Failed to upsert machine");
5317
5318 let retrieved = db
5319 .get_machine("test-uuid-1234")
5320 .expect("Failed to get machine")
5321 .expect("Machine should exist");
5322
5323 assert_eq!(retrieved.id, "test-uuid-1234");
5324 assert_eq!(retrieved.name, "my-laptop");
5325 }
5326
5327 #[test]
5328 fn test_upsert_machine_update() {
5329 let (db, _dir) = create_test_db();
5330
5331 let machine1 = Machine {
5333 id: "test-uuid-5678".to_string(),
5334 name: "old-name".to_string(),
5335 created_at: Utc::now().to_rfc3339(),
5336 };
5337 db.upsert_machine(&machine1)
5338 .expect("Failed to upsert machine");
5339
5340 let machine2 = Machine {
5342 id: "test-uuid-5678".to_string(),
5343 name: "new-name".to_string(),
5344 created_at: Utc::now().to_rfc3339(),
5345 };
5346 db.upsert_machine(&machine2)
5347 .expect("Failed to upsert machine");
5348
5349 let retrieved = db
5351 .get_machine("test-uuid-5678")
5352 .expect("Failed to get machine")
5353 .expect("Machine should exist");
5354
5355 assert_eq!(retrieved.name, "new-name");
5356 }
5357
5358 #[test]
5359 fn test_get_machine() {
5360 let (db, _dir) = create_test_db();
5361
5362 let not_found = db.get_machine("nonexistent-uuid").expect("Failed to query");
5364 assert!(not_found.is_none(), "Machine should not exist");
5365
5366 let machine = Machine {
5368 id: "existing-uuid".to_string(),
5369 name: "test-machine".to_string(),
5370 created_at: Utc::now().to_rfc3339(),
5371 };
5372 db.upsert_machine(&machine).expect("Failed to upsert");
5373
5374 let found = db
5376 .get_machine("existing-uuid")
5377 .expect("Failed to query")
5378 .expect("Machine should exist");
5379
5380 assert_eq!(found.id, "existing-uuid");
5381 assert_eq!(found.name, "test-machine");
5382 }
5383
5384 #[test]
5385 fn test_get_machine_name_found() {
5386 let (db, _dir) = create_test_db();
5387
5388 let machine = Machine {
5389 id: "uuid-for-name-test".to_string(),
5390 name: "my-workstation".to_string(),
5391 created_at: Utc::now().to_rfc3339(),
5392 };
5393 db.upsert_machine(&machine).expect("Failed to upsert");
5394
5395 let name = db
5396 .get_machine_name("uuid-for-name-test")
5397 .expect("Failed to get name");
5398
5399 assert_eq!(name, "my-workstation");
5400 }
5401
5402 #[test]
5403 fn test_get_machine_name_not_found() {
5404 let (db, _dir) = create_test_db();
5405
5406 let name = db
5408 .get_machine_name("abc123def456789")
5409 .expect("Failed to get name");
5410
5411 assert_eq!(name, "abc123de", "Should return first 8 characters");
5412
5413 let short_name = db.get_machine_name("short").expect("Failed to get name");
5415
5416 assert_eq!(
5417 short_name, "short",
5418 "Should return full ID if shorter than 8 chars"
5419 );
5420 }
5421
5422 #[test]
5423 fn test_list_machines() {
5424 let (db, _dir) = create_test_db();
5425
5426 let machines = db.list_machines().expect("Failed to list");
5428 assert!(machines.is_empty(), "Should have no machines initially");
5429
5430 let machine1 = Machine {
5432 id: "uuid-1".to_string(),
5433 name: "machine-1".to_string(),
5434 created_at: "2024-01-01T00:00:00Z".to_string(),
5435 };
5436 let machine2 = Machine {
5437 id: "uuid-2".to_string(),
5438 name: "machine-2".to_string(),
5439 created_at: "2024-01-02T00:00:00Z".to_string(),
5440 };
5441
5442 db.upsert_machine(&machine1).expect("Failed to upsert");
5443 db.upsert_machine(&machine2).expect("Failed to upsert");
5444
5445 let machines = db.list_machines().expect("Failed to list");
5447 assert_eq!(machines.len(), 2, "Should have 2 machines");
5448
5449 assert_eq!(machines[0].id, "uuid-1");
5451 assert_eq!(machines[1].id, "uuid-2");
5452 }
5453
5454 #[test]
5457 fn test_find_session_by_id_prefix_full_uuid() {
5458 let (db, _dir) = create_test_db();
5459 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5460 db.insert_session(&session).expect("insert session");
5461
5462 let found = db
5464 .find_session_by_id_prefix(&session.id.to_string())
5465 .expect("find session")
5466 .expect("session should exist");
5467
5468 assert_eq!(found.id, session.id, "Should find session by full UUID");
5469 }
5470
5471 #[test]
5472 fn test_find_session_by_id_prefix_short_prefix() {
5473 let (db, _dir) = create_test_db();
5474 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5475 db.insert_session(&session).expect("insert session");
5476
5477 let prefix = &session.id.to_string()[..8];
5479
5480 let found = db
5481 .find_session_by_id_prefix(prefix)
5482 .expect("find session")
5483 .expect("session should exist");
5484
5485 assert_eq!(found.id, session.id, "Should find session by short prefix");
5486 }
5487
5488 #[test]
5489 fn test_find_session_by_id_prefix_very_short_prefix() {
5490 let (db, _dir) = create_test_db();
5491 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5492 db.insert_session(&session).expect("insert session");
5493
5494 let prefix = &session.id.to_string()[..4];
5496
5497 let found = db
5498 .find_session_by_id_prefix(prefix)
5499 .expect("find session")
5500 .expect("session should exist");
5501
5502 assert_eq!(
5503 found.id, session.id,
5504 "Should find session by very short prefix"
5505 );
5506 }
5507
5508 #[test]
5509 fn test_find_session_by_id_prefix_not_found() {
5510 let (db, _dir) = create_test_db();
5511 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5512 db.insert_session(&session).expect("insert session");
5513
5514 let found = db
5516 .find_session_by_id_prefix("zzz999")
5517 .expect("find session");
5518
5519 assert!(
5520 found.is_none(),
5521 "Should return None for non-matching prefix"
5522 );
5523 }
5524
5525 #[test]
5526 fn test_find_session_by_id_prefix_empty_db() {
5527 let (db, _dir) = create_test_db();
5528
5529 let found = db
5530 .find_session_by_id_prefix("abc123")
5531 .expect("find session");
5532
5533 assert!(found.is_none(), "Should return None for empty database");
5534 }
5535
5536 #[test]
5537 fn test_find_session_by_id_prefix_ambiguous() {
5538 let (db, _dir) = create_test_db();
5539
5540 let mut sessions = Vec::new();
5542 for _ in 0..100 {
5543 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5544 db.insert_session(&session).expect("insert session");
5545 sessions.push(session);
5546 }
5547
5548 let first_session = &sessions[0];
5550 let first_char = first_session.id.to_string().chars().next().unwrap();
5551
5552 let matching_count = sessions
5554 .iter()
5555 .filter(|s| s.id.to_string().starts_with(first_char))
5556 .count();
5557
5558 if matching_count > 1 {
5559 let result = db.find_session_by_id_prefix(&first_char.to_string());
5562 assert!(
5563 result.is_err(),
5564 "Should return error for ambiguous single-character prefix"
5565 );
5566 let error_msg = result.unwrap_err().to_string();
5567 assert!(
5568 error_msg.contains("Ambiguous"),
5569 "Error should mention ambiguity"
5570 );
5571 }
5572 }
5573
5574 #[test]
5575 fn test_find_session_by_id_prefix_returns_correct_session_data() {
5576 let (db, _dir) = create_test_db();
5577
5578 let mut session =
5579 create_test_session("claude-code", "/home/user/myproject", Utc::now(), None);
5580 session.tool_version = Some("2.0.0".to_string());
5581 session.model = Some("claude-opus-4".to_string());
5582 session.git_branch = Some("feature/test".to_string());
5583 session.message_count = 42;
5584 db.insert_session(&session).expect("insert session");
5585
5586 let prefix = &session.id.to_string()[..8];
5588 let found = db
5589 .find_session_by_id_prefix(prefix)
5590 .expect("find session")
5591 .expect("session should exist");
5592
5593 assert_eq!(found.id, session.id);
5595 assert_eq!(found.tool, "claude-code");
5596 assert_eq!(found.tool_version, Some("2.0.0".to_string()));
5597 assert_eq!(found.model, Some("claude-opus-4".to_string()));
5598 assert_eq!(found.working_directory, "/home/user/myproject");
5599 assert_eq!(found.git_branch, Some("feature/test".to_string()));
5600 assert_eq!(found.message_count, 42);
5601 }
5602
5603 #[test]
5604 fn test_find_session_by_id_prefix_many_sessions() {
5605 let (db, _dir) = create_test_db();
5606
5607 let mut target_session = None;
5609 for i in 0..200 {
5610 let session =
5611 create_test_session("claude-code", &format!("/project/{i}"), Utc::now(), None);
5612 db.insert_session(&session).expect("insert session");
5613 if i == 150 {
5615 target_session = Some(session);
5616 }
5617 }
5618
5619 let target = target_session.expect("should have target session");
5620 let prefix = &target.id.to_string()[..8];
5621
5622 let found = db
5624 .find_session_by_id_prefix(prefix)
5625 .expect("find session")
5626 .expect("session should exist");
5627
5628 assert_eq!(
5629 found.id, target.id,
5630 "Should find correct session among many"
5631 );
5632 assert_eq!(found.working_directory, "/project/150");
5633 }
5634
5635 #[test]
5636 fn test_import_session_with_messages() {
5637 let (mut db, _dir) = create_test_db();
5638
5639 let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
5640 let messages = vec![
5641 create_test_message(session.id, 0, MessageRole::User, "Hello"),
5642 create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!"),
5643 create_test_message(session.id, 2, MessageRole::User, "How are you?"),
5644 ];
5645
5646 let synced_at = Utc::now();
5647 db.import_session_with_messages(&session, &messages, Some(synced_at))
5648 .expect("Failed to import session with messages");
5649
5650 let retrieved_session = db.get_session(&session.id).expect("Failed to get session");
5652 assert!(retrieved_session.is_some(), "Session should exist");
5653 let retrieved_session = retrieved_session.unwrap();
5654 assert_eq!(retrieved_session.tool, "claude-code");
5655
5656 let retrieved_messages = db
5658 .get_messages(&session.id)
5659 .expect("Failed to get messages");
5660 assert_eq!(retrieved_messages.len(), 3, "Should have 3 messages");
5661 assert_eq!(retrieved_messages[0].content.text(), "Hello");
5662 assert_eq!(retrieved_messages[1].content.text(), "Hi there!");
5663 assert_eq!(retrieved_messages[2].content.text(), "How are you?");
5664
5665 let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5667 assert!(
5668 !unsynced.iter().any(|s| s.id == session.id),
5669 "Session should be marked as synced"
5670 );
5671 }
5672
5673 #[test]
5674 fn test_import_session_with_messages_no_sync() {
5675 let (mut db, _dir) = create_test_db();
5676
5677 let session = create_test_session("aider", "/tmp/test", Utc::now(), None);
5678 let messages = vec![create_test_message(
5679 session.id,
5680 0,
5681 MessageRole::User,
5682 "Test message",
5683 )];
5684
5685 db.import_session_with_messages(&session, &messages, None)
5687 .expect("Failed to import session");
5688
5689 let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5691 assert!(
5692 unsynced.iter().any(|s| s.id == session.id),
5693 "Session should NOT be marked as synced"
5694 );
5695 }
5696
5697 #[test]
5698 fn test_session_update_resets_sync_status() {
5699 let (db, _dir) = create_test_db();
5700
5701 let mut session =
5703 create_test_session("claude-code", "/home/user/project", Utc::now(), None);
5704 session.message_count = 5;
5705 db.insert_session(&session)
5706 .expect("Failed to insert session");
5707
5708 db.mark_sessions_synced(&[session.id], Utc::now())
5710 .expect("Failed to mark synced");
5711
5712 let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5714 assert!(
5715 !unsynced.iter().any(|s| s.id == session.id),
5716 "Session should be synced initially"
5717 );
5718
5719 session.message_count = 10;
5721 session.ended_at = Some(Utc::now());
5722 db.insert_session(&session)
5723 .expect("Failed to update session");
5724
5725 let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5727 assert!(
5728 unsynced.iter().any(|s| s.id == session.id),
5729 "Session should be marked for re-sync after update"
5730 );
5731
5732 let retrieved = db
5734 .get_session(&session.id)
5735 .expect("Failed to get session")
5736 .expect("Session should exist");
5737 assert_eq!(
5738 retrieved.message_count, 10,
5739 "Message count should be updated"
5740 );
5741 }
5742
5743 #[test]
5744 fn test_clear_sync_status_all_sessions() {
5745 let (db, _dir) = create_test_db();
5746
5747 let session1 = create_test_session("claude-code", "/home/user/project1", Utc::now(), None);
5749 let session2 = create_test_session("aider", "/home/user/project2", Utc::now(), None);
5750 let session3 = create_test_session("cline", "/home/user/project3", Utc::now(), None);
5751
5752 db.insert_session(&session1)
5753 .expect("Failed to insert session1");
5754 db.insert_session(&session2)
5755 .expect("Failed to insert session2");
5756 db.insert_session(&session3)
5757 .expect("Failed to insert session3");
5758
5759 db.mark_sessions_synced(&[session1.id, session2.id, session3.id], Utc::now())
5761 .expect("Failed to mark synced");
5762
5763 let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5765 assert_eq!(unsynced.len(), 0, "All sessions should be synced");
5766
5767 let count = db.clear_sync_status().expect("Failed to clear sync status");
5769 assert_eq!(count, 3, "Should have cleared 3 sessions");
5770
5771 let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5773 assert_eq!(unsynced.len(), 3, "All sessions should be unsynced now");
5774 }
5775
5776 #[test]
5777 fn test_clear_sync_status_for_specific_sessions() {
5778 let (db, _dir) = create_test_db();
5779
5780 let session1 = create_test_session("claude-code", "/home/user/project1", Utc::now(), None);
5782 let session2 = create_test_session("aider", "/home/user/project2", Utc::now(), None);
5783 let session3 = create_test_session("cline", "/home/user/project3", Utc::now(), None);
5784
5785 db.insert_session(&session1)
5786 .expect("Failed to insert session1");
5787 db.insert_session(&session2)
5788 .expect("Failed to insert session2");
5789 db.insert_session(&session3)
5790 .expect("Failed to insert session3");
5791
5792 db.mark_sessions_synced(&[session1.id, session2.id, session3.id], Utc::now())
5794 .expect("Failed to mark synced");
5795
5796 let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5798 assert_eq!(unsynced.len(), 0, "All sessions should be synced");
5799
5800 let count = db
5802 .clear_sync_status_for_sessions(&[session1.id, session3.id])
5803 .expect("Failed to clear sync status");
5804 assert_eq!(count, 2, "Should have cleared 2 sessions");
5805
5806 let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5808 assert_eq!(unsynced.len(), 2, "Two sessions should be unsynced");
5809 assert!(
5810 unsynced.iter().any(|s| s.id == session1.id),
5811 "session1 should be unsynced"
5812 );
5813 assert!(
5814 !unsynced.iter().any(|s| s.id == session2.id),
5815 "session2 should still be synced"
5816 );
5817 assert!(
5818 unsynced.iter().any(|s| s.id == session3.id),
5819 "session3 should be unsynced"
5820 );
5821 }
5822
5823 #[test]
5824 fn test_clear_sync_status_for_sessions_empty_list() {
5825 let (db, _dir) = create_test_db();
5826
5827 let count = db
5829 .clear_sync_status_for_sessions(&[])
5830 .expect("Failed to clear sync status");
5831 assert_eq!(count, 0, "Should return 0 for empty list");
5832 }
5833
5834 #[test]
5835 fn test_clear_sync_status_for_nonexistent_session() {
5836 let (db, _dir) = create_test_db();
5837
5838 let fake_id = Uuid::new_v4();
5840 let count = db
5841 .clear_sync_status_for_sessions(&[fake_id])
5842 .expect("Failed to clear sync status");
5843 assert_eq!(count, 0, "Should return 0 for nonexistent session");
5844 }
5845
5846 #[test]
5849 fn test_sessions_in_date_range_all() {
5850 let (db, _dir) = create_test_db();
5851 let now = Utc::now();
5852
5853 let s1 = create_test_session("claude-code", "/project/a", now - Duration::hours(3), None);
5854 let s2 = create_test_session("aider", "/project/b", now - Duration::hours(1), None);
5855
5856 db.insert_session(&s1).expect("Failed to insert session");
5857 db.insert_session(&s2).expect("Failed to insert session");
5858
5859 let results = db
5860 .sessions_in_date_range(None, None, None)
5861 .expect("Failed to query sessions");
5862 assert_eq!(
5863 results.len(),
5864 2,
5865 "Should return all sessions when no filters"
5866 );
5867 }
5868
5869 #[test]
5870 fn test_sessions_in_date_range_with_since() {
5871 let (db, _dir) = create_test_db();
5872 let now = Utc::now();
5873
5874 let s1 = create_test_session("claude-code", "/project/a", now - Duration::hours(5), None);
5875 let s2 = create_test_session("aider", "/project/b", now - Duration::hours(1), None);
5876
5877 db.insert_session(&s1).expect("Failed to insert session");
5878 db.insert_session(&s2).expect("Failed to insert session");
5879
5880 let since = now - Duration::hours(3);
5881 let results = db
5882 .sessions_in_date_range(Some(since), None, None)
5883 .expect("Failed to query sessions");
5884 assert_eq!(results.len(), 1, "Should return only sessions after since");
5885 assert_eq!(results[0].id, s2.id, "Should return the newer session");
5886 }
5887
5888 #[test]
5889 fn test_sessions_in_date_range_with_working_dir() {
5890 let (db, _dir) = create_test_db();
5891 let now = Utc::now();
5892
5893 let s1 = create_test_session(
5894 "claude-code",
5895 "/project/alpha",
5896 now - Duration::hours(2),
5897 None,
5898 );
5899 let s2 = create_test_session("aider", "/project/beta", now - Duration::hours(1), None);
5900
5901 db.insert_session(&s1).expect("Failed to insert session");
5902 db.insert_session(&s2).expect("Failed to insert session");
5903
5904 let results = db
5905 .sessions_in_date_range(None, None, Some("/project/alpha"))
5906 .expect("Failed to query sessions");
5907 assert_eq!(results.len(), 1, "Should return only matching working dir");
5908 assert_eq!(results[0].id, s1.id, "Should return the alpha session");
5909 }
5910
5911 #[test]
5912 fn test_sessions_in_date_range_with_until() {
5913 let (db, _dir) = create_test_db();
5914 let now = Utc::now();
5915
5916 let s1 = create_test_session("claude-code", "/project/a", now - Duration::hours(5), None);
5917 let s2 = create_test_session("aider", "/project/b", now - Duration::hours(1), None);
5918
5919 db.insert_session(&s1).expect("Failed to insert session");
5920 db.insert_session(&s2).expect("Failed to insert session");
5921
5922 let until = now - Duration::hours(3);
5923 let results = db
5924 .sessions_in_date_range(None, Some(until), None)
5925 .expect("Failed to query sessions");
5926 assert_eq!(results.len(), 1, "Should return only sessions before until");
5927 assert_eq!(results[0].id, s1.id, "Should return the older session");
5928 }
5929
5930 #[test]
5931 fn test_sessions_in_date_range_with_since_and_until() {
5932 let (db, _dir) = create_test_db();
5933 let now = Utc::now();
5934
5935 let s1 = create_test_session("claude-code", "/project", now - Duration::hours(8), None);
5936 let s2 = create_test_session("aider", "/project", now - Duration::hours(4), None);
5937 let s3 = create_test_session("claude-code", "/project", now - Duration::hours(1), None);
5938
5939 db.insert_session(&s1).expect("Failed to insert session");
5940 db.insert_session(&s2).expect("Failed to insert session");
5941 db.insert_session(&s3).expect("Failed to insert session");
5942
5943 let since = now - Duration::hours(6);
5944 let until = now - Duration::hours(2);
5945 let results = db
5946 .sessions_in_date_range(Some(since), Some(until), None)
5947 .expect("Failed to query sessions");
5948 assert_eq!(
5949 results.len(),
5950 1,
5951 "Should return only sessions in the window"
5952 );
5953 assert_eq!(results[0].id, s2.id, "Should return the middle session");
5954 }
5955
5956 #[test]
5957 fn test_average_session_duration() {
5958 let (db, _dir) = create_test_db();
5959 let now = Utc::now();
5960
5961 let mut s1 = create_test_session("claude-code", "/project", now - Duration::hours(2), None);
5962 s1.ended_at = Some(s1.started_at + Duration::minutes(30));
5963
5964 let mut s2 = create_test_session("aider", "/project", now - Duration::hours(1), None);
5965 s2.ended_at = Some(s2.started_at + Duration::minutes(60));
5966
5967 db.insert_session(&s1).expect("Failed to insert session");
5968 db.insert_session(&s2).expect("Failed to insert session");
5969
5970 let avg = db
5971 .average_session_duration_minutes(None, None)
5972 .expect("Failed to get average duration");
5973 assert!(avg.is_some(), "Should return an average");
5974 let avg_val = avg.unwrap();
5975 assert!(
5977 (avg_val - 45.0).abs() < 1.0,
5978 "Average should be approximately 45 minutes, got {}",
5979 avg_val
5980 );
5981 }
5982
5983 #[test]
5984 fn test_average_session_duration_no_ended_sessions() {
5985 let (db, _dir) = create_test_db();
5986 let now = Utc::now();
5987
5988 let s1 = create_test_session("claude-code", "/project", now, None);
5990 db.insert_session(&s1).expect("Failed to insert session");
5991
5992 let avg = db
5993 .average_session_duration_minutes(None, None)
5994 .expect("Failed to get average duration");
5995 assert!(
5996 avg.is_none(),
5997 "Should return None when no sessions have ended_at"
5998 );
5999 }
6000
6001 #[test]
6002 fn test_sessions_by_tool_in_range() {
6003 let (db, _dir) = create_test_db();
6004 let now = Utc::now();
6005
6006 let s1 = create_test_session("claude-code", "/project", now - Duration::hours(3), None);
6007 let s2 = create_test_session("claude-code", "/project", now - Duration::hours(2), None);
6008 let s3 = create_test_session("aider", "/project", now - Duration::hours(1), None);
6009
6010 db.insert_session(&s1).expect("Failed to insert session");
6011 db.insert_session(&s2).expect("Failed to insert session");
6012 db.insert_session(&s3).expect("Failed to insert session");
6013
6014 let results = db
6015 .sessions_by_tool_in_range(None, None)
6016 .expect("Failed to get sessions by tool");
6017 assert_eq!(results.len(), 2, "Should have two tools");
6018 assert_eq!(results[0].0, "claude-code");
6020 assert_eq!(results[0].1, 2);
6021 assert_eq!(results[1].0, "aider");
6022 assert_eq!(results[1].1, 1);
6023 }
6024
6025 #[test]
6026 fn test_sessions_by_weekday() {
6027 let (db, _dir) = create_test_db();
6028 let monday = chrono::NaiveDate::from_ymd_opt(2024, 1, 15)
6030 .unwrap()
6031 .and_hms_opt(12, 0, 0)
6032 .unwrap()
6033 .and_utc();
6034
6035 let s1 = create_test_session("claude-code", "/project", monday, None);
6036 let s2 = create_test_session("aider", "/project", monday + Duration::hours(1), None);
6037
6038 db.insert_session(&s1).expect("Failed to insert session");
6039 db.insert_session(&s2).expect("Failed to insert session");
6040
6041 let results = db
6042 .sessions_by_weekday(None, None)
6043 .expect("Failed to get sessions by weekday");
6044 assert_eq!(results.len(), 1, "Should have one weekday entry");
6045 assert_eq!(results[0].0, 1, "Monday is weekday 1");
6046 assert_eq!(results[0].1, 2, "Should have 2 sessions on Monday");
6047 }
6048
6049 #[test]
6050 fn test_average_message_count() {
6051 let (db, _dir) = create_test_db();
6052 let now = Utc::now();
6053
6054 let mut s1 = create_test_session("claude-code", "/project", now - Duration::hours(2), None);
6055 s1.message_count = 10;
6056
6057 let mut s2 = create_test_session("aider", "/project", now - Duration::hours(1), None);
6058 s2.message_count = 20;
6059
6060 db.insert_session(&s1).expect("Failed to insert session");
6061 db.insert_session(&s2).expect("Failed to insert session");
6062
6063 let avg = db
6064 .average_message_count(None, None)
6065 .expect("Failed to get average message count");
6066 assert!(avg.is_some(), "Should return an average");
6067 let avg_val = avg.unwrap();
6068 assert!(
6070 (avg_val - 15.0).abs() < 0.01,
6071 "Average should be 15.0, got {}",
6072 avg_val
6073 );
6074 }
6075}