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