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
2280/// Statistics about the Lore database.
2281#[derive(Debug, Clone)]
2282pub struct DatabaseStats {
2283    /// Total number of sessions.
2284    pub session_count: i32,
2285    /// Total number of messages.
2286    pub message_count: i32,
2287    /// Total number of session links.
2288    pub link_count: i32,
2289    /// Timestamp of the oldest session.
2290    pub oldest_session: Option<DateTime<Utc>>,
2291    /// Timestamp of the newest session.
2292    pub newest_session: Option<DateTime<Utc>>,
2293    /// Session counts grouped by tool name.
2294    pub sessions_by_tool: Vec<(String, i32)>,
2295}
2296
2297#[cfg(test)]
2298mod tests {
2299    use super::*;
2300    use crate::storage::models::{
2301        LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
2302    };
2303    use chrono::{Duration, Utc};
2304    use tempfile::tempdir;
2305
2306    /// Creates a test database in a temporary directory.
2307    /// Returns the Database instance and the temp directory (which must be kept alive).
2308    fn create_test_db() -> (Database, tempfile::TempDir) {
2309        let dir = tempdir().expect("Failed to create temp directory");
2310        let db_path = dir.path().join("test.db");
2311        let db = Database::open(&db_path).expect("Failed to open test database");
2312        (db, dir)
2313    }
2314
2315    /// Creates a test session with the given parameters.
2316    fn create_test_session(
2317        tool: &str,
2318        working_directory: &str,
2319        started_at: chrono::DateTime<Utc>,
2320        source_path: Option<&str>,
2321    ) -> Session {
2322        Session {
2323            id: Uuid::new_v4(),
2324            tool: tool.to_string(),
2325            tool_version: Some("1.0.0".to_string()),
2326            started_at,
2327            ended_at: None,
2328            model: Some("test-model".to_string()),
2329            working_directory: working_directory.to_string(),
2330            git_branch: Some("main".to_string()),
2331            source_path: source_path.map(|s| s.to_string()),
2332            message_count: 0,
2333            machine_id: Some("test-machine".to_string()),
2334        }
2335    }
2336
2337    /// Creates a test message for the given session.
2338    fn create_test_message(
2339        session_id: Uuid,
2340        index: i32,
2341        role: MessageRole,
2342        content: &str,
2343    ) -> Message {
2344        Message {
2345            id: Uuid::new_v4(),
2346            session_id,
2347            parent_id: None,
2348            index,
2349            timestamp: Utc::now(),
2350            role,
2351            content: MessageContent::Text(content.to_string()),
2352            model: Some("test-model".to_string()),
2353            git_branch: Some("main".to_string()),
2354            cwd: Some("/test/cwd".to_string()),
2355        }
2356    }
2357
2358    /// Creates a test session link for the given session.
2359    fn create_test_link(
2360        session_id: Uuid,
2361        commit_sha: Option<&str>,
2362        link_type: LinkType,
2363    ) -> SessionLink {
2364        SessionLink {
2365            id: Uuid::new_v4(),
2366            session_id,
2367            link_type,
2368            commit_sha: commit_sha.map(|s| s.to_string()),
2369            branch: Some("main".to_string()),
2370            remote: Some("origin".to_string()),
2371            created_at: Utc::now(),
2372            created_by: LinkCreator::Auto,
2373            confidence: Some(0.95),
2374        }
2375    }
2376
2377    // ==================== Session Tests ====================
2378
2379    #[test]
2380    fn test_insert_and_get_session() {
2381        let (db, _dir) = create_test_db();
2382        let session = create_test_session(
2383            "claude-code",
2384            "/home/user/project",
2385            Utc::now(),
2386            Some("/path/to/source.jsonl"),
2387        );
2388
2389        db.insert_session(&session)
2390            .expect("Failed to insert session");
2391
2392        let retrieved = db
2393            .get_session(&session.id)
2394            .expect("Failed to get session")
2395            .expect("Session should exist");
2396
2397        assert_eq!(retrieved.id, session.id, "Session ID should match");
2398        assert_eq!(retrieved.tool, session.tool, "Tool should match");
2399        assert_eq!(
2400            retrieved.tool_version, session.tool_version,
2401            "Tool version should match"
2402        );
2403        assert_eq!(
2404            retrieved.working_directory, session.working_directory,
2405            "Working directory should match"
2406        );
2407        assert_eq!(
2408            retrieved.git_branch, session.git_branch,
2409            "Git branch should match"
2410        );
2411        assert_eq!(
2412            retrieved.source_path, session.source_path,
2413            "Source path should match"
2414        );
2415    }
2416
2417    #[test]
2418    fn test_list_sessions() {
2419        let (db, _dir) = create_test_db();
2420        let now = Utc::now();
2421
2422        // Insert sessions with different timestamps (oldest first)
2423        let session1 =
2424            create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
2425        let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
2426        let session3 = create_test_session("claude-code", "/project3", now, None);
2427
2428        db.insert_session(&session1)
2429            .expect("Failed to insert session1");
2430        db.insert_session(&session2)
2431            .expect("Failed to insert session2");
2432        db.insert_session(&session3)
2433            .expect("Failed to insert session3");
2434
2435        let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
2436
2437        assert_eq!(sessions.len(), 3, "Should have 3 sessions");
2438        // Sessions should be ordered by started_at DESC (most recent first)
2439        assert_eq!(
2440            sessions[0].id, session3.id,
2441            "Most recent session should be first"
2442        );
2443        assert_eq!(
2444            sessions[1].id, session2.id,
2445            "Second most recent session should be second"
2446        );
2447        assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
2448    }
2449
2450    #[test]
2451    fn test_list_ended_sessions() {
2452        let (db, _dir) = create_test_db();
2453        let now = Utc::now();
2454
2455        let mut ended = create_test_session(
2456            "claude-code",
2457            "/home/user/project",
2458            now - Duration::minutes(60),
2459            None,
2460        );
2461        ended.ended_at = Some(now - Duration::minutes(30));
2462
2463        let ongoing = create_test_session(
2464            "claude-code",
2465            "/home/user/project",
2466            now - Duration::minutes(10),
2467            None,
2468        );
2469
2470        db.insert_session(&ended).expect("insert ended session");
2471        db.insert_session(&ongoing).expect("insert ongoing session");
2472
2473        let sessions = db
2474            .list_ended_sessions(100, None)
2475            .expect("Failed to list ended sessions");
2476
2477        assert_eq!(sessions.len(), 1);
2478        assert_eq!(sessions[0].id, ended.id);
2479    }
2480
2481    #[test]
2482    fn test_list_sessions_with_working_dir_filter() {
2483        let (db, _dir) = create_test_db();
2484        let now = Utc::now();
2485
2486        let session1 = create_test_session(
2487            "claude-code",
2488            "/home/user/project-a",
2489            now - Duration::hours(1),
2490            None,
2491        );
2492        let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
2493        let session3 = create_test_session("claude-code", "/other/path", now, None);
2494
2495        db.insert_session(&session1)
2496            .expect("Failed to insert session1");
2497        db.insert_session(&session2)
2498            .expect("Failed to insert session2");
2499        db.insert_session(&session3)
2500            .expect("Failed to insert session3");
2501
2502        // Filter by working directory prefix
2503        let sessions = db
2504            .list_sessions(10, Some("/home/user"))
2505            .expect("Failed to list sessions");
2506
2507        assert_eq!(
2508            sessions.len(),
2509            2,
2510            "Should have 2 sessions matching /home/user prefix"
2511        );
2512
2513        // Verify both matching sessions are returned
2514        let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
2515        assert!(ids.contains(&session1.id), "Should contain session1");
2516        assert!(ids.contains(&session2.id), "Should contain session2");
2517        assert!(!ids.contains(&session3.id), "Should not contain session3");
2518    }
2519
2520    #[test]
2521    fn test_session_exists_by_source() {
2522        let (db, _dir) = create_test_db();
2523        let source_path = "/path/to/session.jsonl";
2524
2525        let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
2526
2527        // Before insert, should not exist
2528        assert!(
2529            !db.session_exists_by_source(source_path)
2530                .expect("Failed to check existence"),
2531            "Session should not exist before insert"
2532        );
2533
2534        db.insert_session(&session)
2535            .expect("Failed to insert session");
2536
2537        // After insert, should exist
2538        assert!(
2539            db.session_exists_by_source(source_path)
2540                .expect("Failed to check existence"),
2541            "Session should exist after insert"
2542        );
2543
2544        // Different path should not exist
2545        assert!(
2546            !db.session_exists_by_source("/other/path.jsonl")
2547                .expect("Failed to check existence"),
2548            "Different source path should not exist"
2549        );
2550    }
2551
2552    #[test]
2553    fn test_get_session_by_source() {
2554        let (db, _dir) = create_test_db();
2555        let source_path = "/path/to/session.jsonl";
2556
2557        let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
2558
2559        // Before insert, should return None
2560        assert!(
2561            db.get_session_by_source(source_path)
2562                .expect("Failed to get session")
2563                .is_none(),
2564            "Session should not exist before insert"
2565        );
2566
2567        db.insert_session(&session)
2568            .expect("Failed to insert session");
2569
2570        // After insert, should return the session
2571        let retrieved = db
2572            .get_session_by_source(source_path)
2573            .expect("Failed to get session")
2574            .expect("Session should exist after insert");
2575
2576        assert_eq!(retrieved.id, session.id, "Session ID should match");
2577        assert_eq!(
2578            retrieved.source_path,
2579            Some(source_path.to_string()),
2580            "Source path should match"
2581        );
2582
2583        // Different path should return None
2584        assert!(
2585            db.get_session_by_source("/other/path.jsonl")
2586                .expect("Failed to get session")
2587                .is_none(),
2588            "Different source path should return None"
2589        );
2590    }
2591
2592    #[test]
2593    fn test_update_session_branch() {
2594        let (db, _dir) = create_test_db();
2595        let now = Utc::now();
2596
2597        // Create session with initial branch
2598        let mut session = create_test_session("claude-code", "/project", now, None);
2599        session.git_branch = Some("main".to_string());
2600
2601        db.insert_session(&session)
2602            .expect("Failed to insert session");
2603
2604        // Verify initial branch
2605        let fetched = db
2606            .get_session(&session.id)
2607            .expect("Failed to get session")
2608            .expect("Session should exist");
2609        assert_eq!(fetched.git_branch, Some("main".to_string()));
2610
2611        // Update branch
2612        let rows = db
2613            .update_session_branch(session.id, "feature-branch")
2614            .expect("Failed to update branch");
2615        assert_eq!(rows, 1, "Should update exactly one row");
2616
2617        // Verify updated branch
2618        let fetched = db
2619            .get_session(&session.id)
2620            .expect("Failed to get session")
2621            .expect("Session should exist");
2622        assert_eq!(fetched.git_branch, Some("feature-branch".to_string()));
2623    }
2624
2625    #[test]
2626    fn test_update_session_branch_nonexistent() {
2627        let (db, _dir) = create_test_db();
2628        let nonexistent_id = Uuid::new_v4();
2629
2630        // Updating a nonexistent session should return 0 rows
2631        let rows = db
2632            .update_session_branch(nonexistent_id, "some-branch")
2633            .expect("Failed to update branch");
2634        assert_eq!(
2635            rows, 0,
2636            "Should not update any rows for nonexistent session"
2637        );
2638    }
2639
2640    #[test]
2641    fn test_update_session_branch_from_none() {
2642        let (db, _dir) = create_test_db();
2643        let now = Utc::now();
2644
2645        // Create session without initial branch
2646        let mut session = create_test_session("claude-code", "/project", now, None);
2647        session.git_branch = None; // Explicitly set to None for this test
2648
2649        db.insert_session(&session)
2650            .expect("Failed to insert session");
2651
2652        // Verify no initial branch
2653        let fetched = db
2654            .get_session(&session.id)
2655            .expect("Failed to get session")
2656            .expect("Session should exist");
2657        assert_eq!(fetched.git_branch, None);
2658
2659        // Update branch from None to a value
2660        let rows = db
2661            .update_session_branch(session.id, "new-branch")
2662            .expect("Failed to update branch");
2663        assert_eq!(rows, 1, "Should update exactly one row");
2664
2665        // Verify updated branch
2666        let fetched = db
2667            .get_session(&session.id)
2668            .expect("Failed to get session")
2669            .expect("Session should exist");
2670        assert_eq!(fetched.git_branch, Some("new-branch".to_string()));
2671    }
2672
2673    #[test]
2674    fn test_get_nonexistent_session() {
2675        let (db, _dir) = create_test_db();
2676        let nonexistent_id = Uuid::new_v4();
2677
2678        let result = db
2679            .get_session(&nonexistent_id)
2680            .expect("Failed to query for nonexistent session");
2681
2682        assert!(
2683            result.is_none(),
2684            "Should return None for nonexistent session"
2685        );
2686    }
2687
2688    // ==================== Message Tests ====================
2689
2690    #[test]
2691    fn test_insert_and_get_messages() {
2692        let (db, _dir) = create_test_db();
2693
2694        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2695        db.insert_session(&session)
2696            .expect("Failed to insert session");
2697
2698        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
2699        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
2700
2701        db.insert_message(&msg1)
2702            .expect("Failed to insert message 1");
2703        db.insert_message(&msg2)
2704            .expect("Failed to insert message 2");
2705
2706        let messages = db
2707            .get_messages(&session.id)
2708            .expect("Failed to get messages");
2709
2710        assert_eq!(messages.len(), 2, "Should have 2 messages");
2711        assert_eq!(messages[0].id, msg1.id, "First message ID should match");
2712        assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
2713        assert_eq!(
2714            messages[0].role,
2715            MessageRole::User,
2716            "First message role should be User"
2717        );
2718        assert_eq!(
2719            messages[1].role,
2720            MessageRole::Assistant,
2721            "Second message role should be Assistant"
2722        );
2723    }
2724
2725    #[test]
2726    fn test_messages_ordered_by_index() {
2727        let (db, _dir) = create_test_db();
2728
2729        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2730        db.insert_session(&session)
2731            .expect("Failed to insert session");
2732
2733        // Insert messages out of order
2734        let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
2735        let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
2736        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
2737
2738        db.insert_message(&msg3)
2739            .expect("Failed to insert message 3");
2740        db.insert_message(&msg1)
2741            .expect("Failed to insert message 1");
2742        db.insert_message(&msg2)
2743            .expect("Failed to insert message 2");
2744
2745        let messages = db
2746            .get_messages(&session.id)
2747            .expect("Failed to get messages");
2748
2749        assert_eq!(messages.len(), 3, "Should have 3 messages");
2750        assert_eq!(messages[0].index, 0, "First message should have index 0");
2751        assert_eq!(messages[1].index, 1, "Second message should have index 1");
2752        assert_eq!(messages[2].index, 2, "Third message should have index 2");
2753
2754        // Verify content matches expected order
2755        assert_eq!(
2756            messages[0].content.text(),
2757            "First",
2758            "First message content should be 'First'"
2759        );
2760        assert_eq!(
2761            messages[1].content.text(),
2762            "Second",
2763            "Second message content should be 'Second'"
2764        );
2765        assert_eq!(
2766            messages[2].content.text(),
2767            "Third",
2768            "Third message content should be 'Third'"
2769        );
2770    }
2771
2772    // ==================== SessionLink Tests ====================
2773
2774    #[test]
2775    fn test_insert_and_get_links_by_session() {
2776        let (db, _dir) = create_test_db();
2777
2778        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2779        db.insert_session(&session)
2780            .expect("Failed to insert session");
2781
2782        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2783        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2784
2785        db.insert_link(&link1).expect("Failed to insert link 1");
2786        db.insert_link(&link2).expect("Failed to insert link 2");
2787
2788        let links = db
2789            .get_links_by_session(&session.id)
2790            .expect("Failed to get links");
2791
2792        assert_eq!(links.len(), 2, "Should have 2 links");
2793
2794        let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
2795        assert!(link_ids.contains(&link1.id), "Should contain link1");
2796        assert!(link_ids.contains(&link2.id), "Should contain link2");
2797
2798        // Verify link properties
2799        let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
2800        assert_eq!(
2801            retrieved_link.commit_sha,
2802            Some("abc123def456".to_string()),
2803            "Commit SHA should match"
2804        );
2805        assert_eq!(
2806            retrieved_link.link_type,
2807            LinkType::Commit,
2808            "Link type should be Commit"
2809        );
2810        assert_eq!(
2811            retrieved_link.created_by,
2812            LinkCreator::Auto,
2813            "Created by should be Auto"
2814        );
2815    }
2816
2817    #[test]
2818    fn test_get_links_by_commit() {
2819        let (db, _dir) = create_test_db();
2820
2821        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2822        db.insert_session(&session)
2823            .expect("Failed to insert session");
2824
2825        let full_sha = "abc123def456789012345678901234567890abcd";
2826        let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
2827        db.insert_link(&link).expect("Failed to insert link");
2828
2829        // Test full SHA match
2830        let links_full = db
2831            .get_links_by_commit(full_sha)
2832            .expect("Failed to get links by full SHA");
2833        assert_eq!(links_full.len(), 1, "Should find link by full SHA");
2834        assert_eq!(links_full[0].id, link.id, "Link ID should match");
2835
2836        // Test partial SHA match (prefix)
2837        let links_partial = db
2838            .get_links_by_commit("abc123")
2839            .expect("Failed to get links by partial SHA");
2840        assert_eq!(
2841            links_partial.len(),
2842            1,
2843            "Should find link by partial SHA prefix"
2844        );
2845        assert_eq!(links_partial[0].id, link.id, "Link ID should match");
2846
2847        // Test non-matching SHA
2848        let links_none = db
2849            .get_links_by_commit("zzz999")
2850            .expect("Failed to get links by non-matching SHA");
2851        assert_eq!(
2852            links_none.len(),
2853            0,
2854            "Should not find link with non-matching SHA"
2855        );
2856    }
2857
2858    // ==================== Database Tests ====================
2859
2860    #[test]
2861    fn test_database_creation() {
2862        let dir = tempdir().expect("Failed to create temp directory");
2863        let db_path = dir.path().join("new_test.db");
2864
2865        // Database should not exist before creation
2866        assert!(
2867            !db_path.exists(),
2868            "Database file should not exist before creation"
2869        );
2870
2871        let db = Database::open(&db_path).expect("Failed to create database");
2872
2873        // Database file should exist after creation
2874        assert!(
2875            db_path.exists(),
2876            "Database file should exist after creation"
2877        );
2878
2879        // Verify tables exist by attempting operations
2880        let session_count = db.session_count().expect("Failed to get session count");
2881        assert_eq!(session_count, 0, "New database should have 0 sessions");
2882
2883        let message_count = db.message_count().expect("Failed to get message count");
2884        assert_eq!(message_count, 0, "New database should have 0 messages");
2885    }
2886
2887    #[test]
2888    fn test_session_count() {
2889        let (db, _dir) = create_test_db();
2890
2891        assert_eq!(
2892            db.session_count().expect("Failed to get count"),
2893            0,
2894            "Initial session count should be 0"
2895        );
2896
2897        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
2898        db.insert_session(&session1)
2899            .expect("Failed to insert session1");
2900
2901        assert_eq!(
2902            db.session_count().expect("Failed to get count"),
2903            1,
2904            "Session count should be 1 after first insert"
2905        );
2906
2907        let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
2908        db.insert_session(&session2)
2909            .expect("Failed to insert session2");
2910
2911        assert_eq!(
2912            db.session_count().expect("Failed to get count"),
2913            2,
2914            "Session count should be 2 after second insert"
2915        );
2916    }
2917
2918    #[test]
2919    fn test_message_count() {
2920        let (db, _dir) = create_test_db();
2921
2922        assert_eq!(
2923            db.message_count().expect("Failed to get count"),
2924            0,
2925            "Initial message count should be 0"
2926        );
2927
2928        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2929        db.insert_session(&session)
2930            .expect("Failed to insert session");
2931
2932        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
2933        db.insert_message(&msg1).expect("Failed to insert message1");
2934
2935        assert_eq!(
2936            db.message_count().expect("Failed to get count"),
2937            1,
2938            "Message count should be 1 after first insert"
2939        );
2940
2941        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
2942        let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
2943        db.insert_message(&msg2).expect("Failed to insert message2");
2944        db.insert_message(&msg3).expect("Failed to insert message3");
2945
2946        assert_eq!(
2947            db.message_count().expect("Failed to get count"),
2948            3,
2949            "Message count should be 3 after all inserts"
2950        );
2951    }
2952
2953    #[test]
2954    fn test_link_count() {
2955        let (db, _dir) = create_test_db();
2956
2957        assert_eq!(
2958            db.link_count().expect("Failed to get count"),
2959            0,
2960            "Initial link count should be 0"
2961        );
2962
2963        let session = create_test_session("claude-code", "/project", Utc::now(), None);
2964        db.insert_session(&session)
2965            .expect("Failed to insert session");
2966
2967        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
2968        db.insert_link(&link1).expect("Failed to insert link1");
2969
2970        assert_eq!(
2971            db.link_count().expect("Failed to get count"),
2972            1,
2973            "Link count should be 1 after first insert"
2974        );
2975
2976        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
2977        db.insert_link(&link2).expect("Failed to insert link2");
2978
2979        assert_eq!(
2980            db.link_count().expect("Failed to get count"),
2981            2,
2982            "Link count should be 2 after second insert"
2983        );
2984    }
2985
2986    #[test]
2987    fn test_db_path() {
2988        let dir = tempdir().expect("Failed to create temp directory");
2989        let db_path = dir.path().join("test.db");
2990        let db = Database::open(&db_path).expect("Failed to open test database");
2991
2992        let retrieved_path = db.db_path();
2993        assert!(
2994            retrieved_path.is_some(),
2995            "Database path should be available"
2996        );
2997
2998        // Canonicalize both paths to handle macOS /var -> /private/var symlinks
2999        let expected = db_path.canonicalize().unwrap_or(db_path);
3000        let actual = retrieved_path.unwrap();
3001        let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
3002
3003        assert_eq!(
3004            actual_canonical, expected,
3005            "Database path should match (after canonicalization)"
3006        );
3007    }
3008
3009    // ==================== Search Tests ====================
3010
3011    #[test]
3012    fn test_search_messages_basic() {
3013        let (db, _dir) = create_test_db();
3014
3015        let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
3016        db.insert_session(&session)
3017            .expect("Failed to insert session");
3018
3019        let msg1 = create_test_message(
3020            session.id,
3021            0,
3022            MessageRole::User,
3023            "How do I implement error handling in Rust?",
3024        );
3025        let msg2 = create_test_message(
3026            session.id,
3027            1,
3028            MessageRole::Assistant,
3029            "You can use Result types for error handling. The anyhow crate is also helpful.",
3030        );
3031
3032        db.insert_message(&msg1)
3033            .expect("Failed to insert message 1");
3034        db.insert_message(&msg2)
3035            .expect("Failed to insert message 2");
3036
3037        // Search for "error"
3038        let results = db
3039            .search_messages("error", 10, None, None, None)
3040            .expect("Failed to search");
3041
3042        assert_eq!(
3043            results.len(),
3044            2,
3045            "Should find 2 messages containing 'error'"
3046        );
3047    }
3048
3049    #[test]
3050    fn test_search_messages_no_results() {
3051        let (db, _dir) = create_test_db();
3052
3053        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3054        db.insert_session(&session)
3055            .expect("Failed to insert session");
3056
3057        let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
3058        db.insert_message(&msg).expect("Failed to insert message");
3059
3060        // Search for something not in the messages
3061        let results = db
3062            .search_messages("nonexistent_term_xyz", 10, None, None, None)
3063            .expect("Failed to search");
3064
3065        assert!(results.is_empty(), "Should find no results");
3066    }
3067
3068    #[test]
3069    fn test_search_messages_with_role_filter() {
3070        let (db, _dir) = create_test_db();
3071
3072        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3073        db.insert_session(&session)
3074            .expect("Failed to insert session");
3075
3076        let msg1 = create_test_message(
3077            session.id,
3078            0,
3079            MessageRole::User,
3080            "Tell me about Rust programming",
3081        );
3082        let msg2 = create_test_message(
3083            session.id,
3084            1,
3085            MessageRole::Assistant,
3086            "Rust is a systems programming language",
3087        );
3088
3089        db.insert_message(&msg1)
3090            .expect("Failed to insert message 1");
3091        db.insert_message(&msg2)
3092            .expect("Failed to insert message 2");
3093
3094        // Search with user role filter
3095        let user_results = db
3096            .search_messages("programming", 10, None, None, Some("user"))
3097            .expect("Failed to search");
3098
3099        assert_eq!(user_results.len(), 1, "Should find 1 user message");
3100        assert_eq!(
3101            user_results[0].role,
3102            MessageRole::User,
3103            "Result should be from user"
3104        );
3105
3106        // Search with assistant role filter
3107        let assistant_results = db
3108            .search_messages("programming", 10, None, None, Some("assistant"))
3109            .expect("Failed to search");
3110
3111        assert_eq!(
3112            assistant_results.len(),
3113            1,
3114            "Should find 1 assistant message"
3115        );
3116        assert_eq!(
3117            assistant_results[0].role,
3118            MessageRole::Assistant,
3119            "Result should be from assistant"
3120        );
3121    }
3122
3123    #[test]
3124    fn test_search_messages_with_repo_filter() {
3125        let (db, _dir) = create_test_db();
3126
3127        let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
3128        let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
3129
3130        db.insert_session(&session1).expect("insert 1");
3131        db.insert_session(&session2).expect("insert 2");
3132
3133        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
3134        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
3135
3136        db.insert_message(&msg1).expect("insert msg 1");
3137        db.insert_message(&msg2).expect("insert msg 2");
3138
3139        // Search with repo filter
3140        let results = db
3141            .search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
3142            .expect("Failed to search");
3143
3144        assert_eq!(results.len(), 1, "Should find 1 message in project-a");
3145        assert!(
3146            results[0].working_directory.contains("project-a"),
3147            "Should be from project-a"
3148        );
3149    }
3150
3151    #[test]
3152    fn test_search_messages_limit() {
3153        let (db, _dir) = create_test_db();
3154
3155        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3156        db.insert_session(&session).expect("insert session");
3157
3158        // Insert 5 messages all containing "test"
3159        for i in 0..5 {
3160            let msg = create_test_message(
3161                session.id,
3162                i,
3163                MessageRole::User,
3164                &format!("This is test message number {i}"),
3165            );
3166            db.insert_message(&msg).expect("insert message");
3167        }
3168
3169        // Search with limit of 3
3170        let results = db
3171            .search_messages("test", 3, None, None, None)
3172            .expect("Failed to search");
3173
3174        assert_eq!(results.len(), 3, "Should respect limit of 3");
3175    }
3176
3177    #[test]
3178    fn test_search_index_needs_rebuild_empty_db() {
3179        let (db, _dir) = create_test_db();
3180
3181        let needs_rebuild = db
3182            .search_index_needs_rebuild()
3183            .expect("Failed to check rebuild status");
3184
3185        assert!(!needs_rebuild, "Empty database should not need rebuild");
3186    }
3187
3188    #[test]
3189    fn test_rebuild_search_index() {
3190        let (db, _dir) = create_test_db();
3191
3192        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3193        db.insert_session(&session).expect("insert session");
3194
3195        let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
3196        let msg2 = create_test_message(
3197            session.id,
3198            1,
3199            MessageRole::Assistant,
3200            "Second test response",
3201        );
3202
3203        db.insert_message(&msg1).expect("insert msg 1");
3204        db.insert_message(&msg2).expect("insert msg 2");
3205
3206        // Clear and rebuild the index
3207        db.conn
3208            .execute("DELETE FROM messages_fts", [])
3209            .expect("clear fts");
3210
3211        // Index should now need rebuilding
3212        assert!(
3213            db.search_index_needs_rebuild().expect("check rebuild"),
3214            "Should need rebuild after clearing FTS"
3215        );
3216
3217        // Rebuild
3218        let count = db.rebuild_search_index().expect("rebuild");
3219        assert_eq!(count, 2, "Should have indexed 2 messages");
3220
3221        // Index should no longer need rebuilding
3222        assert!(
3223            !db.search_index_needs_rebuild().expect("check rebuild"),
3224            "Should not need rebuild after rebuilding"
3225        );
3226
3227        // Search should work
3228        let results = db
3229            .search_messages("test", 10, None, None, None)
3230            .expect("search");
3231        assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
3232    }
3233
3234    #[test]
3235    fn test_search_with_block_content() {
3236        let (db, _dir) = create_test_db();
3237
3238        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3239        db.insert_session(&session).expect("insert session");
3240
3241        // Create a message with block content
3242        let block_content = MessageContent::Blocks(vec![
3243            crate::storage::models::ContentBlock::Text {
3244                text: "Let me help with your database query.".to_string(),
3245            },
3246            crate::storage::models::ContentBlock::ToolUse {
3247                id: "tool_123".to_string(),
3248                name: "Bash".to_string(),
3249                input: serde_json::json!({"command": "ls -la"}),
3250            },
3251        ]);
3252
3253        let msg = Message {
3254            id: Uuid::new_v4(),
3255            session_id: session.id,
3256            parent_id: None,
3257            index: 0,
3258            timestamp: Utc::now(),
3259            role: MessageRole::Assistant,
3260            content: block_content,
3261            model: Some("claude-opus-4".to_string()),
3262            git_branch: Some("main".to_string()),
3263            cwd: Some("/project".to_string()),
3264        };
3265
3266        db.insert_message(&msg).expect("insert message");
3267
3268        // Search should find text from blocks
3269        let results = db
3270            .search_messages("database", 10, None, None, None)
3271            .expect("search");
3272
3273        assert_eq!(results.len(), 1, "Should find message with block content");
3274    }
3275
3276    #[test]
3277    fn test_search_result_contains_session_info() {
3278        let (db, _dir) = create_test_db();
3279
3280        let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
3281        db.insert_session(&session).expect("insert session");
3282
3283        let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
3284        db.insert_message(&msg).expect("insert message");
3285
3286        let results = db
3287            .search_messages("Search", 10, None, None, None)
3288            .expect("search");
3289
3290        assert_eq!(results.len(), 1, "Should find 1 result");
3291        assert_eq!(results[0].session_id, session.id, "Session ID should match");
3292        assert_eq!(results[0].message_id, msg.id, "Message ID should match");
3293        assert_eq!(
3294            results[0].working_directory, "/home/user/my-project",
3295            "Working directory should match"
3296        );
3297        assert_eq!(results[0].role, MessageRole::User, "Role should match");
3298    }
3299
3300    // ==================== Delete Link Tests ====================
3301
3302    #[test]
3303    fn test_delete_link_by_id() {
3304        let (db, _dir) = create_test_db();
3305
3306        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3307        db.insert_session(&session)
3308            .expect("Failed to insert session");
3309
3310        let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3311        db.insert_link(&link).expect("Failed to insert link");
3312
3313        // Verify link exists
3314        let links_before = db
3315            .get_links_by_session(&session.id)
3316            .expect("Failed to get links");
3317        assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
3318
3319        // Delete the link
3320        let deleted = db.delete_link(&link.id).expect("Failed to delete link");
3321        assert!(deleted, "Should return true when link is deleted");
3322
3323        // Verify link is gone
3324        let links_after = db
3325            .get_links_by_session(&session.id)
3326            .expect("Failed to get links");
3327        assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
3328    }
3329
3330    #[test]
3331    fn test_delete_link_nonexistent() {
3332        let (db, _dir) = create_test_db();
3333
3334        let nonexistent_id = Uuid::new_v4();
3335        let deleted = db
3336            .delete_link(&nonexistent_id)
3337            .expect("Failed to call delete_link");
3338
3339        assert!(!deleted, "Should return false for nonexistent link");
3340    }
3341
3342    #[test]
3343    fn test_delete_links_by_session() {
3344        let (db, _dir) = create_test_db();
3345
3346        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3347        db.insert_session(&session)
3348            .expect("Failed to insert session");
3349
3350        // Create multiple links for the same session
3351        let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3352        let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
3353        let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
3354
3355        db.insert_link(&link1).expect("Failed to insert link1");
3356        db.insert_link(&link2).expect("Failed to insert link2");
3357        db.insert_link(&link3).expect("Failed to insert link3");
3358
3359        // Verify all links exist
3360        let links_before = db
3361            .get_links_by_session(&session.id)
3362            .expect("Failed to get links");
3363        assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
3364
3365        // Delete all links for the session
3366        let count = db
3367            .delete_links_by_session(&session.id)
3368            .expect("Failed to delete links");
3369        assert_eq!(count, 3, "Should have deleted 3 links");
3370
3371        // Verify all links are gone
3372        let links_after = db
3373            .get_links_by_session(&session.id)
3374            .expect("Failed to get links");
3375        assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
3376    }
3377
3378    #[test]
3379    fn test_delete_links_by_session_no_links() {
3380        let (db, _dir) = create_test_db();
3381
3382        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3383        db.insert_session(&session)
3384            .expect("Failed to insert session");
3385
3386        // Delete links for session that has none
3387        let count = db
3388            .delete_links_by_session(&session.id)
3389            .expect("Failed to call delete_links_by_session");
3390        assert_eq!(count, 0, "Should return 0 when no links exist");
3391    }
3392
3393    #[test]
3394    fn test_delete_links_by_session_preserves_other_sessions() {
3395        let (db, _dir) = create_test_db();
3396
3397        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3398        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3399
3400        db.insert_session(&session1)
3401            .expect("Failed to insert session1");
3402        db.insert_session(&session2)
3403            .expect("Failed to insert session2");
3404
3405        let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3406        let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
3407
3408        db.insert_link(&link1).expect("Failed to insert link1");
3409        db.insert_link(&link2).expect("Failed to insert link2");
3410
3411        // Delete links only for session1
3412        let count = db
3413            .delete_links_by_session(&session1.id)
3414            .expect("Failed to delete links");
3415        assert_eq!(count, 1, "Should have deleted 1 link");
3416
3417        // Verify session2's link is preserved
3418        let session2_links = db
3419            .get_links_by_session(&session2.id)
3420            .expect("Failed to get links");
3421        assert_eq!(
3422            session2_links.len(),
3423            1,
3424            "Session2's link should be preserved"
3425        );
3426        assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
3427    }
3428
3429    #[test]
3430    fn test_delete_link_by_session_and_commit() {
3431        let (db, _dir) = create_test_db();
3432
3433        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3434        db.insert_session(&session)
3435            .expect("Failed to insert session");
3436
3437        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3438        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
3439
3440        db.insert_link(&link1).expect("Failed to insert link1");
3441        db.insert_link(&link2).expect("Failed to insert link2");
3442
3443        // Delete only the first link by commit SHA
3444        let deleted = db
3445            .delete_link_by_session_and_commit(&session.id, "abc123")
3446            .expect("Failed to delete link");
3447        assert!(deleted, "Should return true when link is deleted");
3448
3449        // Verify only link2 remains
3450        let links = db
3451            .get_links_by_session(&session.id)
3452            .expect("Failed to get links");
3453        assert_eq!(links.len(), 1, "Should have 1 link remaining");
3454        assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
3455    }
3456
3457    #[test]
3458    fn test_delete_link_by_session_and_commit_full_sha() {
3459        let (db, _dir) = create_test_db();
3460
3461        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3462        db.insert_session(&session)
3463            .expect("Failed to insert session");
3464
3465        let full_sha = "abc123def456789012345678901234567890abcd";
3466        let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
3467        db.insert_link(&link).expect("Failed to insert link");
3468
3469        // Delete using full SHA
3470        let deleted = db
3471            .delete_link_by_session_and_commit(&session.id, full_sha)
3472            .expect("Failed to delete link");
3473        assert!(deleted, "Should delete with full SHA");
3474
3475        let links = db
3476            .get_links_by_session(&session.id)
3477            .expect("Failed to get links");
3478        assert_eq!(links.len(), 0, "Should have 0 links after delete");
3479    }
3480
3481    #[test]
3482    fn test_delete_link_by_session_and_commit_no_match() {
3483        let (db, _dir) = create_test_db();
3484
3485        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3486        db.insert_session(&session)
3487            .expect("Failed to insert session");
3488
3489        let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
3490        db.insert_link(&link).expect("Failed to insert link");
3491
3492        // Try to delete with non-matching commit
3493        let deleted = db
3494            .delete_link_by_session_and_commit(&session.id, "xyz999")
3495            .expect("Failed to call delete");
3496        assert!(!deleted, "Should return false when no match");
3497
3498        // Verify original link is preserved
3499        let links = db
3500            .get_links_by_session(&session.id)
3501            .expect("Failed to get links");
3502        assert_eq!(links.len(), 1, "Link should be preserved");
3503    }
3504
3505    #[test]
3506    fn test_delete_link_by_session_and_commit_wrong_session() {
3507        let (db, _dir) = create_test_db();
3508
3509        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3510        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3511
3512        db.insert_session(&session1)
3513            .expect("Failed to insert session1");
3514        db.insert_session(&session2)
3515            .expect("Failed to insert session2");
3516
3517        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3518        db.insert_link(&link).expect("Failed to insert link");
3519
3520        // Try to delete from wrong session
3521        let deleted = db
3522            .delete_link_by_session_and_commit(&session2.id, "abc123")
3523            .expect("Failed to call delete");
3524        assert!(!deleted, "Should not delete link from different session");
3525
3526        // Verify original link is preserved
3527        let links = db
3528            .get_links_by_session(&session1.id)
3529            .expect("Failed to get links");
3530        assert_eq!(links.len(), 1, "Link should be preserved");
3531    }
3532
3533    // ==================== Auto-linking Tests ====================
3534
3535    #[test]
3536    fn test_find_sessions_near_commit_time_basic() {
3537        let (db, _dir) = create_test_db();
3538        let now = Utc::now();
3539
3540        // Create a session that ended 10 minutes ago
3541        let mut session = create_test_session(
3542            "claude-code",
3543            "/home/user/project",
3544            now - Duration::hours(1),
3545            None,
3546        );
3547        session.ended_at = Some(now - Duration::minutes(10));
3548
3549        db.insert_session(&session).expect("insert session");
3550
3551        // Find sessions near "now" with a 30 minute window
3552        let found = db
3553            .find_sessions_near_commit_time(now, 30, None)
3554            .expect("find sessions");
3555
3556        assert_eq!(found.len(), 1, "Should find session within window");
3557        assert_eq!(found[0].id, session.id);
3558    }
3559
3560    #[test]
3561    fn test_find_sessions_near_commit_time_outside_window() {
3562        let (db, _dir) = create_test_db();
3563        let now = Utc::now();
3564
3565        // Create a session that ended 2 hours ago
3566        let mut session =
3567            create_test_session("claude-code", "/project", now - Duration::hours(3), None);
3568        session.ended_at = Some(now - Duration::hours(2));
3569
3570        db.insert_session(&session).expect("insert session");
3571
3572        // Find sessions near "now" with a 30 minute window
3573        let found = db
3574            .find_sessions_near_commit_time(now, 30, None)
3575            .expect("find sessions");
3576
3577        assert!(found.is_empty(), "Should not find session outside window");
3578    }
3579
3580    #[test]
3581    fn test_find_sessions_near_commit_time_with_working_dir() {
3582        let (db, _dir) = create_test_db();
3583        let now = Utc::now();
3584
3585        // Create sessions in different directories
3586        let mut session1 = create_test_session(
3587            "claude-code",
3588            "/home/user/project-a",
3589            now - Duration::minutes(30),
3590            None,
3591        );
3592        session1.ended_at = Some(now - Duration::minutes(5));
3593
3594        let mut session2 = create_test_session(
3595            "claude-code",
3596            "/home/user/project-b",
3597            now - Duration::minutes(30),
3598            None,
3599        );
3600        session2.ended_at = Some(now - Duration::minutes(5));
3601
3602        db.insert_session(&session1).expect("insert session1");
3603        db.insert_session(&session2).expect("insert session2");
3604
3605        // Find sessions near "now" filtering by project-a
3606        let found = db
3607            .find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
3608            .expect("find sessions");
3609
3610        assert_eq!(found.len(), 1, "Should find only session in project-a");
3611        assert_eq!(found[0].id, session1.id);
3612    }
3613
3614    #[test]
3615    fn test_find_sessions_near_commit_time_ongoing_session() {
3616        let (db, _dir) = create_test_db();
3617        let now = Utc::now();
3618
3619        // Create an ongoing session (no ended_at)
3620        let session =
3621            create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
3622        // ended_at is None by default
3623
3624        db.insert_session(&session).expect("insert session");
3625
3626        // Find sessions near "now"
3627        let found = db
3628            .find_sessions_near_commit_time(now, 30, None)
3629            .expect("find sessions");
3630
3631        assert_eq!(found.len(), 1, "Should find ongoing session");
3632        assert_eq!(found[0].id, session.id);
3633    }
3634
3635    #[test]
3636    fn test_link_exists_true() {
3637        let (db, _dir) = create_test_db();
3638
3639        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3640        db.insert_session(&session).expect("insert session");
3641
3642        let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
3643        db.insert_link(&link).expect("insert link");
3644
3645        // Check with full SHA
3646        assert!(
3647            db.link_exists(&session.id, "abc123def456")
3648                .expect("check exists"),
3649            "Should find link with full SHA"
3650        );
3651
3652        // Check with partial SHA
3653        assert!(
3654            db.link_exists(&session.id, "abc123").expect("check exists"),
3655            "Should find link with partial SHA"
3656        );
3657    }
3658
3659    #[test]
3660    fn test_link_exists_false() {
3661        let (db, _dir) = create_test_db();
3662
3663        let session = create_test_session("claude-code", "/project", Utc::now(), None);
3664        db.insert_session(&session).expect("insert session");
3665
3666        // No links created
3667        assert!(
3668            !db.link_exists(&session.id, "abc123").expect("check exists"),
3669            "Should not find non-existent link"
3670        );
3671    }
3672
3673    #[test]
3674    fn test_link_exists_different_session() {
3675        let (db, _dir) = create_test_db();
3676
3677        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3678        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
3679
3680        db.insert_session(&session1).expect("insert session1");
3681        db.insert_session(&session2).expect("insert session2");
3682
3683        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
3684        db.insert_link(&link).expect("insert link");
3685
3686        // Link exists for session1 but not session2
3687        assert!(
3688            db.link_exists(&session1.id, "abc123").expect("check"),
3689            "Should find link for session1"
3690        );
3691        assert!(
3692            !db.link_exists(&session2.id, "abc123").expect("check"),
3693            "Should not find link for session2"
3694        );
3695    }
3696
3697    // ==================== Forward Auto-linking Tests ====================
3698
3699    #[test]
3700    fn test_find_active_sessions_for_directory_ongoing() {
3701        let (db, _dir) = create_test_db();
3702        let now = Utc::now();
3703
3704        // Create an ongoing session (no ended_at)
3705        let session = create_test_session(
3706            "claude-code",
3707            "/home/user/project",
3708            now - Duration::minutes(30),
3709            None,
3710        );
3711        // ended_at is None by default (ongoing)
3712
3713        db.insert_session(&session).expect("insert session");
3714
3715        // Find active sessions
3716        let found = db
3717            .find_active_sessions_for_directory("/home/user/project", None)
3718            .expect("find active sessions");
3719
3720        assert_eq!(found.len(), 1, "Should find ongoing session");
3721        assert_eq!(found[0].id, session.id);
3722    }
3723
3724    #[test]
3725    fn test_find_active_sessions_for_directory_recently_ended() {
3726        let (db, _dir) = create_test_db();
3727        let now = Utc::now();
3728
3729        // Create a session that ended 2 minutes ago (within default 5 minute window)
3730        let mut session = create_test_session(
3731            "claude-code",
3732            "/home/user/project",
3733            now - Duration::minutes(30),
3734            None,
3735        );
3736        session.ended_at = Some(now - Duration::minutes(2));
3737
3738        db.insert_session(&session).expect("insert session");
3739
3740        // Find active sessions
3741        let found = db
3742            .find_active_sessions_for_directory("/home/user/project", None)
3743            .expect("find active sessions");
3744
3745        assert_eq!(found.len(), 1, "Should find recently ended session");
3746        assert_eq!(found[0].id, session.id);
3747    }
3748
3749    #[test]
3750    fn test_find_active_sessions_for_directory_old_session() {
3751        let (db, _dir) = create_test_db();
3752        let now = Utc::now();
3753
3754        // Create a session that ended 10 minutes ago (outside default 5 minute window)
3755        let mut session = create_test_session(
3756            "claude-code",
3757            "/home/user/project",
3758            now - Duration::minutes(60),
3759            None,
3760        );
3761        session.ended_at = Some(now - Duration::minutes(10));
3762
3763        db.insert_session(&session).expect("insert session");
3764
3765        // Find active sessions
3766        let found = db
3767            .find_active_sessions_for_directory("/home/user/project", None)
3768            .expect("find active sessions");
3769
3770        assert!(found.is_empty(), "Should not find old session");
3771    }
3772
3773    #[test]
3774    fn test_find_active_sessions_for_directory_filters_by_path() {
3775        let (db, _dir) = create_test_db();
3776        let now = Utc::now();
3777
3778        // Create sessions in different directories
3779        let session1 = create_test_session(
3780            "claude-code",
3781            "/home/user/project-a",
3782            now - Duration::minutes(10),
3783            None,
3784        );
3785        let session2 = create_test_session(
3786            "claude-code",
3787            "/home/user/project-b",
3788            now - Duration::minutes(10),
3789            None,
3790        );
3791
3792        db.insert_session(&session1).expect("insert session1");
3793        db.insert_session(&session2).expect("insert session2");
3794
3795        // Find active sessions for project-a only
3796        let found = db
3797            .find_active_sessions_for_directory("/home/user/project-a", None)
3798            .expect("find active sessions");
3799
3800        assert_eq!(found.len(), 1, "Should find only session in project-a");
3801        assert_eq!(found[0].id, session1.id);
3802    }
3803
3804    #[test]
3805    fn test_find_active_sessions_for_directory_trailing_slash_matches() {
3806        let (db, _dir) = create_test_db();
3807        let now = Utc::now();
3808
3809        let session = create_test_session(
3810            "claude-code",
3811            "/home/user/project",
3812            now - Duration::minutes(10),
3813            None,
3814        );
3815        db.insert_session(&session).expect("insert session");
3816
3817        let found = db
3818            .find_active_sessions_for_directory("/home/user/project/", None)
3819            .expect("find active sessions");
3820
3821        assert_eq!(found.len(), 1, "Should match even with trailing slash");
3822        assert_eq!(found[0].id, session.id);
3823    }
3824
3825    #[test]
3826    fn test_find_active_sessions_for_directory_does_not_match_prefix_siblings() {
3827        let (db, _dir) = create_test_db();
3828        let now = Utc::now();
3829
3830        let session_root = create_test_session(
3831            "claude-code",
3832            "/home/user/project",
3833            now - Duration::minutes(10),
3834            None,
3835        );
3836        let session_subdir = create_test_session(
3837            "claude-code",
3838            "/home/user/project/src",
3839            now - Duration::minutes(10),
3840            None,
3841        );
3842        let session_sibling = create_test_session(
3843            "claude-code",
3844            "/home/user/project-old",
3845            now - Duration::minutes(10),
3846            None,
3847        );
3848
3849        db.insert_session(&session_root)
3850            .expect("insert session_root");
3851        db.insert_session(&session_subdir)
3852            .expect("insert session_subdir");
3853        db.insert_session(&session_sibling)
3854            .expect("insert session_sibling");
3855
3856        let found = db
3857            .find_active_sessions_for_directory("/home/user/project", None)
3858            .expect("find active sessions");
3859
3860        let found_ids: std::collections::HashSet<Uuid> =
3861            found.iter().map(|session| session.id).collect();
3862        assert!(found_ids.contains(&session_root.id));
3863        assert!(found_ids.contains(&session_subdir.id));
3864        assert!(!found_ids.contains(&session_sibling.id));
3865    }
3866
3867    #[test]
3868    fn test_find_active_sessions_for_directory_custom_window() {
3869        let (db, _dir) = create_test_db();
3870        let now = Utc::now();
3871
3872        // Create a session that ended 8 minutes ago
3873        let mut session = create_test_session(
3874            "claude-code",
3875            "/home/user/project",
3876            now - Duration::minutes(30),
3877            None,
3878        );
3879        session.ended_at = Some(now - Duration::minutes(8));
3880
3881        db.insert_session(&session).expect("insert session");
3882
3883        // Should not find with default 5 minute window
3884        let found = db
3885            .find_active_sessions_for_directory("/home/user/project", None)
3886            .expect("find with default window");
3887        assert!(found.is_empty(), "Should not find with 5 minute window");
3888
3889        // Should find with 10 minute window
3890        let found = db
3891            .find_active_sessions_for_directory("/home/user/project", Some(10))
3892            .expect("find with 10 minute window");
3893        assert_eq!(found.len(), 1, "Should find with 10 minute window");
3894    }
3895
3896    // ==================== Enhanced Search Tests ====================
3897
3898    #[test]
3899    fn test_search_with_tool_filter() {
3900        let (db, _dir) = create_test_db();
3901
3902        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
3903        let session2 = create_test_session("aider", "/project2", Utc::now(), None);
3904
3905        db.insert_session(&session1).expect("insert session1");
3906        db.insert_session(&session2).expect("insert session2");
3907
3908        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
3909        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
3910
3911        db.insert_message(&msg1).expect("insert msg1");
3912        db.insert_message(&msg2).expect("insert msg2");
3913
3914        // Search with tool filter
3915        let options = SearchOptions {
3916            query: "Hello".to_string(),
3917            limit: 10,
3918            tool: Some("claude-code".to_string()),
3919            ..Default::default()
3920        };
3921        let results = db.search_with_options(&options).expect("search");
3922
3923        assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
3924        assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
3925    }
3926
3927    #[test]
3928    fn test_search_with_date_range() {
3929        let (db, _dir) = create_test_db();
3930
3931        let old_time = Utc::now() - chrono::Duration::days(30);
3932        let new_time = Utc::now() - chrono::Duration::days(1);
3933
3934        let session1 = create_test_session("claude-code", "/project1", old_time, None);
3935        let session2 = create_test_session("claude-code", "/project2", new_time, None);
3936
3937        db.insert_session(&session1).expect("insert session1");
3938        db.insert_session(&session2).expect("insert session2");
3939
3940        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
3941        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
3942
3943        db.insert_message(&msg1).expect("insert msg1");
3944        db.insert_message(&msg2).expect("insert msg2");
3945
3946        // Search with since filter (last 7 days)
3947        let since = Utc::now() - chrono::Duration::days(7);
3948        let options = SearchOptions {
3949            query: "session".to_string(),
3950            limit: 10,
3951            since: Some(since),
3952            ..Default::default()
3953        };
3954        let results = db.search_with_options(&options).expect("search");
3955
3956        assert_eq!(results.len(), 1, "Should find 1 result within date range");
3957        assert!(
3958            results[0].working_directory.contains("project2"),
3959            "Should be from newer project"
3960        );
3961    }
3962
3963    #[test]
3964    fn test_search_with_project_filter() {
3965        let (db, _dir) = create_test_db();
3966
3967        let session1 =
3968            create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
3969        let session2 =
3970            create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
3971
3972        db.insert_session(&session1).expect("insert session1");
3973        db.insert_session(&session2).expect("insert session2");
3974
3975        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
3976        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
3977
3978        db.insert_message(&msg1).expect("insert msg1");
3979        db.insert_message(&msg2).expect("insert msg2");
3980
3981        // Search with project filter
3982        let options = SearchOptions {
3983            query: "Testing".to_string(),
3984            limit: 10,
3985            project: Some("frontend".to_string()),
3986            ..Default::default()
3987        };
3988        let results = db.search_with_options(&options).expect("search");
3989
3990        assert_eq!(results.len(), 1, "Should find 1 result with project filter");
3991        assert!(
3992            results[0].working_directory.contains("frontend"),
3993            "Should be from frontend project"
3994        );
3995    }
3996
3997    #[test]
3998    fn test_search_with_branch_filter() {
3999        let (db, _dir) = create_test_db();
4000
4001        let session1 = Session {
4002            id: Uuid::new_v4(),
4003            tool: "claude-code".to_string(),
4004            tool_version: None,
4005            started_at: Utc::now(),
4006            ended_at: None,
4007            model: None,
4008            working_directory: "/project".to_string(),
4009            git_branch: Some("feat/auth".to_string()),
4010            source_path: None,
4011            message_count: 0,
4012            machine_id: None,
4013        };
4014        let session2 = Session {
4015            id: Uuid::new_v4(),
4016            tool: "claude-code".to_string(),
4017            tool_version: None,
4018            started_at: Utc::now(),
4019            ended_at: None,
4020            model: None,
4021            working_directory: "/project".to_string(),
4022            git_branch: Some("main".to_string()),
4023            source_path: None,
4024            message_count: 0,
4025            machine_id: None,
4026        };
4027
4028        db.insert_session(&session1).expect("insert session1");
4029        db.insert_session(&session2).expect("insert session2");
4030
4031        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
4032        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
4033
4034        db.insert_message(&msg1).expect("insert msg1");
4035        db.insert_message(&msg2).expect("insert msg2");
4036
4037        // Search with branch filter
4038        let options = SearchOptions {
4039            query: "work".to_string(),
4040            limit: 10,
4041            branch: Some("auth".to_string()),
4042            ..Default::default()
4043        };
4044        let results = db.search_with_options(&options).expect("search");
4045
4046        assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
4047        assert_eq!(
4048            results[0].git_branch.as_deref(),
4049            Some("feat/auth"),
4050            "Should be from feat/auth branch"
4051        );
4052    }
4053
4054    #[test]
4055    fn test_search_metadata_matches_project() {
4056        let (db, _dir) = create_test_db();
4057
4058        let session =
4059            create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
4060        db.insert_session(&session).expect("insert session");
4061
4062        // Add a message that does NOT contain "redactyl"
4063        let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
4064        db.insert_message(&msg).expect("insert msg");
4065
4066        // Search for "redactyl" - should match session metadata
4067        let options = SearchOptions {
4068            query: "redactyl".to_string(),
4069            limit: 10,
4070            ..Default::default()
4071        };
4072        let results = db.search_with_options(&options).expect("search");
4073
4074        assert_eq!(
4075            results.len(),
4076            1,
4077            "Should find session via metadata match on project name"
4078        );
4079    }
4080
4081    #[test]
4082    fn test_search_returns_extended_session_info() {
4083        let (db, _dir) = create_test_db();
4084
4085        let started_at = Utc::now();
4086        let session = Session {
4087            id: Uuid::new_v4(),
4088            tool: "claude-code".to_string(),
4089            tool_version: Some("1.0.0".to_string()),
4090            started_at,
4091            ended_at: None,
4092            model: None,
4093            working_directory: "/home/user/myapp".to_string(),
4094            git_branch: Some("develop".to_string()),
4095            source_path: None,
4096            message_count: 5,
4097            machine_id: None,
4098        };
4099        db.insert_session(&session).expect("insert session");
4100
4101        let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
4102        db.insert_message(&msg).expect("insert msg");
4103
4104        let options = SearchOptions {
4105            query: "Test".to_string(),
4106            limit: 10,
4107            ..Default::default()
4108        };
4109        let results = db.search_with_options(&options).expect("search");
4110
4111        assert_eq!(results.len(), 1, "Should find 1 result");
4112        let result = &results[0];
4113
4114        assert_eq!(result.tool, "claude-code", "Tool should be populated");
4115        assert_eq!(
4116            result.git_branch.as_deref(),
4117            Some("develop"),
4118            "Branch should be populated"
4119        );
4120        assert!(
4121            result.session_message_count > 0,
4122            "Message count should be populated"
4123        );
4124        assert!(
4125            result.session_started_at.is_some(),
4126            "Session start time should be populated"
4127        );
4128    }
4129
4130    #[test]
4131    fn test_get_context_messages() {
4132        let (db, _dir) = create_test_db();
4133
4134        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4135        db.insert_session(&session).expect("insert session");
4136
4137        // Create 5 messages in sequence
4138        for i in 0..5 {
4139            let role = if i % 2 == 0 {
4140                MessageRole::User
4141            } else {
4142                MessageRole::Assistant
4143            };
4144            let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
4145            db.insert_message(&msg).expect("insert message");
4146        }
4147
4148        // Get context around message index 2 (the middle one)
4149        let (before, after) = db
4150            .get_context_messages(&session.id, 2, 1)
4151            .expect("get context");
4152
4153        assert_eq!(before.len(), 1, "Should have 1 message before");
4154        assert_eq!(after.len(), 1, "Should have 1 message after");
4155        assert_eq!(before[0].index, 1, "Before message should be index 1");
4156        assert_eq!(after[0].index, 3, "After message should be index 3");
4157    }
4158
4159    #[test]
4160    fn test_get_context_messages_at_start() {
4161        let (db, _dir) = create_test_db();
4162
4163        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4164        db.insert_session(&session).expect("insert session");
4165
4166        for i in 0..3 {
4167            let msg =
4168                create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
4169            db.insert_message(&msg).expect("insert message");
4170        }
4171
4172        // Get context around first message (index 0)
4173        let (before, after) = db
4174            .get_context_messages(&session.id, 0, 2)
4175            .expect("get context");
4176
4177        assert!(
4178            before.is_empty(),
4179            "Should have no messages before first message"
4180        );
4181        assert_eq!(after.len(), 2, "Should have 2 messages after");
4182    }
4183
4184    #[test]
4185    fn test_get_context_messages_at_end() {
4186        let (db, _dir) = create_test_db();
4187
4188        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4189        db.insert_session(&session).expect("insert session");
4190
4191        for i in 0..3 {
4192            let msg =
4193                create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
4194            db.insert_message(&msg).expect("insert message");
4195        }
4196
4197        // Get context around last message (index 2)
4198        let (before, after) = db
4199            .get_context_messages(&session.id, 2, 2)
4200            .expect("get context");
4201
4202        assert_eq!(before.len(), 2, "Should have 2 messages before");
4203        assert!(
4204            after.is_empty(),
4205            "Should have no messages after last message"
4206        );
4207    }
4208
4209    #[test]
4210    fn test_search_combined_filters() {
4211        let (db, _dir) = create_test_db();
4212
4213        let session1 = Session {
4214            id: Uuid::new_v4(),
4215            tool: "claude-code".to_string(),
4216            tool_version: None,
4217            started_at: Utc::now(),
4218            ended_at: None,
4219            model: None,
4220            working_directory: "/home/user/myapp".to_string(),
4221            git_branch: Some("feat/api".to_string()),
4222            source_path: None,
4223            message_count: 1,
4224            machine_id: None,
4225        };
4226        let session2 = Session {
4227            id: Uuid::new_v4(),
4228            tool: "aider".to_string(),
4229            tool_version: None,
4230            started_at: Utc::now(),
4231            ended_at: None,
4232            model: None,
4233            working_directory: "/home/user/myapp".to_string(),
4234            git_branch: Some("feat/api".to_string()),
4235            source_path: None,
4236            message_count: 1,
4237            machine_id: None,
4238        };
4239
4240        db.insert_session(&session1).expect("insert session1");
4241        db.insert_session(&session2).expect("insert session2");
4242
4243        let msg1 =
4244            create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
4245        let msg2 =
4246            create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
4247
4248        db.insert_message(&msg1).expect("insert msg1");
4249        db.insert_message(&msg2).expect("insert msg2");
4250
4251        // Search with multiple filters
4252        let options = SearchOptions {
4253            query: "API".to_string(),
4254            limit: 10,
4255            tool: Some("claude-code".to_string()),
4256            branch: Some("api".to_string()),
4257            project: Some("myapp".to_string()),
4258            ..Default::default()
4259        };
4260        let results = db.search_with_options(&options).expect("search");
4261
4262        // Results may include both message content match and metadata match from same session
4263        assert!(
4264            !results.is_empty(),
4265            "Should find at least 1 result matching all filters"
4266        );
4267        // All results should be from claude-code (the filtered tool)
4268        for result in &results {
4269            assert_eq!(
4270                result.tool, "claude-code",
4271                "All results should be from claude-code"
4272            );
4273        }
4274    }
4275
4276    // ==================== Session Deletion Tests ====================
4277
4278    #[test]
4279    fn test_delete_session_removes_all_data() {
4280        let (db, _dir) = create_test_db();
4281
4282        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4283        db.insert_session(&session).expect("insert session");
4284
4285        // Add messages
4286        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
4287        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there");
4288        db.insert_message(&msg1).expect("insert msg1");
4289        db.insert_message(&msg2).expect("insert msg2");
4290
4291        // Add a link
4292        let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
4293        db.insert_link(&link).expect("insert link");
4294
4295        // Verify data exists
4296        assert_eq!(db.session_count().expect("count"), 1);
4297        assert_eq!(db.message_count().expect("count"), 2);
4298        assert_eq!(db.link_count().expect("count"), 1);
4299
4300        // Delete the session
4301        let (msgs_deleted, links_deleted) = db.delete_session(&session.id).expect("delete");
4302        assert_eq!(msgs_deleted, 2, "Should delete 2 messages");
4303        assert_eq!(links_deleted, 1, "Should delete 1 link");
4304
4305        // Verify all data is gone
4306        assert_eq!(db.session_count().expect("count"), 0);
4307        assert_eq!(db.message_count().expect("count"), 0);
4308        assert_eq!(db.link_count().expect("count"), 0);
4309        assert!(db.get_session(&session.id).expect("get").is_none());
4310    }
4311
4312    #[test]
4313    fn test_delete_session_preserves_other_sessions() {
4314        let (db, _dir) = create_test_db();
4315
4316        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
4317        let session2 = create_test_session("aider", "/project2", Utc::now(), None);
4318
4319        db.insert_session(&session1).expect("insert session1");
4320        db.insert_session(&session2).expect("insert session2");
4321
4322        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello 1");
4323        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello 2");
4324        db.insert_message(&msg1).expect("insert msg1");
4325        db.insert_message(&msg2).expect("insert msg2");
4326
4327        // Delete only session1
4328        db.delete_session(&session1.id).expect("delete");
4329
4330        // Verify session2 still exists
4331        assert_eq!(db.session_count().expect("count"), 1);
4332        assert_eq!(db.message_count().expect("count"), 1);
4333        assert!(db.get_session(&session2.id).expect("get").is_some());
4334    }
4335
4336    // ==================== Database Maintenance Tests ====================
4337
4338    #[test]
4339    fn test_file_size() {
4340        let (db, _dir) = create_test_db();
4341
4342        let size = db.file_size().expect("get size");
4343        assert!(size.is_some(), "Should have file size for file-based db");
4344        assert!(size.unwrap() > 0, "Database file should have size > 0");
4345    }
4346
4347    #[test]
4348    fn test_vacuum() {
4349        let (db, _dir) = create_test_db();
4350
4351        // Just verify vacuum runs without error
4352        db.vacuum().expect("vacuum should succeed");
4353    }
4354
4355    #[test]
4356    fn test_count_sessions_older_than() {
4357        let (db, _dir) = create_test_db();
4358        let now = Utc::now();
4359
4360        // Create sessions at different times
4361        let old_session =
4362            create_test_session("claude-code", "/project1", now - Duration::days(100), None);
4363        let recent_session =
4364            create_test_session("claude-code", "/project2", now - Duration::days(10), None);
4365
4366        db.insert_session(&old_session).expect("insert old");
4367        db.insert_session(&recent_session).expect("insert recent");
4368
4369        // Count sessions older than 30 days
4370        let cutoff = now - Duration::days(30);
4371        let count = db.count_sessions_older_than(cutoff).expect("count");
4372        assert_eq!(count, 1, "Should find 1 session older than 30 days");
4373
4374        // Count sessions older than 200 days
4375        let old_cutoff = now - Duration::days(200);
4376        let old_count = db.count_sessions_older_than(old_cutoff).expect("count");
4377        assert_eq!(old_count, 0, "Should find 0 sessions older than 200 days");
4378    }
4379
4380    #[test]
4381    fn test_delete_sessions_older_than() {
4382        let (db, _dir) = create_test_db();
4383        let now = Utc::now();
4384
4385        // Create sessions at different times
4386        let old_session =
4387            create_test_session("claude-code", "/project1", now - Duration::days(100), None);
4388        let recent_session =
4389            create_test_session("claude-code", "/project2", now - Duration::days(10), None);
4390
4391        db.insert_session(&old_session).expect("insert old");
4392        db.insert_session(&recent_session).expect("insert recent");
4393
4394        // Add messages to both
4395        let msg1 = create_test_message(old_session.id, 0, MessageRole::User, "Old message");
4396        let msg2 = create_test_message(recent_session.id, 0, MessageRole::User, "Recent message");
4397        db.insert_message(&msg1).expect("insert msg1");
4398        db.insert_message(&msg2).expect("insert msg2");
4399
4400        // Delete sessions older than 30 days
4401        let cutoff = now - Duration::days(30);
4402        let deleted = db.delete_sessions_older_than(cutoff).expect("delete");
4403        assert_eq!(deleted, 1, "Should delete 1 session");
4404
4405        // Verify only recent session remains
4406        assert_eq!(db.session_count().expect("count"), 1);
4407        assert!(db.get_session(&recent_session.id).expect("get").is_some());
4408        assert!(db.get_session(&old_session.id).expect("get").is_none());
4409
4410        // Verify messages were also deleted
4411        assert_eq!(db.message_count().expect("count"), 1);
4412    }
4413
4414    #[test]
4415    fn test_get_sessions_older_than() {
4416        let (db, _dir) = create_test_db();
4417        let now = Utc::now();
4418
4419        // Create sessions at different times
4420        let old_session = create_test_session(
4421            "claude-code",
4422            "/project/old",
4423            now - Duration::days(100),
4424            None,
4425        );
4426        let medium_session =
4427            create_test_session("aider", "/project/medium", now - Duration::days(50), None);
4428        let recent_session =
4429            create_test_session("gemini", "/project/recent", now - Duration::days(10), None);
4430
4431        db.insert_session(&old_session).expect("insert old");
4432        db.insert_session(&medium_session).expect("insert medium");
4433        db.insert_session(&recent_session).expect("insert recent");
4434
4435        // Get sessions older than 30 days
4436        let cutoff = now - Duration::days(30);
4437        let sessions = db.get_sessions_older_than(cutoff).expect("get sessions");
4438        assert_eq!(
4439            sessions.len(),
4440            2,
4441            "Should find 2 sessions older than 30 days"
4442        );
4443
4444        // Verify sessions are ordered by start date (oldest first)
4445        assert_eq!(sessions[0].id, old_session.id);
4446        assert_eq!(sessions[1].id, medium_session.id);
4447
4448        // Verify session data is returned correctly
4449        assert_eq!(sessions[0].tool, "claude-code");
4450        assert_eq!(sessions[0].working_directory, "/project/old");
4451        assert_eq!(sessions[1].tool, "aider");
4452        assert_eq!(sessions[1].working_directory, "/project/medium");
4453
4454        // Get sessions older than 200 days
4455        let old_cutoff = now - Duration::days(200);
4456        let old_sessions = db
4457            .get_sessions_older_than(old_cutoff)
4458            .expect("get old sessions");
4459        assert_eq!(
4460            old_sessions.len(),
4461            0,
4462            "Should find 0 sessions older than 200 days"
4463        );
4464    }
4465
4466    #[test]
4467    fn test_stats() {
4468        let (db, _dir) = create_test_db();
4469        let now = Utc::now();
4470
4471        // Empty database stats
4472        let empty_stats = db.stats().expect("stats");
4473        assert_eq!(empty_stats.session_count, 0);
4474        assert_eq!(empty_stats.message_count, 0);
4475        assert_eq!(empty_stats.link_count, 0);
4476        assert!(empty_stats.oldest_session.is_none());
4477        assert!(empty_stats.newest_session.is_none());
4478        assert!(empty_stats.sessions_by_tool.is_empty());
4479
4480        // Add some data
4481        let session1 =
4482            create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
4483        let session2 = create_test_session("aider", "/project2", now - Duration::hours(1), None);
4484        let session3 = create_test_session("claude-code", "/project3", now, None);
4485
4486        db.insert_session(&session1).expect("insert 1");
4487        db.insert_session(&session2).expect("insert 2");
4488        db.insert_session(&session3).expect("insert 3");
4489
4490        let msg = create_test_message(session1.id, 0, MessageRole::User, "Hello");
4491        db.insert_message(&msg).expect("insert msg");
4492
4493        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
4494        db.insert_link(&link).expect("insert link");
4495
4496        // Check stats
4497        let stats = db.stats().expect("stats");
4498        assert_eq!(stats.session_count, 3);
4499        assert_eq!(stats.message_count, 1);
4500        assert_eq!(stats.link_count, 1);
4501        assert!(stats.oldest_session.is_some());
4502        assert!(stats.newest_session.is_some());
4503
4504        // Check sessions by tool
4505        assert_eq!(stats.sessions_by_tool.len(), 2);
4506        // claude-code should come first (most sessions)
4507        assert_eq!(stats.sessions_by_tool[0].0, "claude-code");
4508        assert_eq!(stats.sessions_by_tool[0].1, 2);
4509        assert_eq!(stats.sessions_by_tool[1].0, "aider");
4510        assert_eq!(stats.sessions_by_tool[1].1, 1);
4511    }
4512
4513    // ==================== Branch History Tests ====================
4514
4515    #[test]
4516    fn test_get_session_branch_history_no_messages() {
4517        let (db, _dir) = create_test_db();
4518        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4519        db.insert_session(&session)
4520            .expect("Failed to insert session");
4521
4522        let branches = db
4523            .get_session_branch_history(session.id)
4524            .expect("Failed to get branch history");
4525
4526        assert!(branches.is_empty(), "Empty session should have no branches");
4527    }
4528
4529    #[test]
4530    fn test_get_session_branch_history_single_branch() {
4531        let (db, _dir) = create_test_db();
4532        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4533        db.insert_session(&session)
4534            .expect("Failed to insert session");
4535
4536        // Insert messages all on the same branch
4537        for i in 0..3 {
4538            let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
4539            msg.git_branch = Some("main".to_string());
4540            db.insert_message(&msg).expect("Failed to insert message");
4541        }
4542
4543        let branches = db
4544            .get_session_branch_history(session.id)
4545            .expect("Failed to get branch history");
4546
4547        assert_eq!(branches, vec!["main"], "Should have single branch");
4548    }
4549
4550    #[test]
4551    fn test_get_session_branch_history_multiple_branches() {
4552        let (db, _dir) = create_test_db();
4553        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4554        db.insert_session(&session)
4555            .expect("Failed to insert session");
4556
4557        // Insert messages with branch transitions: main -> feat/auth -> main
4558        let branch_sequence = ["main", "main", "feat/auth", "feat/auth", "main"];
4559        for (i, branch) in branch_sequence.iter().enumerate() {
4560            let mut msg = create_test_message(session.id, i as i32, MessageRole::User, "test");
4561            msg.git_branch = Some(branch.to_string());
4562            db.insert_message(&msg).expect("Failed to insert message");
4563        }
4564
4565        let branches = db
4566            .get_session_branch_history(session.id)
4567            .expect("Failed to get branch history");
4568
4569        assert_eq!(
4570            branches,
4571            vec!["main", "feat/auth", "main"],
4572            "Should show branch transitions without consecutive duplicates"
4573        );
4574    }
4575
4576    #[test]
4577    fn test_get_session_branch_history_with_none_branches() {
4578        let (db, _dir) = create_test_db();
4579        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4580        db.insert_session(&session)
4581            .expect("Failed to insert session");
4582
4583        // Insert messages with a mix of Some and None branches
4584        let mut msg1 = create_test_message(session.id, 0, MessageRole::User, "test");
4585        msg1.git_branch = Some("main".to_string());
4586        db.insert_message(&msg1).expect("Failed to insert message");
4587
4588        let mut msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "test");
4589        msg2.git_branch = None; // No branch info
4590        db.insert_message(&msg2).expect("Failed to insert message");
4591
4592        let mut msg3 = create_test_message(session.id, 2, MessageRole::User, "test");
4593        msg3.git_branch = Some("feat/new".to_string());
4594        db.insert_message(&msg3).expect("Failed to insert message");
4595
4596        let branches = db
4597            .get_session_branch_history(session.id)
4598            .expect("Failed to get branch history");
4599
4600        assert_eq!(
4601            branches,
4602            vec!["main", "feat/new"],
4603            "Should skip None branches and show transitions"
4604        );
4605    }
4606
4607    #[test]
4608    fn test_get_session_branch_history_all_none_branches() {
4609        let (db, _dir) = create_test_db();
4610        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4611        db.insert_session(&session)
4612            .expect("Failed to insert session");
4613
4614        // Insert messages with no branch info
4615        for i in 0..3 {
4616            let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
4617            msg.git_branch = None;
4618            db.insert_message(&msg).expect("Failed to insert message");
4619        }
4620
4621        let branches = db
4622            .get_session_branch_history(session.id)
4623            .expect("Failed to get branch history");
4624
4625        assert!(
4626            branches.is_empty(),
4627            "Session with all None branches should return empty"
4628        );
4629    }
4630
4631    // ==================== Machine ID Tests ====================
4632
4633    #[test]
4634    fn test_session_stores_machine_id() {
4635        let (db, _dir) = create_test_db();
4636        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4637
4638        db.insert_session(&session)
4639            .expect("Failed to insert session");
4640
4641        let retrieved = db
4642            .get_session(&session.id)
4643            .expect("Failed to get session")
4644            .expect("Session should exist");
4645
4646        assert_eq!(
4647            retrieved.machine_id,
4648            Some("test-machine".to_string()),
4649            "Machine ID should be preserved"
4650        );
4651    }
4652
4653    #[test]
4654    fn test_session_with_none_machine_id() {
4655        let (db, _dir) = create_test_db();
4656        let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
4657        session.machine_id = None;
4658
4659        db.insert_session(&session)
4660            .expect("Failed to insert session");
4661
4662        let retrieved = db
4663            .get_session(&session.id)
4664            .expect("Failed to get session")
4665            .expect("Session should exist");
4666
4667        assert!(
4668            retrieved.machine_id.is_none(),
4669            "Session with None machine_id should preserve None"
4670        );
4671    }
4672
4673    #[test]
4674    fn test_migration_adds_machine_id_column() {
4675        // Create a database and verify the machine_id column works
4676        let (db, _dir) = create_test_db();
4677
4678        // Insert a session with machine_id to confirm the column exists
4679        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4680        db.insert_session(&session)
4681            .expect("Should insert session with machine_id column");
4682
4683        // Retrieve and verify
4684        let retrieved = db
4685            .get_session(&session.id)
4686            .expect("Failed to get session")
4687            .expect("Session should exist");
4688
4689        assert_eq!(
4690            retrieved.machine_id,
4691            Some("test-machine".to_string()),
4692            "Machine ID should be stored and retrieved"
4693        );
4694    }
4695
4696    #[test]
4697    fn test_list_sessions_includes_machine_id() {
4698        let (db, _dir) = create_test_db();
4699        let now = Utc::now();
4700
4701        let mut session1 = create_test_session("claude-code", "/project1", now, None);
4702        session1.machine_id = Some("machine-a".to_string());
4703
4704        let mut session2 = create_test_session("claude-code", "/project2", now, None);
4705        session2.machine_id = Some("machine-b".to_string());
4706
4707        db.insert_session(&session1).expect("insert");
4708        db.insert_session(&session2).expect("insert");
4709
4710        let sessions = db.list_sessions(10, None).expect("list");
4711
4712        assert_eq!(sessions.len(), 2);
4713        let machine_ids: Vec<Option<String>> =
4714            sessions.iter().map(|s| s.machine_id.clone()).collect();
4715        assert!(machine_ids.contains(&Some("machine-a".to_string())));
4716        assert!(machine_ids.contains(&Some("machine-b".to_string())));
4717    }
4718
4719    // ==================== Annotation Tests ====================
4720
4721    #[test]
4722    fn test_insert_and_get_annotations() {
4723        let (db, _dir) = create_test_db();
4724        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4725        db.insert_session(&session).expect("insert session");
4726
4727        let annotation = Annotation {
4728            id: Uuid::new_v4(),
4729            session_id: session.id,
4730            content: "This is a test note".to_string(),
4731            created_at: Utc::now(),
4732        };
4733        db.insert_annotation(&annotation)
4734            .expect("insert annotation");
4735
4736        let annotations = db.get_annotations(&session.id).expect("get annotations");
4737        assert_eq!(annotations.len(), 1);
4738        assert_eq!(annotations[0].content, "This is a test note");
4739        assert_eq!(annotations[0].session_id, session.id);
4740    }
4741
4742    #[test]
4743    fn test_delete_annotation() {
4744        let (db, _dir) = create_test_db();
4745        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4746        db.insert_session(&session).expect("insert session");
4747
4748        let annotation = Annotation {
4749            id: Uuid::new_v4(),
4750            session_id: session.id,
4751            content: "Test annotation".to_string(),
4752            created_at: Utc::now(),
4753        };
4754        db.insert_annotation(&annotation).expect("insert");
4755
4756        let deleted = db.delete_annotation(&annotation.id).expect("delete");
4757        assert!(deleted);
4758
4759        let annotations = db.get_annotations(&session.id).expect("get");
4760        assert!(annotations.is_empty());
4761    }
4762
4763    #[test]
4764    fn test_delete_annotations_by_session() {
4765        let (db, _dir) = create_test_db();
4766        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4767        db.insert_session(&session).expect("insert session");
4768
4769        for i in 0..3 {
4770            let annotation = Annotation {
4771                id: Uuid::new_v4(),
4772                session_id: session.id,
4773                content: format!("Annotation {i}"),
4774                created_at: Utc::now(),
4775            };
4776            db.insert_annotation(&annotation).expect("insert");
4777        }
4778
4779        let count = db
4780            .delete_annotations_by_session(&session.id)
4781            .expect("delete all");
4782        assert_eq!(count, 3);
4783
4784        let annotations = db.get_annotations(&session.id).expect("get");
4785        assert!(annotations.is_empty());
4786    }
4787
4788    // ==================== Tag Tests ====================
4789
4790    #[test]
4791    fn test_insert_and_get_tags() {
4792        let (db, _dir) = create_test_db();
4793        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4794        db.insert_session(&session).expect("insert session");
4795
4796        let tag = Tag {
4797            id: Uuid::new_v4(),
4798            session_id: session.id,
4799            label: "bug-fix".to_string(),
4800            created_at: Utc::now(),
4801        };
4802        db.insert_tag(&tag).expect("insert tag");
4803
4804        let tags = db.get_tags(&session.id).expect("get tags");
4805        assert_eq!(tags.len(), 1);
4806        assert_eq!(tags[0].label, "bug-fix");
4807    }
4808
4809    #[test]
4810    fn test_tag_exists() {
4811        let (db, _dir) = create_test_db();
4812        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4813        db.insert_session(&session).expect("insert session");
4814
4815        assert!(!db.tag_exists(&session.id, "bug-fix").expect("check"));
4816
4817        let tag = Tag {
4818            id: Uuid::new_v4(),
4819            session_id: session.id,
4820            label: "bug-fix".to_string(),
4821            created_at: Utc::now(),
4822        };
4823        db.insert_tag(&tag).expect("insert tag");
4824
4825        assert!(db.tag_exists(&session.id, "bug-fix").expect("check"));
4826        assert!(!db.tag_exists(&session.id, "feature").expect("check other"));
4827    }
4828
4829    #[test]
4830    fn test_delete_tag() {
4831        let (db, _dir) = create_test_db();
4832        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4833        db.insert_session(&session).expect("insert session");
4834
4835        let tag = Tag {
4836            id: Uuid::new_v4(),
4837            session_id: session.id,
4838            label: "wip".to_string(),
4839            created_at: Utc::now(),
4840        };
4841        db.insert_tag(&tag).expect("insert tag");
4842
4843        let deleted = db.delete_tag(&session.id, "wip").expect("delete");
4844        assert!(deleted);
4845
4846        let deleted_again = db.delete_tag(&session.id, "wip").expect("delete again");
4847        assert!(!deleted_again);
4848    }
4849
4850    #[test]
4851    fn test_list_sessions_with_tag() {
4852        let (db, _dir) = create_test_db();
4853        let now = Utc::now();
4854
4855        let session1 = create_test_session("claude-code", "/project1", now, None);
4856        let session2 =
4857            create_test_session("claude-code", "/project2", now - Duration::minutes(5), None);
4858        let session3 = create_test_session(
4859            "claude-code",
4860            "/project3",
4861            now - Duration::minutes(10),
4862            None,
4863        );
4864
4865        db.insert_session(&session1).expect("insert");
4866        db.insert_session(&session2).expect("insert");
4867        db.insert_session(&session3).expect("insert");
4868
4869        // Tag session1 and session3 with "feature"
4870        let tag1 = Tag {
4871            id: Uuid::new_v4(),
4872            session_id: session1.id,
4873            label: "feature".to_string(),
4874            created_at: Utc::now(),
4875        };
4876        let tag3 = Tag {
4877            id: Uuid::new_v4(),
4878            session_id: session3.id,
4879            label: "feature".to_string(),
4880            created_at: Utc::now(),
4881        };
4882        db.insert_tag(&tag1).expect("insert tag");
4883        db.insert_tag(&tag3).expect("insert tag");
4884
4885        let sessions = db.list_sessions_with_tag("feature", 10).expect("list");
4886        assert_eq!(sessions.len(), 2);
4887        // Should be ordered by start time descending
4888        assert_eq!(sessions[0].id, session1.id);
4889        assert_eq!(sessions[1].id, session3.id);
4890
4891        let sessions = db.list_sessions_with_tag("nonexistent", 10).expect("list");
4892        assert!(sessions.is_empty());
4893    }
4894
4895    #[test]
4896    fn test_get_most_recent_session_for_directory() {
4897        let (db, _dir) = create_test_db();
4898        let now = Utc::now();
4899
4900        let session1 = create_test_session(
4901            "claude-code",
4902            "/home/user/project",
4903            now - Duration::hours(1),
4904            None,
4905        );
4906        let session2 = create_test_session("claude-code", "/home/user/project", now, None);
4907        let session3 = create_test_session("claude-code", "/home/user/other", now, None);
4908
4909        db.insert_session(&session1).expect("insert");
4910        db.insert_session(&session2).expect("insert");
4911        db.insert_session(&session3).expect("insert");
4912
4913        let result = db
4914            .get_most_recent_session_for_directory("/home/user/project")
4915            .expect("get");
4916        assert!(result.is_some());
4917        assert_eq!(result.unwrap().id, session2.id);
4918
4919        let result = db
4920            .get_most_recent_session_for_directory("/home/user/nonexistent")
4921            .expect("get");
4922        assert!(result.is_none());
4923    }
4924
4925    #[test]
4926    fn test_session_deletion_removes_annotations_and_tags() {
4927        let (db, _dir) = create_test_db();
4928        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4929        db.insert_session(&session).expect("insert session");
4930
4931        // Add annotation
4932        let annotation = Annotation {
4933            id: Uuid::new_v4(),
4934            session_id: session.id,
4935            content: "Test annotation".to_string(),
4936            created_at: Utc::now(),
4937        };
4938        db.insert_annotation(&annotation).expect("insert");
4939
4940        // Add tag
4941        let tag = Tag {
4942            id: Uuid::new_v4(),
4943            session_id: session.id,
4944            label: "test-tag".to_string(),
4945            created_at: Utc::now(),
4946        };
4947        db.insert_tag(&tag).expect("insert");
4948
4949        // Delete the session
4950        db.delete_session(&session.id).expect("delete");
4951
4952        // Verify annotations and tags are gone
4953        let annotations = db.get_annotations(&session.id).expect("get");
4954        assert!(annotations.is_empty());
4955
4956        let tags = db.get_tags(&session.id).expect("get");
4957        assert!(tags.is_empty());
4958    }
4959
4960    #[test]
4961    fn test_insert_and_get_summary() {
4962        let (db, _dir) = create_test_db();
4963        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4964        db.insert_session(&session).expect("insert session");
4965
4966        let summary = Summary {
4967            id: Uuid::new_v4(),
4968            session_id: session.id,
4969            content: "Test summary content".to_string(),
4970            generated_at: Utc::now(),
4971        };
4972        db.insert_summary(&summary).expect("insert summary");
4973
4974        let retrieved = db.get_summary(&session.id).expect("get summary");
4975        assert!(retrieved.is_some());
4976        let retrieved = retrieved.unwrap();
4977        assert_eq!(retrieved.content, "Test summary content");
4978        assert_eq!(retrieved.session_id, session.id);
4979    }
4980
4981    #[test]
4982    fn test_get_summary_nonexistent() {
4983        let (db, _dir) = create_test_db();
4984        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4985        db.insert_session(&session).expect("insert session");
4986
4987        let retrieved = db.get_summary(&session.id).expect("get summary");
4988        assert!(retrieved.is_none());
4989    }
4990
4991    #[test]
4992    fn test_update_summary() {
4993        let (db, _dir) = create_test_db();
4994        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
4995        db.insert_session(&session).expect("insert session");
4996
4997        let summary = Summary {
4998            id: Uuid::new_v4(),
4999            session_id: session.id,
5000            content: "Original content".to_string(),
5001            generated_at: Utc::now(),
5002        };
5003        db.insert_summary(&summary).expect("insert summary");
5004
5005        // Update the summary
5006        let updated = db
5007            .update_summary(&session.id, "Updated content")
5008            .expect("update summary");
5009        assert!(updated);
5010
5011        let retrieved = db.get_summary(&session.id).expect("get summary");
5012        assert!(retrieved.is_some());
5013        assert_eq!(retrieved.unwrap().content, "Updated content");
5014    }
5015
5016    #[test]
5017    fn test_update_summary_nonexistent() {
5018        let (db, _dir) = create_test_db();
5019        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
5020        db.insert_session(&session).expect("insert session");
5021
5022        // Try to update a summary that does not exist
5023        let updated = db
5024            .update_summary(&session.id, "New content")
5025            .expect("update summary");
5026        assert!(!updated);
5027    }
5028
5029    #[test]
5030    fn test_delete_summary() {
5031        let (db, _dir) = create_test_db();
5032        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
5033        db.insert_session(&session).expect("insert session");
5034
5035        let summary = Summary {
5036            id: Uuid::new_v4(),
5037            session_id: session.id,
5038            content: "To be deleted".to_string(),
5039            generated_at: Utc::now(),
5040        };
5041        db.insert_summary(&summary).expect("insert summary");
5042
5043        // Delete the summary
5044        let deleted = db.delete_summary(&session.id).expect("delete summary");
5045        assert!(deleted);
5046
5047        // Verify it's gone
5048        let retrieved = db.get_summary(&session.id).expect("get summary");
5049        assert!(retrieved.is_none());
5050    }
5051
5052    #[test]
5053    fn test_delete_session_removes_summary() {
5054        let (db, _dir) = create_test_db();
5055        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
5056        db.insert_session(&session).expect("insert session");
5057
5058        let summary = Summary {
5059            id: Uuid::new_v4(),
5060            session_id: session.id,
5061            content: "Session summary".to_string(),
5062            generated_at: Utc::now(),
5063        };
5064        db.insert_summary(&summary).expect("insert summary");
5065
5066        // Delete the session
5067        db.delete_session(&session.id).expect("delete session");
5068
5069        // Verify summary is also deleted
5070        let retrieved = db.get_summary(&session.id).expect("get summary");
5071        assert!(retrieved.is_none());
5072    }
5073
5074    // ==================== Machine Tests ====================
5075
5076    #[test]
5077    fn test_upsert_machine_insert() {
5078        let (db, _dir) = create_test_db();
5079
5080        let machine = Machine {
5081            id: "test-uuid-1234".to_string(),
5082            name: "my-laptop".to_string(),
5083            created_at: Utc::now().to_rfc3339(),
5084        };
5085
5086        db.upsert_machine(&machine)
5087            .expect("Failed to upsert machine");
5088
5089        let retrieved = db
5090            .get_machine("test-uuid-1234")
5091            .expect("Failed to get machine")
5092            .expect("Machine should exist");
5093
5094        assert_eq!(retrieved.id, "test-uuid-1234");
5095        assert_eq!(retrieved.name, "my-laptop");
5096    }
5097
5098    #[test]
5099    fn test_upsert_machine_update() {
5100        let (db, _dir) = create_test_db();
5101
5102        // Insert initial machine
5103        let machine1 = Machine {
5104            id: "test-uuid-5678".to_string(),
5105            name: "old-name".to_string(),
5106            created_at: Utc::now().to_rfc3339(),
5107        };
5108        db.upsert_machine(&machine1)
5109            .expect("Failed to upsert machine");
5110
5111        // Update with new name
5112        let machine2 = Machine {
5113            id: "test-uuid-5678".to_string(),
5114            name: "new-name".to_string(),
5115            created_at: Utc::now().to_rfc3339(),
5116        };
5117        db.upsert_machine(&machine2)
5118            .expect("Failed to upsert machine");
5119
5120        // Verify name was updated
5121        let retrieved = db
5122            .get_machine("test-uuid-5678")
5123            .expect("Failed to get machine")
5124            .expect("Machine should exist");
5125
5126        assert_eq!(retrieved.name, "new-name");
5127    }
5128
5129    #[test]
5130    fn test_get_machine() {
5131        let (db, _dir) = create_test_db();
5132
5133        // Machine does not exist initially
5134        let not_found = db.get_machine("nonexistent-uuid").expect("Failed to query");
5135        assert!(not_found.is_none(), "Machine should not exist");
5136
5137        // Insert a machine
5138        let machine = Machine {
5139            id: "existing-uuid".to_string(),
5140            name: "test-machine".to_string(),
5141            created_at: Utc::now().to_rfc3339(),
5142        };
5143        db.upsert_machine(&machine).expect("Failed to upsert");
5144
5145        // Now it should be found
5146        let found = db
5147            .get_machine("existing-uuid")
5148            .expect("Failed to query")
5149            .expect("Machine should exist");
5150
5151        assert_eq!(found.id, "existing-uuid");
5152        assert_eq!(found.name, "test-machine");
5153    }
5154
5155    #[test]
5156    fn test_get_machine_name_found() {
5157        let (db, _dir) = create_test_db();
5158
5159        let machine = Machine {
5160            id: "uuid-for-name-test".to_string(),
5161            name: "my-workstation".to_string(),
5162            created_at: Utc::now().to_rfc3339(),
5163        };
5164        db.upsert_machine(&machine).expect("Failed to upsert");
5165
5166        let name = db
5167            .get_machine_name("uuid-for-name-test")
5168            .expect("Failed to get name");
5169
5170        assert_eq!(name, "my-workstation");
5171    }
5172
5173    #[test]
5174    fn test_get_machine_name_not_found() {
5175        let (db, _dir) = create_test_db();
5176
5177        // Machine does not exist, should return truncated UUID
5178        let name = db
5179            .get_machine_name("abc123def456789")
5180            .expect("Failed to get name");
5181
5182        assert_eq!(name, "abc123de", "Should return first 8 characters");
5183
5184        // Test with short ID
5185        let short_name = db.get_machine_name("short").expect("Failed to get name");
5186
5187        assert_eq!(
5188            short_name, "short",
5189            "Should return full ID if shorter than 8 chars"
5190        );
5191    }
5192
5193    #[test]
5194    fn test_list_machines() {
5195        let (db, _dir) = create_test_db();
5196
5197        // Initially empty
5198        let machines = db.list_machines().expect("Failed to list");
5199        assert!(machines.is_empty(), "Should have no machines initially");
5200
5201        // Add machines
5202        let machine1 = Machine {
5203            id: "uuid-1".to_string(),
5204            name: "machine-1".to_string(),
5205            created_at: "2024-01-01T00:00:00Z".to_string(),
5206        };
5207        let machine2 = Machine {
5208            id: "uuid-2".to_string(),
5209            name: "machine-2".to_string(),
5210            created_at: "2024-01-02T00:00:00Z".to_string(),
5211        };
5212
5213        db.upsert_machine(&machine1).expect("Failed to upsert");
5214        db.upsert_machine(&machine2).expect("Failed to upsert");
5215
5216        // List should return both machines
5217        let machines = db.list_machines().expect("Failed to list");
5218        assert_eq!(machines.len(), 2, "Should have 2 machines");
5219
5220        // Should be ordered by created_at (oldest first)
5221        assert_eq!(machines[0].id, "uuid-1");
5222        assert_eq!(machines[1].id, "uuid-2");
5223    }
5224
5225    // ==================== Session ID Prefix Lookup Tests ====================
5226
5227    #[test]
5228    fn test_find_session_by_id_prefix_full_uuid() {
5229        let (db, _dir) = create_test_db();
5230        let session = create_test_session("claude-code", "/project", Utc::now(), None);
5231        db.insert_session(&session).expect("insert session");
5232
5233        // Find by full UUID string
5234        let found = db
5235            .find_session_by_id_prefix(&session.id.to_string())
5236            .expect("find session")
5237            .expect("session should exist");
5238
5239        assert_eq!(found.id, session.id, "Should find session by full UUID");
5240    }
5241
5242    #[test]
5243    fn test_find_session_by_id_prefix_short_prefix() {
5244        let (db, _dir) = create_test_db();
5245        let session = create_test_session("claude-code", "/project", Utc::now(), None);
5246        db.insert_session(&session).expect("insert session");
5247
5248        // Get a short prefix (first 8 characters)
5249        let prefix = &session.id.to_string()[..8];
5250
5251        let found = db
5252            .find_session_by_id_prefix(prefix)
5253            .expect("find session")
5254            .expect("session should exist");
5255
5256        assert_eq!(found.id, session.id, "Should find session by short prefix");
5257    }
5258
5259    #[test]
5260    fn test_find_session_by_id_prefix_very_short_prefix() {
5261        let (db, _dir) = create_test_db();
5262        let session = create_test_session("claude-code", "/project", Utc::now(), None);
5263        db.insert_session(&session).expect("insert session");
5264
5265        // Get just the first 4 characters
5266        let prefix = &session.id.to_string()[..4];
5267
5268        let found = db
5269            .find_session_by_id_prefix(prefix)
5270            .expect("find session")
5271            .expect("session should exist");
5272
5273        assert_eq!(
5274            found.id, session.id,
5275            "Should find session by very short prefix"
5276        );
5277    }
5278
5279    #[test]
5280    fn test_find_session_by_id_prefix_not_found() {
5281        let (db, _dir) = create_test_db();
5282        let session = create_test_session("claude-code", "/project", Utc::now(), None);
5283        db.insert_session(&session).expect("insert session");
5284
5285        // Try to find with a non-matching prefix
5286        let found = db
5287            .find_session_by_id_prefix("zzz999")
5288            .expect("find session");
5289
5290        assert!(
5291            found.is_none(),
5292            "Should return None for non-matching prefix"
5293        );
5294    }
5295
5296    #[test]
5297    fn test_find_session_by_id_prefix_empty_db() {
5298        let (db, _dir) = create_test_db();
5299
5300        let found = db
5301            .find_session_by_id_prefix("abc123")
5302            .expect("find session");
5303
5304        assert!(found.is_none(), "Should return None for empty database");
5305    }
5306
5307    #[test]
5308    fn test_find_session_by_id_prefix_ambiguous() {
5309        let (db, _dir) = create_test_db();
5310
5311        // Create 100 sessions to increase chance of prefix collision
5312        let mut sessions = Vec::new();
5313        for _ in 0..100 {
5314            let session = create_test_session("claude-code", "/project", Utc::now(), None);
5315            db.insert_session(&session).expect("insert session");
5316            sessions.push(session);
5317        }
5318
5319        // Find two sessions that share a common prefix (first char)
5320        let first_session = &sessions[0];
5321        let first_char = first_session.id.to_string().chars().next().unwrap();
5322
5323        // Count how many sessions start with the same character
5324        let matching_count = sessions
5325            .iter()
5326            .filter(|s| s.id.to_string().starts_with(first_char))
5327            .count();
5328
5329        if matching_count > 1 {
5330            // If we have multiple sessions starting with same character,
5331            // a single-character prefix should return an ambiguity error
5332            let result = db.find_session_by_id_prefix(&first_char.to_string());
5333            assert!(
5334                result.is_err(),
5335                "Should return error for ambiguous single-character prefix"
5336            );
5337            let error_msg = result.unwrap_err().to_string();
5338            assert!(
5339                error_msg.contains("Ambiguous"),
5340                "Error should mention ambiguity"
5341            );
5342        }
5343    }
5344
5345    #[test]
5346    fn test_find_session_by_id_prefix_returns_correct_session_data() {
5347        let (db, _dir) = create_test_db();
5348
5349        let mut session =
5350            create_test_session("claude-code", "/home/user/myproject", Utc::now(), None);
5351        session.tool_version = Some("2.0.0".to_string());
5352        session.model = Some("claude-opus-4".to_string());
5353        session.git_branch = Some("feature/test".to_string());
5354        session.message_count = 42;
5355        db.insert_session(&session).expect("insert session");
5356
5357        // Find by prefix
5358        let prefix = &session.id.to_string()[..8];
5359        let found = db
5360            .find_session_by_id_prefix(prefix)
5361            .expect("find session")
5362            .expect("session should exist");
5363
5364        // Verify all fields are correctly returned
5365        assert_eq!(found.id, session.id);
5366        assert_eq!(found.tool, "claude-code");
5367        assert_eq!(found.tool_version, Some("2.0.0".to_string()));
5368        assert_eq!(found.model, Some("claude-opus-4".to_string()));
5369        assert_eq!(found.working_directory, "/home/user/myproject");
5370        assert_eq!(found.git_branch, Some("feature/test".to_string()));
5371        assert_eq!(found.message_count, 42);
5372    }
5373
5374    #[test]
5375    fn test_find_session_by_id_prefix_many_sessions() {
5376        let (db, _dir) = create_test_db();
5377
5378        // Insert many sessions (more than the old 100/1000 limits)
5379        let mut target_session = None;
5380        for i in 0..200 {
5381            let session =
5382                create_test_session("claude-code", &format!("/project/{i}"), Utc::now(), None);
5383            db.insert_session(&session).expect("insert session");
5384            // Save a session to search for later
5385            if i == 150 {
5386                target_session = Some(session);
5387            }
5388        }
5389
5390        let target = target_session.expect("should have target session");
5391        let prefix = &target.id.to_string()[..8];
5392
5393        // Should still find the session even with many sessions in the database
5394        let found = db
5395            .find_session_by_id_prefix(prefix)
5396            .expect("find session")
5397            .expect("session should exist");
5398
5399        assert_eq!(
5400            found.id, target.id,
5401            "Should find correct session among many"
5402        );
5403        assert_eq!(found.working_directory, "/project/150");
5404    }
5405
5406    #[test]
5407    fn test_import_session_with_messages() {
5408        let (mut db, _dir) = create_test_db();
5409
5410        let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
5411        let messages = vec![
5412            create_test_message(session.id, 0, MessageRole::User, "Hello"),
5413            create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!"),
5414            create_test_message(session.id, 2, MessageRole::User, "How are you?"),
5415        ];
5416
5417        let synced_at = Utc::now();
5418        db.import_session_with_messages(&session, &messages, Some(synced_at))
5419            .expect("Failed to import session with messages");
5420
5421        // Verify session was inserted
5422        let retrieved_session = db.get_session(&session.id).expect("Failed to get session");
5423        assert!(retrieved_session.is_some(), "Session should exist");
5424        let retrieved_session = retrieved_session.unwrap();
5425        assert_eq!(retrieved_session.tool, "claude-code");
5426
5427        // Verify messages were inserted
5428        let retrieved_messages = db
5429            .get_messages(&session.id)
5430            .expect("Failed to get messages");
5431        assert_eq!(retrieved_messages.len(), 3, "Should have 3 messages");
5432        assert_eq!(retrieved_messages[0].content.text(), "Hello");
5433        assert_eq!(retrieved_messages[1].content.text(), "Hi there!");
5434        assert_eq!(retrieved_messages[2].content.text(), "How are you?");
5435
5436        // Verify session is marked as synced
5437        let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5438        assert!(
5439            !unsynced.iter().any(|s| s.id == session.id),
5440            "Session should be marked as synced"
5441        );
5442    }
5443
5444    #[test]
5445    fn test_import_session_with_messages_no_sync() {
5446        let (mut db, _dir) = create_test_db();
5447
5448        let session = create_test_session("aider", "/tmp/test", Utc::now(), None);
5449        let messages = vec![create_test_message(
5450            session.id,
5451            0,
5452            MessageRole::User,
5453            "Test message",
5454        )];
5455
5456        // Import without marking as synced
5457        db.import_session_with_messages(&session, &messages, None)
5458            .expect("Failed to import session");
5459
5460        // Verify session is NOT synced
5461        let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5462        assert!(
5463            unsynced.iter().any(|s| s.id == session.id),
5464            "Session should NOT be marked as synced"
5465        );
5466    }
5467
5468    #[test]
5469    fn test_session_update_resets_sync_status() {
5470        let (db, _dir) = create_test_db();
5471
5472        // Create and insert a session
5473        let mut session =
5474            create_test_session("claude-code", "/home/user/project", Utc::now(), None);
5475        session.message_count = 5;
5476        db.insert_session(&session)
5477            .expect("Failed to insert session");
5478
5479        // Mark it as synced
5480        db.mark_sessions_synced(&[session.id], Utc::now())
5481            .expect("Failed to mark synced");
5482
5483        // Verify it's synced (not in unsynced list)
5484        let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5485        assert!(
5486            !unsynced.iter().any(|s| s.id == session.id),
5487            "Session should be synced initially"
5488        );
5489
5490        // Simulate session being continued with new messages
5491        session.message_count = 10;
5492        session.ended_at = Some(Utc::now());
5493        db.insert_session(&session)
5494            .expect("Failed to update session");
5495
5496        // Verify it's now marked as unsynced (needs re-sync)
5497        let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5498        assert!(
5499            unsynced.iter().any(|s| s.id == session.id),
5500            "Session should be marked for re-sync after update"
5501        );
5502
5503        // Verify the message count was updated
5504        let retrieved = db
5505            .get_session(&session.id)
5506            .expect("Failed to get session")
5507            .expect("Session should exist");
5508        assert_eq!(
5509            retrieved.message_count, 10,
5510            "Message count should be updated"
5511        );
5512    }
5513
5514    #[test]
5515    fn test_clear_sync_status_all_sessions() {
5516        let (db, _dir) = create_test_db();
5517
5518        // Create and insert multiple sessions
5519        let session1 = create_test_session("claude-code", "/home/user/project1", Utc::now(), None);
5520        let session2 = create_test_session("aider", "/home/user/project2", Utc::now(), None);
5521        let session3 = create_test_session("cline", "/home/user/project3", Utc::now(), None);
5522
5523        db.insert_session(&session1)
5524            .expect("Failed to insert session1");
5525        db.insert_session(&session2)
5526            .expect("Failed to insert session2");
5527        db.insert_session(&session3)
5528            .expect("Failed to insert session3");
5529
5530        // Mark all as synced
5531        db.mark_sessions_synced(&[session1.id, session2.id, session3.id], Utc::now())
5532            .expect("Failed to mark synced");
5533
5534        // Verify all are synced
5535        let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5536        assert_eq!(unsynced.len(), 0, "All sessions should be synced");
5537
5538        // Clear sync status for all
5539        let count = db.clear_sync_status().expect("Failed to clear sync status");
5540        assert_eq!(count, 3, "Should have cleared 3 sessions");
5541
5542        // Verify all are now unsynced
5543        let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5544        assert_eq!(unsynced.len(), 3, "All sessions should be unsynced now");
5545    }
5546
5547    #[test]
5548    fn test_clear_sync_status_for_specific_sessions() {
5549        let (db, _dir) = create_test_db();
5550
5551        // Create and insert multiple sessions
5552        let session1 = create_test_session("claude-code", "/home/user/project1", Utc::now(), None);
5553        let session2 = create_test_session("aider", "/home/user/project2", Utc::now(), None);
5554        let session3 = create_test_session("cline", "/home/user/project3", Utc::now(), None);
5555
5556        db.insert_session(&session1)
5557            .expect("Failed to insert session1");
5558        db.insert_session(&session2)
5559            .expect("Failed to insert session2");
5560        db.insert_session(&session3)
5561            .expect("Failed to insert session3");
5562
5563        // Mark all as synced
5564        db.mark_sessions_synced(&[session1.id, session2.id, session3.id], Utc::now())
5565            .expect("Failed to mark synced");
5566
5567        // Verify all are synced
5568        let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5569        assert_eq!(unsynced.len(), 0, "All sessions should be synced");
5570
5571        // Clear sync status for only session1 and session3
5572        let count = db
5573            .clear_sync_status_for_sessions(&[session1.id, session3.id])
5574            .expect("Failed to clear sync status");
5575        assert_eq!(count, 2, "Should have cleared 2 sessions");
5576
5577        // Verify only session2 is still synced
5578        let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
5579        assert_eq!(unsynced.len(), 2, "Two sessions should be unsynced");
5580        assert!(
5581            unsynced.iter().any(|s| s.id == session1.id),
5582            "session1 should be unsynced"
5583        );
5584        assert!(
5585            !unsynced.iter().any(|s| s.id == session2.id),
5586            "session2 should still be synced"
5587        );
5588        assert!(
5589            unsynced.iter().any(|s| s.id == session3.id),
5590            "session3 should be unsynced"
5591        );
5592    }
5593
5594    #[test]
5595    fn test_clear_sync_status_for_sessions_empty_list() {
5596        let (db, _dir) = create_test_db();
5597
5598        // Clear sync status with empty list should return 0
5599        let count = db
5600            .clear_sync_status_for_sessions(&[])
5601            .expect("Failed to clear sync status");
5602        assert_eq!(count, 0, "Should return 0 for empty list");
5603    }
5604
5605    #[test]
5606    fn test_clear_sync_status_for_nonexistent_session() {
5607        let (db, _dir) = create_test_db();
5608
5609        // Try to clear sync status for a session that does not exist
5610        let fake_id = Uuid::new_v4();
5611        let count = db
5612            .clear_sync_status_for_sessions(&[fake_id])
5613            .expect("Failed to clear sync status");
5614        assert_eq!(count, 0, "Should return 0 for nonexistent session");
5615    }
5616}