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