Skip to main content

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