1use chrono::{DateTime, Utc};
14use rusqlite::{Connection, OptionalExtension, Result as RusqliteResult, params};
15use std::path::{Path, PathBuf};
16use thiserror::Error;
17use uuid::Uuid;
18
19use crate::{Session, SessionInfo};
20
21#[derive(Debug, Error)]
23pub enum IndexError {
24 #[error("index store error: {0}")]
26 Store(#[from] IndexStoreError),
27
28 #[error("I/O error: {0}")]
30 IoError(#[from] std::io::Error),
31
32 #[error("invalid UUID: {0}")]
34 InvalidUuid(String),
35}
36
37#[derive(Debug, Error)]
39pub enum IndexStoreError {
40 #[error("database operation failed: {0}")]
42 Database(String),
43}
44
45impl From<rusqlite::Error> for IndexStoreError {
46 fn from(err: rusqlite::Error) -> Self {
47 IndexStoreError::Database(err.to_string())
48 }
49}
50
51impl From<rusqlite::Error> for IndexError {
52 fn from(err: rusqlite::Error) -> Self {
53 IndexError::Store(IndexStoreError::from(err))
54 }
55}
56
57#[derive(Debug, Clone)]
59pub struct SearchResult {
60 pub session_id: String,
62
63 pub project: String,
65
66 pub workspace_root: String,
68
69 pub snippet: String,
71
72 pub timestamp: DateTime<Utc>,
74
75 pub rank: f64,
77}
78
79#[derive(Debug, Clone)]
84pub struct ForkInfo {
85 pub forked_session_id: String,
87
88 pub fork_entry_id: String,
90
91 pub forked_at: DateTime<Utc>,
93}
94
95#[derive(Debug)]
100pub struct SessionIndex {
101 conn: Connection,
102 db_path: PathBuf,
103}
104
105impl SessionIndex {
106 pub fn new(path: &Path) -> Result<Self, IndexError> {
119 if let Some(parent) = path.parent() {
120 std::fs::create_dir_all(parent)?;
121 }
122
123 let conn = Connection::open(path)?;
124
125 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
126
127 Ok(Self {
128 conn,
129 db_path: path.to_path_buf(),
130 })
131 }
132
133 pub fn init_schema(&self) -> Result<(), IndexError> {
142 self.conn.execute_batch(
143 r#"
144 CREATE TABLE IF NOT EXISTS sessions (
145 id TEXT PRIMARY KEY,
146 project TEXT NOT NULL,
147 workspace_root TEXT NOT NULL DEFAULT '',
148 created_at TEXT NOT NULL,
149 updated_at TEXT NOT NULL,
150 message_count INTEGER NOT NULL DEFAULT 0
151 );
152
153 CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
154 session_id,
155 role,
156 content,
157 timestamp
158 );
159
160 CREATE TABLE IF NOT EXISTS forks (
161 source_session_id TEXT NOT NULL,
162 forked_session_id TEXT PRIMARY KEY,
163 fork_entry_id TEXT NOT NULL,
164 forked_at TEXT NOT NULL
165 );
166 "#,
167 )?;
168
169 self.conn
171 .execute_batch(
172 "ALTER TABLE sessions ADD COLUMN workspace_root TEXT NOT NULL DEFAULT '';",
173 )
174 .ok();
175
176 Ok(())
177 }
178
179 pub fn index_session(&mut self, session: &Session) -> Result<(), IndexError> {
193 let entries = session
194 .read_entries()
195 .map_err(|e| IndexError::IoError(std::io::Error::other(e.to_string())))?;
196
197 let tx = self.conn.transaction()?;
198
199 tx.execute(
200 "DELETE FROM messages_fts WHERE session_id = ?1",
201 params![session.id.to_string()],
202 )?;
203
204 let created_at = session.created_at.to_rfc3339();
205 let updated_at = entries
206 .last()
207 .map(|e| e.timestamp.to_rfc3339())
208 .unwrap_or_else(|| Utc::now().to_rfc3339());
209 let message_count = entries.len() as i64;
210
211 tx.execute(
212 r#"
213 INSERT INTO sessions (id, project, workspace_root, created_at, updated_at, message_count)
214 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
215 ON CONFLICT(id) DO UPDATE SET
216 project = excluded.project,
217 workspace_root = excluded.workspace_root,
218 updated_at = excluded.updated_at,
219 message_count = excluded.message_count
220 "#,
221 params![
222 session.id.to_string(),
223 session.project,
224 session.workspace_root,
225 created_at,
226 updated_at,
227 message_count,
228 ],
229 )?;
230
231 {
232 let mut stmt = tx.prepare(
233 "INSERT INTO messages_fts (session_id, role, content, timestamp) VALUES (?1, ?2, ?3, ?4)",
234 )?;
235
236 for entry in &entries {
237 stmt.execute(params![
238 session.id.to_string(),
239 entry.role,
240 entry.content,
241 entry.timestamp.to_rfc3339(),
242 ])?;
243 }
244 }
245
246 tx.commit()?;
247
248 Ok(())
249 }
250
251 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, IndexError> {
265 let mut stmt = self.conn.prepare(
266 r#"
267 SELECT
268 f.session_id,
269 s.project,
270 s.workspace_root,
271 snippet(messages_fts, 2, '<b>', '</b>', '...', 32) AS snippet,
272 f.timestamp,
273 bm25(messages_fts) AS rank
274 FROM messages_fts f
275 LEFT JOIN sessions s ON f.session_id = s.id
276 WHERE messages_fts MATCH ?1
277 ORDER BY rank ASC
278 LIMIT ?2
279 "#,
280 )?;
281
282 let results = stmt
283 .query_map(params![query, limit as i64], |row| {
284 let session_id: String = row.get(0)?;
285 let project: String = row.get(1).unwrap_or_else(|_| "unknown".to_string());
286 let workspace_root: String = row.get(2).unwrap_or_else(|_| String::new());
287 let snippet: String = row.get(3)?;
288 let timestamp_str: String = row.get(4)?;
289 let rank: f64 = row.get(5)?;
290
291 let timestamp = DateTime::parse_from_rfc3339(×tamp_str)
292 .map(|dt| dt.with_timezone(&Utc))
293 .unwrap_or_else(|_| Utc::now());
294
295 Ok(SearchResult {
296 session_id,
297 project,
298 workspace_root,
299 snippet,
300 timestamp,
301 rank,
302 })
303 })?
304 .collect::<RusqliteResult<Vec<_>>>()?;
305
306 Ok(results)
307 }
308
309 pub fn list_recent(&self, limit: usize) -> Result<Vec<SessionInfo>, IndexError> {
321 let mut stmt = self.conn.prepare(
322 r#"
323 SELECT id, project, workspace_root, message_count, updated_at
324 FROM sessions
325 ORDER BY updated_at DESC
326 LIMIT ?1
327 "#,
328 )?;
329
330 let results = stmt
331 .query_map(params![limit as i64], |row| {
332 let id_str: String = row.get(0)?;
333 let project: String = row.get(1)?;
334 let workspace_root: String = row.get(2).unwrap_or_default();
335 let message_count: i64 = row.get(3)?;
336 let updated_at_str: String = row.get(4)?;
337
338 let id = Uuid::parse_str(&id_str).map_err(|_| {
339 rusqlite::Error::InvalidColumnType(
340 0,
341 id_str.clone(),
342 rusqlite::types::Type::Text,
343 )
344 })?;
345
346 let timestamp = DateTime::parse_from_rfc3339(&updated_at_str)
347 .map(|dt| dt.with_timezone(&Utc))
348 .unwrap_or_else(|_| Utc::now());
349
350 Ok(SessionInfo {
351 id,
352 project,
353 workspace_root,
354 last_message_preview: String::new(),
355 timestamp,
356 message_count: message_count as usize,
357 })
358 })?
359 .collect::<RusqliteResult<Vec<_>>>()?;
360
361 Ok(results)
362 }
363
364 pub fn get_session_info(&self, session_id: &str) -> Result<Option<SessionInfo>, IndexError> {
376 let mut stmt = self.conn.prepare(
377 r#"
378 SELECT id, project, workspace_root, message_count, updated_at
379 FROM sessions
380 WHERE id = ?1
381 "#,
382 )?;
383
384 let result = stmt
385 .query_row(params![session_id], |row| {
386 let id_str: String = row.get(0)?;
387 let project: String = row.get(1)?;
388 let workspace_root: String = row.get(2).unwrap_or_default();
389 let message_count: i64 = row.get(3)?;
390 let updated_at_str: String = row.get(4)?;
391
392 let id = Uuid::parse_str(&id_str).map_err(|_| {
393 rusqlite::Error::InvalidColumnType(
394 0,
395 id_str.clone(),
396 rusqlite::types::Type::Text,
397 )
398 })?;
399
400 let timestamp = DateTime::parse_from_rfc3339(&updated_at_str)
401 .map(|dt| dt.with_timezone(&Utc))
402 .unwrap_or_else(|_| Utc::now());
403
404 Ok(SessionInfo {
405 id,
406 project,
407 workspace_root,
408 last_message_preview: String::new(),
409 timestamp,
410 message_count: message_count as usize,
411 })
412 })
413 .optional()?;
414
415 Ok(result)
416 }
417
418 pub fn db_path(&self) -> &Path {
420 &self.db_path
421 }
422
423 pub fn list_all_session_ids(&self) -> Result<Vec<String>, IndexError> {
424 let mut stmt = self.conn.prepare("SELECT id FROM sessions")?;
425 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
426 let mut ids = Vec::new();
427 for row in rows {
428 ids.push(row?);
429 }
430 Ok(ids)
431 }
432
433 pub fn delete_session(&mut self, session_id: &str) -> Result<(), IndexError> {
434 let tx = self.conn.transaction()?;
435 tx.execute(
436 "DELETE FROM messages_fts WHERE session_id = ?1",
437 params![session_id],
438 )?;
439 tx.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
440 tx.execute(
441 "DELETE FROM forks WHERE source_session_id = ?1 OR forked_session_id = ?1",
442 params![session_id],
443 )?;
444 tx.commit()?;
445 Ok(())
446 }
447
448 pub fn checkpoint_truncate(&self) -> Result<(), IndexError> {
450 self.conn
451 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
452 Ok(())
453 }
454
455 pub fn vacuum(&self) -> Result<(), IndexError> {
457 self.conn.execute_batch("VACUUM;")?;
458 Ok(())
459 }
460
461 pub fn record_fork(
476 &mut self,
477 source_session_id: &str,
478 forked_session_id: &str,
479 fork_entry_id: &str,
480 ) -> Result<(), IndexError> {
481 let forked_at = Utc::now().to_rfc3339();
482
483 self.conn.execute(
484 "INSERT INTO forks (source_session_id, forked_session_id, fork_entry_id, forked_at) VALUES (?1, ?2, ?3, ?4)",
485 params![source_session_id, forked_session_id, fork_entry_id, forked_at],
486 )?;
487
488 Ok(())
489 }
490
491 pub fn get_forks(&self, session_id: &str) -> Result<Vec<ForkInfo>, IndexError> {
504 let mut stmt = self.conn.prepare(
505 r#"
506 SELECT forked_session_id, fork_entry_id, forked_at
507 FROM forks
508 WHERE source_session_id = ?1
509 ORDER BY forked_at DESC
510 "#,
511 )?;
512
513 let results = stmt
514 .query_map(params![session_id], |row| {
515 let forked_session_id: String = row.get(0)?;
516 let fork_entry_id: String = row.get(1)?;
517 let forked_at_str: String = row.get(2)?;
518
519 let forked_at = DateTime::parse_from_rfc3339(&forked_at_str)
520 .map(|dt| dt.with_timezone(&Utc))
521 .unwrap_or_else(|_| Utc::now());
522
523 Ok(ForkInfo {
524 forked_session_id,
525 fork_entry_id,
526 forked_at,
527 })
528 })?
529 .collect::<RusqliteResult<Vec<_>>>()?;
530
531 Ok(results)
532 }
533}
534
535#[cfg(test)]
536#[allow(warnings)]
537mod tests {
538 use super::*;
539 use crate::{Session, SessionManager};
540 use chrono::Datelike;
541 use talos_core::message::Message;
542
543 fn temp_index() -> (SessionIndex, tempfile::TempDir) {
544 let dir = tempfile::tempdir().expect("operation should succeed");
545 let db_path = dir.path().join("test_index.db");
546 let index = SessionIndex::new(&db_path).expect("operation should succeed");
547 index.init_schema().expect("operation should succeed");
548 (index, dir)
549 }
550
551 fn test_session(manager: &SessionManager) -> Session {
552 let session = manager
553 .create_session("test-project", "")
554 .expect("operation should succeed");
555 session
556 .append(&Message::User {
557 content: "Hello, how do I implement full-text search in Rust?".into(),
558 })
559 .expect("operation should succeed");
560 session
561 .append(&Message::Assistant {
562 content: "You can use SQLite with FTS5 extension. It provides efficient full-text indexing.".into(),
563 tool_calls: vec![],
564 reasoning: None,
565 })
566 .expect("operation should succeed");
567 session
568 .append(&Message::User {
569 content: "What about ranking and relevance?".into(),
570 })
571 .expect("operation should succeed");
572 session
573 .append(&Message::Assistant {
574 content: "FTS5 uses BM25 ranking by default. Lower scores indicate more relevant matches.".into(),
575 tool_calls: vec![],
576 reasoning: None,
577 })
578 .expect("operation should succeed");
579 session
580 }
581
582 #[test]
583 fn test_schema_creation() {
584 let (index, _dir) = temp_index();
585
586 let table_count: i64 = index
587 .conn
588 .query_row(
589 "SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table', 'view') AND name IN ('sessions', 'messages_fts')",
590 [],
591 |row| row.get(0),
592 )
593 .expect("operation should succeed");
594
595 assert_eq!(
596 table_count, 2,
597 "Both sessions and messages_fts tables should exist"
598 );
599 }
600
601 #[test]
602 fn test_session_indexing() {
603 let manager = SessionManager::with_dir(
604 tempfile::tempdir()
605 .expect("operation should succeed")
606 .path()
607 .to_path_buf(),
608 );
609 let session = test_session(&manager);
610
611 let (mut index, _dir) = temp_index();
612 index
613 .index_session(&session)
614 .expect("operation should succeed");
615
616 let info = index
617 .get_session_info(&session.id.to_string())
618 .expect("operation should succeed");
619 assert!(info.is_some());
620 let info = info.expect("operation should succeed");
621 assert_eq!(info.project, "test-project");
622 assert_eq!(info.message_count, 4);
623 }
624
625 #[test]
626 fn test_full_text_search_basic() {
627 let manager = SessionManager::with_dir(
628 tempfile::tempdir()
629 .expect("operation should succeed")
630 .path()
631 .to_path_buf(),
632 );
633 let session = test_session(&manager);
634
635 let (mut index, _dir) = temp_index();
636 index
637 .index_session(&session)
638 .expect("operation should succeed");
639
640 let results = index
641 .search("SQLite", 10)
642 .expect("operation should succeed");
643 assert!(!results.is_empty(), "Should find results for 'SQLite'");
644
645 for result in &results {
646 assert_eq!(result.session_id, session.id.to_string());
647 }
648 }
649
650 #[test]
651 fn test_full_text_search_bm25() {
652 let manager = SessionManager::with_dir(
653 tempfile::tempdir()
654 .expect("operation should succeed")
655 .path()
656 .to_path_buf(),
657 );
658 let session = test_session(&manager);
659
660 let (mut index, _dir) = temp_index();
661 index
662 .index_session(&session)
663 .expect("operation should succeed");
664
665 let results = index
666 .search("ranking BM25", 10)
667 .expect("operation should succeed");
668 assert!(
669 !results.is_empty(),
670 "Should find results for 'ranking BM25'"
671 );
672
673 for i in 1..results.len() {
674 assert!(
675 results[i].rank >= results[i - 1].rank,
676 "Results should be ordered by rank ascending"
677 );
678 }
679 }
680
681 #[test]
682 fn test_full_text_search_no_results() {
683 let manager = SessionManager::with_dir(
684 tempfile::tempdir()
685 .expect("operation should succeed")
686 .path()
687 .to_path_buf(),
688 );
689 let session = test_session(&manager);
690
691 let (mut index, _dir) = temp_index();
692 index
693 .index_session(&session)
694 .expect("operation should succeed");
695
696 let results = index
697 .search("nonexistent_term_xyz_12345", 10)
698 .expect("operation should succeed");
699 assert!(
700 results.is_empty(),
701 "Should return empty results for non-matching query"
702 );
703 }
704
705 #[test]
706 fn test_full_text_search_with_limit() {
707 let manager = SessionManager::with_dir(
708 tempfile::tempdir()
709 .expect("operation should succeed")
710 .path()
711 .to_path_buf(),
712 );
713 let session = test_session(&manager);
714
715 let (mut index, _dir) = temp_index();
716 index
717 .index_session(&session)
718 .expect("operation should succeed");
719
720 let results_all = index
721 .search("session", 100)
722 .expect("operation should succeed");
723 let results_limited = index
724 .search("session", 2)
725 .expect("operation should succeed");
726
727 assert!(results_limited.len() <= 2, "Should respect limit");
728 assert!(
729 results_limited.len() <= results_all.len(),
730 "Limited results should not exceed all results"
731 );
732 }
733
734 #[test]
735 fn test_search_result_snippet() {
736 let manager = SessionManager::with_dir(
737 tempfile::tempdir()
738 .expect("operation should succeed")
739 .path()
740 .to_path_buf(),
741 );
742 let session = test_session(&manager);
743
744 let (mut index, _dir) = temp_index();
745 index
746 .index_session(&session)
747 .expect("operation should succeed");
748
749 let results = index.search("Rust", 10).expect("operation should succeed");
750 assert!(!results.is_empty(), "Should find results for 'Rust'");
751
752 let found_highlight = results.iter().any(|r| r.snippet.contains("<b>"));
753 assert!(
754 found_highlight,
755 "Snippet should contain highlighted matches"
756 );
757 }
758
759 #[test]
760 fn test_list_recent_ordering() {
761 let manager = SessionManager::with_dir(
762 tempfile::tempdir()
763 .expect("operation should succeed")
764 .path()
765 .to_path_buf(),
766 );
767
768 let session1 = manager
769 .create_session("project-alpha", "")
770 .expect("operation should succeed");
771 session1
772 .append(&Message::User {
773 content: "First session message".into(),
774 })
775 .expect("operation should succeed");
776
777 std::thread::sleep(std::time::Duration::from_millis(10));
778
779 let session2 = manager
780 .create_session("project-beta", "")
781 .expect("operation should succeed");
782 session2
783 .append(&Message::User {
784 content: "Second session message".into(),
785 })
786 .expect("operation should succeed");
787
788 let (mut index, _dir) = temp_index();
789 index
790 .index_session(&session1)
791 .expect("operation should succeed");
792 index
793 .index_session(&session2)
794 .expect("operation should succeed");
795
796 let recent = index.list_recent(10).expect("operation should succeed");
797 assert_eq!(recent.len(), 2, "Should return both sessions");
798
799 assert_eq!(
800 recent[0].id, session2.id,
801 "Most recent session should be first"
802 );
803 assert_eq!(recent[1].id, session1.id, "Older session should be second");
804 }
805
806 #[test]
807 fn test_list_recent_with_limit() {
808 let manager = SessionManager::with_dir(
809 tempfile::tempdir()
810 .expect("operation should succeed")
811 .path()
812 .to_path_buf(),
813 );
814 let db_path = tempfile::tempdir()
815 .expect("operation should succeed")
816 .path()
817 .join("test_limit.db");
818 let mut index = SessionIndex::new(&db_path).expect("operation should succeed");
819 index.init_schema().expect("operation should succeed");
820
821 for i in 0..5 {
822 let session = manager
823 .create_session(&format!("project-limit-{i}"), "")
824 .expect("operation should succeed");
825 session
826 .append(&Message::User {
827 content: format!("Message in project {i}"),
828 })
829 .expect("operation should succeed");
830 index
831 .index_session(&session)
832 .expect("operation should succeed");
833 std::thread::sleep(std::time::Duration::from_millis(5));
834 }
835
836 let limited = index.list_recent(3).expect("operation should succeed");
837 assert_eq!(limited.len(), 3, "Should respect limit");
838 }
839
840 #[test]
841 fn test_get_session_info_existing() {
842 let manager = SessionManager::with_dir(
843 tempfile::tempdir()
844 .expect("operation should succeed")
845 .path()
846 .to_path_buf(),
847 );
848 let session = test_session(&manager);
849
850 let (mut index, _dir) = temp_index();
851 index
852 .index_session(&session)
853 .expect("operation should succeed");
854
855 let info = index
856 .get_session_info(&session.id.to_string())
857 .expect("operation should succeed");
858 assert!(info.is_some());
859 let info = info.expect("operation should succeed");
860 assert_eq!(info.id, session.id);
861 assert_eq!(info.project, "test-project");
862 assert_eq!(info.message_count, 4);
863 }
864
865 #[test]
866 fn test_get_session_info_nonexistent() {
867 let (index, _dir) = temp_index();
868
869 let info = index
870 .get_session_info("nonexistent-id")
871 .expect("operation should succeed");
872 assert!(
873 info.is_none(),
874 "Should return None for non-existent session"
875 );
876 }
877
878 #[test]
879 fn test_list_all_session_ids() {
880 let manager = SessionManager::with_dir(
881 tempfile::tempdir()
882 .expect("operation should succeed")
883 .path()
884 .to_path_buf(),
885 );
886 let s1 = test_session(&manager);
887 let s2 = manager
888 .create_session("test-project", "")
889 .expect("operation should succeed");
890 let (mut index, _dir) = temp_index();
891 index.index_session(&s1).expect("operation should succeed");
892 index.index_session(&s2).expect("operation should succeed");
893
894 let mut ids = index
895 .list_all_session_ids()
896 .expect("operation should succeed");
897 ids.sort();
898 let mut expected = vec![s1.id.to_string(), s2.id.to_string()];
899 expected.sort();
900 assert_eq!(ids, expected);
901 }
902
903 #[test]
904 fn test_delete_session_removes_index_entries() {
905 let manager = SessionManager::with_dir(
906 tempfile::tempdir()
907 .expect("operation should succeed")
908 .path()
909 .to_path_buf(),
910 );
911 let session = test_session(&manager);
912 let (mut index, _dir) = temp_index();
913 index
914 .index_session(&session)
915 .expect("operation should succeed");
916
917 index
918 .delete_session(&session.id.to_string())
919 .expect("delete should succeed");
920 let info = index
921 .get_session_info(&session.id.to_string())
922 .expect("operation should succeed");
923 assert!(info.is_none(), "index entry should be removed");
924 let ids = index
925 .list_all_session_ids()
926 .expect("operation should succeed");
927 assert!(!ids.contains(&session.id.to_string()));
928 }
929
930 #[test]
931 fn test_index_session_upsert() {
932 let manager = SessionManager::with_dir(
933 tempfile::tempdir()
934 .expect("operation should succeed")
935 .path()
936 .to_path_buf(),
937 );
938 let session = manager
939 .create_session("test-project", "")
940 .expect("operation should succeed");
941
942 session
943 .append(&Message::User {
944 content: "Initial message".into(),
945 })
946 .expect("operation should succeed");
947
948 let (mut index, _dir) = temp_index();
949 index
950 .index_session(&session)
951 .expect("operation should succeed");
952
953 session
954 .append(&Message::Assistant {
955 content: "Reply to initial message".into(),
956 tool_calls: vec![],
957 reasoning: None,
958 })
959 .expect("operation should succeed");
960 index
961 .index_session(&session)
962 .expect("operation should succeed");
963
964 let info = index
965 .get_session_info(&session.id.to_string())
966 .expect("operation should succeed");
967 assert!(info.is_some());
968 assert_eq!(
969 info.expect("operation should succeed").message_count,
970 2,
971 "Message count should be updated"
972 );
973
974 let results = index
975 .search("message", 100)
976 .expect("operation should succeed");
977 let unique_count = results
978 .iter()
979 .filter(|r| r.session_id == session.id.to_string())
980 .count();
981 assert_eq!(unique_count, 2, "Should not have duplicate entries");
982 }
983
984 #[test]
985 fn test_search_with_phrase_query() {
986 let manager = SessionManager::with_dir(
987 tempfile::tempdir()
988 .expect("operation should succeed")
989 .path()
990 .to_path_buf(),
991 );
992 let session = test_session(&manager);
993
994 let (mut index, _dir) = temp_index();
995 index
996 .index_session(&session)
997 .expect("operation should succeed");
998
999 let results = index
1000 .search("\"BM25 ranking\"", 10)
1001 .expect("operation should succeed");
1002 assert!(!results.is_empty(), "Should find exact phrase match");
1003 }
1004
1005 #[test]
1006 fn test_search_result_timestamp() {
1007 let manager = SessionManager::with_dir(
1008 tempfile::tempdir()
1009 .expect("operation should succeed")
1010 .path()
1011 .to_path_buf(),
1012 );
1013 let session = test_session(&manager);
1014
1015 let (mut index, _dir) = temp_index();
1016 index
1017 .index_session(&session)
1018 .expect("operation should succeed");
1019
1020 let results = index.search("Rust", 10).expect("operation should succeed");
1021 assert!(!results.is_empty());
1022
1023 for result in &results {
1024 assert!(
1025 result.timestamp.year() > 2020,
1026 "Timestamp should be reasonable"
1027 );
1028 }
1029 }
1030
1031 #[test]
1032 fn test_db_path() {
1033 let dir = tempfile::tempdir().expect("operation should succeed");
1034 let db_path = dir.path().join("test_path.db");
1035 let index = SessionIndex::new(&db_path).expect("operation should succeed");
1036
1037 assert_eq!(index.db_path(), db_path);
1038 }
1039
1040 #[test]
1043 fn test_fork_schema_creation() {
1044 let (index, _dir) = temp_index();
1045
1046 let table_count: i64 = index
1047 .conn
1048 .query_row(
1049 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='forks'",
1050 [],
1051 |row| row.get(0),
1052 )
1053 .expect("operation should succeed");
1054
1055 assert_eq!(table_count, 1, "forks table should exist");
1056 }
1057
1058 #[test]
1059 fn test_record_fork() {
1060 let (mut index, _dir) = temp_index();
1061
1062 index
1063 .record_fork("source-session-1", "forked-session-1", "entry-abc")
1064 .expect("operation should succeed");
1065
1066 let count: i64 = index
1067 .conn
1068 .query_row("SELECT COUNT(*) FROM forks", [], |row| row.get(0))
1069 .expect("operation should succeed");
1070
1071 assert_eq!(count, 1, "One fork relationship should be recorded");
1072 }
1073
1074 #[test]
1075 fn test_get_forks_returns_correct_relationships() {
1076 let (mut index, _dir) = temp_index();
1077
1078 index
1079 .record_fork("source-1", "forked-1", "entry-a")
1080 .expect("operation should succeed");
1081 index
1082 .record_fork("source-1", "forked-2", "entry-b")
1083 .expect("operation should succeed");
1084 index
1085 .record_fork("source-2", "forked-3", "entry-c")
1086 .expect("operation should succeed");
1087
1088 let forks = index
1089 .get_forks("source-1")
1090 .expect("operation should succeed");
1091 assert_eq!(forks.len(), 2, "Should return 2 forks for source-1");
1092
1093 let fork_ids: Vec<&str> = forks.iter().map(|f| f.forked_session_id.as_str()).collect();
1094 assert!(fork_ids.contains(&"forked-1"));
1095 assert!(fork_ids.contains(&"forked-2"));
1096
1097 let forks_source2 = index
1098 .get_forks("source-2")
1099 .expect("operation should succeed");
1100 assert_eq!(forks_source2.len(), 1);
1101 assert_eq!(forks_source2[0].forked_session_id, "forked-3");
1102 }
1103
1104 #[test]
1105 fn test_get_forks_empty_for_unknown_session() {
1106 let (index, _dir) = temp_index();
1107
1108 let forks = index
1109 .get_forks("unknown-session")
1110 .expect("operation should succeed");
1111 assert!(forks.is_empty(), "Should return empty for unknown session");
1112 }
1113
1114 #[test]
1115 fn test_fork_info_contains_entry_id_and_timestamp() {
1116 let (mut index, _dir) = temp_index();
1117
1118 index
1119 .record_fork("source-1", "forked-1", "entry-123")
1120 .expect("operation should succeed");
1121
1122 let forks = index
1123 .get_forks("source-1")
1124 .expect("operation should succeed");
1125 assert_eq!(forks.len(), 1);
1126
1127 let fork = &forks[0];
1128 assert_eq!(fork.fork_entry_id, "entry-123");
1129 assert_eq!(fork.forked_session_id, "forked-1");
1130 assert!(
1131 fork.forked_at.year() > 2020,
1132 "Timestamp should be reasonable"
1133 );
1134 }
1135}