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    /// Updates the git branch for a session.
311    ///
312    /// Used by the daemon when a message is processed with a different branch
313    /// than the session's current branch, indicating a branch switch mid-session.
314    /// Also updates the sessions_fts index to keep search in sync.
315    ///
316    /// Returns the number of rows affected (0 or 1).
317    pub fn update_session_branch(&self, session_id: Uuid, new_branch: &str) -> Result<usize> {
318        let rows_changed = self.conn.execute(
319            "UPDATE sessions SET git_branch = ?1 WHERE id = ?2",
320            params![new_branch, session_id.to_string()],
321        )?;
322
323        // Also update the FTS index if the session was updated
324        if rows_changed > 0 {
325            self.conn.execute(
326                "UPDATE sessions_fts SET git_branch = ?1 WHERE session_id = ?2",
327                params![new_branch, session_id.to_string()],
328            )?;
329        }
330
331        Ok(rows_changed)
332    }
333
334    fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<Session> {
335        let ended_at_str: Option<String> = row.get(4)?;
336        let ended_at = match ended_at_str {
337            Some(s) => Some(parse_datetime(&s)?),
338            None => None,
339        };
340
341        Ok(Session {
342            id: parse_uuid(&row.get::<_, String>(0)?)?,
343            tool: row.get(1)?,
344            tool_version: row.get(2)?,
345            started_at: parse_datetime(&row.get::<_, String>(3)?)?,
346            ended_at,
347            model: row.get(5)?,
348            working_directory: row.get(6)?,
349            git_branch: row.get(7)?,
350            source_path: row.get(8)?,
351            message_count: row.get(9)?,
352        })
353    }
354
355    // ==================== Messages ====================
356
357    /// Inserts a message into the database.
358    ///
359    /// If a message with the same ID already exists, the insert is ignored.
360    /// Message content is serialized to JSON for storage. Also inserts
361    /// extracted text content into the FTS index for full-text search.
362    pub fn insert_message(&self, message: &Message) -> Result<()> {
363        let content_json = serde_json::to_string(&message.content)?;
364
365        let rows_changed = self.conn.execute(
366            r#"
367            INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
368            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
369            ON CONFLICT(id) DO NOTHING
370            "#,
371            params![
372                message.id.to_string(),
373                message.session_id.to_string(),
374                message.parent_id.map(|u| u.to_string()),
375                message.index,
376                message.timestamp.to_rfc3339(),
377                message.role.to_string(),
378                content_json,
379                message.model,
380                message.git_branch,
381                message.cwd,
382            ],
383        )?;
384
385        // Only insert into FTS if the message was actually inserted (not a duplicate)
386        if rows_changed > 0 {
387            let text_content = message.content.text();
388            if !text_content.is_empty() {
389                self.conn.execute(
390                    "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
391                    params![message.id.to_string(), text_content],
392                )?;
393            }
394        }
395
396        Ok(())
397    }
398
399    /// Retrieves all messages for a session, ordered by index.
400    ///
401    /// Messages are returned in conversation order (by their `index` field).
402    pub fn get_messages(&self, session_id: &Uuid) -> Result<Vec<Message>> {
403        let mut stmt = self.conn.prepare(
404            "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd 
405             FROM messages 
406             WHERE session_id = ?1 
407             ORDER BY idx"
408        )?;
409
410        let rows = stmt.query_map(params![session_id.to_string()], |row| {
411            let role_str: String = row.get(5)?;
412            let content_str: String = row.get(6)?;
413
414            let parent_id_str: Option<String> = row.get(2)?;
415            let parent_id = match parent_id_str {
416                Some(s) => Some(parse_uuid(&s)?),
417                None => None,
418            };
419
420            Ok(Message {
421                id: parse_uuid(&row.get::<_, String>(0)?)?,
422                session_id: parse_uuid(&row.get::<_, String>(1)?)?,
423                parent_id,
424                index: row.get(3)?,
425                timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
426                role: match role_str.as_str() {
427                    "user" => MessageRole::User,
428                    "assistant" => MessageRole::Assistant,
429                    "system" => MessageRole::System,
430                    _ => MessageRole::User,
431                },
432                content: serde_json::from_str(&content_str)
433                    .unwrap_or(MessageContent::Text(content_str)),
434                model: row.get(7)?,
435                git_branch: row.get(8)?,
436                cwd: row.get(9)?,
437            })
438        })?;
439
440        rows.collect::<Result<Vec<_>, _>>()
441            .context("Failed to get messages")
442    }
443
444    /// Returns the ordered list of distinct branches for a session.
445    ///
446    /// Branches are returned in the order they first appeared in messages,
447    /// with consecutive duplicates removed. This shows the branch transitions
448    /// during a session (e.g., "main -> feat/auth -> main").
449    ///
450    /// Returns an empty vector if the session has no messages or all messages
451    /// have None branches.
452    pub fn get_session_branch_history(&self, session_id: Uuid) -> Result<Vec<String>> {
453        let mut stmt = self
454            .conn
455            .prepare("SELECT git_branch FROM messages WHERE session_id = ?1 ORDER BY idx")?;
456
457        let rows = stmt.query_map(params![session_id.to_string()], |row| {
458            let branch: Option<String> = row.get(0)?;
459            Ok(branch)
460        })?;
461
462        // Collect branches, keeping only the first occurrence of consecutive duplicates
463        let mut branches: Vec<String> = Vec::new();
464        for row in rows {
465            if let Some(branch) = row? {
466                // Only add if different from the last branch (removes consecutive duplicates)
467                if branches.last() != Some(&branch) {
468                    branches.push(branch);
469                }
470            }
471        }
472
473        Ok(branches)
474    }
475
476    // ==================== Session Links ====================
477
478    /// Inserts a link between a session and a git commit.
479    ///
480    /// Links can be created manually by users or automatically by
481    /// the auto-linking system based on time and file overlap heuristics.
482    pub fn insert_link(&self, link: &SessionLink) -> Result<()> {
483        self.conn.execute(
484            r#"
485            INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
486            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
487            "#,
488            params![
489                link.id.to_string(),
490                link.session_id.to_string(),
491                format!("{:?}", link.link_type).to_lowercase(),
492                link.commit_sha,
493                link.branch,
494                link.remote,
495                link.created_at.to_rfc3339(),
496                format!("{:?}", link.created_by).to_lowercase(),
497                link.confidence,
498            ],
499        )?;
500        Ok(())
501    }
502
503    /// Retrieves all session links for a commit.
504    ///
505    /// Supports prefix matching on the commit SHA, allowing short SHAs
506    /// (e.g., first 8 characters) to be used for lookup.
507    pub fn get_links_by_commit(&self, commit_sha: &str) -> Result<Vec<SessionLink>> {
508        let mut stmt = self.conn.prepare(
509            "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence 
510             FROM session_links 
511             WHERE commit_sha LIKE ?1"
512        )?;
513
514        let pattern = format!("{commit_sha}%");
515        let rows = stmt.query_map(params![pattern], Self::row_to_link)?;
516
517        rows.collect::<Result<Vec<_>, _>>()
518            .context("Failed to get links")
519    }
520
521    /// Retrieves all links associated with a session.
522    ///
523    /// A session can be linked to multiple commits if it spans
524    /// several git operations.
525    pub fn get_links_by_session(&self, session_id: &Uuid) -> Result<Vec<SessionLink>> {
526        let mut stmt = self.conn.prepare(
527            "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence 
528             FROM session_links 
529             WHERE session_id = ?1"
530        )?;
531
532        let rows = stmt.query_map(params![session_id.to_string()], Self::row_to_link)?;
533
534        rows.collect::<Result<Vec<_>, _>>()
535            .context("Failed to get links")
536    }
537
538    fn row_to_link(row: &rusqlite::Row) -> rusqlite::Result<SessionLink> {
539        use super::models::{LinkCreator, LinkType};
540
541        let link_type_str: String = row.get(2)?;
542        let created_by_str: String = row.get(7)?;
543
544        Ok(SessionLink {
545            id: parse_uuid(&row.get::<_, String>(0)?)?,
546            session_id: parse_uuid(&row.get::<_, String>(1)?)?,
547            link_type: match link_type_str.as_str() {
548                "commit" => LinkType::Commit,
549                "branch" => LinkType::Branch,
550                "pr" => LinkType::Pr,
551                _ => LinkType::Manual,
552            },
553            commit_sha: row.get(3)?,
554            branch: row.get(4)?,
555            remote: row.get(5)?,
556            created_at: parse_datetime(&row.get::<_, String>(6)?)?,
557            created_by: match created_by_str.as_str() {
558                "auto" => LinkCreator::Auto,
559                _ => LinkCreator::User,
560            },
561            confidence: row.get(8)?,
562        })
563    }
564
565    /// Deletes a specific session link by its ID.
566    ///
567    /// Returns `true` if a link was deleted, `false` if no link with that ID existed.
568    ///
569    /// Note: This method is part of the public API for programmatic use,
570    /// though the CLI currently uses session/commit-based deletion.
571    #[allow(dead_code)]
572    pub fn delete_link(&self, link_id: &Uuid) -> Result<bool> {
573        let rows_affected = self.conn.execute(
574            "DELETE FROM session_links WHERE id = ?1",
575            params![link_id.to_string()],
576        )?;
577        Ok(rows_affected > 0)
578    }
579
580    /// Deletes all links for a session.
581    ///
582    /// Returns the number of links deleted.
583    pub fn delete_links_by_session(&self, session_id: &Uuid) -> Result<usize> {
584        let rows_affected = self.conn.execute(
585            "DELETE FROM session_links WHERE session_id = ?1",
586            params![session_id.to_string()],
587        )?;
588        Ok(rows_affected)
589    }
590
591    /// Deletes a link between a specific session and commit.
592    ///
593    /// The commit_sha is matched as a prefix, so short SHAs work.
594    /// Returns `true` if a link was deleted, `false` if no matching link existed.
595    pub fn delete_link_by_session_and_commit(
596        &self,
597        session_id: &Uuid,
598        commit_sha: &str,
599    ) -> Result<bool> {
600        let pattern = format!("{commit_sha}%");
601        let rows_affected = self.conn.execute(
602            "DELETE FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
603            params![session_id.to_string(), pattern],
604        )?;
605        Ok(rows_affected > 0)
606    }
607
608    // ==================== Search ====================
609
610    /// Searches message content using full-text search.
611    ///
612    /// Uses SQLite FTS5 to search for messages matching the query.
613    /// Returns results ordered by FTS5 relevance ranking.
614    ///
615    /// Optional filters:
616    /// - `working_dir`: Filter by working directory prefix
617    /// - `since`: Filter by minimum timestamp
618    /// - `role`: Filter by message role
619    ///
620    /// Note: This is the legacy search API. For new code, use `search_with_options`.
621    #[allow(dead_code)]
622    pub fn search_messages(
623        &self,
624        query: &str,
625        limit: usize,
626        working_dir: Option<&str>,
627        since: Option<chrono::DateTime<chrono::Utc>>,
628        role: Option<&str>,
629    ) -> Result<Vec<SearchResult>> {
630        use super::models::SearchOptions;
631
632        // Convert to SearchOptions and use the new method
633        let options = SearchOptions {
634            query: query.to_string(),
635            limit,
636            repo: working_dir.map(|s| s.to_string()),
637            since,
638            role: role.map(|s| s.to_string()),
639            ..Default::default()
640        };
641
642        self.search_with_options(&options)
643    }
644
645    /// Searches messages and session metadata using full-text search with filters.
646    ///
647    /// Uses SQLite FTS5 to search for messages matching the query.
648    /// Also searches session metadata (tool, project, branch) via sessions_fts.
649    /// Returns results ordered by FTS5 relevance ranking.
650    ///
651    /// Supports extensive filtering via SearchOptions:
652    /// - `tool`: Filter by AI tool name
653    /// - `since`/`until`: Filter by date range
654    /// - `project`: Filter by project name (partial match)
655    /// - `branch`: Filter by git branch (partial match)
656    /// - `role`: Filter by message role
657    /// - `repo`: Filter by working directory prefix
658    pub fn search_with_options(
659        &self,
660        options: &super::models::SearchOptions,
661    ) -> Result<Vec<SearchResult>> {
662        // Escape the query for FTS5 to handle special characters
663        let escaped_query = escape_fts5_query(&options.query);
664
665        // Build the query dynamically based on filters
666        // Use UNION to search both message content and session metadata
667        let mut sql = String::from(
668            r#"
669            SELECT
670                m.session_id,
671                m.id as message_id,
672                m.role,
673                snippet(messages_fts, 1, '**', '**', '...', 32) as snippet,
674                m.timestamp,
675                s.working_directory,
676                s.tool,
677                s.git_branch,
678                s.message_count,
679                s.started_at,
680                m.idx as message_index
681            FROM messages_fts fts
682            JOIN messages m ON fts.message_id = m.id
683            JOIN sessions s ON m.session_id = s.id
684            WHERE messages_fts MATCH ?1
685            "#,
686        );
687
688        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(escaped_query.clone())];
689        let mut param_idx = 2;
690
691        // Add filters
692        if options.repo.is_some() {
693            sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
694            param_idx += 1;
695        }
696        if options.tool.is_some() {
697            sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
698            param_idx += 1;
699        }
700        if options.since.is_some() {
701            sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
702            param_idx += 1;
703        }
704        if options.until.is_some() {
705            sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
706            param_idx += 1;
707        }
708        if options.project.is_some() {
709            sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
710            param_idx += 1;
711        }
712        if options.branch.is_some() {
713            sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
714            param_idx += 1;
715        }
716        if options.role.is_some() {
717            sql.push_str(&format!(" AND m.role = ?{param_idx}"));
718            param_idx += 1;
719        }
720
721        // Build first SELECT parameter list (after the FTS query param which is already in params_vec)
722        if let Some(ref wd) = options.repo {
723            params_vec.push(Box::new(format!("{wd}%")));
724        }
725        if let Some(ref tool) = options.tool {
726            params_vec.push(Box::new(tool.clone()));
727        }
728        if let Some(ts) = options.since {
729            params_vec.push(Box::new(ts.to_rfc3339()));
730        }
731        if let Some(ts) = options.until {
732            params_vec.push(Box::new(ts.to_rfc3339()));
733        }
734        if let Some(ref project) = options.project {
735            params_vec.push(Box::new(format!("%{project}%")));
736        }
737        if let Some(ref branch) = options.branch {
738            params_vec.push(Box::new(format!("%{branch}%")));
739        }
740        if let Some(ref role) = options.role {
741            params_vec.push(Box::new(role.clone()));
742        }
743
744        // Add UNION for session metadata search (only if not filtering by role)
745        // This finds sessions where the metadata matches, returning the first message as representative
746        // Uses LIKE patterns instead of FTS5 for metadata since paths contain special characters
747        let include_metadata_search = options.role.is_none();
748        let metadata_query_pattern = format!("%{}%", options.query);
749
750        if include_metadata_search {
751            // For the metadata search, we need 3 separate params for the OR conditions
752            let meta_param1 = param_idx;
753            let meta_param2 = param_idx + 1;
754            let meta_param3 = param_idx + 2;
755            param_idx += 3;
756
757            sql.push_str(&format!(
758                r#"
759            UNION
760            SELECT
761                s.id as session_id,
762                (SELECT id FROM messages WHERE session_id = s.id ORDER BY idx LIMIT 1) as message_id,
763                'user' as role,
764                substr(s.tool || ' session in ' || s.working_directory || COALESCE(' on branch ' || s.git_branch, ''), 1, 100) as snippet,
765                s.started_at as timestamp,
766                s.working_directory,
767                s.tool,
768                s.git_branch,
769                s.message_count,
770                s.started_at,
771                0 as message_index
772            FROM sessions s
773            WHERE (
774                s.tool LIKE ?{meta_param1}
775                OR s.working_directory LIKE ?{meta_param2}
776                OR s.git_branch LIKE ?{meta_param3}
777            )
778            "#
779            ));
780
781            // Add metadata patterns to params
782            params_vec.push(Box::new(metadata_query_pattern.clone()));
783            params_vec.push(Box::new(metadata_query_pattern.clone()));
784            params_vec.push(Box::new(metadata_query_pattern));
785
786            // Re-apply session-level filters to the UNION query
787            if options.repo.is_some() {
788                sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
789                params_vec.push(Box::new(format!("{}%", options.repo.as_ref().unwrap())));
790                param_idx += 1;
791            }
792            if options.tool.is_some() {
793                sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
794                params_vec.push(Box::new(options.tool.as_ref().unwrap().clone()));
795                param_idx += 1;
796            }
797            if options.since.is_some() {
798                sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
799                params_vec.push(Box::new(options.since.unwrap().to_rfc3339()));
800                param_idx += 1;
801            }
802            if options.until.is_some() {
803                sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
804                params_vec.push(Box::new(options.until.unwrap().to_rfc3339()));
805                param_idx += 1;
806            }
807            if options.project.is_some() {
808                sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
809                params_vec.push(Box::new(format!("%{}%", options.project.as_ref().unwrap())));
810                param_idx += 1;
811            }
812            if options.branch.is_some() {
813                sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
814                params_vec.push(Box::new(format!("%{}%", options.branch.as_ref().unwrap())));
815                param_idx += 1;
816            }
817        }
818
819        sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{param_idx}"));
820        params_vec.push(Box::new(options.limit as i64));
821
822        // Prepare and execute
823        let mut stmt = self.conn.prepare(&sql)?;
824        let params_refs: Vec<&dyn rusqlite::ToSql> =
825            params_vec.iter().map(|p| p.as_ref()).collect();
826
827        let rows = stmt.query_map(params_refs.as_slice(), |row| {
828            let role_str: String = row.get(2)?;
829            let git_branch: Option<String> = row.get(7)?;
830            let started_at_str: Option<String> = row.get(9)?;
831
832            Ok(SearchResult {
833                session_id: parse_uuid(&row.get::<_, String>(0)?)?,
834                message_id: parse_uuid(&row.get::<_, String>(1)?)?,
835                role: match role_str.as_str() {
836                    "user" => MessageRole::User,
837                    "assistant" => MessageRole::Assistant,
838                    "system" => MessageRole::System,
839                    _ => MessageRole::User,
840                },
841                snippet: row.get(3)?,
842                timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
843                working_directory: row.get(5)?,
844                tool: row.get(6)?,
845                git_branch,
846                session_message_count: row.get(8)?,
847                session_started_at: started_at_str.map(|s| parse_datetime(&s)).transpose()?,
848                message_index: row.get(10)?,
849            })
850        })?;
851
852        rows.collect::<Result<Vec<_>, _>>()
853            .context("Failed to search messages")
854    }
855
856    /// Gets messages around a specific message for context.
857    ///
858    /// Returns N messages before and N messages after the specified message,
859    /// useful for displaying search results with surrounding context.
860    pub fn get_context_messages(
861        &self,
862        session_id: &Uuid,
863        message_index: i32,
864        context_count: usize,
865    ) -> Result<(Vec<Message>, Vec<Message>)> {
866        // Get messages before
867        let mut before_stmt = self.conn.prepare(
868            "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
869             FROM messages
870             WHERE session_id = ?1 AND idx < ?2
871             ORDER BY idx DESC
872             LIMIT ?3",
873        )?;
874
875        let before_rows = before_stmt.query_map(
876            params![session_id.to_string(), message_index, context_count as i64],
877            Self::row_to_message,
878        )?;
879
880        let mut before: Vec<Message> = before_rows
881            .collect::<Result<Vec<_>, _>>()
882            .context("Failed to get before messages")?;
883        before.reverse(); // Put in chronological order
884
885        // Get messages after
886        let mut after_stmt = self.conn.prepare(
887            "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
888             FROM messages
889             WHERE session_id = ?1 AND idx > ?2
890             ORDER BY idx ASC
891             LIMIT ?3",
892        )?;
893
894        let after_rows = after_stmt.query_map(
895            params![session_id.to_string(), message_index, context_count as i64],
896            Self::row_to_message,
897        )?;
898
899        let after: Vec<Message> = after_rows
900            .collect::<Result<Vec<_>, _>>()
901            .context("Failed to get after messages")?;
902
903        Ok((before, after))
904    }
905
906    /// Gets a single message by its index within a session.
907    #[allow(dead_code)]
908    pub fn get_message_by_index(&self, session_id: &Uuid, index: i32) -> Result<Option<Message>> {
909        self.conn
910            .query_row(
911                "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
912                 FROM messages
913                 WHERE session_id = ?1 AND idx = ?2",
914                params![session_id.to_string(), index],
915                Self::row_to_message,
916            )
917            .optional()
918            .context("Failed to get message by index")
919    }
920
921    fn row_to_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
922        let role_str: String = row.get(5)?;
923        let content_str: String = row.get(6)?;
924
925        let parent_id_str: Option<String> = row.get(2)?;
926        let parent_id = match parent_id_str {
927            Some(s) => Some(parse_uuid(&s)?),
928            None => None,
929        };
930
931        Ok(Message {
932            id: parse_uuid(&row.get::<_, String>(0)?)?,
933            session_id: parse_uuid(&row.get::<_, String>(1)?)?,
934            parent_id,
935            index: row.get(3)?,
936            timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
937            role: match role_str.as_str() {
938                "user" => MessageRole::User,
939                "assistant" => MessageRole::Assistant,
940                "system" => MessageRole::System,
941                _ => MessageRole::User,
942            },
943            content: serde_json::from_str(&content_str)
944                .unwrap_or(MessageContent::Text(content_str)),
945            model: row.get(7)?,
946            git_branch: row.get(8)?,
947            cwd: row.get(9)?,
948        })
949    }
950
951    /// Rebuilds the full-text search index from existing messages and sessions.
952    ///
953    /// This should be called when:
954    /// - Upgrading from a database without FTS support
955    /// - The FTS index becomes corrupted or out of sync
956    ///
957    /// Returns the number of messages indexed.
958    pub fn rebuild_search_index(&self) -> Result<usize> {
959        // Clear existing FTS data
960        self.conn.execute("DELETE FROM messages_fts", [])?;
961        self.conn.execute("DELETE FROM sessions_fts", [])?;
962
963        // Reindex all messages
964        let mut msg_stmt = self.conn.prepare("SELECT id, content FROM messages")?;
965
966        let rows = msg_stmt.query_map([], |row| {
967            let id: String = row.get(0)?;
968            let content_json: String = row.get(1)?;
969            Ok((id, content_json))
970        })?;
971
972        let mut count = 0;
973        for row in rows {
974            let (id, content_json) = row?;
975            // Parse the content JSON and extract text
976            let content: MessageContent = serde_json::from_str(&content_json)
977                .unwrap_or(MessageContent::Text(content_json.clone()));
978            let text_content = content.text();
979
980            if !text_content.is_empty() {
981                self.conn.execute(
982                    "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
983                    params![id, text_content],
984                )?;
985                count += 1;
986            }
987        }
988
989        // Reindex all sessions for metadata search
990        let mut session_stmt = self
991            .conn
992            .prepare("SELECT id, tool, working_directory, git_branch FROM sessions")?;
993
994        let session_rows = session_stmt.query_map([], |row| {
995            let id: String = row.get(0)?;
996            let tool: String = row.get(1)?;
997            let working_directory: String = row.get(2)?;
998            let git_branch: Option<String> = row.get(3)?;
999            Ok((id, tool, working_directory, git_branch))
1000        })?;
1001
1002        for row in session_rows {
1003            let (id, tool, working_directory, git_branch) = row?;
1004            self.conn.execute(
1005                "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
1006                params![id, tool, working_directory, git_branch.unwrap_or_default()],
1007            )?;
1008        }
1009
1010        Ok(count)
1011    }
1012
1013    /// Checks if the search index needs rebuilding.
1014    ///
1015    /// Returns true if there are messages or sessions in the database but the FTS
1016    /// indexes are empty, indicating data was imported before FTS was added.
1017    pub fn search_index_needs_rebuild(&self) -> Result<bool> {
1018        let message_count: i32 =
1019            self.conn
1020                .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1021
1022        let msg_fts_count: i32 =
1023            self.conn
1024                .query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
1025
1026        let session_count: i32 =
1027            self.conn
1028                .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1029
1030        let session_fts_count: i32 =
1031            self.conn
1032                .query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
1033
1034        // Rebuild needed if we have messages/sessions but either FTS index is empty
1035        Ok((message_count > 0 && msg_fts_count == 0)
1036            || (session_count > 0 && session_fts_count == 0))
1037    }
1038
1039    // ==================== Stats ====================
1040
1041    /// Returns the total number of sessions in the database.
1042    pub fn session_count(&self) -> Result<i32> {
1043        let count: i32 = self
1044            .conn
1045            .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1046        Ok(count)
1047    }
1048
1049    /// Returns the total number of messages across all sessions.
1050    pub fn message_count(&self) -> Result<i32> {
1051        let count: i32 = self
1052            .conn
1053            .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1054        Ok(count)
1055    }
1056
1057    /// Returns the total number of session links in the database.
1058    pub fn link_count(&self) -> Result<i32> {
1059        let count: i32 = self
1060            .conn
1061            .query_row("SELECT COUNT(*) FROM session_links", [], |row| row.get(0))?;
1062        Ok(count)
1063    }
1064
1065    /// Returns the path to the database file, if available.
1066    ///
1067    /// Returns `None` for in-memory databases.
1068    pub fn db_path(&self) -> Option<std::path::PathBuf> {
1069        self.conn.path().map(std::path::PathBuf::from)
1070    }
1071
1072    // ==================== Auto-linking ====================
1073
1074    /// Finds sessions that were active around a commit time.
1075    ///
1076    /// A session is considered active if the commit time falls within the
1077    /// window before and after the session's time range (started_at to ended_at).
1078    ///
1079    /// # Arguments
1080    ///
1081    /// * `commit_time` - The timestamp of the commit
1082    /// * `window_minutes` - The window in minutes before/after the session
1083    /// * `working_dir` - Optional working directory filter (prefix match)
1084    ///
1085    /// # Returns
1086    ///
1087    /// Sessions that were active near the commit time, ordered by proximity.
1088    pub fn find_sessions_near_commit_time(
1089        &self,
1090        commit_time: chrono::DateTime<chrono::Utc>,
1091        window_minutes: i64,
1092        working_dir: Option<&str>,
1093    ) -> Result<Vec<Session>> {
1094        // Convert commit time to RFC3339 for SQLite comparison
1095        let commit_time_str = commit_time.to_rfc3339();
1096
1097        // Calculate the time window boundaries
1098        let window = chrono::Duration::minutes(window_minutes);
1099        let window_start = (commit_time - window).to_rfc3339();
1100        let window_end = (commit_time + window).to_rfc3339();
1101
1102        let sql = if working_dir.is_some() {
1103            r#"
1104            SELECT id, tool, tool_version, started_at, ended_at, model,
1105                   working_directory, git_branch, source_path, message_count
1106            FROM sessions
1107            WHERE working_directory LIKE ?1
1108              AND (
1109                  -- Session started before or during the window
1110                  (started_at <= ?3)
1111                  AND
1112                  -- Session ended after or during the window (or is still ongoing)
1113                  (ended_at IS NULL OR ended_at >= ?2)
1114              )
1115            ORDER BY
1116              -- Order by how close the session end (or start) is to commit time
1117              ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?4))
1118            "#
1119        } else {
1120            r#"
1121            SELECT id, tool, tool_version, started_at, ended_at, model,
1122                   working_directory, git_branch, source_path, message_count
1123            FROM sessions
1124            WHERE
1125              -- Session started before or during the window
1126              (started_at <= ?2)
1127              AND
1128              -- Session ended after or during the window (or is still ongoing)
1129              (ended_at IS NULL OR ended_at >= ?1)
1130            ORDER BY
1131              -- Order by how close the session end (or start) is to commit time
1132              ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?3))
1133            "#
1134        };
1135
1136        let mut stmt = self.conn.prepare(sql)?;
1137
1138        let rows = if let Some(wd) = working_dir {
1139            stmt.query_map(
1140                params![format!("{wd}%"), window_start, window_end, commit_time_str],
1141                Self::row_to_session,
1142            )?
1143        } else {
1144            stmt.query_map(
1145                params![window_start, window_end, commit_time_str],
1146                Self::row_to_session,
1147            )?
1148        };
1149
1150        rows.collect::<Result<Vec<_>, _>>()
1151            .context("Failed to find sessions near commit time")
1152    }
1153
1154    /// Checks if a link already exists between a session and commit.
1155    ///
1156    /// Used to avoid creating duplicate links during auto-linking.
1157    pub fn link_exists(&self, session_id: &Uuid, commit_sha: &str) -> Result<bool> {
1158        let pattern = format!("{commit_sha}%");
1159        let count: i32 = self.conn.query_row(
1160            "SELECT COUNT(*) FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1161            params![session_id.to_string(), pattern],
1162            |row| row.get(0),
1163        )?;
1164        Ok(count > 0)
1165    }
1166
1167    // ==================== Session Deletion ====================
1168
1169    /// Deletes a session and all its associated data.
1170    ///
1171    /// Removes the session, all its messages, all FTS index entries, and all
1172    /// session links. Returns the counts of deleted items.
1173    ///
1174    /// # Returns
1175    ///
1176    /// A tuple of (messages_deleted, links_deleted) counts.
1177    pub fn delete_session(&self, session_id: &Uuid) -> Result<(usize, usize)> {
1178        let session_id_str = session_id.to_string();
1179
1180        // Delete from messages_fts first (need message IDs)
1181        self.conn.execute(
1182            "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1183            params![session_id_str],
1184        )?;
1185
1186        // Delete messages
1187        let messages_deleted = self.conn.execute(
1188            "DELETE FROM messages WHERE session_id = ?1",
1189            params![session_id_str],
1190        )?;
1191
1192        // Delete links
1193        let links_deleted = self.conn.execute(
1194            "DELETE FROM session_links WHERE session_id = ?1",
1195            params![session_id_str],
1196        )?;
1197
1198        // Delete from sessions_fts
1199        self.conn.execute(
1200            "DELETE FROM sessions_fts WHERE session_id = ?1",
1201            params![session_id_str],
1202        )?;
1203
1204        // Delete the session itself
1205        self.conn.execute(
1206            "DELETE FROM sessions WHERE id = ?1",
1207            params![session_id_str],
1208        )?;
1209
1210        Ok((messages_deleted, links_deleted))
1211    }
1212
1213    // ==================== Database Maintenance ====================
1214
1215    /// Runs SQLite VACUUM to reclaim unused space and defragment the database.
1216    ///
1217    /// This operation can take some time on large databases and temporarily
1218    /// doubles the disk space used while rebuilding.
1219    pub fn vacuum(&self) -> Result<()> {
1220        self.conn.execute("VACUUM", [])?;
1221        Ok(())
1222    }
1223
1224    /// Returns the file size of the database in bytes.
1225    ///
1226    /// Returns `None` for in-memory databases.
1227    pub fn file_size(&self) -> Result<Option<u64>> {
1228        if let Some(path) = self.db_path() {
1229            let metadata = std::fs::metadata(&path)?;
1230            Ok(Some(metadata.len()))
1231        } else {
1232            Ok(None)
1233        }
1234    }
1235
1236    /// Deletes sessions older than the specified date.
1237    ///
1238    /// Also deletes all associated messages, links, and FTS entries.
1239    ///
1240    /// # Arguments
1241    ///
1242    /// * `before` - Delete sessions that started before this date
1243    ///
1244    /// # Returns
1245    ///
1246    /// The number of sessions deleted.
1247    pub fn delete_sessions_older_than(&self, before: DateTime<Utc>) -> Result<usize> {
1248        let before_str = before.to_rfc3339();
1249
1250        // Get session IDs to delete
1251        let mut stmt = self
1252            .conn
1253            .prepare("SELECT id FROM sessions WHERE started_at < ?1")?;
1254        let session_ids: Vec<String> = stmt
1255            .query_map(params![before_str], |row| row.get(0))?
1256            .collect::<Result<Vec<_>, _>>()?;
1257
1258        if session_ids.is_empty() {
1259            return Ok(0);
1260        }
1261
1262        let count = session_ids.len();
1263
1264        // Delete associated data for each session
1265        for session_id_str in &session_ids {
1266            // Delete from messages_fts
1267            self.conn.execute(
1268                "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1269                params![session_id_str],
1270            )?;
1271
1272            // Delete messages
1273            self.conn.execute(
1274                "DELETE FROM messages WHERE session_id = ?1",
1275                params![session_id_str],
1276            )?;
1277
1278            // Delete links
1279            self.conn.execute(
1280                "DELETE FROM session_links WHERE session_id = ?1",
1281                params![session_id_str],
1282            )?;
1283
1284            // Delete from sessions_fts
1285            self.conn.execute(
1286                "DELETE FROM sessions_fts WHERE session_id = ?1",
1287                params![session_id_str],
1288            )?;
1289        }
1290
1291        // Delete the sessions
1292        self.conn.execute(
1293            "DELETE FROM sessions WHERE started_at < ?1",
1294            params![before_str],
1295        )?;
1296
1297        Ok(count)
1298    }
1299
1300    /// Counts sessions older than the specified date (for dry-run preview).
1301    ///
1302    /// # Arguments
1303    ///
1304    /// * `before` - Count sessions that started before this date
1305    ///
1306    /// # Returns
1307    ///
1308    /// The number of sessions that would be deleted.
1309    pub fn count_sessions_older_than(&self, before: DateTime<Utc>) -> Result<i32> {
1310        let before_str = before.to_rfc3339();
1311        let count: i32 = self.conn.query_row(
1312            "SELECT COUNT(*) FROM sessions WHERE started_at < ?1",
1313            params![before_str],
1314            |row| row.get(0),
1315        )?;
1316        Ok(count)
1317    }
1318
1319    /// Returns sessions older than the specified date (for dry-run preview).
1320    ///
1321    /// # Arguments
1322    ///
1323    /// * `before` - Return sessions that started before this date
1324    ///
1325    /// # Returns
1326    ///
1327    /// A vector of sessions that would be deleted, ordered by start date.
1328    pub fn get_sessions_older_than(&self, before: DateTime<Utc>) -> Result<Vec<Session>> {
1329        let before_str = before.to_rfc3339();
1330        let mut stmt = self.conn.prepare(
1331            "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count
1332             FROM sessions
1333             WHERE started_at < ?1
1334             ORDER BY started_at ASC",
1335        )?;
1336
1337        let rows = stmt.query_map(params![before_str], Self::row_to_session)?;
1338
1339        rows.collect::<Result<Vec<_>, _>>()
1340            .context("Failed to get sessions older than cutoff")
1341    }
1342
1343    /// Returns database statistics including counts and date ranges.
1344    ///
1345    /// # Returns
1346    ///
1347    /// A `DatabaseStats` struct with session, message, and link counts,
1348    /// plus the date range of sessions and a breakdown by tool.
1349    pub fn stats(&self) -> Result<DatabaseStats> {
1350        let session_count = self.session_count()?;
1351        let message_count = self.message_count()?;
1352        let link_count = self.link_count()?;
1353
1354        // Get date range
1355        let oldest: Option<String> = self
1356            .conn
1357            .query_row("SELECT MIN(started_at) FROM sessions", [], |row| row.get(0))
1358            .optional()?
1359            .flatten();
1360
1361        let newest: Option<String> = self
1362            .conn
1363            .query_row("SELECT MAX(started_at) FROM sessions", [], |row| row.get(0))
1364            .optional()?
1365            .flatten();
1366
1367        let oldest_session = oldest
1368            .map(|s| parse_datetime(&s))
1369            .transpose()
1370            .unwrap_or(None);
1371        let newest_session = newest
1372            .map(|s| parse_datetime(&s))
1373            .transpose()
1374            .unwrap_or(None);
1375
1376        // Get sessions by tool
1377        let mut stmt = self
1378            .conn
1379            .prepare("SELECT tool, COUNT(*) FROM sessions GROUP BY tool ORDER BY COUNT(*) DESC")?;
1380        let sessions_by_tool: Vec<(String, i32)> = stmt
1381            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1382            .collect::<Result<Vec<_>, _>>()?;
1383
1384        Ok(DatabaseStats {
1385            session_count,
1386            message_count,
1387            link_count,
1388            oldest_session,
1389            newest_session,
1390            sessions_by_tool,
1391        })
1392    }
1393}
1394
1395/// Statistics about the Lore database.
1396#[derive(Debug, Clone)]
1397pub struct DatabaseStats {
1398    /// Total number of sessions.
1399    pub session_count: i32,
1400    /// Total number of messages.
1401    pub message_count: i32,
1402    /// Total number of session links.
1403    pub link_count: i32,
1404    /// Timestamp of the oldest session.
1405    pub oldest_session: Option<DateTime<Utc>>,
1406    /// Timestamp of the newest session.
1407    pub newest_session: Option<DateTime<Utc>>,
1408    /// Session counts grouped by tool name.
1409    pub sessions_by_tool: Vec<(String, i32)>,
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414    use super::*;
1415    use crate::storage::models::{
1416        LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
1417    };
1418    use chrono::{Duration, Utc};
1419    use tempfile::tempdir;
1420
1421    /// Creates a test database in a temporary directory.
1422    /// Returns the Database instance and the temp directory (which must be kept alive).
1423    fn create_test_db() -> (Database, tempfile::TempDir) {
1424        let dir = tempdir().expect("Failed to create temp directory");
1425        let db_path = dir.path().join("test.db");
1426        let db = Database::open(&db_path).expect("Failed to open test database");
1427        (db, dir)
1428    }
1429
1430    /// Creates a test session with the given parameters.
1431    fn create_test_session(
1432        tool: &str,
1433        working_directory: &str,
1434        started_at: chrono::DateTime<Utc>,
1435        source_path: Option<&str>,
1436    ) -> Session {
1437        Session {
1438            id: Uuid::new_v4(),
1439            tool: tool.to_string(),
1440            tool_version: Some("1.0.0".to_string()),
1441            started_at,
1442            ended_at: None,
1443            model: Some("test-model".to_string()),
1444            working_directory: working_directory.to_string(),
1445            git_branch: Some("main".to_string()),
1446            source_path: source_path.map(|s| s.to_string()),
1447            message_count: 0,
1448        }
1449    }
1450
1451    /// Creates a test message for the given session.
1452    fn create_test_message(
1453        session_id: Uuid,
1454        index: i32,
1455        role: MessageRole,
1456        content: &str,
1457    ) -> Message {
1458        Message {
1459            id: Uuid::new_v4(),
1460            session_id,
1461            parent_id: None,
1462            index,
1463            timestamp: Utc::now(),
1464            role,
1465            content: MessageContent::Text(content.to_string()),
1466            model: Some("test-model".to_string()),
1467            git_branch: Some("main".to_string()),
1468            cwd: Some("/test/cwd".to_string()),
1469        }
1470    }
1471
1472    /// Creates a test session link for the given session.
1473    fn create_test_link(
1474        session_id: Uuid,
1475        commit_sha: Option<&str>,
1476        link_type: LinkType,
1477    ) -> SessionLink {
1478        SessionLink {
1479            id: Uuid::new_v4(),
1480            session_id,
1481            link_type,
1482            commit_sha: commit_sha.map(|s| s.to_string()),
1483            branch: Some("main".to_string()),
1484            remote: Some("origin".to_string()),
1485            created_at: Utc::now(),
1486            created_by: LinkCreator::Auto,
1487            confidence: Some(0.95),
1488        }
1489    }
1490
1491    // ==================== Session Tests ====================
1492
1493    #[test]
1494    fn test_insert_and_get_session() {
1495        let (db, _dir) = create_test_db();
1496        let session = create_test_session(
1497            "claude-code",
1498            "/home/user/project",
1499            Utc::now(),
1500            Some("/path/to/source.jsonl"),
1501        );
1502
1503        db.insert_session(&session)
1504            .expect("Failed to insert session");
1505
1506        let retrieved = db
1507            .get_session(&session.id)
1508            .expect("Failed to get session")
1509            .expect("Session should exist");
1510
1511        assert_eq!(retrieved.id, session.id, "Session ID should match");
1512        assert_eq!(retrieved.tool, session.tool, "Tool should match");
1513        assert_eq!(
1514            retrieved.tool_version, session.tool_version,
1515            "Tool version should match"
1516        );
1517        assert_eq!(
1518            retrieved.working_directory, session.working_directory,
1519            "Working directory should match"
1520        );
1521        assert_eq!(
1522            retrieved.git_branch, session.git_branch,
1523            "Git branch should match"
1524        );
1525        assert_eq!(
1526            retrieved.source_path, session.source_path,
1527            "Source path should match"
1528        );
1529    }
1530
1531    #[test]
1532    fn test_list_sessions() {
1533        let (db, _dir) = create_test_db();
1534        let now = Utc::now();
1535
1536        // Insert sessions with different timestamps (oldest first)
1537        let session1 =
1538            create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
1539        let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
1540        let session3 = create_test_session("claude-code", "/project3", now, None);
1541
1542        db.insert_session(&session1)
1543            .expect("Failed to insert session1");
1544        db.insert_session(&session2)
1545            .expect("Failed to insert session2");
1546        db.insert_session(&session3)
1547            .expect("Failed to insert session3");
1548
1549        let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
1550
1551        assert_eq!(sessions.len(), 3, "Should have 3 sessions");
1552        // Sessions should be ordered by started_at DESC (most recent first)
1553        assert_eq!(
1554            sessions[0].id, session3.id,
1555            "Most recent session should be first"
1556        );
1557        assert_eq!(
1558            sessions[1].id, session2.id,
1559            "Second most recent session should be second"
1560        );
1561        assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
1562    }
1563
1564    #[test]
1565    fn test_list_sessions_with_working_dir_filter() {
1566        let (db, _dir) = create_test_db();
1567        let now = Utc::now();
1568
1569        let session1 = create_test_session(
1570            "claude-code",
1571            "/home/user/project-a",
1572            now - Duration::hours(1),
1573            None,
1574        );
1575        let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
1576        let session3 = create_test_session("claude-code", "/other/path", now, None);
1577
1578        db.insert_session(&session1)
1579            .expect("Failed to insert session1");
1580        db.insert_session(&session2)
1581            .expect("Failed to insert session2");
1582        db.insert_session(&session3)
1583            .expect("Failed to insert session3");
1584
1585        // Filter by working directory prefix
1586        let sessions = db
1587            .list_sessions(10, Some("/home/user"))
1588            .expect("Failed to list sessions");
1589
1590        assert_eq!(
1591            sessions.len(),
1592            2,
1593            "Should have 2 sessions matching /home/user prefix"
1594        );
1595
1596        // Verify both matching sessions are returned
1597        let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
1598        assert!(ids.contains(&session1.id), "Should contain session1");
1599        assert!(ids.contains(&session2.id), "Should contain session2");
1600        assert!(!ids.contains(&session3.id), "Should not contain session3");
1601    }
1602
1603    #[test]
1604    fn test_session_exists_by_source() {
1605        let (db, _dir) = create_test_db();
1606        let source_path = "/path/to/session.jsonl";
1607
1608        let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
1609
1610        // Before insert, should not exist
1611        assert!(
1612            !db.session_exists_by_source(source_path)
1613                .expect("Failed to check existence"),
1614            "Session should not exist before insert"
1615        );
1616
1617        db.insert_session(&session)
1618            .expect("Failed to insert session");
1619
1620        // After insert, should exist
1621        assert!(
1622            db.session_exists_by_source(source_path)
1623                .expect("Failed to check existence"),
1624            "Session should exist after insert"
1625        );
1626
1627        // Different path should not exist
1628        assert!(
1629            !db.session_exists_by_source("/other/path.jsonl")
1630                .expect("Failed to check existence"),
1631            "Different source path should not exist"
1632        );
1633    }
1634
1635    #[test]
1636    fn test_update_session_branch() {
1637        let (db, _dir) = create_test_db();
1638        let now = Utc::now();
1639
1640        // Create session with initial branch
1641        let mut session = create_test_session("claude-code", "/project", now, None);
1642        session.git_branch = Some("main".to_string());
1643
1644        db.insert_session(&session)
1645            .expect("Failed to insert session");
1646
1647        // Verify initial branch
1648        let fetched = db
1649            .get_session(&session.id)
1650            .expect("Failed to get session")
1651            .expect("Session should exist");
1652        assert_eq!(fetched.git_branch, Some("main".to_string()));
1653
1654        // Update branch
1655        let rows = db
1656            .update_session_branch(session.id, "feature-branch")
1657            .expect("Failed to update branch");
1658        assert_eq!(rows, 1, "Should update exactly one row");
1659
1660        // Verify updated branch
1661        let fetched = db
1662            .get_session(&session.id)
1663            .expect("Failed to get session")
1664            .expect("Session should exist");
1665        assert_eq!(fetched.git_branch, Some("feature-branch".to_string()));
1666    }
1667
1668    #[test]
1669    fn test_update_session_branch_nonexistent() {
1670        let (db, _dir) = create_test_db();
1671        let nonexistent_id = Uuid::new_v4();
1672
1673        // Updating a nonexistent session should return 0 rows
1674        let rows = db
1675            .update_session_branch(nonexistent_id, "some-branch")
1676            .expect("Failed to update branch");
1677        assert_eq!(
1678            rows, 0,
1679            "Should not update any rows for nonexistent session"
1680        );
1681    }
1682
1683    #[test]
1684    fn test_update_session_branch_from_none() {
1685        let (db, _dir) = create_test_db();
1686        let now = Utc::now();
1687
1688        // Create session without initial branch
1689        let mut session = create_test_session("claude-code", "/project", now, None);
1690        session.git_branch = None; // Explicitly set to None for this test
1691
1692        db.insert_session(&session)
1693            .expect("Failed to insert session");
1694
1695        // Verify no initial branch
1696        let fetched = db
1697            .get_session(&session.id)
1698            .expect("Failed to get session")
1699            .expect("Session should exist");
1700        assert_eq!(fetched.git_branch, None);
1701
1702        // Update branch from None to a value
1703        let rows = db
1704            .update_session_branch(session.id, "new-branch")
1705            .expect("Failed to update branch");
1706        assert_eq!(rows, 1, "Should update exactly one row");
1707
1708        // Verify updated branch
1709        let fetched = db
1710            .get_session(&session.id)
1711            .expect("Failed to get session")
1712            .expect("Session should exist");
1713        assert_eq!(fetched.git_branch, Some("new-branch".to_string()));
1714    }
1715
1716    #[test]
1717    fn test_get_nonexistent_session() {
1718        let (db, _dir) = create_test_db();
1719        let nonexistent_id = Uuid::new_v4();
1720
1721        let result = db
1722            .get_session(&nonexistent_id)
1723            .expect("Failed to query for nonexistent session");
1724
1725        assert!(
1726            result.is_none(),
1727            "Should return None for nonexistent session"
1728        );
1729    }
1730
1731    // ==================== Message Tests ====================
1732
1733    #[test]
1734    fn test_insert_and_get_messages() {
1735        let (db, _dir) = create_test_db();
1736
1737        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1738        db.insert_session(&session)
1739            .expect("Failed to insert session");
1740
1741        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
1742        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
1743
1744        db.insert_message(&msg1)
1745            .expect("Failed to insert message 1");
1746        db.insert_message(&msg2)
1747            .expect("Failed to insert message 2");
1748
1749        let messages = db
1750            .get_messages(&session.id)
1751            .expect("Failed to get messages");
1752
1753        assert_eq!(messages.len(), 2, "Should have 2 messages");
1754        assert_eq!(messages[0].id, msg1.id, "First message ID should match");
1755        assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
1756        assert_eq!(
1757            messages[0].role,
1758            MessageRole::User,
1759            "First message role should be User"
1760        );
1761        assert_eq!(
1762            messages[1].role,
1763            MessageRole::Assistant,
1764            "Second message role should be Assistant"
1765        );
1766    }
1767
1768    #[test]
1769    fn test_messages_ordered_by_index() {
1770        let (db, _dir) = create_test_db();
1771
1772        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1773        db.insert_session(&session)
1774            .expect("Failed to insert session");
1775
1776        // Insert messages out of order
1777        let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
1778        let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
1779        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
1780
1781        db.insert_message(&msg3)
1782            .expect("Failed to insert message 3");
1783        db.insert_message(&msg1)
1784            .expect("Failed to insert message 1");
1785        db.insert_message(&msg2)
1786            .expect("Failed to insert message 2");
1787
1788        let messages = db
1789            .get_messages(&session.id)
1790            .expect("Failed to get messages");
1791
1792        assert_eq!(messages.len(), 3, "Should have 3 messages");
1793        assert_eq!(messages[0].index, 0, "First message should have index 0");
1794        assert_eq!(messages[1].index, 1, "Second message should have index 1");
1795        assert_eq!(messages[2].index, 2, "Third message should have index 2");
1796
1797        // Verify content matches expected order
1798        assert_eq!(
1799            messages[0].content.text(),
1800            "First",
1801            "First message content should be 'First'"
1802        );
1803        assert_eq!(
1804            messages[1].content.text(),
1805            "Second",
1806            "Second message content should be 'Second'"
1807        );
1808        assert_eq!(
1809            messages[2].content.text(),
1810            "Third",
1811            "Third message content should be 'Third'"
1812        );
1813    }
1814
1815    // ==================== SessionLink Tests ====================
1816
1817    #[test]
1818    fn test_insert_and_get_links_by_session() {
1819        let (db, _dir) = create_test_db();
1820
1821        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1822        db.insert_session(&session)
1823            .expect("Failed to insert session");
1824
1825        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
1826        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
1827
1828        db.insert_link(&link1).expect("Failed to insert link 1");
1829        db.insert_link(&link2).expect("Failed to insert link 2");
1830
1831        let links = db
1832            .get_links_by_session(&session.id)
1833            .expect("Failed to get links");
1834
1835        assert_eq!(links.len(), 2, "Should have 2 links");
1836
1837        let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
1838        assert!(link_ids.contains(&link1.id), "Should contain link1");
1839        assert!(link_ids.contains(&link2.id), "Should contain link2");
1840
1841        // Verify link properties
1842        let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
1843        assert_eq!(
1844            retrieved_link.commit_sha,
1845            Some("abc123def456".to_string()),
1846            "Commit SHA should match"
1847        );
1848        assert_eq!(
1849            retrieved_link.link_type,
1850            LinkType::Commit,
1851            "Link type should be Commit"
1852        );
1853        assert_eq!(
1854            retrieved_link.created_by,
1855            LinkCreator::Auto,
1856            "Created by should be Auto"
1857        );
1858    }
1859
1860    #[test]
1861    fn test_get_links_by_commit() {
1862        let (db, _dir) = create_test_db();
1863
1864        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1865        db.insert_session(&session)
1866            .expect("Failed to insert session");
1867
1868        let full_sha = "abc123def456789012345678901234567890abcd";
1869        let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
1870        db.insert_link(&link).expect("Failed to insert link");
1871
1872        // Test full SHA match
1873        let links_full = db
1874            .get_links_by_commit(full_sha)
1875            .expect("Failed to get links by full SHA");
1876        assert_eq!(links_full.len(), 1, "Should find link by full SHA");
1877        assert_eq!(links_full[0].id, link.id, "Link ID should match");
1878
1879        // Test partial SHA match (prefix)
1880        let links_partial = db
1881            .get_links_by_commit("abc123")
1882            .expect("Failed to get links by partial SHA");
1883        assert_eq!(
1884            links_partial.len(),
1885            1,
1886            "Should find link by partial SHA prefix"
1887        );
1888        assert_eq!(links_partial[0].id, link.id, "Link ID should match");
1889
1890        // Test non-matching SHA
1891        let links_none = db
1892            .get_links_by_commit("zzz999")
1893            .expect("Failed to get links by non-matching SHA");
1894        assert_eq!(
1895            links_none.len(),
1896            0,
1897            "Should not find link with non-matching SHA"
1898        );
1899    }
1900
1901    // ==================== Database Tests ====================
1902
1903    #[test]
1904    fn test_database_creation() {
1905        let dir = tempdir().expect("Failed to create temp directory");
1906        let db_path = dir.path().join("new_test.db");
1907
1908        // Database should not exist before creation
1909        assert!(
1910            !db_path.exists(),
1911            "Database file should not exist before creation"
1912        );
1913
1914        let db = Database::open(&db_path).expect("Failed to create database");
1915
1916        // Database file should exist after creation
1917        assert!(
1918            db_path.exists(),
1919            "Database file should exist after creation"
1920        );
1921
1922        // Verify tables exist by attempting operations
1923        let session_count = db.session_count().expect("Failed to get session count");
1924        assert_eq!(session_count, 0, "New database should have 0 sessions");
1925
1926        let message_count = db.message_count().expect("Failed to get message count");
1927        assert_eq!(message_count, 0, "New database should have 0 messages");
1928    }
1929
1930    #[test]
1931    fn test_session_count() {
1932        let (db, _dir) = create_test_db();
1933
1934        assert_eq!(
1935            db.session_count().expect("Failed to get count"),
1936            0,
1937            "Initial session count should be 0"
1938        );
1939
1940        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
1941        db.insert_session(&session1)
1942            .expect("Failed to insert session1");
1943
1944        assert_eq!(
1945            db.session_count().expect("Failed to get count"),
1946            1,
1947            "Session count should be 1 after first insert"
1948        );
1949
1950        let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
1951        db.insert_session(&session2)
1952            .expect("Failed to insert session2");
1953
1954        assert_eq!(
1955            db.session_count().expect("Failed to get count"),
1956            2,
1957            "Session count should be 2 after second insert"
1958        );
1959    }
1960
1961    #[test]
1962    fn test_message_count() {
1963        let (db, _dir) = create_test_db();
1964
1965        assert_eq!(
1966            db.message_count().expect("Failed to get count"),
1967            0,
1968            "Initial message count should be 0"
1969        );
1970
1971        let session = create_test_session("claude-code", "/project", Utc::now(), None);
1972        db.insert_session(&session)
1973            .expect("Failed to insert session");
1974
1975        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
1976        db.insert_message(&msg1).expect("Failed to insert message1");
1977
1978        assert_eq!(
1979            db.message_count().expect("Failed to get count"),
1980            1,
1981            "Message count should be 1 after first insert"
1982        );
1983
1984        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
1985        let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
1986        db.insert_message(&msg2).expect("Failed to insert message2");
1987        db.insert_message(&msg3).expect("Failed to insert message3");
1988
1989        assert_eq!(
1990            db.message_count().expect("Failed to get count"),
1991            3,
1992            "Message count should be 3 after all inserts"
1993        );
1994    }
1995
1996    #[test]
1997    fn test_link_count() {
1998        let (db, _dir) = create_test_db();
1999
2000        assert_eq!(
2001            db.link_count().expect("Failed to get count"),
2002            0,
2003            "Initial link count should be 0"
2004        );
2005
2006        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2007        db.insert_session(&session)
2008            .expect("Failed to insert session");
2009
2010        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2011        db.insert_link(&link1).expect("Failed to insert link1");
2012
2013        assert_eq!(
2014            db.link_count().expect("Failed to get count"),
2015            1,
2016            "Link count should be 1 after first insert"
2017        );
2018
2019        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2020        db.insert_link(&link2).expect("Failed to insert link2");
2021
2022        assert_eq!(
2023            db.link_count().expect("Failed to get count"),
2024            2,
2025            "Link count should be 2 after second insert"
2026        );
2027    }
2028
2029    #[test]
2030    fn test_db_path() {
2031        let dir = tempdir().expect("Failed to create temp directory");
2032        let db_path = dir.path().join("test.db");
2033        let db = Database::open(&db_path).expect("Failed to open test database");
2034
2035        let retrieved_path = db.db_path();
2036        assert!(
2037            retrieved_path.is_some(),
2038            "Database path should be available"
2039        );
2040
2041        // Canonicalize both paths to handle macOS /var -> /private/var symlinks
2042        let expected = db_path.canonicalize().unwrap_or(db_path);
2043        let actual = retrieved_path.unwrap();
2044        let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
2045
2046        assert_eq!(
2047            actual_canonical, expected,
2048            "Database path should match (after canonicalization)"
2049        );
2050    }
2051
2052    // ==================== Search Tests ====================
2053
2054    #[test]
2055    fn test_search_messages_basic() {
2056        let (db, _dir) = create_test_db();
2057
2058        let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
2059        db.insert_session(&session)
2060            .expect("Failed to insert session");
2061
2062        let msg1 = create_test_message(
2063            session.id,
2064            0,
2065            MessageRole::User,
2066            "How do I implement error handling in Rust?",
2067        );
2068        let msg2 = create_test_message(
2069            session.id,
2070            1,
2071            MessageRole::Assistant,
2072            "You can use Result types for error handling. The anyhow crate is also helpful.",
2073        );
2074
2075        db.insert_message(&msg1)
2076            .expect("Failed to insert message 1");
2077        db.insert_message(&msg2)
2078            .expect("Failed to insert message 2");
2079
2080        // Search for "error"
2081        let results = db
2082            .search_messages("error", 10, None, None, None)
2083            .expect("Failed to search");
2084
2085        assert_eq!(
2086            results.len(),
2087            2,
2088            "Should find 2 messages containing 'error'"
2089        );
2090    }
2091
2092    #[test]
2093    fn test_search_messages_no_results() {
2094        let (db, _dir) = create_test_db();
2095
2096        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2097        db.insert_session(&session)
2098            .expect("Failed to insert session");
2099
2100        let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
2101        db.insert_message(&msg).expect("Failed to insert message");
2102
2103        // Search for something not in the messages
2104        let results = db
2105            .search_messages("nonexistent_term_xyz", 10, None, None, None)
2106            .expect("Failed to search");
2107
2108        assert!(results.is_empty(), "Should find no results");
2109    }
2110
2111    #[test]
2112    fn test_search_messages_with_role_filter() {
2113        let (db, _dir) = create_test_db();
2114
2115        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2116        db.insert_session(&session)
2117            .expect("Failed to insert session");
2118
2119        let msg1 = create_test_message(
2120            session.id,
2121            0,
2122            MessageRole::User,
2123            "Tell me about Rust programming",
2124        );
2125        let msg2 = create_test_message(
2126            session.id,
2127            1,
2128            MessageRole::Assistant,
2129            "Rust is a systems programming language",
2130        );
2131
2132        db.insert_message(&msg1)
2133            .expect("Failed to insert message 1");
2134        db.insert_message(&msg2)
2135            .expect("Failed to insert message 2");
2136
2137        // Search with user role filter
2138        let user_results = db
2139            .search_messages("programming", 10, None, None, Some("user"))
2140            .expect("Failed to search");
2141
2142        assert_eq!(user_results.len(), 1, "Should find 1 user message");
2143        assert_eq!(
2144            user_results[0].role,
2145            MessageRole::User,
2146            "Result should be from user"
2147        );
2148
2149        // Search with assistant role filter
2150        let assistant_results = db
2151            .search_messages("programming", 10, None, None, Some("assistant"))
2152            .expect("Failed to search");
2153
2154        assert_eq!(
2155            assistant_results.len(),
2156            1,
2157            "Should find 1 assistant message"
2158        );
2159        assert_eq!(
2160            assistant_results[0].role,
2161            MessageRole::Assistant,
2162            "Result should be from assistant"
2163        );
2164    }
2165
2166    #[test]
2167    fn test_search_messages_with_repo_filter() {
2168        let (db, _dir) = create_test_db();
2169
2170        let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
2171        let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
2172
2173        db.insert_session(&session1).expect("insert 1");
2174        db.insert_session(&session2).expect("insert 2");
2175
2176        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
2177        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
2178
2179        db.insert_message(&msg1).expect("insert msg 1");
2180        db.insert_message(&msg2).expect("insert msg 2");
2181
2182        // Search with repo filter
2183        let results = db
2184            .search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
2185            .expect("Failed to search");
2186
2187        assert_eq!(results.len(), 1, "Should find 1 message in project-a");
2188        assert!(
2189            results[0].working_directory.contains("project-a"),
2190            "Should be from project-a"
2191        );
2192    }
2193
2194    #[test]
2195    fn test_search_messages_limit() {
2196        let (db, _dir) = create_test_db();
2197
2198        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2199        db.insert_session(&session).expect("insert session");
2200
2201        // Insert 5 messages all containing "test"
2202        for i in 0..5 {
2203            let msg = create_test_message(
2204                session.id,
2205                i,
2206                MessageRole::User,
2207                &format!("This is test message number {i}"),
2208            );
2209            db.insert_message(&msg).expect("insert message");
2210        }
2211
2212        // Search with limit of 3
2213        let results = db
2214            .search_messages("test", 3, None, None, None)
2215            .expect("Failed to search");
2216
2217        assert_eq!(results.len(), 3, "Should respect limit of 3");
2218    }
2219
2220    #[test]
2221    fn test_search_index_needs_rebuild_empty_db() {
2222        let (db, _dir) = create_test_db();
2223
2224        let needs_rebuild = db
2225            .search_index_needs_rebuild()
2226            .expect("Failed to check rebuild status");
2227
2228        assert!(!needs_rebuild, "Empty database should not need rebuild");
2229    }
2230
2231    #[test]
2232    fn test_rebuild_search_index() {
2233        let (db, _dir) = create_test_db();
2234
2235        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2236        db.insert_session(&session).expect("insert session");
2237
2238        let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
2239        let msg2 = create_test_message(
2240            session.id,
2241            1,
2242            MessageRole::Assistant,
2243            "Second test response",
2244        );
2245
2246        db.insert_message(&msg1).expect("insert msg 1");
2247        db.insert_message(&msg2).expect("insert msg 2");
2248
2249        // Clear and rebuild the index
2250        db.conn
2251            .execute("DELETE FROM messages_fts", [])
2252            .expect("clear fts");
2253
2254        // Index should now need rebuilding
2255        assert!(
2256            db.search_index_needs_rebuild().expect("check rebuild"),
2257            "Should need rebuild after clearing FTS"
2258        );
2259
2260        // Rebuild
2261        let count = db.rebuild_search_index().expect("rebuild");
2262        assert_eq!(count, 2, "Should have indexed 2 messages");
2263
2264        // Index should no longer need rebuilding
2265        assert!(
2266            !db.search_index_needs_rebuild().expect("check rebuild"),
2267            "Should not need rebuild after rebuilding"
2268        );
2269
2270        // Search should work
2271        let results = db
2272            .search_messages("test", 10, None, None, None)
2273            .expect("search");
2274        assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
2275    }
2276
2277    #[test]
2278    fn test_search_with_block_content() {
2279        let (db, _dir) = create_test_db();
2280
2281        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2282        db.insert_session(&session).expect("insert session");
2283
2284        // Create a message with block content
2285        let block_content = MessageContent::Blocks(vec![
2286            crate::storage::models::ContentBlock::Text {
2287                text: "Let me help with your database query.".to_string(),
2288            },
2289            crate::storage::models::ContentBlock::ToolUse {
2290                id: "tool_123".to_string(),
2291                name: "Bash".to_string(),
2292                input: serde_json::json!({"command": "ls -la"}),
2293            },
2294        ]);
2295
2296        let msg = Message {
2297            id: Uuid::new_v4(),
2298            session_id: session.id,
2299            parent_id: None,
2300            index: 0,
2301            timestamp: Utc::now(),
2302            role: MessageRole::Assistant,
2303            content: block_content,
2304            model: Some("claude-opus-4".to_string()),
2305            git_branch: Some("main".to_string()),
2306            cwd: Some("/project".to_string()),
2307        };
2308
2309        db.insert_message(&msg).expect("insert message");
2310
2311        // Search should find text from blocks
2312        let results = db
2313            .search_messages("database", 10, None, None, None)
2314            .expect("search");
2315
2316        assert_eq!(results.len(), 1, "Should find message with block content");
2317    }
2318
2319    #[test]
2320    fn test_search_result_contains_session_info() {
2321        let (db, _dir) = create_test_db();
2322
2323        let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
2324        db.insert_session(&session).expect("insert session");
2325
2326        let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
2327        db.insert_message(&msg).expect("insert message");
2328
2329        let results = db
2330            .search_messages("Search", 10, None, None, None)
2331            .expect("search");
2332
2333        assert_eq!(results.len(), 1, "Should find 1 result");
2334        assert_eq!(results[0].session_id, session.id, "Session ID should match");
2335        assert_eq!(results[0].message_id, msg.id, "Message ID should match");
2336        assert_eq!(
2337            results[0].working_directory, "/home/user/my-project",
2338            "Working directory should match"
2339        );
2340        assert_eq!(results[0].role, MessageRole::User, "Role should match");
2341    }
2342
2343    // ==================== Delete Link Tests ====================
2344
2345    #[test]
2346    fn test_delete_link_by_id() {
2347        let (db, _dir) = create_test_db();
2348
2349        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2350        db.insert_session(&session)
2351            .expect("Failed to insert session");
2352
2353        let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2354        db.insert_link(&link).expect("Failed to insert link");
2355
2356        // Verify link exists
2357        let links_before = db
2358            .get_links_by_session(&session.id)
2359            .expect("Failed to get links");
2360        assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
2361
2362        // Delete the link
2363        let deleted = db.delete_link(&link.id).expect("Failed to delete link");
2364        assert!(deleted, "Should return true when link is deleted");
2365
2366        // Verify link is gone
2367        let links_after = db
2368            .get_links_by_session(&session.id)
2369            .expect("Failed to get links");
2370        assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
2371    }
2372
2373    #[test]
2374    fn test_delete_link_nonexistent() {
2375        let (db, _dir) = create_test_db();
2376
2377        let nonexistent_id = Uuid::new_v4();
2378        let deleted = db
2379            .delete_link(&nonexistent_id)
2380            .expect("Failed to call delete_link");
2381
2382        assert!(!deleted, "Should return false for nonexistent link");
2383    }
2384
2385    #[test]
2386    fn test_delete_links_by_session() {
2387        let (db, _dir) = create_test_db();
2388
2389        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2390        db.insert_session(&session)
2391            .expect("Failed to insert session");
2392
2393        // Create multiple links for the same session
2394        let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
2395        let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
2396        let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
2397
2398        db.insert_link(&link1).expect("Failed to insert link1");
2399        db.insert_link(&link2).expect("Failed to insert link2");
2400        db.insert_link(&link3).expect("Failed to insert link3");
2401
2402        // Verify all links exist
2403        let links_before = db
2404            .get_links_by_session(&session.id)
2405            .expect("Failed to get links");
2406        assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
2407
2408        // Delete all links for the session
2409        let count = db
2410            .delete_links_by_session(&session.id)
2411            .expect("Failed to delete links");
2412        assert_eq!(count, 3, "Should have deleted 3 links");
2413
2414        // Verify all links are gone
2415        let links_after = db
2416            .get_links_by_session(&session.id)
2417            .expect("Failed to get links");
2418        assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
2419    }
2420
2421    #[test]
2422    fn test_delete_links_by_session_no_links() {
2423        let (db, _dir) = create_test_db();
2424
2425        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2426        db.insert_session(&session)
2427            .expect("Failed to insert session");
2428
2429        // Delete links for session that has none
2430        let count = db
2431            .delete_links_by_session(&session.id)
2432            .expect("Failed to call delete_links_by_session");
2433        assert_eq!(count, 0, "Should return 0 when no links exist");
2434    }
2435
2436    #[test]
2437    fn test_delete_links_by_session_preserves_other_sessions() {
2438        let (db, _dir) = create_test_db();
2439
2440        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2441        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2442
2443        db.insert_session(&session1)
2444            .expect("Failed to insert session1");
2445        db.insert_session(&session2)
2446            .expect("Failed to insert session2");
2447
2448        let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2449        let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
2450
2451        db.insert_link(&link1).expect("Failed to insert link1");
2452        db.insert_link(&link2).expect("Failed to insert link2");
2453
2454        // Delete links only for session1
2455        let count = db
2456            .delete_links_by_session(&session1.id)
2457            .expect("Failed to delete links");
2458        assert_eq!(count, 1, "Should have deleted 1 link");
2459
2460        // Verify session2's link is preserved
2461        let session2_links = db
2462            .get_links_by_session(&session2.id)
2463            .expect("Failed to get links");
2464        assert_eq!(
2465            session2_links.len(),
2466            1,
2467            "Session2's link should be preserved"
2468        );
2469        assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
2470    }
2471
2472    #[test]
2473    fn test_delete_link_by_session_and_commit() {
2474        let (db, _dir) = create_test_db();
2475
2476        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2477        db.insert_session(&session)
2478            .expect("Failed to insert session");
2479
2480        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2481        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2482
2483        db.insert_link(&link1).expect("Failed to insert link1");
2484        db.insert_link(&link2).expect("Failed to insert link2");
2485
2486        // Delete only the first link by commit SHA
2487        let deleted = db
2488            .delete_link_by_session_and_commit(&session.id, "abc123")
2489            .expect("Failed to delete link");
2490        assert!(deleted, "Should return true when link is deleted");
2491
2492        // Verify only link2 remains
2493        let links = db
2494            .get_links_by_session(&session.id)
2495            .expect("Failed to get links");
2496        assert_eq!(links.len(), 1, "Should have 1 link remaining");
2497        assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
2498    }
2499
2500    #[test]
2501    fn test_delete_link_by_session_and_commit_full_sha() {
2502        let (db, _dir) = create_test_db();
2503
2504        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2505        db.insert_session(&session)
2506            .expect("Failed to insert session");
2507
2508        let full_sha = "abc123def456789012345678901234567890abcd";
2509        let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
2510        db.insert_link(&link).expect("Failed to insert link");
2511
2512        // Delete using full SHA
2513        let deleted = db
2514            .delete_link_by_session_and_commit(&session.id, full_sha)
2515            .expect("Failed to delete link");
2516        assert!(deleted, "Should delete with full SHA");
2517
2518        let links = db
2519            .get_links_by_session(&session.id)
2520            .expect("Failed to get links");
2521        assert_eq!(links.len(), 0, "Should have 0 links after delete");
2522    }
2523
2524    #[test]
2525    fn test_delete_link_by_session_and_commit_no_match() {
2526        let (db, _dir) = create_test_db();
2527
2528        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2529        db.insert_session(&session)
2530            .expect("Failed to insert session");
2531
2532        let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
2533        db.insert_link(&link).expect("Failed to insert link");
2534
2535        // Try to delete with non-matching commit
2536        let deleted = db
2537            .delete_link_by_session_and_commit(&session.id, "xyz999")
2538            .expect("Failed to call delete");
2539        assert!(!deleted, "Should return false when no match");
2540
2541        // Verify original link is preserved
2542        let links = db
2543            .get_links_by_session(&session.id)
2544            .expect("Failed to get links");
2545        assert_eq!(links.len(), 1, "Link should be preserved");
2546    }
2547
2548    #[test]
2549    fn test_delete_link_by_session_and_commit_wrong_session() {
2550        let (db, _dir) = create_test_db();
2551
2552        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2553        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2554
2555        db.insert_session(&session1)
2556            .expect("Failed to insert session1");
2557        db.insert_session(&session2)
2558            .expect("Failed to insert session2");
2559
2560        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2561        db.insert_link(&link).expect("Failed to insert link");
2562
2563        // Try to delete from wrong session
2564        let deleted = db
2565            .delete_link_by_session_and_commit(&session2.id, "abc123")
2566            .expect("Failed to call delete");
2567        assert!(!deleted, "Should not delete link from different session");
2568
2569        // Verify original link is preserved
2570        let links = db
2571            .get_links_by_session(&session1.id)
2572            .expect("Failed to get links");
2573        assert_eq!(links.len(), 1, "Link should be preserved");
2574    }
2575
2576    // ==================== Auto-linking Tests ====================
2577
2578    #[test]
2579    fn test_find_sessions_near_commit_time_basic() {
2580        let (db, _dir) = create_test_db();
2581        let now = Utc::now();
2582
2583        // Create a session that ended 10 minutes ago
2584        let mut session = create_test_session(
2585            "claude-code",
2586            "/home/user/project",
2587            now - Duration::hours(1),
2588            None,
2589        );
2590        session.ended_at = Some(now - Duration::minutes(10));
2591
2592        db.insert_session(&session).expect("insert session");
2593
2594        // Find sessions near "now" with a 30 minute window
2595        let found = db
2596            .find_sessions_near_commit_time(now, 30, None)
2597            .expect("find sessions");
2598
2599        assert_eq!(found.len(), 1, "Should find session within window");
2600        assert_eq!(found[0].id, session.id);
2601    }
2602
2603    #[test]
2604    fn test_find_sessions_near_commit_time_outside_window() {
2605        let (db, _dir) = create_test_db();
2606        let now = Utc::now();
2607
2608        // Create a session that ended 2 hours ago
2609        let mut session =
2610            create_test_session("claude-code", "/project", now - Duration::hours(3), None);
2611        session.ended_at = Some(now - Duration::hours(2));
2612
2613        db.insert_session(&session).expect("insert session");
2614
2615        // Find sessions near "now" with a 30 minute window
2616        let found = db
2617            .find_sessions_near_commit_time(now, 30, None)
2618            .expect("find sessions");
2619
2620        assert!(found.is_empty(), "Should not find session outside window");
2621    }
2622
2623    #[test]
2624    fn test_find_sessions_near_commit_time_with_working_dir() {
2625        let (db, _dir) = create_test_db();
2626        let now = Utc::now();
2627
2628        // Create sessions in different directories
2629        let mut session1 = create_test_session(
2630            "claude-code",
2631            "/home/user/project-a",
2632            now - Duration::minutes(30),
2633            None,
2634        );
2635        session1.ended_at = Some(now - Duration::minutes(5));
2636
2637        let mut session2 = create_test_session(
2638            "claude-code",
2639            "/home/user/project-b",
2640            now - Duration::minutes(30),
2641            None,
2642        );
2643        session2.ended_at = Some(now - Duration::minutes(5));
2644
2645        db.insert_session(&session1).expect("insert session1");
2646        db.insert_session(&session2).expect("insert session2");
2647
2648        // Find sessions near "now" filtering by project-a
2649        let found = db
2650            .find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
2651            .expect("find sessions");
2652
2653        assert_eq!(found.len(), 1, "Should find only session in project-a");
2654        assert_eq!(found[0].id, session1.id);
2655    }
2656
2657    #[test]
2658    fn test_find_sessions_near_commit_time_ongoing_session() {
2659        let (db, _dir) = create_test_db();
2660        let now = Utc::now();
2661
2662        // Create an ongoing session (no ended_at)
2663        let session =
2664            create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
2665        // ended_at is None by default
2666
2667        db.insert_session(&session).expect("insert session");
2668
2669        // Find sessions near "now"
2670        let found = db
2671            .find_sessions_near_commit_time(now, 30, None)
2672            .expect("find sessions");
2673
2674        assert_eq!(found.len(), 1, "Should find ongoing session");
2675        assert_eq!(found[0].id, session.id);
2676    }
2677
2678    #[test]
2679    fn test_link_exists_true() {
2680        let (db, _dir) = create_test_db();
2681
2682        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2683        db.insert_session(&session).expect("insert session");
2684
2685        let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2686        db.insert_link(&link).expect("insert link");
2687
2688        // Check with full SHA
2689        assert!(
2690            db.link_exists(&session.id, "abc123def456")
2691                .expect("check exists"),
2692            "Should find link with full SHA"
2693        );
2694
2695        // Check with partial SHA
2696        assert!(
2697            db.link_exists(&session.id, "abc123").expect("check exists"),
2698            "Should find link with partial SHA"
2699        );
2700    }
2701
2702    #[test]
2703    fn test_link_exists_false() {
2704        let (db, _dir) = create_test_db();
2705
2706        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2707        db.insert_session(&session).expect("insert session");
2708
2709        // No links created
2710        assert!(
2711            !db.link_exists(&session.id, "abc123").expect("check exists"),
2712            "Should not find non-existent link"
2713        );
2714    }
2715
2716    #[test]
2717    fn test_link_exists_different_session() {
2718        let (db, _dir) = create_test_db();
2719
2720        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2721        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2722
2723        db.insert_session(&session1).expect("insert session1");
2724        db.insert_session(&session2).expect("insert session2");
2725
2726        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2727        db.insert_link(&link).expect("insert link");
2728
2729        // Link exists for session1 but not session2
2730        assert!(
2731            db.link_exists(&session1.id, "abc123").expect("check"),
2732            "Should find link for session1"
2733        );
2734        assert!(
2735            !db.link_exists(&session2.id, "abc123").expect("check"),
2736            "Should not find link for session2"
2737        );
2738    }
2739
2740    // ==================== Enhanced Search Tests ====================
2741
2742    #[test]
2743    fn test_search_with_tool_filter() {
2744        let (db, _dir) = create_test_db();
2745
2746        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2747        let session2 = create_test_session("aider", "/project2", Utc::now(), None);
2748
2749        db.insert_session(&session1).expect("insert session1");
2750        db.insert_session(&session2).expect("insert session2");
2751
2752        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
2753        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
2754
2755        db.insert_message(&msg1).expect("insert msg1");
2756        db.insert_message(&msg2).expect("insert msg2");
2757
2758        // Search with tool filter
2759        let options = SearchOptions {
2760            query: "Hello".to_string(),
2761            limit: 10,
2762            tool: Some("claude-code".to_string()),
2763            ..Default::default()
2764        };
2765        let results = db.search_with_options(&options).expect("search");
2766
2767        assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
2768        assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
2769    }
2770
2771    #[test]
2772    fn test_search_with_date_range() {
2773        let (db, _dir) = create_test_db();
2774
2775        let old_time = Utc::now() - chrono::Duration::days(30);
2776        let new_time = Utc::now() - chrono::Duration::days(1);
2777
2778        let session1 = create_test_session("claude-code", "/project1", old_time, None);
2779        let session2 = create_test_session("claude-code", "/project2", new_time, None);
2780
2781        db.insert_session(&session1).expect("insert session1");
2782        db.insert_session(&session2).expect("insert session2");
2783
2784        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
2785        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
2786
2787        db.insert_message(&msg1).expect("insert msg1");
2788        db.insert_message(&msg2).expect("insert msg2");
2789
2790        // Search with since filter (last 7 days)
2791        let since = Utc::now() - chrono::Duration::days(7);
2792        let options = SearchOptions {
2793            query: "session".to_string(),
2794            limit: 10,
2795            since: Some(since),
2796            ..Default::default()
2797        };
2798        let results = db.search_with_options(&options).expect("search");
2799
2800        assert_eq!(results.len(), 1, "Should find 1 result within date range");
2801        assert!(
2802            results[0].working_directory.contains("project2"),
2803            "Should be from newer project"
2804        );
2805    }
2806
2807    #[test]
2808    fn test_search_with_project_filter() {
2809        let (db, _dir) = create_test_db();
2810
2811        let session1 =
2812            create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
2813        let session2 =
2814            create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
2815
2816        db.insert_session(&session1).expect("insert session1");
2817        db.insert_session(&session2).expect("insert session2");
2818
2819        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
2820        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
2821
2822        db.insert_message(&msg1).expect("insert msg1");
2823        db.insert_message(&msg2).expect("insert msg2");
2824
2825        // Search with project filter
2826        let options = SearchOptions {
2827            query: "Testing".to_string(),
2828            limit: 10,
2829            project: Some("frontend".to_string()),
2830            ..Default::default()
2831        };
2832        let results = db.search_with_options(&options).expect("search");
2833
2834        assert_eq!(results.len(), 1, "Should find 1 result with project filter");
2835        assert!(
2836            results[0].working_directory.contains("frontend"),
2837            "Should be from frontend project"
2838        );
2839    }
2840
2841    #[test]
2842    fn test_search_with_branch_filter() {
2843        let (db, _dir) = create_test_db();
2844
2845        let session1 = Session {
2846            id: Uuid::new_v4(),
2847            tool: "claude-code".to_string(),
2848            tool_version: None,
2849            started_at: Utc::now(),
2850            ended_at: None,
2851            model: None,
2852            working_directory: "/project".to_string(),
2853            git_branch: Some("feat/auth".to_string()),
2854            source_path: None,
2855            message_count: 0,
2856        };
2857        let session2 = Session {
2858            id: Uuid::new_v4(),
2859            tool: "claude-code".to_string(),
2860            tool_version: None,
2861            started_at: Utc::now(),
2862            ended_at: None,
2863            model: None,
2864            working_directory: "/project".to_string(),
2865            git_branch: Some("main".to_string()),
2866            source_path: None,
2867            message_count: 0,
2868        };
2869
2870        db.insert_session(&session1).expect("insert session1");
2871        db.insert_session(&session2).expect("insert session2");
2872
2873        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
2874        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
2875
2876        db.insert_message(&msg1).expect("insert msg1");
2877        db.insert_message(&msg2).expect("insert msg2");
2878
2879        // Search with branch filter
2880        let options = SearchOptions {
2881            query: "work".to_string(),
2882            limit: 10,
2883            branch: Some("auth".to_string()),
2884            ..Default::default()
2885        };
2886        let results = db.search_with_options(&options).expect("search");
2887
2888        assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
2889        assert_eq!(
2890            results[0].git_branch.as_deref(),
2891            Some("feat/auth"),
2892            "Should be from feat/auth branch"
2893        );
2894    }
2895
2896    #[test]
2897    fn test_search_metadata_matches_project() {
2898        let (db, _dir) = create_test_db();
2899
2900        let session =
2901            create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
2902        db.insert_session(&session).expect("insert session");
2903
2904        // Add a message that does NOT contain "redactyl"
2905        let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
2906        db.insert_message(&msg).expect("insert msg");
2907
2908        // Search for "redactyl" - should match session metadata
2909        let options = SearchOptions {
2910            query: "redactyl".to_string(),
2911            limit: 10,
2912            ..Default::default()
2913        };
2914        let results = db.search_with_options(&options).expect("search");
2915
2916        assert_eq!(
2917            results.len(),
2918            1,
2919            "Should find session via metadata match on project name"
2920        );
2921    }
2922
2923    #[test]
2924    fn test_search_returns_extended_session_info() {
2925        let (db, _dir) = create_test_db();
2926
2927        let started_at = Utc::now();
2928        let session = Session {
2929            id: Uuid::new_v4(),
2930            tool: "claude-code".to_string(),
2931            tool_version: Some("1.0.0".to_string()),
2932            started_at,
2933            ended_at: None,
2934            model: None,
2935            working_directory: "/home/user/myapp".to_string(),
2936            git_branch: Some("develop".to_string()),
2937            source_path: None,
2938            message_count: 5,
2939        };
2940        db.insert_session(&session).expect("insert session");
2941
2942        let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
2943        db.insert_message(&msg).expect("insert msg");
2944
2945        let options = SearchOptions {
2946            query: "Test".to_string(),
2947            limit: 10,
2948            ..Default::default()
2949        };
2950        let results = db.search_with_options(&options).expect("search");
2951
2952        assert_eq!(results.len(), 1, "Should find 1 result");
2953        let result = &results[0];
2954
2955        assert_eq!(result.tool, "claude-code", "Tool should be populated");
2956        assert_eq!(
2957            result.git_branch.as_deref(),
2958            Some("develop"),
2959            "Branch should be populated"
2960        );
2961        assert!(
2962            result.session_message_count > 0,
2963            "Message count should be populated"
2964        );
2965        assert!(
2966            result.session_started_at.is_some(),
2967            "Session start time should be populated"
2968        );
2969    }
2970
2971    #[test]
2972    fn test_get_context_messages() {
2973        let (db, _dir) = create_test_db();
2974
2975        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2976        db.insert_session(&session).expect("insert session");
2977
2978        // Create 5 messages in sequence
2979        for i in 0..5 {
2980            let role = if i % 2 == 0 {
2981                MessageRole::User
2982            } else {
2983                MessageRole::Assistant
2984            };
2985            let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
2986            db.insert_message(&msg).expect("insert message");
2987        }
2988
2989        // Get context around message index 2 (the middle one)
2990        let (before, after) = db
2991            .get_context_messages(&session.id, 2, 1)
2992            .expect("get context");
2993
2994        assert_eq!(before.len(), 1, "Should have 1 message before");
2995        assert_eq!(after.len(), 1, "Should have 1 message after");
2996        assert_eq!(before[0].index, 1, "Before message should be index 1");
2997        assert_eq!(after[0].index, 3, "After message should be index 3");
2998    }
2999
3000    #[test]
3001    fn test_get_context_messages_at_start() {
3002        let (db, _dir) = create_test_db();
3003
3004        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3005        db.insert_session(&session).expect("insert session");
3006
3007        for i in 0..3 {
3008            let msg =
3009                create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
3010            db.insert_message(&msg).expect("insert message");
3011        }
3012
3013        // Get context around first message (index 0)
3014        let (before, after) = db
3015            .get_context_messages(&session.id, 0, 2)
3016            .expect("get context");
3017
3018        assert!(
3019            before.is_empty(),
3020            "Should have no messages before first message"
3021        );
3022        assert_eq!(after.len(), 2, "Should have 2 messages after");
3023    }
3024
3025    #[test]
3026    fn test_get_context_messages_at_end() {
3027        let (db, _dir) = create_test_db();
3028
3029        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3030        db.insert_session(&session).expect("insert session");
3031
3032        for i in 0..3 {
3033            let msg =
3034                create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
3035            db.insert_message(&msg).expect("insert message");
3036        }
3037
3038        // Get context around last message (index 2)
3039        let (before, after) = db
3040            .get_context_messages(&session.id, 2, 2)
3041            .expect("get context");
3042
3043        assert_eq!(before.len(), 2, "Should have 2 messages before");
3044        assert!(
3045            after.is_empty(),
3046            "Should have no messages after last message"
3047        );
3048    }
3049
3050    #[test]
3051    fn test_search_combined_filters() {
3052        let (db, _dir) = create_test_db();
3053
3054        let session1 = Session {
3055            id: Uuid::new_v4(),
3056            tool: "claude-code".to_string(),
3057            tool_version: None,
3058            started_at: Utc::now(),
3059            ended_at: None,
3060            model: None,
3061            working_directory: "/home/user/myapp".to_string(),
3062            git_branch: Some("feat/api".to_string()),
3063            source_path: None,
3064            message_count: 1,
3065        };
3066        let session2 = Session {
3067            id: Uuid::new_v4(),
3068            tool: "aider".to_string(),
3069            tool_version: None,
3070            started_at: Utc::now(),
3071            ended_at: None,
3072            model: None,
3073            working_directory: "/home/user/myapp".to_string(),
3074            git_branch: Some("feat/api".to_string()),
3075            source_path: None,
3076            message_count: 1,
3077        };
3078
3079        db.insert_session(&session1).expect("insert session1");
3080        db.insert_session(&session2).expect("insert session2");
3081
3082        let msg1 =
3083            create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
3084        let msg2 =
3085            create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
3086
3087        db.insert_message(&msg1).expect("insert msg1");
3088        db.insert_message(&msg2).expect("insert msg2");
3089
3090        // Search with multiple filters
3091        let options = SearchOptions {
3092            query: "API".to_string(),
3093            limit: 10,
3094            tool: Some("claude-code".to_string()),
3095            branch: Some("api".to_string()),
3096            project: Some("myapp".to_string()),
3097            ..Default::default()
3098        };
3099        let results = db.search_with_options(&options).expect("search");
3100
3101        // Results may include both message content match and metadata match from same session
3102        assert!(
3103            !results.is_empty(),
3104            "Should find at least 1 result matching all filters"
3105        );
3106        // All results should be from claude-code (the filtered tool)
3107        for result in &results {
3108            assert_eq!(
3109                result.tool, "claude-code",
3110                "All results should be from claude-code"
3111            );
3112        }
3113    }
3114
3115    // ==================== Session Deletion Tests ====================
3116
3117    #[test]
3118    fn test_delete_session_removes_all_data() {
3119        let (db, _dir) = create_test_db();
3120
3121        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3122        db.insert_session(&session).expect("insert session");
3123
3124        // Add messages
3125        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
3126        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there");
3127        db.insert_message(&msg1).expect("insert msg1");
3128        db.insert_message(&msg2).expect("insert msg2");
3129
3130        // Add a link
3131        let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3132        db.insert_link(&link).expect("insert link");
3133
3134        // Verify data exists
3135        assert_eq!(db.session_count().expect("count"), 1);
3136        assert_eq!(db.message_count().expect("count"), 2);
3137        assert_eq!(db.link_count().expect("count"), 1);
3138
3139        // Delete the session
3140        let (msgs_deleted, links_deleted) = db.delete_session(&session.id).expect("delete");
3141        assert_eq!(msgs_deleted, 2, "Should delete 2 messages");
3142        assert_eq!(links_deleted, 1, "Should delete 1 link");
3143
3144        // Verify all data is gone
3145        assert_eq!(db.session_count().expect("count"), 0);
3146        assert_eq!(db.message_count().expect("count"), 0);
3147        assert_eq!(db.link_count().expect("count"), 0);
3148        assert!(db.get_session(&session.id).expect("get").is_none());
3149    }
3150
3151    #[test]
3152    fn test_delete_session_preserves_other_sessions() {
3153        let (db, _dir) = create_test_db();
3154
3155        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3156        let session2 = create_test_session("aider", "/project2", Utc::now(), None);
3157
3158        db.insert_session(&session1).expect("insert session1");
3159        db.insert_session(&session2).expect("insert session2");
3160
3161        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello 1");
3162        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello 2");
3163        db.insert_message(&msg1).expect("insert msg1");
3164        db.insert_message(&msg2).expect("insert msg2");
3165
3166        // Delete only session1
3167        db.delete_session(&session1.id).expect("delete");
3168
3169        // Verify session2 still exists
3170        assert_eq!(db.session_count().expect("count"), 1);
3171        assert_eq!(db.message_count().expect("count"), 1);
3172        assert!(db.get_session(&session2.id).expect("get").is_some());
3173    }
3174
3175    // ==================== Database Maintenance Tests ====================
3176
3177    #[test]
3178    fn test_file_size() {
3179        let (db, _dir) = create_test_db();
3180
3181        let size = db.file_size().expect("get size");
3182        assert!(size.is_some(), "Should have file size for file-based db");
3183        assert!(size.unwrap() > 0, "Database file should have size > 0");
3184    }
3185
3186    #[test]
3187    fn test_vacuum() {
3188        let (db, _dir) = create_test_db();
3189
3190        // Just verify vacuum runs without error
3191        db.vacuum().expect("vacuum should succeed");
3192    }
3193
3194    #[test]
3195    fn test_count_sessions_older_than() {
3196        let (db, _dir) = create_test_db();
3197        let now = Utc::now();
3198
3199        // Create sessions at different times
3200        let old_session =
3201            create_test_session("claude-code", "/project1", now - Duration::days(100), None);
3202        let recent_session =
3203            create_test_session("claude-code", "/project2", now - Duration::days(10), None);
3204
3205        db.insert_session(&old_session).expect("insert old");
3206        db.insert_session(&recent_session).expect("insert recent");
3207
3208        // Count sessions older than 30 days
3209        let cutoff = now - Duration::days(30);
3210        let count = db.count_sessions_older_than(cutoff).expect("count");
3211        assert_eq!(count, 1, "Should find 1 session older than 30 days");
3212
3213        // Count sessions older than 200 days
3214        let old_cutoff = now - Duration::days(200);
3215        let old_count = db.count_sessions_older_than(old_cutoff).expect("count");
3216        assert_eq!(old_count, 0, "Should find 0 sessions older than 200 days");
3217    }
3218
3219    #[test]
3220    fn test_delete_sessions_older_than() {
3221        let (db, _dir) = create_test_db();
3222        let now = Utc::now();
3223
3224        // Create sessions at different times
3225        let old_session =
3226            create_test_session("claude-code", "/project1", now - Duration::days(100), None);
3227        let recent_session =
3228            create_test_session("claude-code", "/project2", now - Duration::days(10), None);
3229
3230        db.insert_session(&old_session).expect("insert old");
3231        db.insert_session(&recent_session).expect("insert recent");
3232
3233        // Add messages to both
3234        let msg1 = create_test_message(old_session.id, 0, MessageRole::User, "Old message");
3235        let msg2 = create_test_message(recent_session.id, 0, MessageRole::User, "Recent message");
3236        db.insert_message(&msg1).expect("insert msg1");
3237        db.insert_message(&msg2).expect("insert msg2");
3238
3239        // Delete sessions older than 30 days
3240        let cutoff = now - Duration::days(30);
3241        let deleted = db.delete_sessions_older_than(cutoff).expect("delete");
3242        assert_eq!(deleted, 1, "Should delete 1 session");
3243
3244        // Verify only recent session remains
3245        assert_eq!(db.session_count().expect("count"), 1);
3246        assert!(db.get_session(&recent_session.id).expect("get").is_some());
3247        assert!(db.get_session(&old_session.id).expect("get").is_none());
3248
3249        // Verify messages were also deleted
3250        assert_eq!(db.message_count().expect("count"), 1);
3251    }
3252
3253    #[test]
3254    fn test_get_sessions_older_than() {
3255        let (db, _dir) = create_test_db();
3256        let now = Utc::now();
3257
3258        // Create sessions at different times
3259        let old_session = create_test_session(
3260            "claude-code",
3261            "/project/old",
3262            now - Duration::days(100),
3263            None,
3264        );
3265        let medium_session =
3266            create_test_session("aider", "/project/medium", now - Duration::days(50), None);
3267        let recent_session =
3268            create_test_session("gemini", "/project/recent", now - Duration::days(10), None);
3269
3270        db.insert_session(&old_session).expect("insert old");
3271        db.insert_session(&medium_session).expect("insert medium");
3272        db.insert_session(&recent_session).expect("insert recent");
3273
3274        // Get sessions older than 30 days
3275        let cutoff = now - Duration::days(30);
3276        let sessions = db.get_sessions_older_than(cutoff).expect("get sessions");
3277        assert_eq!(
3278            sessions.len(),
3279            2,
3280            "Should find 2 sessions older than 30 days"
3281        );
3282
3283        // Verify sessions are ordered by start date (oldest first)
3284        assert_eq!(sessions[0].id, old_session.id);
3285        assert_eq!(sessions[1].id, medium_session.id);
3286
3287        // Verify session data is returned correctly
3288        assert_eq!(sessions[0].tool, "claude-code");
3289        assert_eq!(sessions[0].working_directory, "/project/old");
3290        assert_eq!(sessions[1].tool, "aider");
3291        assert_eq!(sessions[1].working_directory, "/project/medium");
3292
3293        // Get sessions older than 200 days
3294        let old_cutoff = now - Duration::days(200);
3295        let old_sessions = db
3296            .get_sessions_older_than(old_cutoff)
3297            .expect("get old sessions");
3298        assert_eq!(
3299            old_sessions.len(),
3300            0,
3301            "Should find 0 sessions older than 200 days"
3302        );
3303    }
3304
3305    #[test]
3306    fn test_stats() {
3307        let (db, _dir) = create_test_db();
3308        let now = Utc::now();
3309
3310        // Empty database stats
3311        let empty_stats = db.stats().expect("stats");
3312        assert_eq!(empty_stats.session_count, 0);
3313        assert_eq!(empty_stats.message_count, 0);
3314        assert_eq!(empty_stats.link_count, 0);
3315        assert!(empty_stats.oldest_session.is_none());
3316        assert!(empty_stats.newest_session.is_none());
3317        assert!(empty_stats.sessions_by_tool.is_empty());
3318
3319        // Add some data
3320        let session1 =
3321            create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
3322        let session2 = create_test_session("aider", "/project2", now - Duration::hours(1), None);
3323        let session3 = create_test_session("claude-code", "/project3", now, None);
3324
3325        db.insert_session(&session1).expect("insert 1");
3326        db.insert_session(&session2).expect("insert 2");
3327        db.insert_session(&session3).expect("insert 3");
3328
3329        let msg = create_test_message(session1.id, 0, MessageRole::User, "Hello");
3330        db.insert_message(&msg).expect("insert msg");
3331
3332        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3333        db.insert_link(&link).expect("insert link");
3334
3335        // Check stats
3336        let stats = db.stats().expect("stats");
3337        assert_eq!(stats.session_count, 3);
3338        assert_eq!(stats.message_count, 1);
3339        assert_eq!(stats.link_count, 1);
3340        assert!(stats.oldest_session.is_some());
3341        assert!(stats.newest_session.is_some());
3342
3343        // Check sessions by tool
3344        assert_eq!(stats.sessions_by_tool.len(), 2);
3345        // claude-code should come first (most sessions)
3346        assert_eq!(stats.sessions_by_tool[0].0, "claude-code");
3347        assert_eq!(stats.sessions_by_tool[0].1, 2);
3348        assert_eq!(stats.sessions_by_tool[1].0, "aider");
3349        assert_eq!(stats.sessions_by_tool[1].1, 1);
3350    }
3351
3352    // ==================== Branch History Tests ====================
3353
3354    #[test]
3355    fn test_get_session_branch_history_no_messages() {
3356        let (db, _dir) = create_test_db();
3357        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3358        db.insert_session(&session)
3359            .expect("Failed to insert session");
3360
3361        let branches = db
3362            .get_session_branch_history(session.id)
3363            .expect("Failed to get branch history");
3364
3365        assert!(branches.is_empty(), "Empty session should have no branches");
3366    }
3367
3368    #[test]
3369    fn test_get_session_branch_history_single_branch() {
3370        let (db, _dir) = create_test_db();
3371        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3372        db.insert_session(&session)
3373            .expect("Failed to insert session");
3374
3375        // Insert messages all on the same branch
3376        for i in 0..3 {
3377            let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
3378            msg.git_branch = Some("main".to_string());
3379            db.insert_message(&msg).expect("Failed to insert message");
3380        }
3381
3382        let branches = db
3383            .get_session_branch_history(session.id)
3384            .expect("Failed to get branch history");
3385
3386        assert_eq!(branches, vec!["main"], "Should have single branch");
3387    }
3388
3389    #[test]
3390    fn test_get_session_branch_history_multiple_branches() {
3391        let (db, _dir) = create_test_db();
3392        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3393        db.insert_session(&session)
3394            .expect("Failed to insert session");
3395
3396        // Insert messages with branch transitions: main -> feat/auth -> main
3397        let branch_sequence = ["main", "main", "feat/auth", "feat/auth", "main"];
3398        for (i, branch) in branch_sequence.iter().enumerate() {
3399            let mut msg = create_test_message(session.id, i as i32, MessageRole::User, "test");
3400            msg.git_branch = Some(branch.to_string());
3401            db.insert_message(&msg).expect("Failed to insert message");
3402        }
3403
3404        let branches = db
3405            .get_session_branch_history(session.id)
3406            .expect("Failed to get branch history");
3407
3408        assert_eq!(
3409            branches,
3410            vec!["main", "feat/auth", "main"],
3411            "Should show branch transitions without consecutive duplicates"
3412        );
3413    }
3414
3415    #[test]
3416    fn test_get_session_branch_history_with_none_branches() {
3417        let (db, _dir) = create_test_db();
3418        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3419        db.insert_session(&session)
3420            .expect("Failed to insert session");
3421
3422        // Insert messages with a mix of Some and None branches
3423        let mut msg1 = create_test_message(session.id, 0, MessageRole::User, "test");
3424        msg1.git_branch = Some("main".to_string());
3425        db.insert_message(&msg1).expect("Failed to insert message");
3426
3427        let mut msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "test");
3428        msg2.git_branch = None; // No branch info
3429        db.insert_message(&msg2).expect("Failed to insert message");
3430
3431        let mut msg3 = create_test_message(session.id, 2, MessageRole::User, "test");
3432        msg3.git_branch = Some("feat/new".to_string());
3433        db.insert_message(&msg3).expect("Failed to insert message");
3434
3435        let branches = db
3436            .get_session_branch_history(session.id)
3437            .expect("Failed to get branch history");
3438
3439        assert_eq!(
3440            branches,
3441            vec!["main", "feat/new"],
3442            "Should skip None branches and show transitions"
3443        );
3444    }
3445
3446    #[test]
3447    fn test_get_session_branch_history_all_none_branches() {
3448        let (db, _dir) = create_test_db();
3449        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3450        db.insert_session(&session)
3451            .expect("Failed to insert session");
3452
3453        // Insert messages with no branch info
3454        for i in 0..3 {
3455            let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
3456            msg.git_branch = None;
3457            db.insert_message(&msg).expect("Failed to insert message");
3458        }
3459
3460        let branches = db
3461            .get_session_branch_history(session.id)
3462            .expect("Failed to get branch history");
3463
3464        assert!(
3465            branches.is_empty(),
3466            "Session with all None branches should return empty"
3467        );
3468    }
3469}