1use anyhow::{Context, Result};
8use chrono::{DateTime, Utc};
9use rusqlite::{params, Connection, OptionalExtension};
10use std::path::PathBuf;
11use uuid::Uuid;
12
13use super::models::{
14 Annotation, Machine, Message, MessageContent, MessageRole, SearchResult, Session, SessionLink,
15 Summary, Tag,
16};
17
18fn parse_uuid(s: &str) -> rusqlite::Result<Uuid> {
22 Uuid::parse_str(s).map_err(|e| {
23 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
24 })
25}
26
27fn parse_datetime(s: &str) -> rusqlite::Result<DateTime<Utc>> {
31 chrono::DateTime::parse_from_rfc3339(s)
32 .map(|dt| dt.with_timezone(&Utc))
33 .map_err(|e| {
34 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
35 })
36}
37
38fn escape_fts5_query(query: &str) -> String {
43 query
45 .split_whitespace()
46 .map(|word| {
47 let escaped = word.replace('"', "\"\"");
48 format!("\"{escaped}\"")
49 })
50 .collect::<Vec<_>>()
51 .join(" ")
52}
53
54pub fn default_db_path() -> Result<PathBuf> {
58 let config_dir = dirs::home_dir()
59 .context("Could not find home directory. Ensure your HOME environment variable is set.")?
60 .join(".lore");
61
62 std::fs::create_dir_all(&config_dir).with_context(|| {
63 format!(
64 "Failed to create Lore data directory at {}. Check directory permissions.",
65 config_dir.display()
66 )
67 })?;
68 Ok(config_dir.join("lore.db"))
69}
70
71pub struct Database {
77 conn: Connection,
78}
79
80impl Database {
81 pub fn open(path: &PathBuf) -> Result<Self> {
85 let conn = Connection::open(path)?;
86 let db = Self { conn };
87 db.migrate()?;
88 Ok(db)
89 }
90
91 pub fn open_default() -> Result<Self> {
95 let path = default_db_path()?;
96 Self::open(&path)
97 }
98
99 fn migrate(&self) -> Result<()> {
104 self.conn.execute_batch(
105 r#"
106 CREATE TABLE IF NOT EXISTS schema_version (
107 version INTEGER PRIMARY KEY
108 );
109
110 CREATE TABLE IF NOT EXISTS sessions (
111 id TEXT PRIMARY KEY,
112 tool TEXT NOT NULL,
113 tool_version TEXT,
114 started_at TEXT NOT NULL,
115 ended_at TEXT,
116 model TEXT,
117 working_directory TEXT NOT NULL,
118 git_branch TEXT,
119 source_path TEXT,
120 message_count INTEGER NOT NULL DEFAULT 0,
121 created_at TEXT NOT NULL DEFAULT (datetime('now')),
122 machine_id TEXT
123 );
124
125 CREATE TABLE IF NOT EXISTS messages (
126 id TEXT PRIMARY KEY,
127 session_id TEXT NOT NULL,
128 parent_id TEXT,
129 idx INTEGER NOT NULL,
130 timestamp TEXT NOT NULL,
131 role TEXT NOT NULL,
132 content TEXT NOT NULL,
133 model TEXT,
134 git_branch TEXT,
135 cwd TEXT,
136 FOREIGN KEY (session_id) REFERENCES sessions(id)
137 );
138
139 CREATE TABLE IF NOT EXISTS session_links (
140 id TEXT PRIMARY KEY,
141 session_id TEXT NOT NULL,
142 link_type TEXT NOT NULL,
143 commit_sha TEXT,
144 branch TEXT,
145 remote TEXT,
146 created_at TEXT NOT NULL,
147 created_by TEXT NOT NULL,
148 confidence REAL,
149 FOREIGN KEY (session_id) REFERENCES sessions(id)
150 );
151
152 CREATE TABLE IF NOT EXISTS repositories (
153 id TEXT PRIMARY KEY,
154 path TEXT NOT NULL UNIQUE,
155 name TEXT NOT NULL,
156 remote_url TEXT,
157 created_at TEXT NOT NULL DEFAULT (datetime('now')),
158 last_session_at TEXT
159 );
160
161 CREATE TABLE IF NOT EXISTS annotations (
162 id TEXT PRIMARY KEY,
163 session_id TEXT NOT NULL,
164 content TEXT NOT NULL,
165 created_at TEXT NOT NULL,
166 FOREIGN KEY (session_id) REFERENCES sessions(id)
167 );
168
169 CREATE TABLE IF NOT EXISTS tags (
170 id TEXT PRIMARY KEY,
171 session_id TEXT NOT NULL,
172 label TEXT NOT NULL,
173 created_at TEXT NOT NULL,
174 FOREIGN KEY (session_id) REFERENCES sessions(id),
175 UNIQUE(session_id, label)
176 );
177
178 CREATE TABLE IF NOT EXISTS summaries (
179 id TEXT PRIMARY KEY,
180 session_id TEXT NOT NULL UNIQUE,
181 content TEXT NOT NULL,
182 generated_at TEXT NOT NULL,
183 FOREIGN KEY (session_id) REFERENCES sessions(id)
184 );
185
186 CREATE TABLE IF NOT EXISTS machines (
187 id TEXT PRIMARY KEY,
188 name TEXT NOT NULL,
189 created_at TEXT NOT NULL
190 );
191
192 -- Indexes for common queries
193 CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
194 CREATE INDEX IF NOT EXISTS idx_sessions_working_directory ON sessions(working_directory);
195 CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
196 CREATE INDEX IF NOT EXISTS idx_session_links_session_id ON session_links(session_id);
197 CREATE INDEX IF NOT EXISTS idx_session_links_commit_sha ON session_links(commit_sha);
198 CREATE INDEX IF NOT EXISTS idx_annotations_session_id ON annotations(session_id);
199 CREATE INDEX IF NOT EXISTS idx_tags_session_id ON tags(session_id);
200 CREATE INDEX IF NOT EXISTS idx_tags_label ON tags(label);
201 "#,
202 )?;
203
204 self.conn.execute_batch(
209 r#"
210 CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
211 message_id,
212 text_content,
213 tokenize='porter unicode61'
214 );
215 "#,
216 )?;
217
218 self.conn.execute_batch(
221 r#"
222 CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
223 session_id,
224 tool,
225 working_directory,
226 git_branch,
227 tokenize='porter unicode61'
228 );
229 "#,
230 )?;
231
232 self.migrate_add_machine_id()?;
235
236 Ok(())
237 }
238
239 fn migrate_add_machine_id(&self) -> Result<()> {
247 let columns: Vec<String> = self
249 .conn
250 .prepare("PRAGMA table_info(sessions)")?
251 .query_map([], |row| row.get::<_, String>(1))?
252 .collect::<Result<Vec<_>, _>>()?;
253
254 if !columns.iter().any(|c| c == "machine_id") {
255 self.conn
256 .execute("ALTER TABLE sessions ADD COLUMN machine_id TEXT", [])?;
257 }
258
259 if let Some(machine_uuid) = super::get_machine_id() {
261 self.conn.execute(
262 "UPDATE sessions SET machine_id = ?1 WHERE machine_id IS NULL",
263 [&machine_uuid],
264 )?;
265
266 if let Some(hostname) = hostname::get().ok().and_then(|h| h.into_string().ok()) {
271 self.conn.execute(
272 "UPDATE sessions SET machine_id = ?1 WHERE machine_id = ?2",
273 [&machine_uuid, &hostname],
274 )?;
275 }
276 }
277
278 Ok(())
279 }
280
281 pub fn insert_session(&self, session: &Session) -> Result<()> {
289 let rows_changed = self.conn.execute(
290 r#"
291 INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id)
292 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
293 ON CONFLICT(id) DO UPDATE SET
294 ended_at = ?5,
295 message_count = ?10
296 "#,
297 params![
298 session.id.to_string(),
299 session.tool,
300 session.tool_version,
301 session.started_at.to_rfc3339(),
302 session.ended_at.map(|t| t.to_rfc3339()),
303 session.model,
304 session.working_directory,
305 session.git_branch,
306 session.source_path,
307 session.message_count,
308 session.machine_id,
309 ],
310 )?;
311
312 if rows_changed > 0 {
314 let fts_count: i32 = self.conn.query_row(
316 "SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
317 params![session.id.to_string()],
318 |row| row.get(0),
319 )?;
320
321 if fts_count == 0 {
322 self.conn.execute(
323 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
324 params![
325 session.id.to_string(),
326 session.tool,
327 session.working_directory,
328 session.git_branch.as_deref().unwrap_or(""),
329 ],
330 )?;
331 }
332 }
333
334 Ok(())
335 }
336
337 pub fn get_session(&self, id: &Uuid) -> Result<Option<Session>> {
341 self.conn
342 .query_row(
343 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id FROM sessions WHERE id = ?1",
344 params![id.to_string()],
345 Self::row_to_session,
346 )
347 .optional()
348 .context("Failed to get session")
349 }
350
351 pub fn list_sessions(&self, limit: usize, working_dir: Option<&str>) -> Result<Vec<Session>> {
356 let mut stmt = if working_dir.is_some() {
357 self.conn.prepare(
358 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
359 FROM sessions
360 WHERE working_directory LIKE ?1
361 ORDER BY started_at DESC
362 LIMIT ?2"
363 )?
364 } else {
365 self.conn.prepare(
366 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
367 FROM sessions
368 ORDER BY started_at DESC
369 LIMIT ?1"
370 )?
371 };
372
373 let rows = if let Some(wd) = working_dir {
374 stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
375 } else {
376 stmt.query_map(params![limit], Self::row_to_session)?
377 };
378
379 rows.collect::<Result<Vec<_>, _>>()
380 .context("Failed to list sessions")
381 }
382
383 pub fn session_exists_by_source(&self, source_path: &str) -> Result<bool> {
387 let count: i32 = self.conn.query_row(
388 "SELECT COUNT(*) FROM sessions WHERE source_path = ?1",
389 params![source_path],
390 |row| row.get(0),
391 )?;
392 Ok(count > 0)
393 }
394
395 pub fn update_session_branch(&self, session_id: Uuid, new_branch: &str) -> Result<usize> {
403 let rows_changed = self.conn.execute(
404 "UPDATE sessions SET git_branch = ?1 WHERE id = ?2",
405 params![new_branch, session_id.to_string()],
406 )?;
407
408 if rows_changed > 0 {
410 self.conn.execute(
411 "UPDATE sessions_fts SET git_branch = ?1 WHERE session_id = ?2",
412 params![new_branch, session_id.to_string()],
413 )?;
414 }
415
416 Ok(rows_changed)
417 }
418
419 fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<Session> {
420 let ended_at_str: Option<String> = row.get(4)?;
421 let ended_at = match ended_at_str {
422 Some(s) => Some(parse_datetime(&s)?),
423 None => None,
424 };
425
426 Ok(Session {
427 id: parse_uuid(&row.get::<_, String>(0)?)?,
428 tool: row.get(1)?,
429 tool_version: row.get(2)?,
430 started_at: parse_datetime(&row.get::<_, String>(3)?)?,
431 ended_at,
432 model: row.get(5)?,
433 working_directory: row.get(6)?,
434 git_branch: row.get(7)?,
435 source_path: row.get(8)?,
436 message_count: row.get(9)?,
437 machine_id: row.get(10)?,
438 })
439 }
440
441 pub fn insert_message(&self, message: &Message) -> Result<()> {
449 let content_json = serde_json::to_string(&message.content)?;
450
451 let rows_changed = self.conn.execute(
452 r#"
453 INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
454 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
455 ON CONFLICT(id) DO NOTHING
456 "#,
457 params![
458 message.id.to_string(),
459 message.session_id.to_string(),
460 message.parent_id.map(|u| u.to_string()),
461 message.index,
462 message.timestamp.to_rfc3339(),
463 message.role.to_string(),
464 content_json,
465 message.model,
466 message.git_branch,
467 message.cwd,
468 ],
469 )?;
470
471 if rows_changed > 0 {
473 let text_content = message.content.text();
474 if !text_content.is_empty() {
475 self.conn.execute(
476 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
477 params![message.id.to_string(), text_content],
478 )?;
479 }
480 }
481
482 Ok(())
483 }
484
485 pub fn get_messages(&self, session_id: &Uuid) -> Result<Vec<Message>> {
489 let mut stmt = self.conn.prepare(
490 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
491 FROM messages
492 WHERE session_id = ?1
493 ORDER BY idx"
494 )?;
495
496 let rows = stmt.query_map(params![session_id.to_string()], |row| {
497 let role_str: String = row.get(5)?;
498 let content_str: String = row.get(6)?;
499
500 let parent_id_str: Option<String> = row.get(2)?;
501 let parent_id = match parent_id_str {
502 Some(s) => Some(parse_uuid(&s)?),
503 None => None,
504 };
505
506 Ok(Message {
507 id: parse_uuid(&row.get::<_, String>(0)?)?,
508 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
509 parent_id,
510 index: row.get(3)?,
511 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
512 role: match role_str.as_str() {
513 "user" => MessageRole::User,
514 "assistant" => MessageRole::Assistant,
515 "system" => MessageRole::System,
516 _ => MessageRole::User,
517 },
518 content: serde_json::from_str(&content_str)
519 .unwrap_or(MessageContent::Text(content_str)),
520 model: row.get(7)?,
521 git_branch: row.get(8)?,
522 cwd: row.get(9)?,
523 })
524 })?;
525
526 rows.collect::<Result<Vec<_>, _>>()
527 .context("Failed to get messages")
528 }
529
530 pub fn get_session_branch_history(&self, session_id: Uuid) -> Result<Vec<String>> {
539 let mut stmt = self
540 .conn
541 .prepare("SELECT git_branch FROM messages WHERE session_id = ?1 ORDER BY idx")?;
542
543 let rows = stmt.query_map(params![session_id.to_string()], |row| {
544 let branch: Option<String> = row.get(0)?;
545 Ok(branch)
546 })?;
547
548 let mut branches: Vec<String> = Vec::new();
550 for row in rows {
551 if let Some(branch) = row? {
552 if branches.last() != Some(&branch) {
554 branches.push(branch);
555 }
556 }
557 }
558
559 Ok(branches)
560 }
561
562 pub fn insert_link(&self, link: &SessionLink) -> Result<()> {
569 self.conn.execute(
570 r#"
571 INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
572 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
573 "#,
574 params![
575 link.id.to_string(),
576 link.session_id.to_string(),
577 format!("{:?}", link.link_type).to_lowercase(),
578 link.commit_sha,
579 link.branch,
580 link.remote,
581 link.created_at.to_rfc3339(),
582 format!("{:?}", link.created_by).to_lowercase(),
583 link.confidence,
584 ],
585 )?;
586 Ok(())
587 }
588
589 pub fn get_links_by_commit(&self, commit_sha: &str) -> Result<Vec<SessionLink>> {
594 let mut stmt = self.conn.prepare(
595 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
596 FROM session_links
597 WHERE commit_sha LIKE ?1"
598 )?;
599
600 let pattern = format!("{commit_sha}%");
601 let rows = stmt.query_map(params![pattern], Self::row_to_link)?;
602
603 rows.collect::<Result<Vec<_>, _>>()
604 .context("Failed to get links")
605 }
606
607 pub fn get_links_by_session(&self, session_id: &Uuid) -> Result<Vec<SessionLink>> {
612 let mut stmt = self.conn.prepare(
613 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
614 FROM session_links
615 WHERE session_id = ?1"
616 )?;
617
618 let rows = stmt.query_map(params![session_id.to_string()], Self::row_to_link)?;
619
620 rows.collect::<Result<Vec<_>, _>>()
621 .context("Failed to get links")
622 }
623
624 fn row_to_link(row: &rusqlite::Row) -> rusqlite::Result<SessionLink> {
625 use super::models::{LinkCreator, LinkType};
626
627 let link_type_str: String = row.get(2)?;
628 let created_by_str: String = row.get(7)?;
629
630 Ok(SessionLink {
631 id: parse_uuid(&row.get::<_, String>(0)?)?,
632 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
633 link_type: match link_type_str.as_str() {
634 "commit" => LinkType::Commit,
635 "branch" => LinkType::Branch,
636 "pr" => LinkType::Pr,
637 _ => LinkType::Manual,
638 },
639 commit_sha: row.get(3)?,
640 branch: row.get(4)?,
641 remote: row.get(5)?,
642 created_at: parse_datetime(&row.get::<_, String>(6)?)?,
643 created_by: match created_by_str.as_str() {
644 "auto" => LinkCreator::Auto,
645 _ => LinkCreator::User,
646 },
647 confidence: row.get(8)?,
648 })
649 }
650
651 #[allow(dead_code)]
658 pub fn delete_link(&self, link_id: &Uuid) -> Result<bool> {
659 let rows_affected = self.conn.execute(
660 "DELETE FROM session_links WHERE id = ?1",
661 params![link_id.to_string()],
662 )?;
663 Ok(rows_affected > 0)
664 }
665
666 pub fn delete_links_by_session(&self, session_id: &Uuid) -> Result<usize> {
670 let rows_affected = self.conn.execute(
671 "DELETE FROM session_links WHERE session_id = ?1",
672 params![session_id.to_string()],
673 )?;
674 Ok(rows_affected)
675 }
676
677 pub fn delete_link_by_session_and_commit(
682 &self,
683 session_id: &Uuid,
684 commit_sha: &str,
685 ) -> Result<bool> {
686 let pattern = format!("{commit_sha}%");
687 let rows_affected = self.conn.execute(
688 "DELETE FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
689 params![session_id.to_string(), pattern],
690 )?;
691 Ok(rows_affected > 0)
692 }
693
694 #[allow(dead_code)]
708 pub fn search_messages(
709 &self,
710 query: &str,
711 limit: usize,
712 working_dir: Option<&str>,
713 since: Option<chrono::DateTime<chrono::Utc>>,
714 role: Option<&str>,
715 ) -> Result<Vec<SearchResult>> {
716 use super::models::SearchOptions;
717
718 let options = SearchOptions {
720 query: query.to_string(),
721 limit,
722 repo: working_dir.map(|s| s.to_string()),
723 since,
724 role: role.map(|s| s.to_string()),
725 ..Default::default()
726 };
727
728 self.search_with_options(&options)
729 }
730
731 pub fn search_with_options(
745 &self,
746 options: &super::models::SearchOptions,
747 ) -> Result<Vec<SearchResult>> {
748 let escaped_query = escape_fts5_query(&options.query);
750
751 let mut sql = String::from(
754 r#"
755 SELECT
756 m.session_id,
757 m.id as message_id,
758 m.role,
759 snippet(messages_fts, 1, '**', '**', '...', 32) as snippet,
760 m.timestamp,
761 s.working_directory,
762 s.tool,
763 s.git_branch,
764 s.message_count,
765 s.started_at,
766 m.idx as message_index
767 FROM messages_fts fts
768 JOIN messages m ON fts.message_id = m.id
769 JOIN sessions s ON m.session_id = s.id
770 WHERE messages_fts MATCH ?1
771 "#,
772 );
773
774 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(escaped_query.clone())];
775 let mut param_idx = 2;
776
777 if options.repo.is_some() {
779 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
780 param_idx += 1;
781 }
782 if options.tool.is_some() {
783 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
784 param_idx += 1;
785 }
786 if options.since.is_some() {
787 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
788 param_idx += 1;
789 }
790 if options.until.is_some() {
791 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
792 param_idx += 1;
793 }
794 if options.project.is_some() {
795 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
796 param_idx += 1;
797 }
798 if options.branch.is_some() {
799 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
800 param_idx += 1;
801 }
802 if options.role.is_some() {
803 sql.push_str(&format!(" AND m.role = ?{param_idx}"));
804 param_idx += 1;
805 }
806
807 if let Some(ref wd) = options.repo {
809 params_vec.push(Box::new(format!("{wd}%")));
810 }
811 if let Some(ref tool) = options.tool {
812 params_vec.push(Box::new(tool.clone()));
813 }
814 if let Some(ts) = options.since {
815 params_vec.push(Box::new(ts.to_rfc3339()));
816 }
817 if let Some(ts) = options.until {
818 params_vec.push(Box::new(ts.to_rfc3339()));
819 }
820 if let Some(ref project) = options.project {
821 params_vec.push(Box::new(format!("%{project}%")));
822 }
823 if let Some(ref branch) = options.branch {
824 params_vec.push(Box::new(format!("%{branch}%")));
825 }
826 if let Some(ref role) = options.role {
827 params_vec.push(Box::new(role.clone()));
828 }
829
830 let include_metadata_search = options.role.is_none();
834 let metadata_query_pattern = format!("%{}%", options.query);
835
836 if include_metadata_search {
837 let meta_param1 = param_idx;
839 let meta_param2 = param_idx + 1;
840 let meta_param3 = param_idx + 2;
841 param_idx += 3;
842
843 sql.push_str(&format!(
844 r#"
845 UNION
846 SELECT
847 s.id as session_id,
848 (SELECT id FROM messages WHERE session_id = s.id ORDER BY idx LIMIT 1) as message_id,
849 'user' as role,
850 substr(s.tool || ' session in ' || s.working_directory || COALESCE(' on branch ' || s.git_branch, ''), 1, 100) as snippet,
851 s.started_at as timestamp,
852 s.working_directory,
853 s.tool,
854 s.git_branch,
855 s.message_count,
856 s.started_at,
857 0 as message_index
858 FROM sessions s
859 WHERE (
860 s.tool LIKE ?{meta_param1}
861 OR s.working_directory LIKE ?{meta_param2}
862 OR s.git_branch LIKE ?{meta_param3}
863 )
864 "#
865 ));
866
867 params_vec.push(Box::new(metadata_query_pattern.clone()));
869 params_vec.push(Box::new(metadata_query_pattern.clone()));
870 params_vec.push(Box::new(metadata_query_pattern));
871
872 if options.repo.is_some() {
874 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
875 params_vec.push(Box::new(format!("{}%", options.repo.as_ref().unwrap())));
876 param_idx += 1;
877 }
878 if options.tool.is_some() {
879 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
880 params_vec.push(Box::new(options.tool.as_ref().unwrap().clone()));
881 param_idx += 1;
882 }
883 if options.since.is_some() {
884 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
885 params_vec.push(Box::new(options.since.unwrap().to_rfc3339()));
886 param_idx += 1;
887 }
888 if options.until.is_some() {
889 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
890 params_vec.push(Box::new(options.until.unwrap().to_rfc3339()));
891 param_idx += 1;
892 }
893 if options.project.is_some() {
894 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
895 params_vec.push(Box::new(format!("%{}%", options.project.as_ref().unwrap())));
896 param_idx += 1;
897 }
898 if options.branch.is_some() {
899 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
900 params_vec.push(Box::new(format!("%{}%", options.branch.as_ref().unwrap())));
901 param_idx += 1;
902 }
903 }
904
905 sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{param_idx}"));
906 params_vec.push(Box::new(options.limit as i64));
907
908 let mut stmt = self.conn.prepare(&sql)?;
910 let params_refs: Vec<&dyn rusqlite::ToSql> =
911 params_vec.iter().map(|p| p.as_ref()).collect();
912
913 let rows = stmt.query_map(params_refs.as_slice(), |row| {
914 let role_str: String = row.get(2)?;
915 let git_branch: Option<String> = row.get(7)?;
916 let started_at_str: Option<String> = row.get(9)?;
917
918 Ok(SearchResult {
919 session_id: parse_uuid(&row.get::<_, String>(0)?)?,
920 message_id: parse_uuid(&row.get::<_, String>(1)?)?,
921 role: match role_str.as_str() {
922 "user" => MessageRole::User,
923 "assistant" => MessageRole::Assistant,
924 "system" => MessageRole::System,
925 _ => MessageRole::User,
926 },
927 snippet: row.get(3)?,
928 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
929 working_directory: row.get(5)?,
930 tool: row.get(6)?,
931 git_branch,
932 session_message_count: row.get(8)?,
933 session_started_at: started_at_str.map(|s| parse_datetime(&s)).transpose()?,
934 message_index: row.get(10)?,
935 })
936 })?;
937
938 rows.collect::<Result<Vec<_>, _>>()
939 .context("Failed to search messages")
940 }
941
942 pub fn get_context_messages(
947 &self,
948 session_id: &Uuid,
949 message_index: i32,
950 context_count: usize,
951 ) -> Result<(Vec<Message>, Vec<Message>)> {
952 let mut before_stmt = self.conn.prepare(
954 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
955 FROM messages
956 WHERE session_id = ?1 AND idx < ?2
957 ORDER BY idx DESC
958 LIMIT ?3",
959 )?;
960
961 let before_rows = before_stmt.query_map(
962 params![session_id.to_string(), message_index, context_count as i64],
963 Self::row_to_message,
964 )?;
965
966 let mut before: Vec<Message> = before_rows
967 .collect::<Result<Vec<_>, _>>()
968 .context("Failed to get before messages")?;
969 before.reverse(); let mut after_stmt = self.conn.prepare(
973 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
974 FROM messages
975 WHERE session_id = ?1 AND idx > ?2
976 ORDER BY idx ASC
977 LIMIT ?3",
978 )?;
979
980 let after_rows = after_stmt.query_map(
981 params![session_id.to_string(), message_index, context_count as i64],
982 Self::row_to_message,
983 )?;
984
985 let after: Vec<Message> = after_rows
986 .collect::<Result<Vec<_>, _>>()
987 .context("Failed to get after messages")?;
988
989 Ok((before, after))
990 }
991
992 #[allow(dead_code)]
994 pub fn get_message_by_index(&self, session_id: &Uuid, index: i32) -> Result<Option<Message>> {
995 self.conn
996 .query_row(
997 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
998 FROM messages
999 WHERE session_id = ?1 AND idx = ?2",
1000 params![session_id.to_string(), index],
1001 Self::row_to_message,
1002 )
1003 .optional()
1004 .context("Failed to get message by index")
1005 }
1006
1007 fn row_to_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
1008 let role_str: String = row.get(5)?;
1009 let content_str: String = row.get(6)?;
1010
1011 let parent_id_str: Option<String> = row.get(2)?;
1012 let parent_id = match parent_id_str {
1013 Some(s) => Some(parse_uuid(&s)?),
1014 None => None,
1015 };
1016
1017 Ok(Message {
1018 id: parse_uuid(&row.get::<_, String>(0)?)?,
1019 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1020 parent_id,
1021 index: row.get(3)?,
1022 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1023 role: match role_str.as_str() {
1024 "user" => MessageRole::User,
1025 "assistant" => MessageRole::Assistant,
1026 "system" => MessageRole::System,
1027 _ => MessageRole::User,
1028 },
1029 content: serde_json::from_str(&content_str)
1030 .unwrap_or(MessageContent::Text(content_str)),
1031 model: row.get(7)?,
1032 git_branch: row.get(8)?,
1033 cwd: row.get(9)?,
1034 })
1035 }
1036
1037 pub fn rebuild_search_index(&self) -> Result<usize> {
1045 self.conn.execute("DELETE FROM messages_fts", [])?;
1047 self.conn.execute("DELETE FROM sessions_fts", [])?;
1048
1049 let mut msg_stmt = self.conn.prepare("SELECT id, content FROM messages")?;
1051
1052 let rows = msg_stmt.query_map([], |row| {
1053 let id: String = row.get(0)?;
1054 let content_json: String = row.get(1)?;
1055 Ok((id, content_json))
1056 })?;
1057
1058 let mut count = 0;
1059 for row in rows {
1060 let (id, content_json) = row?;
1061 let content: MessageContent = serde_json::from_str(&content_json)
1063 .unwrap_or(MessageContent::Text(content_json.clone()));
1064 let text_content = content.text();
1065
1066 if !text_content.is_empty() {
1067 self.conn.execute(
1068 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
1069 params![id, text_content],
1070 )?;
1071 count += 1;
1072 }
1073 }
1074
1075 let mut session_stmt = self
1077 .conn
1078 .prepare("SELECT id, tool, working_directory, git_branch FROM sessions")?;
1079
1080 let session_rows = session_stmt.query_map([], |row| {
1081 let id: String = row.get(0)?;
1082 let tool: String = row.get(1)?;
1083 let working_directory: String = row.get(2)?;
1084 let git_branch: Option<String> = row.get(3)?;
1085 Ok((id, tool, working_directory, git_branch))
1086 })?;
1087
1088 for row in session_rows {
1089 let (id, tool, working_directory, git_branch) = row?;
1090 self.conn.execute(
1091 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
1092 params![id, tool, working_directory, git_branch.unwrap_or_default()],
1093 )?;
1094 }
1095
1096 Ok(count)
1097 }
1098
1099 pub fn search_index_needs_rebuild(&self) -> Result<bool> {
1104 let message_count: i32 =
1105 self.conn
1106 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1107
1108 let msg_fts_count: i32 =
1109 self.conn
1110 .query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
1111
1112 let session_count: i32 =
1113 self.conn
1114 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1115
1116 let session_fts_count: i32 =
1117 self.conn
1118 .query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
1119
1120 Ok((message_count > 0 && msg_fts_count == 0)
1122 || (session_count > 0 && session_fts_count == 0))
1123 }
1124
1125 pub fn session_count(&self) -> Result<i32> {
1129 let count: i32 = self
1130 .conn
1131 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1132 Ok(count)
1133 }
1134
1135 pub fn message_count(&self) -> Result<i32> {
1137 let count: i32 = self
1138 .conn
1139 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1140 Ok(count)
1141 }
1142
1143 pub fn link_count(&self) -> Result<i32> {
1145 let count: i32 = self
1146 .conn
1147 .query_row("SELECT COUNT(*) FROM session_links", [], |row| row.get(0))?;
1148 Ok(count)
1149 }
1150
1151 pub fn db_path(&self) -> Option<std::path::PathBuf> {
1155 self.conn.path().map(std::path::PathBuf::from)
1156 }
1157
1158 pub fn find_sessions_near_commit_time(
1175 &self,
1176 commit_time: chrono::DateTime<chrono::Utc>,
1177 window_minutes: i64,
1178 working_dir: Option<&str>,
1179 ) -> Result<Vec<Session>> {
1180 let commit_time_str = commit_time.to_rfc3339();
1182
1183 let window = chrono::Duration::minutes(window_minutes);
1185 let window_start = (commit_time - window).to_rfc3339();
1186 let window_end = (commit_time + window).to_rfc3339();
1187
1188 let sql = if working_dir.is_some() {
1189 r#"
1190 SELECT id, tool, tool_version, started_at, ended_at, model,
1191 working_directory, git_branch, source_path, message_count, machine_id
1192 FROM sessions
1193 WHERE working_directory LIKE ?1
1194 AND (
1195 -- Session started before or during the window
1196 (started_at <= ?3)
1197 AND
1198 -- Session ended after or during the window (or is still ongoing)
1199 (ended_at IS NULL OR ended_at >= ?2)
1200 )
1201 ORDER BY
1202 -- Order by how close the session end (or start) is to commit time
1203 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?4))
1204 "#
1205 } else {
1206 r#"
1207 SELECT id, tool, tool_version, started_at, ended_at, model,
1208 working_directory, git_branch, source_path, message_count, machine_id
1209 FROM sessions
1210 WHERE
1211 -- Session started before or during the window
1212 (started_at <= ?2)
1213 AND
1214 -- Session ended after or during the window (or is still ongoing)
1215 (ended_at IS NULL OR ended_at >= ?1)
1216 ORDER BY
1217 -- Order by how close the session end (or start) is to commit time
1218 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?3))
1219 "#
1220 };
1221
1222 let mut stmt = self.conn.prepare(sql)?;
1223
1224 let rows = if let Some(wd) = working_dir {
1225 stmt.query_map(
1226 params![format!("{wd}%"), window_start, window_end, commit_time_str],
1227 Self::row_to_session,
1228 )?
1229 } else {
1230 stmt.query_map(
1231 params![window_start, window_end, commit_time_str],
1232 Self::row_to_session,
1233 )?
1234 };
1235
1236 rows.collect::<Result<Vec<_>, _>>()
1237 .context("Failed to find sessions near commit time")
1238 }
1239
1240 pub fn link_exists(&self, session_id: &Uuid, commit_sha: &str) -> Result<bool> {
1244 let pattern = format!("{commit_sha}%");
1245 let count: i32 = self.conn.query_row(
1246 "SELECT COUNT(*) FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1247 params![session_id.to_string(), pattern],
1248 |row| row.get(0),
1249 )?;
1250 Ok(count > 0)
1251 }
1252
1253 pub fn delete_session(&self, session_id: &Uuid) -> Result<(usize, usize)> {
1264 let session_id_str = session_id.to_string();
1265
1266 self.conn.execute(
1268 "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1269 params![session_id_str],
1270 )?;
1271
1272 let messages_deleted = self.conn.execute(
1274 "DELETE FROM messages WHERE session_id = ?1",
1275 params![session_id_str],
1276 )?;
1277
1278 let links_deleted = self.conn.execute(
1280 "DELETE FROM session_links WHERE session_id = ?1",
1281 params![session_id_str],
1282 )?;
1283
1284 self.conn.execute(
1286 "DELETE FROM annotations WHERE session_id = ?1",
1287 params![session_id_str],
1288 )?;
1289
1290 self.conn.execute(
1292 "DELETE FROM tags WHERE session_id = ?1",
1293 params![session_id_str],
1294 )?;
1295
1296 self.conn.execute(
1298 "DELETE FROM summaries WHERE session_id = ?1",
1299 params![session_id_str],
1300 )?;
1301
1302 self.conn.execute(
1304 "DELETE FROM sessions_fts WHERE session_id = ?1",
1305 params![session_id_str],
1306 )?;
1307
1308 self.conn.execute(
1310 "DELETE FROM sessions WHERE id = ?1",
1311 params![session_id_str],
1312 )?;
1313
1314 Ok((messages_deleted, links_deleted))
1315 }
1316
1317 pub fn insert_annotation(&self, annotation: &Annotation) -> Result<()> {
1323 self.conn.execute(
1324 r#"
1325 INSERT INTO annotations (id, session_id, content, created_at)
1326 VALUES (?1, ?2, ?3, ?4)
1327 "#,
1328 params![
1329 annotation.id.to_string(),
1330 annotation.session_id.to_string(),
1331 annotation.content,
1332 annotation.created_at.to_rfc3339(),
1333 ],
1334 )?;
1335 Ok(())
1336 }
1337
1338 #[allow(dead_code)]
1342 pub fn get_annotations(&self, session_id: &Uuid) -> Result<Vec<Annotation>> {
1343 let mut stmt = self.conn.prepare(
1344 "SELECT id, session_id, content, created_at
1345 FROM annotations
1346 WHERE session_id = ?1
1347 ORDER BY created_at ASC",
1348 )?;
1349
1350 let rows = stmt.query_map(params![session_id.to_string()], |row| {
1351 Ok(Annotation {
1352 id: parse_uuid(&row.get::<_, String>(0)?)?,
1353 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1354 content: row.get(2)?,
1355 created_at: parse_datetime(&row.get::<_, String>(3)?)?,
1356 })
1357 })?;
1358
1359 rows.collect::<Result<Vec<_>, _>>()
1360 .context("Failed to get annotations")
1361 }
1362
1363 #[allow(dead_code)]
1367 pub fn delete_annotation(&self, annotation_id: &Uuid) -> Result<bool> {
1368 let rows_affected = self.conn.execute(
1369 "DELETE FROM annotations WHERE id = ?1",
1370 params![annotation_id.to_string()],
1371 )?;
1372 Ok(rows_affected > 0)
1373 }
1374
1375 #[allow(dead_code)]
1379 pub fn delete_annotations_by_session(&self, session_id: &Uuid) -> Result<usize> {
1380 let rows_affected = self.conn.execute(
1381 "DELETE FROM annotations WHERE session_id = ?1",
1382 params![session_id.to_string()],
1383 )?;
1384 Ok(rows_affected)
1385 }
1386
1387 pub fn insert_tag(&self, tag: &Tag) -> Result<()> {
1394 self.conn.execute(
1395 r#"
1396 INSERT INTO tags (id, session_id, label, created_at)
1397 VALUES (?1, ?2, ?3, ?4)
1398 "#,
1399 params![
1400 tag.id.to_string(),
1401 tag.session_id.to_string(),
1402 tag.label,
1403 tag.created_at.to_rfc3339(),
1404 ],
1405 )?;
1406 Ok(())
1407 }
1408
1409 pub fn get_tags(&self, session_id: &Uuid) -> Result<Vec<Tag>> {
1413 let mut stmt = self.conn.prepare(
1414 "SELECT id, session_id, label, created_at
1415 FROM tags
1416 WHERE session_id = ?1
1417 ORDER BY label ASC",
1418 )?;
1419
1420 let rows = stmt.query_map(params![session_id.to_string()], |row| {
1421 Ok(Tag {
1422 id: parse_uuid(&row.get::<_, String>(0)?)?,
1423 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1424 label: row.get(2)?,
1425 created_at: parse_datetime(&row.get::<_, String>(3)?)?,
1426 })
1427 })?;
1428
1429 rows.collect::<Result<Vec<_>, _>>()
1430 .context("Failed to get tags")
1431 }
1432
1433 pub fn tag_exists(&self, session_id: &Uuid, label: &str) -> Result<bool> {
1435 let count: i32 = self.conn.query_row(
1436 "SELECT COUNT(*) FROM tags WHERE session_id = ?1 AND label = ?2",
1437 params![session_id.to_string(), label],
1438 |row| row.get(0),
1439 )?;
1440 Ok(count > 0)
1441 }
1442
1443 pub fn delete_tag(&self, session_id: &Uuid, label: &str) -> Result<bool> {
1447 let rows_affected = self.conn.execute(
1448 "DELETE FROM tags WHERE session_id = ?1 AND label = ?2",
1449 params![session_id.to_string(), label],
1450 )?;
1451 Ok(rows_affected > 0)
1452 }
1453
1454 #[allow(dead_code)]
1458 pub fn delete_tags_by_session(&self, session_id: &Uuid) -> Result<usize> {
1459 let rows_affected = self.conn.execute(
1460 "DELETE FROM tags WHERE session_id = ?1",
1461 params![session_id.to_string()],
1462 )?;
1463 Ok(rows_affected)
1464 }
1465
1466 pub fn list_sessions_with_tag(&self, label: &str, limit: usize) -> Result<Vec<Session>> {
1470 let mut stmt = self.conn.prepare(
1471 "SELECT s.id, s.tool, s.tool_version, s.started_at, s.ended_at, s.model,
1472 s.working_directory, s.git_branch, s.source_path, s.message_count, s.machine_id
1473 FROM sessions s
1474 INNER JOIN tags t ON s.id = t.session_id
1475 WHERE t.label = ?1
1476 ORDER BY s.started_at DESC
1477 LIMIT ?2",
1478 )?;
1479
1480 let rows = stmt.query_map(params![label, limit], Self::row_to_session)?;
1481
1482 rows.collect::<Result<Vec<_>, _>>()
1483 .context("Failed to list sessions with tag")
1484 }
1485
1486 pub fn insert_summary(&self, summary: &Summary) -> Result<()> {
1493 self.conn.execute(
1494 r#"
1495 INSERT INTO summaries (id, session_id, content, generated_at)
1496 VALUES (?1, ?2, ?3, ?4)
1497 "#,
1498 params![
1499 summary.id.to_string(),
1500 summary.session_id.to_string(),
1501 summary.content,
1502 summary.generated_at.to_rfc3339(),
1503 ],
1504 )?;
1505 Ok(())
1506 }
1507
1508 pub fn get_summary(&self, session_id: &Uuid) -> Result<Option<Summary>> {
1510 self.conn
1511 .query_row(
1512 "SELECT id, session_id, content, generated_at
1513 FROM summaries
1514 WHERE session_id = ?1",
1515 params![session_id.to_string()],
1516 |row| {
1517 Ok(Summary {
1518 id: parse_uuid(&row.get::<_, String>(0)?)?,
1519 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1520 content: row.get(2)?,
1521 generated_at: parse_datetime(&row.get::<_, String>(3)?)?,
1522 })
1523 },
1524 )
1525 .optional()
1526 .context("Failed to get summary")
1527 }
1528
1529 pub fn update_summary(&self, session_id: &Uuid, content: &str) -> Result<bool> {
1534 let now = chrono::Utc::now().to_rfc3339();
1535 let rows_affected = self.conn.execute(
1536 "UPDATE summaries SET content = ?1, generated_at = ?2 WHERE session_id = ?3",
1537 params![content, now, session_id.to_string()],
1538 )?;
1539 Ok(rows_affected > 0)
1540 }
1541
1542 #[allow(dead_code)]
1546 pub fn delete_summary(&self, session_id: &Uuid) -> Result<bool> {
1547 let rows_affected = self.conn.execute(
1548 "DELETE FROM summaries WHERE session_id = ?1",
1549 params![session_id.to_string()],
1550 )?;
1551 Ok(rows_affected > 0)
1552 }
1553
1554 pub fn upsert_machine(&self, machine: &Machine) -> Result<()> {
1561 self.conn.execute(
1562 r#"
1563 INSERT INTO machines (id, name, created_at)
1564 VALUES (?1, ?2, ?3)
1565 ON CONFLICT(id) DO UPDATE SET
1566 name = ?2
1567 "#,
1568 params![machine.id, machine.name, machine.created_at],
1569 )?;
1570 Ok(())
1571 }
1572
1573 #[allow(dead_code)]
1577 pub fn get_machine(&self, id: &str) -> Result<Option<Machine>> {
1578 self.conn
1579 .query_row(
1580 "SELECT id, name, created_at FROM machines WHERE id = ?1",
1581 params![id],
1582 |row| {
1583 Ok(Machine {
1584 id: row.get(0)?,
1585 name: row.get(1)?,
1586 created_at: row.get(2)?,
1587 })
1588 },
1589 )
1590 .optional()
1591 .context("Failed to get machine")
1592 }
1593
1594 #[allow(dead_code)]
1599 pub fn get_machine_name(&self, id: &str) -> Result<String> {
1600 if let Some(machine) = self.get_machine(id)? {
1601 Ok(machine.name)
1602 } else {
1603 if id.len() > 8 {
1605 Ok(id[..8].to_string())
1606 } else {
1607 Ok(id.to_string())
1608 }
1609 }
1610 }
1611
1612 #[allow(dead_code)]
1616 pub fn list_machines(&self) -> Result<Vec<Machine>> {
1617 let mut stmt = self
1618 .conn
1619 .prepare("SELECT id, name, created_at FROM machines ORDER BY created_at ASC")?;
1620
1621 let rows = stmt.query_map([], |row| {
1622 Ok(Machine {
1623 id: row.get(0)?,
1624 name: row.get(1)?,
1625 created_at: row.get(2)?,
1626 })
1627 })?;
1628
1629 rows.collect::<Result<Vec<_>, _>>()
1630 .context("Failed to list machines")
1631 }
1632
1633 pub fn get_most_recent_session_for_directory(
1638 &self,
1639 working_dir: &str,
1640 ) -> Result<Option<Session>> {
1641 self.conn
1642 .query_row(
1643 "SELECT id, tool, tool_version, started_at, ended_at, model,
1644 working_directory, git_branch, source_path, message_count, machine_id
1645 FROM sessions
1646 WHERE working_directory LIKE ?1
1647 ORDER BY started_at DESC
1648 LIMIT 1",
1649 params![format!("{working_dir}%")],
1650 Self::row_to_session,
1651 )
1652 .optional()
1653 .context("Failed to get most recent session for directory")
1654 }
1655
1656 pub fn vacuum(&self) -> Result<()> {
1663 self.conn.execute("VACUUM", [])?;
1664 Ok(())
1665 }
1666
1667 pub fn file_size(&self) -> Result<Option<u64>> {
1671 if let Some(path) = self.db_path() {
1672 let metadata = std::fs::metadata(&path)?;
1673 Ok(Some(metadata.len()))
1674 } else {
1675 Ok(None)
1676 }
1677 }
1678
1679 pub fn delete_sessions_older_than(&self, before: DateTime<Utc>) -> Result<usize> {
1691 let before_str = before.to_rfc3339();
1692
1693 let mut stmt = self
1695 .conn
1696 .prepare("SELECT id FROM sessions WHERE started_at < ?1")?;
1697 let session_ids: Vec<String> = stmt
1698 .query_map(params![before_str], |row| row.get(0))?
1699 .collect::<Result<Vec<_>, _>>()?;
1700
1701 if session_ids.is_empty() {
1702 return Ok(0);
1703 }
1704
1705 let count = session_ids.len();
1706
1707 for session_id_str in &session_ids {
1709 self.conn.execute(
1711 "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1712 params![session_id_str],
1713 )?;
1714
1715 self.conn.execute(
1717 "DELETE FROM messages WHERE session_id = ?1",
1718 params![session_id_str],
1719 )?;
1720
1721 self.conn.execute(
1723 "DELETE FROM session_links WHERE session_id = ?1",
1724 params![session_id_str],
1725 )?;
1726
1727 self.conn.execute(
1729 "DELETE FROM annotations WHERE session_id = ?1",
1730 params![session_id_str],
1731 )?;
1732
1733 self.conn.execute(
1735 "DELETE FROM tags WHERE session_id = ?1",
1736 params![session_id_str],
1737 )?;
1738
1739 self.conn.execute(
1741 "DELETE FROM summaries WHERE session_id = ?1",
1742 params![session_id_str],
1743 )?;
1744
1745 self.conn.execute(
1747 "DELETE FROM sessions_fts WHERE session_id = ?1",
1748 params![session_id_str],
1749 )?;
1750 }
1751
1752 self.conn.execute(
1754 "DELETE FROM sessions WHERE started_at < ?1",
1755 params![before_str],
1756 )?;
1757
1758 Ok(count)
1759 }
1760
1761 pub fn count_sessions_older_than(&self, before: DateTime<Utc>) -> Result<i32> {
1771 let before_str = before.to_rfc3339();
1772 let count: i32 = self.conn.query_row(
1773 "SELECT COUNT(*) FROM sessions WHERE started_at < ?1",
1774 params![before_str],
1775 |row| row.get(0),
1776 )?;
1777 Ok(count)
1778 }
1779
1780 pub fn get_sessions_older_than(&self, before: DateTime<Utc>) -> Result<Vec<Session>> {
1790 let before_str = before.to_rfc3339();
1791 let mut stmt = self.conn.prepare(
1792 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
1793 FROM sessions
1794 WHERE started_at < ?1
1795 ORDER BY started_at ASC",
1796 )?;
1797
1798 let rows = stmt.query_map(params![before_str], Self::row_to_session)?;
1799
1800 rows.collect::<Result<Vec<_>, _>>()
1801 .context("Failed to get sessions older than cutoff")
1802 }
1803
1804 pub fn stats(&self) -> Result<DatabaseStats> {
1811 let session_count = self.session_count()?;
1812 let message_count = self.message_count()?;
1813 let link_count = self.link_count()?;
1814
1815 let oldest: Option<String> = self
1817 .conn
1818 .query_row("SELECT MIN(started_at) FROM sessions", [], |row| row.get(0))
1819 .optional()?
1820 .flatten();
1821
1822 let newest: Option<String> = self
1823 .conn
1824 .query_row("SELECT MAX(started_at) FROM sessions", [], |row| row.get(0))
1825 .optional()?
1826 .flatten();
1827
1828 let oldest_session = oldest
1829 .map(|s| parse_datetime(&s))
1830 .transpose()
1831 .unwrap_or(None);
1832 let newest_session = newest
1833 .map(|s| parse_datetime(&s))
1834 .transpose()
1835 .unwrap_or(None);
1836
1837 let mut stmt = self
1839 .conn
1840 .prepare("SELECT tool, COUNT(*) FROM sessions GROUP BY tool ORDER BY COUNT(*) DESC")?;
1841 let sessions_by_tool: Vec<(String, i32)> = stmt
1842 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1843 .collect::<Result<Vec<_>, _>>()?;
1844
1845 Ok(DatabaseStats {
1846 session_count,
1847 message_count,
1848 link_count,
1849 oldest_session,
1850 newest_session,
1851 sessions_by_tool,
1852 })
1853 }
1854}
1855
1856#[derive(Debug, Clone)]
1858pub struct DatabaseStats {
1859 pub session_count: i32,
1861 pub message_count: i32,
1863 pub link_count: i32,
1865 pub oldest_session: Option<DateTime<Utc>>,
1867 pub newest_session: Option<DateTime<Utc>>,
1869 pub sessions_by_tool: Vec<(String, i32)>,
1871}
1872
1873#[cfg(test)]
1874mod tests {
1875 use super::*;
1876 use crate::storage::models::{
1877 LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
1878 };
1879 use chrono::{Duration, Utc};
1880 use tempfile::tempdir;
1881
1882 fn create_test_db() -> (Database, tempfile::TempDir) {
1885 let dir = tempdir().expect("Failed to create temp directory");
1886 let db_path = dir.path().join("test.db");
1887 let db = Database::open(&db_path).expect("Failed to open test database");
1888 (db, dir)
1889 }
1890
1891 fn create_test_session(
1893 tool: &str,
1894 working_directory: &str,
1895 started_at: chrono::DateTime<Utc>,
1896 source_path: Option<&str>,
1897 ) -> Session {
1898 Session {
1899 id: Uuid::new_v4(),
1900 tool: tool.to_string(),
1901 tool_version: Some("1.0.0".to_string()),
1902 started_at,
1903 ended_at: None,
1904 model: Some("test-model".to_string()),
1905 working_directory: working_directory.to_string(),
1906 git_branch: Some("main".to_string()),
1907 source_path: source_path.map(|s| s.to_string()),
1908 message_count: 0,
1909 machine_id: Some("test-machine".to_string()),
1910 }
1911 }
1912
1913 fn create_test_message(
1915 session_id: Uuid,
1916 index: i32,
1917 role: MessageRole,
1918 content: &str,
1919 ) -> Message {
1920 Message {
1921 id: Uuid::new_v4(),
1922 session_id,
1923 parent_id: None,
1924 index,
1925 timestamp: Utc::now(),
1926 role,
1927 content: MessageContent::Text(content.to_string()),
1928 model: Some("test-model".to_string()),
1929 git_branch: Some("main".to_string()),
1930 cwd: Some("/test/cwd".to_string()),
1931 }
1932 }
1933
1934 fn create_test_link(
1936 session_id: Uuid,
1937 commit_sha: Option<&str>,
1938 link_type: LinkType,
1939 ) -> SessionLink {
1940 SessionLink {
1941 id: Uuid::new_v4(),
1942 session_id,
1943 link_type,
1944 commit_sha: commit_sha.map(|s| s.to_string()),
1945 branch: Some("main".to_string()),
1946 remote: Some("origin".to_string()),
1947 created_at: Utc::now(),
1948 created_by: LinkCreator::Auto,
1949 confidence: Some(0.95),
1950 }
1951 }
1952
1953 #[test]
1956 fn test_insert_and_get_session() {
1957 let (db, _dir) = create_test_db();
1958 let session = create_test_session(
1959 "claude-code",
1960 "/home/user/project",
1961 Utc::now(),
1962 Some("/path/to/source.jsonl"),
1963 );
1964
1965 db.insert_session(&session)
1966 .expect("Failed to insert session");
1967
1968 let retrieved = db
1969 .get_session(&session.id)
1970 .expect("Failed to get session")
1971 .expect("Session should exist");
1972
1973 assert_eq!(retrieved.id, session.id, "Session ID should match");
1974 assert_eq!(retrieved.tool, session.tool, "Tool should match");
1975 assert_eq!(
1976 retrieved.tool_version, session.tool_version,
1977 "Tool version should match"
1978 );
1979 assert_eq!(
1980 retrieved.working_directory, session.working_directory,
1981 "Working directory should match"
1982 );
1983 assert_eq!(
1984 retrieved.git_branch, session.git_branch,
1985 "Git branch should match"
1986 );
1987 assert_eq!(
1988 retrieved.source_path, session.source_path,
1989 "Source path should match"
1990 );
1991 }
1992
1993 #[test]
1994 fn test_list_sessions() {
1995 let (db, _dir) = create_test_db();
1996 let now = Utc::now();
1997
1998 let session1 =
2000 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
2001 let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
2002 let session3 = create_test_session("claude-code", "/project3", now, None);
2003
2004 db.insert_session(&session1)
2005 .expect("Failed to insert session1");
2006 db.insert_session(&session2)
2007 .expect("Failed to insert session2");
2008 db.insert_session(&session3)
2009 .expect("Failed to insert session3");
2010
2011 let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
2012
2013 assert_eq!(sessions.len(), 3, "Should have 3 sessions");
2014 assert_eq!(
2016 sessions[0].id, session3.id,
2017 "Most recent session should be first"
2018 );
2019 assert_eq!(
2020 sessions[1].id, session2.id,
2021 "Second most recent session should be second"
2022 );
2023 assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
2024 }
2025
2026 #[test]
2027 fn test_list_sessions_with_working_dir_filter() {
2028 let (db, _dir) = create_test_db();
2029 let now = Utc::now();
2030
2031 let session1 = create_test_session(
2032 "claude-code",
2033 "/home/user/project-a",
2034 now - Duration::hours(1),
2035 None,
2036 );
2037 let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
2038 let session3 = create_test_session("claude-code", "/other/path", now, None);
2039
2040 db.insert_session(&session1)
2041 .expect("Failed to insert session1");
2042 db.insert_session(&session2)
2043 .expect("Failed to insert session2");
2044 db.insert_session(&session3)
2045 .expect("Failed to insert session3");
2046
2047 let sessions = db
2049 .list_sessions(10, Some("/home/user"))
2050 .expect("Failed to list sessions");
2051
2052 assert_eq!(
2053 sessions.len(),
2054 2,
2055 "Should have 2 sessions matching /home/user prefix"
2056 );
2057
2058 let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
2060 assert!(ids.contains(&session1.id), "Should contain session1");
2061 assert!(ids.contains(&session2.id), "Should contain session2");
2062 assert!(!ids.contains(&session3.id), "Should not contain session3");
2063 }
2064
2065 #[test]
2066 fn test_session_exists_by_source() {
2067 let (db, _dir) = create_test_db();
2068 let source_path = "/path/to/session.jsonl";
2069
2070 let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
2071
2072 assert!(
2074 !db.session_exists_by_source(source_path)
2075 .expect("Failed to check existence"),
2076 "Session should not exist before insert"
2077 );
2078
2079 db.insert_session(&session)
2080 .expect("Failed to insert session");
2081
2082 assert!(
2084 db.session_exists_by_source(source_path)
2085 .expect("Failed to check existence"),
2086 "Session should exist after insert"
2087 );
2088
2089 assert!(
2091 !db.session_exists_by_source("/other/path.jsonl")
2092 .expect("Failed to check existence"),
2093 "Different source path should not exist"
2094 );
2095 }
2096
2097 #[test]
2098 fn test_update_session_branch() {
2099 let (db, _dir) = create_test_db();
2100 let now = Utc::now();
2101
2102 let mut session = create_test_session("claude-code", "/project", now, None);
2104 session.git_branch = Some("main".to_string());
2105
2106 db.insert_session(&session)
2107 .expect("Failed to insert session");
2108
2109 let fetched = db
2111 .get_session(&session.id)
2112 .expect("Failed to get session")
2113 .expect("Session should exist");
2114 assert_eq!(fetched.git_branch, Some("main".to_string()));
2115
2116 let rows = db
2118 .update_session_branch(session.id, "feature-branch")
2119 .expect("Failed to update branch");
2120 assert_eq!(rows, 1, "Should update exactly one row");
2121
2122 let fetched = db
2124 .get_session(&session.id)
2125 .expect("Failed to get session")
2126 .expect("Session should exist");
2127 assert_eq!(fetched.git_branch, Some("feature-branch".to_string()));
2128 }
2129
2130 #[test]
2131 fn test_update_session_branch_nonexistent() {
2132 let (db, _dir) = create_test_db();
2133 let nonexistent_id = Uuid::new_v4();
2134
2135 let rows = db
2137 .update_session_branch(nonexistent_id, "some-branch")
2138 .expect("Failed to update branch");
2139 assert_eq!(
2140 rows, 0,
2141 "Should not update any rows for nonexistent session"
2142 );
2143 }
2144
2145 #[test]
2146 fn test_update_session_branch_from_none() {
2147 let (db, _dir) = create_test_db();
2148 let now = Utc::now();
2149
2150 let mut session = create_test_session("claude-code", "/project", now, None);
2152 session.git_branch = None; db.insert_session(&session)
2155 .expect("Failed to insert session");
2156
2157 let fetched = db
2159 .get_session(&session.id)
2160 .expect("Failed to get session")
2161 .expect("Session should exist");
2162 assert_eq!(fetched.git_branch, None);
2163
2164 let rows = db
2166 .update_session_branch(session.id, "new-branch")
2167 .expect("Failed to update branch");
2168 assert_eq!(rows, 1, "Should update exactly one row");
2169
2170 let fetched = db
2172 .get_session(&session.id)
2173 .expect("Failed to get session")
2174 .expect("Session should exist");
2175 assert_eq!(fetched.git_branch, Some("new-branch".to_string()));
2176 }
2177
2178 #[test]
2179 fn test_get_nonexistent_session() {
2180 let (db, _dir) = create_test_db();
2181 let nonexistent_id = Uuid::new_v4();
2182
2183 let result = db
2184 .get_session(&nonexistent_id)
2185 .expect("Failed to query for nonexistent session");
2186
2187 assert!(
2188 result.is_none(),
2189 "Should return None for nonexistent session"
2190 );
2191 }
2192
2193 #[test]
2196 fn test_insert_and_get_messages() {
2197 let (db, _dir) = create_test_db();
2198
2199 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2200 db.insert_session(&session)
2201 .expect("Failed to insert session");
2202
2203 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
2204 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
2205
2206 db.insert_message(&msg1)
2207 .expect("Failed to insert message 1");
2208 db.insert_message(&msg2)
2209 .expect("Failed to insert message 2");
2210
2211 let messages = db
2212 .get_messages(&session.id)
2213 .expect("Failed to get messages");
2214
2215 assert_eq!(messages.len(), 2, "Should have 2 messages");
2216 assert_eq!(messages[0].id, msg1.id, "First message ID should match");
2217 assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
2218 assert_eq!(
2219 messages[0].role,
2220 MessageRole::User,
2221 "First message role should be User"
2222 );
2223 assert_eq!(
2224 messages[1].role,
2225 MessageRole::Assistant,
2226 "Second message role should be Assistant"
2227 );
2228 }
2229
2230 #[test]
2231 fn test_messages_ordered_by_index() {
2232 let (db, _dir) = create_test_db();
2233
2234 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2235 db.insert_session(&session)
2236 .expect("Failed to insert session");
2237
2238 let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
2240 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
2241 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
2242
2243 db.insert_message(&msg3)
2244 .expect("Failed to insert message 3");
2245 db.insert_message(&msg1)
2246 .expect("Failed to insert message 1");
2247 db.insert_message(&msg2)
2248 .expect("Failed to insert message 2");
2249
2250 let messages = db
2251 .get_messages(&session.id)
2252 .expect("Failed to get messages");
2253
2254 assert_eq!(messages.len(), 3, "Should have 3 messages");
2255 assert_eq!(messages[0].index, 0, "First message should have index 0");
2256 assert_eq!(messages[1].index, 1, "Second message should have index 1");
2257 assert_eq!(messages[2].index, 2, "Third message should have index 2");
2258
2259 assert_eq!(
2261 messages[0].content.text(),
2262 "First",
2263 "First message content should be 'First'"
2264 );
2265 assert_eq!(
2266 messages[1].content.text(),
2267 "Second",
2268 "Second message content should be 'Second'"
2269 );
2270 assert_eq!(
2271 messages[2].content.text(),
2272 "Third",
2273 "Third message content should be 'Third'"
2274 );
2275 }
2276
2277 #[test]
2280 fn test_insert_and_get_links_by_session() {
2281 let (db, _dir) = create_test_db();
2282
2283 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2284 db.insert_session(&session)
2285 .expect("Failed to insert session");
2286
2287 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2288 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2289
2290 db.insert_link(&link1).expect("Failed to insert link 1");
2291 db.insert_link(&link2).expect("Failed to insert link 2");
2292
2293 let links = db
2294 .get_links_by_session(&session.id)
2295 .expect("Failed to get links");
2296
2297 assert_eq!(links.len(), 2, "Should have 2 links");
2298
2299 let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
2300 assert!(link_ids.contains(&link1.id), "Should contain link1");
2301 assert!(link_ids.contains(&link2.id), "Should contain link2");
2302
2303 let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
2305 assert_eq!(
2306 retrieved_link.commit_sha,
2307 Some("abc123def456".to_string()),
2308 "Commit SHA should match"
2309 );
2310 assert_eq!(
2311 retrieved_link.link_type,
2312 LinkType::Commit,
2313 "Link type should be Commit"
2314 );
2315 assert_eq!(
2316 retrieved_link.created_by,
2317 LinkCreator::Auto,
2318 "Created by should be Auto"
2319 );
2320 }
2321
2322 #[test]
2323 fn test_get_links_by_commit() {
2324 let (db, _dir) = create_test_db();
2325
2326 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2327 db.insert_session(&session)
2328 .expect("Failed to insert session");
2329
2330 let full_sha = "abc123def456789012345678901234567890abcd";
2331 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
2332 db.insert_link(&link).expect("Failed to insert link");
2333
2334 let links_full = db
2336 .get_links_by_commit(full_sha)
2337 .expect("Failed to get links by full SHA");
2338 assert_eq!(links_full.len(), 1, "Should find link by full SHA");
2339 assert_eq!(links_full[0].id, link.id, "Link ID should match");
2340
2341 let links_partial = db
2343 .get_links_by_commit("abc123")
2344 .expect("Failed to get links by partial SHA");
2345 assert_eq!(
2346 links_partial.len(),
2347 1,
2348 "Should find link by partial SHA prefix"
2349 );
2350 assert_eq!(links_partial[0].id, link.id, "Link ID should match");
2351
2352 let links_none = db
2354 .get_links_by_commit("zzz999")
2355 .expect("Failed to get links by non-matching SHA");
2356 assert_eq!(
2357 links_none.len(),
2358 0,
2359 "Should not find link with non-matching SHA"
2360 );
2361 }
2362
2363 #[test]
2366 fn test_database_creation() {
2367 let dir = tempdir().expect("Failed to create temp directory");
2368 let db_path = dir.path().join("new_test.db");
2369
2370 assert!(
2372 !db_path.exists(),
2373 "Database file should not exist before creation"
2374 );
2375
2376 let db = Database::open(&db_path).expect("Failed to create database");
2377
2378 assert!(
2380 db_path.exists(),
2381 "Database file should exist after creation"
2382 );
2383
2384 let session_count = db.session_count().expect("Failed to get session count");
2386 assert_eq!(session_count, 0, "New database should have 0 sessions");
2387
2388 let message_count = db.message_count().expect("Failed to get message count");
2389 assert_eq!(message_count, 0, "New database should have 0 messages");
2390 }
2391
2392 #[test]
2393 fn test_session_count() {
2394 let (db, _dir) = create_test_db();
2395
2396 assert_eq!(
2397 db.session_count().expect("Failed to get count"),
2398 0,
2399 "Initial session count should be 0"
2400 );
2401
2402 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2403 db.insert_session(&session1)
2404 .expect("Failed to insert session1");
2405
2406 assert_eq!(
2407 db.session_count().expect("Failed to get count"),
2408 1,
2409 "Session count should be 1 after first insert"
2410 );
2411
2412 let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
2413 db.insert_session(&session2)
2414 .expect("Failed to insert session2");
2415
2416 assert_eq!(
2417 db.session_count().expect("Failed to get count"),
2418 2,
2419 "Session count should be 2 after second insert"
2420 );
2421 }
2422
2423 #[test]
2424 fn test_message_count() {
2425 let (db, _dir) = create_test_db();
2426
2427 assert_eq!(
2428 db.message_count().expect("Failed to get count"),
2429 0,
2430 "Initial message count should be 0"
2431 );
2432
2433 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2434 db.insert_session(&session)
2435 .expect("Failed to insert session");
2436
2437 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
2438 db.insert_message(&msg1).expect("Failed to insert message1");
2439
2440 assert_eq!(
2441 db.message_count().expect("Failed to get count"),
2442 1,
2443 "Message count should be 1 after first insert"
2444 );
2445
2446 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
2447 let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
2448 db.insert_message(&msg2).expect("Failed to insert message2");
2449 db.insert_message(&msg3).expect("Failed to insert message3");
2450
2451 assert_eq!(
2452 db.message_count().expect("Failed to get count"),
2453 3,
2454 "Message count should be 3 after all inserts"
2455 );
2456 }
2457
2458 #[test]
2459 fn test_link_count() {
2460 let (db, _dir) = create_test_db();
2461
2462 assert_eq!(
2463 db.link_count().expect("Failed to get count"),
2464 0,
2465 "Initial link count should be 0"
2466 );
2467
2468 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2469 db.insert_session(&session)
2470 .expect("Failed to insert session");
2471
2472 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2473 db.insert_link(&link1).expect("Failed to insert link1");
2474
2475 assert_eq!(
2476 db.link_count().expect("Failed to get count"),
2477 1,
2478 "Link count should be 1 after first insert"
2479 );
2480
2481 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2482 db.insert_link(&link2).expect("Failed to insert link2");
2483
2484 assert_eq!(
2485 db.link_count().expect("Failed to get count"),
2486 2,
2487 "Link count should be 2 after second insert"
2488 );
2489 }
2490
2491 #[test]
2492 fn test_db_path() {
2493 let dir = tempdir().expect("Failed to create temp directory");
2494 let db_path = dir.path().join("test.db");
2495 let db = Database::open(&db_path).expect("Failed to open test database");
2496
2497 let retrieved_path = db.db_path();
2498 assert!(
2499 retrieved_path.is_some(),
2500 "Database path should be available"
2501 );
2502
2503 let expected = db_path.canonicalize().unwrap_or(db_path);
2505 let actual = retrieved_path.unwrap();
2506 let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
2507
2508 assert_eq!(
2509 actual_canonical, expected,
2510 "Database path should match (after canonicalization)"
2511 );
2512 }
2513
2514 #[test]
2517 fn test_search_messages_basic() {
2518 let (db, _dir) = create_test_db();
2519
2520 let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
2521 db.insert_session(&session)
2522 .expect("Failed to insert session");
2523
2524 let msg1 = create_test_message(
2525 session.id,
2526 0,
2527 MessageRole::User,
2528 "How do I implement error handling in Rust?",
2529 );
2530 let msg2 = create_test_message(
2531 session.id,
2532 1,
2533 MessageRole::Assistant,
2534 "You can use Result types for error handling. The anyhow crate is also helpful.",
2535 );
2536
2537 db.insert_message(&msg1)
2538 .expect("Failed to insert message 1");
2539 db.insert_message(&msg2)
2540 .expect("Failed to insert message 2");
2541
2542 let results = db
2544 .search_messages("error", 10, None, None, None)
2545 .expect("Failed to search");
2546
2547 assert_eq!(
2548 results.len(),
2549 2,
2550 "Should find 2 messages containing 'error'"
2551 );
2552 }
2553
2554 #[test]
2555 fn test_search_messages_no_results() {
2556 let (db, _dir) = create_test_db();
2557
2558 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2559 db.insert_session(&session)
2560 .expect("Failed to insert session");
2561
2562 let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
2563 db.insert_message(&msg).expect("Failed to insert message");
2564
2565 let results = db
2567 .search_messages("nonexistent_term_xyz", 10, None, None, None)
2568 .expect("Failed to search");
2569
2570 assert!(results.is_empty(), "Should find no results");
2571 }
2572
2573 #[test]
2574 fn test_search_messages_with_role_filter() {
2575 let (db, _dir) = create_test_db();
2576
2577 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2578 db.insert_session(&session)
2579 .expect("Failed to insert session");
2580
2581 let msg1 = create_test_message(
2582 session.id,
2583 0,
2584 MessageRole::User,
2585 "Tell me about Rust programming",
2586 );
2587 let msg2 = create_test_message(
2588 session.id,
2589 1,
2590 MessageRole::Assistant,
2591 "Rust is a systems programming language",
2592 );
2593
2594 db.insert_message(&msg1)
2595 .expect("Failed to insert message 1");
2596 db.insert_message(&msg2)
2597 .expect("Failed to insert message 2");
2598
2599 let user_results = db
2601 .search_messages("programming", 10, None, None, Some("user"))
2602 .expect("Failed to search");
2603
2604 assert_eq!(user_results.len(), 1, "Should find 1 user message");
2605 assert_eq!(
2606 user_results[0].role,
2607 MessageRole::User,
2608 "Result should be from user"
2609 );
2610
2611 let assistant_results = db
2613 .search_messages("programming", 10, None, None, Some("assistant"))
2614 .expect("Failed to search");
2615
2616 assert_eq!(
2617 assistant_results.len(),
2618 1,
2619 "Should find 1 assistant message"
2620 );
2621 assert_eq!(
2622 assistant_results[0].role,
2623 MessageRole::Assistant,
2624 "Result should be from assistant"
2625 );
2626 }
2627
2628 #[test]
2629 fn test_search_messages_with_repo_filter() {
2630 let (db, _dir) = create_test_db();
2631
2632 let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
2633 let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
2634
2635 db.insert_session(&session1).expect("insert 1");
2636 db.insert_session(&session2).expect("insert 2");
2637
2638 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
2639 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
2640
2641 db.insert_message(&msg1).expect("insert msg 1");
2642 db.insert_message(&msg2).expect("insert msg 2");
2643
2644 let results = db
2646 .search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
2647 .expect("Failed to search");
2648
2649 assert_eq!(results.len(), 1, "Should find 1 message in project-a");
2650 assert!(
2651 results[0].working_directory.contains("project-a"),
2652 "Should be from project-a"
2653 );
2654 }
2655
2656 #[test]
2657 fn test_search_messages_limit() {
2658 let (db, _dir) = create_test_db();
2659
2660 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2661 db.insert_session(&session).expect("insert session");
2662
2663 for i in 0..5 {
2665 let msg = create_test_message(
2666 session.id,
2667 i,
2668 MessageRole::User,
2669 &format!("This is test message number {i}"),
2670 );
2671 db.insert_message(&msg).expect("insert message");
2672 }
2673
2674 let results = db
2676 .search_messages("test", 3, None, None, None)
2677 .expect("Failed to search");
2678
2679 assert_eq!(results.len(), 3, "Should respect limit of 3");
2680 }
2681
2682 #[test]
2683 fn test_search_index_needs_rebuild_empty_db() {
2684 let (db, _dir) = create_test_db();
2685
2686 let needs_rebuild = db
2687 .search_index_needs_rebuild()
2688 .expect("Failed to check rebuild status");
2689
2690 assert!(!needs_rebuild, "Empty database should not need rebuild");
2691 }
2692
2693 #[test]
2694 fn test_rebuild_search_index() {
2695 let (db, _dir) = create_test_db();
2696
2697 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2698 db.insert_session(&session).expect("insert session");
2699
2700 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
2701 let msg2 = create_test_message(
2702 session.id,
2703 1,
2704 MessageRole::Assistant,
2705 "Second test response",
2706 );
2707
2708 db.insert_message(&msg1).expect("insert msg 1");
2709 db.insert_message(&msg2).expect("insert msg 2");
2710
2711 db.conn
2713 .execute("DELETE FROM messages_fts", [])
2714 .expect("clear fts");
2715
2716 assert!(
2718 db.search_index_needs_rebuild().expect("check rebuild"),
2719 "Should need rebuild after clearing FTS"
2720 );
2721
2722 let count = db.rebuild_search_index().expect("rebuild");
2724 assert_eq!(count, 2, "Should have indexed 2 messages");
2725
2726 assert!(
2728 !db.search_index_needs_rebuild().expect("check rebuild"),
2729 "Should not need rebuild after rebuilding"
2730 );
2731
2732 let results = db
2734 .search_messages("test", 10, None, None, None)
2735 .expect("search");
2736 assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
2737 }
2738
2739 #[test]
2740 fn test_search_with_block_content() {
2741 let (db, _dir) = create_test_db();
2742
2743 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2744 db.insert_session(&session).expect("insert session");
2745
2746 let block_content = MessageContent::Blocks(vec![
2748 crate::storage::models::ContentBlock::Text {
2749 text: "Let me help with your database query.".to_string(),
2750 },
2751 crate::storage::models::ContentBlock::ToolUse {
2752 id: "tool_123".to_string(),
2753 name: "Bash".to_string(),
2754 input: serde_json::json!({"command": "ls -la"}),
2755 },
2756 ]);
2757
2758 let msg = Message {
2759 id: Uuid::new_v4(),
2760 session_id: session.id,
2761 parent_id: None,
2762 index: 0,
2763 timestamp: Utc::now(),
2764 role: MessageRole::Assistant,
2765 content: block_content,
2766 model: Some("claude-opus-4".to_string()),
2767 git_branch: Some("main".to_string()),
2768 cwd: Some("/project".to_string()),
2769 };
2770
2771 db.insert_message(&msg).expect("insert message");
2772
2773 let results = db
2775 .search_messages("database", 10, None, None, None)
2776 .expect("search");
2777
2778 assert_eq!(results.len(), 1, "Should find message with block content");
2779 }
2780
2781 #[test]
2782 fn test_search_result_contains_session_info() {
2783 let (db, _dir) = create_test_db();
2784
2785 let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
2786 db.insert_session(&session).expect("insert session");
2787
2788 let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
2789 db.insert_message(&msg).expect("insert message");
2790
2791 let results = db
2792 .search_messages("Search", 10, None, None, None)
2793 .expect("search");
2794
2795 assert_eq!(results.len(), 1, "Should find 1 result");
2796 assert_eq!(results[0].session_id, session.id, "Session ID should match");
2797 assert_eq!(results[0].message_id, msg.id, "Message ID should match");
2798 assert_eq!(
2799 results[0].working_directory, "/home/user/my-project",
2800 "Working directory should match"
2801 );
2802 assert_eq!(results[0].role, MessageRole::User, "Role should match");
2803 }
2804
2805 #[test]
2808 fn test_delete_link_by_id() {
2809 let (db, _dir) = create_test_db();
2810
2811 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2812 db.insert_session(&session)
2813 .expect("Failed to insert session");
2814
2815 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2816 db.insert_link(&link).expect("Failed to insert link");
2817
2818 let links_before = db
2820 .get_links_by_session(&session.id)
2821 .expect("Failed to get links");
2822 assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
2823
2824 let deleted = db.delete_link(&link.id).expect("Failed to delete link");
2826 assert!(deleted, "Should return true when link is deleted");
2827
2828 let links_after = db
2830 .get_links_by_session(&session.id)
2831 .expect("Failed to get links");
2832 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
2833 }
2834
2835 #[test]
2836 fn test_delete_link_nonexistent() {
2837 let (db, _dir) = create_test_db();
2838
2839 let nonexistent_id = Uuid::new_v4();
2840 let deleted = db
2841 .delete_link(&nonexistent_id)
2842 .expect("Failed to call delete_link");
2843
2844 assert!(!deleted, "Should return false for nonexistent link");
2845 }
2846
2847 #[test]
2848 fn test_delete_links_by_session() {
2849 let (db, _dir) = create_test_db();
2850
2851 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2852 db.insert_session(&session)
2853 .expect("Failed to insert session");
2854
2855 let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
2857 let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
2858 let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
2859
2860 db.insert_link(&link1).expect("Failed to insert link1");
2861 db.insert_link(&link2).expect("Failed to insert link2");
2862 db.insert_link(&link3).expect("Failed to insert link3");
2863
2864 let links_before = db
2866 .get_links_by_session(&session.id)
2867 .expect("Failed to get links");
2868 assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
2869
2870 let count = db
2872 .delete_links_by_session(&session.id)
2873 .expect("Failed to delete links");
2874 assert_eq!(count, 3, "Should have deleted 3 links");
2875
2876 let links_after = db
2878 .get_links_by_session(&session.id)
2879 .expect("Failed to get links");
2880 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
2881 }
2882
2883 #[test]
2884 fn test_delete_links_by_session_no_links() {
2885 let (db, _dir) = create_test_db();
2886
2887 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2888 db.insert_session(&session)
2889 .expect("Failed to insert session");
2890
2891 let count = db
2893 .delete_links_by_session(&session.id)
2894 .expect("Failed to call delete_links_by_session");
2895 assert_eq!(count, 0, "Should return 0 when no links exist");
2896 }
2897
2898 #[test]
2899 fn test_delete_links_by_session_preserves_other_sessions() {
2900 let (db, _dir) = create_test_db();
2901
2902 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2903 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2904
2905 db.insert_session(&session1)
2906 .expect("Failed to insert session1");
2907 db.insert_session(&session2)
2908 .expect("Failed to insert session2");
2909
2910 let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2911 let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
2912
2913 db.insert_link(&link1).expect("Failed to insert link1");
2914 db.insert_link(&link2).expect("Failed to insert link2");
2915
2916 let count = db
2918 .delete_links_by_session(&session1.id)
2919 .expect("Failed to delete links");
2920 assert_eq!(count, 1, "Should have deleted 1 link");
2921
2922 let session2_links = db
2924 .get_links_by_session(&session2.id)
2925 .expect("Failed to get links");
2926 assert_eq!(
2927 session2_links.len(),
2928 1,
2929 "Session2's link should be preserved"
2930 );
2931 assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
2932 }
2933
2934 #[test]
2935 fn test_delete_link_by_session_and_commit() {
2936 let (db, _dir) = create_test_db();
2937
2938 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2939 db.insert_session(&session)
2940 .expect("Failed to insert session");
2941
2942 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2943 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2944
2945 db.insert_link(&link1).expect("Failed to insert link1");
2946 db.insert_link(&link2).expect("Failed to insert link2");
2947
2948 let deleted = db
2950 .delete_link_by_session_and_commit(&session.id, "abc123")
2951 .expect("Failed to delete link");
2952 assert!(deleted, "Should return true when link is deleted");
2953
2954 let links = db
2956 .get_links_by_session(&session.id)
2957 .expect("Failed to get links");
2958 assert_eq!(links.len(), 1, "Should have 1 link remaining");
2959 assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
2960 }
2961
2962 #[test]
2963 fn test_delete_link_by_session_and_commit_full_sha() {
2964 let (db, _dir) = create_test_db();
2965
2966 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2967 db.insert_session(&session)
2968 .expect("Failed to insert session");
2969
2970 let full_sha = "abc123def456789012345678901234567890abcd";
2971 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
2972 db.insert_link(&link).expect("Failed to insert link");
2973
2974 let deleted = db
2976 .delete_link_by_session_and_commit(&session.id, full_sha)
2977 .expect("Failed to delete link");
2978 assert!(deleted, "Should delete with full SHA");
2979
2980 let links = db
2981 .get_links_by_session(&session.id)
2982 .expect("Failed to get links");
2983 assert_eq!(links.len(), 0, "Should have 0 links after delete");
2984 }
2985
2986 #[test]
2987 fn test_delete_link_by_session_and_commit_no_match() {
2988 let (db, _dir) = create_test_db();
2989
2990 let session = create_test_session("claude-code", "/project", Utc::now(), None);
2991 db.insert_session(&session)
2992 .expect("Failed to insert session");
2993
2994 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
2995 db.insert_link(&link).expect("Failed to insert link");
2996
2997 let deleted = db
2999 .delete_link_by_session_and_commit(&session.id, "xyz999")
3000 .expect("Failed to call delete");
3001 assert!(!deleted, "Should return false when no match");
3002
3003 let links = db
3005 .get_links_by_session(&session.id)
3006 .expect("Failed to get links");
3007 assert_eq!(links.len(), 1, "Link should be preserved");
3008 }
3009
3010 #[test]
3011 fn test_delete_link_by_session_and_commit_wrong_session() {
3012 let (db, _dir) = create_test_db();
3013
3014 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3015 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3016
3017 db.insert_session(&session1)
3018 .expect("Failed to insert session1");
3019 db.insert_session(&session2)
3020 .expect("Failed to insert session2");
3021
3022 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3023 db.insert_link(&link).expect("Failed to insert link");
3024
3025 let deleted = db
3027 .delete_link_by_session_and_commit(&session2.id, "abc123")
3028 .expect("Failed to call delete");
3029 assert!(!deleted, "Should not delete link from different session");
3030
3031 let links = db
3033 .get_links_by_session(&session1.id)
3034 .expect("Failed to get links");
3035 assert_eq!(links.len(), 1, "Link should be preserved");
3036 }
3037
3038 #[test]
3041 fn test_find_sessions_near_commit_time_basic() {
3042 let (db, _dir) = create_test_db();
3043 let now = Utc::now();
3044
3045 let mut session = create_test_session(
3047 "claude-code",
3048 "/home/user/project",
3049 now - Duration::hours(1),
3050 None,
3051 );
3052 session.ended_at = Some(now - Duration::minutes(10));
3053
3054 db.insert_session(&session).expect("insert session");
3055
3056 let found = db
3058 .find_sessions_near_commit_time(now, 30, None)
3059 .expect("find sessions");
3060
3061 assert_eq!(found.len(), 1, "Should find session within window");
3062 assert_eq!(found[0].id, session.id);
3063 }
3064
3065 #[test]
3066 fn test_find_sessions_near_commit_time_outside_window() {
3067 let (db, _dir) = create_test_db();
3068 let now = Utc::now();
3069
3070 let mut session =
3072 create_test_session("claude-code", "/project", now - Duration::hours(3), None);
3073 session.ended_at = Some(now - Duration::hours(2));
3074
3075 db.insert_session(&session).expect("insert session");
3076
3077 let found = db
3079 .find_sessions_near_commit_time(now, 30, None)
3080 .expect("find sessions");
3081
3082 assert!(found.is_empty(), "Should not find session outside window");
3083 }
3084
3085 #[test]
3086 fn test_find_sessions_near_commit_time_with_working_dir() {
3087 let (db, _dir) = create_test_db();
3088 let now = Utc::now();
3089
3090 let mut session1 = create_test_session(
3092 "claude-code",
3093 "/home/user/project-a",
3094 now - Duration::minutes(30),
3095 None,
3096 );
3097 session1.ended_at = Some(now - Duration::minutes(5));
3098
3099 let mut session2 = create_test_session(
3100 "claude-code",
3101 "/home/user/project-b",
3102 now - Duration::minutes(30),
3103 None,
3104 );
3105 session2.ended_at = Some(now - Duration::minutes(5));
3106
3107 db.insert_session(&session1).expect("insert session1");
3108 db.insert_session(&session2).expect("insert session2");
3109
3110 let found = db
3112 .find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
3113 .expect("find sessions");
3114
3115 assert_eq!(found.len(), 1, "Should find only session in project-a");
3116 assert_eq!(found[0].id, session1.id);
3117 }
3118
3119 #[test]
3120 fn test_find_sessions_near_commit_time_ongoing_session() {
3121 let (db, _dir) = create_test_db();
3122 let now = Utc::now();
3123
3124 let session =
3126 create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
3127 db.insert_session(&session).expect("insert session");
3130
3131 let found = db
3133 .find_sessions_near_commit_time(now, 30, None)
3134 .expect("find sessions");
3135
3136 assert_eq!(found.len(), 1, "Should find ongoing session");
3137 assert_eq!(found[0].id, session.id);
3138 }
3139
3140 #[test]
3141 fn test_link_exists_true() {
3142 let (db, _dir) = create_test_db();
3143
3144 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3145 db.insert_session(&session).expect("insert session");
3146
3147 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3148 db.insert_link(&link).expect("insert link");
3149
3150 assert!(
3152 db.link_exists(&session.id, "abc123def456")
3153 .expect("check exists"),
3154 "Should find link with full SHA"
3155 );
3156
3157 assert!(
3159 db.link_exists(&session.id, "abc123").expect("check exists"),
3160 "Should find link with partial SHA"
3161 );
3162 }
3163
3164 #[test]
3165 fn test_link_exists_false() {
3166 let (db, _dir) = create_test_db();
3167
3168 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3169 db.insert_session(&session).expect("insert session");
3170
3171 assert!(
3173 !db.link_exists(&session.id, "abc123").expect("check exists"),
3174 "Should not find non-existent link"
3175 );
3176 }
3177
3178 #[test]
3179 fn test_link_exists_different_session() {
3180 let (db, _dir) = create_test_db();
3181
3182 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3183 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3184
3185 db.insert_session(&session1).expect("insert session1");
3186 db.insert_session(&session2).expect("insert session2");
3187
3188 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3189 db.insert_link(&link).expect("insert link");
3190
3191 assert!(
3193 db.link_exists(&session1.id, "abc123").expect("check"),
3194 "Should find link for session1"
3195 );
3196 assert!(
3197 !db.link_exists(&session2.id, "abc123").expect("check"),
3198 "Should not find link for session2"
3199 );
3200 }
3201
3202 #[test]
3205 fn test_search_with_tool_filter() {
3206 let (db, _dir) = create_test_db();
3207
3208 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3209 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
3210
3211 db.insert_session(&session1).expect("insert session1");
3212 db.insert_session(&session2).expect("insert session2");
3213
3214 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
3215 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
3216
3217 db.insert_message(&msg1).expect("insert msg1");
3218 db.insert_message(&msg2).expect("insert msg2");
3219
3220 let options = SearchOptions {
3222 query: "Hello".to_string(),
3223 limit: 10,
3224 tool: Some("claude-code".to_string()),
3225 ..Default::default()
3226 };
3227 let results = db.search_with_options(&options).expect("search");
3228
3229 assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
3230 assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
3231 }
3232
3233 #[test]
3234 fn test_search_with_date_range() {
3235 let (db, _dir) = create_test_db();
3236
3237 let old_time = Utc::now() - chrono::Duration::days(30);
3238 let new_time = Utc::now() - chrono::Duration::days(1);
3239
3240 let session1 = create_test_session("claude-code", "/project1", old_time, None);
3241 let session2 = create_test_session("claude-code", "/project2", new_time, None);
3242
3243 db.insert_session(&session1).expect("insert session1");
3244 db.insert_session(&session2).expect("insert session2");
3245
3246 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
3247 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
3248
3249 db.insert_message(&msg1).expect("insert msg1");
3250 db.insert_message(&msg2).expect("insert msg2");
3251
3252 let since = Utc::now() - chrono::Duration::days(7);
3254 let options = SearchOptions {
3255 query: "session".to_string(),
3256 limit: 10,
3257 since: Some(since),
3258 ..Default::default()
3259 };
3260 let results = db.search_with_options(&options).expect("search");
3261
3262 assert_eq!(results.len(), 1, "Should find 1 result within date range");
3263 assert!(
3264 results[0].working_directory.contains("project2"),
3265 "Should be from newer project"
3266 );
3267 }
3268
3269 #[test]
3270 fn test_search_with_project_filter() {
3271 let (db, _dir) = create_test_db();
3272
3273 let session1 =
3274 create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
3275 let session2 =
3276 create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
3277
3278 db.insert_session(&session1).expect("insert session1");
3279 db.insert_session(&session2).expect("insert session2");
3280
3281 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
3282 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
3283
3284 db.insert_message(&msg1).expect("insert msg1");
3285 db.insert_message(&msg2).expect("insert msg2");
3286
3287 let options = SearchOptions {
3289 query: "Testing".to_string(),
3290 limit: 10,
3291 project: Some("frontend".to_string()),
3292 ..Default::default()
3293 };
3294 let results = db.search_with_options(&options).expect("search");
3295
3296 assert_eq!(results.len(), 1, "Should find 1 result with project filter");
3297 assert!(
3298 results[0].working_directory.contains("frontend"),
3299 "Should be from frontend project"
3300 );
3301 }
3302
3303 #[test]
3304 fn test_search_with_branch_filter() {
3305 let (db, _dir) = create_test_db();
3306
3307 let session1 = Session {
3308 id: Uuid::new_v4(),
3309 tool: "claude-code".to_string(),
3310 tool_version: None,
3311 started_at: Utc::now(),
3312 ended_at: None,
3313 model: None,
3314 working_directory: "/project".to_string(),
3315 git_branch: Some("feat/auth".to_string()),
3316 source_path: None,
3317 message_count: 0,
3318 machine_id: None,
3319 };
3320 let session2 = Session {
3321 id: Uuid::new_v4(),
3322 tool: "claude-code".to_string(),
3323 tool_version: None,
3324 started_at: Utc::now(),
3325 ended_at: None,
3326 model: None,
3327 working_directory: "/project".to_string(),
3328 git_branch: Some("main".to_string()),
3329 source_path: None,
3330 message_count: 0,
3331 machine_id: None,
3332 };
3333
3334 db.insert_session(&session1).expect("insert session1");
3335 db.insert_session(&session2).expect("insert session2");
3336
3337 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
3338 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
3339
3340 db.insert_message(&msg1).expect("insert msg1");
3341 db.insert_message(&msg2).expect("insert msg2");
3342
3343 let options = SearchOptions {
3345 query: "work".to_string(),
3346 limit: 10,
3347 branch: Some("auth".to_string()),
3348 ..Default::default()
3349 };
3350 let results = db.search_with_options(&options).expect("search");
3351
3352 assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
3353 assert_eq!(
3354 results[0].git_branch.as_deref(),
3355 Some("feat/auth"),
3356 "Should be from feat/auth branch"
3357 );
3358 }
3359
3360 #[test]
3361 fn test_search_metadata_matches_project() {
3362 let (db, _dir) = create_test_db();
3363
3364 let session =
3365 create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
3366 db.insert_session(&session).expect("insert session");
3367
3368 let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
3370 db.insert_message(&msg).expect("insert msg");
3371
3372 let options = SearchOptions {
3374 query: "redactyl".to_string(),
3375 limit: 10,
3376 ..Default::default()
3377 };
3378 let results = db.search_with_options(&options).expect("search");
3379
3380 assert_eq!(
3381 results.len(),
3382 1,
3383 "Should find session via metadata match on project name"
3384 );
3385 }
3386
3387 #[test]
3388 fn test_search_returns_extended_session_info() {
3389 let (db, _dir) = create_test_db();
3390
3391 let started_at = Utc::now();
3392 let session = Session {
3393 id: Uuid::new_v4(),
3394 tool: "claude-code".to_string(),
3395 tool_version: Some("1.0.0".to_string()),
3396 started_at,
3397 ended_at: None,
3398 model: None,
3399 working_directory: "/home/user/myapp".to_string(),
3400 git_branch: Some("develop".to_string()),
3401 source_path: None,
3402 message_count: 5,
3403 machine_id: None,
3404 };
3405 db.insert_session(&session).expect("insert session");
3406
3407 let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
3408 db.insert_message(&msg).expect("insert msg");
3409
3410 let options = SearchOptions {
3411 query: "Test".to_string(),
3412 limit: 10,
3413 ..Default::default()
3414 };
3415 let results = db.search_with_options(&options).expect("search");
3416
3417 assert_eq!(results.len(), 1, "Should find 1 result");
3418 let result = &results[0];
3419
3420 assert_eq!(result.tool, "claude-code", "Tool should be populated");
3421 assert_eq!(
3422 result.git_branch.as_deref(),
3423 Some("develop"),
3424 "Branch should be populated"
3425 );
3426 assert!(
3427 result.session_message_count > 0,
3428 "Message count should be populated"
3429 );
3430 assert!(
3431 result.session_started_at.is_some(),
3432 "Session start time should be populated"
3433 );
3434 }
3435
3436 #[test]
3437 fn test_get_context_messages() {
3438 let (db, _dir) = create_test_db();
3439
3440 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3441 db.insert_session(&session).expect("insert session");
3442
3443 for i in 0..5 {
3445 let role = if i % 2 == 0 {
3446 MessageRole::User
3447 } else {
3448 MessageRole::Assistant
3449 };
3450 let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
3451 db.insert_message(&msg).expect("insert message");
3452 }
3453
3454 let (before, after) = db
3456 .get_context_messages(&session.id, 2, 1)
3457 .expect("get context");
3458
3459 assert_eq!(before.len(), 1, "Should have 1 message before");
3460 assert_eq!(after.len(), 1, "Should have 1 message after");
3461 assert_eq!(before[0].index, 1, "Before message should be index 1");
3462 assert_eq!(after[0].index, 3, "After message should be index 3");
3463 }
3464
3465 #[test]
3466 fn test_get_context_messages_at_start() {
3467 let (db, _dir) = create_test_db();
3468
3469 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3470 db.insert_session(&session).expect("insert session");
3471
3472 for i in 0..3 {
3473 let msg =
3474 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
3475 db.insert_message(&msg).expect("insert message");
3476 }
3477
3478 let (before, after) = db
3480 .get_context_messages(&session.id, 0, 2)
3481 .expect("get context");
3482
3483 assert!(
3484 before.is_empty(),
3485 "Should have no messages before first message"
3486 );
3487 assert_eq!(after.len(), 2, "Should have 2 messages after");
3488 }
3489
3490 #[test]
3491 fn test_get_context_messages_at_end() {
3492 let (db, _dir) = create_test_db();
3493
3494 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3495 db.insert_session(&session).expect("insert session");
3496
3497 for i in 0..3 {
3498 let msg =
3499 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
3500 db.insert_message(&msg).expect("insert message");
3501 }
3502
3503 let (before, after) = db
3505 .get_context_messages(&session.id, 2, 2)
3506 .expect("get context");
3507
3508 assert_eq!(before.len(), 2, "Should have 2 messages before");
3509 assert!(
3510 after.is_empty(),
3511 "Should have no messages after last message"
3512 );
3513 }
3514
3515 #[test]
3516 fn test_search_combined_filters() {
3517 let (db, _dir) = create_test_db();
3518
3519 let session1 = Session {
3520 id: Uuid::new_v4(),
3521 tool: "claude-code".to_string(),
3522 tool_version: None,
3523 started_at: Utc::now(),
3524 ended_at: None,
3525 model: None,
3526 working_directory: "/home/user/myapp".to_string(),
3527 git_branch: Some("feat/api".to_string()),
3528 source_path: None,
3529 message_count: 1,
3530 machine_id: None,
3531 };
3532 let session2 = Session {
3533 id: Uuid::new_v4(),
3534 tool: "aider".to_string(),
3535 tool_version: None,
3536 started_at: Utc::now(),
3537 ended_at: None,
3538 model: None,
3539 working_directory: "/home/user/myapp".to_string(),
3540 git_branch: Some("feat/api".to_string()),
3541 source_path: None,
3542 message_count: 1,
3543 machine_id: None,
3544 };
3545
3546 db.insert_session(&session1).expect("insert session1");
3547 db.insert_session(&session2).expect("insert session2");
3548
3549 let msg1 =
3550 create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
3551 let msg2 =
3552 create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
3553
3554 db.insert_message(&msg1).expect("insert msg1");
3555 db.insert_message(&msg2).expect("insert msg2");
3556
3557 let options = SearchOptions {
3559 query: "API".to_string(),
3560 limit: 10,
3561 tool: Some("claude-code".to_string()),
3562 branch: Some("api".to_string()),
3563 project: Some("myapp".to_string()),
3564 ..Default::default()
3565 };
3566 let results = db.search_with_options(&options).expect("search");
3567
3568 assert!(
3570 !results.is_empty(),
3571 "Should find at least 1 result matching all filters"
3572 );
3573 for result in &results {
3575 assert_eq!(
3576 result.tool, "claude-code",
3577 "All results should be from claude-code"
3578 );
3579 }
3580 }
3581
3582 #[test]
3585 fn test_delete_session_removes_all_data() {
3586 let (db, _dir) = create_test_db();
3587
3588 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3589 db.insert_session(&session).expect("insert session");
3590
3591 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
3593 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there");
3594 db.insert_message(&msg1).expect("insert msg1");
3595 db.insert_message(&msg2).expect("insert msg2");
3596
3597 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3599 db.insert_link(&link).expect("insert link");
3600
3601 assert_eq!(db.session_count().expect("count"), 1);
3603 assert_eq!(db.message_count().expect("count"), 2);
3604 assert_eq!(db.link_count().expect("count"), 1);
3605
3606 let (msgs_deleted, links_deleted) = db.delete_session(&session.id).expect("delete");
3608 assert_eq!(msgs_deleted, 2, "Should delete 2 messages");
3609 assert_eq!(links_deleted, 1, "Should delete 1 link");
3610
3611 assert_eq!(db.session_count().expect("count"), 0);
3613 assert_eq!(db.message_count().expect("count"), 0);
3614 assert_eq!(db.link_count().expect("count"), 0);
3615 assert!(db.get_session(&session.id).expect("get").is_none());
3616 }
3617
3618 #[test]
3619 fn test_delete_session_preserves_other_sessions() {
3620 let (db, _dir) = create_test_db();
3621
3622 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3623 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
3624
3625 db.insert_session(&session1).expect("insert session1");
3626 db.insert_session(&session2).expect("insert session2");
3627
3628 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello 1");
3629 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello 2");
3630 db.insert_message(&msg1).expect("insert msg1");
3631 db.insert_message(&msg2).expect("insert msg2");
3632
3633 db.delete_session(&session1.id).expect("delete");
3635
3636 assert_eq!(db.session_count().expect("count"), 1);
3638 assert_eq!(db.message_count().expect("count"), 1);
3639 assert!(db.get_session(&session2.id).expect("get").is_some());
3640 }
3641
3642 #[test]
3645 fn test_file_size() {
3646 let (db, _dir) = create_test_db();
3647
3648 let size = db.file_size().expect("get size");
3649 assert!(size.is_some(), "Should have file size for file-based db");
3650 assert!(size.unwrap() > 0, "Database file should have size > 0");
3651 }
3652
3653 #[test]
3654 fn test_vacuum() {
3655 let (db, _dir) = create_test_db();
3656
3657 db.vacuum().expect("vacuum should succeed");
3659 }
3660
3661 #[test]
3662 fn test_count_sessions_older_than() {
3663 let (db, _dir) = create_test_db();
3664 let now = Utc::now();
3665
3666 let old_session =
3668 create_test_session("claude-code", "/project1", now - Duration::days(100), None);
3669 let recent_session =
3670 create_test_session("claude-code", "/project2", now - Duration::days(10), None);
3671
3672 db.insert_session(&old_session).expect("insert old");
3673 db.insert_session(&recent_session).expect("insert recent");
3674
3675 let cutoff = now - Duration::days(30);
3677 let count = db.count_sessions_older_than(cutoff).expect("count");
3678 assert_eq!(count, 1, "Should find 1 session older than 30 days");
3679
3680 let old_cutoff = now - Duration::days(200);
3682 let old_count = db.count_sessions_older_than(old_cutoff).expect("count");
3683 assert_eq!(old_count, 0, "Should find 0 sessions older than 200 days");
3684 }
3685
3686 #[test]
3687 fn test_delete_sessions_older_than() {
3688 let (db, _dir) = create_test_db();
3689 let now = Utc::now();
3690
3691 let old_session =
3693 create_test_session("claude-code", "/project1", now - Duration::days(100), None);
3694 let recent_session =
3695 create_test_session("claude-code", "/project2", now - Duration::days(10), None);
3696
3697 db.insert_session(&old_session).expect("insert old");
3698 db.insert_session(&recent_session).expect("insert recent");
3699
3700 let msg1 = create_test_message(old_session.id, 0, MessageRole::User, "Old message");
3702 let msg2 = create_test_message(recent_session.id, 0, MessageRole::User, "Recent message");
3703 db.insert_message(&msg1).expect("insert msg1");
3704 db.insert_message(&msg2).expect("insert msg2");
3705
3706 let cutoff = now - Duration::days(30);
3708 let deleted = db.delete_sessions_older_than(cutoff).expect("delete");
3709 assert_eq!(deleted, 1, "Should delete 1 session");
3710
3711 assert_eq!(db.session_count().expect("count"), 1);
3713 assert!(db.get_session(&recent_session.id).expect("get").is_some());
3714 assert!(db.get_session(&old_session.id).expect("get").is_none());
3715
3716 assert_eq!(db.message_count().expect("count"), 1);
3718 }
3719
3720 #[test]
3721 fn test_get_sessions_older_than() {
3722 let (db, _dir) = create_test_db();
3723 let now = Utc::now();
3724
3725 let old_session = create_test_session(
3727 "claude-code",
3728 "/project/old",
3729 now - Duration::days(100),
3730 None,
3731 );
3732 let medium_session =
3733 create_test_session("aider", "/project/medium", now - Duration::days(50), None);
3734 let recent_session =
3735 create_test_session("gemini", "/project/recent", now - Duration::days(10), None);
3736
3737 db.insert_session(&old_session).expect("insert old");
3738 db.insert_session(&medium_session).expect("insert medium");
3739 db.insert_session(&recent_session).expect("insert recent");
3740
3741 let cutoff = now - Duration::days(30);
3743 let sessions = db.get_sessions_older_than(cutoff).expect("get sessions");
3744 assert_eq!(
3745 sessions.len(),
3746 2,
3747 "Should find 2 sessions older than 30 days"
3748 );
3749
3750 assert_eq!(sessions[0].id, old_session.id);
3752 assert_eq!(sessions[1].id, medium_session.id);
3753
3754 assert_eq!(sessions[0].tool, "claude-code");
3756 assert_eq!(sessions[0].working_directory, "/project/old");
3757 assert_eq!(sessions[1].tool, "aider");
3758 assert_eq!(sessions[1].working_directory, "/project/medium");
3759
3760 let old_cutoff = now - Duration::days(200);
3762 let old_sessions = db
3763 .get_sessions_older_than(old_cutoff)
3764 .expect("get old sessions");
3765 assert_eq!(
3766 old_sessions.len(),
3767 0,
3768 "Should find 0 sessions older than 200 days"
3769 );
3770 }
3771
3772 #[test]
3773 fn test_stats() {
3774 let (db, _dir) = create_test_db();
3775 let now = Utc::now();
3776
3777 let empty_stats = db.stats().expect("stats");
3779 assert_eq!(empty_stats.session_count, 0);
3780 assert_eq!(empty_stats.message_count, 0);
3781 assert_eq!(empty_stats.link_count, 0);
3782 assert!(empty_stats.oldest_session.is_none());
3783 assert!(empty_stats.newest_session.is_none());
3784 assert!(empty_stats.sessions_by_tool.is_empty());
3785
3786 let session1 =
3788 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
3789 let session2 = create_test_session("aider", "/project2", now - Duration::hours(1), None);
3790 let session3 = create_test_session("claude-code", "/project3", now, None);
3791
3792 db.insert_session(&session1).expect("insert 1");
3793 db.insert_session(&session2).expect("insert 2");
3794 db.insert_session(&session3).expect("insert 3");
3795
3796 let msg = create_test_message(session1.id, 0, MessageRole::User, "Hello");
3797 db.insert_message(&msg).expect("insert msg");
3798
3799 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3800 db.insert_link(&link).expect("insert link");
3801
3802 let stats = db.stats().expect("stats");
3804 assert_eq!(stats.session_count, 3);
3805 assert_eq!(stats.message_count, 1);
3806 assert_eq!(stats.link_count, 1);
3807 assert!(stats.oldest_session.is_some());
3808 assert!(stats.newest_session.is_some());
3809
3810 assert_eq!(stats.sessions_by_tool.len(), 2);
3812 assert_eq!(stats.sessions_by_tool[0].0, "claude-code");
3814 assert_eq!(stats.sessions_by_tool[0].1, 2);
3815 assert_eq!(stats.sessions_by_tool[1].0, "aider");
3816 assert_eq!(stats.sessions_by_tool[1].1, 1);
3817 }
3818
3819 #[test]
3822 fn test_get_session_branch_history_no_messages() {
3823 let (db, _dir) = create_test_db();
3824 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3825 db.insert_session(&session)
3826 .expect("Failed to insert session");
3827
3828 let branches = db
3829 .get_session_branch_history(session.id)
3830 .expect("Failed to get branch history");
3831
3832 assert!(branches.is_empty(), "Empty session should have no branches");
3833 }
3834
3835 #[test]
3836 fn test_get_session_branch_history_single_branch() {
3837 let (db, _dir) = create_test_db();
3838 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3839 db.insert_session(&session)
3840 .expect("Failed to insert session");
3841
3842 for i in 0..3 {
3844 let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
3845 msg.git_branch = Some("main".to_string());
3846 db.insert_message(&msg).expect("Failed to insert message");
3847 }
3848
3849 let branches = db
3850 .get_session_branch_history(session.id)
3851 .expect("Failed to get branch history");
3852
3853 assert_eq!(branches, vec!["main"], "Should have single branch");
3854 }
3855
3856 #[test]
3857 fn test_get_session_branch_history_multiple_branches() {
3858 let (db, _dir) = create_test_db();
3859 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3860 db.insert_session(&session)
3861 .expect("Failed to insert session");
3862
3863 let branch_sequence = ["main", "main", "feat/auth", "feat/auth", "main"];
3865 for (i, branch) in branch_sequence.iter().enumerate() {
3866 let mut msg = create_test_message(session.id, i as i32, MessageRole::User, "test");
3867 msg.git_branch = Some(branch.to_string());
3868 db.insert_message(&msg).expect("Failed to insert message");
3869 }
3870
3871 let branches = db
3872 .get_session_branch_history(session.id)
3873 .expect("Failed to get branch history");
3874
3875 assert_eq!(
3876 branches,
3877 vec!["main", "feat/auth", "main"],
3878 "Should show branch transitions without consecutive duplicates"
3879 );
3880 }
3881
3882 #[test]
3883 fn test_get_session_branch_history_with_none_branches() {
3884 let (db, _dir) = create_test_db();
3885 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3886 db.insert_session(&session)
3887 .expect("Failed to insert session");
3888
3889 let mut msg1 = create_test_message(session.id, 0, MessageRole::User, "test");
3891 msg1.git_branch = Some("main".to_string());
3892 db.insert_message(&msg1).expect("Failed to insert message");
3893
3894 let mut msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "test");
3895 msg2.git_branch = None; db.insert_message(&msg2).expect("Failed to insert message");
3897
3898 let mut msg3 = create_test_message(session.id, 2, MessageRole::User, "test");
3899 msg3.git_branch = Some("feat/new".to_string());
3900 db.insert_message(&msg3).expect("Failed to insert message");
3901
3902 let branches = db
3903 .get_session_branch_history(session.id)
3904 .expect("Failed to get branch history");
3905
3906 assert_eq!(
3907 branches,
3908 vec!["main", "feat/new"],
3909 "Should skip None branches and show transitions"
3910 );
3911 }
3912
3913 #[test]
3914 fn test_get_session_branch_history_all_none_branches() {
3915 let (db, _dir) = create_test_db();
3916 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3917 db.insert_session(&session)
3918 .expect("Failed to insert session");
3919
3920 for i in 0..3 {
3922 let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
3923 msg.git_branch = None;
3924 db.insert_message(&msg).expect("Failed to insert message");
3925 }
3926
3927 let branches = db
3928 .get_session_branch_history(session.id)
3929 .expect("Failed to get branch history");
3930
3931 assert!(
3932 branches.is_empty(),
3933 "Session with all None branches should return empty"
3934 );
3935 }
3936
3937 #[test]
3940 fn test_session_stores_machine_id() {
3941 let (db, _dir) = create_test_db();
3942 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3943
3944 db.insert_session(&session)
3945 .expect("Failed to insert session");
3946
3947 let retrieved = db
3948 .get_session(&session.id)
3949 .expect("Failed to get session")
3950 .expect("Session should exist");
3951
3952 assert_eq!(
3953 retrieved.machine_id,
3954 Some("test-machine".to_string()),
3955 "Machine ID should be preserved"
3956 );
3957 }
3958
3959 #[test]
3960 fn test_session_with_none_machine_id() {
3961 let (db, _dir) = create_test_db();
3962 let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
3963 session.machine_id = None;
3964
3965 db.insert_session(&session)
3966 .expect("Failed to insert session");
3967
3968 let retrieved = db
3969 .get_session(&session.id)
3970 .expect("Failed to get session")
3971 .expect("Session should exist");
3972
3973 assert!(
3974 retrieved.machine_id.is_none(),
3975 "Session with None machine_id should preserve None"
3976 );
3977 }
3978
3979 #[test]
3980 fn test_migration_adds_machine_id_column() {
3981 let (db, _dir) = create_test_db();
3983
3984 let session = create_test_session("claude-code", "/project", Utc::now(), None);
3986 db.insert_session(&session)
3987 .expect("Should insert session with machine_id column");
3988
3989 let retrieved = db
3991 .get_session(&session.id)
3992 .expect("Failed to get session")
3993 .expect("Session should exist");
3994
3995 assert_eq!(
3996 retrieved.machine_id,
3997 Some("test-machine".to_string()),
3998 "Machine ID should be stored and retrieved"
3999 );
4000 }
4001
4002 #[test]
4003 fn test_list_sessions_includes_machine_id() {
4004 let (db, _dir) = create_test_db();
4005 let now = Utc::now();
4006
4007 let mut session1 = create_test_session("claude-code", "/project1", now, None);
4008 session1.machine_id = Some("machine-a".to_string());
4009
4010 let mut session2 = create_test_session("claude-code", "/project2", now, None);
4011 session2.machine_id = Some("machine-b".to_string());
4012
4013 db.insert_session(&session1).expect("insert");
4014 db.insert_session(&session2).expect("insert");
4015
4016 let sessions = db.list_sessions(10, None).expect("list");
4017
4018 assert_eq!(sessions.len(), 2);
4019 let machine_ids: Vec<Option<String>> =
4020 sessions.iter().map(|s| s.machine_id.clone()).collect();
4021 assert!(machine_ids.contains(&Some("machine-a".to_string())));
4022 assert!(machine_ids.contains(&Some("machine-b".to_string())));
4023 }
4024
4025 #[test]
4028 fn test_insert_and_get_annotations() {
4029 let (db, _dir) = create_test_db();
4030 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4031 db.insert_session(&session).expect("insert session");
4032
4033 let annotation = Annotation {
4034 id: Uuid::new_v4(),
4035 session_id: session.id,
4036 content: "This is a test note".to_string(),
4037 created_at: Utc::now(),
4038 };
4039 db.insert_annotation(&annotation)
4040 .expect("insert annotation");
4041
4042 let annotations = db.get_annotations(&session.id).expect("get annotations");
4043 assert_eq!(annotations.len(), 1);
4044 assert_eq!(annotations[0].content, "This is a test note");
4045 assert_eq!(annotations[0].session_id, session.id);
4046 }
4047
4048 #[test]
4049 fn test_delete_annotation() {
4050 let (db, _dir) = create_test_db();
4051 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4052 db.insert_session(&session).expect("insert session");
4053
4054 let annotation = Annotation {
4055 id: Uuid::new_v4(),
4056 session_id: session.id,
4057 content: "Test annotation".to_string(),
4058 created_at: Utc::now(),
4059 };
4060 db.insert_annotation(&annotation).expect("insert");
4061
4062 let deleted = db.delete_annotation(&annotation.id).expect("delete");
4063 assert!(deleted);
4064
4065 let annotations = db.get_annotations(&session.id).expect("get");
4066 assert!(annotations.is_empty());
4067 }
4068
4069 #[test]
4070 fn test_delete_annotations_by_session() {
4071 let (db, _dir) = create_test_db();
4072 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4073 db.insert_session(&session).expect("insert session");
4074
4075 for i in 0..3 {
4076 let annotation = Annotation {
4077 id: Uuid::new_v4(),
4078 session_id: session.id,
4079 content: format!("Annotation {i}"),
4080 created_at: Utc::now(),
4081 };
4082 db.insert_annotation(&annotation).expect("insert");
4083 }
4084
4085 let count = db
4086 .delete_annotations_by_session(&session.id)
4087 .expect("delete all");
4088 assert_eq!(count, 3);
4089
4090 let annotations = db.get_annotations(&session.id).expect("get");
4091 assert!(annotations.is_empty());
4092 }
4093
4094 #[test]
4097 fn test_insert_and_get_tags() {
4098 let (db, _dir) = create_test_db();
4099 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4100 db.insert_session(&session).expect("insert session");
4101
4102 let tag = Tag {
4103 id: Uuid::new_v4(),
4104 session_id: session.id,
4105 label: "bug-fix".to_string(),
4106 created_at: Utc::now(),
4107 };
4108 db.insert_tag(&tag).expect("insert tag");
4109
4110 let tags = db.get_tags(&session.id).expect("get tags");
4111 assert_eq!(tags.len(), 1);
4112 assert_eq!(tags[0].label, "bug-fix");
4113 }
4114
4115 #[test]
4116 fn test_tag_exists() {
4117 let (db, _dir) = create_test_db();
4118 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4119 db.insert_session(&session).expect("insert session");
4120
4121 assert!(!db.tag_exists(&session.id, "bug-fix").expect("check"));
4122
4123 let tag = Tag {
4124 id: Uuid::new_v4(),
4125 session_id: session.id,
4126 label: "bug-fix".to_string(),
4127 created_at: Utc::now(),
4128 };
4129 db.insert_tag(&tag).expect("insert tag");
4130
4131 assert!(db.tag_exists(&session.id, "bug-fix").expect("check"));
4132 assert!(!db.tag_exists(&session.id, "feature").expect("check other"));
4133 }
4134
4135 #[test]
4136 fn test_delete_tag() {
4137 let (db, _dir) = create_test_db();
4138 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4139 db.insert_session(&session).expect("insert session");
4140
4141 let tag = Tag {
4142 id: Uuid::new_v4(),
4143 session_id: session.id,
4144 label: "wip".to_string(),
4145 created_at: Utc::now(),
4146 };
4147 db.insert_tag(&tag).expect("insert tag");
4148
4149 let deleted = db.delete_tag(&session.id, "wip").expect("delete");
4150 assert!(deleted);
4151
4152 let deleted_again = db.delete_tag(&session.id, "wip").expect("delete again");
4153 assert!(!deleted_again);
4154 }
4155
4156 #[test]
4157 fn test_list_sessions_with_tag() {
4158 let (db, _dir) = create_test_db();
4159 let now = Utc::now();
4160
4161 let session1 = create_test_session("claude-code", "/project1", now, None);
4162 let session2 =
4163 create_test_session("claude-code", "/project2", now - Duration::minutes(5), None);
4164 let session3 = create_test_session(
4165 "claude-code",
4166 "/project3",
4167 now - Duration::minutes(10),
4168 None,
4169 );
4170
4171 db.insert_session(&session1).expect("insert");
4172 db.insert_session(&session2).expect("insert");
4173 db.insert_session(&session3).expect("insert");
4174
4175 let tag1 = Tag {
4177 id: Uuid::new_v4(),
4178 session_id: session1.id,
4179 label: "feature".to_string(),
4180 created_at: Utc::now(),
4181 };
4182 let tag3 = Tag {
4183 id: Uuid::new_v4(),
4184 session_id: session3.id,
4185 label: "feature".to_string(),
4186 created_at: Utc::now(),
4187 };
4188 db.insert_tag(&tag1).expect("insert tag");
4189 db.insert_tag(&tag3).expect("insert tag");
4190
4191 let sessions = db.list_sessions_with_tag("feature", 10).expect("list");
4192 assert_eq!(sessions.len(), 2);
4193 assert_eq!(sessions[0].id, session1.id);
4195 assert_eq!(sessions[1].id, session3.id);
4196
4197 let sessions = db.list_sessions_with_tag("nonexistent", 10).expect("list");
4198 assert!(sessions.is_empty());
4199 }
4200
4201 #[test]
4202 fn test_get_most_recent_session_for_directory() {
4203 let (db, _dir) = create_test_db();
4204 let now = Utc::now();
4205
4206 let session1 = create_test_session(
4207 "claude-code",
4208 "/home/user/project",
4209 now - Duration::hours(1),
4210 None,
4211 );
4212 let session2 = create_test_session("claude-code", "/home/user/project", now, None);
4213 let session3 = create_test_session("claude-code", "/home/user/other", now, None);
4214
4215 db.insert_session(&session1).expect("insert");
4216 db.insert_session(&session2).expect("insert");
4217 db.insert_session(&session3).expect("insert");
4218
4219 let result = db
4220 .get_most_recent_session_for_directory("/home/user/project")
4221 .expect("get");
4222 assert!(result.is_some());
4223 assert_eq!(result.unwrap().id, session2.id);
4224
4225 let result = db
4226 .get_most_recent_session_for_directory("/home/user/nonexistent")
4227 .expect("get");
4228 assert!(result.is_none());
4229 }
4230
4231 #[test]
4232 fn test_session_deletion_removes_annotations_and_tags() {
4233 let (db, _dir) = create_test_db();
4234 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4235 db.insert_session(&session).expect("insert session");
4236
4237 let annotation = Annotation {
4239 id: Uuid::new_v4(),
4240 session_id: session.id,
4241 content: "Test annotation".to_string(),
4242 created_at: Utc::now(),
4243 };
4244 db.insert_annotation(&annotation).expect("insert");
4245
4246 let tag = Tag {
4248 id: Uuid::new_v4(),
4249 session_id: session.id,
4250 label: "test-tag".to_string(),
4251 created_at: Utc::now(),
4252 };
4253 db.insert_tag(&tag).expect("insert");
4254
4255 db.delete_session(&session.id).expect("delete");
4257
4258 let annotations = db.get_annotations(&session.id).expect("get");
4260 assert!(annotations.is_empty());
4261
4262 let tags = db.get_tags(&session.id).expect("get");
4263 assert!(tags.is_empty());
4264 }
4265
4266 #[test]
4267 fn test_insert_and_get_summary() {
4268 let (db, _dir) = create_test_db();
4269 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4270 db.insert_session(&session).expect("insert session");
4271
4272 let summary = Summary {
4273 id: Uuid::new_v4(),
4274 session_id: session.id,
4275 content: "Test summary content".to_string(),
4276 generated_at: Utc::now(),
4277 };
4278 db.insert_summary(&summary).expect("insert summary");
4279
4280 let retrieved = db.get_summary(&session.id).expect("get summary");
4281 assert!(retrieved.is_some());
4282 let retrieved = retrieved.unwrap();
4283 assert_eq!(retrieved.content, "Test summary content");
4284 assert_eq!(retrieved.session_id, session.id);
4285 }
4286
4287 #[test]
4288 fn test_get_summary_nonexistent() {
4289 let (db, _dir) = create_test_db();
4290 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4291 db.insert_session(&session).expect("insert session");
4292
4293 let retrieved = db.get_summary(&session.id).expect("get summary");
4294 assert!(retrieved.is_none());
4295 }
4296
4297 #[test]
4298 fn test_update_summary() {
4299 let (db, _dir) = create_test_db();
4300 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4301 db.insert_session(&session).expect("insert session");
4302
4303 let summary = Summary {
4304 id: Uuid::new_v4(),
4305 session_id: session.id,
4306 content: "Original content".to_string(),
4307 generated_at: Utc::now(),
4308 };
4309 db.insert_summary(&summary).expect("insert summary");
4310
4311 let updated = db
4313 .update_summary(&session.id, "Updated content")
4314 .expect("update summary");
4315 assert!(updated);
4316
4317 let retrieved = db.get_summary(&session.id).expect("get summary");
4318 assert!(retrieved.is_some());
4319 assert_eq!(retrieved.unwrap().content, "Updated content");
4320 }
4321
4322 #[test]
4323 fn test_update_summary_nonexistent() {
4324 let (db, _dir) = create_test_db();
4325 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4326 db.insert_session(&session).expect("insert session");
4327
4328 let updated = db
4330 .update_summary(&session.id, "New content")
4331 .expect("update summary");
4332 assert!(!updated);
4333 }
4334
4335 #[test]
4336 fn test_delete_summary() {
4337 let (db, _dir) = create_test_db();
4338 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4339 db.insert_session(&session).expect("insert session");
4340
4341 let summary = Summary {
4342 id: Uuid::new_v4(),
4343 session_id: session.id,
4344 content: "To be deleted".to_string(),
4345 generated_at: Utc::now(),
4346 };
4347 db.insert_summary(&summary).expect("insert summary");
4348
4349 let deleted = db.delete_summary(&session.id).expect("delete summary");
4351 assert!(deleted);
4352
4353 let retrieved = db.get_summary(&session.id).expect("get summary");
4355 assert!(retrieved.is_none());
4356 }
4357
4358 #[test]
4359 fn test_delete_session_removes_summary() {
4360 let (db, _dir) = create_test_db();
4361 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4362 db.insert_session(&session).expect("insert session");
4363
4364 let summary = Summary {
4365 id: Uuid::new_v4(),
4366 session_id: session.id,
4367 content: "Session summary".to_string(),
4368 generated_at: Utc::now(),
4369 };
4370 db.insert_summary(&summary).expect("insert summary");
4371
4372 db.delete_session(&session.id).expect("delete session");
4374
4375 let retrieved = db.get_summary(&session.id).expect("get summary");
4377 assert!(retrieved.is_none());
4378 }
4379
4380 #[test]
4383 fn test_upsert_machine_insert() {
4384 let (db, _dir) = create_test_db();
4385
4386 let machine = Machine {
4387 id: "test-uuid-1234".to_string(),
4388 name: "my-laptop".to_string(),
4389 created_at: Utc::now().to_rfc3339(),
4390 };
4391
4392 db.upsert_machine(&machine)
4393 .expect("Failed to upsert machine");
4394
4395 let retrieved = db
4396 .get_machine("test-uuid-1234")
4397 .expect("Failed to get machine")
4398 .expect("Machine should exist");
4399
4400 assert_eq!(retrieved.id, "test-uuid-1234");
4401 assert_eq!(retrieved.name, "my-laptop");
4402 }
4403
4404 #[test]
4405 fn test_upsert_machine_update() {
4406 let (db, _dir) = create_test_db();
4407
4408 let machine1 = Machine {
4410 id: "test-uuid-5678".to_string(),
4411 name: "old-name".to_string(),
4412 created_at: Utc::now().to_rfc3339(),
4413 };
4414 db.upsert_machine(&machine1)
4415 .expect("Failed to upsert machine");
4416
4417 let machine2 = Machine {
4419 id: "test-uuid-5678".to_string(),
4420 name: "new-name".to_string(),
4421 created_at: Utc::now().to_rfc3339(),
4422 };
4423 db.upsert_machine(&machine2)
4424 .expect("Failed to upsert machine");
4425
4426 let retrieved = db
4428 .get_machine("test-uuid-5678")
4429 .expect("Failed to get machine")
4430 .expect("Machine should exist");
4431
4432 assert_eq!(retrieved.name, "new-name");
4433 }
4434
4435 #[test]
4436 fn test_get_machine() {
4437 let (db, _dir) = create_test_db();
4438
4439 let not_found = db.get_machine("nonexistent-uuid").expect("Failed to query");
4441 assert!(not_found.is_none(), "Machine should not exist");
4442
4443 let machine = Machine {
4445 id: "existing-uuid".to_string(),
4446 name: "test-machine".to_string(),
4447 created_at: Utc::now().to_rfc3339(),
4448 };
4449 db.upsert_machine(&machine).expect("Failed to upsert");
4450
4451 let found = db
4453 .get_machine("existing-uuid")
4454 .expect("Failed to query")
4455 .expect("Machine should exist");
4456
4457 assert_eq!(found.id, "existing-uuid");
4458 assert_eq!(found.name, "test-machine");
4459 }
4460
4461 #[test]
4462 fn test_get_machine_name_found() {
4463 let (db, _dir) = create_test_db();
4464
4465 let machine = Machine {
4466 id: "uuid-for-name-test".to_string(),
4467 name: "my-workstation".to_string(),
4468 created_at: Utc::now().to_rfc3339(),
4469 };
4470 db.upsert_machine(&machine).expect("Failed to upsert");
4471
4472 let name = db
4473 .get_machine_name("uuid-for-name-test")
4474 .expect("Failed to get name");
4475
4476 assert_eq!(name, "my-workstation");
4477 }
4478
4479 #[test]
4480 fn test_get_machine_name_not_found() {
4481 let (db, _dir) = create_test_db();
4482
4483 let name = db
4485 .get_machine_name("abc123def456789")
4486 .expect("Failed to get name");
4487
4488 assert_eq!(name, "abc123de", "Should return first 8 characters");
4489
4490 let short_name = db.get_machine_name("short").expect("Failed to get name");
4492
4493 assert_eq!(
4494 short_name, "short",
4495 "Should return full ID if shorter than 8 chars"
4496 );
4497 }
4498
4499 #[test]
4500 fn test_list_machines() {
4501 let (db, _dir) = create_test_db();
4502
4503 let machines = db.list_machines().expect("Failed to list");
4505 assert!(machines.is_empty(), "Should have no machines initially");
4506
4507 let machine1 = Machine {
4509 id: "uuid-1".to_string(),
4510 name: "machine-1".to_string(),
4511 created_at: "2024-01-01T00:00:00Z".to_string(),
4512 };
4513 let machine2 = Machine {
4514 id: "uuid-2".to_string(),
4515 name: "machine-2".to_string(),
4516 created_at: "2024-01-02T00:00:00Z".to_string(),
4517 };
4518
4519 db.upsert_machine(&machine1).expect("Failed to upsert");
4520 db.upsert_machine(&machine2).expect("Failed to upsert");
4521
4522 let machines = db.list_machines().expect("Failed to list");
4524 assert_eq!(machines.len(), 2, "Should have 2 machines");
4525
4526 assert_eq!(machines[0].id, "uuid-1");
4528 assert_eq!(machines[1].id, "uuid-2");
4529 }
4530}