lore_cli/storage/
db.rs

1//! SQLite storage layer for Lore.
2//!
3//! Provides database operations for storing and retrieving sessions,
4//! messages, and session-to-commit links. Uses SQLite for local-first
5//! persistence with automatic schema migrations.
6
7use anyhow::{Context, Result};
8use chrono::{DateTime, Utc};
9use rusqlite::{params, Connection, OptionalExtension};
10use std::path::PathBuf;
11use uuid::Uuid;
12
13use super::models::{Message, MessageContent, MessageRole, SearchResult, Session, SessionLink};
14
15/// Parses a UUID from a string, converting errors to rusqlite errors.
16///
17/// Used in row mapping functions where we need to return rusqlite::Result.
18fn parse_uuid(s: &str) -> rusqlite::Result<Uuid> {
19    Uuid::parse_str(s).map_err(|e| {
20        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
21    })
22}
23
24/// Parses an RFC3339 datetime string, converting errors to rusqlite errors.
25///
26/// Used in row mapping functions where we need to return rusqlite::Result.
27fn parse_datetime(s: &str) -> rusqlite::Result<DateTime<Utc>> {
28    chrono::DateTime::parse_from_rfc3339(s)
29        .map(|dt| dt.with_timezone(&Utc))
30        .map_err(|e| {
31            rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
32        })
33}
34
35/// Escapes a query string for FTS5 by wrapping each word in double quotes.
36///
37/// FTS5 has special syntax characters (e.g., /, *, AND, OR, NOT) that need
38/// escaping to be treated as literal search terms.
39fn escape_fts5_query(query: &str) -> String {
40    // Split on whitespace and wrap each word in quotes, escaping internal quotes
41    query
42        .split_whitespace()
43        .map(|word| {
44            let escaped = word.replace('"', "\"\"");
45            format!("\"{escaped}\"")
46        })
47        .collect::<Vec<_>>()
48        .join(" ")
49}
50
51/// Returns the default database path at `~/.lore/lore.db`.
52///
53/// Creates the `.lore` directory if it does not exist.
54pub fn default_db_path() -> Result<PathBuf> {
55    let config_dir = dirs::home_dir()
56        .context("Could not find home directory. Ensure your HOME environment variable is set.")?
57        .join(".lore");
58
59    std::fs::create_dir_all(&config_dir).with_context(|| {
60        format!(
61            "Failed to create Lore data directory at {}. Check directory permissions.",
62            config_dir.display()
63        )
64    })?;
65    Ok(config_dir.join("lore.db"))
66}
67
68/// SQLite database connection wrapper.
69///
70/// Provides methods for storing and querying sessions, messages,
71/// and session-to-commit links. Handles schema migrations automatically
72/// when opening the database.
73pub struct Database {
74    conn: Connection,
75}
76
77impl Database {
78    /// Opens or creates a database at the specified path.
79    ///
80    /// Runs schema migrations automatically to ensure tables exist.
81    pub fn open(path: &PathBuf) -> Result<Self> {
82        let conn = Connection::open(path)?;
83        let db = Self { conn };
84        db.migrate()?;
85        Ok(db)
86    }
87
88    /// Opens the default database at `~/.lore/lore.db`.
89    ///
90    /// Creates the database file and directory if they do not exist.
91    pub fn open_default() -> Result<Self> {
92        let path = default_db_path()?;
93        Self::open(&path)
94    }
95
96    /// Runs database schema migrations.
97    ///
98    /// Creates tables for sessions, messages, session_links, and repositories
99    /// if they do not already exist. Also creates indexes for common queries.
100    fn migrate(&self) -> Result<()> {
101        self.conn.execute_batch(
102            r#"
103            CREATE TABLE IF NOT EXISTS schema_version (
104                version INTEGER PRIMARY KEY
105            );
106
107            CREATE TABLE IF NOT EXISTS sessions (
108                id TEXT PRIMARY KEY,
109                tool TEXT NOT NULL,
110                tool_version TEXT,
111                started_at TEXT NOT NULL,
112                ended_at TEXT,
113                model TEXT,
114                working_directory TEXT NOT NULL,
115                git_branch TEXT,
116                source_path TEXT,
117                message_count INTEGER NOT NULL DEFAULT 0,
118                created_at TEXT NOT NULL DEFAULT (datetime('now'))
119            );
120
121            CREATE TABLE IF NOT EXISTS messages (
122                id TEXT PRIMARY KEY,
123                session_id TEXT NOT NULL,
124                parent_id TEXT,
125                idx INTEGER NOT NULL,
126                timestamp TEXT NOT NULL,
127                role TEXT NOT NULL,
128                content TEXT NOT NULL,
129                model TEXT,
130                git_branch TEXT,
131                cwd TEXT,
132                FOREIGN KEY (session_id) REFERENCES sessions(id)
133            );
134
135            CREATE TABLE IF NOT EXISTS session_links (
136                id TEXT PRIMARY KEY,
137                session_id TEXT NOT NULL,
138                link_type TEXT NOT NULL,
139                commit_sha TEXT,
140                branch TEXT,
141                remote TEXT,
142                created_at TEXT NOT NULL,
143                created_by TEXT NOT NULL,
144                confidence REAL,
145                FOREIGN KEY (session_id) REFERENCES sessions(id)
146            );
147
148            CREATE TABLE IF NOT EXISTS repositories (
149                id TEXT PRIMARY KEY,
150                path TEXT NOT NULL UNIQUE,
151                name TEXT NOT NULL,
152                remote_url TEXT,
153                created_at TEXT NOT NULL DEFAULT (datetime('now')),
154                last_session_at TEXT
155            );
156
157            -- Indexes for common queries
158            CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
159            CREATE INDEX IF NOT EXISTS idx_sessions_working_directory ON sessions(working_directory);
160            CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
161            CREATE INDEX IF NOT EXISTS idx_session_links_session_id ON session_links(session_id);
162            CREATE INDEX IF NOT EXISTS idx_session_links_commit_sha ON session_links(commit_sha);
163            "#,
164        )?;
165
166        // Create FTS5 virtual table for full-text search on message content.
167        // This is a standalone FTS table (not content-synced) because we need to
168        // store extracted text content, not the raw JSON from the messages table.
169        // The message_id column stores the UUID string for joining back to messages.
170        self.conn.execute_batch(
171            r#"
172            CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
173                message_id,
174                text_content,
175                tokenize='porter unicode61'
176            );
177            "#,
178        )?;
179
180        // Create FTS5 virtual table for session metadata search.
181        // Allows searching by project name, branch, tool, and working directory.
182        self.conn.execute_batch(
183            r#"
184            CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
185                session_id,
186                tool,
187                working_directory,
188                git_branch,
189                tokenize='porter unicode61'
190            );
191            "#,
192        )?;
193
194        Ok(())
195    }
196
197    // ==================== Sessions ====================
198
199    /// Inserts a new session or updates an existing one.
200    ///
201    /// If a session with the same ID already exists, updates the `ended_at`
202    /// and `message_count` fields. Also updates the sessions_fts index for
203    /// full-text search on session metadata.
204    pub fn insert_session(&self, session: &Session) -> Result<()> {
205        let rows_changed = self.conn.execute(
206            r#"
207            INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count)
208            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
209            ON CONFLICT(id) DO UPDATE SET
210                ended_at = ?5,
211                message_count = ?10
212            "#,
213            params![
214                session.id.to_string(),
215                session.tool,
216                session.tool_version,
217                session.started_at.to_rfc3339(),
218                session.ended_at.map(|t| t.to_rfc3339()),
219                session.model,
220                session.working_directory,
221                session.git_branch,
222                session.source_path,
223                session.message_count,
224            ],
225        )?;
226
227        // Insert into sessions_fts for metadata search (only on new inserts)
228        if rows_changed > 0 {
229            // Check if already in FTS (for ON CONFLICT case)
230            let fts_count: i32 = self.conn.query_row(
231                "SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
232                params![session.id.to_string()],
233                |row| row.get(0),
234            )?;
235
236            if fts_count == 0 {
237                self.conn.execute(
238                    "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
239                    params![
240                        session.id.to_string(),
241                        session.tool,
242                        session.working_directory,
243                        session.git_branch.as_deref().unwrap_or(""),
244                    ],
245                )?;
246            }
247        }
248
249        Ok(())
250    }
251
252    /// Retrieves a session by its unique ID.
253    ///
254    /// Returns `None` if no session with the given ID exists.
255    pub fn get_session(&self, id: &Uuid) -> Result<Option<Session>> {
256        self.conn
257            .query_row(
258                "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count FROM sessions WHERE id = ?1",
259                params![id.to_string()],
260                Self::row_to_session,
261            )
262            .optional()
263            .context("Failed to get session")
264    }
265
266    /// Lists sessions ordered by start time (most recent first).
267    ///
268    /// Optionally filters by working directory prefix. Returns at most
269    /// `limit` sessions.
270    pub fn list_sessions(&self, limit: usize, working_dir: Option<&str>) -> Result<Vec<Session>> {
271        let mut stmt = if working_dir.is_some() {
272            self.conn.prepare(
273                "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count 
274                 FROM sessions 
275                 WHERE working_directory LIKE ?1
276                 ORDER BY started_at DESC 
277                 LIMIT ?2"
278            )?
279        } else {
280            self.conn.prepare(
281                "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count 
282                 FROM sessions 
283                 ORDER BY started_at DESC 
284                 LIMIT ?1"
285            )?
286        };
287
288        let rows = if let Some(wd) = working_dir {
289            stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
290        } else {
291            stmt.query_map(params![limit], Self::row_to_session)?
292        };
293
294        rows.collect::<Result<Vec<_>, _>>()
295            .context("Failed to list sessions")
296    }
297
298    /// Checks if a session with the given source path already exists.
299    ///
300    /// Used to detect already-imported sessions during import operations.
301    pub fn session_exists_by_source(&self, source_path: &str) -> Result<bool> {
302        let count: i32 = self.conn.query_row(
303            "SELECT COUNT(*) FROM sessions WHERE source_path = ?1",
304            params![source_path],
305            |row| row.get(0),
306        )?;
307        Ok(count > 0)
308    }
309
310    fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<Session> {
311        let ended_at_str: Option<String> = row.get(4)?;
312        let ended_at = match ended_at_str {
313            Some(s) => Some(parse_datetime(&s)?),
314            None => None,
315        };
316
317        Ok(Session {
318            id: parse_uuid(&row.get::<_, String>(0)?)?,
319            tool: row.get(1)?,
320            tool_version: row.get(2)?,
321            started_at: parse_datetime(&row.get::<_, String>(3)?)?,
322            ended_at,
323            model: row.get(5)?,
324            working_directory: row.get(6)?,
325            git_branch: row.get(7)?,
326            source_path: row.get(8)?,
327            message_count: row.get(9)?,
328        })
329    }
330
331    // ==================== Messages ====================
332
333    /// Inserts a message into the database.
334    ///
335    /// If a message with the same ID already exists, the insert is ignored.
336    /// Message content is serialized to JSON for storage. Also inserts
337    /// extracted text content into the FTS index for full-text search.
338    pub fn insert_message(&self, message: &Message) -> Result<()> {
339        let content_json = serde_json::to_string(&message.content)?;
340
341        let rows_changed = self.conn.execute(
342            r#"
343            INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
344            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
345            ON CONFLICT(id) DO NOTHING
346            "#,
347            params![
348                message.id.to_string(),
349                message.session_id.to_string(),
350                message.parent_id.map(|u| u.to_string()),
351                message.index,
352                message.timestamp.to_rfc3339(),
353                message.role.to_string(),
354                content_json,
355                message.model,
356                message.git_branch,
357                message.cwd,
358            ],
359        )?;
360
361        // Only insert into FTS if the message was actually inserted (not a duplicate)
362        if rows_changed > 0 {
363            let text_content = message.content.text();
364            if !text_content.is_empty() {
365                self.conn.execute(
366                    "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
367                    params![message.id.to_string(), text_content],
368                )?;
369            }
370        }
371
372        Ok(())
373    }
374
375    /// Retrieves all messages for a session, ordered by index.
376    ///
377    /// Messages are returned in conversation order (by their `index` field).
378    pub fn get_messages(&self, session_id: &Uuid) -> Result<Vec<Message>> {
379        let mut stmt = self.conn.prepare(
380            "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd 
381             FROM messages 
382             WHERE session_id = ?1 
383             ORDER BY idx"
384        )?;
385
386        let rows = stmt.query_map(params![session_id.to_string()], |row| {
387            let role_str: String = row.get(5)?;
388            let content_str: String = row.get(6)?;
389
390            let parent_id_str: Option<String> = row.get(2)?;
391            let parent_id = match parent_id_str {
392                Some(s) => Some(parse_uuid(&s)?),
393                None => None,
394            };
395
396            Ok(Message {
397                id: parse_uuid(&row.get::<_, String>(0)?)?,
398                session_id: parse_uuid(&row.get::<_, String>(1)?)?,
399                parent_id,
400                index: row.get(3)?,
401                timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
402                role: match role_str.as_str() {
403                    "user" => MessageRole::User,
404                    "assistant" => MessageRole::Assistant,
405                    "system" => MessageRole::System,
406                    _ => MessageRole::User,
407                },
408                content: serde_json::from_str(&content_str)
409                    .unwrap_or(MessageContent::Text(content_str)),
410                model: row.get(7)?,
411                git_branch: row.get(8)?,
412                cwd: row.get(9)?,
413            })
414        })?;
415
416        rows.collect::<Result<Vec<_>, _>>()
417            .context("Failed to get messages")
418    }
419
420    // ==================== Session Links ====================
421
422    /// Inserts a link between a session and a git commit.
423    ///
424    /// Links can be created manually by users or automatically by
425    /// the auto-linking system based on time and file overlap heuristics.
426    pub fn insert_link(&self, link: &SessionLink) -> Result<()> {
427        self.conn.execute(
428            r#"
429            INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
430            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
431            "#,
432            params![
433                link.id.to_string(),
434                link.session_id.to_string(),
435                format!("{:?}", link.link_type).to_lowercase(),
436                link.commit_sha,
437                link.branch,
438                link.remote,
439                link.created_at.to_rfc3339(),
440                format!("{:?}", link.created_by).to_lowercase(),
441                link.confidence,
442            ],
443        )?;
444        Ok(())
445    }
446
447    /// Retrieves all session links for a commit.
448    ///
449    /// Supports prefix matching on the commit SHA, allowing short SHAs
450    /// (e.g., first 8 characters) to be used for lookup.
451    pub fn get_links_by_commit(&self, commit_sha: &str) -> Result<Vec<SessionLink>> {
452        let mut stmt = self.conn.prepare(
453            "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence 
454             FROM session_links 
455             WHERE commit_sha LIKE ?1"
456        )?;
457
458        let pattern = format!("{commit_sha}%");
459        let rows = stmt.query_map(params![pattern], Self::row_to_link)?;
460
461        rows.collect::<Result<Vec<_>, _>>()
462            .context("Failed to get links")
463    }
464
465    /// Retrieves all links associated with a session.
466    ///
467    /// A session can be linked to multiple commits if it spans
468    /// several git operations.
469    pub fn get_links_by_session(&self, session_id: &Uuid) -> Result<Vec<SessionLink>> {
470        let mut stmt = self.conn.prepare(
471            "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence 
472             FROM session_links 
473             WHERE session_id = ?1"
474        )?;
475
476        let rows = stmt.query_map(params![session_id.to_string()], Self::row_to_link)?;
477
478        rows.collect::<Result<Vec<_>, _>>()
479            .context("Failed to get links")
480    }
481
482    fn row_to_link(row: &rusqlite::Row) -> rusqlite::Result<SessionLink> {
483        use super::models::{LinkCreator, LinkType};
484
485        let link_type_str: String = row.get(2)?;
486        let created_by_str: String = row.get(7)?;
487
488        Ok(SessionLink {
489            id: parse_uuid(&row.get::<_, String>(0)?)?,
490            session_id: parse_uuid(&row.get::<_, String>(1)?)?,
491            link_type: match link_type_str.as_str() {
492                "commit" => LinkType::Commit,
493                "branch" => LinkType::Branch,
494                "pr" => LinkType::Pr,
495                _ => LinkType::Manual,
496            },
497            commit_sha: row.get(3)?,
498            branch: row.get(4)?,
499            remote: row.get(5)?,
500            created_at: parse_datetime(&row.get::<_, String>(6)?)?,
501            created_by: match created_by_str.as_str() {
502                "auto" => LinkCreator::Auto,
503                _ => LinkCreator::User,
504            },
505            confidence: row.get(8)?,
506        })
507    }
508
509    /// Deletes a specific session link by its ID.
510    ///
511    /// Returns `true` if a link was deleted, `false` if no link with that ID existed.
512    ///
513    /// Note: This method is part of the public API for programmatic use,
514    /// though the CLI currently uses session/commit-based deletion.
515    #[allow(dead_code)]
516    pub fn delete_link(&self, link_id: &Uuid) -> Result<bool> {
517        let rows_affected = self.conn.execute(
518            "DELETE FROM session_links WHERE id = ?1",
519            params![link_id.to_string()],
520        )?;
521        Ok(rows_affected > 0)
522    }
523
524    /// Deletes all links for a session.
525    ///
526    /// Returns the number of links deleted.
527    pub fn delete_links_by_session(&self, session_id: &Uuid) -> Result<usize> {
528        let rows_affected = self.conn.execute(
529            "DELETE FROM session_links WHERE session_id = ?1",
530            params![session_id.to_string()],
531        )?;
532        Ok(rows_affected)
533    }
534
535    /// Deletes a link between a specific session and commit.
536    ///
537    /// The commit_sha is matched as a prefix, so short SHAs work.
538    /// Returns `true` if a link was deleted, `false` if no matching link existed.
539    pub fn delete_link_by_session_and_commit(
540        &self,
541        session_id: &Uuid,
542        commit_sha: &str,
543    ) -> Result<bool> {
544        let pattern = format!("{commit_sha}%");
545        let rows_affected = self.conn.execute(
546            "DELETE FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
547            params![session_id.to_string(), pattern],
548        )?;
549        Ok(rows_affected > 0)
550    }
551
552    // ==================== Search ====================
553
554    /// Searches message content using full-text search.
555    ///
556    /// Uses SQLite FTS5 to search for messages matching the query.
557    /// Returns results ordered by FTS5 relevance ranking.
558    ///
559    /// Optional filters:
560    /// - `working_dir`: Filter by working directory prefix
561    /// - `since`: Filter by minimum timestamp
562    /// - `role`: Filter by message role
563    ///
564    /// Note: This is the legacy search API. For new code, use `search_with_options`.
565    #[allow(dead_code)]
566    pub fn search_messages(
567        &self,
568        query: &str,
569        limit: usize,
570        working_dir: Option<&str>,
571        since: Option<chrono::DateTime<chrono::Utc>>,
572        role: Option<&str>,
573    ) -> Result<Vec<SearchResult>> {
574        use super::models::SearchOptions;
575
576        // Convert to SearchOptions and use the new method
577        let options = SearchOptions {
578            query: query.to_string(),
579            limit,
580            repo: working_dir.map(|s| s.to_string()),
581            since,
582            role: role.map(|s| s.to_string()),
583            ..Default::default()
584        };
585
586        self.search_with_options(&options)
587    }
588
589    /// Searches messages and session metadata using full-text search with filters.
590    ///
591    /// Uses SQLite FTS5 to search for messages matching the query.
592    /// Also searches session metadata (tool, project, branch) via sessions_fts.
593    /// Returns results ordered by FTS5 relevance ranking.
594    ///
595    /// Supports extensive filtering via SearchOptions:
596    /// - `tool`: Filter by AI tool name
597    /// - `since`/`until`: Filter by date range
598    /// - `project`: Filter by project name (partial match)
599    /// - `branch`: Filter by git branch (partial match)
600    /// - `role`: Filter by message role
601    /// - `repo`: Filter by working directory prefix
602    pub fn search_with_options(
603        &self,
604        options: &super::models::SearchOptions,
605    ) -> Result<Vec<SearchResult>> {
606        // Escape the query for FTS5 to handle special characters
607        let escaped_query = escape_fts5_query(&options.query);
608
609        // Build the query dynamically based on filters
610        // Use UNION to search both message content and session metadata
611        let mut sql = String::from(
612            r#"
613            SELECT
614                m.session_id,
615                m.id as message_id,
616                m.role,
617                snippet(messages_fts, 1, '**', '**', '...', 32) as snippet,
618                m.timestamp,
619                s.working_directory,
620                s.tool,
621                s.git_branch,
622                s.message_count,
623                s.started_at,
624                m.idx as message_index
625            FROM messages_fts fts
626            JOIN messages m ON fts.message_id = m.id
627            JOIN sessions s ON m.session_id = s.id
628            WHERE messages_fts MATCH ?1
629            "#,
630        );
631
632        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(escaped_query.clone())];
633        let mut param_idx = 2;
634
635        // Add filters
636        if options.repo.is_some() {
637            sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
638            param_idx += 1;
639        }
640        if options.tool.is_some() {
641            sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
642            param_idx += 1;
643        }
644        if options.since.is_some() {
645            sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
646            param_idx += 1;
647        }
648        if options.until.is_some() {
649            sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
650            param_idx += 1;
651        }
652        if options.project.is_some() {
653            sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
654            param_idx += 1;
655        }
656        if options.branch.is_some() {
657            sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
658            param_idx += 1;
659        }
660        if options.role.is_some() {
661            sql.push_str(&format!(" AND m.role = ?{param_idx}"));
662            param_idx += 1;
663        }
664
665        // Build first SELECT parameter list (after the FTS query param which is already in params_vec)
666        if let Some(ref wd) = options.repo {
667            params_vec.push(Box::new(format!("{wd}%")));
668        }
669        if let Some(ref tool) = options.tool {
670            params_vec.push(Box::new(tool.clone()));
671        }
672        if let Some(ts) = options.since {
673            params_vec.push(Box::new(ts.to_rfc3339()));
674        }
675        if let Some(ts) = options.until {
676            params_vec.push(Box::new(ts.to_rfc3339()));
677        }
678        if let Some(ref project) = options.project {
679            params_vec.push(Box::new(format!("%{project}%")));
680        }
681        if let Some(ref branch) = options.branch {
682            params_vec.push(Box::new(format!("%{branch}%")));
683        }
684        if let Some(ref role) = options.role {
685            params_vec.push(Box::new(role.clone()));
686        }
687
688        // Add UNION for session metadata search (only if not filtering by role)
689        // This finds sessions where the metadata matches, returning the first message as representative
690        // Uses LIKE patterns instead of FTS5 for metadata since paths contain special characters
691        let include_metadata_search = options.role.is_none();
692        let metadata_query_pattern = format!("%{}%", options.query);
693
694        if include_metadata_search {
695            // For the metadata search, we need 3 separate params for the OR conditions
696            let meta_param1 = param_idx;
697            let meta_param2 = param_idx + 1;
698            let meta_param3 = param_idx + 2;
699            param_idx += 3;
700
701            sql.push_str(&format!(
702                r#"
703            UNION
704            SELECT
705                s.id as session_id,
706                (SELECT id FROM messages WHERE session_id = s.id ORDER BY idx LIMIT 1) as message_id,
707                'user' as role,
708                substr(s.tool || ' session in ' || s.working_directory || COALESCE(' on branch ' || s.git_branch, ''), 1, 100) as snippet,
709                s.started_at as timestamp,
710                s.working_directory,
711                s.tool,
712                s.git_branch,
713                s.message_count,
714                s.started_at,
715                0 as message_index
716            FROM sessions s
717            WHERE (
718                s.tool LIKE ?{meta_param1}
719                OR s.working_directory LIKE ?{meta_param2}
720                OR s.git_branch LIKE ?{meta_param3}
721            )
722            "#
723            ));
724
725            // Add metadata patterns to params
726            params_vec.push(Box::new(metadata_query_pattern.clone()));
727            params_vec.push(Box::new(metadata_query_pattern.clone()));
728            params_vec.push(Box::new(metadata_query_pattern));
729
730            // Re-apply session-level filters to the UNION query
731            if options.repo.is_some() {
732                sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
733                params_vec.push(Box::new(format!("{}%", options.repo.as_ref().unwrap())));
734                param_idx += 1;
735            }
736            if options.tool.is_some() {
737                sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
738                params_vec.push(Box::new(options.tool.as_ref().unwrap().clone()));
739                param_idx += 1;
740            }
741            if options.since.is_some() {
742                sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
743                params_vec.push(Box::new(options.since.unwrap().to_rfc3339()));
744                param_idx += 1;
745            }
746            if options.until.is_some() {
747                sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
748                params_vec.push(Box::new(options.until.unwrap().to_rfc3339()));
749                param_idx += 1;
750            }
751            if options.project.is_some() {
752                sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
753                params_vec.push(Box::new(format!("%{}%", options.project.as_ref().unwrap())));
754                param_idx += 1;
755            }
756            if options.branch.is_some() {
757                sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
758                params_vec.push(Box::new(format!("%{}%", options.branch.as_ref().unwrap())));
759                param_idx += 1;
760            }
761        }
762
763        sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{param_idx}"));
764        params_vec.push(Box::new(options.limit as i64));
765
766        // Prepare and execute
767        let mut stmt = self.conn.prepare(&sql)?;
768        let params_refs: Vec<&dyn rusqlite::ToSql> =
769            params_vec.iter().map(|p| p.as_ref()).collect();
770
771        let rows = stmt.query_map(params_refs.as_slice(), |row| {
772            let role_str: String = row.get(2)?;
773            let git_branch: Option<String> = row.get(7)?;
774            let started_at_str: Option<String> = row.get(9)?;
775
776            Ok(SearchResult {
777                session_id: parse_uuid(&row.get::<_, String>(0)?)?,
778                message_id: parse_uuid(&row.get::<_, String>(1)?)?,
779                role: match role_str.as_str() {
780                    "user" => MessageRole::User,
781                    "assistant" => MessageRole::Assistant,
782                    "system" => MessageRole::System,
783                    _ => MessageRole::User,
784                },
785                snippet: row.get(3)?,
786                timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
787                working_directory: row.get(5)?,
788                tool: row.get(6)?,
789                git_branch,
790                session_message_count: row.get(8)?,
791                session_started_at: started_at_str.map(|s| parse_datetime(&s)).transpose()?,
792                message_index: row.get(10)?,
793            })
794        })?;
795
796        rows.collect::<Result<Vec<_>, _>>()
797            .context("Failed to search messages")
798    }
799
800    /// Gets messages around a specific message for context.
801    ///
802    /// Returns N messages before and N messages after the specified message,
803    /// useful for displaying search results with surrounding context.
804    pub fn get_context_messages(
805        &self,
806        session_id: &Uuid,
807        message_index: i32,
808        context_count: usize,
809    ) -> Result<(Vec<Message>, Vec<Message>)> {
810        // Get messages before
811        let mut before_stmt = self.conn.prepare(
812            "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
813             FROM messages
814             WHERE session_id = ?1 AND idx < ?2
815             ORDER BY idx DESC
816             LIMIT ?3",
817        )?;
818
819        let before_rows = before_stmt.query_map(
820            params![session_id.to_string(), message_index, context_count as i64],
821            Self::row_to_message,
822        )?;
823
824        let mut before: Vec<Message> = before_rows
825            .collect::<Result<Vec<_>, _>>()
826            .context("Failed to get before messages")?;
827        before.reverse(); // Put in chronological order
828
829        // Get messages after
830        let mut after_stmt = self.conn.prepare(
831            "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
832             FROM messages
833             WHERE session_id = ?1 AND idx > ?2
834             ORDER BY idx ASC
835             LIMIT ?3",
836        )?;
837
838        let after_rows = after_stmt.query_map(
839            params![session_id.to_string(), message_index, context_count as i64],
840            Self::row_to_message,
841        )?;
842
843        let after: Vec<Message> = after_rows
844            .collect::<Result<Vec<_>, _>>()
845            .context("Failed to get after messages")?;
846
847        Ok((before, after))
848    }
849
850    /// Gets a single message by its index within a session.
851    #[allow(dead_code)]
852    pub fn get_message_by_index(&self, session_id: &Uuid, index: i32) -> Result<Option<Message>> {
853        self.conn
854            .query_row(
855                "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
856                 FROM messages
857                 WHERE session_id = ?1 AND idx = ?2",
858                params![session_id.to_string(), index],
859                Self::row_to_message,
860            )
861            .optional()
862            .context("Failed to get message by index")
863    }
864
865    fn row_to_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
866        let role_str: String = row.get(5)?;
867        let content_str: String = row.get(6)?;
868
869        let parent_id_str: Option<String> = row.get(2)?;
870        let parent_id = match parent_id_str {
871            Some(s) => Some(parse_uuid(&s)?),
872            None => None,
873        };
874
875        Ok(Message {
876            id: parse_uuid(&row.get::<_, String>(0)?)?,
877            session_id: parse_uuid(&row.get::<_, String>(1)?)?,
878            parent_id,
879            index: row.get(3)?,
880            timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
881            role: match role_str.as_str() {
882                "user" => MessageRole::User,
883                "assistant" => MessageRole::Assistant,
884                "system" => MessageRole::System,
885                _ => MessageRole::User,
886            },
887            content: serde_json::from_str(&content_str)
888                .unwrap_or(MessageContent::Text(content_str)),
889            model: row.get(7)?,
890            git_branch: row.get(8)?,
891            cwd: row.get(9)?,
892        })
893    }
894
895    /// Rebuilds the full-text search index from existing messages and sessions.
896    ///
897    /// This should be called when:
898    /// - Upgrading from a database without FTS support
899    /// - The FTS index becomes corrupted or out of sync
900    ///
901    /// Returns the number of messages indexed.
902    pub fn rebuild_search_index(&self) -> Result<usize> {
903        // Clear existing FTS data
904        self.conn.execute("DELETE FROM messages_fts", [])?;
905        self.conn.execute("DELETE FROM sessions_fts", [])?;
906
907        // Reindex all messages
908        let mut msg_stmt = self.conn.prepare("SELECT id, content FROM messages")?;
909
910        let rows = msg_stmt.query_map([], |row| {
911            let id: String = row.get(0)?;
912            let content_json: String = row.get(1)?;
913            Ok((id, content_json))
914        })?;
915
916        let mut count = 0;
917        for row in rows {
918            let (id, content_json) = row?;
919            // Parse the content JSON and extract text
920            let content: MessageContent = serde_json::from_str(&content_json)
921                .unwrap_or(MessageContent::Text(content_json.clone()));
922            let text_content = content.text();
923
924            if !text_content.is_empty() {
925                self.conn.execute(
926                    "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
927                    params![id, text_content],
928                )?;
929                count += 1;
930            }
931        }
932
933        // Reindex all sessions for metadata search
934        let mut session_stmt = self
935            .conn
936            .prepare("SELECT id, tool, working_directory, git_branch FROM sessions")?;
937
938        let session_rows = session_stmt.query_map([], |row| {
939            let id: String = row.get(0)?;
940            let tool: String = row.get(1)?;
941            let working_directory: String = row.get(2)?;
942            let git_branch: Option<String> = row.get(3)?;
943            Ok((id, tool, working_directory, git_branch))
944        })?;
945
946        for row in session_rows {
947            let (id, tool, working_directory, git_branch) = row?;
948            self.conn.execute(
949                "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
950                params![id, tool, working_directory, git_branch.unwrap_or_default()],
951            )?;
952        }
953
954        Ok(count)
955    }
956
957    /// Checks if the search index needs rebuilding.
958    ///
959    /// Returns true if there are messages or sessions in the database but the FTS
960    /// indexes are empty, indicating data was imported before FTS was added.
961    pub fn search_index_needs_rebuild(&self) -> Result<bool> {
962        let message_count: i32 =
963            self.conn
964                .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
965
966        let msg_fts_count: i32 =
967            self.conn
968                .query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
969
970        let session_count: i32 =
971            self.conn
972                .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
973
974        let session_fts_count: i32 =
975            self.conn
976                .query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
977
978        // Rebuild needed if we have messages/sessions but either FTS index is empty
979        Ok((message_count > 0 && msg_fts_count == 0)
980            || (session_count > 0 && session_fts_count == 0))
981    }
982
983    // ==================== Stats ====================
984
985    /// Returns the total number of sessions in the database.
986    pub fn session_count(&self) -> Result<i32> {
987        let count: i32 = self
988            .conn
989            .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
990        Ok(count)
991    }
992
993    /// Returns the total number of messages across all sessions.
994    pub fn message_count(&self) -> Result<i32> {
995        let count: i32 = self
996            .conn
997            .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
998        Ok(count)
999    }
1000
1001    /// Returns the total number of session links in the database.
1002    pub fn link_count(&self) -> Result<i32> {
1003        let count: i32 = self
1004            .conn
1005            .query_row("SELECT COUNT(*) FROM session_links", [], |row| row.get(0))?;
1006        Ok(count)
1007    }
1008
1009    /// Returns the path to the database file, if available.
1010    ///
1011    /// Returns `None` for in-memory databases.
1012    pub fn db_path(&self) -> Option<std::path::PathBuf> {
1013        self.conn.path().map(std::path::PathBuf::from)
1014    }
1015
1016    // ==================== Auto-linking ====================
1017
1018    /// Finds sessions that were active around a commit time.
1019    ///
1020    /// A session is considered active if the commit time falls within the
1021    /// window before and after the session's time range (started_at to ended_at).
1022    ///
1023    /// # Arguments
1024    ///
1025    /// * `commit_time` - The timestamp of the commit
1026    /// * `window_minutes` - The window in minutes before/after the session
1027    /// * `working_dir` - Optional working directory filter (prefix match)
1028    ///
1029    /// # Returns
1030    ///
1031    /// Sessions that were active near the commit time, ordered by proximity.
1032    pub fn find_sessions_near_commit_time(
1033        &self,
1034        commit_time: chrono::DateTime<chrono::Utc>,
1035        window_minutes: i64,
1036        working_dir: Option<&str>,
1037    ) -> Result<Vec<Session>> {
1038        // Convert commit time to RFC3339 for SQLite comparison
1039        let commit_time_str = commit_time.to_rfc3339();
1040
1041        // Calculate the time window boundaries
1042        let window = chrono::Duration::minutes(window_minutes);
1043        let window_start = (commit_time - window).to_rfc3339();
1044        let window_end = (commit_time + window).to_rfc3339();
1045
1046        let sql = if working_dir.is_some() {
1047            r#"
1048            SELECT id, tool, tool_version, started_at, ended_at, model,
1049                   working_directory, git_branch, source_path, message_count
1050            FROM sessions
1051            WHERE working_directory LIKE ?1
1052              AND (
1053                  -- Session started before or during the window
1054                  (started_at <= ?3)
1055                  AND
1056                  -- Session ended after or during the window (or is still ongoing)
1057                  (ended_at IS NULL OR ended_at >= ?2)
1058              )
1059            ORDER BY
1060              -- Order by how close the session end (or start) is to commit time
1061              ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?4))
1062            "#
1063        } else {
1064            r#"
1065            SELECT id, tool, tool_version, started_at, ended_at, model,
1066                   working_directory, git_branch, source_path, message_count
1067            FROM sessions
1068            WHERE
1069              -- Session started before or during the window
1070              (started_at <= ?2)
1071              AND
1072              -- Session ended after or during the window (or is still ongoing)
1073              (ended_at IS NULL OR ended_at >= ?1)
1074            ORDER BY
1075              -- Order by how close the session end (or start) is to commit time
1076              ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?3))
1077            "#
1078        };
1079
1080        let mut stmt = self.conn.prepare(sql)?;
1081
1082        let rows = if let Some(wd) = working_dir {
1083            stmt.query_map(
1084                params![format!("{wd}%"), window_start, window_end, commit_time_str],
1085                Self::row_to_session,
1086            )?
1087        } else {
1088            stmt.query_map(
1089                params![window_start, window_end, commit_time_str],
1090                Self::row_to_session,
1091            )?
1092        };
1093
1094        rows.collect::<Result<Vec<_>, _>>()
1095            .context("Failed to find sessions near commit time")
1096    }
1097
1098    /// Checks if a link already exists between a session and commit.
1099    ///
1100    /// Used to avoid creating duplicate links during auto-linking.
1101    pub fn link_exists(&self, session_id: &Uuid, commit_sha: &str) -> Result<bool> {
1102        let pattern = format!("{commit_sha}%");
1103        let count: i32 = self.conn.query_row(
1104            "SELECT COUNT(*) FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1105            params![session_id.to_string(), pattern],
1106            |row| row.get(0),
1107        )?;
1108        Ok(count > 0)
1109    }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use crate::storage::models::{
1116        LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
1117    };
1118    use chrono::{Duration, Utc};
1119    use tempfile::tempdir;
1120
1121    /// Creates a test database in a temporary directory.
1122    /// Returns the Database instance and the temp directory (which must be kept alive).
1123    fn create_test_db() -> (Database, tempfile::TempDir) {
1124        let dir = tempdir().expect("Failed to create temp directory");
1125        let db_path = dir.path().join("test.db");
1126        let db = Database::open(&db_path).expect("Failed to open test database");
1127        (db, dir)
1128    }
1129
1130    /// Creates a test session with the given parameters.
1131    fn create_test_session(
1132        tool: &str,
1133        working_directory: &str,
1134        started_at: chrono::DateTime<Utc>,
1135        source_path: Option<&str>,
1136    ) -> Session {
1137        Session {
1138            id: Uuid::new_v4(),
1139            tool: tool.to_string(),
1140            tool_version: Some("1.0.0".to_string()),
1141            started_at,
1142            ended_at: None,
1143            model: Some("test-model".to_string()),
1144            working_directory: working_directory.to_string(),
1145            git_branch: Some("main".to_string()),
1146            source_path: source_path.map(|s| s.to_string()),
1147            message_count: 0,
1148        }
1149    }
1150
1151    /// Creates a test message for the given session.
1152    fn create_test_message(
1153        session_id: Uuid,
1154        index: i32,
1155        role: MessageRole,
1156        content: &str,
1157    ) -> Message {
1158        Message {
1159            id: Uuid::new_v4(),
1160            session_id,
1161            parent_id: None,
1162            index,
1163            timestamp: Utc::now(),
1164            role,
1165            content: MessageContent::Text(content.to_string()),
1166            model: Some("test-model".to_string()),
1167            git_branch: Some("main".to_string()),
1168            cwd: Some("/test/cwd".to_string()),
1169        }
1170    }
1171
1172    /// Creates a test session link for the given session.
1173    fn create_test_link(
1174        session_id: Uuid,
1175        commit_sha: Option<&str>,
1176        link_type: LinkType,
1177    ) -> SessionLink {
1178        SessionLink {
1179            id: Uuid::new_v4(),
1180            session_id,
1181            link_type,
1182            commit_sha: commit_sha.map(|s| s.to_string()),
1183            branch: Some("main".to_string()),
1184            remote: Some("origin".to_string()),
1185            created_at: Utc::now(),
1186            created_by: LinkCreator::Auto,
1187            confidence: Some(0.95),
1188        }
1189    }
1190
1191    // ==================== Session Tests ====================
1192
1193    #[test]
1194    fn test_insert_and_get_session() {
1195        let (db, _dir) = create_test_db();
1196        let session = create_test_session(
1197            "claude-code",
1198            "/home/user/project",
1199            Utc::now(),
1200            Some("/path/to/source.jsonl"),
1201        );
1202
1203        db.insert_session(&session)
1204            .expect("Failed to insert session");
1205
1206        let retrieved = db
1207            .get_session(&session.id)
1208            .expect("Failed to get session")
1209            .expect("Session should exist");
1210
1211        assert_eq!(retrieved.id, session.id, "Session ID should match");
1212        assert_eq!(retrieved.tool, session.tool, "Tool should match");
1213        assert_eq!(
1214            retrieved.tool_version, session.tool_version,
1215            "Tool version should match"
1216        );
1217        assert_eq!(
1218            retrieved.working_directory, session.working_directory,
1219            "Working directory should match"
1220        );
1221        assert_eq!(
1222            retrieved.git_branch, session.git_branch,
1223            "Git branch should match"
1224        );
1225        assert_eq!(
1226            retrieved.source_path, session.source_path,
1227            "Source path should match"
1228        );
1229    }
1230
1231    #[test]
1232    fn test_list_sessions() {
1233        let (db, _dir) = create_test_db();
1234        let now = Utc::now();
1235
1236        // Insert sessions with different timestamps (oldest first)
1237        let session1 =
1238            create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
1239        let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
1240        let session3 = create_test_session("claude-code", "/project3", now, None);
1241
1242        db.insert_session(&session1)
1243            .expect("Failed to insert session1");
1244        db.insert_session(&session2)
1245            .expect("Failed to insert session2");
1246        db.insert_session(&session3)
1247            .expect("Failed to insert session3");
1248
1249        let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
1250
1251        assert_eq!(sessions.len(), 3, "Should have 3 sessions");
1252        // Sessions should be ordered by started_at DESC (most recent first)
1253        assert_eq!(
1254            sessions[0].id, session3.id,
1255            "Most recent session should be first"
1256        );
1257        assert_eq!(
1258            sessions[1].id, session2.id,
1259            "Second most recent session should be second"
1260        );
1261        assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
1262    }
1263
1264    #[test]
1265    fn test_list_sessions_with_working_dir_filter() {
1266        let (db, _dir) = create_test_db();
1267        let now = Utc::now();
1268
1269        let session1 = create_test_session(
1270            "claude-code",
1271            "/home/user/project-a",
1272            now - Duration::hours(1),
1273            None,
1274        );
1275        let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
1276        let session3 = create_test_session("claude-code", "/other/path", now, None);
1277
1278        db.insert_session(&session1)
1279            .expect("Failed to insert session1");
1280        db.insert_session(&session2)
1281            .expect("Failed to insert session2");
1282        db.insert_session(&session3)
1283            .expect("Failed to insert session3");
1284
1285        // Filter by working directory prefix
1286        let sessions = db
1287            .list_sessions(10, Some("/home/user"))
1288            .expect("Failed to list sessions");
1289
1290        assert_eq!(
1291            sessions.len(),
1292            2,
1293            "Should have 2 sessions matching /home/user prefix"
1294        );
1295
1296        // Verify both matching sessions are returned
1297        let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
1298        assert!(ids.contains(&session1.id), "Should contain session1");
1299        assert!(ids.contains(&session2.id), "Should contain session2");
1300        assert!(!ids.contains(&session3.id), "Should not contain session3");
1301    }
1302
1303    #[test]
1304    fn test_session_exists_by_source() {
1305        let (db, _dir) = create_test_db();
1306        let source_path = "/path/to/session.jsonl";
1307
1308        let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
1309
1310        // Before insert, should not exist
1311        assert!(
1312            !db.session_exists_by_source(source_path)
1313                .expect("Failed to check existence"),
1314            "Session should not exist before insert"
1315        );
1316
1317        db.insert_session(&session)
1318            .expect("Failed to insert session");
1319
1320        // After insert, should exist
1321        assert!(
1322            db.session_exists_by_source(source_path)
1323                .expect("Failed to check existence"),
1324            "Session should exist after insert"
1325        );
1326
1327        // Different path should not exist
1328        assert!(
1329            !db.session_exists_by_source("/other/path.jsonl")
1330                .expect("Failed to check existence"),
1331            "Different source path should not exist"
1332        );
1333    }
1334
1335    #[test]
1336    fn test_get_nonexistent_session() {
1337        let (db, _dir) = create_test_db();
1338        let nonexistent_id = Uuid::new_v4();
1339
1340        let result = db
1341            .get_session(&nonexistent_id)
1342            .expect("Failed to query for nonexistent session");
1343
1344        assert!(
1345            result.is_none(),
1346            "Should return None for nonexistent session"
1347        );
1348    }
1349
1350    // ==================== Message Tests ====================
1351
1352    #[test]
1353    fn test_insert_and_get_messages() {
1354        let (db, _dir) = create_test_db();
1355
1356        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1357        db.insert_session(&session)
1358            .expect("Failed to insert session");
1359
1360        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
1361        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
1362
1363        db.insert_message(&msg1)
1364            .expect("Failed to insert message 1");
1365        db.insert_message(&msg2)
1366            .expect("Failed to insert message 2");
1367
1368        let messages = db
1369            .get_messages(&session.id)
1370            .expect("Failed to get messages");
1371
1372        assert_eq!(messages.len(), 2, "Should have 2 messages");
1373        assert_eq!(messages[0].id, msg1.id, "First message ID should match");
1374        assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
1375        assert_eq!(
1376            messages[0].role,
1377            MessageRole::User,
1378            "First message role should be User"
1379        );
1380        assert_eq!(
1381            messages[1].role,
1382            MessageRole::Assistant,
1383            "Second message role should be Assistant"
1384        );
1385    }
1386
1387    #[test]
1388    fn test_messages_ordered_by_index() {
1389        let (db, _dir) = create_test_db();
1390
1391        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1392        db.insert_session(&session)
1393            .expect("Failed to insert session");
1394
1395        // Insert messages out of order
1396        let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
1397        let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
1398        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
1399
1400        db.insert_message(&msg3)
1401            .expect("Failed to insert message 3");
1402        db.insert_message(&msg1)
1403            .expect("Failed to insert message 1");
1404        db.insert_message(&msg2)
1405            .expect("Failed to insert message 2");
1406
1407        let messages = db
1408            .get_messages(&session.id)
1409            .expect("Failed to get messages");
1410
1411        assert_eq!(messages.len(), 3, "Should have 3 messages");
1412        assert_eq!(messages[0].index, 0, "First message should have index 0");
1413        assert_eq!(messages[1].index, 1, "Second message should have index 1");
1414        assert_eq!(messages[2].index, 2, "Third message should have index 2");
1415
1416        // Verify content matches expected order
1417        assert_eq!(
1418            messages[0].content.text(),
1419            "First",
1420            "First message content should be 'First'"
1421        );
1422        assert_eq!(
1423            messages[1].content.text(),
1424            "Second",
1425            "Second message content should be 'Second'"
1426        );
1427        assert_eq!(
1428            messages[2].content.text(),
1429            "Third",
1430            "Third message content should be 'Third'"
1431        );
1432    }
1433
1434    // ==================== SessionLink Tests ====================
1435
1436    #[test]
1437    fn test_insert_and_get_links_by_session() {
1438        let (db, _dir) = create_test_db();
1439
1440        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1441        db.insert_session(&session)
1442            .expect("Failed to insert session");
1443
1444        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
1445        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
1446
1447        db.insert_link(&link1).expect("Failed to insert link 1");
1448        db.insert_link(&link2).expect("Failed to insert link 2");
1449
1450        let links = db
1451            .get_links_by_session(&session.id)
1452            .expect("Failed to get links");
1453
1454        assert_eq!(links.len(), 2, "Should have 2 links");
1455
1456        let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
1457        assert!(link_ids.contains(&link1.id), "Should contain link1");
1458        assert!(link_ids.contains(&link2.id), "Should contain link2");
1459
1460        // Verify link properties
1461        let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
1462        assert_eq!(
1463            retrieved_link.commit_sha,
1464            Some("abc123def456".to_string()),
1465            "Commit SHA should match"
1466        );
1467        assert_eq!(
1468            retrieved_link.link_type,
1469            LinkType::Commit,
1470            "Link type should be Commit"
1471        );
1472        assert_eq!(
1473            retrieved_link.created_by,
1474            LinkCreator::Auto,
1475            "Created by should be Auto"
1476        );
1477    }
1478
1479    #[test]
1480    fn test_get_links_by_commit() {
1481        let (db, _dir) = create_test_db();
1482
1483        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1484        db.insert_session(&session)
1485            .expect("Failed to insert session");
1486
1487        let full_sha = "abc123def456789012345678901234567890abcd";
1488        let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
1489        db.insert_link(&link).expect("Failed to insert link");
1490
1491        // Test full SHA match
1492        let links_full = db
1493            .get_links_by_commit(full_sha)
1494            .expect("Failed to get links by full SHA");
1495        assert_eq!(links_full.len(), 1, "Should find link by full SHA");
1496        assert_eq!(links_full[0].id, link.id, "Link ID should match");
1497
1498        // Test partial SHA match (prefix)
1499        let links_partial = db
1500            .get_links_by_commit("abc123")
1501            .expect("Failed to get links by partial SHA");
1502        assert_eq!(
1503            links_partial.len(),
1504            1,
1505            "Should find link by partial SHA prefix"
1506        );
1507        assert_eq!(links_partial[0].id, link.id, "Link ID should match");
1508
1509        // Test non-matching SHA
1510        let links_none = db
1511            .get_links_by_commit("zzz999")
1512            .expect("Failed to get links by non-matching SHA");
1513        assert_eq!(
1514            links_none.len(),
1515            0,
1516            "Should not find link with non-matching SHA"
1517        );
1518    }
1519
1520    // ==================== Database Tests ====================
1521
1522    #[test]
1523    fn test_database_creation() {
1524        let dir = tempdir().expect("Failed to create temp directory");
1525        let db_path = dir.path().join("new_test.db");
1526
1527        // Database should not exist before creation
1528        assert!(
1529            !db_path.exists(),
1530            "Database file should not exist before creation"
1531        );
1532
1533        let db = Database::open(&db_path).expect("Failed to create database");
1534
1535        // Database file should exist after creation
1536        assert!(
1537            db_path.exists(),
1538            "Database file should exist after creation"
1539        );
1540
1541        // Verify tables exist by attempting operations
1542        let session_count = db.session_count().expect("Failed to get session count");
1543        assert_eq!(session_count, 0, "New database should have 0 sessions");
1544
1545        let message_count = db.message_count().expect("Failed to get message count");
1546        assert_eq!(message_count, 0, "New database should have 0 messages");
1547    }
1548
1549    #[test]
1550    fn test_session_count() {
1551        let (db, _dir) = create_test_db();
1552
1553        assert_eq!(
1554            db.session_count().expect("Failed to get count"),
1555            0,
1556            "Initial session count should be 0"
1557        );
1558
1559        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
1560        db.insert_session(&session1)
1561            .expect("Failed to insert session1");
1562
1563        assert_eq!(
1564            db.session_count().expect("Failed to get count"),
1565            1,
1566            "Session count should be 1 after first insert"
1567        );
1568
1569        let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
1570        db.insert_session(&session2)
1571            .expect("Failed to insert session2");
1572
1573        assert_eq!(
1574            db.session_count().expect("Failed to get count"),
1575            2,
1576            "Session count should be 2 after second insert"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_message_count() {
1582        let (db, _dir) = create_test_db();
1583
1584        assert_eq!(
1585            db.message_count().expect("Failed to get count"),
1586            0,
1587            "Initial message count should be 0"
1588        );
1589
1590        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1591        db.insert_session(&session)
1592            .expect("Failed to insert session");
1593
1594        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
1595        db.insert_message(&msg1).expect("Failed to insert message1");
1596
1597        assert_eq!(
1598            db.message_count().expect("Failed to get count"),
1599            1,
1600            "Message count should be 1 after first insert"
1601        );
1602
1603        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
1604        let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
1605        db.insert_message(&msg2).expect("Failed to insert message2");
1606        db.insert_message(&msg3).expect("Failed to insert message3");
1607
1608        assert_eq!(
1609            db.message_count().expect("Failed to get count"),
1610            3,
1611            "Message count should be 3 after all inserts"
1612        );
1613    }
1614
1615    #[test]
1616    fn test_link_count() {
1617        let (db, _dir) = create_test_db();
1618
1619        assert_eq!(
1620            db.link_count().expect("Failed to get count"),
1621            0,
1622            "Initial link count should be 0"
1623        );
1624
1625        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1626        db.insert_session(&session)
1627            .expect("Failed to insert session");
1628
1629        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
1630        db.insert_link(&link1).expect("Failed to insert link1");
1631
1632        assert_eq!(
1633            db.link_count().expect("Failed to get count"),
1634            1,
1635            "Link count should be 1 after first insert"
1636        );
1637
1638        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
1639        db.insert_link(&link2).expect("Failed to insert link2");
1640
1641        assert_eq!(
1642            db.link_count().expect("Failed to get count"),
1643            2,
1644            "Link count should be 2 after second insert"
1645        );
1646    }
1647
1648    #[test]
1649    fn test_db_path() {
1650        let dir = tempdir().expect("Failed to create temp directory");
1651        let db_path = dir.path().join("test.db");
1652        let db = Database::open(&db_path).expect("Failed to open test database");
1653
1654        let retrieved_path = db.db_path();
1655        assert!(
1656            retrieved_path.is_some(),
1657            "Database path should be available"
1658        );
1659
1660        // Canonicalize both paths to handle macOS /var -> /private/var symlinks
1661        let expected = db_path.canonicalize().unwrap_or(db_path);
1662        let actual = retrieved_path.unwrap();
1663        let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
1664
1665        assert_eq!(
1666            actual_canonical, expected,
1667            "Database path should match (after canonicalization)"
1668        );
1669    }
1670
1671    // ==================== Search Tests ====================
1672
1673    #[test]
1674    fn test_search_messages_basic() {
1675        let (db, _dir) = create_test_db();
1676
1677        let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
1678        db.insert_session(&session)
1679            .expect("Failed to insert session");
1680
1681        let msg1 = create_test_message(
1682            session.id,
1683            0,
1684            MessageRole::User,
1685            "How do I implement error handling in Rust?",
1686        );
1687        let msg2 = create_test_message(
1688            session.id,
1689            1,
1690            MessageRole::Assistant,
1691            "You can use Result types for error handling. The anyhow crate is also helpful.",
1692        );
1693
1694        db.insert_message(&msg1)
1695            .expect("Failed to insert message 1");
1696        db.insert_message(&msg2)
1697            .expect("Failed to insert message 2");
1698
1699        // Search for "error"
1700        let results = db
1701            .search_messages("error", 10, None, None, None)
1702            .expect("Failed to search");
1703
1704        assert_eq!(
1705            results.len(),
1706            2,
1707            "Should find 2 messages containing 'error'"
1708        );
1709    }
1710
1711    #[test]
1712    fn test_search_messages_no_results() {
1713        let (db, _dir) = create_test_db();
1714
1715        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1716        db.insert_session(&session)
1717            .expect("Failed to insert session");
1718
1719        let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
1720        db.insert_message(&msg).expect("Failed to insert message");
1721
1722        // Search for something not in the messages
1723        let results = db
1724            .search_messages("nonexistent_term_xyz", 10, None, None, None)
1725            .expect("Failed to search");
1726
1727        assert!(results.is_empty(), "Should find no results");
1728    }
1729
1730    #[test]
1731    fn test_search_messages_with_role_filter() {
1732        let (db, _dir) = create_test_db();
1733
1734        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1735        db.insert_session(&session)
1736            .expect("Failed to insert session");
1737
1738        let msg1 = create_test_message(
1739            session.id,
1740            0,
1741            MessageRole::User,
1742            "Tell me about Rust programming",
1743        );
1744        let msg2 = create_test_message(
1745            session.id,
1746            1,
1747            MessageRole::Assistant,
1748            "Rust is a systems programming language",
1749        );
1750
1751        db.insert_message(&msg1)
1752            .expect("Failed to insert message 1");
1753        db.insert_message(&msg2)
1754            .expect("Failed to insert message 2");
1755
1756        // Search with user role filter
1757        let user_results = db
1758            .search_messages("programming", 10, None, None, Some("user"))
1759            .expect("Failed to search");
1760
1761        assert_eq!(user_results.len(), 1, "Should find 1 user message");
1762        assert_eq!(
1763            user_results[0].role,
1764            MessageRole::User,
1765            "Result should be from user"
1766        );
1767
1768        // Search with assistant role filter
1769        let assistant_results = db
1770            .search_messages("programming", 10, None, None, Some("assistant"))
1771            .expect("Failed to search");
1772
1773        assert_eq!(
1774            assistant_results.len(),
1775            1,
1776            "Should find 1 assistant message"
1777        );
1778        assert_eq!(
1779            assistant_results[0].role,
1780            MessageRole::Assistant,
1781            "Result should be from assistant"
1782        );
1783    }
1784
1785    #[test]
1786    fn test_search_messages_with_repo_filter() {
1787        let (db, _dir) = create_test_db();
1788
1789        let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
1790        let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
1791
1792        db.insert_session(&session1).expect("insert 1");
1793        db.insert_session(&session2).expect("insert 2");
1794
1795        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
1796        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
1797
1798        db.insert_message(&msg1).expect("insert msg 1");
1799        db.insert_message(&msg2).expect("insert msg 2");
1800
1801        // Search with repo filter
1802        let results = db
1803            .search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
1804            .expect("Failed to search");
1805
1806        assert_eq!(results.len(), 1, "Should find 1 message in project-a");
1807        assert!(
1808            results[0].working_directory.contains("project-a"),
1809            "Should be from project-a"
1810        );
1811    }
1812
1813    #[test]
1814    fn test_search_messages_limit() {
1815        let (db, _dir) = create_test_db();
1816
1817        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1818        db.insert_session(&session).expect("insert session");
1819
1820        // Insert 5 messages all containing "test"
1821        for i in 0..5 {
1822            let msg = create_test_message(
1823                session.id,
1824                i,
1825                MessageRole::User,
1826                &format!("This is test message number {i}"),
1827            );
1828            db.insert_message(&msg).expect("insert message");
1829        }
1830
1831        // Search with limit of 3
1832        let results = db
1833            .search_messages("test", 3, None, None, None)
1834            .expect("Failed to search");
1835
1836        assert_eq!(results.len(), 3, "Should respect limit of 3");
1837    }
1838
1839    #[test]
1840    fn test_search_index_needs_rebuild_empty_db() {
1841        let (db, _dir) = create_test_db();
1842
1843        let needs_rebuild = db
1844            .search_index_needs_rebuild()
1845            .expect("Failed to check rebuild status");
1846
1847        assert!(!needs_rebuild, "Empty database should not need rebuild");
1848    }
1849
1850    #[test]
1851    fn test_rebuild_search_index() {
1852        let (db, _dir) = create_test_db();
1853
1854        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1855        db.insert_session(&session).expect("insert session");
1856
1857        let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
1858        let msg2 = create_test_message(
1859            session.id,
1860            1,
1861            MessageRole::Assistant,
1862            "Second test response",
1863        );
1864
1865        db.insert_message(&msg1).expect("insert msg 1");
1866        db.insert_message(&msg2).expect("insert msg 2");
1867
1868        // Clear and rebuild the index
1869        db.conn
1870            .execute("DELETE FROM messages_fts", [])
1871            .expect("clear fts");
1872
1873        // Index should now need rebuilding
1874        assert!(
1875            db.search_index_needs_rebuild().expect("check rebuild"),
1876            "Should need rebuild after clearing FTS"
1877        );
1878
1879        // Rebuild
1880        let count = db.rebuild_search_index().expect("rebuild");
1881        assert_eq!(count, 2, "Should have indexed 2 messages");
1882
1883        // Index should no longer need rebuilding
1884        assert!(
1885            !db.search_index_needs_rebuild().expect("check rebuild"),
1886            "Should not need rebuild after rebuilding"
1887        );
1888
1889        // Search should work
1890        let results = db
1891            .search_messages("test", 10, None, None, None)
1892            .expect("search");
1893        assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
1894    }
1895
1896    #[test]
1897    fn test_search_with_block_content() {
1898        let (db, _dir) = create_test_db();
1899
1900        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1901        db.insert_session(&session).expect("insert session");
1902
1903        // Create a message with block content
1904        let block_content = MessageContent::Blocks(vec![
1905            crate::storage::models::ContentBlock::Text {
1906                text: "Let me help with your database query.".to_string(),
1907            },
1908            crate::storage::models::ContentBlock::ToolUse {
1909                id: "tool_123".to_string(),
1910                name: "Bash".to_string(),
1911                input: serde_json::json!({"command": "ls -la"}),
1912            },
1913        ]);
1914
1915        let msg = Message {
1916            id: Uuid::new_v4(),
1917            session_id: session.id,
1918            parent_id: None,
1919            index: 0,
1920            timestamp: Utc::now(),
1921            role: MessageRole::Assistant,
1922            content: block_content,
1923            model: Some("claude-opus-4".to_string()),
1924            git_branch: Some("main".to_string()),
1925            cwd: Some("/project".to_string()),
1926        };
1927
1928        db.insert_message(&msg).expect("insert message");
1929
1930        // Search should find text from blocks
1931        let results = db
1932            .search_messages("database", 10, None, None, None)
1933            .expect("search");
1934
1935        assert_eq!(results.len(), 1, "Should find message with block content");
1936    }
1937
1938    #[test]
1939    fn test_search_result_contains_session_info() {
1940        let (db, _dir) = create_test_db();
1941
1942        let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
1943        db.insert_session(&session).expect("insert session");
1944
1945        let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
1946        db.insert_message(&msg).expect("insert message");
1947
1948        let results = db
1949            .search_messages("Search", 10, None, None, None)
1950            .expect("search");
1951
1952        assert_eq!(results.len(), 1, "Should find 1 result");
1953        assert_eq!(results[0].session_id, session.id, "Session ID should match");
1954        assert_eq!(results[0].message_id, msg.id, "Message ID should match");
1955        assert_eq!(
1956            results[0].working_directory, "/home/user/my-project",
1957            "Working directory should match"
1958        );
1959        assert_eq!(results[0].role, MessageRole::User, "Role should match");
1960    }
1961
1962    // ==================== Delete Link Tests ====================
1963
1964    #[test]
1965    fn test_delete_link_by_id() {
1966        let (db, _dir) = create_test_db();
1967
1968        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1969        db.insert_session(&session)
1970            .expect("Failed to insert session");
1971
1972        let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
1973        db.insert_link(&link).expect("Failed to insert link");
1974
1975        // Verify link exists
1976        let links_before = db
1977            .get_links_by_session(&session.id)
1978            .expect("Failed to get links");
1979        assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
1980
1981        // Delete the link
1982        let deleted = db.delete_link(&link.id).expect("Failed to delete link");
1983        assert!(deleted, "Should return true when link is deleted");
1984
1985        // Verify link is gone
1986        let links_after = db
1987            .get_links_by_session(&session.id)
1988            .expect("Failed to get links");
1989        assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
1990    }
1991
1992    #[test]
1993    fn test_delete_link_nonexistent() {
1994        let (db, _dir) = create_test_db();
1995
1996        let nonexistent_id = Uuid::new_v4();
1997        let deleted = db
1998            .delete_link(&nonexistent_id)
1999            .expect("Failed to call delete_link");
2000
2001        assert!(!deleted, "Should return false for nonexistent link");
2002    }
2003
2004    #[test]
2005    fn test_delete_links_by_session() {
2006        let (db, _dir) = create_test_db();
2007
2008        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2009        db.insert_session(&session)
2010            .expect("Failed to insert session");
2011
2012        // Create multiple links for the same session
2013        let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
2014        let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
2015        let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
2016
2017        db.insert_link(&link1).expect("Failed to insert link1");
2018        db.insert_link(&link2).expect("Failed to insert link2");
2019        db.insert_link(&link3).expect("Failed to insert link3");
2020
2021        // Verify all links exist
2022        let links_before = db
2023            .get_links_by_session(&session.id)
2024            .expect("Failed to get links");
2025        assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
2026
2027        // Delete all links for the session
2028        let count = db
2029            .delete_links_by_session(&session.id)
2030            .expect("Failed to delete links");
2031        assert_eq!(count, 3, "Should have deleted 3 links");
2032
2033        // Verify all links are gone
2034        let links_after = db
2035            .get_links_by_session(&session.id)
2036            .expect("Failed to get links");
2037        assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
2038    }
2039
2040    #[test]
2041    fn test_delete_links_by_session_no_links() {
2042        let (db, _dir) = create_test_db();
2043
2044        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2045        db.insert_session(&session)
2046            .expect("Failed to insert session");
2047
2048        // Delete links for session that has none
2049        let count = db
2050            .delete_links_by_session(&session.id)
2051            .expect("Failed to call delete_links_by_session");
2052        assert_eq!(count, 0, "Should return 0 when no links exist");
2053    }
2054
2055    #[test]
2056    fn test_delete_links_by_session_preserves_other_sessions() {
2057        let (db, _dir) = create_test_db();
2058
2059        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2060        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2061
2062        db.insert_session(&session1)
2063            .expect("Failed to insert session1");
2064        db.insert_session(&session2)
2065            .expect("Failed to insert session2");
2066
2067        let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2068        let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
2069
2070        db.insert_link(&link1).expect("Failed to insert link1");
2071        db.insert_link(&link2).expect("Failed to insert link2");
2072
2073        // Delete links only for session1
2074        let count = db
2075            .delete_links_by_session(&session1.id)
2076            .expect("Failed to delete links");
2077        assert_eq!(count, 1, "Should have deleted 1 link");
2078
2079        // Verify session2's link is preserved
2080        let session2_links = db
2081            .get_links_by_session(&session2.id)
2082            .expect("Failed to get links");
2083        assert_eq!(
2084            session2_links.len(),
2085            1,
2086            "Session2's link should be preserved"
2087        );
2088        assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
2089    }
2090
2091    #[test]
2092    fn test_delete_link_by_session_and_commit() {
2093        let (db, _dir) = create_test_db();
2094
2095        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2096        db.insert_session(&session)
2097            .expect("Failed to insert session");
2098
2099        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2100        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2101
2102        db.insert_link(&link1).expect("Failed to insert link1");
2103        db.insert_link(&link2).expect("Failed to insert link2");
2104
2105        // Delete only the first link by commit SHA
2106        let deleted = db
2107            .delete_link_by_session_and_commit(&session.id, "abc123")
2108            .expect("Failed to delete link");
2109        assert!(deleted, "Should return true when link is deleted");
2110
2111        // Verify only link2 remains
2112        let links = db
2113            .get_links_by_session(&session.id)
2114            .expect("Failed to get links");
2115        assert_eq!(links.len(), 1, "Should have 1 link remaining");
2116        assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
2117    }
2118
2119    #[test]
2120    fn test_delete_link_by_session_and_commit_full_sha() {
2121        let (db, _dir) = create_test_db();
2122
2123        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2124        db.insert_session(&session)
2125            .expect("Failed to insert session");
2126
2127        let full_sha = "abc123def456789012345678901234567890abcd";
2128        let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
2129        db.insert_link(&link).expect("Failed to insert link");
2130
2131        // Delete using full SHA
2132        let deleted = db
2133            .delete_link_by_session_and_commit(&session.id, full_sha)
2134            .expect("Failed to delete link");
2135        assert!(deleted, "Should delete with full SHA");
2136
2137        let links = db
2138            .get_links_by_session(&session.id)
2139            .expect("Failed to get links");
2140        assert_eq!(links.len(), 0, "Should have 0 links after delete");
2141    }
2142
2143    #[test]
2144    fn test_delete_link_by_session_and_commit_no_match() {
2145        let (db, _dir) = create_test_db();
2146
2147        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2148        db.insert_session(&session)
2149            .expect("Failed to insert session");
2150
2151        let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
2152        db.insert_link(&link).expect("Failed to insert link");
2153
2154        // Try to delete with non-matching commit
2155        let deleted = db
2156            .delete_link_by_session_and_commit(&session.id, "xyz999")
2157            .expect("Failed to call delete");
2158        assert!(!deleted, "Should return false when no match");
2159
2160        // Verify original link is preserved
2161        let links = db
2162            .get_links_by_session(&session.id)
2163            .expect("Failed to get links");
2164        assert_eq!(links.len(), 1, "Link should be preserved");
2165    }
2166
2167    #[test]
2168    fn test_delete_link_by_session_and_commit_wrong_session() {
2169        let (db, _dir) = create_test_db();
2170
2171        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2172        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2173
2174        db.insert_session(&session1)
2175            .expect("Failed to insert session1");
2176        db.insert_session(&session2)
2177            .expect("Failed to insert session2");
2178
2179        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2180        db.insert_link(&link).expect("Failed to insert link");
2181
2182        // Try to delete from wrong session
2183        let deleted = db
2184            .delete_link_by_session_and_commit(&session2.id, "abc123")
2185            .expect("Failed to call delete");
2186        assert!(!deleted, "Should not delete link from different session");
2187
2188        // Verify original link is preserved
2189        let links = db
2190            .get_links_by_session(&session1.id)
2191            .expect("Failed to get links");
2192        assert_eq!(links.len(), 1, "Link should be preserved");
2193    }
2194
2195    // ==================== Auto-linking Tests ====================
2196
2197    #[test]
2198    fn test_find_sessions_near_commit_time_basic() {
2199        let (db, _dir) = create_test_db();
2200        let now = Utc::now();
2201
2202        // Create a session that ended 10 minutes ago
2203        let mut session = create_test_session(
2204            "claude-code",
2205            "/home/user/project",
2206            now - Duration::hours(1),
2207            None,
2208        );
2209        session.ended_at = Some(now - Duration::minutes(10));
2210
2211        db.insert_session(&session).expect("insert session");
2212
2213        // Find sessions near "now" with a 30 minute window
2214        let found = db
2215            .find_sessions_near_commit_time(now, 30, None)
2216            .expect("find sessions");
2217
2218        assert_eq!(found.len(), 1, "Should find session within window");
2219        assert_eq!(found[0].id, session.id);
2220    }
2221
2222    #[test]
2223    fn test_find_sessions_near_commit_time_outside_window() {
2224        let (db, _dir) = create_test_db();
2225        let now = Utc::now();
2226
2227        // Create a session that ended 2 hours ago
2228        let mut session =
2229            create_test_session("claude-code", "/project", now - Duration::hours(3), None);
2230        session.ended_at = Some(now - Duration::hours(2));
2231
2232        db.insert_session(&session).expect("insert session");
2233
2234        // Find sessions near "now" with a 30 minute window
2235        let found = db
2236            .find_sessions_near_commit_time(now, 30, None)
2237            .expect("find sessions");
2238
2239        assert!(found.is_empty(), "Should not find session outside window");
2240    }
2241
2242    #[test]
2243    fn test_find_sessions_near_commit_time_with_working_dir() {
2244        let (db, _dir) = create_test_db();
2245        let now = Utc::now();
2246
2247        // Create sessions in different directories
2248        let mut session1 = create_test_session(
2249            "claude-code",
2250            "/home/user/project-a",
2251            now - Duration::minutes(30),
2252            None,
2253        );
2254        session1.ended_at = Some(now - Duration::minutes(5));
2255
2256        let mut session2 = create_test_session(
2257            "claude-code",
2258            "/home/user/project-b",
2259            now - Duration::minutes(30),
2260            None,
2261        );
2262        session2.ended_at = Some(now - Duration::minutes(5));
2263
2264        db.insert_session(&session1).expect("insert session1");
2265        db.insert_session(&session2).expect("insert session2");
2266
2267        // Find sessions near "now" filtering by project-a
2268        let found = db
2269            .find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
2270            .expect("find sessions");
2271
2272        assert_eq!(found.len(), 1, "Should find only session in project-a");
2273        assert_eq!(found[0].id, session1.id);
2274    }
2275
2276    #[test]
2277    fn test_find_sessions_near_commit_time_ongoing_session() {
2278        let (db, _dir) = create_test_db();
2279        let now = Utc::now();
2280
2281        // Create an ongoing session (no ended_at)
2282        let session =
2283            create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
2284        // ended_at is None by default
2285
2286        db.insert_session(&session).expect("insert session");
2287
2288        // Find sessions near "now"
2289        let found = db
2290            .find_sessions_near_commit_time(now, 30, None)
2291            .expect("find sessions");
2292
2293        assert_eq!(found.len(), 1, "Should find ongoing session");
2294        assert_eq!(found[0].id, session.id);
2295    }
2296
2297    #[test]
2298    fn test_link_exists_true() {
2299        let (db, _dir) = create_test_db();
2300
2301        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2302        db.insert_session(&session).expect("insert session");
2303
2304        let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2305        db.insert_link(&link).expect("insert link");
2306
2307        // Check with full SHA
2308        assert!(
2309            db.link_exists(&session.id, "abc123def456")
2310                .expect("check exists"),
2311            "Should find link with full SHA"
2312        );
2313
2314        // Check with partial SHA
2315        assert!(
2316            db.link_exists(&session.id, "abc123").expect("check exists"),
2317            "Should find link with partial SHA"
2318        );
2319    }
2320
2321    #[test]
2322    fn test_link_exists_false() {
2323        let (db, _dir) = create_test_db();
2324
2325        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2326        db.insert_session(&session).expect("insert session");
2327
2328        // No links created
2329        assert!(
2330            !db.link_exists(&session.id, "abc123").expect("check exists"),
2331            "Should not find non-existent link"
2332        );
2333    }
2334
2335    #[test]
2336    fn test_link_exists_different_session() {
2337        let (db, _dir) = create_test_db();
2338
2339        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2340        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2341
2342        db.insert_session(&session1).expect("insert session1");
2343        db.insert_session(&session2).expect("insert session2");
2344
2345        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2346        db.insert_link(&link).expect("insert link");
2347
2348        // Link exists for session1 but not session2
2349        assert!(
2350            db.link_exists(&session1.id, "abc123").expect("check"),
2351            "Should find link for session1"
2352        );
2353        assert!(
2354            !db.link_exists(&session2.id, "abc123").expect("check"),
2355            "Should not find link for session2"
2356        );
2357    }
2358
2359    // ==================== Enhanced Search Tests ====================
2360
2361    #[test]
2362    fn test_search_with_tool_filter() {
2363        let (db, _dir) = create_test_db();
2364
2365        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2366        let session2 = create_test_session("aider", "/project2", Utc::now(), None);
2367
2368        db.insert_session(&session1).expect("insert session1");
2369        db.insert_session(&session2).expect("insert session2");
2370
2371        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
2372        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
2373
2374        db.insert_message(&msg1).expect("insert msg1");
2375        db.insert_message(&msg2).expect("insert msg2");
2376
2377        // Search with tool filter
2378        let options = SearchOptions {
2379            query: "Hello".to_string(),
2380            limit: 10,
2381            tool: Some("claude-code".to_string()),
2382            ..Default::default()
2383        };
2384        let results = db.search_with_options(&options).expect("search");
2385
2386        assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
2387        assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
2388    }
2389
2390    #[test]
2391    fn test_search_with_date_range() {
2392        let (db, _dir) = create_test_db();
2393
2394        let old_time = Utc::now() - chrono::Duration::days(30);
2395        let new_time = Utc::now() - chrono::Duration::days(1);
2396
2397        let session1 = create_test_session("claude-code", "/project1", old_time, None);
2398        let session2 = create_test_session("claude-code", "/project2", new_time, None);
2399
2400        db.insert_session(&session1).expect("insert session1");
2401        db.insert_session(&session2).expect("insert session2");
2402
2403        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
2404        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
2405
2406        db.insert_message(&msg1).expect("insert msg1");
2407        db.insert_message(&msg2).expect("insert msg2");
2408
2409        // Search with since filter (last 7 days)
2410        let since = Utc::now() - chrono::Duration::days(7);
2411        let options = SearchOptions {
2412            query: "session".to_string(),
2413            limit: 10,
2414            since: Some(since),
2415            ..Default::default()
2416        };
2417        let results = db.search_with_options(&options).expect("search");
2418
2419        assert_eq!(results.len(), 1, "Should find 1 result within date range");
2420        assert!(
2421            results[0].working_directory.contains("project2"),
2422            "Should be from newer project"
2423        );
2424    }
2425
2426    #[test]
2427    fn test_search_with_project_filter() {
2428        let (db, _dir) = create_test_db();
2429
2430        let session1 =
2431            create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
2432        let session2 =
2433            create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
2434
2435        db.insert_session(&session1).expect("insert session1");
2436        db.insert_session(&session2).expect("insert session2");
2437
2438        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
2439        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
2440
2441        db.insert_message(&msg1).expect("insert msg1");
2442        db.insert_message(&msg2).expect("insert msg2");
2443
2444        // Search with project filter
2445        let options = SearchOptions {
2446            query: "Testing".to_string(),
2447            limit: 10,
2448            project: Some("frontend".to_string()),
2449            ..Default::default()
2450        };
2451        let results = db.search_with_options(&options).expect("search");
2452
2453        assert_eq!(results.len(), 1, "Should find 1 result with project filter");
2454        assert!(
2455            results[0].working_directory.contains("frontend"),
2456            "Should be from frontend project"
2457        );
2458    }
2459
2460    #[test]
2461    fn test_search_with_branch_filter() {
2462        let (db, _dir) = create_test_db();
2463
2464        let session1 = Session {
2465            id: Uuid::new_v4(),
2466            tool: "claude-code".to_string(),
2467            tool_version: None,
2468            started_at: Utc::now(),
2469            ended_at: None,
2470            model: None,
2471            working_directory: "/project".to_string(),
2472            git_branch: Some("feat/auth".to_string()),
2473            source_path: None,
2474            message_count: 0,
2475        };
2476        let session2 = Session {
2477            id: Uuid::new_v4(),
2478            tool: "claude-code".to_string(),
2479            tool_version: None,
2480            started_at: Utc::now(),
2481            ended_at: None,
2482            model: None,
2483            working_directory: "/project".to_string(),
2484            git_branch: Some("main".to_string()),
2485            source_path: None,
2486            message_count: 0,
2487        };
2488
2489        db.insert_session(&session1).expect("insert session1");
2490        db.insert_session(&session2).expect("insert session2");
2491
2492        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
2493        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
2494
2495        db.insert_message(&msg1).expect("insert msg1");
2496        db.insert_message(&msg2).expect("insert msg2");
2497
2498        // Search with branch filter
2499        let options = SearchOptions {
2500            query: "work".to_string(),
2501            limit: 10,
2502            branch: Some("auth".to_string()),
2503            ..Default::default()
2504        };
2505        let results = db.search_with_options(&options).expect("search");
2506
2507        assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
2508        assert_eq!(
2509            results[0].git_branch.as_deref(),
2510            Some("feat/auth"),
2511            "Should be from feat/auth branch"
2512        );
2513    }
2514
2515    #[test]
2516    fn test_search_metadata_matches_project() {
2517        let (db, _dir) = create_test_db();
2518
2519        let session =
2520            create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
2521        db.insert_session(&session).expect("insert session");
2522
2523        // Add a message that does NOT contain "redactyl"
2524        let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
2525        db.insert_message(&msg).expect("insert msg");
2526
2527        // Search for "redactyl" - should match session metadata
2528        let options = SearchOptions {
2529            query: "redactyl".to_string(),
2530            limit: 10,
2531            ..Default::default()
2532        };
2533        let results = db.search_with_options(&options).expect("search");
2534
2535        assert_eq!(
2536            results.len(),
2537            1,
2538            "Should find session via metadata match on project name"
2539        );
2540    }
2541
2542    #[test]
2543    fn test_search_returns_extended_session_info() {
2544        let (db, _dir) = create_test_db();
2545
2546        let started_at = Utc::now();
2547        let session = Session {
2548            id: Uuid::new_v4(),
2549            tool: "claude-code".to_string(),
2550            tool_version: Some("1.0.0".to_string()),
2551            started_at,
2552            ended_at: None,
2553            model: None,
2554            working_directory: "/home/user/myapp".to_string(),
2555            git_branch: Some("develop".to_string()),
2556            source_path: None,
2557            message_count: 5,
2558        };
2559        db.insert_session(&session).expect("insert session");
2560
2561        let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
2562        db.insert_message(&msg).expect("insert msg");
2563
2564        let options = SearchOptions {
2565            query: "Test".to_string(),
2566            limit: 10,
2567            ..Default::default()
2568        };
2569        let results = db.search_with_options(&options).expect("search");
2570
2571        assert_eq!(results.len(), 1, "Should find 1 result");
2572        let result = &results[0];
2573
2574        assert_eq!(result.tool, "claude-code", "Tool should be populated");
2575        assert_eq!(
2576            result.git_branch.as_deref(),
2577            Some("develop"),
2578            "Branch should be populated"
2579        );
2580        assert!(
2581            result.session_message_count > 0,
2582            "Message count should be populated"
2583        );
2584        assert!(
2585            result.session_started_at.is_some(),
2586            "Session start time should be populated"
2587        );
2588    }
2589
2590    #[test]
2591    fn test_get_context_messages() {
2592        let (db, _dir) = create_test_db();
2593
2594        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2595        db.insert_session(&session).expect("insert session");
2596
2597        // Create 5 messages in sequence
2598        for i in 0..5 {
2599            let role = if i % 2 == 0 {
2600                MessageRole::User
2601            } else {
2602                MessageRole::Assistant
2603            };
2604            let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
2605            db.insert_message(&msg).expect("insert message");
2606        }
2607
2608        // Get context around message index 2 (the middle one)
2609        let (before, after) = db
2610            .get_context_messages(&session.id, 2, 1)
2611            .expect("get context");
2612
2613        assert_eq!(before.len(), 1, "Should have 1 message before");
2614        assert_eq!(after.len(), 1, "Should have 1 message after");
2615        assert_eq!(before[0].index, 1, "Before message should be index 1");
2616        assert_eq!(after[0].index, 3, "After message should be index 3");
2617    }
2618
2619    #[test]
2620    fn test_get_context_messages_at_start() {
2621        let (db, _dir) = create_test_db();
2622
2623        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2624        db.insert_session(&session).expect("insert session");
2625
2626        for i in 0..3 {
2627            let msg =
2628                create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
2629            db.insert_message(&msg).expect("insert message");
2630        }
2631
2632        // Get context around first message (index 0)
2633        let (before, after) = db
2634            .get_context_messages(&session.id, 0, 2)
2635            .expect("get context");
2636
2637        assert!(
2638            before.is_empty(),
2639            "Should have no messages before first message"
2640        );
2641        assert_eq!(after.len(), 2, "Should have 2 messages after");
2642    }
2643
2644    #[test]
2645    fn test_get_context_messages_at_end() {
2646        let (db, _dir) = create_test_db();
2647
2648        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2649        db.insert_session(&session).expect("insert session");
2650
2651        for i in 0..3 {
2652            let msg =
2653                create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
2654            db.insert_message(&msg).expect("insert message");
2655        }
2656
2657        // Get context around last message (index 2)
2658        let (before, after) = db
2659            .get_context_messages(&session.id, 2, 2)
2660            .expect("get context");
2661
2662        assert_eq!(before.len(), 2, "Should have 2 messages before");
2663        assert!(
2664            after.is_empty(),
2665            "Should have no messages after last message"
2666        );
2667    }
2668
2669    #[test]
2670    fn test_search_combined_filters() {
2671        let (db, _dir) = create_test_db();
2672
2673        let session1 = Session {
2674            id: Uuid::new_v4(),
2675            tool: "claude-code".to_string(),
2676            tool_version: None,
2677            started_at: Utc::now(),
2678            ended_at: None,
2679            model: None,
2680            working_directory: "/home/user/myapp".to_string(),
2681            git_branch: Some("feat/api".to_string()),
2682            source_path: None,
2683            message_count: 1,
2684        };
2685        let session2 = Session {
2686            id: Uuid::new_v4(),
2687            tool: "aider".to_string(),
2688            tool_version: None,
2689            started_at: Utc::now(),
2690            ended_at: None,
2691            model: None,
2692            working_directory: "/home/user/myapp".to_string(),
2693            git_branch: Some("feat/api".to_string()),
2694            source_path: None,
2695            message_count: 1,
2696        };
2697
2698        db.insert_session(&session1).expect("insert session1");
2699        db.insert_session(&session2).expect("insert session2");
2700
2701        let msg1 =
2702            create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
2703        let msg2 =
2704            create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
2705
2706        db.insert_message(&msg1).expect("insert msg1");
2707        db.insert_message(&msg2).expect("insert msg2");
2708
2709        // Search with multiple filters
2710        let options = SearchOptions {
2711            query: "API".to_string(),
2712            limit: 10,
2713            tool: Some("claude-code".to_string()),
2714            branch: Some("api".to_string()),
2715            project: Some("myapp".to_string()),
2716            ..Default::default()
2717        };
2718        let results = db.search_with_options(&options).expect("search");
2719
2720        // Results may include both message content match and metadata match from same session
2721        assert!(
2722            !results.is_empty(),
2723            "Should find at least 1 result matching all filters"
2724        );
2725        // All results should be from claude-code (the filtered tool)
2726        for result in &results {
2727            assert_eq!(
2728                result.tool, "claude-code",
2729                "All results should be from claude-code"
2730            );
2731        }
2732    }
2733}