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::{
14    Annotation, Machine, Message, MessageContent, MessageRole, SearchResult, Session, SessionLink,
15    Summary, Tag,
16};
17
18/// Parses a UUID from a string, converting errors to rusqlite errors.
19///
20/// Used in row mapping functions where we need to return rusqlite::Result.
21fn parse_uuid(s: &str) -> rusqlite::Result<Uuid> {
22    Uuid::parse_str(s).map_err(|e| {
23        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
24    })
25}
26
27/// Parses an RFC3339 datetime string, converting errors to rusqlite errors.
28///
29/// Used in row mapping functions where we need to return rusqlite::Result.
30fn parse_datetime(s: &str) -> rusqlite::Result<DateTime<Utc>> {
31    chrono::DateTime::parse_from_rfc3339(s)
32        .map(|dt| dt.with_timezone(&Utc))
33        .map_err(|e| {
34            rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
35        })
36}
37
38/// Escapes a query string for FTS5 by wrapping each word in double quotes.
39///
40/// FTS5 has special syntax characters (e.g., /, *, AND, OR, NOT) that need
41/// escaping to be treated as literal search terms.
42fn escape_fts5_query(query: &str) -> String {
43    // Split on whitespace and wrap each word in quotes, escaping internal quotes
44    query
45        .split_whitespace()
46        .map(|word| {
47            let escaped = word.replace('"', "\"\"");
48            format!("\"{escaped}\"")
49        })
50        .collect::<Vec<_>>()
51        .join(" ")
52}
53
54/// Returns the default database path at `~/.lore/lore.db`.
55///
56/// Creates the `.lore` directory if it does not exist.
57pub fn default_db_path() -> Result<PathBuf> {
58    let config_dir = dirs::home_dir()
59        .context("Could not find home directory. Ensure your HOME environment variable is set.")?
60        .join(".lore");
61
62    std::fs::create_dir_all(&config_dir).with_context(|| {
63        format!(
64            "Failed to create Lore data directory at {}. Check directory permissions.",
65            config_dir.display()
66        )
67    })?;
68    Ok(config_dir.join("lore.db"))
69}
70
71/// SQLite database connection wrapper.
72///
73/// Provides methods for storing and querying sessions, messages,
74/// and session-to-commit links. Handles schema migrations automatically
75/// when opening the database.
76pub struct Database {
77    conn: Connection,
78}
79
80impl Database {
81    /// Opens or creates a database at the specified path.
82    ///
83    /// Runs schema migrations automatically to ensure tables exist.
84    pub fn open(path: &PathBuf) -> Result<Self> {
85        let conn = Connection::open(path)?;
86        let db = Self { conn };
87        db.migrate()?;
88        Ok(db)
89    }
90
91    /// Opens the default database at `~/.lore/lore.db`.
92    ///
93    /// Creates the database file and directory if they do not exist.
94    pub fn open_default() -> Result<Self> {
95        let path = default_db_path()?;
96        Self::open(&path)
97    }
98
99    /// Runs database schema migrations.
100    ///
101    /// Creates tables for sessions, messages, session_links, and repositories
102    /// if they do not already exist. Also creates indexes for common queries.
103    fn migrate(&self) -> Result<()> {
104        self.conn.execute_batch(
105            r#"
106            CREATE TABLE IF NOT EXISTS schema_version (
107                version INTEGER PRIMARY KEY
108            );
109
110            CREATE TABLE IF NOT EXISTS sessions (
111                id TEXT PRIMARY KEY,
112                tool TEXT NOT NULL,
113                tool_version TEXT,
114                started_at TEXT NOT NULL,
115                ended_at TEXT,
116                model TEXT,
117                working_directory TEXT NOT NULL,
118                git_branch TEXT,
119                source_path TEXT,
120                message_count INTEGER NOT NULL DEFAULT 0,
121                created_at TEXT NOT NULL DEFAULT (datetime('now')),
122                machine_id TEXT
123            );
124
125            CREATE TABLE IF NOT EXISTS messages (
126                id TEXT PRIMARY KEY,
127                session_id TEXT NOT NULL,
128                parent_id TEXT,
129                idx INTEGER NOT NULL,
130                timestamp TEXT NOT NULL,
131                role TEXT NOT NULL,
132                content TEXT NOT NULL,
133                model TEXT,
134                git_branch TEXT,
135                cwd TEXT,
136                FOREIGN KEY (session_id) REFERENCES sessions(id)
137            );
138
139            CREATE TABLE IF NOT EXISTS session_links (
140                id TEXT PRIMARY KEY,
141                session_id TEXT NOT NULL,
142                link_type TEXT NOT NULL,
143                commit_sha TEXT,
144                branch TEXT,
145                remote TEXT,
146                created_at TEXT NOT NULL,
147                created_by TEXT NOT NULL,
148                confidence REAL,
149                FOREIGN KEY (session_id) REFERENCES sessions(id)
150            );
151
152            CREATE TABLE IF NOT EXISTS repositories (
153                id TEXT PRIMARY KEY,
154                path TEXT NOT NULL UNIQUE,
155                name TEXT NOT NULL,
156                remote_url TEXT,
157                created_at TEXT NOT NULL DEFAULT (datetime('now')),
158                last_session_at TEXT
159            );
160
161            CREATE TABLE IF NOT EXISTS annotations (
162                id TEXT PRIMARY KEY,
163                session_id TEXT NOT NULL,
164                content TEXT NOT NULL,
165                created_at TEXT NOT NULL,
166                FOREIGN KEY (session_id) REFERENCES sessions(id)
167            );
168
169            CREATE TABLE IF NOT EXISTS tags (
170                id TEXT PRIMARY KEY,
171                session_id TEXT NOT NULL,
172                label TEXT NOT NULL,
173                created_at TEXT NOT NULL,
174                FOREIGN KEY (session_id) REFERENCES sessions(id),
175                UNIQUE(session_id, label)
176            );
177
178            CREATE TABLE IF NOT EXISTS summaries (
179                id TEXT PRIMARY KEY,
180                session_id TEXT NOT NULL UNIQUE,
181                content TEXT NOT NULL,
182                generated_at TEXT NOT NULL,
183                FOREIGN KEY (session_id) REFERENCES sessions(id)
184            );
185
186            CREATE TABLE IF NOT EXISTS machines (
187                id TEXT PRIMARY KEY,
188                name TEXT NOT NULL,
189                created_at TEXT NOT NULL
190            );
191
192            -- Indexes for common queries
193            CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
194            CREATE INDEX IF NOT EXISTS idx_sessions_working_directory ON sessions(working_directory);
195            CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
196            CREATE INDEX IF NOT EXISTS idx_session_links_session_id ON session_links(session_id);
197            CREATE INDEX IF NOT EXISTS idx_session_links_commit_sha ON session_links(commit_sha);
198            CREATE INDEX IF NOT EXISTS idx_annotations_session_id ON annotations(session_id);
199            CREATE INDEX IF NOT EXISTS idx_tags_session_id ON tags(session_id);
200            CREATE INDEX IF NOT EXISTS idx_tags_label ON tags(label);
201            "#,
202        )?;
203
204        // Create FTS5 virtual table for full-text search on message content.
205        // This is a standalone FTS table (not content-synced) because we need to
206        // store extracted text content, not the raw JSON from the messages table.
207        // The message_id column stores the UUID string for joining back to messages.
208        self.conn.execute_batch(
209            r#"
210            CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
211                message_id,
212                text_content,
213                tokenize='porter unicode61'
214            );
215            "#,
216        )?;
217
218        // Create FTS5 virtual table for session metadata search.
219        // Allows searching by project name, branch, tool, and working directory.
220        self.conn.execute_batch(
221            r#"
222            CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
223                session_id,
224                tool,
225                working_directory,
226                git_branch,
227                tokenize='porter unicode61'
228            );
229            "#,
230        )?;
231
232        // Migration: Add machine_id column to existing sessions table if not present.
233        // This handles upgrades from databases created before machine_id was added.
234        self.migrate_add_machine_id()?;
235
236        Ok(())
237    }
238
239    /// Adds the machine_id column to the sessions table if it does not exist,
240    /// and backfills NULL values with the current machine's UUID.
241    ///
242    /// Also migrates sessions that were previously backfilled with hostname
243    /// to use the UUID instead.
244    ///
245    /// This migration is idempotent and safe to run on both new and existing databases.
246    fn migrate_add_machine_id(&self) -> Result<()> {
247        // Check if machine_id column already exists
248        let columns: Vec<String> = self
249            .conn
250            .prepare("PRAGMA table_info(sessions)")?
251            .query_map([], |row| row.get::<_, String>(1))?
252            .collect::<Result<Vec<_>, _>>()?;
253
254        if !columns.iter().any(|c| c == "machine_id") {
255            self.conn
256                .execute("ALTER TABLE sessions ADD COLUMN machine_id TEXT", [])?;
257        }
258
259        // Backfill NULL machine_id values with current machine UUID
260        if let Some(machine_uuid) = super::get_machine_id() {
261            self.conn.execute(
262                "UPDATE sessions SET machine_id = ?1 WHERE machine_id IS NULL",
263                [&machine_uuid],
264            )?;
265
266            // Migrate sessions that were backfilled with hostname to use UUID.
267            // We detect hostname-based machine_ids by checking if they don't look
268            // like UUIDs (UUIDs contain dashes in the format 8-4-4-4-12).
269            // This is safe because it only affects sessions from this machine.
270            if let Some(hostname) = hostname::get().ok().and_then(|h| h.into_string().ok()) {
271                self.conn.execute(
272                    "UPDATE sessions SET machine_id = ?1 WHERE machine_id = ?2",
273                    [&machine_uuid, &hostname],
274                )?;
275            }
276        }
277
278        Ok(())
279    }
280
281    // ==================== Sessions ====================
282
283    /// Inserts a new session or updates an existing one.
284    ///
285    /// If a session with the same ID already exists, updates the `ended_at`
286    /// and `message_count` fields. Also updates the sessions_fts index for
287    /// full-text search on session metadata.
288    pub fn insert_session(&self, session: &Session) -> Result<()> {
289        let rows_changed = self.conn.execute(
290            r#"
291            INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id)
292            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
293            ON CONFLICT(id) DO UPDATE SET
294                ended_at = ?5,
295                message_count = ?10
296            "#,
297            params![
298                session.id.to_string(),
299                session.tool,
300                session.tool_version,
301                session.started_at.to_rfc3339(),
302                session.ended_at.map(|t| t.to_rfc3339()),
303                session.model,
304                session.working_directory,
305                session.git_branch,
306                session.source_path,
307                session.message_count,
308                session.machine_id,
309            ],
310        )?;
311
312        // Insert into sessions_fts for metadata search (only on new inserts)
313        if rows_changed > 0 {
314            // Check if already in FTS (for ON CONFLICT case)
315            let fts_count: i32 = self.conn.query_row(
316                "SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
317                params![session.id.to_string()],
318                |row| row.get(0),
319            )?;
320
321            if fts_count == 0 {
322                self.conn.execute(
323                    "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
324                    params![
325                        session.id.to_string(),
326                        session.tool,
327                        session.working_directory,
328                        session.git_branch.as_deref().unwrap_or(""),
329                    ],
330                )?;
331            }
332        }
333
334        Ok(())
335    }
336
337    /// Retrieves a session by its unique ID.
338    ///
339    /// Returns `None` if no session with the given ID exists.
340    pub fn get_session(&self, id: &Uuid) -> Result<Option<Session>> {
341        self.conn
342            .query_row(
343                "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id FROM sessions WHERE id = ?1",
344                params![id.to_string()],
345                Self::row_to_session,
346            )
347            .optional()
348            .context("Failed to get session")
349    }
350
351    /// Lists sessions ordered by start time (most recent first).
352    ///
353    /// Optionally filters by working directory prefix. Returns at most
354    /// `limit` sessions.
355    pub fn list_sessions(&self, limit: usize, working_dir: Option<&str>) -> Result<Vec<Session>> {
356        let mut stmt = if working_dir.is_some() {
357            self.conn.prepare(
358                "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
359                 FROM sessions
360                 WHERE working_directory LIKE ?1
361                 ORDER BY started_at DESC
362                 LIMIT ?2"
363            )?
364        } else {
365            self.conn.prepare(
366                "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
367                 FROM sessions
368                 ORDER BY started_at DESC
369                 LIMIT ?1"
370            )?
371        };
372
373        let rows = if let Some(wd) = working_dir {
374            stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
375        } else {
376            stmt.query_map(params![limit], Self::row_to_session)?
377        };
378
379        rows.collect::<Result<Vec<_>, _>>()
380            .context("Failed to list sessions")
381    }
382
383    /// Checks if a session with the given source path already exists.
384    ///
385    /// Used to detect already-imported sessions during import operations.
386    pub fn session_exists_by_source(&self, source_path: &str) -> Result<bool> {
387        let count: i32 = self.conn.query_row(
388            "SELECT COUNT(*) FROM sessions WHERE source_path = ?1",
389            params![source_path],
390            |row| row.get(0),
391        )?;
392        Ok(count > 0)
393    }
394
395    /// Finds a session by ID prefix, searching all sessions in the database.
396    ///
397    /// This method uses SQL LIKE to efficiently search by prefix without
398    /// loading all sessions into memory. Returns an error if the prefix
399    /// is ambiguous (matches multiple sessions).
400    ///
401    /// # Arguments
402    ///
403    /// * `prefix` - The UUID prefix to search for (can be any length)
404    ///
405    /// # Returns
406    ///
407    /// * `Ok(Some(session))` - If exactly one session matches the prefix
408    /// * `Ok(None)` - If no sessions match the prefix
409    /// * `Err` - If multiple sessions match (ambiguous prefix) or database error
410    pub fn find_session_by_id_prefix(&self, prefix: &str) -> Result<Option<Session>> {
411        // First try parsing as a full UUID
412        if let Ok(uuid) = Uuid::parse_str(prefix) {
413            return self.get_session(&uuid);
414        }
415
416        // Search by prefix using LIKE
417        let pattern = format!("{prefix}%");
418
419        // First, count how many sessions match
420        let count: i32 = self.conn.query_row(
421            "SELECT COUNT(*) FROM sessions WHERE id LIKE ?1",
422            params![pattern],
423            |row| row.get(0),
424        )?;
425
426        match count {
427            0 => Ok(None),
428            1 => {
429                // Exactly one match, retrieve it
430                self.conn
431                    .query_row(
432                        "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
433                         FROM sessions
434                         WHERE id LIKE ?1",
435                        params![pattern],
436                        Self::row_to_session,
437                    )
438                    .optional()
439                    .context("Failed to find session by prefix")
440            }
441            n => {
442                // Multiple matches - return an error indicating ambiguity
443                anyhow::bail!(
444                    "Ambiguous session ID prefix '{prefix}' matches {n} sessions. Use a longer prefix."
445                )
446            }
447        }
448    }
449
450    /// Updates the git branch for a session.
451    ///
452    /// Used by the daemon when a message is processed with a different branch
453    /// than the session's current branch, indicating a branch switch mid-session.
454    /// Also updates the sessions_fts index to keep search in sync.
455    ///
456    /// Returns the number of rows affected (0 or 1).
457    pub fn update_session_branch(&self, session_id: Uuid, new_branch: &str) -> Result<usize> {
458        let rows_changed = self.conn.execute(
459            "UPDATE sessions SET git_branch = ?1 WHERE id = ?2",
460            params![new_branch, session_id.to_string()],
461        )?;
462
463        // Also update the FTS index if the session was updated
464        if rows_changed > 0 {
465            self.conn.execute(
466                "UPDATE sessions_fts SET git_branch = ?1 WHERE session_id = ?2",
467                params![new_branch, session_id.to_string()],
468            )?;
469        }
470
471        Ok(rows_changed)
472    }
473
474    fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<Session> {
475        let ended_at_str: Option<String> = row.get(4)?;
476        let ended_at = match ended_at_str {
477            Some(s) => Some(parse_datetime(&s)?),
478            None => None,
479        };
480
481        Ok(Session {
482            id: parse_uuid(&row.get::<_, String>(0)?)?,
483            tool: row.get(1)?,
484            tool_version: row.get(2)?,
485            started_at: parse_datetime(&row.get::<_, String>(3)?)?,
486            ended_at,
487            model: row.get(5)?,
488            working_directory: row.get(6)?,
489            git_branch: row.get(7)?,
490            source_path: row.get(8)?,
491            message_count: row.get(9)?,
492            machine_id: row.get(10)?,
493        })
494    }
495
496    // ==================== Messages ====================
497
498    /// Inserts a message into the database.
499    ///
500    /// If a message with the same ID already exists, the insert is ignored.
501    /// Message content is serialized to JSON for storage. Also inserts
502    /// extracted text content into the FTS index for full-text search.
503    pub fn insert_message(&self, message: &Message) -> Result<()> {
504        let content_json = serde_json::to_string(&message.content)?;
505
506        let rows_changed = self.conn.execute(
507            r#"
508            INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
509            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
510            ON CONFLICT(id) DO NOTHING
511            "#,
512            params![
513                message.id.to_string(),
514                message.session_id.to_string(),
515                message.parent_id.map(|u| u.to_string()),
516                message.index,
517                message.timestamp.to_rfc3339(),
518                message.role.to_string(),
519                content_json,
520                message.model,
521                message.git_branch,
522                message.cwd,
523            ],
524        )?;
525
526        // Only insert into FTS if the message was actually inserted (not a duplicate)
527        if rows_changed > 0 {
528            let text_content = message.content.text();
529            if !text_content.is_empty() {
530                self.conn.execute(
531                    "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
532                    params![message.id.to_string(), text_content],
533                )?;
534            }
535        }
536
537        Ok(())
538    }
539
540    /// Retrieves all messages for a session, ordered by index.
541    ///
542    /// Messages are returned in conversation order (by their `index` field).
543    pub fn get_messages(&self, session_id: &Uuid) -> Result<Vec<Message>> {
544        let mut stmt = self.conn.prepare(
545            "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd 
546             FROM messages 
547             WHERE session_id = ?1 
548             ORDER BY idx"
549        )?;
550
551        let rows = stmt.query_map(params![session_id.to_string()], |row| {
552            let role_str: String = row.get(5)?;
553            let content_str: String = row.get(6)?;
554
555            let parent_id_str: Option<String> = row.get(2)?;
556            let parent_id = match parent_id_str {
557                Some(s) => Some(parse_uuid(&s)?),
558                None => None,
559            };
560
561            Ok(Message {
562                id: parse_uuid(&row.get::<_, String>(0)?)?,
563                session_id: parse_uuid(&row.get::<_, String>(1)?)?,
564                parent_id,
565                index: row.get(3)?,
566                timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
567                role: match role_str.as_str() {
568                    "user" => MessageRole::User,
569                    "assistant" => MessageRole::Assistant,
570                    "system" => MessageRole::System,
571                    _ => MessageRole::User,
572                },
573                content: serde_json::from_str(&content_str)
574                    .unwrap_or(MessageContent::Text(content_str)),
575                model: row.get(7)?,
576                git_branch: row.get(8)?,
577                cwd: row.get(9)?,
578            })
579        })?;
580
581        rows.collect::<Result<Vec<_>, _>>()
582            .context("Failed to get messages")
583    }
584
585    /// Returns the ordered list of distinct branches for a session.
586    ///
587    /// Branches are returned in the order they first appeared in messages,
588    /// with consecutive duplicates removed. This shows the branch transitions
589    /// during a session (e.g., "main -> feat/auth -> main").
590    ///
591    /// Returns an empty vector if the session has no messages or all messages
592    /// have None branches.
593    pub fn get_session_branch_history(&self, session_id: Uuid) -> Result<Vec<String>> {
594        let mut stmt = self
595            .conn
596            .prepare("SELECT git_branch FROM messages WHERE session_id = ?1 ORDER BY idx")?;
597
598        let rows = stmt.query_map(params![session_id.to_string()], |row| {
599            let branch: Option<String> = row.get(0)?;
600            Ok(branch)
601        })?;
602
603        // Collect branches, keeping only the first occurrence of consecutive duplicates
604        let mut branches: Vec<String> = Vec::new();
605        for row in rows {
606            if let Some(branch) = row? {
607                // Only add if different from the last branch (removes consecutive duplicates)
608                if branches.last() != Some(&branch) {
609                    branches.push(branch);
610                }
611            }
612        }
613
614        Ok(branches)
615    }
616
617    // ==================== Session Links ====================
618
619    /// Inserts a link between a session and a git commit.
620    ///
621    /// Links can be created manually by users or automatically by
622    /// the auto-linking system based on time and file overlap heuristics.
623    pub fn insert_link(&self, link: &SessionLink) -> Result<()> {
624        self.conn.execute(
625            r#"
626            INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
627            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
628            "#,
629            params![
630                link.id.to_string(),
631                link.session_id.to_string(),
632                format!("{:?}", link.link_type).to_lowercase(),
633                link.commit_sha,
634                link.branch,
635                link.remote,
636                link.created_at.to_rfc3339(),
637                format!("{:?}", link.created_by).to_lowercase(),
638                link.confidence,
639            ],
640        )?;
641        Ok(())
642    }
643
644    /// Retrieves all session links for a commit.
645    ///
646    /// Supports prefix matching on the commit SHA, allowing short SHAs
647    /// (e.g., first 8 characters) to be used for lookup.
648    pub fn get_links_by_commit(&self, commit_sha: &str) -> Result<Vec<SessionLink>> {
649        let mut stmt = self.conn.prepare(
650            "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence 
651             FROM session_links 
652             WHERE commit_sha LIKE ?1"
653        )?;
654
655        let pattern = format!("{commit_sha}%");
656        let rows = stmt.query_map(params![pattern], Self::row_to_link)?;
657
658        rows.collect::<Result<Vec<_>, _>>()
659            .context("Failed to get links")
660    }
661
662    /// Retrieves all links associated with a session.
663    ///
664    /// A session can be linked to multiple commits if it spans
665    /// several git operations.
666    pub fn get_links_by_session(&self, session_id: &Uuid) -> Result<Vec<SessionLink>> {
667        let mut stmt = self.conn.prepare(
668            "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence 
669             FROM session_links 
670             WHERE session_id = ?1"
671        )?;
672
673        let rows = stmt.query_map(params![session_id.to_string()], Self::row_to_link)?;
674
675        rows.collect::<Result<Vec<_>, _>>()
676            .context("Failed to get links")
677    }
678
679    fn row_to_link(row: &rusqlite::Row) -> rusqlite::Result<SessionLink> {
680        use super::models::{LinkCreator, LinkType};
681
682        let link_type_str: String = row.get(2)?;
683        let created_by_str: String = row.get(7)?;
684
685        Ok(SessionLink {
686            id: parse_uuid(&row.get::<_, String>(0)?)?,
687            session_id: parse_uuid(&row.get::<_, String>(1)?)?,
688            link_type: match link_type_str.as_str() {
689                "commit" => LinkType::Commit,
690                "branch" => LinkType::Branch,
691                "pr" => LinkType::Pr,
692                _ => LinkType::Manual,
693            },
694            commit_sha: row.get(3)?,
695            branch: row.get(4)?,
696            remote: row.get(5)?,
697            created_at: parse_datetime(&row.get::<_, String>(6)?)?,
698            created_by: match created_by_str.as_str() {
699                "auto" => LinkCreator::Auto,
700                _ => LinkCreator::User,
701            },
702            confidence: row.get(8)?,
703        })
704    }
705
706    /// Deletes a specific session link by its ID.
707    ///
708    /// Returns `true` if a link was deleted, `false` if no link with that ID existed.
709    ///
710    /// Note: This method is part of the public API for programmatic use,
711    /// though the CLI currently uses session/commit-based deletion.
712    #[allow(dead_code)]
713    pub fn delete_link(&self, link_id: &Uuid) -> Result<bool> {
714        let rows_affected = self.conn.execute(
715            "DELETE FROM session_links WHERE id = ?1",
716            params![link_id.to_string()],
717        )?;
718        Ok(rows_affected > 0)
719    }
720
721    /// Deletes all links for a session.
722    ///
723    /// Returns the number of links deleted.
724    pub fn delete_links_by_session(&self, session_id: &Uuid) -> Result<usize> {
725        let rows_affected = self.conn.execute(
726            "DELETE FROM session_links WHERE session_id = ?1",
727            params![session_id.to_string()],
728        )?;
729        Ok(rows_affected)
730    }
731
732    /// Deletes a link between a specific session and commit.
733    ///
734    /// The commit_sha is matched as a prefix, so short SHAs work.
735    /// Returns `true` if a link was deleted, `false` if no matching link existed.
736    pub fn delete_link_by_session_and_commit(
737        &self,
738        session_id: &Uuid,
739        commit_sha: &str,
740    ) -> Result<bool> {
741        let pattern = format!("{commit_sha}%");
742        let rows_affected = self.conn.execute(
743            "DELETE FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
744            params![session_id.to_string(), pattern],
745        )?;
746        Ok(rows_affected > 0)
747    }
748
749    // ==================== Search ====================
750
751    /// Searches message content using full-text search.
752    ///
753    /// Uses SQLite FTS5 to search for messages matching the query.
754    /// Returns results ordered by FTS5 relevance ranking.
755    ///
756    /// Optional filters:
757    /// - `working_dir`: Filter by working directory prefix
758    /// - `since`: Filter by minimum timestamp
759    /// - `role`: Filter by message role
760    ///
761    /// Note: This is the legacy search API. For new code, use `search_with_options`.
762    #[allow(dead_code)]
763    pub fn search_messages(
764        &self,
765        query: &str,
766        limit: usize,
767        working_dir: Option<&str>,
768        since: Option<chrono::DateTime<chrono::Utc>>,
769        role: Option<&str>,
770    ) -> Result<Vec<SearchResult>> {
771        use super::models::SearchOptions;
772
773        // Convert to SearchOptions and use the new method
774        let options = SearchOptions {
775            query: query.to_string(),
776            limit,
777            repo: working_dir.map(|s| s.to_string()),
778            since,
779            role: role.map(|s| s.to_string()),
780            ..Default::default()
781        };
782
783        self.search_with_options(&options)
784    }
785
786    /// Searches messages and session metadata using full-text search with filters.
787    ///
788    /// Uses SQLite FTS5 to search for messages matching the query.
789    /// Also searches session metadata (tool, project, branch) via sessions_fts.
790    /// Returns results ordered by FTS5 relevance ranking.
791    ///
792    /// Supports extensive filtering via SearchOptions:
793    /// - `tool`: Filter by AI tool name
794    /// - `since`/`until`: Filter by date range
795    /// - `project`: Filter by project name (partial match)
796    /// - `branch`: Filter by git branch (partial match)
797    /// - `role`: Filter by message role
798    /// - `repo`: Filter by working directory prefix
799    pub fn search_with_options(
800        &self,
801        options: &super::models::SearchOptions,
802    ) -> Result<Vec<SearchResult>> {
803        // Escape the query for FTS5 to handle special characters
804        let escaped_query = escape_fts5_query(&options.query);
805
806        // Build the query dynamically based on filters
807        // Use UNION to search both message content and session metadata
808        let mut sql = String::from(
809            r#"
810            SELECT
811                m.session_id,
812                m.id as message_id,
813                m.role,
814                snippet(messages_fts, 1, '**', '**', '...', 32) as snippet,
815                m.timestamp,
816                s.working_directory,
817                s.tool,
818                s.git_branch,
819                s.message_count,
820                s.started_at,
821                m.idx as message_index
822            FROM messages_fts fts
823            JOIN messages m ON fts.message_id = m.id
824            JOIN sessions s ON m.session_id = s.id
825            WHERE messages_fts MATCH ?1
826            "#,
827        );
828
829        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(escaped_query.clone())];
830        let mut param_idx = 2;
831
832        // Add filters
833        if options.repo.is_some() {
834            sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
835            param_idx += 1;
836        }
837        if options.tool.is_some() {
838            sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
839            param_idx += 1;
840        }
841        if options.since.is_some() {
842            sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
843            param_idx += 1;
844        }
845        if options.until.is_some() {
846            sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
847            param_idx += 1;
848        }
849        if options.project.is_some() {
850            sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
851            param_idx += 1;
852        }
853        if options.branch.is_some() {
854            sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
855            param_idx += 1;
856        }
857        if options.role.is_some() {
858            sql.push_str(&format!(" AND m.role = ?{param_idx}"));
859            param_idx += 1;
860        }
861
862        // Build first SELECT parameter list (after the FTS query param which is already in params_vec)
863        if let Some(ref wd) = options.repo {
864            params_vec.push(Box::new(format!("{wd}%")));
865        }
866        if let Some(ref tool) = options.tool {
867            params_vec.push(Box::new(tool.clone()));
868        }
869        if let Some(ts) = options.since {
870            params_vec.push(Box::new(ts.to_rfc3339()));
871        }
872        if let Some(ts) = options.until {
873            params_vec.push(Box::new(ts.to_rfc3339()));
874        }
875        if let Some(ref project) = options.project {
876            params_vec.push(Box::new(format!("%{project}%")));
877        }
878        if let Some(ref branch) = options.branch {
879            params_vec.push(Box::new(format!("%{branch}%")));
880        }
881        if let Some(ref role) = options.role {
882            params_vec.push(Box::new(role.clone()));
883        }
884
885        // Add UNION for session metadata search (only if not filtering by role)
886        // This finds sessions where the metadata matches, returning the first message as representative
887        // Uses LIKE patterns instead of FTS5 for metadata since paths contain special characters
888        let include_metadata_search = options.role.is_none();
889        let metadata_query_pattern = format!("%{}%", options.query);
890
891        if include_metadata_search {
892            // For the metadata search, we need 3 separate params for the OR conditions
893            let meta_param1 = param_idx;
894            let meta_param2 = param_idx + 1;
895            let meta_param3 = param_idx + 2;
896            param_idx += 3;
897
898            sql.push_str(&format!(
899                r#"
900            UNION
901            SELECT
902                s.id as session_id,
903                (SELECT id FROM messages WHERE session_id = s.id ORDER BY idx LIMIT 1) as message_id,
904                'user' as role,
905                substr(s.tool || ' session in ' || s.working_directory || COALESCE(' on branch ' || s.git_branch, ''), 1, 100) as snippet,
906                s.started_at as timestamp,
907                s.working_directory,
908                s.tool,
909                s.git_branch,
910                s.message_count,
911                s.started_at,
912                0 as message_index
913            FROM sessions s
914            WHERE (
915                s.tool LIKE ?{meta_param1}
916                OR s.working_directory LIKE ?{meta_param2}
917                OR s.git_branch LIKE ?{meta_param3}
918            )
919            "#
920            ));
921
922            // Add metadata patterns to params
923            params_vec.push(Box::new(metadata_query_pattern.clone()));
924            params_vec.push(Box::new(metadata_query_pattern.clone()));
925            params_vec.push(Box::new(metadata_query_pattern));
926
927            // Re-apply session-level filters to the UNION query
928            if options.repo.is_some() {
929                sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
930                params_vec.push(Box::new(format!("{}%", options.repo.as_ref().unwrap())));
931                param_idx += 1;
932            }
933            if options.tool.is_some() {
934                sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
935                params_vec.push(Box::new(options.tool.as_ref().unwrap().clone()));
936                param_idx += 1;
937            }
938            if options.since.is_some() {
939                sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
940                params_vec.push(Box::new(options.since.unwrap().to_rfc3339()));
941                param_idx += 1;
942            }
943            if options.until.is_some() {
944                sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
945                params_vec.push(Box::new(options.until.unwrap().to_rfc3339()));
946                param_idx += 1;
947            }
948            if options.project.is_some() {
949                sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
950                params_vec.push(Box::new(format!("%{}%", options.project.as_ref().unwrap())));
951                param_idx += 1;
952            }
953            if options.branch.is_some() {
954                sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
955                params_vec.push(Box::new(format!("%{}%", options.branch.as_ref().unwrap())));
956                param_idx += 1;
957            }
958        }
959
960        sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{param_idx}"));
961        params_vec.push(Box::new(options.limit as i64));
962
963        // Prepare and execute
964        let mut stmt = self.conn.prepare(&sql)?;
965        let params_refs: Vec<&dyn rusqlite::ToSql> =
966            params_vec.iter().map(|p| p.as_ref()).collect();
967
968        let rows = stmt.query_map(params_refs.as_slice(), |row| {
969            let role_str: String = row.get(2)?;
970            let git_branch: Option<String> = row.get(7)?;
971            let started_at_str: Option<String> = row.get(9)?;
972
973            Ok(SearchResult {
974                session_id: parse_uuid(&row.get::<_, String>(0)?)?,
975                message_id: parse_uuid(&row.get::<_, String>(1)?)?,
976                role: match role_str.as_str() {
977                    "user" => MessageRole::User,
978                    "assistant" => MessageRole::Assistant,
979                    "system" => MessageRole::System,
980                    _ => MessageRole::User,
981                },
982                snippet: row.get(3)?,
983                timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
984                working_directory: row.get(5)?,
985                tool: row.get(6)?,
986                git_branch,
987                session_message_count: row.get(8)?,
988                session_started_at: started_at_str.map(|s| parse_datetime(&s)).transpose()?,
989                message_index: row.get(10)?,
990            })
991        })?;
992
993        rows.collect::<Result<Vec<_>, _>>()
994            .context("Failed to search messages")
995    }
996
997    /// Gets messages around a specific message for context.
998    ///
999    /// Returns N messages before and N messages after the specified message,
1000    /// useful for displaying search results with surrounding context.
1001    pub fn get_context_messages(
1002        &self,
1003        session_id: &Uuid,
1004        message_index: i32,
1005        context_count: usize,
1006    ) -> Result<(Vec<Message>, Vec<Message>)> {
1007        // Get messages before
1008        let mut before_stmt = self.conn.prepare(
1009            "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1010             FROM messages
1011             WHERE session_id = ?1 AND idx < ?2
1012             ORDER BY idx DESC
1013             LIMIT ?3",
1014        )?;
1015
1016        let before_rows = before_stmt.query_map(
1017            params![session_id.to_string(), message_index, context_count as i64],
1018            Self::row_to_message,
1019        )?;
1020
1021        let mut before: Vec<Message> = before_rows
1022            .collect::<Result<Vec<_>, _>>()
1023            .context("Failed to get before messages")?;
1024        before.reverse(); // Put in chronological order
1025
1026        // Get messages after
1027        let mut after_stmt = self.conn.prepare(
1028            "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1029             FROM messages
1030             WHERE session_id = ?1 AND idx > ?2
1031             ORDER BY idx ASC
1032             LIMIT ?3",
1033        )?;
1034
1035        let after_rows = after_stmt.query_map(
1036            params![session_id.to_string(), message_index, context_count as i64],
1037            Self::row_to_message,
1038        )?;
1039
1040        let after: Vec<Message> = after_rows
1041            .collect::<Result<Vec<_>, _>>()
1042            .context("Failed to get after messages")?;
1043
1044        Ok((before, after))
1045    }
1046
1047    /// Gets a single message by its index within a session.
1048    #[allow(dead_code)]
1049    pub fn get_message_by_index(&self, session_id: &Uuid, index: i32) -> Result<Option<Message>> {
1050        self.conn
1051            .query_row(
1052                "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1053                 FROM messages
1054                 WHERE session_id = ?1 AND idx = ?2",
1055                params![session_id.to_string(), index],
1056                Self::row_to_message,
1057            )
1058            .optional()
1059            .context("Failed to get message by index")
1060    }
1061
1062    fn row_to_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
1063        let role_str: String = row.get(5)?;
1064        let content_str: String = row.get(6)?;
1065
1066        let parent_id_str: Option<String> = row.get(2)?;
1067        let parent_id = match parent_id_str {
1068            Some(s) => Some(parse_uuid(&s)?),
1069            None => None,
1070        };
1071
1072        Ok(Message {
1073            id: parse_uuid(&row.get::<_, String>(0)?)?,
1074            session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1075            parent_id,
1076            index: row.get(3)?,
1077            timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1078            role: match role_str.as_str() {
1079                "user" => MessageRole::User,
1080                "assistant" => MessageRole::Assistant,
1081                "system" => MessageRole::System,
1082                _ => MessageRole::User,
1083            },
1084            content: serde_json::from_str(&content_str)
1085                .unwrap_or(MessageContent::Text(content_str)),
1086            model: row.get(7)?,
1087            git_branch: row.get(8)?,
1088            cwd: row.get(9)?,
1089        })
1090    }
1091
1092    /// Rebuilds the full-text search index from existing messages and sessions.
1093    ///
1094    /// This should be called when:
1095    /// - Upgrading from a database without FTS support
1096    /// - The FTS index becomes corrupted or out of sync
1097    ///
1098    /// Returns the number of messages indexed.
1099    pub fn rebuild_search_index(&self) -> Result<usize> {
1100        // Clear existing FTS data
1101        self.conn.execute("DELETE FROM messages_fts", [])?;
1102        self.conn.execute("DELETE FROM sessions_fts", [])?;
1103
1104        // Reindex all messages
1105        let mut msg_stmt = self.conn.prepare("SELECT id, content FROM messages")?;
1106
1107        let rows = msg_stmt.query_map([], |row| {
1108            let id: String = row.get(0)?;
1109            let content_json: String = row.get(1)?;
1110            Ok((id, content_json))
1111        })?;
1112
1113        let mut count = 0;
1114        for row in rows {
1115            let (id, content_json) = row?;
1116            // Parse the content JSON and extract text
1117            let content: MessageContent = serde_json::from_str(&content_json)
1118                .unwrap_or(MessageContent::Text(content_json.clone()));
1119            let text_content = content.text();
1120
1121            if !text_content.is_empty() {
1122                self.conn.execute(
1123                    "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
1124                    params![id, text_content],
1125                )?;
1126                count += 1;
1127            }
1128        }
1129
1130        // Reindex all sessions for metadata search
1131        let mut session_stmt = self
1132            .conn
1133            .prepare("SELECT id, tool, working_directory, git_branch FROM sessions")?;
1134
1135        let session_rows = session_stmt.query_map([], |row| {
1136            let id: String = row.get(0)?;
1137            let tool: String = row.get(1)?;
1138            let working_directory: String = row.get(2)?;
1139            let git_branch: Option<String> = row.get(3)?;
1140            Ok((id, tool, working_directory, git_branch))
1141        })?;
1142
1143        for row in session_rows {
1144            let (id, tool, working_directory, git_branch) = row?;
1145            self.conn.execute(
1146                "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
1147                params![id, tool, working_directory, git_branch.unwrap_or_default()],
1148            )?;
1149        }
1150
1151        Ok(count)
1152    }
1153
1154    /// Checks if the search index needs rebuilding.
1155    ///
1156    /// Returns true if there are messages or sessions in the database but the FTS
1157    /// indexes are empty, indicating data was imported before FTS was added.
1158    pub fn search_index_needs_rebuild(&self) -> Result<bool> {
1159        let message_count: i32 =
1160            self.conn
1161                .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1162
1163        let msg_fts_count: i32 =
1164            self.conn
1165                .query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
1166
1167        let session_count: i32 =
1168            self.conn
1169                .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1170
1171        let session_fts_count: i32 =
1172            self.conn
1173                .query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
1174
1175        // Rebuild needed if we have messages/sessions but either FTS index is empty
1176        Ok((message_count > 0 && msg_fts_count == 0)
1177            || (session_count > 0 && session_fts_count == 0))
1178    }
1179
1180    // ==================== Stats ====================
1181
1182    /// Returns the total number of sessions in the database.
1183    pub fn session_count(&self) -> Result<i32> {
1184        let count: i32 = self
1185            .conn
1186            .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
1187        Ok(count)
1188    }
1189
1190    /// Returns the total number of messages across all sessions.
1191    pub fn message_count(&self) -> Result<i32> {
1192        let count: i32 = self
1193            .conn
1194            .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
1195        Ok(count)
1196    }
1197
1198    /// Returns the total number of session links in the database.
1199    pub fn link_count(&self) -> Result<i32> {
1200        let count: i32 = self
1201            .conn
1202            .query_row("SELECT COUNT(*) FROM session_links", [], |row| row.get(0))?;
1203        Ok(count)
1204    }
1205
1206    /// Returns the path to the database file, if available.
1207    ///
1208    /// Returns `None` for in-memory databases.
1209    pub fn db_path(&self) -> Option<std::path::PathBuf> {
1210        self.conn.path().map(std::path::PathBuf::from)
1211    }
1212
1213    // ==================== Auto-linking ====================
1214
1215    /// Finds sessions that were active around a commit time.
1216    ///
1217    /// A session is considered active if the commit time falls within the
1218    /// window before and after the session's time range (started_at to ended_at).
1219    ///
1220    /// # Arguments
1221    ///
1222    /// * `commit_time` - The timestamp of the commit
1223    /// * `window_minutes` - The window in minutes before/after the session
1224    /// * `working_dir` - Optional working directory filter (prefix match)
1225    ///
1226    /// # Returns
1227    ///
1228    /// Sessions that were active near the commit time, ordered by proximity.
1229    pub fn find_sessions_near_commit_time(
1230        &self,
1231        commit_time: chrono::DateTime<chrono::Utc>,
1232        window_minutes: i64,
1233        working_dir: Option<&str>,
1234    ) -> Result<Vec<Session>> {
1235        // Convert commit time to RFC3339 for SQLite comparison
1236        let commit_time_str = commit_time.to_rfc3339();
1237
1238        // Calculate the time window boundaries
1239        let window = chrono::Duration::minutes(window_minutes);
1240        let window_start = (commit_time - window).to_rfc3339();
1241        let window_end = (commit_time + window).to_rfc3339();
1242
1243        let sql = if working_dir.is_some() {
1244            r#"
1245            SELECT id, tool, tool_version, started_at, ended_at, model,
1246                   working_directory, git_branch, source_path, message_count, machine_id
1247            FROM sessions
1248            WHERE working_directory LIKE ?1
1249              AND (
1250                  -- Session started before or during the window
1251                  (started_at <= ?3)
1252                  AND
1253                  -- Session ended after or during the window (or is still ongoing)
1254                  (ended_at IS NULL OR ended_at >= ?2)
1255              )
1256            ORDER BY
1257              -- Order by how close the session end (or start) is to commit time
1258              ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?4))
1259            "#
1260        } else {
1261            r#"
1262            SELECT id, tool, tool_version, started_at, ended_at, model,
1263                   working_directory, git_branch, source_path, message_count, machine_id
1264            FROM sessions
1265            WHERE
1266              -- Session started before or during the window
1267              (started_at <= ?2)
1268              AND
1269              -- Session ended after or during the window (or is still ongoing)
1270              (ended_at IS NULL OR ended_at >= ?1)
1271            ORDER BY
1272              -- Order by how close the session end (or start) is to commit time
1273              ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?3))
1274            "#
1275        };
1276
1277        let mut stmt = self.conn.prepare(sql)?;
1278
1279        let rows = if let Some(wd) = working_dir {
1280            stmt.query_map(
1281                params![format!("{wd}%"), window_start, window_end, commit_time_str],
1282                Self::row_to_session,
1283            )?
1284        } else {
1285            stmt.query_map(
1286                params![window_start, window_end, commit_time_str],
1287                Self::row_to_session,
1288            )?
1289        };
1290
1291        rows.collect::<Result<Vec<_>, _>>()
1292            .context("Failed to find sessions near commit time")
1293    }
1294
1295    /// Checks if a link already exists between a session and commit.
1296    ///
1297    /// Used to avoid creating duplicate links during auto-linking.
1298    pub fn link_exists(&self, session_id: &Uuid, commit_sha: &str) -> Result<bool> {
1299        let pattern = format!("{commit_sha}%");
1300        let count: i32 = self.conn.query_row(
1301            "SELECT COUNT(*) FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1302            params![session_id.to_string(), pattern],
1303            |row| row.get(0),
1304        )?;
1305        Ok(count > 0)
1306    }
1307
1308    // ==================== Session Deletion ====================
1309
1310    /// Deletes a session and all its associated data.
1311    ///
1312    /// Removes the session, all its messages, all FTS index entries, and all
1313    /// session links. Returns the counts of deleted items.
1314    ///
1315    /// # Returns
1316    ///
1317    /// A tuple of (messages_deleted, links_deleted) counts.
1318    pub fn delete_session(&self, session_id: &Uuid) -> Result<(usize, usize)> {
1319        let session_id_str = session_id.to_string();
1320
1321        // Delete from messages_fts first (need message IDs)
1322        self.conn.execute(
1323            "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1324            params![session_id_str],
1325        )?;
1326
1327        // Delete messages
1328        let messages_deleted = self.conn.execute(
1329            "DELETE FROM messages WHERE session_id = ?1",
1330            params![session_id_str],
1331        )?;
1332
1333        // Delete links
1334        let links_deleted = self.conn.execute(
1335            "DELETE FROM session_links WHERE session_id = ?1",
1336            params![session_id_str],
1337        )?;
1338
1339        // Delete annotations
1340        self.conn.execute(
1341            "DELETE FROM annotations WHERE session_id = ?1",
1342            params![session_id_str],
1343        )?;
1344
1345        // Delete tags
1346        self.conn.execute(
1347            "DELETE FROM tags WHERE session_id = ?1",
1348            params![session_id_str],
1349        )?;
1350
1351        // Delete summary
1352        self.conn.execute(
1353            "DELETE FROM summaries WHERE session_id = ?1",
1354            params![session_id_str],
1355        )?;
1356
1357        // Delete from sessions_fts
1358        self.conn.execute(
1359            "DELETE FROM sessions_fts WHERE session_id = ?1",
1360            params![session_id_str],
1361        )?;
1362
1363        // Delete the session itself
1364        self.conn.execute(
1365            "DELETE FROM sessions WHERE id = ?1",
1366            params![session_id_str],
1367        )?;
1368
1369        Ok((messages_deleted, links_deleted))
1370    }
1371
1372    // ==================== Annotations ====================
1373
1374    /// Inserts a new annotation for a session.
1375    ///
1376    /// Annotations are user-created bookmarks or notes attached to sessions.
1377    pub fn insert_annotation(&self, annotation: &Annotation) -> Result<()> {
1378        self.conn.execute(
1379            r#"
1380            INSERT INTO annotations (id, session_id, content, created_at)
1381            VALUES (?1, ?2, ?3, ?4)
1382            "#,
1383            params![
1384                annotation.id.to_string(),
1385                annotation.session_id.to_string(),
1386                annotation.content,
1387                annotation.created_at.to_rfc3339(),
1388            ],
1389        )?;
1390        Ok(())
1391    }
1392
1393    /// Retrieves all annotations for a session.
1394    ///
1395    /// Annotations are returned in order of creation (oldest first).
1396    #[allow(dead_code)]
1397    pub fn get_annotations(&self, session_id: &Uuid) -> Result<Vec<Annotation>> {
1398        let mut stmt = self.conn.prepare(
1399            "SELECT id, session_id, content, created_at
1400             FROM annotations
1401             WHERE session_id = ?1
1402             ORDER BY created_at ASC",
1403        )?;
1404
1405        let rows = stmt.query_map(params![session_id.to_string()], |row| {
1406            Ok(Annotation {
1407                id: parse_uuid(&row.get::<_, String>(0)?)?,
1408                session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1409                content: row.get(2)?,
1410                created_at: parse_datetime(&row.get::<_, String>(3)?)?,
1411            })
1412        })?;
1413
1414        rows.collect::<Result<Vec<_>, _>>()
1415            .context("Failed to get annotations")
1416    }
1417
1418    /// Deletes an annotation by its ID.
1419    ///
1420    /// Returns `true` if an annotation was deleted, `false` if not found.
1421    #[allow(dead_code)]
1422    pub fn delete_annotation(&self, annotation_id: &Uuid) -> Result<bool> {
1423        let rows_affected = self.conn.execute(
1424            "DELETE FROM annotations WHERE id = ?1",
1425            params![annotation_id.to_string()],
1426        )?;
1427        Ok(rows_affected > 0)
1428    }
1429
1430    /// Deletes all annotations for a session.
1431    ///
1432    /// Returns the number of annotations deleted.
1433    #[allow(dead_code)]
1434    pub fn delete_annotations_by_session(&self, session_id: &Uuid) -> Result<usize> {
1435        let rows_affected = self.conn.execute(
1436            "DELETE FROM annotations WHERE session_id = ?1",
1437            params![session_id.to_string()],
1438        )?;
1439        Ok(rows_affected)
1440    }
1441
1442    // ==================== Tags ====================
1443
1444    /// Inserts a new tag for a session.
1445    ///
1446    /// Tags are unique per session, so attempting to add a duplicate
1447    /// tag label to the same session will fail with a constraint error.
1448    pub fn insert_tag(&self, tag: &Tag) -> Result<()> {
1449        self.conn.execute(
1450            r#"
1451            INSERT INTO tags (id, session_id, label, created_at)
1452            VALUES (?1, ?2, ?3, ?4)
1453            "#,
1454            params![
1455                tag.id.to_string(),
1456                tag.session_id.to_string(),
1457                tag.label,
1458                tag.created_at.to_rfc3339(),
1459            ],
1460        )?;
1461        Ok(())
1462    }
1463
1464    /// Retrieves all tags for a session.
1465    ///
1466    /// Tags are returned in alphabetical order by label.
1467    pub fn get_tags(&self, session_id: &Uuid) -> Result<Vec<Tag>> {
1468        let mut stmt = self.conn.prepare(
1469            "SELECT id, session_id, label, created_at
1470             FROM tags
1471             WHERE session_id = ?1
1472             ORDER BY label ASC",
1473        )?;
1474
1475        let rows = stmt.query_map(params![session_id.to_string()], |row| {
1476            Ok(Tag {
1477                id: parse_uuid(&row.get::<_, String>(0)?)?,
1478                session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1479                label: row.get(2)?,
1480                created_at: parse_datetime(&row.get::<_, String>(3)?)?,
1481            })
1482        })?;
1483
1484        rows.collect::<Result<Vec<_>, _>>()
1485            .context("Failed to get tags")
1486    }
1487
1488    /// Checks if a tag with the given label exists for a session.
1489    pub fn tag_exists(&self, session_id: &Uuid, label: &str) -> Result<bool> {
1490        let count: i32 = self.conn.query_row(
1491            "SELECT COUNT(*) FROM tags WHERE session_id = ?1 AND label = ?2",
1492            params![session_id.to_string(), label],
1493            |row| row.get(0),
1494        )?;
1495        Ok(count > 0)
1496    }
1497
1498    /// Deletes a tag by session ID and label.
1499    ///
1500    /// Returns `true` if a tag was deleted, `false` if not found.
1501    pub fn delete_tag(&self, session_id: &Uuid, label: &str) -> Result<bool> {
1502        let rows_affected = self.conn.execute(
1503            "DELETE FROM tags WHERE session_id = ?1 AND label = ?2",
1504            params![session_id.to_string(), label],
1505        )?;
1506        Ok(rows_affected > 0)
1507    }
1508
1509    /// Deletes all tags for a session.
1510    ///
1511    /// Returns the number of tags deleted.
1512    #[allow(dead_code)]
1513    pub fn delete_tags_by_session(&self, session_id: &Uuid) -> Result<usize> {
1514        let rows_affected = self.conn.execute(
1515            "DELETE FROM tags WHERE session_id = ?1",
1516            params![session_id.to_string()],
1517        )?;
1518        Ok(rows_affected)
1519    }
1520
1521    /// Lists sessions with a specific tag label.
1522    ///
1523    /// Returns sessions ordered by start time (most recent first).
1524    pub fn list_sessions_with_tag(&self, label: &str, limit: usize) -> Result<Vec<Session>> {
1525        let mut stmt = self.conn.prepare(
1526            "SELECT s.id, s.tool, s.tool_version, s.started_at, s.ended_at, s.model,
1527                    s.working_directory, s.git_branch, s.source_path, s.message_count, s.machine_id
1528             FROM sessions s
1529             INNER JOIN tags t ON s.id = t.session_id
1530             WHERE t.label = ?1
1531             ORDER BY s.started_at DESC
1532             LIMIT ?2",
1533        )?;
1534
1535        let rows = stmt.query_map(params![label, limit], Self::row_to_session)?;
1536
1537        rows.collect::<Result<Vec<_>, _>>()
1538            .context("Failed to list sessions with tag")
1539    }
1540
1541    // ==================== Summaries ====================
1542
1543    /// Inserts a new summary for a session.
1544    ///
1545    /// Each session can have at most one summary. If a summary already exists
1546    /// for the session, this will fail due to the unique constraint.
1547    pub fn insert_summary(&self, summary: &Summary) -> Result<()> {
1548        self.conn.execute(
1549            r#"
1550            INSERT INTO summaries (id, session_id, content, generated_at)
1551            VALUES (?1, ?2, ?3, ?4)
1552            "#,
1553            params![
1554                summary.id.to_string(),
1555                summary.session_id.to_string(),
1556                summary.content,
1557                summary.generated_at.to_rfc3339(),
1558            ],
1559        )?;
1560        Ok(())
1561    }
1562
1563    /// Retrieves the summary for a session, if one exists.
1564    pub fn get_summary(&self, session_id: &Uuid) -> Result<Option<Summary>> {
1565        self.conn
1566            .query_row(
1567                "SELECT id, session_id, content, generated_at
1568                 FROM summaries
1569                 WHERE session_id = ?1",
1570                params![session_id.to_string()],
1571                |row| {
1572                    Ok(Summary {
1573                        id: parse_uuid(&row.get::<_, String>(0)?)?,
1574                        session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1575                        content: row.get(2)?,
1576                        generated_at: parse_datetime(&row.get::<_, String>(3)?)?,
1577                    })
1578                },
1579            )
1580            .optional()
1581            .context("Failed to get summary")
1582    }
1583
1584    /// Updates the summary for a session.
1585    ///
1586    /// Updates the content and generated_at timestamp for an existing summary.
1587    /// Returns `true` if a summary was updated, `false` if no summary exists.
1588    pub fn update_summary(&self, session_id: &Uuid, content: &str) -> Result<bool> {
1589        let now = chrono::Utc::now().to_rfc3339();
1590        let rows_affected = self.conn.execute(
1591            "UPDATE summaries SET content = ?1, generated_at = ?2 WHERE session_id = ?3",
1592            params![content, now, session_id.to_string()],
1593        )?;
1594        Ok(rows_affected > 0)
1595    }
1596
1597    /// Deletes the summary for a session.
1598    ///
1599    /// Returns `true` if a summary was deleted, `false` if no summary existed.
1600    #[allow(dead_code)]
1601    pub fn delete_summary(&self, session_id: &Uuid) -> Result<bool> {
1602        let rows_affected = self.conn.execute(
1603            "DELETE FROM summaries WHERE session_id = ?1",
1604            params![session_id.to_string()],
1605        )?;
1606        Ok(rows_affected > 0)
1607    }
1608
1609    // ==================== Machines ====================
1610
1611    /// Registers a machine or updates its name if it already exists.
1612    ///
1613    /// Used to store machine identity information for cloud sync.
1614    /// If a machine with the given ID already exists, updates the name.
1615    pub fn upsert_machine(&self, machine: &Machine) -> Result<()> {
1616        self.conn.execute(
1617            r#"
1618            INSERT INTO machines (id, name, created_at)
1619            VALUES (?1, ?2, ?3)
1620            ON CONFLICT(id) DO UPDATE SET
1621                name = ?2
1622            "#,
1623            params![machine.id, machine.name, machine.created_at],
1624        )?;
1625        Ok(())
1626    }
1627
1628    /// Gets a machine by ID.
1629    ///
1630    /// Returns `None` if no machine with the given ID exists.
1631    #[allow(dead_code)]
1632    pub fn get_machine(&self, id: &str) -> Result<Option<Machine>> {
1633        self.conn
1634            .query_row(
1635                "SELECT id, name, created_at FROM machines WHERE id = ?1",
1636                params![id],
1637                |row| {
1638                    Ok(Machine {
1639                        id: row.get(0)?,
1640                        name: row.get(1)?,
1641                        created_at: row.get(2)?,
1642                    })
1643                },
1644            )
1645            .optional()
1646            .context("Failed to get machine")
1647    }
1648
1649    /// Gets the display name for a machine ID.
1650    ///
1651    /// Returns the machine name if found, otherwise returns a truncated UUID
1652    /// (first 8 characters) for readability.
1653    #[allow(dead_code)]
1654    pub fn get_machine_name(&self, id: &str) -> Result<String> {
1655        if let Some(machine) = self.get_machine(id)? {
1656            Ok(machine.name)
1657        } else {
1658            // Fallback to truncated UUID
1659            if id.len() > 8 {
1660                Ok(id[..8].to_string())
1661            } else {
1662                Ok(id.to_string())
1663            }
1664        }
1665    }
1666
1667    /// Lists all registered machines.
1668    ///
1669    /// Returns machines ordered by creation date (oldest first).
1670    #[allow(dead_code)]
1671    pub fn list_machines(&self) -> Result<Vec<Machine>> {
1672        let mut stmt = self
1673            .conn
1674            .prepare("SELECT id, name, created_at FROM machines ORDER BY created_at ASC")?;
1675
1676        let rows = stmt.query_map([], |row| {
1677            Ok(Machine {
1678                id: row.get(0)?,
1679                name: row.get(1)?,
1680                created_at: row.get(2)?,
1681            })
1682        })?;
1683
1684        rows.collect::<Result<Vec<_>, _>>()
1685            .context("Failed to list machines")
1686    }
1687
1688    /// Gets the most recent session for a given working directory.
1689    ///
1690    /// Returns the session with the most recent started_at timestamp
1691    /// where the working directory matches or is a subdirectory of the given path.
1692    pub fn get_most_recent_session_for_directory(
1693        &self,
1694        working_dir: &str,
1695    ) -> Result<Option<Session>> {
1696        self.conn
1697            .query_row(
1698                "SELECT id, tool, tool_version, started_at, ended_at, model,
1699                        working_directory, git_branch, source_path, message_count, machine_id
1700                 FROM sessions
1701                 WHERE working_directory LIKE ?1
1702                 ORDER BY started_at DESC
1703                 LIMIT 1",
1704                params![format!("{working_dir}%")],
1705                Self::row_to_session,
1706            )
1707            .optional()
1708            .context("Failed to get most recent session for directory")
1709    }
1710
1711    // ==================== Database Maintenance ====================
1712
1713    /// Runs SQLite VACUUM to reclaim unused space and defragment the database.
1714    ///
1715    /// This operation can take some time on large databases and temporarily
1716    /// doubles the disk space used while rebuilding.
1717    pub fn vacuum(&self) -> Result<()> {
1718        self.conn.execute("VACUUM", [])?;
1719        Ok(())
1720    }
1721
1722    /// Returns the file size of the database in bytes.
1723    ///
1724    /// Returns `None` for in-memory databases.
1725    pub fn file_size(&self) -> Result<Option<u64>> {
1726        if let Some(path) = self.db_path() {
1727            let metadata = std::fs::metadata(&path)?;
1728            Ok(Some(metadata.len()))
1729        } else {
1730            Ok(None)
1731        }
1732    }
1733
1734    /// Deletes sessions older than the specified date.
1735    ///
1736    /// Also deletes all associated messages, links, and FTS entries.
1737    ///
1738    /// # Arguments
1739    ///
1740    /// * `before` - Delete sessions that started before this date
1741    ///
1742    /// # Returns
1743    ///
1744    /// The number of sessions deleted.
1745    pub fn delete_sessions_older_than(&self, before: DateTime<Utc>) -> Result<usize> {
1746        let before_str = before.to_rfc3339();
1747
1748        // Get session IDs to delete
1749        let mut stmt = self
1750            .conn
1751            .prepare("SELECT id FROM sessions WHERE started_at < ?1")?;
1752        let session_ids: Vec<String> = stmt
1753            .query_map(params![before_str], |row| row.get(0))?
1754            .collect::<Result<Vec<_>, _>>()?;
1755
1756        if session_ids.is_empty() {
1757            return Ok(0);
1758        }
1759
1760        let count = session_ids.len();
1761
1762        // Delete associated data for each session
1763        for session_id_str in &session_ids {
1764            // Delete from messages_fts
1765            self.conn.execute(
1766                "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
1767                params![session_id_str],
1768            )?;
1769
1770            // Delete messages
1771            self.conn.execute(
1772                "DELETE FROM messages WHERE session_id = ?1",
1773                params![session_id_str],
1774            )?;
1775
1776            // Delete links
1777            self.conn.execute(
1778                "DELETE FROM session_links WHERE session_id = ?1",
1779                params![session_id_str],
1780            )?;
1781
1782            // Delete annotations
1783            self.conn.execute(
1784                "DELETE FROM annotations WHERE session_id = ?1",
1785                params![session_id_str],
1786            )?;
1787
1788            // Delete tags
1789            self.conn.execute(
1790                "DELETE FROM tags WHERE session_id = ?1",
1791                params![session_id_str],
1792            )?;
1793
1794            // Delete summary
1795            self.conn.execute(
1796                "DELETE FROM summaries WHERE session_id = ?1",
1797                params![session_id_str],
1798            )?;
1799
1800            // Delete from sessions_fts
1801            self.conn.execute(
1802                "DELETE FROM sessions_fts WHERE session_id = ?1",
1803                params![session_id_str],
1804            )?;
1805        }
1806
1807        // Delete the sessions
1808        self.conn.execute(
1809            "DELETE FROM sessions WHERE started_at < ?1",
1810            params![before_str],
1811        )?;
1812
1813        Ok(count)
1814    }
1815
1816    /// Counts sessions older than the specified date (for dry-run preview).
1817    ///
1818    /// # Arguments
1819    ///
1820    /// * `before` - Count sessions that started before this date
1821    ///
1822    /// # Returns
1823    ///
1824    /// The number of sessions that would be deleted.
1825    pub fn count_sessions_older_than(&self, before: DateTime<Utc>) -> Result<i32> {
1826        let before_str = before.to_rfc3339();
1827        let count: i32 = self.conn.query_row(
1828            "SELECT COUNT(*) FROM sessions WHERE started_at < ?1",
1829            params![before_str],
1830            |row| row.get(0),
1831        )?;
1832        Ok(count)
1833    }
1834
1835    /// Returns sessions older than the specified date (for dry-run preview).
1836    ///
1837    /// # Arguments
1838    ///
1839    /// * `before` - Return sessions that started before this date
1840    ///
1841    /// # Returns
1842    ///
1843    /// A vector of sessions that would be deleted, ordered by start date.
1844    pub fn get_sessions_older_than(&self, before: DateTime<Utc>) -> Result<Vec<Session>> {
1845        let before_str = before.to_rfc3339();
1846        let mut stmt = self.conn.prepare(
1847            "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
1848             FROM sessions
1849             WHERE started_at < ?1
1850             ORDER BY started_at ASC",
1851        )?;
1852
1853        let rows = stmt.query_map(params![before_str], Self::row_to_session)?;
1854
1855        rows.collect::<Result<Vec<_>, _>>()
1856            .context("Failed to get sessions older than cutoff")
1857    }
1858
1859    /// Returns database statistics including counts and date ranges.
1860    ///
1861    /// # Returns
1862    ///
1863    /// A `DatabaseStats` struct with session, message, and link counts,
1864    /// plus the date range of sessions and a breakdown by tool.
1865    pub fn stats(&self) -> Result<DatabaseStats> {
1866        let session_count = self.session_count()?;
1867        let message_count = self.message_count()?;
1868        let link_count = self.link_count()?;
1869
1870        // Get date range
1871        let oldest: Option<String> = self
1872            .conn
1873            .query_row("SELECT MIN(started_at) FROM sessions", [], |row| row.get(0))
1874            .optional()?
1875            .flatten();
1876
1877        let newest: Option<String> = self
1878            .conn
1879            .query_row("SELECT MAX(started_at) FROM sessions", [], |row| row.get(0))
1880            .optional()?
1881            .flatten();
1882
1883        let oldest_session = oldest
1884            .map(|s| parse_datetime(&s))
1885            .transpose()
1886            .unwrap_or(None);
1887        let newest_session = newest
1888            .map(|s| parse_datetime(&s))
1889            .transpose()
1890            .unwrap_or(None);
1891
1892        // Get sessions by tool
1893        let mut stmt = self
1894            .conn
1895            .prepare("SELECT tool, COUNT(*) FROM sessions GROUP BY tool ORDER BY COUNT(*) DESC")?;
1896        let sessions_by_tool: Vec<(String, i32)> = stmt
1897            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1898            .collect::<Result<Vec<_>, _>>()?;
1899
1900        Ok(DatabaseStats {
1901            session_count,
1902            message_count,
1903            link_count,
1904            oldest_session,
1905            newest_session,
1906            sessions_by_tool,
1907        })
1908    }
1909}
1910
1911/// Statistics about the Lore database.
1912#[derive(Debug, Clone)]
1913pub struct DatabaseStats {
1914    /// Total number of sessions.
1915    pub session_count: i32,
1916    /// Total number of messages.
1917    pub message_count: i32,
1918    /// Total number of session links.
1919    pub link_count: i32,
1920    /// Timestamp of the oldest session.
1921    pub oldest_session: Option<DateTime<Utc>>,
1922    /// Timestamp of the newest session.
1923    pub newest_session: Option<DateTime<Utc>>,
1924    /// Session counts grouped by tool name.
1925    pub sessions_by_tool: Vec<(String, i32)>,
1926}
1927
1928#[cfg(test)]
1929mod tests {
1930    use super::*;
1931    use crate::storage::models::{
1932        LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
1933    };
1934    use chrono::{Duration, Utc};
1935    use tempfile::tempdir;
1936
1937    /// Creates a test database in a temporary directory.
1938    /// Returns the Database instance and the temp directory (which must be kept alive).
1939    fn create_test_db() -> (Database, tempfile::TempDir) {
1940        let dir = tempdir().expect("Failed to create temp directory");
1941        let db_path = dir.path().join("test.db");
1942        let db = Database::open(&db_path).expect("Failed to open test database");
1943        (db, dir)
1944    }
1945
1946    /// Creates a test session with the given parameters.
1947    fn create_test_session(
1948        tool: &str,
1949        working_directory: &str,
1950        started_at: chrono::DateTime<Utc>,
1951        source_path: Option<&str>,
1952    ) -> Session {
1953        Session {
1954            id: Uuid::new_v4(),
1955            tool: tool.to_string(),
1956            tool_version: Some("1.0.0".to_string()),
1957            started_at,
1958            ended_at: None,
1959            model: Some("test-model".to_string()),
1960            working_directory: working_directory.to_string(),
1961            git_branch: Some("main".to_string()),
1962            source_path: source_path.map(|s| s.to_string()),
1963            message_count: 0,
1964            machine_id: Some("test-machine".to_string()),
1965        }
1966    }
1967
1968    /// Creates a test message for the given session.
1969    fn create_test_message(
1970        session_id: Uuid,
1971        index: i32,
1972        role: MessageRole,
1973        content: &str,
1974    ) -> Message {
1975        Message {
1976            id: Uuid::new_v4(),
1977            session_id,
1978            parent_id: None,
1979            index,
1980            timestamp: Utc::now(),
1981            role,
1982            content: MessageContent::Text(content.to_string()),
1983            model: Some("test-model".to_string()),
1984            git_branch: Some("main".to_string()),
1985            cwd: Some("/test/cwd".to_string()),
1986        }
1987    }
1988
1989    /// Creates a test session link for the given session.
1990    fn create_test_link(
1991        session_id: Uuid,
1992        commit_sha: Option<&str>,
1993        link_type: LinkType,
1994    ) -> SessionLink {
1995        SessionLink {
1996            id: Uuid::new_v4(),
1997            session_id,
1998            link_type,
1999            commit_sha: commit_sha.map(|s| s.to_string()),
2000            branch: Some("main".to_string()),
2001            remote: Some("origin".to_string()),
2002            created_at: Utc::now(),
2003            created_by: LinkCreator::Auto,
2004            confidence: Some(0.95),
2005        }
2006    }
2007
2008    // ==================== Session Tests ====================
2009
2010    #[test]
2011    fn test_insert_and_get_session() {
2012        let (db, _dir) = create_test_db();
2013        let session = create_test_session(
2014            "claude-code",
2015            "/home/user/project",
2016            Utc::now(),
2017            Some("/path/to/source.jsonl"),
2018        );
2019
2020        db.insert_session(&session)
2021            .expect("Failed to insert session");
2022
2023        let retrieved = db
2024            .get_session(&session.id)
2025            .expect("Failed to get session")
2026            .expect("Session should exist");
2027
2028        assert_eq!(retrieved.id, session.id, "Session ID should match");
2029        assert_eq!(retrieved.tool, session.tool, "Tool should match");
2030        assert_eq!(
2031            retrieved.tool_version, session.tool_version,
2032            "Tool version should match"
2033        );
2034        assert_eq!(
2035            retrieved.working_directory, session.working_directory,
2036            "Working directory should match"
2037        );
2038        assert_eq!(
2039            retrieved.git_branch, session.git_branch,
2040            "Git branch should match"
2041        );
2042        assert_eq!(
2043            retrieved.source_path, session.source_path,
2044            "Source path should match"
2045        );
2046    }
2047
2048    #[test]
2049    fn test_list_sessions() {
2050        let (db, _dir) = create_test_db();
2051        let now = Utc::now();
2052
2053        // Insert sessions with different timestamps (oldest first)
2054        let session1 =
2055            create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
2056        let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
2057        let session3 = create_test_session("claude-code", "/project3", now, None);
2058
2059        db.insert_session(&session1)
2060            .expect("Failed to insert session1");
2061        db.insert_session(&session2)
2062            .expect("Failed to insert session2");
2063        db.insert_session(&session3)
2064            .expect("Failed to insert session3");
2065
2066        let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
2067
2068        assert_eq!(sessions.len(), 3, "Should have 3 sessions");
2069        // Sessions should be ordered by started_at DESC (most recent first)
2070        assert_eq!(
2071            sessions[0].id, session3.id,
2072            "Most recent session should be first"
2073        );
2074        assert_eq!(
2075            sessions[1].id, session2.id,
2076            "Second most recent session should be second"
2077        );
2078        assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
2079    }
2080
2081    #[test]
2082    fn test_list_sessions_with_working_dir_filter() {
2083        let (db, _dir) = create_test_db();
2084        let now = Utc::now();
2085
2086        let session1 = create_test_session(
2087            "claude-code",
2088            "/home/user/project-a",
2089            now - Duration::hours(1),
2090            None,
2091        );
2092        let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
2093        let session3 = create_test_session("claude-code", "/other/path", now, None);
2094
2095        db.insert_session(&session1)
2096            .expect("Failed to insert session1");
2097        db.insert_session(&session2)
2098            .expect("Failed to insert session2");
2099        db.insert_session(&session3)
2100            .expect("Failed to insert session3");
2101
2102        // Filter by working directory prefix
2103        let sessions = db
2104            .list_sessions(10, Some("/home/user"))
2105            .expect("Failed to list sessions");
2106
2107        assert_eq!(
2108            sessions.len(),
2109            2,
2110            "Should have 2 sessions matching /home/user prefix"
2111        );
2112
2113        // Verify both matching sessions are returned
2114        let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
2115        assert!(ids.contains(&session1.id), "Should contain session1");
2116        assert!(ids.contains(&session2.id), "Should contain session2");
2117        assert!(!ids.contains(&session3.id), "Should not contain session3");
2118    }
2119
2120    #[test]
2121    fn test_session_exists_by_source() {
2122        let (db, _dir) = create_test_db();
2123        let source_path = "/path/to/session.jsonl";
2124
2125        let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
2126
2127        // Before insert, should not exist
2128        assert!(
2129            !db.session_exists_by_source(source_path)
2130                .expect("Failed to check existence"),
2131            "Session should not exist before insert"
2132        );
2133
2134        db.insert_session(&session)
2135            .expect("Failed to insert session");
2136
2137        // After insert, should exist
2138        assert!(
2139            db.session_exists_by_source(source_path)
2140                .expect("Failed to check existence"),
2141            "Session should exist after insert"
2142        );
2143
2144        // Different path should not exist
2145        assert!(
2146            !db.session_exists_by_source("/other/path.jsonl")
2147                .expect("Failed to check existence"),
2148            "Different source path should not exist"
2149        );
2150    }
2151
2152    #[test]
2153    fn test_update_session_branch() {
2154        let (db, _dir) = create_test_db();
2155        let now = Utc::now();
2156
2157        // Create session with initial branch
2158        let mut session = create_test_session("claude-code", "/project", now, None);
2159        session.git_branch = Some("main".to_string());
2160
2161        db.insert_session(&session)
2162            .expect("Failed to insert session");
2163
2164        // Verify initial branch
2165        let fetched = db
2166            .get_session(&session.id)
2167            .expect("Failed to get session")
2168            .expect("Session should exist");
2169        assert_eq!(fetched.git_branch, Some("main".to_string()));
2170
2171        // Update branch
2172        let rows = db
2173            .update_session_branch(session.id, "feature-branch")
2174            .expect("Failed to update branch");
2175        assert_eq!(rows, 1, "Should update exactly one row");
2176
2177        // Verify updated branch
2178        let fetched = db
2179            .get_session(&session.id)
2180            .expect("Failed to get session")
2181            .expect("Session should exist");
2182        assert_eq!(fetched.git_branch, Some("feature-branch".to_string()));
2183    }
2184
2185    #[test]
2186    fn test_update_session_branch_nonexistent() {
2187        let (db, _dir) = create_test_db();
2188        let nonexistent_id = Uuid::new_v4();
2189
2190        // Updating a nonexistent session should return 0 rows
2191        let rows = db
2192            .update_session_branch(nonexistent_id, "some-branch")
2193            .expect("Failed to update branch");
2194        assert_eq!(
2195            rows, 0,
2196            "Should not update any rows for nonexistent session"
2197        );
2198    }
2199
2200    #[test]
2201    fn test_update_session_branch_from_none() {
2202        let (db, _dir) = create_test_db();
2203        let now = Utc::now();
2204
2205        // Create session without initial branch
2206        let mut session = create_test_session("claude-code", "/project", now, None);
2207        session.git_branch = None; // Explicitly set to None for this test
2208
2209        db.insert_session(&session)
2210            .expect("Failed to insert session");
2211
2212        // Verify no initial branch
2213        let fetched = db
2214            .get_session(&session.id)
2215            .expect("Failed to get session")
2216            .expect("Session should exist");
2217        assert_eq!(fetched.git_branch, None);
2218
2219        // Update branch from None to a value
2220        let rows = db
2221            .update_session_branch(session.id, "new-branch")
2222            .expect("Failed to update branch");
2223        assert_eq!(rows, 1, "Should update exactly one row");
2224
2225        // Verify updated branch
2226        let fetched = db
2227            .get_session(&session.id)
2228            .expect("Failed to get session")
2229            .expect("Session should exist");
2230        assert_eq!(fetched.git_branch, Some("new-branch".to_string()));
2231    }
2232
2233    #[test]
2234    fn test_get_nonexistent_session() {
2235        let (db, _dir) = create_test_db();
2236        let nonexistent_id = Uuid::new_v4();
2237
2238        let result = db
2239            .get_session(&nonexistent_id)
2240            .expect("Failed to query for nonexistent session");
2241
2242        assert!(
2243            result.is_none(),
2244            "Should return None for nonexistent session"
2245        );
2246    }
2247
2248    // ==================== Message Tests ====================
2249
2250    #[test]
2251    fn test_insert_and_get_messages() {
2252        let (db, _dir) = create_test_db();
2253
2254        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2255        db.insert_session(&session)
2256            .expect("Failed to insert session");
2257
2258        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
2259        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
2260
2261        db.insert_message(&msg1)
2262            .expect("Failed to insert message 1");
2263        db.insert_message(&msg2)
2264            .expect("Failed to insert message 2");
2265
2266        let messages = db
2267            .get_messages(&session.id)
2268            .expect("Failed to get messages");
2269
2270        assert_eq!(messages.len(), 2, "Should have 2 messages");
2271        assert_eq!(messages[0].id, msg1.id, "First message ID should match");
2272        assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
2273        assert_eq!(
2274            messages[0].role,
2275            MessageRole::User,
2276            "First message role should be User"
2277        );
2278        assert_eq!(
2279            messages[1].role,
2280            MessageRole::Assistant,
2281            "Second message role should be Assistant"
2282        );
2283    }
2284
2285    #[test]
2286    fn test_messages_ordered_by_index() {
2287        let (db, _dir) = create_test_db();
2288
2289        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2290        db.insert_session(&session)
2291            .expect("Failed to insert session");
2292
2293        // Insert messages out of order
2294        let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
2295        let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
2296        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
2297
2298        db.insert_message(&msg3)
2299            .expect("Failed to insert message 3");
2300        db.insert_message(&msg1)
2301            .expect("Failed to insert message 1");
2302        db.insert_message(&msg2)
2303            .expect("Failed to insert message 2");
2304
2305        let messages = db
2306            .get_messages(&session.id)
2307            .expect("Failed to get messages");
2308
2309        assert_eq!(messages.len(), 3, "Should have 3 messages");
2310        assert_eq!(messages[0].index, 0, "First message should have index 0");
2311        assert_eq!(messages[1].index, 1, "Second message should have index 1");
2312        assert_eq!(messages[2].index, 2, "Third message should have index 2");
2313
2314        // Verify content matches expected order
2315        assert_eq!(
2316            messages[0].content.text(),
2317            "First",
2318            "First message content should be 'First'"
2319        );
2320        assert_eq!(
2321            messages[1].content.text(),
2322            "Second",
2323            "Second message content should be 'Second'"
2324        );
2325        assert_eq!(
2326            messages[2].content.text(),
2327            "Third",
2328            "Third message content should be 'Third'"
2329        );
2330    }
2331
2332    // ==================== SessionLink Tests ====================
2333
2334    #[test]
2335    fn test_insert_and_get_links_by_session() {
2336        let (db, _dir) = create_test_db();
2337
2338        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2339        db.insert_session(&session)
2340            .expect("Failed to insert session");
2341
2342        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2343        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2344
2345        db.insert_link(&link1).expect("Failed to insert link 1");
2346        db.insert_link(&link2).expect("Failed to insert link 2");
2347
2348        let links = db
2349            .get_links_by_session(&session.id)
2350            .expect("Failed to get links");
2351
2352        assert_eq!(links.len(), 2, "Should have 2 links");
2353
2354        let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
2355        assert!(link_ids.contains(&link1.id), "Should contain link1");
2356        assert!(link_ids.contains(&link2.id), "Should contain link2");
2357
2358        // Verify link properties
2359        let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
2360        assert_eq!(
2361            retrieved_link.commit_sha,
2362            Some("abc123def456".to_string()),
2363            "Commit SHA should match"
2364        );
2365        assert_eq!(
2366            retrieved_link.link_type,
2367            LinkType::Commit,
2368            "Link type should be Commit"
2369        );
2370        assert_eq!(
2371            retrieved_link.created_by,
2372            LinkCreator::Auto,
2373            "Created by should be Auto"
2374        );
2375    }
2376
2377    #[test]
2378    fn test_get_links_by_commit() {
2379        let (db, _dir) = create_test_db();
2380
2381        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2382        db.insert_session(&session)
2383            .expect("Failed to insert session");
2384
2385        let full_sha = "abc123def456789012345678901234567890abcd";
2386        let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
2387        db.insert_link(&link).expect("Failed to insert link");
2388
2389        // Test full SHA match
2390        let links_full = db
2391            .get_links_by_commit(full_sha)
2392            .expect("Failed to get links by full SHA");
2393        assert_eq!(links_full.len(), 1, "Should find link by full SHA");
2394        assert_eq!(links_full[0].id, link.id, "Link ID should match");
2395
2396        // Test partial SHA match (prefix)
2397        let links_partial = db
2398            .get_links_by_commit("abc123")
2399            .expect("Failed to get links by partial SHA");
2400        assert_eq!(
2401            links_partial.len(),
2402            1,
2403            "Should find link by partial SHA prefix"
2404        );
2405        assert_eq!(links_partial[0].id, link.id, "Link ID should match");
2406
2407        // Test non-matching SHA
2408        let links_none = db
2409            .get_links_by_commit("zzz999")
2410            .expect("Failed to get links by non-matching SHA");
2411        assert_eq!(
2412            links_none.len(),
2413            0,
2414            "Should not find link with non-matching SHA"
2415        );
2416    }
2417
2418    // ==================== Database Tests ====================
2419
2420    #[test]
2421    fn test_database_creation() {
2422        let dir = tempdir().expect("Failed to create temp directory");
2423        let db_path = dir.path().join("new_test.db");
2424
2425        // Database should not exist before creation
2426        assert!(
2427            !db_path.exists(),
2428            "Database file should not exist before creation"
2429        );
2430
2431        let db = Database::open(&db_path).expect("Failed to create database");
2432
2433        // Database file should exist after creation
2434        assert!(
2435            db_path.exists(),
2436            "Database file should exist after creation"
2437        );
2438
2439        // Verify tables exist by attempting operations
2440        let session_count = db.session_count().expect("Failed to get session count");
2441        assert_eq!(session_count, 0, "New database should have 0 sessions");
2442
2443        let message_count = db.message_count().expect("Failed to get message count");
2444        assert_eq!(message_count, 0, "New database should have 0 messages");
2445    }
2446
2447    #[test]
2448    fn test_session_count() {
2449        let (db, _dir) = create_test_db();
2450
2451        assert_eq!(
2452            db.session_count().expect("Failed to get count"),
2453            0,
2454            "Initial session count should be 0"
2455        );
2456
2457        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2458        db.insert_session(&session1)
2459            .expect("Failed to insert session1");
2460
2461        assert_eq!(
2462            db.session_count().expect("Failed to get count"),
2463            1,
2464            "Session count should be 1 after first insert"
2465        );
2466
2467        let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
2468        db.insert_session(&session2)
2469            .expect("Failed to insert session2");
2470
2471        assert_eq!(
2472            db.session_count().expect("Failed to get count"),
2473            2,
2474            "Session count should be 2 after second insert"
2475        );
2476    }
2477
2478    #[test]
2479    fn test_message_count() {
2480        let (db, _dir) = create_test_db();
2481
2482        assert_eq!(
2483            db.message_count().expect("Failed to get count"),
2484            0,
2485            "Initial message count should be 0"
2486        );
2487
2488        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2489        db.insert_session(&session)
2490            .expect("Failed to insert session");
2491
2492        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
2493        db.insert_message(&msg1).expect("Failed to insert message1");
2494
2495        assert_eq!(
2496            db.message_count().expect("Failed to get count"),
2497            1,
2498            "Message count should be 1 after first insert"
2499        );
2500
2501        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
2502        let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
2503        db.insert_message(&msg2).expect("Failed to insert message2");
2504        db.insert_message(&msg3).expect("Failed to insert message3");
2505
2506        assert_eq!(
2507            db.message_count().expect("Failed to get count"),
2508            3,
2509            "Message count should be 3 after all inserts"
2510        );
2511    }
2512
2513    #[test]
2514    fn test_link_count() {
2515        let (db, _dir) = create_test_db();
2516
2517        assert_eq!(
2518            db.link_count().expect("Failed to get count"),
2519            0,
2520            "Initial link count should be 0"
2521        );
2522
2523        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2524        db.insert_session(&session)
2525            .expect("Failed to insert session");
2526
2527        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2528        db.insert_link(&link1).expect("Failed to insert link1");
2529
2530        assert_eq!(
2531            db.link_count().expect("Failed to get count"),
2532            1,
2533            "Link count should be 1 after first insert"
2534        );
2535
2536        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2537        db.insert_link(&link2).expect("Failed to insert link2");
2538
2539        assert_eq!(
2540            db.link_count().expect("Failed to get count"),
2541            2,
2542            "Link count should be 2 after second insert"
2543        );
2544    }
2545
2546    #[test]
2547    fn test_db_path() {
2548        let dir = tempdir().expect("Failed to create temp directory");
2549        let db_path = dir.path().join("test.db");
2550        let db = Database::open(&db_path).expect("Failed to open test database");
2551
2552        let retrieved_path = db.db_path();
2553        assert!(
2554            retrieved_path.is_some(),
2555            "Database path should be available"
2556        );
2557
2558        // Canonicalize both paths to handle macOS /var -> /private/var symlinks
2559        let expected = db_path.canonicalize().unwrap_or(db_path);
2560        let actual = retrieved_path.unwrap();
2561        let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
2562
2563        assert_eq!(
2564            actual_canonical, expected,
2565            "Database path should match (after canonicalization)"
2566        );
2567    }
2568
2569    // ==================== Search Tests ====================
2570
2571    #[test]
2572    fn test_search_messages_basic() {
2573        let (db, _dir) = create_test_db();
2574
2575        let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
2576        db.insert_session(&session)
2577            .expect("Failed to insert session");
2578
2579        let msg1 = create_test_message(
2580            session.id,
2581            0,
2582            MessageRole::User,
2583            "How do I implement error handling in Rust?",
2584        );
2585        let msg2 = create_test_message(
2586            session.id,
2587            1,
2588            MessageRole::Assistant,
2589            "You can use Result types for error handling. The anyhow crate is also helpful.",
2590        );
2591
2592        db.insert_message(&msg1)
2593            .expect("Failed to insert message 1");
2594        db.insert_message(&msg2)
2595            .expect("Failed to insert message 2");
2596
2597        // Search for "error"
2598        let results = db
2599            .search_messages("error", 10, None, None, None)
2600            .expect("Failed to search");
2601
2602        assert_eq!(
2603            results.len(),
2604            2,
2605            "Should find 2 messages containing 'error'"
2606        );
2607    }
2608
2609    #[test]
2610    fn test_search_messages_no_results() {
2611        let (db, _dir) = create_test_db();
2612
2613        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2614        db.insert_session(&session)
2615            .expect("Failed to insert session");
2616
2617        let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
2618        db.insert_message(&msg).expect("Failed to insert message");
2619
2620        // Search for something not in the messages
2621        let results = db
2622            .search_messages("nonexistent_term_xyz", 10, None, None, None)
2623            .expect("Failed to search");
2624
2625        assert!(results.is_empty(), "Should find no results");
2626    }
2627
2628    #[test]
2629    fn test_search_messages_with_role_filter() {
2630        let (db, _dir) = create_test_db();
2631
2632        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2633        db.insert_session(&session)
2634            .expect("Failed to insert session");
2635
2636        let msg1 = create_test_message(
2637            session.id,
2638            0,
2639            MessageRole::User,
2640            "Tell me about Rust programming",
2641        );
2642        let msg2 = create_test_message(
2643            session.id,
2644            1,
2645            MessageRole::Assistant,
2646            "Rust is a systems programming language",
2647        );
2648
2649        db.insert_message(&msg1)
2650            .expect("Failed to insert message 1");
2651        db.insert_message(&msg2)
2652            .expect("Failed to insert message 2");
2653
2654        // Search with user role filter
2655        let user_results = db
2656            .search_messages("programming", 10, None, None, Some("user"))
2657            .expect("Failed to search");
2658
2659        assert_eq!(user_results.len(), 1, "Should find 1 user message");
2660        assert_eq!(
2661            user_results[0].role,
2662            MessageRole::User,
2663            "Result should be from user"
2664        );
2665
2666        // Search with assistant role filter
2667        let assistant_results = db
2668            .search_messages("programming", 10, None, None, Some("assistant"))
2669            .expect("Failed to search");
2670
2671        assert_eq!(
2672            assistant_results.len(),
2673            1,
2674            "Should find 1 assistant message"
2675        );
2676        assert_eq!(
2677            assistant_results[0].role,
2678            MessageRole::Assistant,
2679            "Result should be from assistant"
2680        );
2681    }
2682
2683    #[test]
2684    fn test_search_messages_with_repo_filter() {
2685        let (db, _dir) = create_test_db();
2686
2687        let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
2688        let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
2689
2690        db.insert_session(&session1).expect("insert 1");
2691        db.insert_session(&session2).expect("insert 2");
2692
2693        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
2694        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
2695
2696        db.insert_message(&msg1).expect("insert msg 1");
2697        db.insert_message(&msg2).expect("insert msg 2");
2698
2699        // Search with repo filter
2700        let results = db
2701            .search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
2702            .expect("Failed to search");
2703
2704        assert_eq!(results.len(), 1, "Should find 1 message in project-a");
2705        assert!(
2706            results[0].working_directory.contains("project-a"),
2707            "Should be from project-a"
2708        );
2709    }
2710
2711    #[test]
2712    fn test_search_messages_limit() {
2713        let (db, _dir) = create_test_db();
2714
2715        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2716        db.insert_session(&session).expect("insert session");
2717
2718        // Insert 5 messages all containing "test"
2719        for i in 0..5 {
2720            let msg = create_test_message(
2721                session.id,
2722                i,
2723                MessageRole::User,
2724                &format!("This is test message number {i}"),
2725            );
2726            db.insert_message(&msg).expect("insert message");
2727        }
2728
2729        // Search with limit of 3
2730        let results = db
2731            .search_messages("test", 3, None, None, None)
2732            .expect("Failed to search");
2733
2734        assert_eq!(results.len(), 3, "Should respect limit of 3");
2735    }
2736
2737    #[test]
2738    fn test_search_index_needs_rebuild_empty_db() {
2739        let (db, _dir) = create_test_db();
2740
2741        let needs_rebuild = db
2742            .search_index_needs_rebuild()
2743            .expect("Failed to check rebuild status");
2744
2745        assert!(!needs_rebuild, "Empty database should not need rebuild");
2746    }
2747
2748    #[test]
2749    fn test_rebuild_search_index() {
2750        let (db, _dir) = create_test_db();
2751
2752        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2753        db.insert_session(&session).expect("insert session");
2754
2755        let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
2756        let msg2 = create_test_message(
2757            session.id,
2758            1,
2759            MessageRole::Assistant,
2760            "Second test response",
2761        );
2762
2763        db.insert_message(&msg1).expect("insert msg 1");
2764        db.insert_message(&msg2).expect("insert msg 2");
2765
2766        // Clear and rebuild the index
2767        db.conn
2768            .execute("DELETE FROM messages_fts", [])
2769            .expect("clear fts");
2770
2771        // Index should now need rebuilding
2772        assert!(
2773            db.search_index_needs_rebuild().expect("check rebuild"),
2774            "Should need rebuild after clearing FTS"
2775        );
2776
2777        // Rebuild
2778        let count = db.rebuild_search_index().expect("rebuild");
2779        assert_eq!(count, 2, "Should have indexed 2 messages");
2780
2781        // Index should no longer need rebuilding
2782        assert!(
2783            !db.search_index_needs_rebuild().expect("check rebuild"),
2784            "Should not need rebuild after rebuilding"
2785        );
2786
2787        // Search should work
2788        let results = db
2789            .search_messages("test", 10, None, None, None)
2790            .expect("search");
2791        assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
2792    }
2793
2794    #[test]
2795    fn test_search_with_block_content() {
2796        let (db, _dir) = create_test_db();
2797
2798        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2799        db.insert_session(&session).expect("insert session");
2800
2801        // Create a message with block content
2802        let block_content = MessageContent::Blocks(vec![
2803            crate::storage::models::ContentBlock::Text {
2804                text: "Let me help with your database query.".to_string(),
2805            },
2806            crate::storage::models::ContentBlock::ToolUse {
2807                id: "tool_123".to_string(),
2808                name: "Bash".to_string(),
2809                input: serde_json::json!({"command": "ls -la"}),
2810            },
2811        ]);
2812
2813        let msg = Message {
2814            id: Uuid::new_v4(),
2815            session_id: session.id,
2816            parent_id: None,
2817            index: 0,
2818            timestamp: Utc::now(),
2819            role: MessageRole::Assistant,
2820            content: block_content,
2821            model: Some("claude-opus-4".to_string()),
2822            git_branch: Some("main".to_string()),
2823            cwd: Some("/project".to_string()),
2824        };
2825
2826        db.insert_message(&msg).expect("insert message");
2827
2828        // Search should find text from blocks
2829        let results = db
2830            .search_messages("database", 10, None, None, None)
2831            .expect("search");
2832
2833        assert_eq!(results.len(), 1, "Should find message with block content");
2834    }
2835
2836    #[test]
2837    fn test_search_result_contains_session_info() {
2838        let (db, _dir) = create_test_db();
2839
2840        let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
2841        db.insert_session(&session).expect("insert session");
2842
2843        let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
2844        db.insert_message(&msg).expect("insert message");
2845
2846        let results = db
2847            .search_messages("Search", 10, None, None, None)
2848            .expect("search");
2849
2850        assert_eq!(results.len(), 1, "Should find 1 result");
2851        assert_eq!(results[0].session_id, session.id, "Session ID should match");
2852        assert_eq!(results[0].message_id, msg.id, "Message ID should match");
2853        assert_eq!(
2854            results[0].working_directory, "/home/user/my-project",
2855            "Working directory should match"
2856        );
2857        assert_eq!(results[0].role, MessageRole::User, "Role should match");
2858    }
2859
2860    // ==================== Delete Link Tests ====================
2861
2862    #[test]
2863    fn test_delete_link_by_id() {
2864        let (db, _dir) = create_test_db();
2865
2866        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2867        db.insert_session(&session)
2868            .expect("Failed to insert session");
2869
2870        let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2871        db.insert_link(&link).expect("Failed to insert link");
2872
2873        // Verify link exists
2874        let links_before = db
2875            .get_links_by_session(&session.id)
2876            .expect("Failed to get links");
2877        assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
2878
2879        // Delete the link
2880        let deleted = db.delete_link(&link.id).expect("Failed to delete link");
2881        assert!(deleted, "Should return true when link is deleted");
2882
2883        // Verify link is gone
2884        let links_after = db
2885            .get_links_by_session(&session.id)
2886            .expect("Failed to get links");
2887        assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
2888    }
2889
2890    #[test]
2891    fn test_delete_link_nonexistent() {
2892        let (db, _dir) = create_test_db();
2893
2894        let nonexistent_id = Uuid::new_v4();
2895        let deleted = db
2896            .delete_link(&nonexistent_id)
2897            .expect("Failed to call delete_link");
2898
2899        assert!(!deleted, "Should return false for nonexistent link");
2900    }
2901
2902    #[test]
2903    fn test_delete_links_by_session() {
2904        let (db, _dir) = create_test_db();
2905
2906        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2907        db.insert_session(&session)
2908            .expect("Failed to insert session");
2909
2910        // Create multiple links for the same session
2911        let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
2912        let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
2913        let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
2914
2915        db.insert_link(&link1).expect("Failed to insert link1");
2916        db.insert_link(&link2).expect("Failed to insert link2");
2917        db.insert_link(&link3).expect("Failed to insert link3");
2918
2919        // Verify all links exist
2920        let links_before = db
2921            .get_links_by_session(&session.id)
2922            .expect("Failed to get links");
2923        assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
2924
2925        // Delete all links for the session
2926        let count = db
2927            .delete_links_by_session(&session.id)
2928            .expect("Failed to delete links");
2929        assert_eq!(count, 3, "Should have deleted 3 links");
2930
2931        // Verify all links are gone
2932        let links_after = db
2933            .get_links_by_session(&session.id)
2934            .expect("Failed to get links");
2935        assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
2936    }
2937
2938    #[test]
2939    fn test_delete_links_by_session_no_links() {
2940        let (db, _dir) = create_test_db();
2941
2942        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2943        db.insert_session(&session)
2944            .expect("Failed to insert session");
2945
2946        // Delete links for session that has none
2947        let count = db
2948            .delete_links_by_session(&session.id)
2949            .expect("Failed to call delete_links_by_session");
2950        assert_eq!(count, 0, "Should return 0 when no links exist");
2951    }
2952
2953    #[test]
2954    fn test_delete_links_by_session_preserves_other_sessions() {
2955        let (db, _dir) = create_test_db();
2956
2957        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2958        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
2959
2960        db.insert_session(&session1)
2961            .expect("Failed to insert session1");
2962        db.insert_session(&session2)
2963            .expect("Failed to insert session2");
2964
2965        let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
2966        let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
2967
2968        db.insert_link(&link1).expect("Failed to insert link1");
2969        db.insert_link(&link2).expect("Failed to insert link2");
2970
2971        // Delete links only for session1
2972        let count = db
2973            .delete_links_by_session(&session1.id)
2974            .expect("Failed to delete links");
2975        assert_eq!(count, 1, "Should have deleted 1 link");
2976
2977        // Verify session2's link is preserved
2978        let session2_links = db
2979            .get_links_by_session(&session2.id)
2980            .expect("Failed to get links");
2981        assert_eq!(
2982            session2_links.len(),
2983            1,
2984            "Session2's link should be preserved"
2985        );
2986        assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
2987    }
2988
2989    #[test]
2990    fn test_delete_link_by_session_and_commit() {
2991        let (db, _dir) = create_test_db();
2992
2993        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2994        db.insert_session(&session)
2995            .expect("Failed to insert session");
2996
2997        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2998        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2999
3000        db.insert_link(&link1).expect("Failed to insert link1");
3001        db.insert_link(&link2).expect("Failed to insert link2");
3002
3003        // Delete only the first link by commit SHA
3004        let deleted = db
3005            .delete_link_by_session_and_commit(&session.id, "abc123")
3006            .expect("Failed to delete link");
3007        assert!(deleted, "Should return true when link is deleted");
3008
3009        // Verify only link2 remains
3010        let links = db
3011            .get_links_by_session(&session.id)
3012            .expect("Failed to get links");
3013        assert_eq!(links.len(), 1, "Should have 1 link remaining");
3014        assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
3015    }
3016
3017    #[test]
3018    fn test_delete_link_by_session_and_commit_full_sha() {
3019        let (db, _dir) = create_test_db();
3020
3021        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3022        db.insert_session(&session)
3023            .expect("Failed to insert session");
3024
3025        let full_sha = "abc123def456789012345678901234567890abcd";
3026        let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
3027        db.insert_link(&link).expect("Failed to insert link");
3028
3029        // Delete using full SHA
3030        let deleted = db
3031            .delete_link_by_session_and_commit(&session.id, full_sha)
3032            .expect("Failed to delete link");
3033        assert!(deleted, "Should delete with full SHA");
3034
3035        let links = db
3036            .get_links_by_session(&session.id)
3037            .expect("Failed to get links");
3038        assert_eq!(links.len(), 0, "Should have 0 links after delete");
3039    }
3040
3041    #[test]
3042    fn test_delete_link_by_session_and_commit_no_match() {
3043        let (db, _dir) = create_test_db();
3044
3045        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3046        db.insert_session(&session)
3047            .expect("Failed to insert session");
3048
3049        let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3050        db.insert_link(&link).expect("Failed to insert link");
3051
3052        // Try to delete with non-matching commit
3053        let deleted = db
3054            .delete_link_by_session_and_commit(&session.id, "xyz999")
3055            .expect("Failed to call delete");
3056        assert!(!deleted, "Should return false when no match");
3057
3058        // Verify original link is preserved
3059        let links = db
3060            .get_links_by_session(&session.id)
3061            .expect("Failed to get links");
3062        assert_eq!(links.len(), 1, "Link should be preserved");
3063    }
3064
3065    #[test]
3066    fn test_delete_link_by_session_and_commit_wrong_session() {
3067        let (db, _dir) = create_test_db();
3068
3069        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3070        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3071
3072        db.insert_session(&session1)
3073            .expect("Failed to insert session1");
3074        db.insert_session(&session2)
3075            .expect("Failed to insert session2");
3076
3077        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3078        db.insert_link(&link).expect("Failed to insert link");
3079
3080        // Try to delete from wrong session
3081        let deleted = db
3082            .delete_link_by_session_and_commit(&session2.id, "abc123")
3083            .expect("Failed to call delete");
3084        assert!(!deleted, "Should not delete link from different session");
3085
3086        // Verify original link is preserved
3087        let links = db
3088            .get_links_by_session(&session1.id)
3089            .expect("Failed to get links");
3090        assert_eq!(links.len(), 1, "Link should be preserved");
3091    }
3092
3093    // ==================== Auto-linking Tests ====================
3094
3095    #[test]
3096    fn test_find_sessions_near_commit_time_basic() {
3097        let (db, _dir) = create_test_db();
3098        let now = Utc::now();
3099
3100        // Create a session that ended 10 minutes ago
3101        let mut session = create_test_session(
3102            "claude-code",
3103            "/home/user/project",
3104            now - Duration::hours(1),
3105            None,
3106        );
3107        session.ended_at = Some(now - Duration::minutes(10));
3108
3109        db.insert_session(&session).expect("insert session");
3110
3111        // Find sessions near "now" with a 30 minute window
3112        let found = db
3113            .find_sessions_near_commit_time(now, 30, None)
3114            .expect("find sessions");
3115
3116        assert_eq!(found.len(), 1, "Should find session within window");
3117        assert_eq!(found[0].id, session.id);
3118    }
3119
3120    #[test]
3121    fn test_find_sessions_near_commit_time_outside_window() {
3122        let (db, _dir) = create_test_db();
3123        let now = Utc::now();
3124
3125        // Create a session that ended 2 hours ago
3126        let mut session =
3127            create_test_session("claude-code", "/project", now - Duration::hours(3), None);
3128        session.ended_at = Some(now - Duration::hours(2));
3129
3130        db.insert_session(&session).expect("insert session");
3131
3132        // Find sessions near "now" with a 30 minute window
3133        let found = db
3134            .find_sessions_near_commit_time(now, 30, None)
3135            .expect("find sessions");
3136
3137        assert!(found.is_empty(), "Should not find session outside window");
3138    }
3139
3140    #[test]
3141    fn test_find_sessions_near_commit_time_with_working_dir() {
3142        let (db, _dir) = create_test_db();
3143        let now = Utc::now();
3144
3145        // Create sessions in different directories
3146        let mut session1 = create_test_session(
3147            "claude-code",
3148            "/home/user/project-a",
3149            now - Duration::minutes(30),
3150            None,
3151        );
3152        session1.ended_at = Some(now - Duration::minutes(5));
3153
3154        let mut session2 = create_test_session(
3155            "claude-code",
3156            "/home/user/project-b",
3157            now - Duration::minutes(30),
3158            None,
3159        );
3160        session2.ended_at = Some(now - Duration::minutes(5));
3161
3162        db.insert_session(&session1).expect("insert session1");
3163        db.insert_session(&session2).expect("insert session2");
3164
3165        // Find sessions near "now" filtering by project-a
3166        let found = db
3167            .find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
3168            .expect("find sessions");
3169
3170        assert_eq!(found.len(), 1, "Should find only session in project-a");
3171        assert_eq!(found[0].id, session1.id);
3172    }
3173
3174    #[test]
3175    fn test_find_sessions_near_commit_time_ongoing_session() {
3176        let (db, _dir) = create_test_db();
3177        let now = Utc::now();
3178
3179        // Create an ongoing session (no ended_at)
3180        let session =
3181            create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
3182        // ended_at is None by default
3183
3184        db.insert_session(&session).expect("insert session");
3185
3186        // Find sessions near "now"
3187        let found = db
3188            .find_sessions_near_commit_time(now, 30, None)
3189            .expect("find sessions");
3190
3191        assert_eq!(found.len(), 1, "Should find ongoing session");
3192        assert_eq!(found[0].id, session.id);
3193    }
3194
3195    #[test]
3196    fn test_link_exists_true() {
3197        let (db, _dir) = create_test_db();
3198
3199        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3200        db.insert_session(&session).expect("insert session");
3201
3202        let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3203        db.insert_link(&link).expect("insert link");
3204
3205        // Check with full SHA
3206        assert!(
3207            db.link_exists(&session.id, "abc123def456")
3208                .expect("check exists"),
3209            "Should find link with full SHA"
3210        );
3211
3212        // Check with partial SHA
3213        assert!(
3214            db.link_exists(&session.id, "abc123").expect("check exists"),
3215            "Should find link with partial SHA"
3216        );
3217    }
3218
3219    #[test]
3220    fn test_link_exists_false() {
3221        let (db, _dir) = create_test_db();
3222
3223        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3224        db.insert_session(&session).expect("insert session");
3225
3226        // No links created
3227        assert!(
3228            !db.link_exists(&session.id, "abc123").expect("check exists"),
3229            "Should not find non-existent link"
3230        );
3231    }
3232
3233    #[test]
3234    fn test_link_exists_different_session() {
3235        let (db, _dir) = create_test_db();
3236
3237        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3238        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3239
3240        db.insert_session(&session1).expect("insert session1");
3241        db.insert_session(&session2).expect("insert session2");
3242
3243        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3244        db.insert_link(&link).expect("insert link");
3245
3246        // Link exists for session1 but not session2
3247        assert!(
3248            db.link_exists(&session1.id, "abc123").expect("check"),
3249            "Should find link for session1"
3250        );
3251        assert!(
3252            !db.link_exists(&session2.id, "abc123").expect("check"),
3253            "Should not find link for session2"
3254        );
3255    }
3256
3257    // ==================== Enhanced Search Tests ====================
3258
3259    #[test]
3260    fn test_search_with_tool_filter() {
3261        let (db, _dir) = create_test_db();
3262
3263        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3264        let session2 = create_test_session("aider", "/project2", Utc::now(), None);
3265
3266        db.insert_session(&session1).expect("insert session1");
3267        db.insert_session(&session2).expect("insert session2");
3268
3269        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
3270        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
3271
3272        db.insert_message(&msg1).expect("insert msg1");
3273        db.insert_message(&msg2).expect("insert msg2");
3274
3275        // Search with tool filter
3276        let options = SearchOptions {
3277            query: "Hello".to_string(),
3278            limit: 10,
3279            tool: Some("claude-code".to_string()),
3280            ..Default::default()
3281        };
3282        let results = db.search_with_options(&options).expect("search");
3283
3284        assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
3285        assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
3286    }
3287
3288    #[test]
3289    fn test_search_with_date_range() {
3290        let (db, _dir) = create_test_db();
3291
3292        let old_time = Utc::now() - chrono::Duration::days(30);
3293        let new_time = Utc::now() - chrono::Duration::days(1);
3294
3295        let session1 = create_test_session("claude-code", "/project1", old_time, None);
3296        let session2 = create_test_session("claude-code", "/project2", new_time, None);
3297
3298        db.insert_session(&session1).expect("insert session1");
3299        db.insert_session(&session2).expect("insert session2");
3300
3301        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
3302        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
3303
3304        db.insert_message(&msg1).expect("insert msg1");
3305        db.insert_message(&msg2).expect("insert msg2");
3306
3307        // Search with since filter (last 7 days)
3308        let since = Utc::now() - chrono::Duration::days(7);
3309        let options = SearchOptions {
3310            query: "session".to_string(),
3311            limit: 10,
3312            since: Some(since),
3313            ..Default::default()
3314        };
3315        let results = db.search_with_options(&options).expect("search");
3316
3317        assert_eq!(results.len(), 1, "Should find 1 result within date range");
3318        assert!(
3319            results[0].working_directory.contains("project2"),
3320            "Should be from newer project"
3321        );
3322    }
3323
3324    #[test]
3325    fn test_search_with_project_filter() {
3326        let (db, _dir) = create_test_db();
3327
3328        let session1 =
3329            create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
3330        let session2 =
3331            create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
3332
3333        db.insert_session(&session1).expect("insert session1");
3334        db.insert_session(&session2).expect("insert session2");
3335
3336        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
3337        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
3338
3339        db.insert_message(&msg1).expect("insert msg1");
3340        db.insert_message(&msg2).expect("insert msg2");
3341
3342        // Search with project filter
3343        let options = SearchOptions {
3344            query: "Testing".to_string(),
3345            limit: 10,
3346            project: Some("frontend".to_string()),
3347            ..Default::default()
3348        };
3349        let results = db.search_with_options(&options).expect("search");
3350
3351        assert_eq!(results.len(), 1, "Should find 1 result with project filter");
3352        assert!(
3353            results[0].working_directory.contains("frontend"),
3354            "Should be from frontend project"
3355        );
3356    }
3357
3358    #[test]
3359    fn test_search_with_branch_filter() {
3360        let (db, _dir) = create_test_db();
3361
3362        let session1 = Session {
3363            id: Uuid::new_v4(),
3364            tool: "claude-code".to_string(),
3365            tool_version: None,
3366            started_at: Utc::now(),
3367            ended_at: None,
3368            model: None,
3369            working_directory: "/project".to_string(),
3370            git_branch: Some("feat/auth".to_string()),
3371            source_path: None,
3372            message_count: 0,
3373            machine_id: None,
3374        };
3375        let session2 = Session {
3376            id: Uuid::new_v4(),
3377            tool: "claude-code".to_string(),
3378            tool_version: None,
3379            started_at: Utc::now(),
3380            ended_at: None,
3381            model: None,
3382            working_directory: "/project".to_string(),
3383            git_branch: Some("main".to_string()),
3384            source_path: None,
3385            message_count: 0,
3386            machine_id: None,
3387        };
3388
3389        db.insert_session(&session1).expect("insert session1");
3390        db.insert_session(&session2).expect("insert session2");
3391
3392        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
3393        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
3394
3395        db.insert_message(&msg1).expect("insert msg1");
3396        db.insert_message(&msg2).expect("insert msg2");
3397
3398        // Search with branch filter
3399        let options = SearchOptions {
3400            query: "work".to_string(),
3401            limit: 10,
3402            branch: Some("auth".to_string()),
3403            ..Default::default()
3404        };
3405        let results = db.search_with_options(&options).expect("search");
3406
3407        assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
3408        assert_eq!(
3409            results[0].git_branch.as_deref(),
3410            Some("feat/auth"),
3411            "Should be from feat/auth branch"
3412        );
3413    }
3414
3415    #[test]
3416    fn test_search_metadata_matches_project() {
3417        let (db, _dir) = create_test_db();
3418
3419        let session =
3420            create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
3421        db.insert_session(&session).expect("insert session");
3422
3423        // Add a message that does NOT contain "redactyl"
3424        let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
3425        db.insert_message(&msg).expect("insert msg");
3426
3427        // Search for "redactyl" - should match session metadata
3428        let options = SearchOptions {
3429            query: "redactyl".to_string(),
3430            limit: 10,
3431            ..Default::default()
3432        };
3433        let results = db.search_with_options(&options).expect("search");
3434
3435        assert_eq!(
3436            results.len(),
3437            1,
3438            "Should find session via metadata match on project name"
3439        );
3440    }
3441
3442    #[test]
3443    fn test_search_returns_extended_session_info() {
3444        let (db, _dir) = create_test_db();
3445
3446        let started_at = Utc::now();
3447        let session = Session {
3448            id: Uuid::new_v4(),
3449            tool: "claude-code".to_string(),
3450            tool_version: Some("1.0.0".to_string()),
3451            started_at,
3452            ended_at: None,
3453            model: None,
3454            working_directory: "/home/user/myapp".to_string(),
3455            git_branch: Some("develop".to_string()),
3456            source_path: None,
3457            message_count: 5,
3458            machine_id: None,
3459        };
3460        db.insert_session(&session).expect("insert session");
3461
3462        let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
3463        db.insert_message(&msg).expect("insert msg");
3464
3465        let options = SearchOptions {
3466            query: "Test".to_string(),
3467            limit: 10,
3468            ..Default::default()
3469        };
3470        let results = db.search_with_options(&options).expect("search");
3471
3472        assert_eq!(results.len(), 1, "Should find 1 result");
3473        let result = &results[0];
3474
3475        assert_eq!(result.tool, "claude-code", "Tool should be populated");
3476        assert_eq!(
3477            result.git_branch.as_deref(),
3478            Some("develop"),
3479            "Branch should be populated"
3480        );
3481        assert!(
3482            result.session_message_count > 0,
3483            "Message count should be populated"
3484        );
3485        assert!(
3486            result.session_started_at.is_some(),
3487            "Session start time should be populated"
3488        );
3489    }
3490
3491    #[test]
3492    fn test_get_context_messages() {
3493        let (db, _dir) = create_test_db();
3494
3495        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3496        db.insert_session(&session).expect("insert session");
3497
3498        // Create 5 messages in sequence
3499        for i in 0..5 {
3500            let role = if i % 2 == 0 {
3501                MessageRole::User
3502            } else {
3503                MessageRole::Assistant
3504            };
3505            let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
3506            db.insert_message(&msg).expect("insert message");
3507        }
3508
3509        // Get context around message index 2 (the middle one)
3510        let (before, after) = db
3511            .get_context_messages(&session.id, 2, 1)
3512            .expect("get context");
3513
3514        assert_eq!(before.len(), 1, "Should have 1 message before");
3515        assert_eq!(after.len(), 1, "Should have 1 message after");
3516        assert_eq!(before[0].index, 1, "Before message should be index 1");
3517        assert_eq!(after[0].index, 3, "After message should be index 3");
3518    }
3519
3520    #[test]
3521    fn test_get_context_messages_at_start() {
3522        let (db, _dir) = create_test_db();
3523
3524        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3525        db.insert_session(&session).expect("insert session");
3526
3527        for i in 0..3 {
3528            let msg =
3529                create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
3530            db.insert_message(&msg).expect("insert message");
3531        }
3532
3533        // Get context around first message (index 0)
3534        let (before, after) = db
3535            .get_context_messages(&session.id, 0, 2)
3536            .expect("get context");
3537
3538        assert!(
3539            before.is_empty(),
3540            "Should have no messages before first message"
3541        );
3542        assert_eq!(after.len(), 2, "Should have 2 messages after");
3543    }
3544
3545    #[test]
3546    fn test_get_context_messages_at_end() {
3547        let (db, _dir) = create_test_db();
3548
3549        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3550        db.insert_session(&session).expect("insert session");
3551
3552        for i in 0..3 {
3553            let msg =
3554                create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
3555            db.insert_message(&msg).expect("insert message");
3556        }
3557
3558        // Get context around last message (index 2)
3559        let (before, after) = db
3560            .get_context_messages(&session.id, 2, 2)
3561            .expect("get context");
3562
3563        assert_eq!(before.len(), 2, "Should have 2 messages before");
3564        assert!(
3565            after.is_empty(),
3566            "Should have no messages after last message"
3567        );
3568    }
3569
3570    #[test]
3571    fn test_search_combined_filters() {
3572        let (db, _dir) = create_test_db();
3573
3574        let session1 = Session {
3575            id: Uuid::new_v4(),
3576            tool: "claude-code".to_string(),
3577            tool_version: None,
3578            started_at: Utc::now(),
3579            ended_at: None,
3580            model: None,
3581            working_directory: "/home/user/myapp".to_string(),
3582            git_branch: Some("feat/api".to_string()),
3583            source_path: None,
3584            message_count: 1,
3585            machine_id: None,
3586        };
3587        let session2 = Session {
3588            id: Uuid::new_v4(),
3589            tool: "aider".to_string(),
3590            tool_version: None,
3591            started_at: Utc::now(),
3592            ended_at: None,
3593            model: None,
3594            working_directory: "/home/user/myapp".to_string(),
3595            git_branch: Some("feat/api".to_string()),
3596            source_path: None,
3597            message_count: 1,
3598            machine_id: None,
3599        };
3600
3601        db.insert_session(&session1).expect("insert session1");
3602        db.insert_session(&session2).expect("insert session2");
3603
3604        let msg1 =
3605            create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
3606        let msg2 =
3607            create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
3608
3609        db.insert_message(&msg1).expect("insert msg1");
3610        db.insert_message(&msg2).expect("insert msg2");
3611
3612        // Search with multiple filters
3613        let options = SearchOptions {
3614            query: "API".to_string(),
3615            limit: 10,
3616            tool: Some("claude-code".to_string()),
3617            branch: Some("api".to_string()),
3618            project: Some("myapp".to_string()),
3619            ..Default::default()
3620        };
3621        let results = db.search_with_options(&options).expect("search");
3622
3623        // Results may include both message content match and metadata match from same session
3624        assert!(
3625            !results.is_empty(),
3626            "Should find at least 1 result matching all filters"
3627        );
3628        // All results should be from claude-code (the filtered tool)
3629        for result in &results {
3630            assert_eq!(
3631                result.tool, "claude-code",
3632                "All results should be from claude-code"
3633            );
3634        }
3635    }
3636
3637    // ==================== Session Deletion Tests ====================
3638
3639    #[test]
3640    fn test_delete_session_removes_all_data() {
3641        let (db, _dir) = create_test_db();
3642
3643        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3644        db.insert_session(&session).expect("insert session");
3645
3646        // Add messages
3647        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
3648        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there");
3649        db.insert_message(&msg1).expect("insert msg1");
3650        db.insert_message(&msg2).expect("insert msg2");
3651
3652        // Add a link
3653        let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3654        db.insert_link(&link).expect("insert link");
3655
3656        // Verify data exists
3657        assert_eq!(db.session_count().expect("count"), 1);
3658        assert_eq!(db.message_count().expect("count"), 2);
3659        assert_eq!(db.link_count().expect("count"), 1);
3660
3661        // Delete the session
3662        let (msgs_deleted, links_deleted) = db.delete_session(&session.id).expect("delete");
3663        assert_eq!(msgs_deleted, 2, "Should delete 2 messages");
3664        assert_eq!(links_deleted, 1, "Should delete 1 link");
3665
3666        // Verify all data is gone
3667        assert_eq!(db.session_count().expect("count"), 0);
3668        assert_eq!(db.message_count().expect("count"), 0);
3669        assert_eq!(db.link_count().expect("count"), 0);
3670        assert!(db.get_session(&session.id).expect("get").is_none());
3671    }
3672
3673    #[test]
3674    fn test_delete_session_preserves_other_sessions() {
3675        let (db, _dir) = create_test_db();
3676
3677        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3678        let session2 = create_test_session("aider", "/project2", Utc::now(), None);
3679
3680        db.insert_session(&session1).expect("insert session1");
3681        db.insert_session(&session2).expect("insert session2");
3682
3683        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello 1");
3684        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello 2");
3685        db.insert_message(&msg1).expect("insert msg1");
3686        db.insert_message(&msg2).expect("insert msg2");
3687
3688        // Delete only session1
3689        db.delete_session(&session1.id).expect("delete");
3690
3691        // Verify session2 still exists
3692        assert_eq!(db.session_count().expect("count"), 1);
3693        assert_eq!(db.message_count().expect("count"), 1);
3694        assert!(db.get_session(&session2.id).expect("get").is_some());
3695    }
3696
3697    // ==================== Database Maintenance Tests ====================
3698
3699    #[test]
3700    fn test_file_size() {
3701        let (db, _dir) = create_test_db();
3702
3703        let size = db.file_size().expect("get size");
3704        assert!(size.is_some(), "Should have file size for file-based db");
3705        assert!(size.unwrap() > 0, "Database file should have size > 0");
3706    }
3707
3708    #[test]
3709    fn test_vacuum() {
3710        let (db, _dir) = create_test_db();
3711
3712        // Just verify vacuum runs without error
3713        db.vacuum().expect("vacuum should succeed");
3714    }
3715
3716    #[test]
3717    fn test_count_sessions_older_than() {
3718        let (db, _dir) = create_test_db();
3719        let now = Utc::now();
3720
3721        // Create sessions at different times
3722        let old_session =
3723            create_test_session("claude-code", "/project1", now - Duration::days(100), None);
3724        let recent_session =
3725            create_test_session("claude-code", "/project2", now - Duration::days(10), None);
3726
3727        db.insert_session(&old_session).expect("insert old");
3728        db.insert_session(&recent_session).expect("insert recent");
3729
3730        // Count sessions older than 30 days
3731        let cutoff = now - Duration::days(30);
3732        let count = db.count_sessions_older_than(cutoff).expect("count");
3733        assert_eq!(count, 1, "Should find 1 session older than 30 days");
3734
3735        // Count sessions older than 200 days
3736        let old_cutoff = now - Duration::days(200);
3737        let old_count = db.count_sessions_older_than(old_cutoff).expect("count");
3738        assert_eq!(old_count, 0, "Should find 0 sessions older than 200 days");
3739    }
3740
3741    #[test]
3742    fn test_delete_sessions_older_than() {
3743        let (db, _dir) = create_test_db();
3744        let now = Utc::now();
3745
3746        // Create sessions at different times
3747        let old_session =
3748            create_test_session("claude-code", "/project1", now - Duration::days(100), None);
3749        let recent_session =
3750            create_test_session("claude-code", "/project2", now - Duration::days(10), None);
3751
3752        db.insert_session(&old_session).expect("insert old");
3753        db.insert_session(&recent_session).expect("insert recent");
3754
3755        // Add messages to both
3756        let msg1 = create_test_message(old_session.id, 0, MessageRole::User, "Old message");
3757        let msg2 = create_test_message(recent_session.id, 0, MessageRole::User, "Recent message");
3758        db.insert_message(&msg1).expect("insert msg1");
3759        db.insert_message(&msg2).expect("insert msg2");
3760
3761        // Delete sessions older than 30 days
3762        let cutoff = now - Duration::days(30);
3763        let deleted = db.delete_sessions_older_than(cutoff).expect("delete");
3764        assert_eq!(deleted, 1, "Should delete 1 session");
3765
3766        // Verify only recent session remains
3767        assert_eq!(db.session_count().expect("count"), 1);
3768        assert!(db.get_session(&recent_session.id).expect("get").is_some());
3769        assert!(db.get_session(&old_session.id).expect("get").is_none());
3770
3771        // Verify messages were also deleted
3772        assert_eq!(db.message_count().expect("count"), 1);
3773    }
3774
3775    #[test]
3776    fn test_get_sessions_older_than() {
3777        let (db, _dir) = create_test_db();
3778        let now = Utc::now();
3779
3780        // Create sessions at different times
3781        let old_session = create_test_session(
3782            "claude-code",
3783            "/project/old",
3784            now - Duration::days(100),
3785            None,
3786        );
3787        let medium_session =
3788            create_test_session("aider", "/project/medium", now - Duration::days(50), None);
3789        let recent_session =
3790            create_test_session("gemini", "/project/recent", now - Duration::days(10), None);
3791
3792        db.insert_session(&old_session).expect("insert old");
3793        db.insert_session(&medium_session).expect("insert medium");
3794        db.insert_session(&recent_session).expect("insert recent");
3795
3796        // Get sessions older than 30 days
3797        let cutoff = now - Duration::days(30);
3798        let sessions = db.get_sessions_older_than(cutoff).expect("get sessions");
3799        assert_eq!(
3800            sessions.len(),
3801            2,
3802            "Should find 2 sessions older than 30 days"
3803        );
3804
3805        // Verify sessions are ordered by start date (oldest first)
3806        assert_eq!(sessions[0].id, old_session.id);
3807        assert_eq!(sessions[1].id, medium_session.id);
3808
3809        // Verify session data is returned correctly
3810        assert_eq!(sessions[0].tool, "claude-code");
3811        assert_eq!(sessions[0].working_directory, "/project/old");
3812        assert_eq!(sessions[1].tool, "aider");
3813        assert_eq!(sessions[1].working_directory, "/project/medium");
3814
3815        // Get sessions older than 200 days
3816        let old_cutoff = now - Duration::days(200);
3817        let old_sessions = db
3818            .get_sessions_older_than(old_cutoff)
3819            .expect("get old sessions");
3820        assert_eq!(
3821            old_sessions.len(),
3822            0,
3823            "Should find 0 sessions older than 200 days"
3824        );
3825    }
3826
3827    #[test]
3828    fn test_stats() {
3829        let (db, _dir) = create_test_db();
3830        let now = Utc::now();
3831
3832        // Empty database stats
3833        let empty_stats = db.stats().expect("stats");
3834        assert_eq!(empty_stats.session_count, 0);
3835        assert_eq!(empty_stats.message_count, 0);
3836        assert_eq!(empty_stats.link_count, 0);
3837        assert!(empty_stats.oldest_session.is_none());
3838        assert!(empty_stats.newest_session.is_none());
3839        assert!(empty_stats.sessions_by_tool.is_empty());
3840
3841        // Add some data
3842        let session1 =
3843            create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
3844        let session2 = create_test_session("aider", "/project2", now - Duration::hours(1), None);
3845        let session3 = create_test_session("claude-code", "/project3", now, None);
3846
3847        db.insert_session(&session1).expect("insert 1");
3848        db.insert_session(&session2).expect("insert 2");
3849        db.insert_session(&session3).expect("insert 3");
3850
3851        let msg = create_test_message(session1.id, 0, MessageRole::User, "Hello");
3852        db.insert_message(&msg).expect("insert msg");
3853
3854        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3855        db.insert_link(&link).expect("insert link");
3856
3857        // Check stats
3858        let stats = db.stats().expect("stats");
3859        assert_eq!(stats.session_count, 3);
3860        assert_eq!(stats.message_count, 1);
3861        assert_eq!(stats.link_count, 1);
3862        assert!(stats.oldest_session.is_some());
3863        assert!(stats.newest_session.is_some());
3864
3865        // Check sessions by tool
3866        assert_eq!(stats.sessions_by_tool.len(), 2);
3867        // claude-code should come first (most sessions)
3868        assert_eq!(stats.sessions_by_tool[0].0, "claude-code");
3869        assert_eq!(stats.sessions_by_tool[0].1, 2);
3870        assert_eq!(stats.sessions_by_tool[1].0, "aider");
3871        assert_eq!(stats.sessions_by_tool[1].1, 1);
3872    }
3873
3874    // ==================== Branch History Tests ====================
3875
3876    #[test]
3877    fn test_get_session_branch_history_no_messages() {
3878        let (db, _dir) = create_test_db();
3879        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3880        db.insert_session(&session)
3881            .expect("Failed to insert session");
3882
3883        let branches = db
3884            .get_session_branch_history(session.id)
3885            .expect("Failed to get branch history");
3886
3887        assert!(branches.is_empty(), "Empty session should have no branches");
3888    }
3889
3890    #[test]
3891    fn test_get_session_branch_history_single_branch() {
3892        let (db, _dir) = create_test_db();
3893        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3894        db.insert_session(&session)
3895            .expect("Failed to insert session");
3896
3897        // Insert messages all on the same branch
3898        for i in 0..3 {
3899            let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
3900            msg.git_branch = Some("main".to_string());
3901            db.insert_message(&msg).expect("Failed to insert message");
3902        }
3903
3904        let branches = db
3905            .get_session_branch_history(session.id)
3906            .expect("Failed to get branch history");
3907
3908        assert_eq!(branches, vec!["main"], "Should have single branch");
3909    }
3910
3911    #[test]
3912    fn test_get_session_branch_history_multiple_branches() {
3913        let (db, _dir) = create_test_db();
3914        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3915        db.insert_session(&session)
3916            .expect("Failed to insert session");
3917
3918        // Insert messages with branch transitions: main -> feat/auth -> main
3919        let branch_sequence = ["main", "main", "feat/auth", "feat/auth", "main"];
3920        for (i, branch) in branch_sequence.iter().enumerate() {
3921            let mut msg = create_test_message(session.id, i as i32, MessageRole::User, "test");
3922            msg.git_branch = Some(branch.to_string());
3923            db.insert_message(&msg).expect("Failed to insert message");
3924        }
3925
3926        let branches = db
3927            .get_session_branch_history(session.id)
3928            .expect("Failed to get branch history");
3929
3930        assert_eq!(
3931            branches,
3932            vec!["main", "feat/auth", "main"],
3933            "Should show branch transitions without consecutive duplicates"
3934        );
3935    }
3936
3937    #[test]
3938    fn test_get_session_branch_history_with_none_branches() {
3939        let (db, _dir) = create_test_db();
3940        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3941        db.insert_session(&session)
3942            .expect("Failed to insert session");
3943
3944        // Insert messages with a mix of Some and None branches
3945        let mut msg1 = create_test_message(session.id, 0, MessageRole::User, "test");
3946        msg1.git_branch = Some("main".to_string());
3947        db.insert_message(&msg1).expect("Failed to insert message");
3948
3949        let mut msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "test");
3950        msg2.git_branch = None; // No branch info
3951        db.insert_message(&msg2).expect("Failed to insert message");
3952
3953        let mut msg3 = create_test_message(session.id, 2, MessageRole::User, "test");
3954        msg3.git_branch = Some("feat/new".to_string());
3955        db.insert_message(&msg3).expect("Failed to insert message");
3956
3957        let branches = db
3958            .get_session_branch_history(session.id)
3959            .expect("Failed to get branch history");
3960
3961        assert_eq!(
3962            branches,
3963            vec!["main", "feat/new"],
3964            "Should skip None branches and show transitions"
3965        );
3966    }
3967
3968    #[test]
3969    fn test_get_session_branch_history_all_none_branches() {
3970        let (db, _dir) = create_test_db();
3971        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3972        db.insert_session(&session)
3973            .expect("Failed to insert session");
3974
3975        // Insert messages with no branch info
3976        for i in 0..3 {
3977            let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
3978            msg.git_branch = None;
3979            db.insert_message(&msg).expect("Failed to insert message");
3980        }
3981
3982        let branches = db
3983            .get_session_branch_history(session.id)
3984            .expect("Failed to get branch history");
3985
3986        assert!(
3987            branches.is_empty(),
3988            "Session with all None branches should return empty"
3989        );
3990    }
3991
3992    // ==================== Machine ID Tests ====================
3993
3994    #[test]
3995    fn test_session_stores_machine_id() {
3996        let (db, _dir) = create_test_db();
3997        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3998
3999        db.insert_session(&session)
4000            .expect("Failed to insert session");
4001
4002        let retrieved = db
4003            .get_session(&session.id)
4004            .expect("Failed to get session")
4005            .expect("Session should exist");
4006
4007        assert_eq!(
4008            retrieved.machine_id,
4009            Some("test-machine".to_string()),
4010            "Machine ID should be preserved"
4011        );
4012    }
4013
4014    #[test]
4015    fn test_session_with_none_machine_id() {
4016        let (db, _dir) = create_test_db();
4017        let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
4018        session.machine_id = None;
4019
4020        db.insert_session(&session)
4021            .expect("Failed to insert session");
4022
4023        let retrieved = db
4024            .get_session(&session.id)
4025            .expect("Failed to get session")
4026            .expect("Session should exist");
4027
4028        assert!(
4029            retrieved.machine_id.is_none(),
4030            "Session with None machine_id should preserve None"
4031        );
4032    }
4033
4034    #[test]
4035    fn test_migration_adds_machine_id_column() {
4036        // Create a database and verify the machine_id column works
4037        let (db, _dir) = create_test_db();
4038
4039        // Insert a session with machine_id to confirm the column exists
4040        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4041        db.insert_session(&session)
4042            .expect("Should insert session with machine_id column");
4043
4044        // Retrieve and verify
4045        let retrieved = db
4046            .get_session(&session.id)
4047            .expect("Failed to get session")
4048            .expect("Session should exist");
4049
4050        assert_eq!(
4051            retrieved.machine_id,
4052            Some("test-machine".to_string()),
4053            "Machine ID should be stored and retrieved"
4054        );
4055    }
4056
4057    #[test]
4058    fn test_list_sessions_includes_machine_id() {
4059        let (db, _dir) = create_test_db();
4060        let now = Utc::now();
4061
4062        let mut session1 = create_test_session("claude-code", "/project1", now, None);
4063        session1.machine_id = Some("machine-a".to_string());
4064
4065        let mut session2 = create_test_session("claude-code", "/project2", now, None);
4066        session2.machine_id = Some("machine-b".to_string());
4067
4068        db.insert_session(&session1).expect("insert");
4069        db.insert_session(&session2).expect("insert");
4070
4071        let sessions = db.list_sessions(10, None).expect("list");
4072
4073        assert_eq!(sessions.len(), 2);
4074        let machine_ids: Vec<Option<String>> =
4075            sessions.iter().map(|s| s.machine_id.clone()).collect();
4076        assert!(machine_ids.contains(&Some("machine-a".to_string())));
4077        assert!(machine_ids.contains(&Some("machine-b".to_string())));
4078    }
4079
4080    // ==================== Annotation Tests ====================
4081
4082    #[test]
4083    fn test_insert_and_get_annotations() {
4084        let (db, _dir) = create_test_db();
4085        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4086        db.insert_session(&session).expect("insert session");
4087
4088        let annotation = Annotation {
4089            id: Uuid::new_v4(),
4090            session_id: session.id,
4091            content: "This is a test note".to_string(),
4092            created_at: Utc::now(),
4093        };
4094        db.insert_annotation(&annotation)
4095            .expect("insert annotation");
4096
4097        let annotations = db.get_annotations(&session.id).expect("get annotations");
4098        assert_eq!(annotations.len(), 1);
4099        assert_eq!(annotations[0].content, "This is a test note");
4100        assert_eq!(annotations[0].session_id, session.id);
4101    }
4102
4103    #[test]
4104    fn test_delete_annotation() {
4105        let (db, _dir) = create_test_db();
4106        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4107        db.insert_session(&session).expect("insert session");
4108
4109        let annotation = Annotation {
4110            id: Uuid::new_v4(),
4111            session_id: session.id,
4112            content: "Test annotation".to_string(),
4113            created_at: Utc::now(),
4114        };
4115        db.insert_annotation(&annotation).expect("insert");
4116
4117        let deleted = db.delete_annotation(&annotation.id).expect("delete");
4118        assert!(deleted);
4119
4120        let annotations = db.get_annotations(&session.id).expect("get");
4121        assert!(annotations.is_empty());
4122    }
4123
4124    #[test]
4125    fn test_delete_annotations_by_session() {
4126        let (db, _dir) = create_test_db();
4127        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4128        db.insert_session(&session).expect("insert session");
4129
4130        for i in 0..3 {
4131            let annotation = Annotation {
4132                id: Uuid::new_v4(),
4133                session_id: session.id,
4134                content: format!("Annotation {i}"),
4135                created_at: Utc::now(),
4136            };
4137            db.insert_annotation(&annotation).expect("insert");
4138        }
4139
4140        let count = db
4141            .delete_annotations_by_session(&session.id)
4142            .expect("delete all");
4143        assert_eq!(count, 3);
4144
4145        let annotations = db.get_annotations(&session.id).expect("get");
4146        assert!(annotations.is_empty());
4147    }
4148
4149    // ==================== Tag Tests ====================
4150
4151    #[test]
4152    fn test_insert_and_get_tags() {
4153        let (db, _dir) = create_test_db();
4154        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4155        db.insert_session(&session).expect("insert session");
4156
4157        let tag = Tag {
4158            id: Uuid::new_v4(),
4159            session_id: session.id,
4160            label: "bug-fix".to_string(),
4161            created_at: Utc::now(),
4162        };
4163        db.insert_tag(&tag).expect("insert tag");
4164
4165        let tags = db.get_tags(&session.id).expect("get tags");
4166        assert_eq!(tags.len(), 1);
4167        assert_eq!(tags[0].label, "bug-fix");
4168    }
4169
4170    #[test]
4171    fn test_tag_exists() {
4172        let (db, _dir) = create_test_db();
4173        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4174        db.insert_session(&session).expect("insert session");
4175
4176        assert!(!db.tag_exists(&session.id, "bug-fix").expect("check"));
4177
4178        let tag = Tag {
4179            id: Uuid::new_v4(),
4180            session_id: session.id,
4181            label: "bug-fix".to_string(),
4182            created_at: Utc::now(),
4183        };
4184        db.insert_tag(&tag).expect("insert tag");
4185
4186        assert!(db.tag_exists(&session.id, "bug-fix").expect("check"));
4187        assert!(!db.tag_exists(&session.id, "feature").expect("check other"));
4188    }
4189
4190    #[test]
4191    fn test_delete_tag() {
4192        let (db, _dir) = create_test_db();
4193        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4194        db.insert_session(&session).expect("insert session");
4195
4196        let tag = Tag {
4197            id: Uuid::new_v4(),
4198            session_id: session.id,
4199            label: "wip".to_string(),
4200            created_at: Utc::now(),
4201        };
4202        db.insert_tag(&tag).expect("insert tag");
4203
4204        let deleted = db.delete_tag(&session.id, "wip").expect("delete");
4205        assert!(deleted);
4206
4207        let deleted_again = db.delete_tag(&session.id, "wip").expect("delete again");
4208        assert!(!deleted_again);
4209    }
4210
4211    #[test]
4212    fn test_list_sessions_with_tag() {
4213        let (db, _dir) = create_test_db();
4214        let now = Utc::now();
4215
4216        let session1 = create_test_session("claude-code", "/project1", now, None);
4217        let session2 =
4218            create_test_session("claude-code", "/project2", now - Duration::minutes(5), None);
4219        let session3 = create_test_session(
4220            "claude-code",
4221            "/project3",
4222            now - Duration::minutes(10),
4223            None,
4224        );
4225
4226        db.insert_session(&session1).expect("insert");
4227        db.insert_session(&session2).expect("insert");
4228        db.insert_session(&session3).expect("insert");
4229
4230        // Tag session1 and session3 with "feature"
4231        let tag1 = Tag {
4232            id: Uuid::new_v4(),
4233            session_id: session1.id,
4234            label: "feature".to_string(),
4235            created_at: Utc::now(),
4236        };
4237        let tag3 = Tag {
4238            id: Uuid::new_v4(),
4239            session_id: session3.id,
4240            label: "feature".to_string(),
4241            created_at: Utc::now(),
4242        };
4243        db.insert_tag(&tag1).expect("insert tag");
4244        db.insert_tag(&tag3).expect("insert tag");
4245
4246        let sessions = db.list_sessions_with_tag("feature", 10).expect("list");
4247        assert_eq!(sessions.len(), 2);
4248        // Should be ordered by start time descending
4249        assert_eq!(sessions[0].id, session1.id);
4250        assert_eq!(sessions[1].id, session3.id);
4251
4252        let sessions = db.list_sessions_with_tag("nonexistent", 10).expect("list");
4253        assert!(sessions.is_empty());
4254    }
4255
4256    #[test]
4257    fn test_get_most_recent_session_for_directory() {
4258        let (db, _dir) = create_test_db();
4259        let now = Utc::now();
4260
4261        let session1 = create_test_session(
4262            "claude-code",
4263            "/home/user/project",
4264            now - Duration::hours(1),
4265            None,
4266        );
4267        let session2 = create_test_session("claude-code", "/home/user/project", now, None);
4268        let session3 = create_test_session("claude-code", "/home/user/other", now, None);
4269
4270        db.insert_session(&session1).expect("insert");
4271        db.insert_session(&session2).expect("insert");
4272        db.insert_session(&session3).expect("insert");
4273
4274        let result = db
4275            .get_most_recent_session_for_directory("/home/user/project")
4276            .expect("get");
4277        assert!(result.is_some());
4278        assert_eq!(result.unwrap().id, session2.id);
4279
4280        let result = db
4281            .get_most_recent_session_for_directory("/home/user/nonexistent")
4282            .expect("get");
4283        assert!(result.is_none());
4284    }
4285
4286    #[test]
4287    fn test_session_deletion_removes_annotations_and_tags() {
4288        let (db, _dir) = create_test_db();
4289        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4290        db.insert_session(&session).expect("insert session");
4291
4292        // Add annotation
4293        let annotation = Annotation {
4294            id: Uuid::new_v4(),
4295            session_id: session.id,
4296            content: "Test annotation".to_string(),
4297            created_at: Utc::now(),
4298        };
4299        db.insert_annotation(&annotation).expect("insert");
4300
4301        // Add tag
4302        let tag = Tag {
4303            id: Uuid::new_v4(),
4304            session_id: session.id,
4305            label: "test-tag".to_string(),
4306            created_at: Utc::now(),
4307        };
4308        db.insert_tag(&tag).expect("insert");
4309
4310        // Delete the session
4311        db.delete_session(&session.id).expect("delete");
4312
4313        // Verify annotations and tags are gone
4314        let annotations = db.get_annotations(&session.id).expect("get");
4315        assert!(annotations.is_empty());
4316
4317        let tags = db.get_tags(&session.id).expect("get");
4318        assert!(tags.is_empty());
4319    }
4320
4321    #[test]
4322    fn test_insert_and_get_summary() {
4323        let (db, _dir) = create_test_db();
4324        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4325        db.insert_session(&session).expect("insert session");
4326
4327        let summary = Summary {
4328            id: Uuid::new_v4(),
4329            session_id: session.id,
4330            content: "Test summary content".to_string(),
4331            generated_at: Utc::now(),
4332        };
4333        db.insert_summary(&summary).expect("insert summary");
4334
4335        let retrieved = db.get_summary(&session.id).expect("get summary");
4336        assert!(retrieved.is_some());
4337        let retrieved = retrieved.unwrap();
4338        assert_eq!(retrieved.content, "Test summary content");
4339        assert_eq!(retrieved.session_id, session.id);
4340    }
4341
4342    #[test]
4343    fn test_get_summary_nonexistent() {
4344        let (db, _dir) = create_test_db();
4345        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4346        db.insert_session(&session).expect("insert session");
4347
4348        let retrieved = db.get_summary(&session.id).expect("get summary");
4349        assert!(retrieved.is_none());
4350    }
4351
4352    #[test]
4353    fn test_update_summary() {
4354        let (db, _dir) = create_test_db();
4355        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4356        db.insert_session(&session).expect("insert session");
4357
4358        let summary = Summary {
4359            id: Uuid::new_v4(),
4360            session_id: session.id,
4361            content: "Original content".to_string(),
4362            generated_at: Utc::now(),
4363        };
4364        db.insert_summary(&summary).expect("insert summary");
4365
4366        // Update the summary
4367        let updated = db
4368            .update_summary(&session.id, "Updated content")
4369            .expect("update summary");
4370        assert!(updated);
4371
4372        let retrieved = db.get_summary(&session.id).expect("get summary");
4373        assert!(retrieved.is_some());
4374        assert_eq!(retrieved.unwrap().content, "Updated content");
4375    }
4376
4377    #[test]
4378    fn test_update_summary_nonexistent() {
4379        let (db, _dir) = create_test_db();
4380        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4381        db.insert_session(&session).expect("insert session");
4382
4383        // Try to update a summary that does not exist
4384        let updated = db
4385            .update_summary(&session.id, "New content")
4386            .expect("update summary");
4387        assert!(!updated);
4388    }
4389
4390    #[test]
4391    fn test_delete_summary() {
4392        let (db, _dir) = create_test_db();
4393        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4394        db.insert_session(&session).expect("insert session");
4395
4396        let summary = Summary {
4397            id: Uuid::new_v4(),
4398            session_id: session.id,
4399            content: "To be deleted".to_string(),
4400            generated_at: Utc::now(),
4401        };
4402        db.insert_summary(&summary).expect("insert summary");
4403
4404        // Delete the summary
4405        let deleted = db.delete_summary(&session.id).expect("delete summary");
4406        assert!(deleted);
4407
4408        // Verify it's gone
4409        let retrieved = db.get_summary(&session.id).expect("get summary");
4410        assert!(retrieved.is_none());
4411    }
4412
4413    #[test]
4414    fn test_delete_session_removes_summary() {
4415        let (db, _dir) = create_test_db();
4416        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4417        db.insert_session(&session).expect("insert session");
4418
4419        let summary = Summary {
4420            id: Uuid::new_v4(),
4421            session_id: session.id,
4422            content: "Session summary".to_string(),
4423            generated_at: Utc::now(),
4424        };
4425        db.insert_summary(&summary).expect("insert summary");
4426
4427        // Delete the session
4428        db.delete_session(&session.id).expect("delete session");
4429
4430        // Verify summary is also deleted
4431        let retrieved = db.get_summary(&session.id).expect("get summary");
4432        assert!(retrieved.is_none());
4433    }
4434
4435    // ==================== Machine Tests ====================
4436
4437    #[test]
4438    fn test_upsert_machine_insert() {
4439        let (db, _dir) = create_test_db();
4440
4441        let machine = Machine {
4442            id: "test-uuid-1234".to_string(),
4443            name: "my-laptop".to_string(),
4444            created_at: Utc::now().to_rfc3339(),
4445        };
4446
4447        db.upsert_machine(&machine)
4448            .expect("Failed to upsert machine");
4449
4450        let retrieved = db
4451            .get_machine("test-uuid-1234")
4452            .expect("Failed to get machine")
4453            .expect("Machine should exist");
4454
4455        assert_eq!(retrieved.id, "test-uuid-1234");
4456        assert_eq!(retrieved.name, "my-laptop");
4457    }
4458
4459    #[test]
4460    fn test_upsert_machine_update() {
4461        let (db, _dir) = create_test_db();
4462
4463        // Insert initial machine
4464        let machine1 = Machine {
4465            id: "test-uuid-5678".to_string(),
4466            name: "old-name".to_string(),
4467            created_at: Utc::now().to_rfc3339(),
4468        };
4469        db.upsert_machine(&machine1)
4470            .expect("Failed to upsert machine");
4471
4472        // Update with new name
4473        let machine2 = Machine {
4474            id: "test-uuid-5678".to_string(),
4475            name: "new-name".to_string(),
4476            created_at: Utc::now().to_rfc3339(),
4477        };
4478        db.upsert_machine(&machine2)
4479            .expect("Failed to upsert machine");
4480
4481        // Verify name was updated
4482        let retrieved = db
4483            .get_machine("test-uuid-5678")
4484            .expect("Failed to get machine")
4485            .expect("Machine should exist");
4486
4487        assert_eq!(retrieved.name, "new-name");
4488    }
4489
4490    #[test]
4491    fn test_get_machine() {
4492        let (db, _dir) = create_test_db();
4493
4494        // Machine does not exist initially
4495        let not_found = db.get_machine("nonexistent-uuid").expect("Failed to query");
4496        assert!(not_found.is_none(), "Machine should not exist");
4497
4498        // Insert a machine
4499        let machine = Machine {
4500            id: "existing-uuid".to_string(),
4501            name: "test-machine".to_string(),
4502            created_at: Utc::now().to_rfc3339(),
4503        };
4504        db.upsert_machine(&machine).expect("Failed to upsert");
4505
4506        // Now it should be found
4507        let found = db
4508            .get_machine("existing-uuid")
4509            .expect("Failed to query")
4510            .expect("Machine should exist");
4511
4512        assert_eq!(found.id, "existing-uuid");
4513        assert_eq!(found.name, "test-machine");
4514    }
4515
4516    #[test]
4517    fn test_get_machine_name_found() {
4518        let (db, _dir) = create_test_db();
4519
4520        let machine = Machine {
4521            id: "uuid-for-name-test".to_string(),
4522            name: "my-workstation".to_string(),
4523            created_at: Utc::now().to_rfc3339(),
4524        };
4525        db.upsert_machine(&machine).expect("Failed to upsert");
4526
4527        let name = db
4528            .get_machine_name("uuid-for-name-test")
4529            .expect("Failed to get name");
4530
4531        assert_eq!(name, "my-workstation");
4532    }
4533
4534    #[test]
4535    fn test_get_machine_name_not_found() {
4536        let (db, _dir) = create_test_db();
4537
4538        // Machine does not exist, should return truncated UUID
4539        let name = db
4540            .get_machine_name("abc123def456789")
4541            .expect("Failed to get name");
4542
4543        assert_eq!(name, "abc123de", "Should return first 8 characters");
4544
4545        // Test with short ID
4546        let short_name = db.get_machine_name("short").expect("Failed to get name");
4547
4548        assert_eq!(
4549            short_name, "short",
4550            "Should return full ID if shorter than 8 chars"
4551        );
4552    }
4553
4554    #[test]
4555    fn test_list_machines() {
4556        let (db, _dir) = create_test_db();
4557
4558        // Initially empty
4559        let machines = db.list_machines().expect("Failed to list");
4560        assert!(machines.is_empty(), "Should have no machines initially");
4561
4562        // Add machines
4563        let machine1 = Machine {
4564            id: "uuid-1".to_string(),
4565            name: "machine-1".to_string(),
4566            created_at: "2024-01-01T00:00:00Z".to_string(),
4567        };
4568        let machine2 = Machine {
4569            id: "uuid-2".to_string(),
4570            name: "machine-2".to_string(),
4571            created_at: "2024-01-02T00:00:00Z".to_string(),
4572        };
4573
4574        db.upsert_machine(&machine1).expect("Failed to upsert");
4575        db.upsert_machine(&machine2).expect("Failed to upsert");
4576
4577        // List should return both machines
4578        let machines = db.list_machines().expect("Failed to list");
4579        assert_eq!(machines.len(), 2, "Should have 2 machines");
4580
4581        // Should be ordered by created_at (oldest first)
4582        assert_eq!(machines[0].id, "uuid-1");
4583        assert_eq!(machines[1].id, "uuid-2");
4584    }
4585
4586    // ==================== Session ID Prefix Lookup Tests ====================
4587
4588    #[test]
4589    fn test_find_session_by_id_prefix_full_uuid() {
4590        let (db, _dir) = create_test_db();
4591        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4592        db.insert_session(&session).expect("insert session");
4593
4594        // Find by full UUID string
4595        let found = db
4596            .find_session_by_id_prefix(&session.id.to_string())
4597            .expect("find session")
4598            .expect("session should exist");
4599
4600        assert_eq!(found.id, session.id, "Should find session by full UUID");
4601    }
4602
4603    #[test]
4604    fn test_find_session_by_id_prefix_short_prefix() {
4605        let (db, _dir) = create_test_db();
4606        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4607        db.insert_session(&session).expect("insert session");
4608
4609        // Get a short prefix (first 8 characters)
4610        let prefix = &session.id.to_string()[..8];
4611
4612        let found = db
4613            .find_session_by_id_prefix(prefix)
4614            .expect("find session")
4615            .expect("session should exist");
4616
4617        assert_eq!(found.id, session.id, "Should find session by short prefix");
4618    }
4619
4620    #[test]
4621    fn test_find_session_by_id_prefix_very_short_prefix() {
4622        let (db, _dir) = create_test_db();
4623        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4624        db.insert_session(&session).expect("insert session");
4625
4626        // Get just the first 4 characters
4627        let prefix = &session.id.to_string()[..4];
4628
4629        let found = db
4630            .find_session_by_id_prefix(prefix)
4631            .expect("find session")
4632            .expect("session should exist");
4633
4634        assert_eq!(
4635            found.id, session.id,
4636            "Should find session by very short prefix"
4637        );
4638    }
4639
4640    #[test]
4641    fn test_find_session_by_id_prefix_not_found() {
4642        let (db, _dir) = create_test_db();
4643        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4644        db.insert_session(&session).expect("insert session");
4645
4646        // Try to find with a non-matching prefix
4647        let found = db
4648            .find_session_by_id_prefix("zzz999")
4649            .expect("find session");
4650
4651        assert!(
4652            found.is_none(),
4653            "Should return None for non-matching prefix"
4654        );
4655    }
4656
4657    #[test]
4658    fn test_find_session_by_id_prefix_empty_db() {
4659        let (db, _dir) = create_test_db();
4660
4661        let found = db
4662            .find_session_by_id_prefix("abc123")
4663            .expect("find session");
4664
4665        assert!(found.is_none(), "Should return None for empty database");
4666    }
4667
4668    #[test]
4669    fn test_find_session_by_id_prefix_ambiguous() {
4670        let (db, _dir) = create_test_db();
4671
4672        // Create 100 sessions to increase chance of prefix collision
4673        let mut sessions = Vec::new();
4674        for _ in 0..100 {
4675            let session = create_test_session("claude-code", "/project", Utc::now(), None);
4676            db.insert_session(&session).expect("insert session");
4677            sessions.push(session);
4678        }
4679
4680        // Find two sessions that share a common prefix (first char)
4681        let first_session = &sessions[0];
4682        let first_char = first_session.id.to_string().chars().next().unwrap();
4683
4684        // Count how many sessions start with the same character
4685        let matching_count = sessions
4686            .iter()
4687            .filter(|s| s.id.to_string().starts_with(first_char))
4688            .count();
4689
4690        if matching_count > 1 {
4691            // If we have multiple sessions starting with same character,
4692            // a single-character prefix should return an ambiguity error
4693            let result = db.find_session_by_id_prefix(&first_char.to_string());
4694            assert!(
4695                result.is_err(),
4696                "Should return error for ambiguous single-character prefix"
4697            );
4698            let error_msg = result.unwrap_err().to_string();
4699            assert!(
4700                error_msg.contains("Ambiguous"),
4701                "Error should mention ambiguity"
4702            );
4703        }
4704    }
4705
4706    #[test]
4707    fn test_find_session_by_id_prefix_returns_correct_session_data() {
4708        let (db, _dir) = create_test_db();
4709
4710        let mut session =
4711            create_test_session("claude-code", "/home/user/myproject", Utc::now(), None);
4712        session.tool_version = Some("2.0.0".to_string());
4713        session.model = Some("claude-opus-4".to_string());
4714        session.git_branch = Some("feature/test".to_string());
4715        session.message_count = 42;
4716        db.insert_session(&session).expect("insert session");
4717
4718        // Find by prefix
4719        let prefix = &session.id.to_string()[..8];
4720        let found = db
4721            .find_session_by_id_prefix(prefix)
4722            .expect("find session")
4723            .expect("session should exist");
4724
4725        // Verify all fields are correctly returned
4726        assert_eq!(found.id, session.id);
4727        assert_eq!(found.tool, "claude-code");
4728        assert_eq!(found.tool_version, Some("2.0.0".to_string()));
4729        assert_eq!(found.model, Some("claude-opus-4".to_string()));
4730        assert_eq!(found.working_directory, "/home/user/myproject");
4731        assert_eq!(found.git_branch, Some("feature/test".to_string()));
4732        assert_eq!(found.message_count, 42);
4733    }
4734
4735    #[test]
4736    fn test_find_session_by_id_prefix_many_sessions() {
4737        let (db, _dir) = create_test_db();
4738
4739        // Insert many sessions (more than the old 100/1000 limits)
4740        let mut target_session = None;
4741        for i in 0..200 {
4742            let session =
4743                create_test_session("claude-code", &format!("/project/{i}"), Utc::now(), None);
4744            db.insert_session(&session).expect("insert session");
4745            // Save a session to search for later
4746            if i == 150 {
4747                target_session = Some(session);
4748            }
4749        }
4750
4751        let target = target_session.expect("should have target session");
4752        let prefix = &target.id.to_string()[..8];
4753
4754        // Should still find the session even with many sessions in the database
4755        let found = db
4756            .find_session_by_id_prefix(prefix)
4757            .expect("find session")
4758            .expect("session should exist");
4759
4760        assert_eq!(
4761            found.id, target.id,
4762            "Should find correct session among many"
4763        );
4764        assert_eq!(found.working_directory, "/project/150");
4765    }
4766}