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