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