1use anyhow::{Context, Result};
8use chrono::{DateTime, Utc};
9use rusqlite::{params, Connection, OptionalExtension};
10use std::collections::HashSet;
11use std::path::{Path, PathBuf};
12use uuid::Uuid;
13
14use super::models::{
15 Annotation, Machine, Memory, Message, MessageContent, MessageRole, SearchResult, Session,
16 SessionLink, Summary, Tag, Tombstone,
17};
18
19const TOMBSTONE_KIND_LINK: &str = "link";
21const TOMBSTONE_KIND_TAG: &str = "tag";
23const TOMBSTONE_KIND_ANNOTATION: &str = "annotation";
25const TOMBSTONE_KIND_SUMMARY: &str = "summary";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SyncTrack {
37 PerRepo,
39 Global,
41}
42
43impl SyncTrack {
44 fn column(self) -> &'static str {
46 match self {
47 SyncTrack::PerRepo => "synced_at",
48 SyncTrack::Global => "global_synced_at",
49 }
50 }
51}
52
53fn directory_match_params(directory: &str) -> (String, String, String) {
69 fn escape_like(input: &str) -> String {
70 let mut escaped = String::with_capacity(input.len());
71 for ch in input.chars() {
72 match ch {
73 '|' => escaped.push_str("||"),
74 '%' => escaped.push_str("|%"),
75 '_' => escaped.push_str("|_"),
76 _ => escaped.push(ch),
77 }
78 }
79 escaped
80 }
81
82 let separator = std::path::MAIN_SEPARATOR.to_string();
83 let mut normalized = directory
84 .trim_end_matches(std::path::MAIN_SEPARATOR)
85 .to_string();
86 if normalized.is_empty() {
87 normalized = separator.clone();
88 }
89 let trailing = if normalized == separator {
90 normalized.clone()
91 } else {
92 format!("{normalized}{separator}")
93 };
94 let like_pattern = format!("{}%", escape_like(&trailing));
95 (normalized, trailing, like_pattern)
96}
97
98fn repo_scope_predicate(repo_path: &Path) -> (String, Vec<String>) {
121 let mut variants: Vec<(String, String, String)> = Vec::new();
122
123 let mut add_variant = |dir: &str| {
124 let params = directory_match_params(dir);
125 if !variants.contains(¶ms) {
126 variants.push(params);
127 }
128 };
129
130 add_variant(&repo_path.to_string_lossy());
131 if let Ok(canonical) = repo_path.canonicalize() {
132 add_variant(&canonical.to_string_lossy());
133 }
134
135 let mut clauses = Vec::with_capacity(variants.len());
136 let mut binds = Vec::with_capacity(variants.len() * 3);
137 let mut next = 1;
138 for (exact, trailing, like_pattern) in variants {
139 clauses.push(format!(
140 "(working_directory = ?{} OR working_directory = ?{} OR working_directory LIKE ?{} ESCAPE '|')",
141 next,
142 next + 1,
143 next + 2
144 ));
145 binds.push(exact);
146 binds.push(trailing);
147 binds.push(like_pattern);
148 next += 3;
149 }
150
151 (clauses.join(" OR "), binds)
152}
153
154fn parse_uuid(s: &str) -> rusqlite::Result<Uuid> {
158 Uuid::parse_str(s).map_err(|e| {
159 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
160 })
161}
162
163fn parse_datetime(s: &str) -> rusqlite::Result<DateTime<Utc>> {
167 chrono::DateTime::parse_from_rfc3339(s)
168 .map(|dt| dt.with_timezone(&Utc))
169 .map_err(|e| {
170 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
171 })
172}
173
174fn escape_fts5_query(query: &str) -> String {
179 query
181 .split_whitespace()
182 .map(|word| {
183 let escaped = word.replace('"', "\"\"");
184 format!("\"{escaped}\"")
185 })
186 .collect::<Vec<_>>()
187 .join(" ")
188}
189
190pub fn default_db_path() -> Result<PathBuf> {
194 let config_dir = dirs::home_dir()
195 .context("Could not find home directory. Ensure your HOME environment variable is set.")?
196 .join(".lore");
197
198 std::fs::create_dir_all(&config_dir).with_context(|| {
199 format!(
200 "Failed to create Lore data directory at {}. Check directory permissions.",
201 config_dir.display()
202 )
203 })?;
204 Ok(config_dir.join("lore.db"))
205}
206
207pub struct Database {
213 conn: Connection,
214}
215
216impl Database {
217 pub fn open(path: &PathBuf) -> Result<Self> {
221 let conn = Connection::open(path)?;
222 let db = Self { conn };
223 db.migrate()?;
224 Ok(db)
225 }
226
227 pub fn open_default() -> Result<Self> {
231 let path = default_db_path()?;
232 Self::open(&path)
233 }
234
235 fn migrate(&self) -> Result<()> {
240 self.conn.execute_batch(
241 r#"
242 CREATE TABLE IF NOT EXISTS schema_version (
243 version INTEGER PRIMARY KEY
244 );
245
246 CREATE TABLE IF NOT EXISTS sessions (
247 id TEXT PRIMARY KEY,
248 tool TEXT NOT NULL,
249 tool_version TEXT,
250 started_at TEXT NOT NULL,
251 ended_at TEXT,
252 model TEXT,
253 working_directory TEXT NOT NULL,
254 git_branch TEXT,
255 source_path TEXT,
256 message_count INTEGER NOT NULL DEFAULT 0,
257 created_at TEXT NOT NULL DEFAULT (datetime('now')),
258 machine_id TEXT
259 );
260
261 CREATE TABLE IF NOT EXISTS messages (
262 id TEXT PRIMARY KEY,
263 session_id TEXT NOT NULL,
264 parent_id TEXT,
265 idx INTEGER NOT NULL,
266 timestamp TEXT NOT NULL,
267 role TEXT NOT NULL,
268 content TEXT NOT NULL,
269 model TEXT,
270 git_branch TEXT,
271 cwd TEXT,
272 FOREIGN KEY (session_id) REFERENCES sessions(id)
273 );
274
275 CREATE TABLE IF NOT EXISTS session_links (
276 id TEXT PRIMARY KEY,
277 session_id TEXT NOT NULL,
278 link_type TEXT NOT NULL,
279 commit_sha TEXT,
280 branch TEXT,
281 remote TEXT,
282 created_at TEXT NOT NULL,
283 created_by TEXT NOT NULL,
284 confidence REAL,
285 FOREIGN KEY (session_id) REFERENCES sessions(id)
286 );
287
288 CREATE TABLE IF NOT EXISTS repositories (
289 id TEXT PRIMARY KEY,
290 path TEXT NOT NULL UNIQUE,
291 name TEXT NOT NULL,
292 remote_url TEXT,
293 created_at TEXT NOT NULL DEFAULT (datetime('now')),
294 last_session_at TEXT
295 );
296
297 CREATE TABLE IF NOT EXISTS annotations (
298 id TEXT PRIMARY KEY,
299 session_id TEXT NOT NULL,
300 content TEXT NOT NULL,
301 created_at TEXT NOT NULL,
302 FOREIGN KEY (session_id) REFERENCES sessions(id)
303 );
304
305 CREATE TABLE IF NOT EXISTS tags (
306 id TEXT PRIMARY KEY,
307 session_id TEXT NOT NULL,
308 label TEXT NOT NULL,
309 created_at TEXT NOT NULL,
310 FOREIGN KEY (session_id) REFERENCES sessions(id),
311 UNIQUE(session_id, label)
312 );
313
314 CREATE TABLE IF NOT EXISTS summaries (
315 id TEXT PRIMARY KEY,
316 session_id TEXT NOT NULL UNIQUE,
317 content TEXT NOT NULL,
318 generated_at TEXT NOT NULL,
319 FOREIGN KEY (session_id) REFERENCES sessions(id)
320 );
321
322 CREATE TABLE IF NOT EXISTS machines (
323 id TEXT PRIMARY KEY,
324 name TEXT NOT NULL,
325 created_at TEXT NOT NULL
326 );
327
328 -- Read-only mirror of a coding tool's per-project memory store.
329 -- Each row is one markdown memory file mirrored from the tool's
330 -- memory folder. Scoped by (project_path, source_tool). The
331 -- (project_path, source_tool, file_path) triple is unique so a
332 -- refresh can upsert by source file. Lore never writes back to the
333 -- tool's own memory folder; this table is a read-only reflection.
334 CREATE TABLE IF NOT EXISTS memories (
335 id TEXT PRIMARY KEY,
336 project_path TEXT NOT NULL,
337 source_tool TEXT NOT NULL,
338 name TEXT NOT NULL,
339 description TEXT,
340 memory_type TEXT,
341 content TEXT NOT NULL,
342 file_path TEXT NOT NULL,
343 updated_at TEXT NOT NULL,
344 UNIQUE(project_path, source_tool, file_path)
345 );
346
347 -- Tombstones record locally deleted child records so a deletion on
348 -- one machine propagates through the sync store instead of being
349 -- resurrected by the additive child merge. Keyed by (child_id, kind)
350 -- so a link and a tag can never collide on the same UUID.
351 CREATE TABLE IF NOT EXISTS tombstones (
352 child_id TEXT NOT NULL,
353 kind TEXT NOT NULL CHECK(kind IN ('link','tag','annotation','summary')),
354 session_id TEXT,
355 deleted_at TEXT NOT NULL,
356 PRIMARY KEY (child_id, kind)
357 );
358
359 -- Indexes for common queries
360 CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
361 CREATE INDEX IF NOT EXISTS idx_sessions_working_directory ON sessions(working_directory);
362 CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
363 CREATE INDEX IF NOT EXISTS idx_session_links_session_id ON session_links(session_id);
364 CREATE INDEX IF NOT EXISTS idx_session_links_commit_sha ON session_links(commit_sha);
365 CREATE INDEX IF NOT EXISTS idx_annotations_session_id ON annotations(session_id);
366 CREATE INDEX IF NOT EXISTS idx_tags_session_id ON tags(session_id);
367 CREATE INDEX IF NOT EXISTS idx_tags_label ON tags(label);
368 CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
369 CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_path, source_tool);
370 "#,
371 )?;
372
373 self.conn.execute_batch(
378 r#"
379 CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
380 message_id,
381 text_content,
382 tokenize='porter unicode61'
383 );
384 "#,
385 )?;
386
387 self.conn.execute_batch(
390 r#"
391 CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
392 session_id,
393 tool,
394 working_directory,
395 git_branch,
396 tokenize='porter unicode61'
397 );
398 "#,
399 )?;
400
401 self.conn.execute_batch(
405 r#"
406 CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
407 memory_id,
408 name,
409 description,
410 content,
411 tokenize='porter unicode61'
412 );
413 "#,
414 )?;
415
416 self.migrate_add_machine_id()?;
419
420 self.migrate_add_synced_at()?;
422
423 self.migrate_add_global_synced_at()?;
425
426 Ok(())
427 }
428
429 fn migrate_add_machine_id(&self) -> Result<()> {
437 let columns: Vec<String> = self
439 .conn
440 .prepare("PRAGMA table_info(sessions)")?
441 .query_map([], |row| row.get::<_, String>(1))?
442 .collect::<Result<Vec<_>, _>>()?;
443
444 if !columns.iter().any(|c| c == "machine_id") {
445 self.conn
446 .execute("ALTER TABLE sessions ADD COLUMN machine_id TEXT", [])?;
447 }
448
449 if let Some(machine_uuid) = super::get_machine_id() {
451 self.conn.execute(
452 "UPDATE sessions SET machine_id = ?1 WHERE machine_id IS NULL",
453 [&machine_uuid],
454 )?;
455
456 if let Some(hostname) = hostname::get().ok().and_then(|h| h.into_string().ok()) {
461 self.conn.execute(
462 "UPDATE sessions SET machine_id = ?1 WHERE machine_id = ?2",
463 [&machine_uuid, &hostname],
464 )?;
465 }
466 }
467
468 Ok(())
469 }
470
471 fn migrate_add_synced_at(&self) -> Result<()> {
476 let columns: Vec<String> = self
477 .conn
478 .prepare("PRAGMA table_info(sessions)")?
479 .query_map([], |row| row.get::<_, String>(1))?
480 .collect::<Result<Vec<_>, _>>()?;
481
482 if !columns.iter().any(|c| c == "synced_at") {
483 self.conn
484 .execute("ALTER TABLE sessions ADD COLUMN synced_at TEXT", [])?;
485 }
486
487 Ok(())
488 }
489
490 fn migrate_add_global_synced_at(&self) -> Result<()> {
496 let columns: Vec<String> = self
497 .conn
498 .prepare("PRAGMA table_info(sessions)")?
499 .query_map([], |row| row.get::<_, String>(1))?
500 .collect::<Result<Vec<_>, _>>()?;
501
502 if !columns.iter().any(|c| c == "global_synced_at") {
503 self.conn
504 .execute("ALTER TABLE sessions ADD COLUMN global_synced_at TEXT", [])?;
505 }
506
507 Ok(())
508 }
509
510 pub fn insert_session(&self, session: &Session) -> Result<()> {
522 let rows_changed = self.conn.execute(
523 r#"
524 INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id)
525 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
526 ON CONFLICT(id) DO UPDATE SET
527 ended_at = ?5,
528 message_count = ?10,
529 synced_at = CASE
530 WHEN message_count != ?10 THEN NULL
531 WHEN (ended_at IS NULL AND ?5 IS NOT NULL) THEN NULL
532 WHEN (ended_at IS NOT NULL AND ?5 IS NULL) THEN NULL
533 WHEN ended_at != ?5 THEN NULL
534 ELSE synced_at
535 END,
536 global_synced_at = CASE
537 WHEN message_count != ?10 THEN NULL
538 WHEN (ended_at IS NULL AND ?5 IS NOT NULL) THEN NULL
539 WHEN (ended_at IS NOT NULL AND ?5 IS NULL) THEN NULL
540 WHEN ended_at != ?5 THEN NULL
541 ELSE global_synced_at
542 END
543 "#,
544 params![
545 session.id.to_string(),
546 session.tool,
547 session.tool_version,
548 session.started_at.to_rfc3339(),
549 session.ended_at.map(|t| t.to_rfc3339()),
550 session.model,
551 session.working_directory,
552 session.git_branch,
553 session.source_path,
554 session.message_count,
555 session.machine_id,
556 ],
557 )?;
558
559 if rows_changed > 0 {
561 let fts_count: i32 = self.conn.query_row(
563 "SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
564 params![session.id.to_string()],
565 |row| row.get(0),
566 )?;
567
568 if fts_count == 0 {
569 self.conn.execute(
570 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
571 params![
572 session.id.to_string(),
573 session.tool,
574 session.working_directory,
575 session.git_branch.as_deref().unwrap_or(""),
576 ],
577 )?;
578 }
579 }
580
581 Ok(())
582 }
583
584 pub fn get_session(&self, id: &Uuid) -> Result<Option<Session>> {
588 self.conn
589 .query_row(
590 "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",
591 params![id.to_string()],
592 Self::row_to_session,
593 )
594 .optional()
595 .context("Failed to get session")
596 }
597
598 pub fn list_sessions(&self, limit: usize, working_dir: Option<&str>) -> Result<Vec<Session>> {
603 let mut stmt = if working_dir.is_some() {
604 self.conn.prepare(
605 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
606 FROM sessions
607 WHERE working_directory LIKE ?1
608 ORDER BY started_at DESC
609 LIMIT ?2"
610 )?
611 } else {
612 self.conn.prepare(
613 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
614 FROM sessions
615 ORDER BY started_at DESC
616 LIMIT ?1"
617 )?
618 };
619
620 let rows = if let Some(wd) = working_dir {
621 stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
622 } else {
623 stmt.query_map(params![limit], Self::row_to_session)?
624 };
625
626 rows.collect::<Result<Vec<_>, _>>()
627 .context("Failed to list sessions")
628 }
629
630 pub fn list_ended_sessions(
634 &self,
635 limit: usize,
636 working_dir: Option<&str>,
637 ) -> Result<Vec<Session>> {
638 let mut stmt = if working_dir.is_some() {
639 self.conn.prepare(
640 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
641 FROM sessions
642 WHERE ended_at IS NOT NULL
643 AND working_directory LIKE ?1
644 ORDER BY started_at DESC
645 LIMIT ?2",
646 )?
647 } else {
648 self.conn.prepare(
649 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
650 FROM sessions
651 WHERE ended_at IS NOT NULL
652 ORDER BY started_at DESC
653 LIMIT ?1",
654 )?
655 };
656
657 let rows = if let Some(wd) = working_dir {
658 stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
659 } else {
660 stmt.query_map(params![limit], Self::row_to_session)?
661 };
662
663 rows.collect::<Result<Vec<_>, _>>()
664 .context("Failed to list ended sessions")
665 }
666
667 pub fn session_exists_by_source(&self, source_path: &str) -> Result<bool> {
671 let count: i32 = self.conn.query_row(
672 "SELECT COUNT(*) FROM sessions WHERE source_path = ?1",
673 params![source_path],
674 |row| row.get(0),
675 )?;
676 Ok(count > 0)
677 }
678
679 pub fn get_session_by_source(&self, source_path: &str) -> Result<Option<Session>> {
684 self.conn
685 .query_row(
686 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id FROM sessions WHERE source_path = ?1",
687 params![source_path],
688 Self::row_to_session,
689 )
690 .optional()
691 .context("Failed to get session by source path")
692 }
693
694 pub fn find_session_by_id_prefix(&self, prefix: &str) -> Result<Option<Session>> {
710 if let Ok(uuid) = Uuid::parse_str(prefix) {
712 return self.get_session(&uuid);
713 }
714
715 let pattern = format!("{prefix}%");
717
718 let count: i32 = self.conn.query_row(
720 "SELECT COUNT(*) FROM sessions WHERE id LIKE ?1",
721 params![pattern],
722 |row| row.get(0),
723 )?;
724
725 match count {
726 0 => Ok(None),
727 1 => {
728 self.conn
730 .query_row(
731 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
732 FROM sessions
733 WHERE id LIKE ?1",
734 params![pattern],
735 Self::row_to_session,
736 )
737 .optional()
738 .context("Failed to find session by prefix")
739 }
740 n => {
741 anyhow::bail!(
743 "Ambiguous session ID prefix '{prefix}' matches {n} sessions. Use a longer prefix."
744 )
745 }
746 }
747 }
748
749 pub fn update_session_branch(&self, session_id: Uuid, new_branch: &str) -> Result<usize> {
757 let rows_changed = self.conn.execute(
758 "UPDATE sessions SET git_branch = ?1 WHERE id = ?2",
759 params![new_branch, session_id.to_string()],
760 )?;
761
762 if rows_changed > 0 {
764 self.conn.execute(
765 "UPDATE sessions_fts SET git_branch = ?1 WHERE session_id = ?2",
766 params![new_branch, session_id.to_string()],
767 )?;
768 }
769
770 Ok(rows_changed)
771 }
772
773 fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<Session> {
774 let ended_at_str: Option<String> = row.get(4)?;
775 let ended_at = match ended_at_str {
776 Some(s) => Some(parse_datetime(&s)?),
777 None => None,
778 };
779
780 Ok(Session {
781 id: parse_uuid(&row.get::<_, String>(0)?)?,
782 tool: row.get(1)?,
783 tool_version: row.get(2)?,
784 started_at: parse_datetime(&row.get::<_, String>(3)?)?,
785 ended_at,
786 model: row.get(5)?,
787 working_directory: row.get(6)?,
788 git_branch: row.get(7)?,
789 source_path: row.get(8)?,
790 message_count: row.get(9)?,
791 machine_id: row.get(10)?,
792 })
793 }
794
795 pub fn insert_message(&self, message: &Message) -> Result<()> {
803 let content_json = serde_json::to_string(&message.content)?;
804
805 let rows_changed = self.conn.execute(
806 r#"
807 INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
808 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
809 ON CONFLICT(id) DO NOTHING
810 "#,
811 params![
812 message.id.to_string(),
813 message.session_id.to_string(),
814 message.parent_id.map(|u| u.to_string()),
815 message.index,
816 message.timestamp.to_rfc3339(),
817 message.role.to_string(),
818 content_json,
819 message.model,
820 message.git_branch,
821 message.cwd,
822 ],
823 )?;
824
825 if rows_changed > 0 {
827 let text_content = message.content.text();
828 if !text_content.is_empty() {
829 self.conn.execute(
830 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
831 params![message.id.to_string(), text_content],
832 )?;
833 }
834 }
835
836 Ok(())
837 }
838
839 #[allow(dead_code)]
848 pub fn import_session_with_messages(
849 &mut self,
850 session: &Session,
851 messages: &[Message],
852 synced_at: Option<DateTime<Utc>>,
853 ) -> Result<()> {
854 let tx = self.conn.transaction()?;
855 Self::write_session_with_messages(&tx, session, messages, synced_at, SyncTrack::PerRepo)?;
856 tx.commit()?;
857 Ok(())
858 }
859
860 fn write_session_with_messages(
872 conn: &Connection,
873 session: &Session,
874 messages: &[Message],
875 synced_at: Option<DateTime<Utc>>,
876 track: SyncTrack,
877 ) -> Result<()> {
878 let col = track.column();
881 let insert_sql = format!(
882 "INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id, {col})
883 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
884 ON CONFLICT(id) DO UPDATE SET
885 ended_at = ?5,
886 message_count = ?10,
887 {col} = COALESCE(?12, {col})"
888 );
889 conn.execute(
890 &insert_sql,
891 params![
892 session.id.to_string(),
893 session.tool,
894 session.tool_version,
895 session.started_at.to_rfc3339(),
896 session.ended_at.map(|t| t.to_rfc3339()),
897 session.model,
898 session.working_directory,
899 session.git_branch,
900 session.source_path,
901 session.message_count,
902 session.machine_id,
903 synced_at.map(|t| t.to_rfc3339()),
904 ],
905 )?;
906
907 let fts_count: i32 = conn.query_row(
909 "SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
910 params![session.id.to_string()],
911 |row| row.get(0),
912 )?;
913 if fts_count == 0 {
914 conn.execute(
915 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
916 params![
917 session.id.to_string(),
918 session.tool,
919 session.working_directory,
920 session.git_branch.as_deref().unwrap_or(""),
921 ],
922 )?;
923 }
924
925 for message in messages {
927 let content_json = serde_json::to_string(&message.content)?;
928
929 let rows_changed = conn.execute(
930 r#"
931 INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
932 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
933 ON CONFLICT(id) DO NOTHING
934 "#,
935 params![
936 message.id.to_string(),
937 message.session_id.to_string(),
938 message.parent_id.map(|u| u.to_string()),
939 message.index,
940 message.timestamp.to_rfc3339(),
941 message.role.to_string(),
942 content_json,
943 message.model,
944 message.git_branch,
945 message.cwd,
946 ],
947 )?;
948
949 if rows_changed > 0 {
951 let text_content = message.content.text();
952 if !text_content.is_empty() {
953 conn.execute(
954 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
955 params![message.id.to_string(), text_content],
956 )?;
957 }
958 }
959 }
960
961 Ok(())
962 }
963
964 fn mark_session_unsynced(&self, session_id: &Uuid) -> Result<()> {
976 self.conn.execute(
977 "UPDATE sessions SET synced_at = NULL, global_synced_at = NULL WHERE id = ?1",
978 params![session_id.to_string()],
979 )?;
980 Ok(())
981 }
982
983 fn record_tombstone(
993 conn: &Connection,
994 child_id: &str,
995 kind: &str,
996 session_id: &Uuid,
997 deleted_at: DateTime<Utc>,
998 ) -> Result<()> {
999 conn.execute(
1000 r#"
1001 INSERT INTO tombstones (child_id, kind, session_id, deleted_at)
1002 VALUES (?1, ?2, ?3, ?4)
1003 ON CONFLICT(child_id, kind) DO UPDATE SET
1004 session_id = excluded.session_id,
1005 deleted_at = excluded.deleted_at
1006 "#,
1007 params![
1008 child_id,
1009 kind,
1010 session_id.to_string(),
1011 deleted_at.to_rfc3339()
1012 ],
1013 )?;
1014 Ok(())
1015 }
1016
1017 fn child_ids(&self, table: &str, session_id: &Uuid) -> Result<Vec<String>> {
1023 let sql = format!("SELECT id FROM {table} WHERE session_id = ?1");
1024 let mut stmt = self.conn.prepare(&sql)?;
1025 let rows = stmt.query_map(params![session_id.to_string()], |row| row.get(0))?;
1026 rows.collect::<Result<Vec<_>, _>>()
1027 .context("Failed to read child ids")
1028 }
1029
1030 fn is_tombstoned(conn: &Connection, child_id: &Uuid, kind: &str) -> Result<bool> {
1035 let count: i64 = conn.query_row(
1036 "SELECT COUNT(*) FROM tombstones WHERE child_id = ?1 AND kind = ?2",
1037 params![child_id.to_string(), kind],
1038 |row| row.get(0),
1039 )?;
1040 Ok(count > 0)
1041 }
1042
1043 pub fn list_tombstones(&self) -> Result<Vec<Tombstone>> {
1048 let mut stmt = self.conn.prepare(
1049 "SELECT child_id, kind, session_id, deleted_at
1050 FROM tombstones
1051 ORDER BY kind ASC, child_id ASC",
1052 )?;
1053 let rows = stmt.query_map([], |row| {
1054 let deleted_at: String = row.get(3)?;
1055 Ok(Tombstone {
1056 child_id: row.get(0)?,
1057 kind: row.get(1)?,
1058 session_id: row.get(2)?,
1059 deleted_at: parse_datetime(&deleted_at)?,
1060 })
1061 })?;
1062 rows.collect::<Result<Vec<_>, _>>()
1063 .context("Failed to list tombstones")
1064 }
1065
1066 pub fn add_tombstones(&self, tombstones: &[Tombstone]) -> Result<()> {
1073 for t in tombstones {
1074 self.conn.execute(
1075 r#"
1076 INSERT INTO tombstones (child_id, kind, session_id, deleted_at)
1077 VALUES (?1, ?2, ?3, ?4)
1078 ON CONFLICT(child_id, kind) DO NOTHING
1079 "#,
1080 params![t.child_id, t.kind, t.session_id, t.deleted_at.to_rfc3339()],
1081 )?;
1082 }
1083 Ok(())
1084 }
1085
1086 pub fn apply_tombstones(&self, tombstones: &[Tombstone]) -> Result<()> {
1097 let mut affected: HashSet<Uuid> = HashSet::new();
1098 for t in tombstones {
1099 let deleted = match t.kind.as_str() {
1100 TOMBSTONE_KIND_LINK => self.conn.execute(
1101 "DELETE FROM session_links WHERE id = ?1",
1102 params![t.child_id],
1103 )?,
1104 TOMBSTONE_KIND_TAG => self
1105 .conn
1106 .execute("DELETE FROM tags WHERE id = ?1", params![t.child_id])?,
1107 TOMBSTONE_KIND_ANNOTATION => self
1108 .conn
1109 .execute("DELETE FROM annotations WHERE id = ?1", params![t.child_id])?,
1110 TOMBSTONE_KIND_SUMMARY => self
1111 .conn
1112 .execute("DELETE FROM summaries WHERE id = ?1", params![t.child_id])?,
1113 _ => 0,
1114 };
1115 if deleted > 0 {
1116 if let Some(sid) = &t.session_id {
1117 if let Ok(uuid) = parse_uuid(sid) {
1118 affected.insert(uuid);
1119 }
1120 }
1121 }
1122 }
1123 for session_id in affected {
1124 self.mark_session_unsynced(&session_id)?;
1125 }
1126 Ok(())
1127 }
1128
1129 #[allow(dead_code)]
1140 pub fn prune_tombstones(&self, before: DateTime<Utc>) -> Result<usize> {
1141 let rows = self.conn.execute(
1142 "DELETE FROM tombstones WHERE deleted_at < ?1",
1143 params![before.to_rfc3339()],
1144 )?;
1145 Ok(rows)
1146 }
1147
1148 #[allow(clippy::too_many_arguments)]
1179 pub fn merge_remote_record(
1180 &mut self,
1181 session: &Session,
1182 messages: &[Message],
1183 links: &[SessionLink],
1184 tags: &[Tag],
1185 annotations: &[Annotation],
1186 summary: Option<&Summary>,
1187 synced_at: DateTime<Utc>,
1188 ) -> Result<bool> {
1189 self.merge_remote_record_tracked(
1190 session,
1191 messages,
1192 links,
1193 tags,
1194 annotations,
1195 summary,
1196 synced_at,
1197 SyncTrack::PerRepo,
1198 )
1199 }
1200
1201 #[allow(clippy::too_many_arguments)]
1207 pub fn merge_remote_record_global(
1208 &mut self,
1209 session: &Session,
1210 messages: &[Message],
1211 links: &[SessionLink],
1212 tags: &[Tag],
1213 annotations: &[Annotation],
1214 summary: Option<&Summary>,
1215 synced_at: DateTime<Utc>,
1216 ) -> Result<bool> {
1217 self.merge_remote_record_tracked(
1218 session,
1219 messages,
1220 links,
1221 tags,
1222 annotations,
1223 summary,
1224 synced_at,
1225 SyncTrack::Global,
1226 )
1227 }
1228
1229 #[allow(clippy::too_many_arguments)]
1235 fn merge_remote_record_tracked(
1236 &mut self,
1237 session: &Session,
1238 messages: &[Message],
1239 links: &[SessionLink],
1240 tags: &[Tag],
1241 annotations: &[Annotation],
1242 summary: Option<&Summary>,
1243 synced_at: DateTime<Utc>,
1244 track: SyncTrack,
1245 ) -> Result<bool> {
1246 let tx = self.conn.transaction()?;
1247
1248 let existing: Option<(i32, Option<String>)> = tx
1251 .query_row(
1252 "SELECT message_count, ended_at FROM sessions WHERE id = ?1",
1253 params![session.id.to_string()],
1254 |row| Ok((row.get(0)?, row.get(1)?)),
1255 )
1256 .optional()?;
1257
1258 let import_session = match &existing {
1259 None => true,
1260 Some((local_count, local_ended)) => {
1261 Self::remote_row_is_newer(session, *local_count, local_ended.as_deref())?
1262 }
1263 };
1264
1265 if import_session {
1266 Self::write_session_with_messages(&tx, session, messages, Some(synced_at), track)?;
1267 }
1268
1269 for link in links {
1276 if !Self::is_tombstoned(&tx, &link.id, TOMBSTONE_KIND_LINK)? {
1277 Self::write_link(&tx, link, true)?;
1278 }
1279 }
1280 for tag in tags {
1281 if !Self::is_tombstoned(&tx, &tag.id, TOMBSTONE_KIND_TAG)? {
1282 Self::write_tag(&tx, tag, true)?;
1283 }
1284 }
1285 for annotation in annotations {
1286 if !Self::is_tombstoned(&tx, &annotation.id, TOMBSTONE_KIND_ANNOTATION)? {
1287 Self::write_annotation(&tx, annotation, true)?;
1288 }
1289 }
1290 if let Some(summary) = summary {
1291 if !Self::is_tombstoned(&tx, &summary.id, TOMBSTONE_KIND_SUMMARY)? {
1292 Self::write_summary_newer(&tx, summary)?;
1293 }
1294 }
1295
1296 tx.commit()?;
1297 Ok(import_session)
1298 }
1299
1300 fn remote_row_is_newer(
1307 remote: &Session,
1308 local_count: i32,
1309 local_ended: Option<&str>,
1310 ) -> Result<bool> {
1311 if remote.message_count > local_count {
1312 return Ok(true);
1313 }
1314 let local_ended = match local_ended {
1315 Some(s) => Some(parse_datetime(s)?),
1316 None => None,
1317 };
1318 Ok(match (remote.ended_at, local_ended) {
1319 (Some(r), Some(l)) => r > l,
1320 (Some(_), None) => true,
1321 _ => false,
1322 })
1323 }
1324
1325 pub fn get_messages(&self, session_id: &Uuid) -> Result<Vec<Message>> {
1329 let mut stmt = self.conn.prepare(
1330 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1331 FROM messages
1332 WHERE session_id = ?1
1333 ORDER BY idx"
1334 )?;
1335
1336 let rows = stmt.query_map(params![session_id.to_string()], |row| {
1337 let role_str: String = row.get(5)?;
1338 let content_str: String = row.get(6)?;
1339
1340 let parent_id_str: Option<String> = row.get(2)?;
1341 let parent_id = match parent_id_str {
1342 Some(s) => Some(parse_uuid(&s)?),
1343 None => None,
1344 };
1345
1346 Ok(Message {
1347 id: parse_uuid(&row.get::<_, String>(0)?)?,
1348 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1349 parent_id,
1350 index: row.get(3)?,
1351 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1352 role: match role_str.as_str() {
1353 "user" => MessageRole::User,
1354 "assistant" => MessageRole::Assistant,
1355 "system" => MessageRole::System,
1356 _ => MessageRole::User,
1357 },
1358 content: serde_json::from_str(&content_str)
1359 .unwrap_or(MessageContent::Text(content_str)),
1360 model: row.get(7)?,
1361 git_branch: row.get(8)?,
1362 cwd: row.get(9)?,
1363 })
1364 })?;
1365
1366 rows.collect::<Result<Vec<_>, _>>()
1367 .context("Failed to get messages")
1368 }
1369
1370 pub fn get_session_branch_history(&self, session_id: Uuid) -> Result<Vec<String>> {
1379 let mut stmt = self
1380 .conn
1381 .prepare("SELECT git_branch FROM messages WHERE session_id = ?1 ORDER BY idx")?;
1382
1383 let rows = stmt.query_map(params![session_id.to_string()], |row| {
1384 let branch: Option<String> = row.get(0)?;
1385 Ok(branch)
1386 })?;
1387
1388 let mut branches: Vec<String> = Vec::new();
1390 for row in rows {
1391 if let Some(branch) = row? {
1392 if branches.last() != Some(&branch) {
1394 branches.push(branch);
1395 }
1396 }
1397 }
1398
1399 Ok(branches)
1400 }
1401
1402 pub fn insert_link(&self, link: &SessionLink) -> Result<()> {
1409 Self::write_link(&self.conn, link, false)?;
1410 self.mark_session_unsynced(&link.session_id)?;
1412 Ok(())
1413 }
1414
1415 fn write_link(conn: &Connection, link: &SessionLink, ignore_conflict: bool) -> Result<()> {
1422 let sql = if ignore_conflict {
1423 r#"
1424 INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
1425 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1426 ON CONFLICT(id) DO NOTHING
1427 "#
1428 } else {
1429 r#"
1430 INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
1431 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1432 "#
1433 };
1434 conn.execute(
1435 sql,
1436 params![
1437 link.id.to_string(),
1438 link.session_id.to_string(),
1439 format!("{:?}", link.link_type).to_lowercase(),
1440 link.commit_sha,
1441 link.branch,
1442 link.remote,
1443 link.created_at.to_rfc3339(),
1444 format!("{:?}", link.created_by).to_lowercase(),
1445 link.confidence,
1446 ],
1447 )?;
1448 Ok(())
1449 }
1450
1451 pub fn get_links_by_commit(&self, commit_sha: &str) -> Result<Vec<SessionLink>> {
1456 let mut stmt = self.conn.prepare(
1457 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
1458 FROM session_links
1459 WHERE commit_sha LIKE ?1"
1460 )?;
1461
1462 let pattern = format!("{commit_sha}%");
1463 let rows = stmt.query_map(params![pattern], Self::row_to_link)?;
1464
1465 rows.collect::<Result<Vec<_>, _>>()
1466 .context("Failed to get links")
1467 }
1468
1469 pub fn get_links_by_session(&self, session_id: &Uuid) -> Result<Vec<SessionLink>> {
1474 let mut stmt = self.conn.prepare(
1475 "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
1476 FROM session_links
1477 WHERE session_id = ?1"
1478 )?;
1479
1480 let rows = stmt.query_map(params![session_id.to_string()], Self::row_to_link)?;
1481
1482 rows.collect::<Result<Vec<_>, _>>()
1483 .context("Failed to get links")
1484 }
1485
1486 fn row_to_link(row: &rusqlite::Row) -> rusqlite::Result<SessionLink> {
1487 use super::models::{LinkCreator, LinkType};
1488
1489 let link_type_str: String = row.get(2)?;
1490 let created_by_str: String = row.get(7)?;
1491
1492 Ok(SessionLink {
1493 id: parse_uuid(&row.get::<_, String>(0)?)?,
1494 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1495 link_type: match link_type_str.as_str() {
1496 "commit" => LinkType::Commit,
1497 "branch" => LinkType::Branch,
1498 "pr" => LinkType::Pr,
1499 _ => LinkType::Manual,
1500 },
1501 commit_sha: row.get(3)?,
1502 branch: row.get(4)?,
1503 remote: row.get(5)?,
1504 created_at: parse_datetime(&row.get::<_, String>(6)?)?,
1505 created_by: match created_by_str.as_str() {
1506 "auto" => LinkCreator::Auto,
1507 _ => LinkCreator::User,
1508 },
1509 confidence: row.get(8)?,
1510 })
1511 }
1512
1513 #[allow(dead_code)]
1520 pub fn delete_link(&self, link_id: &Uuid) -> Result<bool> {
1521 let session_id: Option<String> = self
1524 .conn
1525 .query_row(
1526 "SELECT session_id FROM session_links WHERE id = ?1",
1527 params![link_id.to_string()],
1528 |row| row.get(0),
1529 )
1530 .optional()?;
1531 let rows_affected = self.conn.execute(
1532 "DELETE FROM session_links WHERE id = ?1",
1533 params![link_id.to_string()],
1534 )?;
1535 if rows_affected > 0 {
1536 if let Some(sid) = session_id {
1537 let sid = parse_uuid(&sid)?;
1538 Self::record_tombstone(
1540 &self.conn,
1541 &link_id.to_string(),
1542 TOMBSTONE_KIND_LINK,
1543 &sid,
1544 Utc::now(),
1545 )?;
1546 self.mark_session_unsynced(&sid)?;
1547 }
1548 }
1549 Ok(rows_affected > 0)
1550 }
1551
1552 pub fn delete_links_by_session(&self, session_id: &Uuid) -> Result<usize> {
1556 let ids = self.child_ids("session_links", session_id)?;
1559 let rows_affected = self.conn.execute(
1560 "DELETE FROM session_links WHERE session_id = ?1",
1561 params![session_id.to_string()],
1562 )?;
1563 if rows_affected > 0 {
1564 let now = Utc::now();
1565 for id in &ids {
1566 Self::record_tombstone(&self.conn, id, TOMBSTONE_KIND_LINK, session_id, now)?;
1567 }
1568 self.mark_session_unsynced(session_id)?;
1570 }
1571 Ok(rows_affected)
1572 }
1573
1574 pub fn delete_link_by_session_and_commit(
1579 &self,
1580 session_id: &Uuid,
1581 commit_sha: &str,
1582 ) -> Result<bool> {
1583 let pattern = format!("{commit_sha}%");
1584 let ids: Vec<String> = {
1587 let mut stmt = self.conn.prepare(
1588 "SELECT id FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1589 )?;
1590 let rows =
1591 stmt.query_map(params![session_id.to_string(), pattern], |row| row.get(0))?;
1592 rows.collect::<Result<Vec<_>, _>>()
1593 .context("Failed to read link ids")?
1594 };
1595 let rows_affected = self.conn.execute(
1596 "DELETE FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1597 params![session_id.to_string(), pattern],
1598 )?;
1599 if rows_affected > 0 {
1600 let now = Utc::now();
1601 for id in &ids {
1602 Self::record_tombstone(&self.conn, id, TOMBSTONE_KIND_LINK, session_id, now)?;
1603 }
1604 self.mark_session_unsynced(session_id)?;
1606 }
1607 Ok(rows_affected > 0)
1608 }
1609
1610 #[allow(dead_code)]
1624 pub fn search_messages(
1625 &self,
1626 query: &str,
1627 limit: usize,
1628 working_dir: Option<&str>,
1629 since: Option<chrono::DateTime<chrono::Utc>>,
1630 role: Option<&str>,
1631 ) -> Result<Vec<SearchResult>> {
1632 use super::models::SearchOptions;
1633
1634 let options = SearchOptions {
1636 query: query.to_string(),
1637 limit,
1638 repo: working_dir.map(|s| s.to_string()),
1639 since,
1640 role: role.map(|s| s.to_string()),
1641 ..Default::default()
1642 };
1643
1644 self.search_with_options(&options)
1645 }
1646
1647 pub fn search_with_options(
1661 &self,
1662 options: &super::models::SearchOptions,
1663 ) -> Result<Vec<SearchResult>> {
1664 let escaped_query = escape_fts5_query(&options.query);
1666
1667 let mut sql = String::from(
1670 r#"
1671 SELECT
1672 m.session_id,
1673 m.id as message_id,
1674 m.role,
1675 snippet(messages_fts, 1, '**', '**', '...', 32) as snippet,
1676 m.timestamp,
1677 s.working_directory,
1678 s.tool,
1679 s.git_branch,
1680 s.message_count,
1681 s.started_at,
1682 m.idx as message_index
1683 FROM messages_fts fts
1684 JOIN messages m ON fts.message_id = m.id
1685 JOIN sessions s ON m.session_id = s.id
1686 WHERE messages_fts MATCH ?1
1687 "#,
1688 );
1689
1690 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(escaped_query.clone())];
1691 let mut param_idx = 2;
1692
1693 if options.repo.is_some() {
1695 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1696 param_idx += 1;
1697 }
1698 if options.tool.is_some() {
1699 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
1700 param_idx += 1;
1701 }
1702 if options.since.is_some() {
1703 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
1704 param_idx += 1;
1705 }
1706 if options.until.is_some() {
1707 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
1708 param_idx += 1;
1709 }
1710 if options.project.is_some() {
1711 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1712 param_idx += 1;
1713 }
1714 if options.branch.is_some() {
1715 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
1716 param_idx += 1;
1717 }
1718 if options.role.is_some() {
1719 sql.push_str(&format!(" AND m.role = ?{param_idx}"));
1720 param_idx += 1;
1721 }
1722
1723 if let Some(ref wd) = options.repo {
1725 params_vec.push(Box::new(format!("{wd}%")));
1726 }
1727 if let Some(ref tool) = options.tool {
1728 params_vec.push(Box::new(tool.clone()));
1729 }
1730 if let Some(ts) = options.since {
1731 params_vec.push(Box::new(ts.to_rfc3339()));
1732 }
1733 if let Some(ts) = options.until {
1734 params_vec.push(Box::new(ts.to_rfc3339()));
1735 }
1736 if let Some(ref project) = options.project {
1737 params_vec.push(Box::new(format!("%{project}%")));
1738 }
1739 if let Some(ref branch) = options.branch {
1740 params_vec.push(Box::new(format!("%{branch}%")));
1741 }
1742 if let Some(ref role) = options.role {
1743 params_vec.push(Box::new(role.clone()));
1744 }
1745
1746 let include_metadata_search = options.role.is_none();
1750 let metadata_query_pattern = format!("%{}%", options.query);
1751
1752 if include_metadata_search {
1753 let meta_param1 = param_idx;
1755 let meta_param2 = param_idx + 1;
1756 let meta_param3 = param_idx + 2;
1757 param_idx += 3;
1758
1759 sql.push_str(&format!(
1760 r#"
1761 UNION
1762 SELECT
1763 s.id as session_id,
1764 (SELECT id FROM messages WHERE session_id = s.id ORDER BY idx LIMIT 1) as message_id,
1765 'user' as role,
1766 substr(s.tool || ' session in ' || s.working_directory || COALESCE(' on branch ' || s.git_branch, ''), 1, 100) as snippet,
1767 s.started_at as timestamp,
1768 s.working_directory,
1769 s.tool,
1770 s.git_branch,
1771 s.message_count,
1772 s.started_at,
1773 0 as message_index
1774 FROM sessions s
1775 WHERE (
1776 s.tool LIKE ?{meta_param1}
1777 OR s.working_directory LIKE ?{meta_param2}
1778 OR s.git_branch LIKE ?{meta_param3}
1779 )
1780 "#
1781 ));
1782
1783 params_vec.push(Box::new(metadata_query_pattern.clone()));
1785 params_vec.push(Box::new(metadata_query_pattern.clone()));
1786 params_vec.push(Box::new(metadata_query_pattern));
1787
1788 if let Some(repo) = &options.repo {
1790 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1791 params_vec.push(Box::new(format!("{}%", repo)));
1792 param_idx += 1;
1793 }
1794 if let Some(tool) = &options.tool {
1795 sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
1796 params_vec.push(Box::new(tool.clone()));
1797 param_idx += 1;
1798 }
1799 if let Some(since) = options.since {
1800 sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
1801 params_vec.push(Box::new(since.to_rfc3339()));
1802 param_idx += 1;
1803 }
1804 if let Some(until) = options.until {
1805 sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
1806 params_vec.push(Box::new(until.to_rfc3339()));
1807 param_idx += 1;
1808 }
1809 if let Some(project) = &options.project {
1810 sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1811 params_vec.push(Box::new(format!("%{}%", project)));
1812 param_idx += 1;
1813 }
1814 if let Some(branch) = &options.branch {
1815 sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
1816 params_vec.push(Box::new(format!("%{}%", branch)));
1817 param_idx += 1;
1818 }
1819 }
1820
1821 sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{param_idx}"));
1822 params_vec.push(Box::new(options.limit as i64));
1823
1824 let mut stmt = self.conn.prepare(&sql)?;
1826 let params_refs: Vec<&dyn rusqlite::ToSql> =
1827 params_vec.iter().map(|p| p.as_ref()).collect();
1828
1829 let rows = stmt.query_map(params_refs.as_slice(), |row| {
1830 let role_str: String = row.get(2)?;
1831 let git_branch: Option<String> = row.get(7)?;
1832 let started_at_str: Option<String> = row.get(9)?;
1833
1834 Ok(SearchResult {
1835 session_id: parse_uuid(&row.get::<_, String>(0)?)?,
1836 message_id: parse_uuid(&row.get::<_, String>(1)?)?,
1837 role: match role_str.as_str() {
1838 "user" => MessageRole::User,
1839 "assistant" => MessageRole::Assistant,
1840 "system" => MessageRole::System,
1841 _ => MessageRole::User,
1842 },
1843 snippet: row.get(3)?,
1844 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1845 working_directory: row.get(5)?,
1846 tool: row.get(6)?,
1847 git_branch,
1848 session_message_count: row.get(8)?,
1849 session_started_at: started_at_str.map(|s| parse_datetime(&s)).transpose()?,
1850 message_index: row.get(10)?,
1851 })
1852 })?;
1853
1854 rows.collect::<Result<Vec<_>, _>>()
1855 .context("Failed to search messages")
1856 }
1857
1858 pub fn get_context_messages(
1863 &self,
1864 session_id: &Uuid,
1865 message_index: i32,
1866 context_count: usize,
1867 ) -> Result<(Vec<Message>, Vec<Message>)> {
1868 let mut before_stmt = self.conn.prepare(
1870 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1871 FROM messages
1872 WHERE session_id = ?1 AND idx < ?2
1873 ORDER BY idx DESC
1874 LIMIT ?3",
1875 )?;
1876
1877 let before_rows = before_stmt.query_map(
1878 params![session_id.to_string(), message_index, context_count as i64],
1879 Self::row_to_message,
1880 )?;
1881
1882 let mut before: Vec<Message> = before_rows
1883 .collect::<Result<Vec<_>, _>>()
1884 .context("Failed to get before messages")?;
1885 before.reverse(); let mut after_stmt = self.conn.prepare(
1889 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1890 FROM messages
1891 WHERE session_id = ?1 AND idx > ?2
1892 ORDER BY idx ASC
1893 LIMIT ?3",
1894 )?;
1895
1896 let after_rows = after_stmt.query_map(
1897 params![session_id.to_string(), message_index, context_count as i64],
1898 Self::row_to_message,
1899 )?;
1900
1901 let after: Vec<Message> = after_rows
1902 .collect::<Result<Vec<_>, _>>()
1903 .context("Failed to get after messages")?;
1904
1905 Ok((before, after))
1906 }
1907
1908 #[allow(dead_code)]
1910 pub fn get_message_by_index(&self, session_id: &Uuid, index: i32) -> Result<Option<Message>> {
1911 self.conn
1912 .query_row(
1913 "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1914 FROM messages
1915 WHERE session_id = ?1 AND idx = ?2",
1916 params![session_id.to_string(), index],
1917 Self::row_to_message,
1918 )
1919 .optional()
1920 .context("Failed to get message by index")
1921 }
1922
1923 fn row_to_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
1924 let role_str: String = row.get(5)?;
1925 let content_str: String = row.get(6)?;
1926
1927 let parent_id_str: Option<String> = row.get(2)?;
1928 let parent_id = match parent_id_str {
1929 Some(s) => Some(parse_uuid(&s)?),
1930 None => None,
1931 };
1932
1933 Ok(Message {
1934 id: parse_uuid(&row.get::<_, String>(0)?)?,
1935 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1936 parent_id,
1937 index: row.get(3)?,
1938 timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1939 role: match role_str.as_str() {
1940 "user" => MessageRole::User,
1941 "assistant" => MessageRole::Assistant,
1942 "system" => MessageRole::System,
1943 _ => MessageRole::User,
1944 },
1945 content: serde_json::from_str(&content_str)
1946 .unwrap_or(MessageContent::Text(content_str)),
1947 model: row.get(7)?,
1948 git_branch: row.get(8)?,
1949 cwd: row.get(9)?,
1950 })
1951 }
1952
1953 pub fn rebuild_search_index(&self) -> Result<usize> {
1961 self.conn.execute("DELETE FROM messages_fts", [])?;
1963 self.conn.execute("DELETE FROM sessions_fts", [])?;
1964
1965 let mut msg_stmt = self.conn.prepare("SELECT id, content FROM messages")?;
1967
1968 let rows = msg_stmt.query_map([], |row| {
1969 let id: String = row.get(0)?;
1970 let content_json: String = row.get(1)?;
1971 Ok((id, content_json))
1972 })?;
1973
1974 let mut count = 0;
1975 for row in rows {
1976 let (id, content_json) = row?;
1977 let content: MessageContent = serde_json::from_str(&content_json)
1979 .unwrap_or(MessageContent::Text(content_json.clone()));
1980 let text_content = content.text();
1981
1982 if !text_content.is_empty() {
1983 self.conn.execute(
1984 "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
1985 params![id, text_content],
1986 )?;
1987 count += 1;
1988 }
1989 }
1990
1991 let mut session_stmt = self
1993 .conn
1994 .prepare("SELECT id, tool, working_directory, git_branch FROM sessions")?;
1995
1996 let session_rows = session_stmt.query_map([], |row| {
1997 let id: String = row.get(0)?;
1998 let tool: String = row.get(1)?;
1999 let working_directory: String = row.get(2)?;
2000 let git_branch: Option<String> = row.get(3)?;
2001 Ok((id, tool, working_directory, git_branch))
2002 })?;
2003
2004 for row in session_rows {
2005 let (id, tool, working_directory, git_branch) = row?;
2006 self.conn.execute(
2007 "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
2008 params![id, tool, working_directory, git_branch.unwrap_or_default()],
2009 )?;
2010 }
2011
2012 Ok(count)
2013 }
2014
2015 pub fn search_index_needs_rebuild(&self) -> Result<bool> {
2020 let message_count: i32 =
2021 self.conn
2022 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
2023
2024 let msg_fts_count: i32 =
2025 self.conn
2026 .query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
2027
2028 let session_count: i32 =
2029 self.conn
2030 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
2031
2032 let session_fts_count: i32 =
2033 self.conn
2034 .query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
2035
2036 Ok((message_count > 0 && msg_fts_count == 0)
2038 || (session_count > 0 && session_fts_count == 0))
2039 }
2040
2041 fn row_to_memory(row: &rusqlite::Row) -> rusqlite::Result<Memory> {
2045 Ok(Memory {
2046 id: parse_uuid(&row.get::<_, String>(0)?)?,
2047 project_path: row.get(1)?,
2048 source_tool: row.get(2)?,
2049 name: row.get(3)?,
2050 description: row.get(4)?,
2051 memory_type: row.get(5)?,
2052 content: row.get(6)?,
2053 file_path: row.get(7)?,
2054 updated_at: parse_datetime(&row.get::<_, String>(8)?)?,
2055 })
2056 }
2057
2058 pub fn upsert_memory(&self, memory: &Memory) -> Result<Uuid> {
2067 let existing_id: Option<String> = self
2070 .conn
2071 .query_row(
2072 "SELECT id FROM memories WHERE project_path = ?1 AND source_tool = ?2 AND file_path = ?3",
2073 params![memory.project_path, memory.source_tool, memory.file_path],
2074 |row| row.get(0),
2075 )
2076 .optional()?;
2077
2078 let id = match existing_id {
2079 Some(s) => Uuid::parse_str(&s).unwrap_or(memory.id),
2080 None => memory.id,
2081 };
2082
2083 self.conn.execute(
2084 r#"
2085 INSERT INTO memories (id, project_path, source_tool, name, description, memory_type, content, file_path, updated_at)
2086 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2087 ON CONFLICT(project_path, source_tool, file_path) DO UPDATE SET
2088 name = excluded.name,
2089 description = excluded.description,
2090 memory_type = excluded.memory_type,
2091 content = excluded.content,
2092 updated_at = excluded.updated_at
2093 "#,
2094 params![
2095 id.to_string(),
2096 memory.project_path,
2097 memory.source_tool,
2098 memory.name,
2099 memory.description,
2100 memory.memory_type,
2101 memory.content,
2102 memory.file_path,
2103 memory.updated_at.to_rfc3339(),
2104 ],
2105 )?;
2106
2107 self.conn.execute(
2109 "DELETE FROM memories_fts WHERE memory_id = ?1",
2110 params![id.to_string()],
2111 )?;
2112 self.conn.execute(
2113 "INSERT INTO memories_fts (memory_id, name, description, content) VALUES (?1, ?2, ?3, ?4)",
2114 params![
2115 id.to_string(),
2116 memory.name,
2117 memory.description.as_deref().unwrap_or(""),
2118 memory.content,
2119 ],
2120 )?;
2121
2122 Ok(id)
2123 }
2124
2125 pub fn delete_memory(&self, id: &Uuid) -> Result<bool> {
2129 self.conn.execute(
2130 "DELETE FROM memories_fts WHERE memory_id = ?1",
2131 params![id.to_string()],
2132 )?;
2133 let rows = self.conn.execute(
2134 "DELETE FROM memories WHERE id = ?1",
2135 params![id.to_string()],
2136 )?;
2137 Ok(rows > 0)
2138 }
2139
2140 pub fn get_memories(&self, project_path: &str, source_tool: &str) -> Result<Vec<Memory>> {
2144 let mut stmt = self.conn.prepare(
2145 "SELECT id, project_path, source_tool, name, description, memory_type, content, file_path, updated_at
2146 FROM memories
2147 WHERE project_path = ?1 AND source_tool = ?2
2148 ORDER BY name",
2149 )?;
2150
2151 let memories = stmt
2152 .query_map(params![project_path, source_tool], Self::row_to_memory)?
2153 .collect::<rusqlite::Result<Vec<_>>>()?;
2154
2155 Ok(memories)
2156 }
2157
2158 pub fn search_memories(
2163 &self,
2164 project_path: &str,
2165 source_tool: &str,
2166 query: &str,
2167 limit: usize,
2168 ) -> Result<Vec<Memory>> {
2169 let escaped_query = escape_fts5_query(query);
2170
2171 let mut stmt = self.conn.prepare(
2172 "SELECT m.id, m.project_path, m.source_tool, m.name, m.description, m.memory_type, m.content, m.file_path, m.updated_at
2173 FROM memories_fts fts
2174 JOIN memories m ON fts.memory_id = m.id
2175 WHERE memories_fts MATCH ?1 AND m.project_path = ?2 AND m.source_tool = ?3
2176 ORDER BY rank
2177 LIMIT ?4",
2178 )?;
2179
2180 let memories = stmt
2181 .query_map(
2182 params![escaped_query, project_path, source_tool, limit as i64],
2183 Self::row_to_memory,
2184 )?
2185 .collect::<rusqlite::Result<Vec<_>>>()?;
2186
2187 Ok(memories)
2188 }
2189
2190 #[allow(dead_code)]
2202 pub fn get_unsynced_sessions(&self) -> Result<Vec<Session>> {
2203 let mut stmt = self.conn.prepare(
2204 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
2205 FROM sessions
2206 WHERE synced_at IS NULL
2207 ORDER BY started_at ASC"
2208 )?;
2209
2210 let rows = stmt.query_map([], Self::row_to_session)?;
2211
2212 rows.collect::<Result<Vec<_>, _>>()
2213 .context("Failed to get unsynced sessions")
2214 }
2215
2216 pub fn get_unsynced_sessions_for_repo(&self, repo_path: &Path) -> Result<Vec<Session>> {
2230 let (predicate, binds) = repo_scope_predicate(repo_path);
2231 let sql = format!(
2232 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
2233 FROM sessions
2234 WHERE synced_at IS NULL
2235 AND ({predicate})
2236 ORDER BY started_at ASC"
2237 );
2238
2239 let mut stmt = self.conn.prepare(&sql)?;
2240 let rows = stmt.query_map(
2241 rusqlite::params_from_iter(binds.iter()),
2242 Self::row_to_session,
2243 )?;
2244
2245 rows.collect::<Result<Vec<_>, _>>()
2246 .context("Failed to get unsynced sessions for repo")
2247 }
2248
2249 pub fn get_session_ids_for_repo(&self, repo_path: &Path) -> Result<HashSet<Uuid>> {
2259 let (predicate, binds) = repo_scope_predicate(repo_path);
2260 let sql = format!("SELECT id FROM sessions WHERE {predicate}");
2261
2262 let mut stmt = self.conn.prepare(&sql)?;
2263 let rows = stmt.query_map(rusqlite::params_from_iter(binds.iter()), |row| {
2264 let id: String = row.get(0)?;
2265 parse_uuid(&id)
2266 })?;
2267
2268 rows.collect::<rusqlite::Result<HashSet<Uuid>>>()
2269 .context("Failed to get session ids for repo")
2270 }
2271
2272 pub fn get_all_session_ids(&self) -> Result<HashSet<Uuid>> {
2280 let mut stmt = self.conn.prepare("SELECT id FROM sessions")?;
2281 let rows = stmt.query_map([], |row| {
2282 let id: String = row.get(0)?;
2283 parse_uuid(&id)
2284 })?;
2285
2286 rows.collect::<rusqlite::Result<HashSet<Uuid>>>()
2287 .context("Failed to get all session ids")
2288 }
2289
2290 pub fn get_unsynced_global_sessions(&self) -> Result<Vec<Session>> {
2297 let mut stmt = self.conn.prepare(
2298 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
2299 FROM sessions
2300 WHERE global_synced_at IS NULL
2301 ORDER BY started_at ASC"
2302 )?;
2303
2304 let rows = stmt.query_map([], Self::row_to_session)?;
2305
2306 rows.collect::<Result<Vec<_>, _>>()
2307 .context("Failed to get unsynced global sessions")
2308 }
2309
2310 pub fn unsynced_global_count(&self) -> Result<i32> {
2312 let count: i32 = self.conn.query_row(
2313 "SELECT COUNT(*) FROM sessions WHERE global_synced_at IS NULL",
2314 [],
2315 |row| row.get(0),
2316 )?;
2317 Ok(count)
2318 }
2319
2320 pub fn mark_global_synced(
2325 &self,
2326 session_ids: &[Uuid],
2327 synced_at: DateTime<Utc>,
2328 ) -> Result<usize> {
2329 if session_ids.is_empty() {
2330 return Ok(0);
2331 }
2332
2333 let synced_at_str = synced_at.to_rfc3339();
2334 let mut total_updated = 0;
2335
2336 for id in session_ids {
2337 let updated = self.conn.execute(
2338 "UPDATE sessions SET global_synced_at = ?1 WHERE id = ?2",
2339 params![synced_at_str, id.to_string()],
2340 )?;
2341 total_updated += updated;
2342 }
2343
2344 Ok(total_updated)
2345 }
2346
2347 pub fn last_global_sync_time(&self) -> Result<Option<DateTime<Utc>>> {
2351 let result: Option<String> = self
2352 .conn
2353 .query_row(
2354 "SELECT MAX(global_synced_at) FROM sessions WHERE global_synced_at IS NOT NULL",
2355 [],
2356 |row| row.get(0),
2357 )
2358 .optional()?
2359 .flatten();
2360
2361 match result {
2362 Some(s) => Ok(Some(parse_datetime(&s)?)),
2363 None => Ok(None),
2364 }
2365 }
2366
2367 pub fn unsynced_session_count_for_repo(&self, repo_path: &Path) -> Result<i32> {
2374 let (predicate, binds) = repo_scope_predicate(repo_path);
2375 let sql = format!(
2376 "SELECT COUNT(*) FROM sessions
2377 WHERE synced_at IS NULL
2378 AND ({predicate})"
2379 );
2380
2381 let count: i32 =
2382 self.conn
2383 .query_row(&sql, rusqlite::params_from_iter(binds.iter()), |row| {
2384 row.get(0)
2385 })?;
2386 Ok(count)
2387 }
2388
2389 pub fn mark_sessions_synced(
2393 &self,
2394 session_ids: &[Uuid],
2395 synced_at: DateTime<Utc>,
2396 ) -> Result<usize> {
2397 if session_ids.is_empty() {
2398 return Ok(0);
2399 }
2400
2401 let synced_at_str = synced_at.to_rfc3339();
2402 let mut total_updated = 0;
2403
2404 for id in session_ids {
2405 let updated = self.conn.execute(
2406 "UPDATE sessions SET synced_at = ?1 WHERE id = ?2",
2407 params![synced_at_str, id.to_string()],
2408 )?;
2409 total_updated += updated;
2410 }
2411
2412 Ok(total_updated)
2413 }
2414
2415 pub fn last_sync_time(&self) -> Result<Option<DateTime<Utc>>> {
2419 let result: Option<String> = self
2420 .conn
2421 .query_row(
2422 "SELECT MAX(synced_at) FROM sessions WHERE synced_at IS NOT NULL",
2423 [],
2424 |row| row.get(0),
2425 )
2426 .optional()?
2427 .flatten();
2428
2429 match result {
2430 Some(s) => Ok(Some(parse_datetime(&s)?)),
2431 None => Ok(None),
2432 }
2433 }
2434
2435 pub fn session_count(&self) -> Result<i32> {
2439 let count: i32 = self
2440 .conn
2441 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
2442 Ok(count)
2443 }
2444
2445 pub fn message_count(&self) -> Result<i32> {
2447 let count: i32 = self
2448 .conn
2449 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
2450 Ok(count)
2451 }
2452
2453 pub fn link_count(&self) -> Result<i32> {
2455 let count: i32 = self
2456 .conn
2457 .query_row("SELECT COUNT(*) FROM session_links", [], |row| row.get(0))?;
2458 Ok(count)
2459 }
2460
2461 pub fn db_path(&self) -> Option<std::path::PathBuf> {
2465 self.conn.path().map(std::path::PathBuf::from)
2466 }
2467
2468 pub fn find_sessions_near_commit_time(
2485 &self,
2486 commit_time: chrono::DateTime<chrono::Utc>,
2487 window_minutes: i64,
2488 working_dir: Option<&str>,
2489 ) -> Result<Vec<Session>> {
2490 let commit_time_str = commit_time.to_rfc3339();
2492
2493 let window = chrono::Duration::minutes(window_minutes);
2495 let window_start = (commit_time - window).to_rfc3339();
2496 let window_end = (commit_time + window).to_rfc3339();
2497
2498 let sql = if working_dir.is_some() {
2499 r#"
2500 SELECT id, tool, tool_version, started_at, ended_at, model,
2501 working_directory, git_branch, source_path, message_count, machine_id
2502 FROM sessions
2503 WHERE working_directory LIKE ?1
2504 AND (
2505 -- Session started before or during the window
2506 (started_at <= ?3)
2507 AND
2508 -- Session ended after or during the window (or is still ongoing)
2509 (ended_at IS NULL OR ended_at >= ?2)
2510 )
2511 ORDER BY
2512 -- Order by how close the session end (or start) is to commit time
2513 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?4))
2514 "#
2515 } else {
2516 r#"
2517 SELECT id, tool, tool_version, started_at, ended_at, model,
2518 working_directory, git_branch, source_path, message_count, machine_id
2519 FROM sessions
2520 WHERE
2521 -- Session started before or during the window
2522 (started_at <= ?2)
2523 AND
2524 -- Session ended after or during the window (or is still ongoing)
2525 (ended_at IS NULL OR ended_at >= ?1)
2526 ORDER BY
2527 -- Order by how close the session end (or start) is to commit time
2528 ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?3))
2529 "#
2530 };
2531
2532 let mut stmt = self.conn.prepare(sql)?;
2533
2534 let rows = if let Some(wd) = working_dir {
2535 stmt.query_map(
2536 params![format!("{wd}%"), window_start, window_end, commit_time_str],
2537 Self::row_to_session,
2538 )?
2539 } else {
2540 stmt.query_map(
2541 params![window_start, window_end, commit_time_str],
2542 Self::row_to_session,
2543 )?
2544 };
2545
2546 rows.collect::<Result<Vec<_>, _>>()
2547 .context("Failed to find sessions near commit time")
2548 }
2549
2550 pub fn link_exists(&self, session_id: &Uuid, commit_sha: &str) -> Result<bool> {
2554 let pattern = format!("{commit_sha}%");
2555 let count: i32 = self.conn.query_row(
2556 "SELECT COUNT(*) FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
2557 params![session_id.to_string(), pattern],
2558 |row| row.get(0),
2559 )?;
2560 Ok(count > 0)
2561 }
2562
2563 pub fn find_active_sessions_for_directory(
2582 &self,
2583 directory: &str,
2584 recent_minutes: Option<i64>,
2585 ) -> Result<Vec<Session>> {
2586 let minutes = recent_minutes.unwrap_or(5);
2587 let cutoff = (chrono::Utc::now() - chrono::Duration::minutes(minutes)).to_rfc3339();
2588 let (exact, trailing, like_pattern) = directory_match_params(directory);
2589
2590 let sql = r#"
2591 SELECT id, tool, tool_version, started_at, ended_at, model,
2592 working_directory, git_branch, source_path, message_count, machine_id
2593 FROM sessions
2594 WHERE (working_directory = ?1
2595 OR working_directory = ?2
2596 OR working_directory LIKE ?3 ESCAPE '|')
2597 AND (ended_at IS NULL OR ended_at >= ?4)
2598 ORDER BY started_at DESC
2599 "#;
2600
2601 let mut stmt = self.conn.prepare(sql)?;
2602 let rows = stmt.query_map(
2603 params![exact, trailing, like_pattern, cutoff],
2604 Self::row_to_session,
2605 )?;
2606
2607 rows.collect::<Result<Vec<_>, _>>()
2608 .context("Failed to find active sessions for directory")
2609 }
2610
2611 pub fn delete_session(&self, session_id: &Uuid) -> Result<(usize, usize)> {
2622 let session_id_str = session_id.to_string();
2623
2624 self.conn.execute(
2626 "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
2627 params![session_id_str],
2628 )?;
2629
2630 let messages_deleted = self.conn.execute(
2632 "DELETE FROM messages WHERE session_id = ?1",
2633 params![session_id_str],
2634 )?;
2635
2636 let links_deleted = self.conn.execute(
2638 "DELETE FROM session_links WHERE session_id = ?1",
2639 params![session_id_str],
2640 )?;
2641
2642 self.conn.execute(
2644 "DELETE FROM annotations WHERE session_id = ?1",
2645 params![session_id_str],
2646 )?;
2647
2648 self.conn.execute(
2650 "DELETE FROM tags WHERE session_id = ?1",
2651 params![session_id_str],
2652 )?;
2653
2654 self.conn.execute(
2656 "DELETE FROM summaries WHERE session_id = ?1",
2657 params![session_id_str],
2658 )?;
2659
2660 self.conn.execute(
2662 "DELETE FROM sessions_fts WHERE session_id = ?1",
2663 params![session_id_str],
2664 )?;
2665
2666 self.conn.execute(
2668 "DELETE FROM sessions WHERE id = ?1",
2669 params![session_id_str],
2670 )?;
2671
2672 Ok((messages_deleted, links_deleted))
2673 }
2674
2675 pub fn insert_annotation(&self, annotation: &Annotation) -> Result<()> {
2681 Self::write_annotation(&self.conn, annotation, false)?;
2682 self.mark_session_unsynced(&annotation.session_id)?;
2684 Ok(())
2685 }
2686
2687 fn write_annotation(
2693 conn: &Connection,
2694 annotation: &Annotation,
2695 ignore_conflict: bool,
2696 ) -> Result<()> {
2697 let sql = if ignore_conflict {
2698 r#"
2699 INSERT INTO annotations (id, session_id, content, created_at)
2700 VALUES (?1, ?2, ?3, ?4)
2701 ON CONFLICT(id) DO NOTHING
2702 "#
2703 } else {
2704 r#"
2705 INSERT INTO annotations (id, session_id, content, created_at)
2706 VALUES (?1, ?2, ?3, ?4)
2707 "#
2708 };
2709 conn.execute(
2710 sql,
2711 params![
2712 annotation.id.to_string(),
2713 annotation.session_id.to_string(),
2714 annotation.content,
2715 annotation.created_at.to_rfc3339(),
2716 ],
2717 )?;
2718 Ok(())
2719 }
2720
2721 pub fn get_annotations(&self, session_id: &Uuid) -> Result<Vec<Annotation>> {
2725 let mut stmt = self.conn.prepare(
2726 "SELECT id, session_id, content, created_at
2727 FROM annotations
2728 WHERE session_id = ?1
2729 ORDER BY created_at ASC",
2730 )?;
2731
2732 let rows = stmt.query_map(params![session_id.to_string()], |row| {
2733 Ok(Annotation {
2734 id: parse_uuid(&row.get::<_, String>(0)?)?,
2735 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
2736 content: row.get(2)?,
2737 created_at: parse_datetime(&row.get::<_, String>(3)?)?,
2738 })
2739 })?;
2740
2741 rows.collect::<Result<Vec<_>, _>>()
2742 .context("Failed to get annotations")
2743 }
2744
2745 #[allow(dead_code)]
2749 pub fn delete_annotation(&self, annotation_id: &Uuid) -> Result<bool> {
2750 let session_id: Option<String> = self
2753 .conn
2754 .query_row(
2755 "SELECT session_id FROM annotations WHERE id = ?1",
2756 params![annotation_id.to_string()],
2757 |row| row.get(0),
2758 )
2759 .optional()?;
2760 let rows_affected = self.conn.execute(
2761 "DELETE FROM annotations WHERE id = ?1",
2762 params![annotation_id.to_string()],
2763 )?;
2764 if rows_affected > 0 {
2765 if let Some(sid) = session_id {
2766 let sid = parse_uuid(&sid)?;
2767 Self::record_tombstone(
2769 &self.conn,
2770 &annotation_id.to_string(),
2771 TOMBSTONE_KIND_ANNOTATION,
2772 &sid,
2773 Utc::now(),
2774 )?;
2775 self.mark_session_unsynced(&sid)?;
2776 }
2777 }
2778 Ok(rows_affected > 0)
2779 }
2780
2781 #[allow(dead_code)]
2785 pub fn delete_annotations_by_session(&self, session_id: &Uuid) -> Result<usize> {
2786 let ids = self.child_ids("annotations", session_id)?;
2789 let rows_affected = self.conn.execute(
2790 "DELETE FROM annotations WHERE session_id = ?1",
2791 params![session_id.to_string()],
2792 )?;
2793 if rows_affected > 0 {
2794 let now = Utc::now();
2795 for id in &ids {
2796 Self::record_tombstone(&self.conn, id, TOMBSTONE_KIND_ANNOTATION, session_id, now)?;
2797 }
2798 self.mark_session_unsynced(session_id)?;
2800 }
2801 Ok(rows_affected)
2802 }
2803
2804 pub fn insert_tag(&self, tag: &Tag) -> Result<()> {
2811 Self::write_tag(&self.conn, tag, false)?;
2812 self.mark_session_unsynced(&tag.session_id)?;
2814 Ok(())
2815 }
2816
2817 fn write_tag(conn: &Connection, tag: &Tag, ignore_conflict: bool) -> Result<()> {
2824 let sql = if ignore_conflict {
2825 r#"
2826 INSERT INTO tags (id, session_id, label, created_at)
2827 VALUES (?1, ?2, ?3, ?4)
2828 ON CONFLICT DO NOTHING
2829 "#
2830 } else {
2831 r#"
2832 INSERT INTO tags (id, session_id, label, created_at)
2833 VALUES (?1, ?2, ?3, ?4)
2834 "#
2835 };
2836 conn.execute(
2837 sql,
2838 params![
2839 tag.id.to_string(),
2840 tag.session_id.to_string(),
2841 tag.label,
2842 tag.created_at.to_rfc3339(),
2843 ],
2844 )?;
2845 Ok(())
2846 }
2847
2848 pub fn get_tags(&self, session_id: &Uuid) -> Result<Vec<Tag>> {
2852 let mut stmt = self.conn.prepare(
2853 "SELECT id, session_id, label, created_at
2854 FROM tags
2855 WHERE session_id = ?1
2856 ORDER BY label ASC",
2857 )?;
2858
2859 let rows = stmt.query_map(params![session_id.to_string()], |row| {
2860 Ok(Tag {
2861 id: parse_uuid(&row.get::<_, String>(0)?)?,
2862 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
2863 label: row.get(2)?,
2864 created_at: parse_datetime(&row.get::<_, String>(3)?)?,
2865 })
2866 })?;
2867
2868 rows.collect::<Result<Vec<_>, _>>()
2869 .context("Failed to get tags")
2870 }
2871
2872 pub fn tag_exists(&self, session_id: &Uuid, label: &str) -> Result<bool> {
2874 let count: i32 = self.conn.query_row(
2875 "SELECT COUNT(*) FROM tags WHERE session_id = ?1 AND label = ?2",
2876 params![session_id.to_string(), label],
2877 |row| row.get(0),
2878 )?;
2879 Ok(count > 0)
2880 }
2881
2882 pub fn delete_tag(&self, session_id: &Uuid, label: &str) -> Result<bool> {
2886 let id: Option<String> = self
2889 .conn
2890 .query_row(
2891 "SELECT id FROM tags WHERE session_id = ?1 AND label = ?2",
2892 params![session_id.to_string(), label],
2893 |row| row.get(0),
2894 )
2895 .optional()?;
2896 let rows_affected = self.conn.execute(
2897 "DELETE FROM tags WHERE session_id = ?1 AND label = ?2",
2898 params![session_id.to_string(), label],
2899 )?;
2900 if rows_affected > 0 {
2901 if let Some(id) = id {
2902 Self::record_tombstone(
2903 &self.conn,
2904 &id,
2905 TOMBSTONE_KIND_TAG,
2906 session_id,
2907 Utc::now(),
2908 )?;
2909 }
2910 self.mark_session_unsynced(session_id)?;
2912 }
2913 Ok(rows_affected > 0)
2914 }
2915
2916 #[allow(dead_code)]
2920 pub fn delete_tags_by_session(&self, session_id: &Uuid) -> Result<usize> {
2921 let ids = self.child_ids("tags", session_id)?;
2924 let rows_affected = self.conn.execute(
2925 "DELETE FROM tags WHERE session_id = ?1",
2926 params![session_id.to_string()],
2927 )?;
2928 if rows_affected > 0 {
2929 let now = Utc::now();
2930 for id in &ids {
2931 Self::record_tombstone(&self.conn, id, TOMBSTONE_KIND_TAG, session_id, now)?;
2932 }
2933 self.mark_session_unsynced(session_id)?;
2935 }
2936 Ok(rows_affected)
2937 }
2938
2939 pub fn list_sessions_with_tag(&self, label: &str, limit: usize) -> Result<Vec<Session>> {
2943 let mut stmt = self.conn.prepare(
2944 "SELECT s.id, s.tool, s.tool_version, s.started_at, s.ended_at, s.model,
2945 s.working_directory, s.git_branch, s.source_path, s.message_count, s.machine_id
2946 FROM sessions s
2947 INNER JOIN tags t ON s.id = t.session_id
2948 WHERE t.label = ?1
2949 ORDER BY s.started_at DESC
2950 LIMIT ?2",
2951 )?;
2952
2953 let rows = stmt.query_map(params![label, limit], Self::row_to_session)?;
2954
2955 rows.collect::<Result<Vec<_>, _>>()
2956 .context("Failed to list sessions with tag")
2957 }
2958
2959 pub fn insert_summary(&self, summary: &Summary) -> Result<()> {
2966 self.conn.execute(
2967 r#"
2968 INSERT INTO summaries (id, session_id, content, generated_at)
2969 VALUES (?1, ?2, ?3, ?4)
2970 "#,
2971 params![
2972 summary.id.to_string(),
2973 summary.session_id.to_string(),
2974 summary.content,
2975 summary.generated_at.to_rfc3339(),
2976 ],
2977 )?;
2978 self.mark_session_unsynced(&summary.session_id)?;
2980 Ok(())
2981 }
2982
2983 fn write_summary_newer(conn: &Connection, summary: &Summary) -> Result<()> {
2992 conn.execute(
2993 r#"
2994 INSERT INTO summaries (id, session_id, content, generated_at)
2995 VALUES (?1, ?2, ?3, ?4)
2996 ON CONFLICT(session_id) DO UPDATE SET
2997 content = excluded.content,
2998 generated_at = excluded.generated_at
2999 WHERE excluded.generated_at > generated_at
3000 "#,
3001 params![
3002 summary.id.to_string(),
3003 summary.session_id.to_string(),
3004 summary.content,
3005 summary.generated_at.to_rfc3339(),
3006 ],
3007 )?;
3008 Ok(())
3009 }
3010
3011 pub fn get_summary(&self, session_id: &Uuid) -> Result<Option<Summary>> {
3013 self.conn
3014 .query_row(
3015 "SELECT id, session_id, content, generated_at
3016 FROM summaries
3017 WHERE session_id = ?1",
3018 params![session_id.to_string()],
3019 |row| {
3020 Ok(Summary {
3021 id: parse_uuid(&row.get::<_, String>(0)?)?,
3022 session_id: parse_uuid(&row.get::<_, String>(1)?)?,
3023 content: row.get(2)?,
3024 generated_at: parse_datetime(&row.get::<_, String>(3)?)?,
3025 })
3026 },
3027 )
3028 .optional()
3029 .context("Failed to get summary")
3030 }
3031
3032 pub fn get_sessions_with_summaries(&self, session_ids: &[Uuid]) -> Result<HashSet<Uuid>> {
3037 if session_ids.is_empty() {
3038 return Ok(HashSet::new());
3039 }
3040
3041 let placeholders: Vec<&str> = session_ids.iter().map(|_| "?").collect();
3042 let sql = format!(
3043 "SELECT session_id FROM summaries WHERE session_id IN ({})",
3044 placeholders.join(", ")
3045 );
3046
3047 let params: Vec<Box<dyn rusqlite::types::ToSql>> = session_ids
3048 .iter()
3049 .map(|id| Box::new(id.to_string()) as Box<dyn rusqlite::types::ToSql>)
3050 .collect();
3051
3052 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
3053 params.iter().map(|p| p.as_ref()).collect();
3054
3055 let mut stmt = self.conn.prepare(&sql)?;
3056 let rows = stmt.query_map(param_refs.as_slice(), |row| {
3057 let id_str: String = row.get(0)?;
3058 parse_uuid(&id_str)
3059 })?;
3060
3061 let mut result = HashSet::new();
3062 for row in rows {
3063 result.insert(row?);
3064 }
3065 Ok(result)
3066 }
3067
3068 pub fn update_summary(&self, session_id: &Uuid, content: &str) -> Result<bool> {
3073 let now = chrono::Utc::now().to_rfc3339();
3074 let rows_affected = self.conn.execute(
3075 "UPDATE summaries SET content = ?1, generated_at = ?2 WHERE session_id = ?3",
3076 params![content, now, session_id.to_string()],
3077 )?;
3078 if rows_affected > 0 {
3079 self.mark_session_unsynced(session_id)?;
3081 }
3082 Ok(rows_affected > 0)
3083 }
3084
3085 #[allow(dead_code)]
3089 pub fn delete_summary(&self, session_id: &Uuid) -> Result<bool> {
3090 let id: Option<String> = self
3093 .conn
3094 .query_row(
3095 "SELECT id FROM summaries WHERE session_id = ?1",
3096 params![session_id.to_string()],
3097 |row| row.get(0),
3098 )
3099 .optional()?;
3100 let rows_affected = self.conn.execute(
3101 "DELETE FROM summaries WHERE session_id = ?1",
3102 params![session_id.to_string()],
3103 )?;
3104 if rows_affected > 0 {
3105 if let Some(id) = id {
3106 Self::record_tombstone(
3107 &self.conn,
3108 &id,
3109 TOMBSTONE_KIND_SUMMARY,
3110 session_id,
3111 Utc::now(),
3112 )?;
3113 }
3114 self.mark_session_unsynced(session_id)?;
3116 }
3117 Ok(rows_affected > 0)
3118 }
3119
3120 pub fn upsert_machine(&self, machine: &Machine) -> Result<()> {
3127 self.conn.execute(
3128 r#"
3129 INSERT INTO machines (id, name, created_at)
3130 VALUES (?1, ?2, ?3)
3131 ON CONFLICT(id) DO UPDATE SET
3132 name = ?2
3133 "#,
3134 params![machine.id, machine.name, machine.created_at],
3135 )?;
3136 Ok(())
3137 }
3138
3139 #[allow(dead_code)]
3143 pub fn get_machine(&self, id: &str) -> Result<Option<Machine>> {
3144 self.conn
3145 .query_row(
3146 "SELECT id, name, created_at FROM machines WHERE id = ?1",
3147 params![id],
3148 |row| {
3149 Ok(Machine {
3150 id: row.get(0)?,
3151 name: row.get(1)?,
3152 created_at: row.get(2)?,
3153 })
3154 },
3155 )
3156 .optional()
3157 .context("Failed to get machine")
3158 }
3159
3160 #[allow(dead_code)]
3165 pub fn get_machine_name(&self, id: &str) -> Result<String> {
3166 if let Some(machine) = self.get_machine(id)? {
3167 Ok(machine.name)
3168 } else {
3169 if id.len() > 8 {
3171 Ok(id[..8].to_string())
3172 } else {
3173 Ok(id.to_string())
3174 }
3175 }
3176 }
3177
3178 #[allow(dead_code)]
3182 pub fn list_machines(&self) -> Result<Vec<Machine>> {
3183 let mut stmt = self
3184 .conn
3185 .prepare("SELECT id, name, created_at FROM machines ORDER BY created_at ASC")?;
3186
3187 let rows = stmt.query_map([], |row| {
3188 Ok(Machine {
3189 id: row.get(0)?,
3190 name: row.get(1)?,
3191 created_at: row.get(2)?,
3192 })
3193 })?;
3194
3195 rows.collect::<Result<Vec<_>, _>>()
3196 .context("Failed to list machines")
3197 }
3198
3199 pub fn get_most_recent_session_for_directory(
3204 &self,
3205 working_dir: &str,
3206 ) -> Result<Option<Session>> {
3207 self.conn
3208 .query_row(
3209 "SELECT id, tool, tool_version, started_at, ended_at, model,
3210 working_directory, git_branch, source_path, message_count, machine_id
3211 FROM sessions
3212 WHERE working_directory LIKE ?1
3213 ORDER BY started_at DESC
3214 LIMIT 1",
3215 params![format!("{working_dir}%")],
3216 Self::row_to_session,
3217 )
3218 .optional()
3219 .context("Failed to get most recent session for directory")
3220 }
3221
3222 pub fn vacuum(&self) -> Result<()> {
3229 self.conn.execute("VACUUM", [])?;
3230 Ok(())
3231 }
3232
3233 pub fn file_size(&self) -> Result<Option<u64>> {
3237 if let Some(path) = self.db_path() {
3238 let metadata = std::fs::metadata(&path)?;
3239 Ok(Some(metadata.len()))
3240 } else {
3241 Ok(None)
3242 }
3243 }
3244
3245 pub fn delete_sessions_older_than(&self, before: DateTime<Utc>) -> Result<usize> {
3257 let before_str = before.to_rfc3339();
3258
3259 let mut stmt = self
3261 .conn
3262 .prepare("SELECT id FROM sessions WHERE started_at < ?1")?;
3263 let session_ids: Vec<String> = stmt
3264 .query_map(params![before_str], |row| row.get(0))?
3265 .collect::<Result<Vec<_>, _>>()?;
3266
3267 if session_ids.is_empty() {
3268 return Ok(0);
3269 }
3270
3271 let count = session_ids.len();
3272
3273 for session_id_str in &session_ids {
3275 self.conn.execute(
3277 "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
3278 params![session_id_str],
3279 )?;
3280
3281 self.conn.execute(
3283 "DELETE FROM messages WHERE session_id = ?1",
3284 params![session_id_str],
3285 )?;
3286
3287 self.conn.execute(
3289 "DELETE FROM session_links WHERE session_id = ?1",
3290 params![session_id_str],
3291 )?;
3292
3293 self.conn.execute(
3295 "DELETE FROM annotations WHERE session_id = ?1",
3296 params![session_id_str],
3297 )?;
3298
3299 self.conn.execute(
3301 "DELETE FROM tags WHERE session_id = ?1",
3302 params![session_id_str],
3303 )?;
3304
3305 self.conn.execute(
3307 "DELETE FROM summaries WHERE session_id = ?1",
3308 params![session_id_str],
3309 )?;
3310
3311 self.conn.execute(
3313 "DELETE FROM sessions_fts WHERE session_id = ?1",
3314 params![session_id_str],
3315 )?;
3316 }
3317
3318 self.conn.execute(
3320 "DELETE FROM sessions WHERE started_at < ?1",
3321 params![before_str],
3322 )?;
3323
3324 Ok(count)
3325 }
3326
3327 pub fn count_sessions_older_than(&self, before: DateTime<Utc>) -> Result<i32> {
3337 let before_str = before.to_rfc3339();
3338 let count: i32 = self.conn.query_row(
3339 "SELECT COUNT(*) FROM sessions WHERE started_at < ?1",
3340 params![before_str],
3341 |row| row.get(0),
3342 )?;
3343 Ok(count)
3344 }
3345
3346 pub fn get_sessions_older_than(&self, before: DateTime<Utc>) -> Result<Vec<Session>> {
3356 let before_str = before.to_rfc3339();
3357 let mut stmt = self.conn.prepare(
3358 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
3359 FROM sessions
3360 WHERE started_at < ?1
3361 ORDER BY started_at ASC",
3362 )?;
3363
3364 let rows = stmt.query_map(params![before_str], Self::row_to_session)?;
3365
3366 rows.collect::<Result<Vec<_>, _>>()
3367 .context("Failed to get sessions older than cutoff")
3368 }
3369
3370 pub fn stats(&self) -> Result<DatabaseStats> {
3377 let session_count = self.session_count()?;
3378 let message_count = self.message_count()?;
3379 let link_count = self.link_count()?;
3380
3381 let oldest: Option<String> = self
3383 .conn
3384 .query_row("SELECT MIN(started_at) FROM sessions", [], |row| row.get(0))
3385 .optional()?
3386 .flatten();
3387
3388 let newest: Option<String> = self
3389 .conn
3390 .query_row("SELECT MAX(started_at) FROM sessions", [], |row| row.get(0))
3391 .optional()?
3392 .flatten();
3393
3394 let oldest_session = oldest
3395 .map(|s| parse_datetime(&s))
3396 .transpose()
3397 .unwrap_or(None);
3398 let newest_session = newest
3399 .map(|s| parse_datetime(&s))
3400 .transpose()
3401 .unwrap_or(None);
3402
3403 let mut stmt = self
3405 .conn
3406 .prepare("SELECT tool, COUNT(*) FROM sessions GROUP BY tool ORDER BY COUNT(*) DESC")?;
3407 let sessions_by_tool: Vec<(String, i32)> = stmt
3408 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
3409 .collect::<Result<Vec<_>, _>>()?;
3410
3411 Ok(DatabaseStats {
3412 session_count,
3413 message_count,
3414 link_count,
3415 oldest_session,
3416 newest_session,
3417 sessions_by_tool,
3418 })
3419 }
3420
3421 pub fn sessions_in_date_range(
3429 &self,
3430 since: Option<DateTime<Utc>>,
3431 until: Option<DateTime<Utc>>,
3432 working_dir: Option<&str>,
3433 ) -> Result<Vec<Session>> {
3434 let mut conditions = Vec::new();
3435 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
3436
3437 if let Some(since) = since {
3438 conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
3439 param_values.push(Box::new(since.to_rfc3339()));
3440 }
3441 if let Some(until) = until {
3442 conditions.push(format!("started_at <= ?{}", param_values.len() + 1));
3443 param_values.push(Box::new(until.to_rfc3339()));
3444 }
3445 if let Some(wd) = working_dir {
3446 conditions.push(format!(
3447 "working_directory LIKE ?{}",
3448 param_values.len() + 1
3449 ));
3450 param_values.push(Box::new(format!("{}%", wd)));
3451 }
3452
3453 let where_clause = if conditions.is_empty() {
3454 String::new()
3455 } else {
3456 format!(" WHERE {}", conditions.join(" AND "))
3457 };
3458
3459 let sql = format!(
3460 "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
3461 FROM sessions{}
3462 ORDER BY started_at DESC",
3463 where_clause
3464 );
3465
3466 let mut stmt = self.conn.prepare(&sql)?;
3467 let params = rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref()));
3468 let rows = stmt.query_map(params, Self::row_to_session)?;
3469
3470 rows.collect::<Result<Vec<_>, _>>()
3471 .context("Failed to query sessions in date range")
3472 }
3473
3474 pub fn average_session_duration_minutes(
3479 &self,
3480 since: Option<DateTime<Utc>>,
3481 working_dir: Option<&str>,
3482 ) -> Result<Option<f64>> {
3483 let mut conditions = vec!["ended_at IS NOT NULL".to_string()];
3484 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
3485
3486 if let Some(since) = since {
3487 conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
3488 param_values.push(Box::new(since.to_rfc3339()));
3489 }
3490 if let Some(wd) = working_dir {
3491 conditions.push(format!(
3492 "working_directory LIKE ?{}",
3493 param_values.len() + 1
3494 ));
3495 param_values.push(Box::new(format!("{}%", wd)));
3496 }
3497
3498 let where_clause = format!(" WHERE {}", conditions.join(" AND "));
3499
3500 let sql = format!(
3501 "SELECT AVG((julianday(ended_at) - julianday(started_at)) * 24 * 60) FROM sessions{}",
3502 where_clause
3503 );
3504
3505 let avg: Option<f64> = self
3506 .conn
3507 .query_row(
3508 &sql,
3509 rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref())),
3510 |row| row.get(0),
3511 )
3512 .optional()?
3513 .flatten();
3514
3515 Ok(avg)
3516 }
3517
3518 pub fn sessions_by_tool_in_range(
3523 &self,
3524 since: Option<DateTime<Utc>>,
3525 working_dir: Option<&str>,
3526 ) -> Result<Vec<(String, i32)>> {
3527 let mut conditions = Vec::new();
3528 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
3529
3530 if let Some(since) = since {
3531 conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
3532 param_values.push(Box::new(since.to_rfc3339()));
3533 }
3534 if let Some(wd) = working_dir {
3535 conditions.push(format!(
3536 "working_directory LIKE ?{}",
3537 param_values.len() + 1
3538 ));
3539 param_values.push(Box::new(format!("{}%", wd)));
3540 }
3541
3542 let where_clause = if conditions.is_empty() {
3543 String::new()
3544 } else {
3545 format!(" WHERE {}", conditions.join(" AND "))
3546 };
3547
3548 let sql = format!(
3549 "SELECT tool, COUNT(*) FROM sessions{} GROUP BY tool ORDER BY COUNT(*) DESC",
3550 where_clause
3551 );
3552
3553 let mut stmt = self.conn.prepare(&sql)?;
3554 let params = rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref()));
3555 let rows = stmt.query_map(params, |row| Ok((row.get(0)?, row.get(1)?)))?;
3556
3557 rows.collect::<Result<Vec<_>, _>>()
3558 .context("Failed to query sessions by tool")
3559 }
3560
3561 pub fn sessions_by_weekday(
3568 &self,
3569 since: Option<DateTime<Utc>>,
3570 working_dir: Option<&str>,
3571 ) -> Result<Vec<(i32, i32)>> {
3572 let mut conditions = Vec::new();
3573 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
3574
3575 if let Some(since) = since {
3576 conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
3577 param_values.push(Box::new(since.to_rfc3339()));
3578 }
3579 if let Some(wd) = working_dir {
3580 conditions.push(format!(
3581 "working_directory LIKE ?{}",
3582 param_values.len() + 1
3583 ));
3584 param_values.push(Box::new(format!("{}%", wd)));
3585 }
3586
3587 let where_clause = if conditions.is_empty() {
3588 String::new()
3589 } else {
3590 format!(" WHERE {}", conditions.join(" AND "))
3591 };
3592
3593 let sql = format!(
3594 "SELECT CAST(strftime('%w', started_at) AS INTEGER), COUNT(*) FROM sessions{} GROUP BY strftime('%w', started_at) ORDER BY strftime('%w', started_at)",
3595 where_clause
3596 );
3597
3598 let mut stmt = self.conn.prepare(&sql)?;
3599 let params = rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref()));
3600 let rows = stmt.query_map(params, |row| Ok((row.get(0)?, row.get(1)?)))?;
3601
3602 rows.collect::<Result<Vec<_>, _>>()
3603 .context("Failed to query sessions by weekday")
3604 }
3605
3606 pub fn average_message_count(
3610 &self,
3611 since: Option<DateTime<Utc>>,
3612 working_dir: Option<&str>,
3613 ) -> Result<Option<f64>> {
3614 let mut conditions = Vec::new();
3615 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
3616
3617 if let Some(since) = since {
3618 conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
3619 param_values.push(Box::new(since.to_rfc3339()));
3620 }
3621 if let Some(wd) = working_dir {
3622 conditions.push(format!(
3623 "working_directory LIKE ?{}",
3624 param_values.len() + 1
3625 ));
3626 param_values.push(Box::new(format!("{}%", wd)));
3627 }
3628
3629 let where_clause = if conditions.is_empty() {
3630 String::new()
3631 } else {
3632 format!(" WHERE {}", conditions.join(" AND "))
3633 };
3634
3635 let sql = format!("SELECT AVG(message_count) FROM sessions{}", where_clause);
3636
3637 let avg: Option<f64> = self
3638 .conn
3639 .query_row(
3640 &sql,
3641 rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref())),
3642 |row| row.get(0),
3643 )
3644 .optional()?
3645 .flatten();
3646
3647 Ok(avg)
3648 }
3649}
3650
3651#[derive(Debug, Clone)]
3653pub struct DatabaseStats {
3654 pub session_count: i32,
3656 pub message_count: i32,
3658 pub link_count: i32,
3660 pub oldest_session: Option<DateTime<Utc>>,
3662 pub newest_session: Option<DateTime<Utc>>,
3664 pub sessions_by_tool: Vec<(String, i32)>,
3666}
3667
3668#[cfg(test)]
3669mod tests {
3670 use super::*;
3671 use crate::storage::models::{
3672 LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
3673 };
3674 use chrono::{Duration, Utc};
3675 use tempfile::tempdir;
3676
3677 fn create_test_db() -> (Database, tempfile::TempDir) {
3680 let dir = tempdir().expect("Failed to create temp directory");
3681 let db_path = dir.path().join("test.db");
3682 let db = Database::open(&db_path).expect("Failed to open test database");
3683 (db, dir)
3684 }
3685
3686 fn create_test_session(
3688 tool: &str,
3689 working_directory: &str,
3690 started_at: chrono::DateTime<Utc>,
3691 source_path: Option<&str>,
3692 ) -> Session {
3693 Session {
3694 id: Uuid::new_v4(),
3695 tool: tool.to_string(),
3696 tool_version: Some("1.0.0".to_string()),
3697 started_at,
3698 ended_at: None,
3699 model: Some("test-model".to_string()),
3700 working_directory: working_directory.to_string(),
3701 git_branch: Some("main".to_string()),
3702 source_path: source_path.map(|s| s.to_string()),
3703 message_count: 0,
3704 machine_id: Some("test-machine".to_string()),
3705 }
3706 }
3707
3708 fn create_test_message(
3710 session_id: Uuid,
3711 index: i32,
3712 role: MessageRole,
3713 content: &str,
3714 ) -> Message {
3715 Message {
3716 id: Uuid::new_v4(),
3717 session_id,
3718 parent_id: None,
3719 index,
3720 timestamp: Utc::now(),
3721 role,
3722 content: MessageContent::Text(content.to_string()),
3723 model: Some("test-model".to_string()),
3724 git_branch: Some("main".to_string()),
3725 cwd: Some("/test/cwd".to_string()),
3726 }
3727 }
3728
3729 fn create_test_link(
3731 session_id: Uuid,
3732 commit_sha: Option<&str>,
3733 link_type: LinkType,
3734 ) -> SessionLink {
3735 SessionLink {
3736 id: Uuid::new_v4(),
3737 session_id,
3738 link_type,
3739 commit_sha: commit_sha.map(|s| s.to_string()),
3740 branch: Some("main".to_string()),
3741 remote: Some("origin".to_string()),
3742 created_at: Utc::now(),
3743 created_by: LinkCreator::Auto,
3744 confidence: Some(0.95),
3745 }
3746 }
3747
3748 #[test]
3751 fn test_insert_and_get_session() {
3752 let (db, _dir) = create_test_db();
3753 let session = create_test_session(
3754 "claude-code",
3755 "/home/user/project",
3756 Utc::now(),
3757 Some("/path/to/source.jsonl"),
3758 );
3759
3760 db.insert_session(&session)
3761 .expect("Failed to insert session");
3762
3763 let retrieved = db
3764 .get_session(&session.id)
3765 .expect("Failed to get session")
3766 .expect("Session should exist");
3767
3768 assert_eq!(retrieved.id, session.id, "Session ID should match");
3769 assert_eq!(retrieved.tool, session.tool, "Tool should match");
3770 assert_eq!(
3771 retrieved.tool_version, session.tool_version,
3772 "Tool version should match"
3773 );
3774 assert_eq!(
3775 retrieved.working_directory, session.working_directory,
3776 "Working directory should match"
3777 );
3778 assert_eq!(
3779 retrieved.git_branch, session.git_branch,
3780 "Git branch should match"
3781 );
3782 assert_eq!(
3783 retrieved.source_path, session.source_path,
3784 "Source path should match"
3785 );
3786 }
3787
3788 #[test]
3789 fn test_list_sessions() {
3790 let (db, _dir) = create_test_db();
3791 let now = Utc::now();
3792
3793 let session1 =
3795 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
3796 let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
3797 let session3 = create_test_session("claude-code", "/project3", now, None);
3798
3799 db.insert_session(&session1)
3800 .expect("Failed to insert session1");
3801 db.insert_session(&session2)
3802 .expect("Failed to insert session2");
3803 db.insert_session(&session3)
3804 .expect("Failed to insert session3");
3805
3806 let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
3807
3808 assert_eq!(sessions.len(), 3, "Should have 3 sessions");
3809 assert_eq!(
3811 sessions[0].id, session3.id,
3812 "Most recent session should be first"
3813 );
3814 assert_eq!(
3815 sessions[1].id, session2.id,
3816 "Second most recent session should be second"
3817 );
3818 assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
3819 }
3820
3821 #[test]
3822 fn test_list_ended_sessions() {
3823 let (db, _dir) = create_test_db();
3824 let now = Utc::now();
3825
3826 let mut ended = create_test_session(
3827 "claude-code",
3828 "/home/user/project",
3829 now - Duration::minutes(60),
3830 None,
3831 );
3832 ended.ended_at = Some(now - Duration::minutes(30));
3833
3834 let ongoing = create_test_session(
3835 "claude-code",
3836 "/home/user/project",
3837 now - Duration::minutes(10),
3838 None,
3839 );
3840
3841 db.insert_session(&ended).expect("insert ended session");
3842 db.insert_session(&ongoing).expect("insert ongoing session");
3843
3844 let sessions = db
3845 .list_ended_sessions(100, None)
3846 .expect("Failed to list ended sessions");
3847
3848 assert_eq!(sessions.len(), 1);
3849 assert_eq!(sessions[0].id, ended.id);
3850 }
3851
3852 #[test]
3853 fn test_list_sessions_with_working_dir_filter() {
3854 let (db, _dir) = create_test_db();
3855 let now = Utc::now();
3856
3857 let session1 = create_test_session(
3858 "claude-code",
3859 "/home/user/project-a",
3860 now - Duration::hours(1),
3861 None,
3862 );
3863 let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
3864 let session3 = create_test_session("claude-code", "/other/path", now, None);
3865
3866 db.insert_session(&session1)
3867 .expect("Failed to insert session1");
3868 db.insert_session(&session2)
3869 .expect("Failed to insert session2");
3870 db.insert_session(&session3)
3871 .expect("Failed to insert session3");
3872
3873 let sessions = db
3875 .list_sessions(10, Some("/home/user"))
3876 .expect("Failed to list sessions");
3877
3878 assert_eq!(
3879 sessions.len(),
3880 2,
3881 "Should have 2 sessions matching /home/user prefix"
3882 );
3883
3884 let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
3886 assert!(ids.contains(&session1.id), "Should contain session1");
3887 assert!(ids.contains(&session2.id), "Should contain session2");
3888 assert!(!ids.contains(&session3.id), "Should not contain session3");
3889 }
3890
3891 #[test]
3892 fn test_session_exists_by_source() {
3893 let (db, _dir) = create_test_db();
3894 let source_path = "/path/to/session.jsonl";
3895
3896 let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
3897
3898 assert!(
3900 !db.session_exists_by_source(source_path)
3901 .expect("Failed to check existence"),
3902 "Session should not exist before insert"
3903 );
3904
3905 db.insert_session(&session)
3906 .expect("Failed to insert session");
3907
3908 assert!(
3910 db.session_exists_by_source(source_path)
3911 .expect("Failed to check existence"),
3912 "Session should exist after insert"
3913 );
3914
3915 assert!(
3917 !db.session_exists_by_source("/other/path.jsonl")
3918 .expect("Failed to check existence"),
3919 "Different source path should not exist"
3920 );
3921 }
3922
3923 #[test]
3924 fn test_get_session_by_source() {
3925 let (db, _dir) = create_test_db();
3926 let source_path = "/path/to/session.jsonl";
3927
3928 let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
3929
3930 assert!(
3932 db.get_session_by_source(source_path)
3933 .expect("Failed to get session")
3934 .is_none(),
3935 "Session should not exist before insert"
3936 );
3937
3938 db.insert_session(&session)
3939 .expect("Failed to insert session");
3940
3941 let retrieved = db
3943 .get_session_by_source(source_path)
3944 .expect("Failed to get session")
3945 .expect("Session should exist after insert");
3946
3947 assert_eq!(retrieved.id, session.id, "Session ID should match");
3948 assert_eq!(
3949 retrieved.source_path,
3950 Some(source_path.to_string()),
3951 "Source path should match"
3952 );
3953
3954 assert!(
3956 db.get_session_by_source("/other/path.jsonl")
3957 .expect("Failed to get session")
3958 .is_none(),
3959 "Different source path should return None"
3960 );
3961 }
3962
3963 #[test]
3964 fn test_update_session_branch() {
3965 let (db, _dir) = create_test_db();
3966 let now = Utc::now();
3967
3968 let mut session = create_test_session("claude-code", "/project", now, None);
3970 session.git_branch = Some("main".to_string());
3971
3972 db.insert_session(&session)
3973 .expect("Failed to insert session");
3974
3975 let fetched = db
3977 .get_session(&session.id)
3978 .expect("Failed to get session")
3979 .expect("Session should exist");
3980 assert_eq!(fetched.git_branch, Some("main".to_string()));
3981
3982 let rows = db
3984 .update_session_branch(session.id, "feature-branch")
3985 .expect("Failed to update branch");
3986 assert_eq!(rows, 1, "Should update exactly one row");
3987
3988 let fetched = db
3990 .get_session(&session.id)
3991 .expect("Failed to get session")
3992 .expect("Session should exist");
3993 assert_eq!(fetched.git_branch, Some("feature-branch".to_string()));
3994 }
3995
3996 #[test]
3997 fn test_update_session_branch_nonexistent() {
3998 let (db, _dir) = create_test_db();
3999 let nonexistent_id = Uuid::new_v4();
4000
4001 let rows = db
4003 .update_session_branch(nonexistent_id, "some-branch")
4004 .expect("Failed to update branch");
4005 assert_eq!(
4006 rows, 0,
4007 "Should not update any rows for nonexistent session"
4008 );
4009 }
4010
4011 #[test]
4012 fn test_update_session_branch_from_none() {
4013 let (db, _dir) = create_test_db();
4014 let now = Utc::now();
4015
4016 let mut session = create_test_session("claude-code", "/project", now, None);
4018 session.git_branch = None; db.insert_session(&session)
4021 .expect("Failed to insert session");
4022
4023 let fetched = db
4025 .get_session(&session.id)
4026 .expect("Failed to get session")
4027 .expect("Session should exist");
4028 assert_eq!(fetched.git_branch, None);
4029
4030 let rows = db
4032 .update_session_branch(session.id, "new-branch")
4033 .expect("Failed to update branch");
4034 assert_eq!(rows, 1, "Should update exactly one row");
4035
4036 let fetched = db
4038 .get_session(&session.id)
4039 .expect("Failed to get session")
4040 .expect("Session should exist");
4041 assert_eq!(fetched.git_branch, Some("new-branch".to_string()));
4042 }
4043
4044 #[test]
4045 fn test_get_nonexistent_session() {
4046 let (db, _dir) = create_test_db();
4047 let nonexistent_id = Uuid::new_v4();
4048
4049 let result = db
4050 .get_session(&nonexistent_id)
4051 .expect("Failed to query for nonexistent session");
4052
4053 assert!(
4054 result.is_none(),
4055 "Should return None for nonexistent session"
4056 );
4057 }
4058
4059 #[test]
4062 fn test_insert_and_get_messages() {
4063 let (db, _dir) = create_test_db();
4064
4065 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4066 db.insert_session(&session)
4067 .expect("Failed to insert session");
4068
4069 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
4070 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
4071
4072 db.insert_message(&msg1)
4073 .expect("Failed to insert message 1");
4074 db.insert_message(&msg2)
4075 .expect("Failed to insert message 2");
4076
4077 let messages = db
4078 .get_messages(&session.id)
4079 .expect("Failed to get messages");
4080
4081 assert_eq!(messages.len(), 2, "Should have 2 messages");
4082 assert_eq!(messages[0].id, msg1.id, "First message ID should match");
4083 assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
4084 assert_eq!(
4085 messages[0].role,
4086 MessageRole::User,
4087 "First message role should be User"
4088 );
4089 assert_eq!(
4090 messages[1].role,
4091 MessageRole::Assistant,
4092 "Second message role should be Assistant"
4093 );
4094 }
4095
4096 #[test]
4097 fn test_messages_ordered_by_index() {
4098 let (db, _dir) = create_test_db();
4099
4100 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4101 db.insert_session(&session)
4102 .expect("Failed to insert session");
4103
4104 let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
4106 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
4107 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
4108
4109 db.insert_message(&msg3)
4110 .expect("Failed to insert message 3");
4111 db.insert_message(&msg1)
4112 .expect("Failed to insert message 1");
4113 db.insert_message(&msg2)
4114 .expect("Failed to insert message 2");
4115
4116 let messages = db
4117 .get_messages(&session.id)
4118 .expect("Failed to get messages");
4119
4120 assert_eq!(messages.len(), 3, "Should have 3 messages");
4121 assert_eq!(messages[0].index, 0, "First message should have index 0");
4122 assert_eq!(messages[1].index, 1, "Second message should have index 1");
4123 assert_eq!(messages[2].index, 2, "Third message should have index 2");
4124
4125 assert_eq!(
4127 messages[0].content.text(),
4128 "First",
4129 "First message content should be 'First'"
4130 );
4131 assert_eq!(
4132 messages[1].content.text(),
4133 "Second",
4134 "Second message content should be 'Second'"
4135 );
4136 assert_eq!(
4137 messages[2].content.text(),
4138 "Third",
4139 "Third message content should be 'Third'"
4140 );
4141 }
4142
4143 #[test]
4146 fn test_insert_and_get_links_by_session() {
4147 let (db, _dir) = create_test_db();
4148
4149 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4150 db.insert_session(&session)
4151 .expect("Failed to insert session");
4152
4153 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
4154 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
4155
4156 db.insert_link(&link1).expect("Failed to insert link 1");
4157 db.insert_link(&link2).expect("Failed to insert link 2");
4158
4159 let links = db
4160 .get_links_by_session(&session.id)
4161 .expect("Failed to get links");
4162
4163 assert_eq!(links.len(), 2, "Should have 2 links");
4164
4165 let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
4166 assert!(link_ids.contains(&link1.id), "Should contain link1");
4167 assert!(link_ids.contains(&link2.id), "Should contain link2");
4168
4169 let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
4171 assert_eq!(
4172 retrieved_link.commit_sha,
4173 Some("abc123def456".to_string()),
4174 "Commit SHA should match"
4175 );
4176 assert_eq!(
4177 retrieved_link.link_type,
4178 LinkType::Commit,
4179 "Link type should be Commit"
4180 );
4181 assert_eq!(
4182 retrieved_link.created_by,
4183 LinkCreator::Auto,
4184 "Created by should be Auto"
4185 );
4186 }
4187
4188 #[test]
4189 fn test_get_links_by_commit() {
4190 let (db, _dir) = create_test_db();
4191
4192 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4193 db.insert_session(&session)
4194 .expect("Failed to insert session");
4195
4196 let full_sha = "abc123def456789012345678901234567890abcd";
4197 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
4198 db.insert_link(&link).expect("Failed to insert link");
4199
4200 let links_full = db
4202 .get_links_by_commit(full_sha)
4203 .expect("Failed to get links by full SHA");
4204 assert_eq!(links_full.len(), 1, "Should find link by full SHA");
4205 assert_eq!(links_full[0].id, link.id, "Link ID should match");
4206
4207 let links_partial = db
4209 .get_links_by_commit("abc123")
4210 .expect("Failed to get links by partial SHA");
4211 assert_eq!(
4212 links_partial.len(),
4213 1,
4214 "Should find link by partial SHA prefix"
4215 );
4216 assert_eq!(links_partial[0].id, link.id, "Link ID should match");
4217
4218 let links_none = db
4220 .get_links_by_commit("zzz999")
4221 .expect("Failed to get links by non-matching SHA");
4222 assert_eq!(
4223 links_none.len(),
4224 0,
4225 "Should not find link with non-matching SHA"
4226 );
4227 }
4228
4229 #[test]
4232 fn test_database_creation() {
4233 let dir = tempdir().expect("Failed to create temp directory");
4234 let db_path = dir.path().join("new_test.db");
4235
4236 assert!(
4238 !db_path.exists(),
4239 "Database file should not exist before creation"
4240 );
4241
4242 let db = Database::open(&db_path).expect("Failed to create database");
4243
4244 assert!(
4246 db_path.exists(),
4247 "Database file should exist after creation"
4248 );
4249
4250 let session_count = db.session_count().expect("Failed to get session count");
4252 assert_eq!(session_count, 0, "New database should have 0 sessions");
4253
4254 let message_count = db.message_count().expect("Failed to get message count");
4255 assert_eq!(message_count, 0, "New database should have 0 messages");
4256 }
4257
4258 #[test]
4259 fn test_session_count() {
4260 let (db, _dir) = create_test_db();
4261
4262 assert_eq!(
4263 db.session_count().expect("Failed to get count"),
4264 0,
4265 "Initial session count should be 0"
4266 );
4267
4268 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
4269 db.insert_session(&session1)
4270 .expect("Failed to insert session1");
4271
4272 assert_eq!(
4273 db.session_count().expect("Failed to get count"),
4274 1,
4275 "Session count should be 1 after first insert"
4276 );
4277
4278 let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
4279 db.insert_session(&session2)
4280 .expect("Failed to insert session2");
4281
4282 assert_eq!(
4283 db.session_count().expect("Failed to get count"),
4284 2,
4285 "Session count should be 2 after second insert"
4286 );
4287 }
4288
4289 #[test]
4290 fn test_message_count() {
4291 let (db, _dir) = create_test_db();
4292
4293 assert_eq!(
4294 db.message_count().expect("Failed to get count"),
4295 0,
4296 "Initial message count should be 0"
4297 );
4298
4299 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4300 db.insert_session(&session)
4301 .expect("Failed to insert session");
4302
4303 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
4304 db.insert_message(&msg1).expect("Failed to insert message1");
4305
4306 assert_eq!(
4307 db.message_count().expect("Failed to get count"),
4308 1,
4309 "Message count should be 1 after first insert"
4310 );
4311
4312 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
4313 let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
4314 db.insert_message(&msg2).expect("Failed to insert message2");
4315 db.insert_message(&msg3).expect("Failed to insert message3");
4316
4317 assert_eq!(
4318 db.message_count().expect("Failed to get count"),
4319 3,
4320 "Message count should be 3 after all inserts"
4321 );
4322 }
4323
4324 #[test]
4325 fn test_link_count() {
4326 let (db, _dir) = create_test_db();
4327
4328 assert_eq!(
4329 db.link_count().expect("Failed to get count"),
4330 0,
4331 "Initial link count should be 0"
4332 );
4333
4334 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4335 db.insert_session(&session)
4336 .expect("Failed to insert session");
4337
4338 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
4339 db.insert_link(&link1).expect("Failed to insert link1");
4340
4341 assert_eq!(
4342 db.link_count().expect("Failed to get count"),
4343 1,
4344 "Link count should be 1 after first insert"
4345 );
4346
4347 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
4348 db.insert_link(&link2).expect("Failed to insert link2");
4349
4350 assert_eq!(
4351 db.link_count().expect("Failed to get count"),
4352 2,
4353 "Link count should be 2 after second insert"
4354 );
4355 }
4356
4357 #[test]
4358 fn test_db_path() {
4359 let dir = tempdir().expect("Failed to create temp directory");
4360 let db_path = dir.path().join("test.db");
4361 let db = Database::open(&db_path).expect("Failed to open test database");
4362
4363 let retrieved_path = db.db_path();
4364 assert!(
4365 retrieved_path.is_some(),
4366 "Database path should be available"
4367 );
4368
4369 let expected = db_path.canonicalize().unwrap_or(db_path);
4371 let actual = retrieved_path.unwrap();
4372 let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
4373
4374 assert_eq!(
4375 actual_canonical, expected,
4376 "Database path should match (after canonicalization)"
4377 );
4378 }
4379
4380 #[test]
4383 fn test_search_messages_basic() {
4384 let (db, _dir) = create_test_db();
4385
4386 let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
4387 db.insert_session(&session)
4388 .expect("Failed to insert session");
4389
4390 let msg1 = create_test_message(
4391 session.id,
4392 0,
4393 MessageRole::User,
4394 "How do I implement error handling in Rust?",
4395 );
4396 let msg2 = create_test_message(
4397 session.id,
4398 1,
4399 MessageRole::Assistant,
4400 "You can use Result types for error handling. The anyhow crate is also helpful.",
4401 );
4402
4403 db.insert_message(&msg1)
4404 .expect("Failed to insert message 1");
4405 db.insert_message(&msg2)
4406 .expect("Failed to insert message 2");
4407
4408 let results = db
4410 .search_messages("error", 10, None, None, None)
4411 .expect("Failed to search");
4412
4413 assert_eq!(
4414 results.len(),
4415 2,
4416 "Should find 2 messages containing 'error'"
4417 );
4418 }
4419
4420 #[test]
4421 fn test_search_messages_no_results() {
4422 let (db, _dir) = create_test_db();
4423
4424 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4425 db.insert_session(&session)
4426 .expect("Failed to insert session");
4427
4428 let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
4429 db.insert_message(&msg).expect("Failed to insert message");
4430
4431 let results = db
4433 .search_messages("nonexistent_term_xyz", 10, None, None, None)
4434 .expect("Failed to search");
4435
4436 assert!(results.is_empty(), "Should find no results");
4437 }
4438
4439 #[test]
4440 fn test_search_messages_with_role_filter() {
4441 let (db, _dir) = create_test_db();
4442
4443 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4444 db.insert_session(&session)
4445 .expect("Failed to insert session");
4446
4447 let msg1 = create_test_message(
4448 session.id,
4449 0,
4450 MessageRole::User,
4451 "Tell me about Rust programming",
4452 );
4453 let msg2 = create_test_message(
4454 session.id,
4455 1,
4456 MessageRole::Assistant,
4457 "Rust is a systems programming language",
4458 );
4459
4460 db.insert_message(&msg1)
4461 .expect("Failed to insert message 1");
4462 db.insert_message(&msg2)
4463 .expect("Failed to insert message 2");
4464
4465 let user_results = db
4467 .search_messages("programming", 10, None, None, Some("user"))
4468 .expect("Failed to search");
4469
4470 assert_eq!(user_results.len(), 1, "Should find 1 user message");
4471 assert_eq!(
4472 user_results[0].role,
4473 MessageRole::User,
4474 "Result should be from user"
4475 );
4476
4477 let assistant_results = db
4479 .search_messages("programming", 10, None, None, Some("assistant"))
4480 .expect("Failed to search");
4481
4482 assert_eq!(
4483 assistant_results.len(),
4484 1,
4485 "Should find 1 assistant message"
4486 );
4487 assert_eq!(
4488 assistant_results[0].role,
4489 MessageRole::Assistant,
4490 "Result should be from assistant"
4491 );
4492 }
4493
4494 #[test]
4495 fn test_search_messages_with_repo_filter() {
4496 let (db, _dir) = create_test_db();
4497
4498 let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
4499 let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
4500
4501 db.insert_session(&session1).expect("insert 1");
4502 db.insert_session(&session2).expect("insert 2");
4503
4504 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
4505 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
4506
4507 db.insert_message(&msg1).expect("insert msg 1");
4508 db.insert_message(&msg2).expect("insert msg 2");
4509
4510 let results = db
4512 .search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
4513 .expect("Failed to search");
4514
4515 assert_eq!(results.len(), 1, "Should find 1 message in project-a");
4516 assert!(
4517 results[0].working_directory.contains("project-a"),
4518 "Should be from project-a"
4519 );
4520 }
4521
4522 #[test]
4523 fn test_search_messages_limit() {
4524 let (db, _dir) = create_test_db();
4525
4526 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4527 db.insert_session(&session).expect("insert session");
4528
4529 for i in 0..5 {
4531 let msg = create_test_message(
4532 session.id,
4533 i,
4534 MessageRole::User,
4535 &format!("This is test message number {i}"),
4536 );
4537 db.insert_message(&msg).expect("insert message");
4538 }
4539
4540 let results = db
4542 .search_messages("test", 3, None, None, None)
4543 .expect("Failed to search");
4544
4545 assert_eq!(results.len(), 3, "Should respect limit of 3");
4546 }
4547
4548 #[test]
4549 fn test_search_index_needs_rebuild_empty_db() {
4550 let (db, _dir) = create_test_db();
4551
4552 let needs_rebuild = db
4553 .search_index_needs_rebuild()
4554 .expect("Failed to check rebuild status");
4555
4556 assert!(!needs_rebuild, "Empty database should not need rebuild");
4557 }
4558
4559 #[test]
4560 fn test_rebuild_search_index() {
4561 let (db, _dir) = create_test_db();
4562
4563 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4564 db.insert_session(&session).expect("insert session");
4565
4566 let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
4567 let msg2 = create_test_message(
4568 session.id,
4569 1,
4570 MessageRole::Assistant,
4571 "Second test response",
4572 );
4573
4574 db.insert_message(&msg1).expect("insert msg 1");
4575 db.insert_message(&msg2).expect("insert msg 2");
4576
4577 db.conn
4579 .execute("DELETE FROM messages_fts", [])
4580 .expect("clear fts");
4581
4582 assert!(
4584 db.search_index_needs_rebuild().expect("check rebuild"),
4585 "Should need rebuild after clearing FTS"
4586 );
4587
4588 let count = db.rebuild_search_index().expect("rebuild");
4590 assert_eq!(count, 2, "Should have indexed 2 messages");
4591
4592 assert!(
4594 !db.search_index_needs_rebuild().expect("check rebuild"),
4595 "Should not need rebuild after rebuilding"
4596 );
4597
4598 let results = db
4600 .search_messages("test", 10, None, None, None)
4601 .expect("search");
4602 assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
4603 }
4604
4605 #[test]
4606 fn test_search_with_block_content() {
4607 let (db, _dir) = create_test_db();
4608
4609 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4610 db.insert_session(&session).expect("insert session");
4611
4612 let block_content = MessageContent::Blocks(vec![
4614 crate::storage::models::ContentBlock::Text {
4615 text: "Let me help with your database query.".to_string(),
4616 },
4617 crate::storage::models::ContentBlock::ToolUse {
4618 id: "tool_123".to_string(),
4619 name: "Bash".to_string(),
4620 input: serde_json::json!({"command": "ls -la"}),
4621 },
4622 ]);
4623
4624 let msg = Message {
4625 id: Uuid::new_v4(),
4626 session_id: session.id,
4627 parent_id: None,
4628 index: 0,
4629 timestamp: Utc::now(),
4630 role: MessageRole::Assistant,
4631 content: block_content,
4632 model: Some("claude-opus-4".to_string()),
4633 git_branch: Some("main".to_string()),
4634 cwd: Some("/project".to_string()),
4635 };
4636
4637 db.insert_message(&msg).expect("insert message");
4638
4639 let results = db
4641 .search_messages("database", 10, None, None, None)
4642 .expect("search");
4643
4644 assert_eq!(results.len(), 1, "Should find message with block content");
4645 }
4646
4647 #[test]
4648 fn test_search_result_contains_session_info() {
4649 let (db, _dir) = create_test_db();
4650
4651 let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
4652 db.insert_session(&session).expect("insert session");
4653
4654 let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
4655 db.insert_message(&msg).expect("insert message");
4656
4657 let results = db
4658 .search_messages("Search", 10, None, None, None)
4659 .expect("search");
4660
4661 assert_eq!(results.len(), 1, "Should find 1 result");
4662 assert_eq!(results[0].session_id, session.id, "Session ID should match");
4663 assert_eq!(results[0].message_id, msg.id, "Message ID should match");
4664 assert_eq!(
4665 results[0].working_directory, "/home/user/my-project",
4666 "Working directory should match"
4667 );
4668 assert_eq!(results[0].role, MessageRole::User, "Role should match");
4669 }
4670
4671 #[test]
4674 fn test_delete_link_by_id() {
4675 let (db, _dir) = create_test_db();
4676
4677 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4678 db.insert_session(&session)
4679 .expect("Failed to insert session");
4680
4681 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
4682 db.insert_link(&link).expect("Failed to insert link");
4683
4684 let links_before = db
4686 .get_links_by_session(&session.id)
4687 .expect("Failed to get links");
4688 assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
4689
4690 let deleted = db.delete_link(&link.id).expect("Failed to delete link");
4692 assert!(deleted, "Should return true when link is deleted");
4693
4694 let links_after = db
4696 .get_links_by_session(&session.id)
4697 .expect("Failed to get links");
4698 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
4699 }
4700
4701 #[test]
4702 fn test_delete_link_nonexistent() {
4703 let (db, _dir) = create_test_db();
4704
4705 let nonexistent_id = Uuid::new_v4();
4706 let deleted = db
4707 .delete_link(&nonexistent_id)
4708 .expect("Failed to call delete_link");
4709
4710 assert!(!deleted, "Should return false for nonexistent link");
4711 }
4712
4713 #[test]
4714 fn test_delete_links_by_session() {
4715 let (db, _dir) = create_test_db();
4716
4717 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4718 db.insert_session(&session)
4719 .expect("Failed to insert session");
4720
4721 let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
4723 let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
4724 let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
4725
4726 db.insert_link(&link1).expect("Failed to insert link1");
4727 db.insert_link(&link2).expect("Failed to insert link2");
4728 db.insert_link(&link3).expect("Failed to insert link3");
4729
4730 let links_before = db
4732 .get_links_by_session(&session.id)
4733 .expect("Failed to get links");
4734 assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
4735
4736 let count = db
4738 .delete_links_by_session(&session.id)
4739 .expect("Failed to delete links");
4740 assert_eq!(count, 3, "Should have deleted 3 links");
4741
4742 let links_after = db
4744 .get_links_by_session(&session.id)
4745 .expect("Failed to get links");
4746 assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
4747 }
4748
4749 #[test]
4750 fn test_delete_links_by_session_no_links() {
4751 let (db, _dir) = create_test_db();
4752
4753 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4754 db.insert_session(&session)
4755 .expect("Failed to insert session");
4756
4757 let count = db
4759 .delete_links_by_session(&session.id)
4760 .expect("Failed to call delete_links_by_session");
4761 assert_eq!(count, 0, "Should return 0 when no links exist");
4762 }
4763
4764 #[test]
4765 fn test_delete_links_by_session_preserves_other_sessions() {
4766 let (db, _dir) = create_test_db();
4767
4768 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
4769 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
4770
4771 db.insert_session(&session1)
4772 .expect("Failed to insert session1");
4773 db.insert_session(&session2)
4774 .expect("Failed to insert session2");
4775
4776 let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
4777 let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
4778
4779 db.insert_link(&link1).expect("Failed to insert link1");
4780 db.insert_link(&link2).expect("Failed to insert link2");
4781
4782 let count = db
4784 .delete_links_by_session(&session1.id)
4785 .expect("Failed to delete links");
4786 assert_eq!(count, 1, "Should have deleted 1 link");
4787
4788 let session2_links = db
4790 .get_links_by_session(&session2.id)
4791 .expect("Failed to get links");
4792 assert_eq!(
4793 session2_links.len(),
4794 1,
4795 "Session2's link should be preserved"
4796 );
4797 assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
4798 }
4799
4800 #[test]
4801 fn test_delete_link_by_session_and_commit() {
4802 let (db, _dir) = create_test_db();
4803
4804 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4805 db.insert_session(&session)
4806 .expect("Failed to insert session");
4807
4808 let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
4809 let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
4810
4811 db.insert_link(&link1).expect("Failed to insert link1");
4812 db.insert_link(&link2).expect("Failed to insert link2");
4813
4814 let deleted = db
4816 .delete_link_by_session_and_commit(&session.id, "abc123")
4817 .expect("Failed to delete link");
4818 assert!(deleted, "Should return true when link is deleted");
4819
4820 let links = db
4822 .get_links_by_session(&session.id)
4823 .expect("Failed to get links");
4824 assert_eq!(links.len(), 1, "Should have 1 link remaining");
4825 assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
4826 }
4827
4828 #[test]
4829 fn test_delete_link_by_session_and_commit_full_sha() {
4830 let (db, _dir) = create_test_db();
4831
4832 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4833 db.insert_session(&session)
4834 .expect("Failed to insert session");
4835
4836 let full_sha = "abc123def456789012345678901234567890abcd";
4837 let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
4838 db.insert_link(&link).expect("Failed to insert link");
4839
4840 let deleted = db
4842 .delete_link_by_session_and_commit(&session.id, full_sha)
4843 .expect("Failed to delete link");
4844 assert!(deleted, "Should delete with full SHA");
4845
4846 let links = db
4847 .get_links_by_session(&session.id)
4848 .expect("Failed to get links");
4849 assert_eq!(links.len(), 0, "Should have 0 links after delete");
4850 }
4851
4852 #[test]
4853 fn test_delete_link_by_session_and_commit_no_match() {
4854 let (db, _dir) = create_test_db();
4855
4856 let session = create_test_session("claude-code", "/project", Utc::now(), None);
4857 db.insert_session(&session)
4858 .expect("Failed to insert session");
4859
4860 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
4861 db.insert_link(&link).expect("Failed to insert link");
4862
4863 let deleted = db
4865 .delete_link_by_session_and_commit(&session.id, "xyz999")
4866 .expect("Failed to call delete");
4867 assert!(!deleted, "Should return false when no match");
4868
4869 let links = db
4871 .get_links_by_session(&session.id)
4872 .expect("Failed to get links");
4873 assert_eq!(links.len(), 1, "Link should be preserved");
4874 }
4875
4876 #[test]
4877 fn test_delete_link_by_session_and_commit_wrong_session() {
4878 let (db, _dir) = create_test_db();
4879
4880 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
4881 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
4882
4883 db.insert_session(&session1)
4884 .expect("Failed to insert session1");
4885 db.insert_session(&session2)
4886 .expect("Failed to insert session2");
4887
4888 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
4889 db.insert_link(&link).expect("Failed to insert link");
4890
4891 let deleted = db
4893 .delete_link_by_session_and_commit(&session2.id, "abc123")
4894 .expect("Failed to call delete");
4895 assert!(!deleted, "Should not delete link from different session");
4896
4897 let links = db
4899 .get_links_by_session(&session1.id)
4900 .expect("Failed to get links");
4901 assert_eq!(links.len(), 1, "Link should be preserved");
4902 }
4903
4904 #[test]
4907 fn test_find_sessions_near_commit_time_basic() {
4908 let (db, _dir) = create_test_db();
4909 let now = Utc::now();
4910
4911 let mut session = create_test_session(
4913 "claude-code",
4914 "/home/user/project",
4915 now - Duration::hours(1),
4916 None,
4917 );
4918 session.ended_at = Some(now - Duration::minutes(10));
4919
4920 db.insert_session(&session).expect("insert session");
4921
4922 let found = db
4924 .find_sessions_near_commit_time(now, 30, None)
4925 .expect("find sessions");
4926
4927 assert_eq!(found.len(), 1, "Should find session within window");
4928 assert_eq!(found[0].id, session.id);
4929 }
4930
4931 #[test]
4932 fn test_find_sessions_near_commit_time_outside_window() {
4933 let (db, _dir) = create_test_db();
4934 let now = Utc::now();
4935
4936 let mut session =
4938 create_test_session("claude-code", "/project", now - Duration::hours(3), None);
4939 session.ended_at = Some(now - Duration::hours(2));
4940
4941 db.insert_session(&session).expect("insert session");
4942
4943 let found = db
4945 .find_sessions_near_commit_time(now, 30, None)
4946 .expect("find sessions");
4947
4948 assert!(found.is_empty(), "Should not find session outside window");
4949 }
4950
4951 #[test]
4952 fn test_find_sessions_near_commit_time_with_working_dir() {
4953 let (db, _dir) = create_test_db();
4954 let now = Utc::now();
4955
4956 let mut session1 = create_test_session(
4958 "claude-code",
4959 "/home/user/project-a",
4960 now - Duration::minutes(30),
4961 None,
4962 );
4963 session1.ended_at = Some(now - Duration::minutes(5));
4964
4965 let mut session2 = create_test_session(
4966 "claude-code",
4967 "/home/user/project-b",
4968 now - Duration::minutes(30),
4969 None,
4970 );
4971 session2.ended_at = Some(now - Duration::minutes(5));
4972
4973 db.insert_session(&session1).expect("insert session1");
4974 db.insert_session(&session2).expect("insert session2");
4975
4976 let found = db
4978 .find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
4979 .expect("find sessions");
4980
4981 assert_eq!(found.len(), 1, "Should find only session in project-a");
4982 assert_eq!(found[0].id, session1.id);
4983 }
4984
4985 #[test]
4986 fn test_find_sessions_near_commit_time_ongoing_session() {
4987 let (db, _dir) = create_test_db();
4988 let now = Utc::now();
4989
4990 let session =
4992 create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
4993 db.insert_session(&session).expect("insert session");
4996
4997 let found = db
4999 .find_sessions_near_commit_time(now, 30, None)
5000 .expect("find sessions");
5001
5002 assert_eq!(found.len(), 1, "Should find ongoing session");
5003 assert_eq!(found[0].id, session.id);
5004 }
5005
5006 #[test]
5007 fn test_link_exists_true() {
5008 let (db, _dir) = create_test_db();
5009
5010 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5011 db.insert_session(&session).expect("insert session");
5012
5013 let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
5014 db.insert_link(&link).expect("insert link");
5015
5016 assert!(
5018 db.link_exists(&session.id, "abc123def456")
5019 .expect("check exists"),
5020 "Should find link with full SHA"
5021 );
5022
5023 assert!(
5025 db.link_exists(&session.id, "abc123").expect("check exists"),
5026 "Should find link with partial SHA"
5027 );
5028 }
5029
5030 #[test]
5031 fn test_link_exists_false() {
5032 let (db, _dir) = create_test_db();
5033
5034 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5035 db.insert_session(&session).expect("insert session");
5036
5037 assert!(
5039 !db.link_exists(&session.id, "abc123").expect("check exists"),
5040 "Should not find non-existent link"
5041 );
5042 }
5043
5044 #[test]
5045 fn test_link_exists_different_session() {
5046 let (db, _dir) = create_test_db();
5047
5048 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
5049 let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
5050
5051 db.insert_session(&session1).expect("insert session1");
5052 db.insert_session(&session2).expect("insert session2");
5053
5054 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
5055 db.insert_link(&link).expect("insert link");
5056
5057 assert!(
5059 db.link_exists(&session1.id, "abc123").expect("check"),
5060 "Should find link for session1"
5061 );
5062 assert!(
5063 !db.link_exists(&session2.id, "abc123").expect("check"),
5064 "Should not find link for session2"
5065 );
5066 }
5067
5068 #[test]
5071 fn test_find_active_sessions_for_directory_ongoing() {
5072 let (db, _dir) = create_test_db();
5073 let now = Utc::now();
5074
5075 let session = create_test_session(
5077 "claude-code",
5078 "/home/user/project",
5079 now - Duration::minutes(30),
5080 None,
5081 );
5082 db.insert_session(&session).expect("insert session");
5085
5086 let found = db
5088 .find_active_sessions_for_directory("/home/user/project", None)
5089 .expect("find active sessions");
5090
5091 assert_eq!(found.len(), 1, "Should find ongoing session");
5092 assert_eq!(found[0].id, session.id);
5093 }
5094
5095 #[test]
5096 fn test_find_active_sessions_for_directory_recently_ended() {
5097 let (db, _dir) = create_test_db();
5098 let now = Utc::now();
5099
5100 let mut session = create_test_session(
5102 "claude-code",
5103 "/home/user/project",
5104 now - Duration::minutes(30),
5105 None,
5106 );
5107 session.ended_at = Some(now - Duration::minutes(2));
5108
5109 db.insert_session(&session).expect("insert session");
5110
5111 let found = db
5113 .find_active_sessions_for_directory("/home/user/project", None)
5114 .expect("find active sessions");
5115
5116 assert_eq!(found.len(), 1, "Should find recently ended session");
5117 assert_eq!(found[0].id, session.id);
5118 }
5119
5120 #[test]
5121 fn test_find_active_sessions_for_directory_old_session() {
5122 let (db, _dir) = create_test_db();
5123 let now = Utc::now();
5124
5125 let mut session = create_test_session(
5127 "claude-code",
5128 "/home/user/project",
5129 now - Duration::minutes(60),
5130 None,
5131 );
5132 session.ended_at = Some(now - Duration::minutes(10));
5133
5134 db.insert_session(&session).expect("insert session");
5135
5136 let found = db
5138 .find_active_sessions_for_directory("/home/user/project", None)
5139 .expect("find active sessions");
5140
5141 assert!(found.is_empty(), "Should not find old session");
5142 }
5143
5144 #[test]
5145 fn test_find_active_sessions_for_directory_filters_by_path() {
5146 let (db, _dir) = create_test_db();
5147 let now = Utc::now();
5148
5149 let session1 = create_test_session(
5151 "claude-code",
5152 "/home/user/project-a",
5153 now - Duration::minutes(10),
5154 None,
5155 );
5156 let session2 = create_test_session(
5157 "claude-code",
5158 "/home/user/project-b",
5159 now - Duration::minutes(10),
5160 None,
5161 );
5162
5163 db.insert_session(&session1).expect("insert session1");
5164 db.insert_session(&session2).expect("insert session2");
5165
5166 let found = db
5168 .find_active_sessions_for_directory("/home/user/project-a", None)
5169 .expect("find active sessions");
5170
5171 assert_eq!(found.len(), 1, "Should find only session in project-a");
5172 assert_eq!(found[0].id, session1.id);
5173 }
5174
5175 #[test]
5176 fn test_find_active_sessions_for_directory_trailing_slash_matches() {
5177 let (db, _dir) = create_test_db();
5178 let now = Utc::now();
5179
5180 let session = create_test_session(
5181 "claude-code",
5182 "/home/user/project",
5183 now - Duration::minutes(10),
5184 None,
5185 );
5186 db.insert_session(&session).expect("insert session");
5187
5188 let found = db
5189 .find_active_sessions_for_directory("/home/user/project/", None)
5190 .expect("find active sessions");
5191
5192 assert_eq!(found.len(), 1, "Should match even with trailing slash");
5193 assert_eq!(found[0].id, session.id);
5194 }
5195
5196 #[test]
5197 fn test_find_active_sessions_for_directory_does_not_match_prefix_siblings() {
5198 let (db, _dir) = create_test_db();
5199 let now = Utc::now();
5200
5201 let session_root = create_test_session(
5202 "claude-code",
5203 "/home/user/project",
5204 now - Duration::minutes(10),
5205 None,
5206 );
5207 let session_subdir = create_test_session(
5208 "claude-code",
5209 "/home/user/project/src",
5210 now - Duration::minutes(10),
5211 None,
5212 );
5213 let session_sibling = create_test_session(
5214 "claude-code",
5215 "/home/user/project-old",
5216 now - Duration::minutes(10),
5217 None,
5218 );
5219
5220 db.insert_session(&session_root)
5221 .expect("insert session_root");
5222 db.insert_session(&session_subdir)
5223 .expect("insert session_subdir");
5224 db.insert_session(&session_sibling)
5225 .expect("insert session_sibling");
5226
5227 let found = db
5228 .find_active_sessions_for_directory("/home/user/project", None)
5229 .expect("find active sessions");
5230
5231 let found_ids: std::collections::HashSet<Uuid> =
5232 found.iter().map(|session| session.id).collect();
5233 assert!(found_ids.contains(&session_root.id));
5234 assert!(found_ids.contains(&session_subdir.id));
5235 assert!(!found_ids.contains(&session_sibling.id));
5236 }
5237
5238 #[test]
5239 fn test_get_unsynced_sessions_for_repo_includes_root() {
5240 let (db, _dir) = create_test_db();
5241 let now = Utc::now();
5242
5243 let session = create_test_session("claude-code", "/home/user/project", now, None);
5244 db.insert_session(&session).expect("insert session");
5245
5246 let found = db
5247 .get_unsynced_sessions_for_repo(Path::new("/home/user/project"))
5248 .expect("scoped unsynced");
5249
5250 assert_eq!(found.len(), 1, "session at the repo root must be selected");
5251 assert_eq!(found[0].id, session.id);
5252 }
5253
5254 #[test]
5255 fn test_get_unsynced_sessions_for_repo_excludes_unrelated() {
5256 let (db, _dir) = create_test_db();
5257 let now = Utc::now();
5258
5259 let inside = create_test_session("claude-code", "/home/user/project", now, None);
5260 let unrelated = create_test_session("aider", "/home/user/other-project", now, None);
5261 db.insert_session(&inside).expect("insert inside");
5262 db.insert_session(&unrelated).expect("insert unrelated");
5263
5264 let found = db
5265 .get_unsynced_sessions_for_repo(Path::new("/home/user/project"))
5266 .expect("scoped unsynced");
5267
5268 let ids: std::collections::HashSet<Uuid> = found.iter().map(|s| s.id).collect();
5269 assert!(
5270 ids.contains(&inside.id),
5271 "in-scope session must be selected"
5272 );
5273 assert!(
5274 !ids.contains(&unrelated.id),
5275 "session in an unrelated directory must not be selected"
5276 );
5277 }
5278
5279 #[test]
5280 fn test_get_unsynced_sessions_for_repo_includes_nested_subdir() {
5281 let (db, _dir) = create_test_db();
5282 let now = Utc::now();
5283
5284 let nested = create_test_session("claude-code", "/home/user/project/src/deep", now, None);
5285 db.insert_session(&nested).expect("insert nested");
5286
5287 let found = db
5288 .get_unsynced_sessions_for_repo(Path::new("/home/user/project"))
5289 .expect("scoped unsynced");
5290
5291 assert_eq!(
5292 found.len(),
5293 1,
5294 "session in a nested subdirectory must be selected"
5295 );
5296 assert_eq!(found[0].id, nested.id);
5297 }
5298
5299 #[test]
5300 fn test_get_unsynced_sessions_for_repo_excludes_prefix_sibling() {
5301 let (db, _dir) = create_test_db();
5302 let now = Utc::now();
5303
5304 let root = create_test_session("claude-code", "/home/user/foo", now, None);
5305 let sibling = create_test_session("claude-code", "/home/user/foobar", now, None);
5306 db.insert_session(&root).expect("insert root");
5307 db.insert_session(&sibling).expect("insert sibling");
5308
5309 let found = db
5310 .get_unsynced_sessions_for_repo(Path::new("/home/user/foo"))
5311 .expect("scoped unsynced");
5312
5313 let ids: std::collections::HashSet<Uuid> = found.iter().map(|s| s.id).collect();
5314 assert!(
5315 ids.contains(&root.id),
5316 "the repo root session must be selected"
5317 );
5318 assert!(
5319 !ids.contains(&sibling.id),
5320 "a prefix-sibling directory must not be matched"
5321 );
5322 }
5323
5324 #[test]
5325 fn test_get_unsynced_sessions_for_repo_excludes_already_synced() {
5326 let (db, _dir) = create_test_db();
5327 let now = Utc::now();
5328
5329 let session = create_test_session("claude-code", "/home/user/project", now, None);
5330 db.insert_session(&session).expect("insert session");
5331 db.mark_sessions_synced(&[session.id], now)
5332 .expect("mark synced");
5333
5334 let found = db
5335 .get_unsynced_sessions_for_repo(Path::new("/home/user/project"))
5336 .expect("scoped unsynced");
5337
5338 assert!(
5339 found.is_empty(),
5340 "an already-synced in-scope session must not be selected"
5341 );
5342 }
5343
5344 #[test]
5345 fn test_unsynced_session_count_for_repo_scopes_to_repo() {
5346 let (db, _dir) = create_test_db();
5347 let now = Utc::now();
5348
5349 let inside = create_test_session("claude-code", "/home/user/project", now, None);
5350 let nested = create_test_session("claude-code", "/home/user/project/src", now, None);
5351 let outside = create_test_session("aider", "/home/user/elsewhere", now, None);
5352 db.insert_session(&inside).expect("insert inside");
5353 db.insert_session(&nested).expect("insert nested");
5354 db.insert_session(&outside).expect("insert outside");
5355
5356 let count = db
5357 .unsynced_session_count_for_repo(Path::new("/home/user/project"))
5358 .expect("scoped count");
5359
5360 assert_eq!(
5361 count, 2,
5362 "count must include only in-scope unsynced sessions"
5363 );
5364 }
5365
5366 #[test]
5367 fn test_get_session_ids_for_repo_includes_synced_and_excludes_unrelated() {
5368 let (db, _dir) = create_test_db();
5369 let now = Utc::now();
5370
5371 let unsynced = create_test_session("claude-code", "/home/user/project", now, None);
5372 let synced = create_test_session("claude-code", "/home/user/project/src", now, None);
5373 let outside = create_test_session("aider", "/home/user/other", now, None);
5374 db.insert_session(&unsynced).expect("insert unsynced");
5375 db.insert_session(&synced).expect("insert synced");
5376 db.insert_session(&outside).expect("insert outside");
5377 db.mark_sessions_synced(&[synced.id], now)
5378 .expect("mark synced");
5379
5380 let ids = db
5381 .get_session_ids_for_repo(Path::new("/home/user/project"))
5382 .expect("scoped ids");
5383
5384 assert!(
5385 ids.contains(&unsynced.id),
5386 "an unsynced in-scope id must be included"
5387 );
5388 assert!(
5389 ids.contains(&synced.id),
5390 "an already-synced in-scope id must still be included (scope ignores sync state)"
5391 );
5392 assert!(
5393 !ids.contains(&outside.id),
5394 "an out-of-scope id must be excluded"
5395 );
5396 }
5397
5398 #[cfg(unix)]
5402 #[test]
5403 fn test_get_unsynced_sessions_for_repo_matches_both_path_variants() {
5404 use std::os::unix::fs::symlink;
5405
5406 let (db, _dir) = create_test_db();
5407 let now = Utc::now();
5408
5409 let base = tempdir().expect("tempdir");
5410 let real = base.path().join("real");
5411 std::fs::create_dir(&real).expect("create real dir");
5412 let link = base.path().join("link");
5413 symlink(&real, &link).expect("create symlink");
5414
5415 let via_link = create_test_session("claude-code", &link.to_string_lossy(), now, None);
5419 let canonical = real.canonicalize().expect("canonicalize real");
5420 let via_real = create_test_session("aider", &canonical.to_string_lossy(), now, None);
5421 db.insert_session(&via_link).expect("insert via link");
5422 db.insert_session(&via_real).expect("insert via real");
5423
5424 let found = db
5425 .get_unsynced_sessions_for_repo(&link)
5426 .expect("scoped unsynced");
5427 let ids: std::collections::HashSet<Uuid> = found.iter().map(|s| s.id).collect();
5428
5429 assert!(
5430 ids.contains(&via_link.id),
5431 "a session captured under the symlinked repo path must match"
5432 );
5433 assert!(
5434 ids.contains(&via_real.id),
5435 "a session captured under the canonicalized repo path must match"
5436 );
5437 }
5438
5439 #[test]
5442 fn test_repo_scope_escapes_like_metacharacters() {
5443 let (db, _dir) = create_test_db();
5444 let now = Utc::now();
5445
5446 let repo = "/home/user/w_%|k";
5450
5451 let root = create_test_session("claude-code", repo, now, None);
5452 let nested = create_test_session("claude-code", "/home/user/w_%|k/deep/file", now, None);
5453 let underscore_decoy = create_test_session("aider", "/home/user/wX%|k/x", now, None);
5457 let percent_decoy = create_test_session("aider", "/home/user/w_ANY|k/y", now, None);
5458 for s in [&root, &nested, &underscore_decoy, &percent_decoy] {
5459 db.insert_session(s).expect("insert session");
5460 }
5461
5462 let found = db
5463 .get_unsynced_sessions_for_repo(Path::new(repo))
5464 .expect("scoped unsynced");
5465 let ids: std::collections::HashSet<Uuid> = found.iter().map(|s| s.id).collect();
5466
5467 assert!(ids.contains(&root.id), "the repo root must match");
5468 assert!(
5469 ids.contains(&nested.id),
5470 "a nested path must match (| escaping keeps the pattern valid)"
5471 );
5472 assert!(
5473 !ids.contains(&underscore_decoy.id),
5474 "'_' must be escaped, not treated as a single-char wildcard"
5475 );
5476 assert!(
5477 !ids.contains(&percent_decoy.id),
5478 "'%' must be escaped, not treated as a multi-char wildcard"
5479 );
5480
5481 let id_set = db
5483 .get_session_ids_for_repo(Path::new(repo))
5484 .expect("scoped ids");
5485 assert!(id_set.contains(&root.id));
5486 assert!(id_set.contains(&nested.id));
5487 assert!(!id_set.contains(&underscore_decoy.id));
5488 assert!(!id_set.contains(&percent_decoy.id));
5489 }
5490
5491 #[test]
5492 fn test_find_active_sessions_for_directory_custom_window() {
5493 let (db, _dir) = create_test_db();
5494 let now = Utc::now();
5495
5496 let mut session = create_test_session(
5498 "claude-code",
5499 "/home/user/project",
5500 now - Duration::minutes(30),
5501 None,
5502 );
5503 session.ended_at = Some(now - Duration::minutes(8));
5504
5505 db.insert_session(&session).expect("insert session");
5506
5507 let found = db
5509 .find_active_sessions_for_directory("/home/user/project", None)
5510 .expect("find with default window");
5511 assert!(found.is_empty(), "Should not find with 5 minute window");
5512
5513 let found = db
5515 .find_active_sessions_for_directory("/home/user/project", Some(10))
5516 .expect("find with 10 minute window");
5517 assert_eq!(found.len(), 1, "Should find with 10 minute window");
5518 }
5519
5520 #[test]
5523 fn test_search_with_tool_filter() {
5524 let (db, _dir) = create_test_db();
5525
5526 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
5527 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
5528
5529 db.insert_session(&session1).expect("insert session1");
5530 db.insert_session(&session2).expect("insert session2");
5531
5532 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
5533 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
5534
5535 db.insert_message(&msg1).expect("insert msg1");
5536 db.insert_message(&msg2).expect("insert msg2");
5537
5538 let options = SearchOptions {
5540 query: "Hello".to_string(),
5541 limit: 10,
5542 tool: Some("claude-code".to_string()),
5543 ..Default::default()
5544 };
5545 let results = db.search_with_options(&options).expect("search");
5546
5547 assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
5548 assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
5549 }
5550
5551 #[test]
5552 fn test_search_with_date_range() {
5553 let (db, _dir) = create_test_db();
5554
5555 let old_time = Utc::now() - chrono::Duration::days(30);
5556 let new_time = Utc::now() - chrono::Duration::days(1);
5557
5558 let session1 = create_test_session("claude-code", "/project1", old_time, None);
5559 let session2 = create_test_session("claude-code", "/project2", new_time, None);
5560
5561 db.insert_session(&session1).expect("insert session1");
5562 db.insert_session(&session2).expect("insert session2");
5563
5564 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
5565 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
5566
5567 db.insert_message(&msg1).expect("insert msg1");
5568 db.insert_message(&msg2).expect("insert msg2");
5569
5570 let since = Utc::now() - chrono::Duration::days(7);
5572 let options = SearchOptions {
5573 query: "session".to_string(),
5574 limit: 10,
5575 since: Some(since),
5576 ..Default::default()
5577 };
5578 let results = db.search_with_options(&options).expect("search");
5579
5580 assert_eq!(results.len(), 1, "Should find 1 result within date range");
5581 assert!(
5582 results[0].working_directory.contains("project2"),
5583 "Should be from newer project"
5584 );
5585 }
5586
5587 #[test]
5588 fn test_search_with_project_filter() {
5589 let (db, _dir) = create_test_db();
5590
5591 let session1 =
5592 create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
5593 let session2 =
5594 create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
5595
5596 db.insert_session(&session1).expect("insert session1");
5597 db.insert_session(&session2).expect("insert session2");
5598
5599 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
5600 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
5601
5602 db.insert_message(&msg1).expect("insert msg1");
5603 db.insert_message(&msg2).expect("insert msg2");
5604
5605 let options = SearchOptions {
5607 query: "Testing".to_string(),
5608 limit: 10,
5609 project: Some("frontend".to_string()),
5610 ..Default::default()
5611 };
5612 let results = db.search_with_options(&options).expect("search");
5613
5614 assert_eq!(results.len(), 1, "Should find 1 result with project filter");
5615 assert!(
5616 results[0].working_directory.contains("frontend"),
5617 "Should be from frontend project"
5618 );
5619 }
5620
5621 #[test]
5622 fn test_search_with_branch_filter() {
5623 let (db, _dir) = create_test_db();
5624
5625 let session1 = Session {
5626 id: Uuid::new_v4(),
5627 tool: "claude-code".to_string(),
5628 tool_version: None,
5629 started_at: Utc::now(),
5630 ended_at: None,
5631 model: None,
5632 working_directory: "/project".to_string(),
5633 git_branch: Some("feat/auth".to_string()),
5634 source_path: None,
5635 message_count: 0,
5636 machine_id: None,
5637 };
5638 let session2 = Session {
5639 id: Uuid::new_v4(),
5640 tool: "claude-code".to_string(),
5641 tool_version: None,
5642 started_at: Utc::now(),
5643 ended_at: None,
5644 model: None,
5645 working_directory: "/project".to_string(),
5646 git_branch: Some("main".to_string()),
5647 source_path: None,
5648 message_count: 0,
5649 machine_id: None,
5650 };
5651
5652 db.insert_session(&session1).expect("insert session1");
5653 db.insert_session(&session2).expect("insert session2");
5654
5655 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
5656 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
5657
5658 db.insert_message(&msg1).expect("insert msg1");
5659 db.insert_message(&msg2).expect("insert msg2");
5660
5661 let options = SearchOptions {
5663 query: "work".to_string(),
5664 limit: 10,
5665 branch: Some("auth".to_string()),
5666 ..Default::default()
5667 };
5668 let results = db.search_with_options(&options).expect("search");
5669
5670 assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
5671 assert_eq!(
5672 results[0].git_branch.as_deref(),
5673 Some("feat/auth"),
5674 "Should be from feat/auth branch"
5675 );
5676 }
5677
5678 #[test]
5679 fn test_search_metadata_matches_project() {
5680 let (db, _dir) = create_test_db();
5681
5682 let session =
5683 create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
5684 db.insert_session(&session).expect("insert session");
5685
5686 let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
5688 db.insert_message(&msg).expect("insert msg");
5689
5690 let options = SearchOptions {
5692 query: "redactyl".to_string(),
5693 limit: 10,
5694 ..Default::default()
5695 };
5696 let results = db.search_with_options(&options).expect("search");
5697
5698 assert_eq!(
5699 results.len(),
5700 1,
5701 "Should find session via metadata match on project name"
5702 );
5703 }
5704
5705 #[test]
5706 fn test_search_returns_extended_session_info() {
5707 let (db, _dir) = create_test_db();
5708
5709 let started_at = Utc::now();
5710 let session = Session {
5711 id: Uuid::new_v4(),
5712 tool: "claude-code".to_string(),
5713 tool_version: Some("1.0.0".to_string()),
5714 started_at,
5715 ended_at: None,
5716 model: None,
5717 working_directory: "/home/user/myapp".to_string(),
5718 git_branch: Some("develop".to_string()),
5719 source_path: None,
5720 message_count: 5,
5721 machine_id: None,
5722 };
5723 db.insert_session(&session).expect("insert session");
5724
5725 let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
5726 db.insert_message(&msg).expect("insert msg");
5727
5728 let options = SearchOptions {
5729 query: "Test".to_string(),
5730 limit: 10,
5731 ..Default::default()
5732 };
5733 let results = db.search_with_options(&options).expect("search");
5734
5735 assert_eq!(results.len(), 1, "Should find 1 result");
5736 let result = &results[0];
5737
5738 assert_eq!(result.tool, "claude-code", "Tool should be populated");
5739 assert_eq!(
5740 result.git_branch.as_deref(),
5741 Some("develop"),
5742 "Branch should be populated"
5743 );
5744 assert!(
5745 result.session_message_count > 0,
5746 "Message count should be populated"
5747 );
5748 assert!(
5749 result.session_started_at.is_some(),
5750 "Session start time should be populated"
5751 );
5752 }
5753
5754 #[test]
5755 fn test_get_context_messages() {
5756 let (db, _dir) = create_test_db();
5757
5758 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5759 db.insert_session(&session).expect("insert session");
5760
5761 for i in 0..5 {
5763 let role = if i % 2 == 0 {
5764 MessageRole::User
5765 } else {
5766 MessageRole::Assistant
5767 };
5768 let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
5769 db.insert_message(&msg).expect("insert message");
5770 }
5771
5772 let (before, after) = db
5774 .get_context_messages(&session.id, 2, 1)
5775 .expect("get context");
5776
5777 assert_eq!(before.len(), 1, "Should have 1 message before");
5778 assert_eq!(after.len(), 1, "Should have 1 message after");
5779 assert_eq!(before[0].index, 1, "Before message should be index 1");
5780 assert_eq!(after[0].index, 3, "After message should be index 3");
5781 }
5782
5783 #[test]
5784 fn test_get_context_messages_at_start() {
5785 let (db, _dir) = create_test_db();
5786
5787 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5788 db.insert_session(&session).expect("insert session");
5789
5790 for i in 0..3 {
5791 let msg =
5792 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
5793 db.insert_message(&msg).expect("insert message");
5794 }
5795
5796 let (before, after) = db
5798 .get_context_messages(&session.id, 0, 2)
5799 .expect("get context");
5800
5801 assert!(
5802 before.is_empty(),
5803 "Should have no messages before first message"
5804 );
5805 assert_eq!(after.len(), 2, "Should have 2 messages after");
5806 }
5807
5808 #[test]
5809 fn test_get_context_messages_at_end() {
5810 let (db, _dir) = create_test_db();
5811
5812 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5813 db.insert_session(&session).expect("insert session");
5814
5815 for i in 0..3 {
5816 let msg =
5817 create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
5818 db.insert_message(&msg).expect("insert message");
5819 }
5820
5821 let (before, after) = db
5823 .get_context_messages(&session.id, 2, 2)
5824 .expect("get context");
5825
5826 assert_eq!(before.len(), 2, "Should have 2 messages before");
5827 assert!(
5828 after.is_empty(),
5829 "Should have no messages after last message"
5830 );
5831 }
5832
5833 #[test]
5834 fn test_search_combined_filters() {
5835 let (db, _dir) = create_test_db();
5836
5837 let session1 = Session {
5838 id: Uuid::new_v4(),
5839 tool: "claude-code".to_string(),
5840 tool_version: None,
5841 started_at: Utc::now(),
5842 ended_at: None,
5843 model: None,
5844 working_directory: "/home/user/myapp".to_string(),
5845 git_branch: Some("feat/api".to_string()),
5846 source_path: None,
5847 message_count: 1,
5848 machine_id: None,
5849 };
5850 let session2 = Session {
5851 id: Uuid::new_v4(),
5852 tool: "aider".to_string(),
5853 tool_version: None,
5854 started_at: Utc::now(),
5855 ended_at: None,
5856 model: None,
5857 working_directory: "/home/user/myapp".to_string(),
5858 git_branch: Some("feat/api".to_string()),
5859 source_path: None,
5860 message_count: 1,
5861 machine_id: None,
5862 };
5863
5864 db.insert_session(&session1).expect("insert session1");
5865 db.insert_session(&session2).expect("insert session2");
5866
5867 let msg1 =
5868 create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
5869 let msg2 =
5870 create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
5871
5872 db.insert_message(&msg1).expect("insert msg1");
5873 db.insert_message(&msg2).expect("insert msg2");
5874
5875 let options = SearchOptions {
5877 query: "API".to_string(),
5878 limit: 10,
5879 tool: Some("claude-code".to_string()),
5880 branch: Some("api".to_string()),
5881 project: Some("myapp".to_string()),
5882 ..Default::default()
5883 };
5884 let results = db.search_with_options(&options).expect("search");
5885
5886 assert!(
5888 !results.is_empty(),
5889 "Should find at least 1 result matching all filters"
5890 );
5891 for result in &results {
5893 assert_eq!(
5894 result.tool, "claude-code",
5895 "All results should be from claude-code"
5896 );
5897 }
5898 }
5899
5900 #[test]
5903 fn test_delete_session_removes_all_data() {
5904 let (db, _dir) = create_test_db();
5905
5906 let session = create_test_session("claude-code", "/project", Utc::now(), None);
5907 db.insert_session(&session).expect("insert session");
5908
5909 let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
5911 let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there");
5912 db.insert_message(&msg1).expect("insert msg1");
5913 db.insert_message(&msg2).expect("insert msg2");
5914
5915 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
5917 db.insert_link(&link).expect("insert link");
5918
5919 assert_eq!(db.session_count().expect("count"), 1);
5921 assert_eq!(db.message_count().expect("count"), 2);
5922 assert_eq!(db.link_count().expect("count"), 1);
5923
5924 let (msgs_deleted, links_deleted) = db.delete_session(&session.id).expect("delete");
5926 assert_eq!(msgs_deleted, 2, "Should delete 2 messages");
5927 assert_eq!(links_deleted, 1, "Should delete 1 link");
5928
5929 assert_eq!(db.session_count().expect("count"), 0);
5931 assert_eq!(db.message_count().expect("count"), 0);
5932 assert_eq!(db.link_count().expect("count"), 0);
5933 assert!(db.get_session(&session.id).expect("get").is_none());
5934 }
5935
5936 #[test]
5937 fn test_delete_session_preserves_other_sessions() {
5938 let (db, _dir) = create_test_db();
5939
5940 let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
5941 let session2 = create_test_session("aider", "/project2", Utc::now(), None);
5942
5943 db.insert_session(&session1).expect("insert session1");
5944 db.insert_session(&session2).expect("insert session2");
5945
5946 let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello 1");
5947 let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello 2");
5948 db.insert_message(&msg1).expect("insert msg1");
5949 db.insert_message(&msg2).expect("insert msg2");
5950
5951 db.delete_session(&session1.id).expect("delete");
5953
5954 assert_eq!(db.session_count().expect("count"), 1);
5956 assert_eq!(db.message_count().expect("count"), 1);
5957 assert!(db.get_session(&session2.id).expect("get").is_some());
5958 }
5959
5960 #[test]
5963 fn test_file_size() {
5964 let (db, _dir) = create_test_db();
5965
5966 let size = db.file_size().expect("get size");
5967 assert!(size.is_some(), "Should have file size for file-based db");
5968 assert!(size.unwrap() > 0, "Database file should have size > 0");
5969 }
5970
5971 #[test]
5972 fn test_vacuum() {
5973 let (db, _dir) = create_test_db();
5974
5975 db.vacuum().expect("vacuum should succeed");
5977 }
5978
5979 #[test]
5980 fn test_count_sessions_older_than() {
5981 let (db, _dir) = create_test_db();
5982 let now = Utc::now();
5983
5984 let old_session =
5986 create_test_session("claude-code", "/project1", now - Duration::days(100), None);
5987 let recent_session =
5988 create_test_session("claude-code", "/project2", now - Duration::days(10), None);
5989
5990 db.insert_session(&old_session).expect("insert old");
5991 db.insert_session(&recent_session).expect("insert recent");
5992
5993 let cutoff = now - Duration::days(30);
5995 let count = db.count_sessions_older_than(cutoff).expect("count");
5996 assert_eq!(count, 1, "Should find 1 session older than 30 days");
5997
5998 let old_cutoff = now - Duration::days(200);
6000 let old_count = db.count_sessions_older_than(old_cutoff).expect("count");
6001 assert_eq!(old_count, 0, "Should find 0 sessions older than 200 days");
6002 }
6003
6004 #[test]
6005 fn test_delete_sessions_older_than() {
6006 let (db, _dir) = create_test_db();
6007 let now = Utc::now();
6008
6009 let old_session =
6011 create_test_session("claude-code", "/project1", now - Duration::days(100), None);
6012 let recent_session =
6013 create_test_session("claude-code", "/project2", now - Duration::days(10), None);
6014
6015 db.insert_session(&old_session).expect("insert old");
6016 db.insert_session(&recent_session).expect("insert recent");
6017
6018 let msg1 = create_test_message(old_session.id, 0, MessageRole::User, "Old message");
6020 let msg2 = create_test_message(recent_session.id, 0, MessageRole::User, "Recent message");
6021 db.insert_message(&msg1).expect("insert msg1");
6022 db.insert_message(&msg2).expect("insert msg2");
6023
6024 let cutoff = now - Duration::days(30);
6026 let deleted = db.delete_sessions_older_than(cutoff).expect("delete");
6027 assert_eq!(deleted, 1, "Should delete 1 session");
6028
6029 assert_eq!(db.session_count().expect("count"), 1);
6031 assert!(db.get_session(&recent_session.id).expect("get").is_some());
6032 assert!(db.get_session(&old_session.id).expect("get").is_none());
6033
6034 assert_eq!(db.message_count().expect("count"), 1);
6036 }
6037
6038 #[test]
6039 fn test_get_sessions_older_than() {
6040 let (db, _dir) = create_test_db();
6041 let now = Utc::now();
6042
6043 let old_session = create_test_session(
6045 "claude-code",
6046 "/project/old",
6047 now - Duration::days(100),
6048 None,
6049 );
6050 let medium_session =
6051 create_test_session("aider", "/project/medium", now - Duration::days(50), None);
6052 let recent_session =
6053 create_test_session("gemini", "/project/recent", now - Duration::days(10), None);
6054
6055 db.insert_session(&old_session).expect("insert old");
6056 db.insert_session(&medium_session).expect("insert medium");
6057 db.insert_session(&recent_session).expect("insert recent");
6058
6059 let cutoff = now - Duration::days(30);
6061 let sessions = db.get_sessions_older_than(cutoff).expect("get sessions");
6062 assert_eq!(
6063 sessions.len(),
6064 2,
6065 "Should find 2 sessions older than 30 days"
6066 );
6067
6068 assert_eq!(sessions[0].id, old_session.id);
6070 assert_eq!(sessions[1].id, medium_session.id);
6071
6072 assert_eq!(sessions[0].tool, "claude-code");
6074 assert_eq!(sessions[0].working_directory, "/project/old");
6075 assert_eq!(sessions[1].tool, "aider");
6076 assert_eq!(sessions[1].working_directory, "/project/medium");
6077
6078 let old_cutoff = now - Duration::days(200);
6080 let old_sessions = db
6081 .get_sessions_older_than(old_cutoff)
6082 .expect("get old sessions");
6083 assert_eq!(
6084 old_sessions.len(),
6085 0,
6086 "Should find 0 sessions older than 200 days"
6087 );
6088 }
6089
6090 #[test]
6091 fn test_stats() {
6092 let (db, _dir) = create_test_db();
6093 let now = Utc::now();
6094
6095 let empty_stats = db.stats().expect("stats");
6097 assert_eq!(empty_stats.session_count, 0);
6098 assert_eq!(empty_stats.message_count, 0);
6099 assert_eq!(empty_stats.link_count, 0);
6100 assert!(empty_stats.oldest_session.is_none());
6101 assert!(empty_stats.newest_session.is_none());
6102 assert!(empty_stats.sessions_by_tool.is_empty());
6103
6104 let session1 =
6106 create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
6107 let session2 = create_test_session("aider", "/project2", now - Duration::hours(1), None);
6108 let session3 = create_test_session("claude-code", "/project3", now, None);
6109
6110 db.insert_session(&session1).expect("insert 1");
6111 db.insert_session(&session2).expect("insert 2");
6112 db.insert_session(&session3).expect("insert 3");
6113
6114 let msg = create_test_message(session1.id, 0, MessageRole::User, "Hello");
6115 db.insert_message(&msg).expect("insert msg");
6116
6117 let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
6118 db.insert_link(&link).expect("insert link");
6119
6120 let stats = db.stats().expect("stats");
6122 assert_eq!(stats.session_count, 3);
6123 assert_eq!(stats.message_count, 1);
6124 assert_eq!(stats.link_count, 1);
6125 assert!(stats.oldest_session.is_some());
6126 assert!(stats.newest_session.is_some());
6127
6128 assert_eq!(stats.sessions_by_tool.len(), 2);
6130 assert_eq!(stats.sessions_by_tool[0].0, "claude-code");
6132 assert_eq!(stats.sessions_by_tool[0].1, 2);
6133 assert_eq!(stats.sessions_by_tool[1].0, "aider");
6134 assert_eq!(stats.sessions_by_tool[1].1, 1);
6135 }
6136
6137 #[test]
6140 fn test_get_session_branch_history_no_messages() {
6141 let (db, _dir) = create_test_db();
6142 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6143 db.insert_session(&session)
6144 .expect("Failed to insert session");
6145
6146 let branches = db
6147 .get_session_branch_history(session.id)
6148 .expect("Failed to get branch history");
6149
6150 assert!(branches.is_empty(), "Empty session should have no branches");
6151 }
6152
6153 #[test]
6154 fn test_get_session_branch_history_single_branch() {
6155 let (db, _dir) = create_test_db();
6156 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6157 db.insert_session(&session)
6158 .expect("Failed to insert session");
6159
6160 for i in 0..3 {
6162 let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
6163 msg.git_branch = Some("main".to_string());
6164 db.insert_message(&msg).expect("Failed to insert message");
6165 }
6166
6167 let branches = db
6168 .get_session_branch_history(session.id)
6169 .expect("Failed to get branch history");
6170
6171 assert_eq!(branches, vec!["main"], "Should have single branch");
6172 }
6173
6174 #[test]
6175 fn test_get_session_branch_history_multiple_branches() {
6176 let (db, _dir) = create_test_db();
6177 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6178 db.insert_session(&session)
6179 .expect("Failed to insert session");
6180
6181 let branch_sequence = ["main", "main", "feat/auth", "feat/auth", "main"];
6183 for (i, branch) in branch_sequence.iter().enumerate() {
6184 let mut msg = create_test_message(session.id, i as i32, MessageRole::User, "test");
6185 msg.git_branch = Some(branch.to_string());
6186 db.insert_message(&msg).expect("Failed to insert message");
6187 }
6188
6189 let branches = db
6190 .get_session_branch_history(session.id)
6191 .expect("Failed to get branch history");
6192
6193 assert_eq!(
6194 branches,
6195 vec!["main", "feat/auth", "main"],
6196 "Should show branch transitions without consecutive duplicates"
6197 );
6198 }
6199
6200 #[test]
6201 fn test_get_session_branch_history_with_none_branches() {
6202 let (db, _dir) = create_test_db();
6203 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6204 db.insert_session(&session)
6205 .expect("Failed to insert session");
6206
6207 let mut msg1 = create_test_message(session.id, 0, MessageRole::User, "test");
6209 msg1.git_branch = Some("main".to_string());
6210 db.insert_message(&msg1).expect("Failed to insert message");
6211
6212 let mut msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "test");
6213 msg2.git_branch = None; db.insert_message(&msg2).expect("Failed to insert message");
6215
6216 let mut msg3 = create_test_message(session.id, 2, MessageRole::User, "test");
6217 msg3.git_branch = Some("feat/new".to_string());
6218 db.insert_message(&msg3).expect("Failed to insert message");
6219
6220 let branches = db
6221 .get_session_branch_history(session.id)
6222 .expect("Failed to get branch history");
6223
6224 assert_eq!(
6225 branches,
6226 vec!["main", "feat/new"],
6227 "Should skip None branches and show transitions"
6228 );
6229 }
6230
6231 #[test]
6232 fn test_get_session_branch_history_all_none_branches() {
6233 let (db, _dir) = create_test_db();
6234 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6235 db.insert_session(&session)
6236 .expect("Failed to insert session");
6237
6238 for i in 0..3 {
6240 let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
6241 msg.git_branch = None;
6242 db.insert_message(&msg).expect("Failed to insert message");
6243 }
6244
6245 let branches = db
6246 .get_session_branch_history(session.id)
6247 .expect("Failed to get branch history");
6248
6249 assert!(
6250 branches.is_empty(),
6251 "Session with all None branches should return empty"
6252 );
6253 }
6254
6255 #[test]
6258 fn test_session_stores_machine_id() {
6259 let (db, _dir) = create_test_db();
6260 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6261
6262 db.insert_session(&session)
6263 .expect("Failed to insert session");
6264
6265 let retrieved = db
6266 .get_session(&session.id)
6267 .expect("Failed to get session")
6268 .expect("Session should exist");
6269
6270 assert_eq!(
6271 retrieved.machine_id,
6272 Some("test-machine".to_string()),
6273 "Machine ID should be preserved"
6274 );
6275 }
6276
6277 #[test]
6278 fn test_session_with_none_machine_id() {
6279 let (db, _dir) = create_test_db();
6280 let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
6281 session.machine_id = None;
6282
6283 db.insert_session(&session)
6284 .expect("Failed to insert session");
6285
6286 let retrieved = db
6287 .get_session(&session.id)
6288 .expect("Failed to get session")
6289 .expect("Session should exist");
6290
6291 assert!(
6292 retrieved.machine_id.is_none(),
6293 "Session with None machine_id should preserve None"
6294 );
6295 }
6296
6297 #[test]
6298 fn test_migration_adds_machine_id_column() {
6299 let (db, _dir) = create_test_db();
6301
6302 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6304 db.insert_session(&session)
6305 .expect("Should insert session with machine_id column");
6306
6307 let retrieved = db
6309 .get_session(&session.id)
6310 .expect("Failed to get session")
6311 .expect("Session should exist");
6312
6313 assert_eq!(
6314 retrieved.machine_id,
6315 Some("test-machine".to_string()),
6316 "Machine ID should be stored and retrieved"
6317 );
6318 }
6319
6320 #[test]
6321 fn test_list_sessions_includes_machine_id() {
6322 let (db, _dir) = create_test_db();
6323 let now = Utc::now();
6324
6325 let mut session1 = create_test_session("claude-code", "/project1", now, None);
6326 session1.machine_id = Some("machine-a".to_string());
6327
6328 let mut session2 = create_test_session("claude-code", "/project2", now, None);
6329 session2.machine_id = Some("machine-b".to_string());
6330
6331 db.insert_session(&session1).expect("insert");
6332 db.insert_session(&session2).expect("insert");
6333
6334 let sessions = db.list_sessions(10, None).expect("list");
6335
6336 assert_eq!(sessions.len(), 2);
6337 let machine_ids: Vec<Option<String>> =
6338 sessions.iter().map(|s| s.machine_id.clone()).collect();
6339 assert!(machine_ids.contains(&Some("machine-a".to_string())));
6340 assert!(machine_ids.contains(&Some("machine-b".to_string())));
6341 }
6342
6343 #[test]
6346 fn test_insert_and_get_annotations() {
6347 let (db, _dir) = create_test_db();
6348 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6349 db.insert_session(&session).expect("insert session");
6350
6351 let annotation = Annotation {
6352 id: Uuid::new_v4(),
6353 session_id: session.id,
6354 content: "This is a test note".to_string(),
6355 created_at: Utc::now(),
6356 };
6357 db.insert_annotation(&annotation)
6358 .expect("insert annotation");
6359
6360 let annotations = db.get_annotations(&session.id).expect("get annotations");
6361 assert_eq!(annotations.len(), 1);
6362 assert_eq!(annotations[0].content, "This is a test note");
6363 assert_eq!(annotations[0].session_id, session.id);
6364 }
6365
6366 #[test]
6367 fn test_delete_annotation() {
6368 let (db, _dir) = create_test_db();
6369 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6370 db.insert_session(&session).expect("insert session");
6371
6372 let annotation = Annotation {
6373 id: Uuid::new_v4(),
6374 session_id: session.id,
6375 content: "Test annotation".to_string(),
6376 created_at: Utc::now(),
6377 };
6378 db.insert_annotation(&annotation).expect("insert");
6379
6380 let deleted = db.delete_annotation(&annotation.id).expect("delete");
6381 assert!(deleted);
6382
6383 let annotations = db.get_annotations(&session.id).expect("get");
6384 assert!(annotations.is_empty());
6385 }
6386
6387 #[test]
6388 fn test_delete_annotations_by_session() {
6389 let (db, _dir) = create_test_db();
6390 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6391 db.insert_session(&session).expect("insert session");
6392
6393 for i in 0..3 {
6394 let annotation = Annotation {
6395 id: Uuid::new_v4(),
6396 session_id: session.id,
6397 content: format!("Annotation {i}"),
6398 created_at: Utc::now(),
6399 };
6400 db.insert_annotation(&annotation).expect("insert");
6401 }
6402
6403 let count = db
6404 .delete_annotations_by_session(&session.id)
6405 .expect("delete all");
6406 assert_eq!(count, 3);
6407
6408 let annotations = db.get_annotations(&session.id).expect("get");
6409 assert!(annotations.is_empty());
6410 }
6411
6412 #[test]
6415 fn test_insert_and_get_tags() {
6416 let (db, _dir) = create_test_db();
6417 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6418 db.insert_session(&session).expect("insert session");
6419
6420 let tag = Tag {
6421 id: Uuid::new_v4(),
6422 session_id: session.id,
6423 label: "bug-fix".to_string(),
6424 created_at: Utc::now(),
6425 };
6426 db.insert_tag(&tag).expect("insert tag");
6427
6428 let tags = db.get_tags(&session.id).expect("get tags");
6429 assert_eq!(tags.len(), 1);
6430 assert_eq!(tags[0].label, "bug-fix");
6431 }
6432
6433 #[test]
6434 fn test_tag_exists() {
6435 let (db, _dir) = create_test_db();
6436 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6437 db.insert_session(&session).expect("insert session");
6438
6439 assert!(!db.tag_exists(&session.id, "bug-fix").expect("check"));
6440
6441 let tag = Tag {
6442 id: Uuid::new_v4(),
6443 session_id: session.id,
6444 label: "bug-fix".to_string(),
6445 created_at: Utc::now(),
6446 };
6447 db.insert_tag(&tag).expect("insert tag");
6448
6449 assert!(db.tag_exists(&session.id, "bug-fix").expect("check"));
6450 assert!(!db.tag_exists(&session.id, "feature").expect("check other"));
6451 }
6452
6453 #[test]
6454 fn test_delete_tag() {
6455 let (db, _dir) = create_test_db();
6456 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6457 db.insert_session(&session).expect("insert session");
6458
6459 let tag = Tag {
6460 id: Uuid::new_v4(),
6461 session_id: session.id,
6462 label: "wip".to_string(),
6463 created_at: Utc::now(),
6464 };
6465 db.insert_tag(&tag).expect("insert tag");
6466
6467 let deleted = db.delete_tag(&session.id, "wip").expect("delete");
6468 assert!(deleted);
6469
6470 let deleted_again = db.delete_tag(&session.id, "wip").expect("delete again");
6471 assert!(!deleted_again);
6472 }
6473
6474 #[test]
6475 fn test_list_sessions_with_tag() {
6476 let (db, _dir) = create_test_db();
6477 let now = Utc::now();
6478
6479 let session1 = create_test_session("claude-code", "/project1", now, None);
6480 let session2 =
6481 create_test_session("claude-code", "/project2", now - Duration::minutes(5), None);
6482 let session3 = create_test_session(
6483 "claude-code",
6484 "/project3",
6485 now - Duration::minutes(10),
6486 None,
6487 );
6488
6489 db.insert_session(&session1).expect("insert");
6490 db.insert_session(&session2).expect("insert");
6491 db.insert_session(&session3).expect("insert");
6492
6493 let tag1 = Tag {
6495 id: Uuid::new_v4(),
6496 session_id: session1.id,
6497 label: "feature".to_string(),
6498 created_at: Utc::now(),
6499 };
6500 let tag3 = Tag {
6501 id: Uuid::new_v4(),
6502 session_id: session3.id,
6503 label: "feature".to_string(),
6504 created_at: Utc::now(),
6505 };
6506 db.insert_tag(&tag1).expect("insert tag");
6507 db.insert_tag(&tag3).expect("insert tag");
6508
6509 let sessions = db.list_sessions_with_tag("feature", 10).expect("list");
6510 assert_eq!(sessions.len(), 2);
6511 assert_eq!(sessions[0].id, session1.id);
6513 assert_eq!(sessions[1].id, session3.id);
6514
6515 let sessions = db.list_sessions_with_tag("nonexistent", 10).expect("list");
6516 assert!(sessions.is_empty());
6517 }
6518
6519 #[test]
6520 fn test_get_most_recent_session_for_directory() {
6521 let (db, _dir) = create_test_db();
6522 let now = Utc::now();
6523
6524 let session1 = create_test_session(
6525 "claude-code",
6526 "/home/user/project",
6527 now - Duration::hours(1),
6528 None,
6529 );
6530 let session2 = create_test_session("claude-code", "/home/user/project", now, None);
6531 let session3 = create_test_session("claude-code", "/home/user/other", now, None);
6532
6533 db.insert_session(&session1).expect("insert");
6534 db.insert_session(&session2).expect("insert");
6535 db.insert_session(&session3).expect("insert");
6536
6537 let result = db
6538 .get_most_recent_session_for_directory("/home/user/project")
6539 .expect("get");
6540 assert!(result.is_some());
6541 assert_eq!(result.unwrap().id, session2.id);
6542
6543 let result = db
6544 .get_most_recent_session_for_directory("/home/user/nonexistent")
6545 .expect("get");
6546 assert!(result.is_none());
6547 }
6548
6549 #[test]
6550 fn test_session_deletion_removes_annotations_and_tags() {
6551 let (db, _dir) = create_test_db();
6552 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6553 db.insert_session(&session).expect("insert session");
6554
6555 let annotation = Annotation {
6557 id: Uuid::new_v4(),
6558 session_id: session.id,
6559 content: "Test annotation".to_string(),
6560 created_at: Utc::now(),
6561 };
6562 db.insert_annotation(&annotation).expect("insert");
6563
6564 let tag = Tag {
6566 id: Uuid::new_v4(),
6567 session_id: session.id,
6568 label: "test-tag".to_string(),
6569 created_at: Utc::now(),
6570 };
6571 db.insert_tag(&tag).expect("insert");
6572
6573 db.delete_session(&session.id).expect("delete");
6575
6576 let annotations = db.get_annotations(&session.id).expect("get");
6578 assert!(annotations.is_empty());
6579
6580 let tags = db.get_tags(&session.id).expect("get");
6581 assert!(tags.is_empty());
6582 }
6583
6584 #[test]
6585 fn test_insert_and_get_summary() {
6586 let (db, _dir) = create_test_db();
6587 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
6588 db.insert_session(&session).expect("insert session");
6589
6590 let summary = Summary {
6591 id: Uuid::new_v4(),
6592 session_id: session.id,
6593 content: "Test summary content".to_string(),
6594 generated_at: Utc::now(),
6595 };
6596 db.insert_summary(&summary).expect("insert summary");
6597
6598 let retrieved = db.get_summary(&session.id).expect("get summary");
6599 assert!(retrieved.is_some());
6600 let retrieved = retrieved.unwrap();
6601 assert_eq!(retrieved.content, "Test summary content");
6602 assert_eq!(retrieved.session_id, session.id);
6603 }
6604
6605 #[test]
6606 fn test_get_summary_nonexistent() {
6607 let (db, _dir) = create_test_db();
6608 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
6609 db.insert_session(&session).expect("insert session");
6610
6611 let retrieved = db.get_summary(&session.id).expect("get summary");
6612 assert!(retrieved.is_none());
6613 }
6614
6615 #[test]
6616 fn test_update_summary() {
6617 let (db, _dir) = create_test_db();
6618 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
6619 db.insert_session(&session).expect("insert session");
6620
6621 let summary = Summary {
6622 id: Uuid::new_v4(),
6623 session_id: session.id,
6624 content: "Original content".to_string(),
6625 generated_at: Utc::now(),
6626 };
6627 db.insert_summary(&summary).expect("insert summary");
6628
6629 let updated = db
6631 .update_summary(&session.id, "Updated content")
6632 .expect("update summary");
6633 assert!(updated);
6634
6635 let retrieved = db.get_summary(&session.id).expect("get summary");
6636 assert!(retrieved.is_some());
6637 assert_eq!(retrieved.unwrap().content, "Updated content");
6638 }
6639
6640 #[test]
6641 fn test_update_summary_nonexistent() {
6642 let (db, _dir) = create_test_db();
6643 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
6644 db.insert_session(&session).expect("insert session");
6645
6646 let updated = db
6648 .update_summary(&session.id, "New content")
6649 .expect("update summary");
6650 assert!(!updated);
6651 }
6652
6653 #[test]
6654 fn test_delete_summary() {
6655 let (db, _dir) = create_test_db();
6656 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
6657 db.insert_session(&session).expect("insert session");
6658
6659 let summary = Summary {
6660 id: Uuid::new_v4(),
6661 session_id: session.id,
6662 content: "To be deleted".to_string(),
6663 generated_at: Utc::now(),
6664 };
6665 db.insert_summary(&summary).expect("insert summary");
6666
6667 let deleted = db.delete_summary(&session.id).expect("delete summary");
6669 assert!(deleted);
6670
6671 let retrieved = db.get_summary(&session.id).expect("get summary");
6673 assert!(retrieved.is_none());
6674 }
6675
6676 #[test]
6677 fn test_delete_session_removes_summary() {
6678 let (db, _dir) = create_test_db();
6679 let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
6680 db.insert_session(&session).expect("insert session");
6681
6682 let summary = Summary {
6683 id: Uuid::new_v4(),
6684 session_id: session.id,
6685 content: "Session summary".to_string(),
6686 generated_at: Utc::now(),
6687 };
6688 db.insert_summary(&summary).expect("insert summary");
6689
6690 db.delete_session(&session.id).expect("delete session");
6692
6693 let retrieved = db.get_summary(&session.id).expect("get summary");
6695 assert!(retrieved.is_none());
6696 }
6697
6698 #[test]
6701 fn test_upsert_machine_insert() {
6702 let (db, _dir) = create_test_db();
6703
6704 let machine = Machine {
6705 id: "test-uuid-1234".to_string(),
6706 name: "my-laptop".to_string(),
6707 created_at: Utc::now().to_rfc3339(),
6708 };
6709
6710 db.upsert_machine(&machine)
6711 .expect("Failed to upsert machine");
6712
6713 let retrieved = db
6714 .get_machine("test-uuid-1234")
6715 .expect("Failed to get machine")
6716 .expect("Machine should exist");
6717
6718 assert_eq!(retrieved.id, "test-uuid-1234");
6719 assert_eq!(retrieved.name, "my-laptop");
6720 }
6721
6722 #[test]
6723 fn test_upsert_machine_update() {
6724 let (db, _dir) = create_test_db();
6725
6726 let machine1 = Machine {
6728 id: "test-uuid-5678".to_string(),
6729 name: "old-name".to_string(),
6730 created_at: Utc::now().to_rfc3339(),
6731 };
6732 db.upsert_machine(&machine1)
6733 .expect("Failed to upsert machine");
6734
6735 let machine2 = Machine {
6737 id: "test-uuid-5678".to_string(),
6738 name: "new-name".to_string(),
6739 created_at: Utc::now().to_rfc3339(),
6740 };
6741 db.upsert_machine(&machine2)
6742 .expect("Failed to upsert machine");
6743
6744 let retrieved = db
6746 .get_machine("test-uuid-5678")
6747 .expect("Failed to get machine")
6748 .expect("Machine should exist");
6749
6750 assert_eq!(retrieved.name, "new-name");
6751 }
6752
6753 #[test]
6754 fn test_get_machine() {
6755 let (db, _dir) = create_test_db();
6756
6757 let not_found = db.get_machine("nonexistent-uuid").expect("Failed to query");
6759 assert!(not_found.is_none(), "Machine should not exist");
6760
6761 let machine = Machine {
6763 id: "existing-uuid".to_string(),
6764 name: "test-machine".to_string(),
6765 created_at: Utc::now().to_rfc3339(),
6766 };
6767 db.upsert_machine(&machine).expect("Failed to upsert");
6768
6769 let found = db
6771 .get_machine("existing-uuid")
6772 .expect("Failed to query")
6773 .expect("Machine should exist");
6774
6775 assert_eq!(found.id, "existing-uuid");
6776 assert_eq!(found.name, "test-machine");
6777 }
6778
6779 #[test]
6780 fn test_get_machine_name_found() {
6781 let (db, _dir) = create_test_db();
6782
6783 let machine = Machine {
6784 id: "uuid-for-name-test".to_string(),
6785 name: "my-workstation".to_string(),
6786 created_at: Utc::now().to_rfc3339(),
6787 };
6788 db.upsert_machine(&machine).expect("Failed to upsert");
6789
6790 let name = db
6791 .get_machine_name("uuid-for-name-test")
6792 .expect("Failed to get name");
6793
6794 assert_eq!(name, "my-workstation");
6795 }
6796
6797 #[test]
6798 fn test_get_machine_name_not_found() {
6799 let (db, _dir) = create_test_db();
6800
6801 let name = db
6803 .get_machine_name("abc123def456789")
6804 .expect("Failed to get name");
6805
6806 assert_eq!(name, "abc123de", "Should return first 8 characters");
6807
6808 let short_name = db.get_machine_name("short").expect("Failed to get name");
6810
6811 assert_eq!(
6812 short_name, "short",
6813 "Should return full ID if shorter than 8 chars"
6814 );
6815 }
6816
6817 #[test]
6818 fn test_list_machines() {
6819 let (db, _dir) = create_test_db();
6820
6821 let machines = db.list_machines().expect("Failed to list");
6823 assert!(machines.is_empty(), "Should have no machines initially");
6824
6825 let machine1 = Machine {
6827 id: "uuid-1".to_string(),
6828 name: "machine-1".to_string(),
6829 created_at: "2024-01-01T00:00:00Z".to_string(),
6830 };
6831 let machine2 = Machine {
6832 id: "uuid-2".to_string(),
6833 name: "machine-2".to_string(),
6834 created_at: "2024-01-02T00:00:00Z".to_string(),
6835 };
6836
6837 db.upsert_machine(&machine1).expect("Failed to upsert");
6838 db.upsert_machine(&machine2).expect("Failed to upsert");
6839
6840 let machines = db.list_machines().expect("Failed to list");
6842 assert_eq!(machines.len(), 2, "Should have 2 machines");
6843
6844 assert_eq!(machines[0].id, "uuid-1");
6846 assert_eq!(machines[1].id, "uuid-2");
6847 }
6848
6849 #[test]
6852 fn test_find_session_by_id_prefix_full_uuid() {
6853 let (db, _dir) = create_test_db();
6854 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6855 db.insert_session(&session).expect("insert session");
6856
6857 let found = db
6859 .find_session_by_id_prefix(&session.id.to_string())
6860 .expect("find session")
6861 .expect("session should exist");
6862
6863 assert_eq!(found.id, session.id, "Should find session by full UUID");
6864 }
6865
6866 #[test]
6867 fn test_find_session_by_id_prefix_short_prefix() {
6868 let (db, _dir) = create_test_db();
6869 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6870 db.insert_session(&session).expect("insert session");
6871
6872 let prefix = &session.id.to_string()[..8];
6874
6875 let found = db
6876 .find_session_by_id_prefix(prefix)
6877 .expect("find session")
6878 .expect("session should exist");
6879
6880 assert_eq!(found.id, session.id, "Should find session by short prefix");
6881 }
6882
6883 #[test]
6884 fn test_find_session_by_id_prefix_very_short_prefix() {
6885 let (db, _dir) = create_test_db();
6886 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6887 db.insert_session(&session).expect("insert session");
6888
6889 let prefix = &session.id.to_string()[..4];
6891
6892 let found = db
6893 .find_session_by_id_prefix(prefix)
6894 .expect("find session")
6895 .expect("session should exist");
6896
6897 assert_eq!(
6898 found.id, session.id,
6899 "Should find session by very short prefix"
6900 );
6901 }
6902
6903 #[test]
6904 fn test_find_session_by_id_prefix_not_found() {
6905 let (db, _dir) = create_test_db();
6906 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6907 db.insert_session(&session).expect("insert session");
6908
6909 let found = db
6911 .find_session_by_id_prefix("zzz999")
6912 .expect("find session");
6913
6914 assert!(
6915 found.is_none(),
6916 "Should return None for non-matching prefix"
6917 );
6918 }
6919
6920 #[test]
6921 fn test_find_session_by_id_prefix_empty_db() {
6922 let (db, _dir) = create_test_db();
6923
6924 let found = db
6925 .find_session_by_id_prefix("abc123")
6926 .expect("find session");
6927
6928 assert!(found.is_none(), "Should return None for empty database");
6929 }
6930
6931 #[test]
6932 fn test_find_session_by_id_prefix_ambiguous() {
6933 let (db, _dir) = create_test_db();
6934
6935 let mut sessions = Vec::new();
6937 for _ in 0..100 {
6938 let session = create_test_session("claude-code", "/project", Utc::now(), None);
6939 db.insert_session(&session).expect("insert session");
6940 sessions.push(session);
6941 }
6942
6943 let first_session = &sessions[0];
6945 let first_char = first_session.id.to_string().chars().next().unwrap();
6946
6947 let matching_count = sessions
6949 .iter()
6950 .filter(|s| s.id.to_string().starts_with(first_char))
6951 .count();
6952
6953 if matching_count > 1 {
6954 let result = db.find_session_by_id_prefix(&first_char.to_string());
6957 assert!(
6958 result.is_err(),
6959 "Should return error for ambiguous single-character prefix"
6960 );
6961 let error_msg = result.unwrap_err().to_string();
6962 assert!(
6963 error_msg.contains("Ambiguous"),
6964 "Error should mention ambiguity"
6965 );
6966 }
6967 }
6968
6969 #[test]
6970 fn test_find_session_by_id_prefix_returns_correct_session_data() {
6971 let (db, _dir) = create_test_db();
6972
6973 let mut session =
6974 create_test_session("claude-code", "/home/user/myproject", Utc::now(), None);
6975 session.tool_version = Some("2.0.0".to_string());
6976 session.model = Some("claude-opus-4".to_string());
6977 session.git_branch = Some("feature/test".to_string());
6978 session.message_count = 42;
6979 db.insert_session(&session).expect("insert session");
6980
6981 let prefix = &session.id.to_string()[..8];
6983 let found = db
6984 .find_session_by_id_prefix(prefix)
6985 .expect("find session")
6986 .expect("session should exist");
6987
6988 assert_eq!(found.id, session.id);
6990 assert_eq!(found.tool, "claude-code");
6991 assert_eq!(found.tool_version, Some("2.0.0".to_string()));
6992 assert_eq!(found.model, Some("claude-opus-4".to_string()));
6993 assert_eq!(found.working_directory, "/home/user/myproject");
6994 assert_eq!(found.git_branch, Some("feature/test".to_string()));
6995 assert_eq!(found.message_count, 42);
6996 }
6997
6998 #[test]
6999 fn test_find_session_by_id_prefix_many_sessions() {
7000 let (db, _dir) = create_test_db();
7001
7002 let mut target_session = None;
7004 for i in 0..200 {
7005 let session =
7006 create_test_session("claude-code", &format!("/project/{i}"), Utc::now(), None);
7007 db.insert_session(&session).expect("insert session");
7008 if i == 150 {
7010 target_session = Some(session);
7011 }
7012 }
7013
7014 let target = target_session.expect("should have target session");
7015 let prefix = &target.id.to_string()[..8];
7016
7017 let found = db
7019 .find_session_by_id_prefix(prefix)
7020 .expect("find session")
7021 .expect("session should exist");
7022
7023 assert_eq!(
7024 found.id, target.id,
7025 "Should find correct session among many"
7026 );
7027 assert_eq!(found.working_directory, "/project/150");
7028 }
7029
7030 #[test]
7031 fn test_import_session_with_messages() {
7032 let (mut db, _dir) = create_test_db();
7033
7034 let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
7035 let messages = vec![
7036 create_test_message(session.id, 0, MessageRole::User, "Hello"),
7037 create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!"),
7038 create_test_message(session.id, 2, MessageRole::User, "How are you?"),
7039 ];
7040
7041 let synced_at = Utc::now();
7042 db.import_session_with_messages(&session, &messages, Some(synced_at))
7043 .expect("Failed to import session with messages");
7044
7045 let retrieved_session = db.get_session(&session.id).expect("Failed to get session");
7047 assert!(retrieved_session.is_some(), "Session should exist");
7048 let retrieved_session = retrieved_session.unwrap();
7049 assert_eq!(retrieved_session.tool, "claude-code");
7050
7051 let retrieved_messages = db
7053 .get_messages(&session.id)
7054 .expect("Failed to get messages");
7055 assert_eq!(retrieved_messages.len(), 3, "Should have 3 messages");
7056 assert_eq!(retrieved_messages[0].content.text(), "Hello");
7057 assert_eq!(retrieved_messages[1].content.text(), "Hi there!");
7058 assert_eq!(retrieved_messages[2].content.text(), "How are you?");
7059
7060 let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
7062 assert!(
7063 !unsynced.iter().any(|s| s.id == session.id),
7064 "Session should be marked as synced"
7065 );
7066 }
7067
7068 #[test]
7069 fn test_import_session_with_messages_no_sync() {
7070 let (mut db, _dir) = create_test_db();
7071
7072 let session = create_test_session("aider", "/tmp/test", Utc::now(), None);
7073 let messages = vec![create_test_message(
7074 session.id,
7075 0,
7076 MessageRole::User,
7077 "Test message",
7078 )];
7079
7080 db.import_session_with_messages(&session, &messages, None)
7082 .expect("Failed to import session");
7083
7084 let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
7086 assert!(
7087 unsynced.iter().any(|s| s.id == session.id),
7088 "Session should NOT be marked as synced"
7089 );
7090 }
7091
7092 #[test]
7093 fn test_session_update_resets_sync_status() {
7094 let (db, _dir) = create_test_db();
7095
7096 let mut session =
7098 create_test_session("claude-code", "/home/user/project", Utc::now(), None);
7099 session.message_count = 5;
7100 db.insert_session(&session)
7101 .expect("Failed to insert session");
7102
7103 db.mark_sessions_synced(&[session.id], Utc::now())
7105 .expect("Failed to mark synced");
7106
7107 let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
7109 assert!(
7110 !unsynced.iter().any(|s| s.id == session.id),
7111 "Session should be synced initially"
7112 );
7113
7114 session.message_count = 10;
7116 session.ended_at = Some(Utc::now());
7117 db.insert_session(&session)
7118 .expect("Failed to update session");
7119
7120 let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
7122 assert!(
7123 unsynced.iter().any(|s| s.id == session.id),
7124 "Session should be marked for re-sync after update"
7125 );
7126
7127 let retrieved = db
7129 .get_session(&session.id)
7130 .expect("Failed to get session")
7131 .expect("Session should exist");
7132 assert_eq!(
7133 retrieved.message_count, 10,
7134 "Message count should be updated"
7135 );
7136 }
7137
7138 #[test]
7141 fn test_sessions_in_date_range_all() {
7142 let (db, _dir) = create_test_db();
7143 let now = Utc::now();
7144
7145 let s1 = create_test_session("claude-code", "/project/a", now - Duration::hours(3), None);
7146 let s2 = create_test_session("aider", "/project/b", now - Duration::hours(1), None);
7147
7148 db.insert_session(&s1).expect("Failed to insert session");
7149 db.insert_session(&s2).expect("Failed to insert session");
7150
7151 let results = db
7152 .sessions_in_date_range(None, None, None)
7153 .expect("Failed to query sessions");
7154 assert_eq!(
7155 results.len(),
7156 2,
7157 "Should return all sessions when no filters"
7158 );
7159 }
7160
7161 #[test]
7162 fn test_sessions_in_date_range_with_since() {
7163 let (db, _dir) = create_test_db();
7164 let now = Utc::now();
7165
7166 let s1 = create_test_session("claude-code", "/project/a", now - Duration::hours(5), None);
7167 let s2 = create_test_session("aider", "/project/b", now - Duration::hours(1), None);
7168
7169 db.insert_session(&s1).expect("Failed to insert session");
7170 db.insert_session(&s2).expect("Failed to insert session");
7171
7172 let since = now - Duration::hours(3);
7173 let results = db
7174 .sessions_in_date_range(Some(since), None, None)
7175 .expect("Failed to query sessions");
7176 assert_eq!(results.len(), 1, "Should return only sessions after since");
7177 assert_eq!(results[0].id, s2.id, "Should return the newer session");
7178 }
7179
7180 #[test]
7181 fn test_sessions_in_date_range_with_working_dir() {
7182 let (db, _dir) = create_test_db();
7183 let now = Utc::now();
7184
7185 let s1 = create_test_session(
7186 "claude-code",
7187 "/project/alpha",
7188 now - Duration::hours(2),
7189 None,
7190 );
7191 let s2 = create_test_session("aider", "/project/beta", now - Duration::hours(1), None);
7192
7193 db.insert_session(&s1).expect("Failed to insert session");
7194 db.insert_session(&s2).expect("Failed to insert session");
7195
7196 let results = db
7197 .sessions_in_date_range(None, None, Some("/project/alpha"))
7198 .expect("Failed to query sessions");
7199 assert_eq!(results.len(), 1, "Should return only matching working dir");
7200 assert_eq!(results[0].id, s1.id, "Should return the alpha session");
7201 }
7202
7203 #[test]
7204 fn test_sessions_in_date_range_with_until() {
7205 let (db, _dir) = create_test_db();
7206 let now = Utc::now();
7207
7208 let s1 = create_test_session("claude-code", "/project/a", now - Duration::hours(5), None);
7209 let s2 = create_test_session("aider", "/project/b", now - Duration::hours(1), None);
7210
7211 db.insert_session(&s1).expect("Failed to insert session");
7212 db.insert_session(&s2).expect("Failed to insert session");
7213
7214 let until = now - Duration::hours(3);
7215 let results = db
7216 .sessions_in_date_range(None, Some(until), None)
7217 .expect("Failed to query sessions");
7218 assert_eq!(results.len(), 1, "Should return only sessions before until");
7219 assert_eq!(results[0].id, s1.id, "Should return the older session");
7220 }
7221
7222 #[test]
7223 fn test_sessions_in_date_range_with_since_and_until() {
7224 let (db, _dir) = create_test_db();
7225 let now = Utc::now();
7226
7227 let s1 = create_test_session("claude-code", "/project", now - Duration::hours(8), None);
7228 let s2 = create_test_session("aider", "/project", now - Duration::hours(4), None);
7229 let s3 = create_test_session("claude-code", "/project", now - Duration::hours(1), None);
7230
7231 db.insert_session(&s1).expect("Failed to insert session");
7232 db.insert_session(&s2).expect("Failed to insert session");
7233 db.insert_session(&s3).expect("Failed to insert session");
7234
7235 let since = now - Duration::hours(6);
7236 let until = now - Duration::hours(2);
7237 let results = db
7238 .sessions_in_date_range(Some(since), Some(until), None)
7239 .expect("Failed to query sessions");
7240 assert_eq!(
7241 results.len(),
7242 1,
7243 "Should return only sessions in the window"
7244 );
7245 assert_eq!(results[0].id, s2.id, "Should return the middle session");
7246 }
7247
7248 #[test]
7249 fn test_average_session_duration() {
7250 let (db, _dir) = create_test_db();
7251 let now = Utc::now();
7252
7253 let mut s1 = create_test_session("claude-code", "/project", now - Duration::hours(2), None);
7254 s1.ended_at = Some(s1.started_at + Duration::minutes(30));
7255
7256 let mut s2 = create_test_session("aider", "/project", now - Duration::hours(1), None);
7257 s2.ended_at = Some(s2.started_at + Duration::minutes(60));
7258
7259 db.insert_session(&s1).expect("Failed to insert session");
7260 db.insert_session(&s2).expect("Failed to insert session");
7261
7262 let avg = db
7263 .average_session_duration_minutes(None, None)
7264 .expect("Failed to get average duration");
7265 assert!(avg.is_some(), "Should return an average");
7266 let avg_val = avg.unwrap();
7267 assert!(
7269 (avg_val - 45.0).abs() < 1.0,
7270 "Average should be approximately 45 minutes, got {}",
7271 avg_val
7272 );
7273 }
7274
7275 #[test]
7276 fn test_average_session_duration_no_ended_sessions() {
7277 let (db, _dir) = create_test_db();
7278 let now = Utc::now();
7279
7280 let s1 = create_test_session("claude-code", "/project", now, None);
7282 db.insert_session(&s1).expect("Failed to insert session");
7283
7284 let avg = db
7285 .average_session_duration_minutes(None, None)
7286 .expect("Failed to get average duration");
7287 assert!(
7288 avg.is_none(),
7289 "Should return None when no sessions have ended_at"
7290 );
7291 }
7292
7293 #[test]
7294 fn test_sessions_by_tool_in_range() {
7295 let (db, _dir) = create_test_db();
7296 let now = Utc::now();
7297
7298 let s1 = create_test_session("claude-code", "/project", now - Duration::hours(3), None);
7299 let s2 = create_test_session("claude-code", "/project", now - Duration::hours(2), None);
7300 let s3 = create_test_session("aider", "/project", now - Duration::hours(1), None);
7301
7302 db.insert_session(&s1).expect("Failed to insert session");
7303 db.insert_session(&s2).expect("Failed to insert session");
7304 db.insert_session(&s3).expect("Failed to insert session");
7305
7306 let results = db
7307 .sessions_by_tool_in_range(None, None)
7308 .expect("Failed to get sessions by tool");
7309 assert_eq!(results.len(), 2, "Should have two tools");
7310 assert_eq!(results[0].0, "claude-code");
7312 assert_eq!(results[0].1, 2);
7313 assert_eq!(results[1].0, "aider");
7314 assert_eq!(results[1].1, 1);
7315 }
7316
7317 #[test]
7318 fn test_sessions_by_weekday() {
7319 let (db, _dir) = create_test_db();
7320 let monday = chrono::NaiveDate::from_ymd_opt(2024, 1, 15)
7322 .unwrap()
7323 .and_hms_opt(12, 0, 0)
7324 .unwrap()
7325 .and_utc();
7326
7327 let s1 = create_test_session("claude-code", "/project", monday, None);
7328 let s2 = create_test_session("aider", "/project", monday + Duration::hours(1), None);
7329
7330 db.insert_session(&s1).expect("Failed to insert session");
7331 db.insert_session(&s2).expect("Failed to insert session");
7332
7333 let results = db
7334 .sessions_by_weekday(None, None)
7335 .expect("Failed to get sessions by weekday");
7336 assert_eq!(results.len(), 1, "Should have one weekday entry");
7337 assert_eq!(results[0].0, 1, "Monday is weekday 1");
7338 assert_eq!(results[0].1, 2, "Should have 2 sessions on Monday");
7339 }
7340
7341 #[test]
7342 fn test_average_message_count() {
7343 let (db, _dir) = create_test_db();
7344 let now = Utc::now();
7345
7346 let mut s1 = create_test_session("claude-code", "/project", now - Duration::hours(2), None);
7347 s1.message_count = 10;
7348
7349 let mut s2 = create_test_session("aider", "/project", now - Duration::hours(1), None);
7350 s2.message_count = 20;
7351
7352 db.insert_session(&s1).expect("Failed to insert session");
7353 db.insert_session(&s2).expect("Failed to insert session");
7354
7355 let avg = db
7356 .average_message_count(None, None)
7357 .expect("Failed to get average message count");
7358 assert!(avg.is_some(), "Should return an average");
7359 let avg_val = avg.unwrap();
7360 assert!(
7362 (avg_val - 15.0).abs() < 0.01,
7363 "Average should be 15.0, got {}",
7364 avg_val
7365 );
7366 }
7367
7368 use crate::storage::models::{Annotation, Summary, Tag};
7371
7372 fn seed_synced_session(db: &mut Database, message_count: i32) -> Session {
7374 let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7375 session.message_count = message_count;
7376 db.import_session_with_messages(&session, &[], Some(Utc::now()))
7377 .unwrap();
7378 session
7379 }
7380
7381 #[test]
7382 fn test_merge_link_is_idempotent() {
7383 let (mut db, _dir) = create_test_db();
7384 let session = seed_synced_session(&mut db, 1);
7385
7386 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
7387
7388 let links_in = std::slice::from_ref(&link);
7390 db.merge_remote_record(&session, &[], links_in, &[], &[], None, Utc::now())
7391 .unwrap();
7392 db.merge_remote_record(&session, &[], links_in, &[], &[], None, Utc::now())
7393 .unwrap();
7394
7395 let links = db.get_links_by_session(&session.id).unwrap();
7396 assert_eq!(
7397 links.len(),
7398 1,
7399 "Re-merging the same link must not duplicate"
7400 );
7401 assert_eq!(links[0].id, link.id);
7402 }
7403
7404 #[test]
7405 fn test_merge_tag_idempotent_by_id_and_label() {
7406 let (mut db, _dir) = create_test_db();
7407 let session = seed_synced_session(&mut db, 1);
7408
7409 let tag = Tag {
7410 id: Uuid::new_v4(),
7411 session_id: session.id,
7412 label: "bug-fix".to_string(),
7413 created_at: Utc::now(),
7414 };
7415 let tag_same_label = Tag {
7417 id: Uuid::new_v4(),
7418 ..tag.clone()
7419 };
7420
7421 let tags_in = std::slice::from_ref(&tag);
7422 db.merge_remote_record(&session, &[], &[], tags_in, &[], None, Utc::now())
7423 .unwrap();
7424 db.merge_remote_record(&session, &[], &[], tags_in, &[], None, Utc::now())
7425 .unwrap();
7426 db.merge_remote_record(
7427 &session,
7428 &[],
7429 &[],
7430 std::slice::from_ref(&tag_same_label),
7431 &[],
7432 None,
7433 Utc::now(),
7434 )
7435 .unwrap();
7436
7437 let tags = db.get_tags(&session.id).unwrap();
7438 assert_eq!(
7439 tags.len(),
7440 1,
7441 "Duplicate tag label must not be inserted twice"
7442 );
7443 assert_eq!(tags[0].label, "bug-fix");
7444 }
7445
7446 #[test]
7447 fn test_merge_annotation_is_idempotent() {
7448 let (mut db, _dir) = create_test_db();
7449 let session = seed_synced_session(&mut db, 1);
7450
7451 let annotation = Annotation {
7452 id: Uuid::new_v4(),
7453 session_id: session.id,
7454 content: "important".to_string(),
7455 created_at: Utc::now(),
7456 };
7457
7458 let annotations_in = std::slice::from_ref(&annotation);
7459 db.merge_remote_record(&session, &[], &[], &[], annotations_in, None, Utc::now())
7460 .unwrap();
7461 db.merge_remote_record(&session, &[], &[], &[], annotations_in, None, Utc::now())
7462 .unwrap();
7463
7464 let annotations = db.get_annotations(&session.id).unwrap();
7465 assert_eq!(annotations.len(), 1);
7466 assert_eq!(annotations[0].content, "important");
7467 }
7468
7469 #[test]
7470 fn test_merge_summary_newer_wins() {
7471 let (mut db, _dir) = create_test_db();
7472 let session = seed_synced_session(&mut db, 1);
7473
7474 let base = Utc::now();
7475 let summary = Summary {
7476 id: Uuid::new_v4(),
7477 session_id: session.id,
7478 content: "first".to_string(),
7479 generated_at: base,
7480 };
7481 db.merge_remote_record(&session, &[], &[], &[], &[], Some(&summary), Utc::now())
7482 .unwrap();
7483
7484 let updated = Summary {
7486 id: Uuid::new_v4(),
7487 session_id: session.id,
7488 content: "second".to_string(),
7489 generated_at: base + Duration::seconds(1),
7490 };
7491 db.merge_remote_record(&session, &[], &[], &[], &[], Some(&updated), Utc::now())
7492 .unwrap();
7493
7494 let stored = db
7495 .get_summary(&session.id)
7496 .unwrap()
7497 .expect("summary exists");
7498 assert_eq!(stored.content, "second", "Newer content should win");
7499 }
7500
7501 #[test]
7502 fn test_merge_summary_older_does_not_overwrite_newer() {
7503 let (mut db, _dir) = create_test_db();
7504 let session = seed_synced_session(&mut db, 1);
7505
7506 let base = Utc::now();
7507 let newer = Summary {
7508 id: Uuid::new_v4(),
7509 session_id: session.id,
7510 content: "newer local".to_string(),
7511 generated_at: base,
7512 };
7513 db.merge_remote_record(&session, &[], &[], &[], &[], Some(&newer), Utc::now())
7514 .unwrap();
7515
7516 let older = Summary {
7518 id: Uuid::new_v4(),
7519 session_id: session.id,
7520 content: "older remote".to_string(),
7521 generated_at: base - Duration::seconds(60),
7522 };
7523 db.merge_remote_record(&session, &[], &[], &[], &[], Some(&older), Utc::now())
7524 .unwrap();
7525
7526 let stored = db
7527 .get_summary(&session.id)
7528 .unwrap()
7529 .expect("summary exists");
7530 assert_eq!(
7531 stored.content, "newer local",
7532 "older summary must not overwrite a newer one"
7533 );
7534 }
7535
7536 #[test]
7537 fn test_insert_link_clears_parent_synced_at() {
7538 let (mut db, _dir) = create_test_db();
7539 let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7540 session.message_count = 0;
7541
7542 db.import_session_with_messages(&session, &[], Some(Utc::now()))
7544 .unwrap();
7545 assert_eq!(
7546 db.get_unsynced_sessions().unwrap().len(),
7547 0,
7548 "session should start synced"
7549 );
7550
7551 db.insert_link(&SessionLink {
7552 id: Uuid::new_v4(),
7553 session_id: session.id,
7554 link_type: LinkType::Commit,
7555 commit_sha: Some("abcdef".to_string()),
7556 branch: None,
7557 remote: None,
7558 created_at: Utc::now(),
7559 created_by: LinkCreator::User,
7560 confidence: None,
7561 })
7562 .unwrap();
7563
7564 let unsynced = db.get_unsynced_sessions().unwrap();
7565 assert_eq!(
7566 unsynced.len(),
7567 1,
7568 "adding a link must re-open the parent session for sync"
7569 );
7570 assert_eq!(unsynced[0].id, session.id);
7571 }
7572
7573 #[test]
7574 fn test_merge_remote_record_imports_child_on_equal_session() {
7575 let (mut db, _dir) = create_test_db();
7576 let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7577 session.message_count = 1;
7578
7579 db.import_session_with_messages(&session, &[], Some(Utc::now()))
7581 .unwrap();
7582
7583 let link = SessionLink {
7585 id: Uuid::new_v4(),
7586 session_id: session.id,
7587 link_type: LinkType::Commit,
7588 commit_sha: Some("cafe".to_string()),
7589 branch: None,
7590 remote: None,
7591 created_at: Utc::now(),
7592 created_by: LinkCreator::Auto,
7593 confidence: None,
7594 };
7595 let imported = db
7596 .merge_remote_record(&session, &[], &[link], &[], &[], None, Utc::now())
7597 .unwrap();
7598
7599 assert!(
7600 !imported,
7601 "an equal session row must not count as a pulled session"
7602 );
7603 let links = db.get_links_by_session(&session.id).unwrap();
7604 assert_eq!(
7605 links.len(),
7606 1,
7607 "the remote-only link must still be merged in"
7608 );
7609 assert_eq!(links[0].commit_sha, Some("cafe".to_string()));
7610 }
7611
7612 #[test]
7613 fn test_merge_remote_record_persists_full_record() {
7614 let (mut db, _dir) = create_test_db();
7617 let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7618 session.message_count = 1;
7619
7620 let message = Message {
7621 id: Uuid::new_v4(),
7622 session_id: session.id,
7623 parent_id: None,
7624 index: 0,
7625 timestamp: Utc::now(),
7626 role: MessageRole::User,
7627 content: MessageContent::Text("hello".to_string()),
7628 model: None,
7629 git_branch: None,
7630 cwd: None,
7631 };
7632 let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
7633 let tag = Tag {
7634 id: Uuid::new_v4(),
7635 session_id: session.id,
7636 label: "feature".to_string(),
7637 created_at: Utc::now(),
7638 };
7639 let annotation = Annotation {
7640 id: Uuid::new_v4(),
7641 session_id: session.id,
7642 content: "note".to_string(),
7643 created_at: Utc::now(),
7644 };
7645 let summary = Summary {
7646 id: Uuid::new_v4(),
7647 session_id: session.id,
7648 content: "did the thing".to_string(),
7649 generated_at: Utc::now(),
7650 };
7651
7652 let imported = db
7653 .merge_remote_record(
7654 &session,
7655 &[message],
7656 &[link],
7657 &[tag],
7658 &[annotation],
7659 Some(&summary),
7660 Utc::now(),
7661 )
7662 .unwrap();
7663
7664 assert!(imported, "a new session must count as pulled");
7665 assert!(db.get_session(&session.id).unwrap().is_some());
7666 assert_eq!(db.get_messages(&session.id).unwrap().len(), 1);
7667 assert_eq!(db.get_links_by_session(&session.id).unwrap().len(), 1);
7668 assert_eq!(db.get_tags(&session.id).unwrap().len(), 1);
7669 assert_eq!(db.get_annotations(&session.id).unwrap().len(), 1);
7670 assert_eq!(
7671 db.get_summary(&session.id).unwrap().unwrap().content,
7672 "did the thing"
7673 );
7674 assert!(
7676 db.get_unsynced_sessions().unwrap().is_empty(),
7677 "a merged session must be marked synced"
7678 );
7679 }
7680
7681 #[test]
7684 fn test_new_session_is_unsynced_on_both_tracks() {
7685 let (mut db, _dir) = create_test_db();
7688 let session = create_test_session("claude-code", "/project", Utc::now(), None);
7689 db.import_session_with_messages(&session, &[], None)
7690 .unwrap();
7691
7692 assert_eq!(db.get_unsynced_sessions().unwrap().len(), 1);
7693 let global = db.get_unsynced_global_sessions().unwrap();
7694 assert_eq!(global.len(), 1);
7695 assert_eq!(global[0].id, session.id);
7696 assert_eq!(db.unsynced_global_count().unwrap(), 1);
7697 }
7698
7699 #[test]
7700 fn test_per_repo_and_global_tracks_are_independent() {
7701 let (mut db, _dir) = create_test_db();
7705 let session = create_test_session("claude-code", "/project", Utc::now(), None);
7706 db.import_session_with_messages(&session, &[], None)
7707 .unwrap();
7708
7709 db.mark_sessions_synced(&[session.id], Utc::now()).unwrap();
7711 assert!(
7712 db.get_unsynced_sessions().unwrap().is_empty(),
7713 "per-repo track must be marked synced"
7714 );
7715 assert_eq!(
7716 db.get_unsynced_global_sessions().unwrap().len(),
7717 1,
7718 "global track must stay pending after a per-repo sync"
7719 );
7720
7721 db.mark_global_synced(&[session.id], Utc::now()).unwrap();
7723 assert!(
7724 db.get_unsynced_global_sessions().unwrap().is_empty(),
7725 "global track must be marked synced"
7726 );
7727 }
7728
7729 #[test]
7730 fn test_global_only_sync_leaves_per_repo_pending() {
7731 let (mut db, _dir) = create_test_db();
7734 let session = create_test_session("claude-code", "/project", Utc::now(), None);
7735 db.import_session_with_messages(&session, &[], None)
7736 .unwrap();
7737
7738 db.mark_global_synced(&[session.id], Utc::now()).unwrap();
7739 assert!(
7740 db.get_unsynced_global_sessions().unwrap().is_empty(),
7741 "global track must be marked synced"
7742 );
7743 assert_eq!(
7744 db.get_unsynced_sessions().unwrap().len(),
7745 1,
7746 "per-repo track must stay pending after a global sync"
7747 );
7748 }
7749
7750 #[test]
7751 fn test_insert_link_clears_both_sync_tracks() {
7752 let (mut db, _dir) = create_test_db();
7755 let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7756 session.message_count = 0;
7757 db.import_session_with_messages(&session, &[], Some(Utc::now()))
7758 .unwrap();
7759 db.mark_global_synced(&[session.id], Utc::now()).unwrap();
7760 assert!(db.get_unsynced_sessions().unwrap().is_empty());
7761 assert!(db.get_unsynced_global_sessions().unwrap().is_empty());
7762
7763 db.insert_link(&create_test_link(
7764 session.id,
7765 Some("abcdef"),
7766 LinkType::Commit,
7767 ))
7768 .unwrap();
7769
7770 assert_eq!(
7771 db.get_unsynced_sessions().unwrap().len(),
7772 1,
7773 "adding a link must re-open the per-repo track"
7774 );
7775 assert_eq!(
7776 db.get_unsynced_global_sessions().unwrap().len(),
7777 1,
7778 "adding a link must re-open the global track"
7779 );
7780 }
7781
7782 #[test]
7783 fn test_insert_session_content_change_clears_both_tracks() {
7784 let (mut db, _dir) = create_test_db();
7787 let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7788 session.message_count = 1;
7789 db.import_session_with_messages(&session, &[], Some(Utc::now()))
7790 .unwrap();
7791 db.mark_global_synced(&[session.id], Utc::now()).unwrap();
7792 assert!(db.get_unsynced_sessions().unwrap().is_empty());
7793 assert!(db.get_unsynced_global_sessions().unwrap().is_empty());
7794
7795 session.message_count = 2;
7797 db.insert_session(&session).unwrap();
7798
7799 assert_eq!(
7800 db.get_unsynced_sessions().unwrap().len(),
7801 1,
7802 "a content change must re-open the per-repo track"
7803 );
7804 assert_eq!(
7805 db.get_unsynced_global_sessions().unwrap().len(),
7806 1,
7807 "a content change must re-open the global track"
7808 );
7809 }
7810
7811 #[test]
7812 fn test_merge_remote_record_global_marks_only_global_track() {
7813 let (mut db, _dir) = create_test_db();
7816 let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7817 session.message_count = 1;
7818
7819 let imported = db
7820 .merge_remote_record_global(&session, &[], &[], &[], &[], None, Utc::now())
7821 .unwrap();
7822 assert!(imported, "a new session must count as pulled");
7823
7824 assert!(
7825 db.get_unsynced_global_sessions().unwrap().is_empty(),
7826 "global merge must mark the global track synced"
7827 );
7828 assert_eq!(
7829 db.get_unsynced_sessions().unwrap().len(),
7830 1,
7831 "global merge must leave the per-repo track pending"
7832 );
7833 }
7834
7835 #[test]
7836 fn test_get_all_session_ids_returns_every_session_regardless_of_sync() {
7837 let (mut db, _dir) = create_test_db();
7838 let a = create_test_session("claude-code", "/a", Utc::now(), None);
7839 let b = create_test_session("claude-code", "/b", Utc::now(), None);
7840 db.import_session_with_messages(&a, &[], None).unwrap();
7841 db.import_session_with_messages(&b, &[], Some(Utc::now()))
7842 .unwrap();
7843 db.mark_global_synced(&[b.id], Utc::now()).unwrap();
7844
7845 let ids = db.get_all_session_ids().unwrap();
7846 assert!(ids.contains(&a.id));
7847 assert!(ids.contains(&b.id));
7848 assert_eq!(ids.len(), 2);
7849 }
7850
7851 #[test]
7852 fn test_last_global_sync_time_tracks_global_column() {
7853 let (mut db, _dir) = create_test_db();
7854 let session = create_test_session("claude-code", "/project", Utc::now(), None);
7855 db.import_session_with_messages(&session, &[], None)
7856 .unwrap();
7857
7858 assert!(
7859 db.last_global_sync_time().unwrap().is_none(),
7860 "no global sync yet"
7861 );
7862
7863 let when = Utc::now();
7864 db.mark_global_synced(&[session.id], when).unwrap();
7865 let last = db
7866 .last_global_sync_time()
7867 .unwrap()
7868 .expect("global sync time recorded");
7869 assert_eq!(last.timestamp(), when.timestamp());
7871 }
7872
7873 fn seed_session_with_children(db: &Database) -> (Uuid, Uuid, Uuid, Uuid, Uuid) {
7880 let session = create_test_session("claude-code", "/project", Utc::now(), None);
7881 db.insert_session(&session).expect("insert session");
7882
7883 let link = create_test_link(session.id, Some("deadbeef"), LinkType::Commit);
7884 db.insert_link(&link).expect("insert link");
7885
7886 let tag = Tag {
7887 id: Uuid::new_v4(),
7888 session_id: session.id,
7889 label: "feature".to_string(),
7890 created_at: Utc::now(),
7891 };
7892 db.insert_tag(&tag).expect("insert tag");
7893
7894 let annotation = Annotation {
7895 id: Uuid::new_v4(),
7896 session_id: session.id,
7897 content: "note".to_string(),
7898 created_at: Utc::now(),
7899 };
7900 db.insert_annotation(&annotation)
7901 .expect("insert annotation");
7902
7903 let summary = Summary {
7904 id: Uuid::new_v4(),
7905 session_id: session.id,
7906 content: "summary".to_string(),
7907 generated_at: Utc::now(),
7908 };
7909 db.insert_summary(&summary).expect("insert summary");
7910
7911 (session.id, link.id, tag.id, annotation.id, summary.id)
7912 }
7913
7914 #[test]
7915 fn test_delete_link_records_tombstone() {
7916 let (db, _dir) = create_test_db();
7917 let (_sid, link_id, _tag, _ann, _sum) = seed_session_with_children(&db);
7918
7919 assert!(db.delete_link(&link_id).unwrap());
7920
7921 let tombstones = db.list_tombstones().unwrap();
7922 assert_eq!(tombstones.len(), 1, "one tombstone recorded");
7923 assert_eq!(tombstones[0].child_id, link_id.to_string());
7924 assert_eq!(tombstones[0].kind, TOMBSTONE_KIND_LINK);
7925 }
7926
7927 #[test]
7928 fn test_delete_tag_records_tombstone() {
7929 let (db, _dir) = create_test_db();
7930 let (sid, _link, tag_id, _ann, _sum) = seed_session_with_children(&db);
7931
7932 assert!(db.delete_tag(&sid, "feature").unwrap());
7933
7934 let tombstones = db.list_tombstones().unwrap();
7935 assert_eq!(tombstones.len(), 1);
7936 assert_eq!(tombstones[0].child_id, tag_id.to_string());
7937 assert_eq!(tombstones[0].kind, TOMBSTONE_KIND_TAG);
7938 }
7939
7940 #[test]
7941 fn test_delete_annotation_records_tombstone() {
7942 let (db, _dir) = create_test_db();
7943 let (_sid, _link, _tag, ann_id, _sum) = seed_session_with_children(&db);
7944
7945 assert!(db.delete_annotation(&ann_id).unwrap());
7946
7947 let tombstones = db.list_tombstones().unwrap();
7948 assert_eq!(tombstones.len(), 1);
7949 assert_eq!(tombstones[0].child_id, ann_id.to_string());
7950 assert_eq!(tombstones[0].kind, TOMBSTONE_KIND_ANNOTATION);
7951 }
7952
7953 #[test]
7954 fn test_delete_summary_records_tombstone() {
7955 let (db, _dir) = create_test_db();
7956 let (sid, _link, _tag, _ann, sum_id) = seed_session_with_children(&db);
7957
7958 assert!(db.delete_summary(&sid).unwrap());
7959
7960 let tombstones = db.list_tombstones().unwrap();
7961 assert_eq!(tombstones.len(), 1);
7962 assert_eq!(tombstones[0].child_id, sum_id.to_string());
7963 assert_eq!(tombstones[0].kind, TOMBSTONE_KIND_SUMMARY);
7964 }
7965
7966 #[test]
7967 fn test_bulk_delete_links_records_tombstone_per_link() {
7968 let (db, _dir) = create_test_db();
7969 let session = create_test_session("claude-code", "/project", Utc::now(), None);
7970 db.insert_session(&session).expect("insert session");
7971 let link_a = create_test_link(session.id, Some("aaaa1111"), LinkType::Commit);
7972 let link_b = create_test_link(session.id, Some("bbbb2222"), LinkType::Commit);
7973 db.insert_link(&link_a).unwrap();
7974 db.insert_link(&link_b).unwrap();
7975
7976 assert_eq!(db.delete_links_by_session(&session.id).unwrap(), 2);
7977
7978 let tombstones = db.list_tombstones().unwrap();
7979 assert_eq!(tombstones.len(), 2, "one tombstone per removed link");
7980 let ids: HashSet<String> = tombstones.iter().map(|t| t.child_id.clone()).collect();
7981 assert!(ids.contains(&link_a.id.to_string()));
7982 assert!(ids.contains(&link_b.id.to_string()));
7983 assert!(tombstones.iter().all(|t| t.kind == TOMBSTONE_KIND_LINK));
7984 }
7985
7986 #[test]
7987 fn test_session_cascade_delete_records_no_tombstones() {
7988 let (db, _dir) = create_test_db();
7992 let (sid, _link, _tag, _ann, _sum) = seed_session_with_children(&db);
7993
7994 db.delete_session(&sid).expect("delete session");
7995
7996 assert!(
7997 db.list_tombstones().unwrap().is_empty(),
7998 "session-cascade delete must not create tombstones"
7999 );
8000 }
8001
8002 #[test]
8003 fn test_delete_sessions_older_than_records_no_tombstones() {
8004 let (db, _dir) = create_test_db();
8007 let old = create_test_session(
8008 "claude-code",
8009 "/project",
8010 Utc::now() - Duration::days(400),
8011 None,
8012 );
8013 db.insert_session(&old).expect("insert session");
8014 let link = create_test_link(old.id, Some("deadbeef"), LinkType::Commit);
8015 db.insert_link(&link).unwrap();
8016
8017 let cutoff = Utc::now() - Duration::days(30);
8018 assert_eq!(db.delete_sessions_older_than(cutoff).unwrap(), 1);
8019
8020 assert!(
8021 db.list_tombstones().unwrap().is_empty(),
8022 "age-based session purge must not create tombstones"
8023 );
8024 }
8025
8026 #[test]
8027 fn test_add_tombstones_unions_first_wins() {
8028 let (db, _dir) = create_test_db();
8029 let child = Uuid::new_v4().to_string();
8030 let session = Uuid::new_v4().to_string();
8031
8032 let earlier = Utc::now() - Duration::hours(2);
8033 let later = Utc::now();
8034 db.add_tombstones(&[Tombstone {
8035 child_id: child.clone(),
8036 kind: TOMBSTONE_KIND_LINK.to_string(),
8037 session_id: Some(session.clone()),
8038 deleted_at: earlier,
8039 }])
8040 .unwrap();
8041 db.add_tombstones(&[Tombstone {
8043 child_id: child.clone(),
8044 kind: TOMBSTONE_KIND_LINK.to_string(),
8045 session_id: Some(session),
8046 deleted_at: later,
8047 }])
8048 .unwrap();
8049
8050 let tombstones = db.list_tombstones().unwrap();
8051 assert_eq!(tombstones.len(), 1, "union is keyed by (child_id, kind)");
8052 assert_eq!(
8053 tombstones[0].deleted_at.timestamp(),
8054 earlier.timestamp(),
8055 "first-wins keeps the earliest recorded deletion"
8056 );
8057 }
8058
8059 #[test]
8060 fn test_apply_tombstones_removes_local_child() {
8061 let (db, _dir) = create_test_db();
8062 let (sid, link_id, _tag, _ann, _sum) = seed_session_with_children(&db);
8063
8064 db.apply_tombstones(&[Tombstone {
8066 child_id: link_id.to_string(),
8067 kind: TOMBSTONE_KIND_LINK.to_string(),
8068 session_id: Some(sid.to_string()),
8069 deleted_at: Utc::now(),
8070 }])
8071 .unwrap();
8072
8073 assert!(
8074 db.get_links_by_session(&sid).unwrap().is_empty(),
8075 "apply_tombstones must remove the locally-present tombstoned link"
8076 );
8077 assert!(
8079 db.get_unsynced_sessions()
8080 .unwrap()
8081 .iter()
8082 .any(|s| s.id == sid),
8083 "cleaning a child must re-open the parent session for re-export"
8084 );
8085 }
8086
8087 #[test]
8088 fn test_apply_tombstones_does_not_record_new_tombstone() {
8089 let (db, _dir) = create_test_db();
8092 let (sid, link_id, _tag, _ann, _sum) = seed_session_with_children(&db);
8093 db.prune_tombstones(Utc::now() + Duration::days(1)).unwrap();
8095 assert!(db.list_tombstones().unwrap().is_empty());
8096
8097 db.apply_tombstones(&[Tombstone {
8098 child_id: link_id.to_string(),
8099 kind: TOMBSTONE_KIND_LINK.to_string(),
8100 session_id: Some(sid.to_string()),
8101 deleted_at: Utc::now(),
8102 }])
8103 .unwrap();
8104
8105 assert!(
8106 db.list_tombstones().unwrap().is_empty(),
8107 "apply_tombstones must not record new tombstones"
8108 );
8109 }
8110
8111 #[test]
8112 fn test_merge_suppresses_tombstoned_child() {
8113 let (mut db, _dir) = create_test_db();
8115 let session = create_test_session("claude-code", "/project", Utc::now(), None);
8116 let link = create_test_link(session.id, Some("deadbeef"), LinkType::Commit);
8117
8118 db.add_tombstones(&[Tombstone {
8120 child_id: link.id.to_string(),
8121 kind: TOMBSTONE_KIND_LINK.to_string(),
8122 session_id: Some(session.id.to_string()),
8123 deleted_at: Utc::now(),
8124 }])
8125 .unwrap();
8126
8127 db.merge_remote_record(
8128 &session,
8129 &[],
8130 std::slice::from_ref(&link),
8131 &[],
8132 &[],
8133 None,
8134 Utc::now(),
8135 )
8136 .unwrap();
8137
8138 assert!(
8139 db.get_links_by_session(&session.id).unwrap().is_empty(),
8140 "merge must suppress a tombstoned link"
8141 );
8142 }
8143
8144 #[test]
8145 fn test_merge_keeps_non_tombstoned_child() {
8146 let (mut db, _dir) = create_test_db();
8149 let session = create_test_session("claude-code", "/project", Utc::now(), None);
8150 let deleted = create_test_link(session.id, Some("deadbeef"), LinkType::Commit);
8151 let added = create_test_link(session.id, Some("feedface"), LinkType::Commit);
8152
8153 db.add_tombstones(&[Tombstone {
8154 child_id: deleted.id.to_string(),
8155 kind: TOMBSTONE_KIND_LINK.to_string(),
8156 session_id: Some(session.id.to_string()),
8157 deleted_at: Utc::now(),
8158 }])
8159 .unwrap();
8160
8161 db.merge_remote_record(
8162 &session,
8163 &[],
8164 &[deleted.clone(), added.clone()],
8165 &[],
8166 &[],
8167 None,
8168 Utc::now(),
8169 )
8170 .unwrap();
8171
8172 let links = db.get_links_by_session(&session.id).unwrap();
8173 assert_eq!(links.len(), 1, "only the non-tombstoned link survives");
8174 assert_eq!(links[0].id, added.id);
8175 }
8176
8177 #[test]
8178 fn test_prune_tombstones_removes_old_only() {
8179 let (db, _dir) = create_test_db();
8180 let old_child = Uuid::new_v4().to_string();
8181 let fresh_child = Uuid::new_v4().to_string();
8182 db.add_tombstones(&[
8183 Tombstone {
8184 child_id: old_child.clone(),
8185 kind: TOMBSTONE_KIND_TAG.to_string(),
8186 session_id: None,
8187 deleted_at: Utc::now() - Duration::days(120),
8188 },
8189 Tombstone {
8190 child_id: fresh_child.clone(),
8191 kind: TOMBSTONE_KIND_TAG.to_string(),
8192 session_id: None,
8193 deleted_at: Utc::now(),
8194 },
8195 ])
8196 .unwrap();
8197
8198 let pruned = db
8199 .prune_tombstones(Utc::now() - Duration::days(90))
8200 .unwrap();
8201 assert_eq!(pruned, 1, "only the old tombstone is pruned");
8202
8203 let remaining = db.list_tombstones().unwrap();
8204 assert_eq!(remaining.len(), 1);
8205 assert_eq!(remaining[0].child_id, fresh_child);
8206 }
8207}