Skip to main content

lore_cli/storage/
db.rs

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