lore_cli/storage/
db.rs

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