Skip to main content

lore_cli/storage/
db.rs

1//! SQLite storage layer for Lore.
2//!
3//! Provides database operations for storing and retrieving sessions,
4//! messages, and session-to-commit links. Uses SQLite for local-first
5//! persistence with automatic schema migrations.
6
7use anyhow::{Context, Result};
8use chrono::{DateTime, Utc};
9use rusqlite::{params, Connection, OptionalExtension};
10use std::collections::HashSet;
11use std::path::{Path, PathBuf};
12use uuid::Uuid;
13
14use super::models::{
15    Annotation, Machine, Memory, Message, MessageContent, MessageRole, SearchResult, Session,
16    SessionLink, Summary, Tag, Tombstone,
17};
18
19/// Tombstone kind for a deleted session-to-commit link.
20const TOMBSTONE_KIND_LINK: &str = "link";
21/// Tombstone kind for a deleted tag.
22const TOMBSTONE_KIND_TAG: &str = "tag";
23/// Tombstone kind for a deleted annotation.
24const TOMBSTONE_KIND_ANNOTATION: &str = "annotation";
25/// Tombstone kind for a deleted summary.
26const TOMBSTONE_KIND_SUMMARY: &str = "summary";
27
28/// Which sync-tracking column a merge or import marks on write.
29///
30/// A session carries two independent sync tracks: the per-repo store
31/// (`synced_at`) and the global personal store (`global_synced_at`). A local
32/// content change invalidates BOTH tracks, but each store syncs and marks its
33/// own column so a push to one store never marks a session as synced for the
34/// other.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SyncTrack {
37    /// The per-repo store's `synced_at` column.
38    PerRepo,
39    /// The global personal store's `global_synced_at` column.
40    Global,
41}
42
43impl SyncTrack {
44    /// Returns the sessions column name this track marks.
45    fn column(self) -> &'static str {
46        match self {
47            SyncTrack::PerRepo => "synced_at",
48            SyncTrack::Global => "global_synced_at",
49        }
50    }
51}
52
53/// Builds the SQL parameters for a path-boundary directory match.
54///
55/// Returns `(exact, trailing, like_pattern)` for matching a
56/// `working_directory` column against `directory`: the column matches when it
57/// equals `exact` (the root with any trailing separator trimmed), equals
58/// `trailing` (the root with a trailing separator), or is
59/// `LIKE like_pattern ESCAPE '|'` (a descendant path). Anchoring the descendant
60/// pattern on the trailing separator is what stops a prefix sibling such as
61/// `/home/me/foobar` from matching the directory `/home/me/foo`. The pattern's
62/// LIKE metacharacters are escaped so a directory containing `%` or `_` cannot
63/// widen the match.
64///
65/// Shared by [`Database::find_active_sessions_for_directory`] and
66/// [`Database::get_unsynced_sessions_for_repo`] so both scope sessions to a
67/// directory the same way.
68fn directory_match_params(directory: &str) -> (String, String, String) {
69    fn escape_like(input: &str) -> String {
70        let mut escaped = String::with_capacity(input.len());
71        for ch in input.chars() {
72            match ch {
73                '|' => escaped.push_str("||"),
74                '%' => escaped.push_str("|%"),
75                '_' => escaped.push_str("|_"),
76                _ => escaped.push(ch),
77            }
78        }
79        escaped
80    }
81
82    let separator = std::path::MAIN_SEPARATOR.to_string();
83    let mut normalized = directory
84        .trim_end_matches(std::path::MAIN_SEPARATOR)
85        .to_string();
86    if normalized.is_empty() {
87        normalized = separator.clone();
88    }
89    let trailing = if normalized == separator {
90        normalized.clone()
91    } else {
92        format!("{normalized}{separator}")
93    };
94    let like_pattern = format!("{}%", escape_like(&trailing));
95    (normalized, trailing, like_pattern)
96}
97
98/// Builds a SQL predicate and bind values scoping `working_directory` to a repo.
99///
100/// A session stores the raw working directory reported by the capturing tool,
101/// which may be a symlinked or otherwise non-canonical path (for example a
102/// session captured under `/link/repo` when the repo canonicalizes to
103/// `/real/repo`). Matching only the canonical repo path would miss such
104/// sessions, so this scopes against BOTH the repo path as given and its
105/// canonicalized form, ORing the per-variant path-boundary conditions from
106/// [`directory_match_params`]. Variants that resolve to identical conditions
107/// (the common no-symlink case) are deduplicated. Each variant stays anchored
108/// on the root plus a separator, so covering both forms still never matches a
109/// path outside either repo root.
110///
111/// Returns the predicate text (with `?N` placeholders) and the flat bind vector
112/// in placeholder order, for use with [`rusqlite::params_from_iter`]. Callers
113/// wrap the predicate in their own parentheses when combining it with other
114/// conditions.
115///
116/// Residual limitation: layouts where the repo is reached through more than the
117/// as-given and fully canonicalized paths (for example a chain of distinct
118/// symlinks, or sessions captured under an intermediate symlink target) are not
119/// covered; only those two forms are matched.
120fn repo_scope_predicate(repo_path: &Path) -> (String, Vec<String>) {
121    let mut variants: Vec<(String, String, String)> = Vec::new();
122
123    let mut add_variant = |dir: &str| {
124        let params = directory_match_params(dir);
125        if !variants.contains(&params) {
126            variants.push(params);
127        }
128    };
129
130    add_variant(&repo_path.to_string_lossy());
131    if let Ok(canonical) = repo_path.canonicalize() {
132        add_variant(&canonical.to_string_lossy());
133    }
134
135    let mut clauses = Vec::with_capacity(variants.len());
136    let mut binds = Vec::with_capacity(variants.len() * 3);
137    let mut next = 1;
138    for (exact, trailing, like_pattern) in variants {
139        clauses.push(format!(
140            "(working_directory = ?{} OR working_directory = ?{} OR working_directory LIKE ?{} ESCAPE '|')",
141            next,
142            next + 1,
143            next + 2
144        ));
145        binds.push(exact);
146        binds.push(trailing);
147        binds.push(like_pattern);
148        next += 3;
149    }
150
151    (clauses.join(" OR "), binds)
152}
153
154/// Parses a UUID from a string, converting errors to rusqlite errors.
155///
156/// Used in row mapping functions where we need to return rusqlite::Result.
157fn parse_uuid(s: &str) -> rusqlite::Result<Uuid> {
158    Uuid::parse_str(s).map_err(|e| {
159        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
160    })
161}
162
163/// Parses an RFC3339 datetime string, converting errors to rusqlite errors.
164///
165/// Used in row mapping functions where we need to return rusqlite::Result.
166fn parse_datetime(s: &str) -> rusqlite::Result<DateTime<Utc>> {
167    chrono::DateTime::parse_from_rfc3339(s)
168        .map(|dt| dt.with_timezone(&Utc))
169        .map_err(|e| {
170            rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
171        })
172}
173
174/// Escapes a query string for FTS5 by wrapping each word in double quotes.
175///
176/// FTS5 has special syntax characters (e.g., /, *, AND, OR, NOT) that need
177/// escaping to be treated as literal search terms.
178fn escape_fts5_query(query: &str) -> String {
179    // Split on whitespace and wrap each word in quotes, escaping internal quotes
180    query
181        .split_whitespace()
182        .map(|word| {
183            let escaped = word.replace('"', "\"\"");
184            format!("\"{escaped}\"")
185        })
186        .collect::<Vec<_>>()
187        .join(" ")
188}
189
190/// Returns the default database path at `~/.lore/lore.db`.
191///
192/// Creates the `.lore` directory if it does not exist.
193pub fn default_db_path() -> Result<PathBuf> {
194    let config_dir = dirs::home_dir()
195        .context("Could not find home directory. Ensure your HOME environment variable is set.")?
196        .join(".lore");
197
198    std::fs::create_dir_all(&config_dir).with_context(|| {
199        format!(
200            "Failed to create Lore data directory at {}. Check directory permissions.",
201            config_dir.display()
202        )
203    })?;
204    Ok(config_dir.join("lore.db"))
205}
206
207/// SQLite database connection wrapper.
208///
209/// Provides methods for storing and querying sessions, messages,
210/// and session-to-commit links. Handles schema migrations automatically
211/// when opening the database.
212pub struct Database {
213    conn: Connection,
214}
215
216impl Database {
217    /// Opens or creates a database at the specified path.
218    ///
219    /// Runs schema migrations automatically to ensure tables exist.
220    pub fn open(path: &PathBuf) -> Result<Self> {
221        let conn = Connection::open(path)?;
222        let db = Self { conn };
223        db.migrate()?;
224        Ok(db)
225    }
226
227    /// Opens the default database at `~/.lore/lore.db`.
228    ///
229    /// Creates the database file and directory if they do not exist.
230    pub fn open_default() -> Result<Self> {
231        let path = default_db_path()?;
232        Self::open(&path)
233    }
234
235    /// Runs database schema migrations.
236    ///
237    /// Creates tables for sessions, messages, session_links, and repositories
238    /// if they do not already exist. Also creates indexes for common queries.
239    fn migrate(&self) -> Result<()> {
240        self.conn.execute_batch(
241            r#"
242            CREATE TABLE IF NOT EXISTS schema_version (
243                version INTEGER PRIMARY KEY
244            );
245
246            CREATE TABLE IF NOT EXISTS sessions (
247                id TEXT PRIMARY KEY,
248                tool TEXT NOT NULL,
249                tool_version TEXT,
250                started_at TEXT NOT NULL,
251                ended_at TEXT,
252                model TEXT,
253                working_directory TEXT NOT NULL,
254                git_branch TEXT,
255                source_path TEXT,
256                message_count INTEGER NOT NULL DEFAULT 0,
257                created_at TEXT NOT NULL DEFAULT (datetime('now')),
258                machine_id TEXT
259            );
260
261            CREATE TABLE IF NOT EXISTS messages (
262                id TEXT PRIMARY KEY,
263                session_id TEXT NOT NULL,
264                parent_id TEXT,
265                idx INTEGER NOT NULL,
266                timestamp TEXT NOT NULL,
267                role TEXT NOT NULL,
268                content TEXT NOT NULL,
269                model TEXT,
270                git_branch TEXT,
271                cwd TEXT,
272                FOREIGN KEY (session_id) REFERENCES sessions(id)
273            );
274
275            CREATE TABLE IF NOT EXISTS session_links (
276                id TEXT PRIMARY KEY,
277                session_id TEXT NOT NULL,
278                link_type TEXT NOT NULL,
279                commit_sha TEXT,
280                branch TEXT,
281                remote TEXT,
282                created_at TEXT NOT NULL,
283                created_by TEXT NOT NULL,
284                confidence REAL,
285                FOREIGN KEY (session_id) REFERENCES sessions(id)
286            );
287
288            CREATE TABLE IF NOT EXISTS repositories (
289                id TEXT PRIMARY KEY,
290                path TEXT NOT NULL UNIQUE,
291                name TEXT NOT NULL,
292                remote_url TEXT,
293                created_at TEXT NOT NULL DEFAULT (datetime('now')),
294                last_session_at TEXT
295            );
296
297            CREATE TABLE IF NOT EXISTS annotations (
298                id TEXT PRIMARY KEY,
299                session_id TEXT NOT NULL,
300                content TEXT NOT NULL,
301                created_at TEXT NOT NULL,
302                FOREIGN KEY (session_id) REFERENCES sessions(id)
303            );
304
305            CREATE TABLE IF NOT EXISTS tags (
306                id TEXT PRIMARY KEY,
307                session_id TEXT NOT NULL,
308                label TEXT NOT NULL,
309                created_at TEXT NOT NULL,
310                FOREIGN KEY (session_id) REFERENCES sessions(id),
311                UNIQUE(session_id, label)
312            );
313
314            CREATE TABLE IF NOT EXISTS summaries (
315                id TEXT PRIMARY KEY,
316                session_id TEXT NOT NULL UNIQUE,
317                content TEXT NOT NULL,
318                generated_at TEXT NOT NULL,
319                FOREIGN KEY (session_id) REFERENCES sessions(id)
320            );
321
322            CREATE TABLE IF NOT EXISTS machines (
323                id TEXT PRIMARY KEY,
324                name TEXT NOT NULL,
325                created_at TEXT NOT NULL
326            );
327
328            -- Read-only mirror of a coding tool's per-project memory store.
329            -- Each row is one markdown memory file mirrored from the tool's
330            -- memory folder. Scoped by (project_path, source_tool). The
331            -- (project_path, source_tool, file_path) triple is unique so a
332            -- refresh can upsert by source file. Lore never writes back to the
333            -- tool's own memory folder; this table is a read-only reflection.
334            CREATE TABLE IF NOT EXISTS memories (
335                id TEXT PRIMARY KEY,
336                project_path TEXT NOT NULL,
337                source_tool TEXT NOT NULL,
338                name TEXT NOT NULL,
339                description TEXT,
340                memory_type TEXT,
341                content TEXT NOT NULL,
342                file_path TEXT NOT NULL,
343                updated_at TEXT NOT NULL,
344                UNIQUE(project_path, source_tool, file_path)
345            );
346
347            -- Tombstones record locally deleted child records so a deletion on
348            -- one machine propagates through the sync store instead of being
349            -- resurrected by the additive child merge. Keyed by (child_id, kind)
350            -- so a link and a tag can never collide on the same UUID.
351            CREATE TABLE IF NOT EXISTS tombstones (
352                child_id TEXT NOT NULL,
353                kind TEXT NOT NULL CHECK(kind IN ('link','tag','annotation','summary')),
354                session_id TEXT,
355                deleted_at TEXT NOT NULL,
356                PRIMARY KEY (child_id, kind)
357            );
358
359            -- Indexes for common queries
360            CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
361            CREATE INDEX IF NOT EXISTS idx_sessions_working_directory ON sessions(working_directory);
362            CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
363            CREATE INDEX IF NOT EXISTS idx_session_links_session_id ON session_links(session_id);
364            CREATE INDEX IF NOT EXISTS idx_session_links_commit_sha ON session_links(commit_sha);
365            CREATE INDEX IF NOT EXISTS idx_annotations_session_id ON annotations(session_id);
366            CREATE INDEX IF NOT EXISTS idx_tags_session_id ON tags(session_id);
367            CREATE INDEX IF NOT EXISTS idx_tags_label ON tags(label);
368            CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
369            CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_path, source_tool);
370            "#,
371        )?;
372
373        // Create FTS5 virtual table for full-text search on message content.
374        // This is a standalone FTS table (not content-synced) because we need to
375        // store extracted text content, not the raw JSON from the messages table.
376        // The message_id column stores the UUID string for joining back to messages.
377        self.conn.execute_batch(
378            r#"
379            CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
380                message_id,
381                text_content,
382                tokenize='porter unicode61'
383            );
384            "#,
385        )?;
386
387        // Create FTS5 virtual table for session metadata search.
388        // Allows searching by project name, branch, tool, and working directory.
389        self.conn.execute_batch(
390            r#"
391            CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
392                session_id,
393                tool,
394                working_directory,
395                git_branch,
396                tokenize='porter unicode61'
397            );
398            "#,
399        )?;
400
401        // Create FTS5 virtual table for full-text search over mirrored memories.
402        // The memory_id column stores the UUID string for joining back to the
403        // memories table.
404        self.conn.execute_batch(
405            r#"
406            CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
407                memory_id,
408                name,
409                description,
410                content,
411                tokenize='porter unicode61'
412            );
413            "#,
414        )?;
415
416        // Migration: Add machine_id column to existing sessions table if not present.
417        // This handles upgrades from databases created before machine_id was added.
418        self.migrate_add_machine_id()?;
419
420        // Migration: Add synced_at column for sync tracking.
421        self.migrate_add_synced_at()?;
422
423        // Migration: Add global_synced_at column for the global personal store.
424        self.migrate_add_global_synced_at()?;
425
426        Ok(())
427    }
428
429    /// Adds the machine_id column to the sessions table if it does not exist,
430    /// and backfills NULL values with the current machine's UUID.
431    ///
432    /// Also migrates sessions that were previously backfilled with hostname
433    /// to use the UUID instead.
434    ///
435    /// This migration is idempotent and safe to run on both new and existing databases.
436    fn migrate_add_machine_id(&self) -> Result<()> {
437        // Check if machine_id column already exists
438        let columns: Vec<String> = self
439            .conn
440            .prepare("PRAGMA table_info(sessions)")?
441            .query_map([], |row| row.get::<_, String>(1))?
442            .collect::<Result<Vec<_>, _>>()?;
443
444        if !columns.iter().any(|c| c == "machine_id") {
445            self.conn
446                .execute("ALTER TABLE sessions ADD COLUMN machine_id TEXT", [])?;
447        }
448
449        // Backfill NULL machine_id values with current machine UUID
450        if let Some(machine_uuid) = super::get_machine_id() {
451            self.conn.execute(
452                "UPDATE sessions SET machine_id = ?1 WHERE machine_id IS NULL",
453                [&machine_uuid],
454            )?;
455
456            // Migrate sessions that were backfilled with hostname to use UUID.
457            // We detect hostname-based machine_ids by checking if they don't look
458            // like UUIDs (UUIDs contain dashes in the format 8-4-4-4-12).
459            // This is safe because it only affects sessions from this machine.
460            if let Some(hostname) = hostname::get().ok().and_then(|h| h.into_string().ok()) {
461                self.conn.execute(
462                    "UPDATE sessions SET machine_id = ?1 WHERE machine_id = ?2",
463                    [&machine_uuid, &hostname],
464                )?;
465            }
466        }
467
468        Ok(())
469    }
470
471    /// Adds the synced_at column to the sessions table if it does not exist.
472    ///
473    /// This column tracks when each session was last synced to its per-repo
474    /// store. A NULL value indicates the session has never been synced.
475    fn migrate_add_synced_at(&self) -> Result<()> {
476        let columns: Vec<String> = self
477            .conn
478            .prepare("PRAGMA table_info(sessions)")?
479            .query_map([], |row| row.get::<_, String>(1))?
480            .collect::<Result<Vec<_>, _>>()?;
481
482        if !columns.iter().any(|c| c == "synced_at") {
483            self.conn
484                .execute("ALTER TABLE sessions ADD COLUMN synced_at TEXT", [])?;
485        }
486
487        Ok(())
488    }
489
490    /// Adds the global_synced_at column to the sessions table if it does not exist.
491    ///
492    /// This column tracks when each session was last synced to the global
493    /// personal store, independently of the per-repo `synced_at` column. A NULL
494    /// value indicates the session has never been synced to the global store.
495    fn migrate_add_global_synced_at(&self) -> Result<()> {
496        let columns: Vec<String> = self
497            .conn
498            .prepare("PRAGMA table_info(sessions)")?
499            .query_map([], |row| row.get::<_, String>(1))?
500            .collect::<Result<Vec<_>, _>>()?;
501
502        if !columns.iter().any(|c| c == "global_synced_at") {
503            self.conn
504                .execute("ALTER TABLE sessions ADD COLUMN global_synced_at TEXT", [])?;
505        }
506
507        Ok(())
508    }
509
510    // ==================== Sessions ====================
511
512    /// Inserts a new session or updates an existing one.
513    ///
514    /// If a session with the same ID already exists, updates the `ended_at`
515    /// and `message_count` fields. Resets both `synced_at` and
516    /// `global_synced_at` to NULL if either the message_count or ended_at has
517    /// changed (a local content change invalidates both the per-repo and global
518    /// sync tracks, so the session is re-exported to each store on the next
519    /// sync). Also updates the sessions_fts index for full-text search on
520    /// session metadata.
521    pub fn insert_session(&self, session: &Session) -> Result<()> {
522        let rows_changed = self.conn.execute(
523            r#"
524            INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id)
525            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
526            ON CONFLICT(id) DO UPDATE SET
527                ended_at = ?5,
528                message_count = ?10,
529                synced_at = CASE
530                    WHEN message_count != ?10 THEN NULL
531                    WHEN (ended_at IS NULL AND ?5 IS NOT NULL) THEN NULL
532                    WHEN (ended_at IS NOT NULL AND ?5 IS NULL) THEN NULL
533                    WHEN ended_at != ?5 THEN NULL
534                    ELSE synced_at
535                END,
536                global_synced_at = CASE
537                    WHEN message_count != ?10 THEN NULL
538                    WHEN (ended_at IS NULL AND ?5 IS NOT NULL) THEN NULL
539                    WHEN (ended_at IS NOT NULL AND ?5 IS NULL) THEN NULL
540                    WHEN ended_at != ?5 THEN NULL
541                    ELSE global_synced_at
542                END
543            "#,
544            params![
545                session.id.to_string(),
546                session.tool,
547                session.tool_version,
548                session.started_at.to_rfc3339(),
549                session.ended_at.map(|t| t.to_rfc3339()),
550                session.model,
551                session.working_directory,
552                session.git_branch,
553                session.source_path,
554                session.message_count,
555                session.machine_id,
556            ],
557        )?;
558
559        // Insert into sessions_fts for metadata search (only on new inserts)
560        if rows_changed > 0 {
561            // Check if already in FTS (for ON CONFLICT case)
562            let fts_count: i32 = self.conn.query_row(
563                "SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
564                params![session.id.to_string()],
565                |row| row.get(0),
566            )?;
567
568            if fts_count == 0 {
569                self.conn.execute(
570                    "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
571                    params![
572                        session.id.to_string(),
573                        session.tool,
574                        session.working_directory,
575                        session.git_branch.as_deref().unwrap_or(""),
576                    ],
577                )?;
578            }
579        }
580
581        Ok(())
582    }
583
584    /// Retrieves a session by its unique ID.
585    ///
586    /// Returns `None` if no session with the given ID exists.
587    pub fn get_session(&self, id: &Uuid) -> Result<Option<Session>> {
588        self.conn
589            .query_row(
590                "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",
591                params![id.to_string()],
592                Self::row_to_session,
593            )
594            .optional()
595            .context("Failed to get session")
596    }
597
598    /// Lists sessions ordered by start time (most recent first).
599    ///
600    /// Optionally filters by working directory prefix. Returns at most
601    /// `limit` sessions.
602    pub fn list_sessions(&self, limit: usize, working_dir: Option<&str>) -> Result<Vec<Session>> {
603        let mut stmt = if working_dir.is_some() {
604            self.conn.prepare(
605                "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
606                 FROM sessions
607                 WHERE working_directory LIKE ?1
608                 ORDER BY started_at DESC
609                 LIMIT ?2"
610            )?
611        } else {
612            self.conn.prepare(
613                "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
614                 FROM sessions
615                 ORDER BY started_at DESC
616                 LIMIT ?1"
617            )?
618        };
619
620        let rows = if let Some(wd) = working_dir {
621            stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
622        } else {
623            stmt.query_map(params![limit], Self::row_to_session)?
624        };
625
626        rows.collect::<Result<Vec<_>, _>>()
627            .context("Failed to list sessions")
628    }
629
630    /// Lists ended sessions ordered by start time (most recent first).
631    ///
632    /// Optionally filters by working directory prefix.
633    pub fn list_ended_sessions(
634        &self,
635        limit: usize,
636        working_dir: Option<&str>,
637    ) -> Result<Vec<Session>> {
638        let mut stmt = if working_dir.is_some() {
639            self.conn.prepare(
640                "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
641                 FROM sessions
642                 WHERE ended_at IS NOT NULL
643                   AND working_directory LIKE ?1
644                 ORDER BY started_at DESC
645                 LIMIT ?2",
646            )?
647        } else {
648            self.conn.prepare(
649                "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
650                 FROM sessions
651                 WHERE ended_at IS NOT NULL
652                 ORDER BY started_at DESC
653                 LIMIT ?1",
654            )?
655        };
656
657        let rows = if let Some(wd) = working_dir {
658            stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
659        } else {
660            stmt.query_map(params![limit], Self::row_to_session)?
661        };
662
663        rows.collect::<Result<Vec<_>, _>>()
664            .context("Failed to list ended sessions")
665    }
666
667    /// Checks if a session with the given source path already exists.
668    ///
669    /// Used to detect already-imported sessions during import operations.
670    pub fn session_exists_by_source(&self, source_path: &str) -> Result<bool> {
671        let count: i32 = self.conn.query_row(
672            "SELECT COUNT(*) FROM sessions WHERE source_path = ?1",
673            params![source_path],
674            |row| row.get(0),
675        )?;
676        Ok(count > 0)
677    }
678
679    /// Retrieves a session by its source path.
680    ///
681    /// Returns `None` if no session with the given source path exists.
682    /// Used by the daemon to find existing sessions when updating them.
683    pub fn get_session_by_source(&self, source_path: &str) -> Result<Option<Session>> {
684        self.conn
685            .query_row(
686                "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",
687                params![source_path],
688                Self::row_to_session,
689            )
690            .optional()
691            .context("Failed to get session by source path")
692    }
693
694    /// Finds a session by ID prefix, searching all sessions in the database.
695    ///
696    /// This method uses SQL LIKE to efficiently search by prefix without
697    /// loading all sessions into memory. Returns an error if the prefix
698    /// is ambiguous (matches multiple sessions).
699    ///
700    /// # Arguments
701    ///
702    /// * `prefix` - The UUID prefix to search for (can be any length)
703    ///
704    /// # Returns
705    ///
706    /// * `Ok(Some(session))` - If exactly one session matches the prefix
707    /// * `Ok(None)` - If no sessions match the prefix
708    /// * `Err` - If multiple sessions match (ambiguous prefix) or database error
709    pub fn find_session_by_id_prefix(&self, prefix: &str) -> Result<Option<Session>> {
710        // First try parsing as a full UUID
711        if let Ok(uuid) = Uuid::parse_str(prefix) {
712            return self.get_session(&uuid);
713        }
714
715        // Search by prefix using LIKE
716        let pattern = format!("{prefix}%");
717
718        // First, count how many sessions match
719        let count: i32 = self.conn.query_row(
720            "SELECT COUNT(*) FROM sessions WHERE id LIKE ?1",
721            params![pattern],
722            |row| row.get(0),
723        )?;
724
725        match count {
726            0 => Ok(None),
727            1 => {
728                // Exactly one match, retrieve it
729                self.conn
730                    .query_row(
731                        "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
732                         FROM sessions
733                         WHERE id LIKE ?1",
734                        params![pattern],
735                        Self::row_to_session,
736                    )
737                    .optional()
738                    .context("Failed to find session by prefix")
739            }
740            n => {
741                // Multiple matches - return an error indicating ambiguity
742                anyhow::bail!(
743                    "Ambiguous session ID prefix '{prefix}' matches {n} sessions. Use a longer prefix."
744                )
745            }
746        }
747    }
748
749    /// Updates the git branch for a session.
750    ///
751    /// Used by the daemon when a message is processed with a different branch
752    /// than the session's current branch, indicating a branch switch mid-session.
753    /// Also updates the sessions_fts index to keep search in sync.
754    ///
755    /// Returns the number of rows affected (0 or 1).
756    pub fn update_session_branch(&self, session_id: Uuid, new_branch: &str) -> Result<usize> {
757        let rows_changed = self.conn.execute(
758            "UPDATE sessions SET git_branch = ?1 WHERE id = ?2",
759            params![new_branch, session_id.to_string()],
760        )?;
761
762        // Also update the FTS index if the session was updated
763        if rows_changed > 0 {
764            self.conn.execute(
765                "UPDATE sessions_fts SET git_branch = ?1 WHERE session_id = ?2",
766                params![new_branch, session_id.to_string()],
767            )?;
768        }
769
770        Ok(rows_changed)
771    }
772
773    fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<Session> {
774        let ended_at_str: Option<String> = row.get(4)?;
775        let ended_at = match ended_at_str {
776            Some(s) => Some(parse_datetime(&s)?),
777            None => None,
778        };
779
780        Ok(Session {
781            id: parse_uuid(&row.get::<_, String>(0)?)?,
782            tool: row.get(1)?,
783            tool_version: row.get(2)?,
784            started_at: parse_datetime(&row.get::<_, String>(3)?)?,
785            ended_at,
786            model: row.get(5)?,
787            working_directory: row.get(6)?,
788            git_branch: row.get(7)?,
789            source_path: row.get(8)?,
790            message_count: row.get(9)?,
791            machine_id: row.get(10)?,
792        })
793    }
794
795    // ==================== Messages ====================
796
797    /// Inserts a message into the database.
798    ///
799    /// If a message with the same ID already exists, the insert is ignored.
800    /// Message content is serialized to JSON for storage. Also inserts
801    /// extracted text content into the FTS index for full-text search.
802    pub fn insert_message(&self, message: &Message) -> Result<()> {
803        let content_json = serde_json::to_string(&message.content)?;
804
805        let rows_changed = self.conn.execute(
806            r#"
807            INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
808            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
809            ON CONFLICT(id) DO NOTHING
810            "#,
811            params![
812                message.id.to_string(),
813                message.session_id.to_string(),
814                message.parent_id.map(|u| u.to_string()),
815                message.index,
816                message.timestamp.to_rfc3339(),
817                message.role.to_string(),
818                content_json,
819                message.model,
820                message.git_branch,
821                message.cwd,
822            ],
823        )?;
824
825        // Only insert into FTS if the message was actually inserted (not a duplicate)
826        if rows_changed > 0 {
827            let text_content = message.content.text();
828            if !text_content.is_empty() {
829                self.conn.execute(
830                    "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
831                    params![message.id.to_string(), text_content],
832                )?;
833            }
834        }
835
836        Ok(())
837    }
838
839    /// Imports a session with all its messages in a single transaction.
840    ///
841    /// This is much faster than calling `insert_session` and `insert_message`
842    /// separately for each message, as it batches all operations into one
843    /// database transaction. Optionally marks the session as synced.
844    ///
845    /// Note: retained as public API and used as the storage tests' fixture
846    /// builder; production import paths write sessions incrementally.
847    #[allow(dead_code)]
848    pub fn import_session_with_messages(
849        &mut self,
850        session: &Session,
851        messages: &[Message],
852        synced_at: Option<DateTime<Utc>>,
853    ) -> Result<()> {
854        let tx = self.conn.transaction()?;
855        Self::write_session_with_messages(&tx, session, messages, synced_at, SyncTrack::PerRepo)?;
856        tx.commit()?;
857        Ok(())
858    }
859
860    /// Writes a session and its messages using the given connection.
861    ///
862    /// Shared by [`Self::import_session_with_messages`] and
863    /// [`Self::merge_remote_record`] so both the plain import and the atomic
864    /// remote-merge transaction apply identical session and message SQL. The
865    /// caller owns the transaction boundary; this function never commits.
866    ///
867    /// `track` selects which sync-tracking column the supplied timestamp is
868    /// written into: the per-repo `synced_at` or the global `global_synced_at`.
869    /// Only that column is touched, so marking a session synced for one store
870    /// never affects the other store's track.
871    fn write_session_with_messages(
872        conn: &Connection,
873        session: &Session,
874        messages: &[Message],
875        synced_at: Option<DateTime<Utc>>,
876        track: SyncTrack,
877    ) -> Result<()> {
878        // Insert session. The tracking column is chosen by `track`; the SQL is
879        // otherwise identical for both stores.
880        let col = track.column();
881        let insert_sql = format!(
882            "INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id, {col})
883            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
884            ON CONFLICT(id) DO UPDATE SET
885                ended_at = ?5,
886                message_count = ?10,
887                {col} = COALESCE(?12, {col})"
888        );
889        conn.execute(
890            &insert_sql,
891            params![
892                session.id.to_string(),
893                session.tool,
894                session.tool_version,
895                session.started_at.to_rfc3339(),
896                session.ended_at.map(|t| t.to_rfc3339()),
897                session.model,
898                session.working_directory,
899                session.git_branch,
900                session.source_path,
901                session.message_count,
902                session.machine_id,
903                synced_at.map(|t| t.to_rfc3339()),
904            ],
905        )?;
906
907        // Insert into sessions_fts for metadata search
908        let fts_count: i32 = conn.query_row(
909            "SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
910            params![session.id.to_string()],
911            |row| row.get(0),
912        )?;
913        if fts_count == 0 {
914            conn.execute(
915                "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
916                params![
917                    session.id.to_string(),
918                    session.tool,
919                    session.working_directory,
920                    session.git_branch.as_deref().unwrap_or(""),
921                ],
922            )?;
923        }
924
925        // Insert all messages
926        for message in messages {
927            let content_json = serde_json::to_string(&message.content)?;
928
929            let rows_changed = conn.execute(
930                r#"
931                INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
932                VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
933                ON CONFLICT(id) DO NOTHING
934                "#,
935                params![
936                    message.id.to_string(),
937                    message.session_id.to_string(),
938                    message.parent_id.map(|u| u.to_string()),
939                    message.index,
940                    message.timestamp.to_rfc3339(),
941                    message.role.to_string(),
942                    content_json,
943                    message.model,
944                    message.git_branch,
945                    message.cwd,
946                ],
947            )?;
948
949            // Insert into FTS if the message was actually inserted
950            if rows_changed > 0 {
951                let text_content = message.content.text();
952                if !text_content.is_empty() {
953                    conn.execute(
954                        "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
955                        params![message.id.to_string(), text_content],
956                    )?;
957                }
958            }
959        }
960
961        Ok(())
962    }
963
964    /// Resets a session's `synced_at` and `global_synced_at` to NULL so a later
965    /// sync re-exports it to both the per-repo and global stores.
966    ///
967    /// Called after any local edit to a session's child records (links, tags,
968    /// annotations, summary). Auto-linking and auto-summarizing frequently happen
969    /// after a session has already synced, so without clearing both columns the
970    /// added child would never be re-encrypted and pushed. A local content change
971    /// invalidates both sync tracks, so both are cleared. The remote-merge path
972    /// (the `upsert_*` methods and [`Self::merge_remote_record`]) deliberately
973    /// does NOT call this: marking a just-pulled session as needing a push would
974    /// bounce the same record back to the remote in a sync loop.
975    fn mark_session_unsynced(&self, session_id: &Uuid) -> Result<()> {
976        self.conn.execute(
977            "UPDATE sessions SET synced_at = NULL, global_synced_at = NULL WHERE id = ?1",
978            params![session_id.to_string()],
979        )?;
980        Ok(())
981    }
982
983    /// Records a tombstone for a locally deleted child record.
984    ///
985    /// Called only from the user-facing child delete methods (unlink, tag
986    /// remove, annotation delete, summary delete). The session-cascade delete
987    /// (`delete_session`, `delete_sessions_older_than`) deletes its children with
988    /// inline SQL and deliberately does NOT call this, so removing a whole
989    /// session never strips that session's children from the shared store. On a
990    /// re-delete the existing row's timestamp is refreshed so garbage collection
991    /// keys off the most recent deletion.
992    fn record_tombstone(
993        conn: &Connection,
994        child_id: &str,
995        kind: &str,
996        session_id: &Uuid,
997        deleted_at: DateTime<Utc>,
998    ) -> Result<()> {
999        conn.execute(
1000            r#"
1001            INSERT INTO tombstones (child_id, kind, session_id, deleted_at)
1002            VALUES (?1, ?2, ?3, ?4)
1003            ON CONFLICT(child_id, kind) DO UPDATE SET
1004                session_id = excluded.session_id,
1005                deleted_at = excluded.deleted_at
1006            "#,
1007            params![
1008                child_id,
1009                kind,
1010                session_id.to_string(),
1011                deleted_at.to_rfc3339()
1012            ],
1013        )?;
1014        Ok(())
1015    }
1016
1017    /// Returns the `id` values of a child table's rows for a session.
1018    ///
1019    /// Used by the bulk child delete methods to capture ids before deletion so
1020    /// each removed row can be tombstoned. `table` is a fixed internal literal
1021    /// (never user input), so interpolating it into the query is safe.
1022    fn child_ids(&self, table: &str, session_id: &Uuid) -> Result<Vec<String>> {
1023        let sql = format!("SELECT id FROM {table} WHERE session_id = ?1");
1024        let mut stmt = self.conn.prepare(&sql)?;
1025        let rows = stmt.query_map(params![session_id.to_string()], |row| row.get(0))?;
1026        rows.collect::<Result<Vec<_>, _>>()
1027            .context("Failed to read child ids")
1028    }
1029
1030    /// Returns whether a child record of the given kind is tombstoned.
1031    ///
1032    /// Used by the merge path to suppress re-adding a record that was deleted on
1033    /// another machine.
1034    fn is_tombstoned(conn: &Connection, child_id: &Uuid, kind: &str) -> Result<bool> {
1035        let count: i64 = conn.query_row(
1036            "SELECT COUNT(*) FROM tombstones WHERE child_id = ?1 AND kind = ?2",
1037            params![child_id.to_string(), kind],
1038            |row| row.get(0),
1039        )?;
1040        Ok(count > 0)
1041    }
1042
1043    /// Returns every locally recorded tombstone.
1044    ///
1045    /// Ordered by `(kind, child_id)` so the serialized store blob is stable
1046    /// across repeated syncs on the same machine (content-addressed dedup).
1047    pub fn list_tombstones(&self) -> Result<Vec<Tombstone>> {
1048        let mut stmt = self.conn.prepare(
1049            "SELECT child_id, kind, session_id, deleted_at
1050             FROM tombstones
1051             ORDER BY kind ASC, child_id ASC",
1052        )?;
1053        let rows = stmt.query_map([], |row| {
1054            let deleted_at: String = row.get(3)?;
1055            Ok(Tombstone {
1056                child_id: row.get(0)?,
1057                kind: row.get(1)?,
1058                session_id: row.get(2)?,
1059                deleted_at: parse_datetime(&deleted_at)?,
1060            })
1061        })?;
1062        rows.collect::<Result<Vec<_>, _>>()
1063            .context("Failed to list tombstones")
1064    }
1065
1066    /// Unions a set of remote tombstones into the local table.
1067    ///
1068    /// First-wins on `(child_id, kind)`: an existing local tombstone is left
1069    /// untouched, so a machine's own deletion timestamp is preserved. Used by
1070    /// the sync merge before importing remote records so the merge can suppress
1071    /// any child that was deleted elsewhere.
1072    pub fn add_tombstones(&self, tombstones: &[Tombstone]) -> Result<()> {
1073        for t in tombstones {
1074            self.conn.execute(
1075                r#"
1076                INSERT INTO tombstones (child_id, kind, session_id, deleted_at)
1077                VALUES (?1, ?2, ?3, ?4)
1078                ON CONFLICT(child_id, kind) DO NOTHING
1079                "#,
1080                params![t.child_id, t.kind, t.session_id, t.deleted_at.to_rfc3339()],
1081            )?;
1082        }
1083        Ok(())
1084    }
1085
1086    /// Deletes any locally-present child records that are tombstoned.
1087    ///
1088    /// Enforces, on this machine, a deletion that happened on another machine.
1089    /// Deletes with plain SQL rather than the user-facing delete methods so it
1090    /// does NOT record a fresh tombstone (the child is already tombstoned) and
1091    /// cannot recurse. Each session whose child was actually removed is marked
1092    /// unsynced so the cleaned record is re-exported on the next sync, replacing
1093    /// the stale blob in the store. Suppression during merge already prevents
1094    /// resurrection, so this re-export is a cleanup rather than a correctness
1095    /// requirement.
1096    pub fn apply_tombstones(&self, tombstones: &[Tombstone]) -> Result<()> {
1097        let mut affected: HashSet<Uuid> = HashSet::new();
1098        for t in tombstones {
1099            let deleted = match t.kind.as_str() {
1100                TOMBSTONE_KIND_LINK => self.conn.execute(
1101                    "DELETE FROM session_links WHERE id = ?1",
1102                    params![t.child_id],
1103                )?,
1104                TOMBSTONE_KIND_TAG => self
1105                    .conn
1106                    .execute("DELETE FROM tags WHERE id = ?1", params![t.child_id])?,
1107                TOMBSTONE_KIND_ANNOTATION => self
1108                    .conn
1109                    .execute("DELETE FROM annotations WHERE id = ?1", params![t.child_id])?,
1110                TOMBSTONE_KIND_SUMMARY => self
1111                    .conn
1112                    .execute("DELETE FROM summaries WHERE id = ?1", params![t.child_id])?,
1113                _ => 0,
1114            };
1115            if deleted > 0 {
1116                if let Some(sid) = &t.session_id {
1117                    if let Ok(uuid) = parse_uuid(sid) {
1118                        affected.insert(uuid);
1119                    }
1120                }
1121            }
1122        }
1123        for session_id in affected {
1124            self.mark_session_unsynced(&session_id)?;
1125        }
1126        Ok(())
1127    }
1128
1129    /// Prunes tombstones deleted before the given cutoff.
1130    ///
1131    /// Returns the number of tombstones removed. This is NOT called during sync:
1132    /// an age-based prune there could drop a tombstone still needed to suppress a
1133    /// stale session blob, resurrecting the deleted child. Tombstones are tiny, so
1134    /// the sync path keeps the full set indefinitely. This method is retained for
1135    /// a possible future safe garbage collection that only prunes tombstones
1136    /// proven to have propagated to every machine.
1137    // Retained for that future safe GC and covered by a direct unit test; it has
1138    // no non-test caller today now that the sync path never prunes.
1139    #[allow(dead_code)]
1140    pub fn prune_tombstones(&self, before: DateTime<Utc>) -> Result<usize> {
1141        let rows = self.conn.execute(
1142            "DELETE FROM tombstones WHERE deleted_at < ?1",
1143            params![before.to_rfc3339()],
1144        )?;
1145        Ok(rows)
1146    }
1147
1148    /// Merges a full remote reasoning record into the database atomically.
1149    ///
1150    /// Used by git-ref sync when pulling a remote store. The session row,
1151    /// messages, and every child record are persisted inside a single
1152    /// transaction, so a crash can never leave child data missing while the
1153    /// session already looks synced and current. Merge rules:
1154    ///
1155    /// - Session row and messages use newer-wins: they are written only when
1156    ///   there is no local session yet or the remote session is newer (more
1157    ///   messages, or an equal message count with a later `ended_at`). When
1158    ///   written, the session is marked synced with `synced_at`.
1159    /// - Links, tags, and annotations are additive and idempotent by id, so they
1160    ///   are merged regardless of which session row is newer, EXCEPT that a child
1161    ///   whose (child_id, kind) is tombstoned is suppressed. This lets a remote
1162    ///   that added a child to an already-synced session still deliver it, while
1163    ///   a child deleted on another machine is not resurrected.
1164    /// - The summary is applied through the newer-wins writer (unless
1165    ///   tombstoned), so an older remote summary never clobbers a newer local one.
1166    ///
1167    /// Deletion propagation: the caller unions remote tombstones into the local
1168    /// table before merging (so suppression here sees them) and applies them
1169    /// afterward via [`Self::apply_tombstones`] to remove any child that was
1170    /// already present locally. See `cli/commands/sync.rs`.
1171    ///
1172    /// Returns `true` when the session row and messages were written (the
1173    /// newer-wins branch ran), which callers use for the pulled count.
1174    ///
1175    /// This marks imported sessions on the per-repo `synced_at` track. The
1176    /// global store uses [`Self::merge_remote_record_global`], which shares the
1177    /// identical merge logic but marks the `global_synced_at` track.
1178    #[allow(clippy::too_many_arguments)]
1179    pub fn merge_remote_record(
1180        &mut self,
1181        session: &Session,
1182        messages: &[Message],
1183        links: &[SessionLink],
1184        tags: &[Tag],
1185        annotations: &[Annotation],
1186        summary: Option<&Summary>,
1187        synced_at: DateTime<Utc>,
1188    ) -> Result<bool> {
1189        self.merge_remote_record_tracked(
1190            session,
1191            messages,
1192            links,
1193            tags,
1194            annotations,
1195            summary,
1196            synced_at,
1197            SyncTrack::PerRepo,
1198        )
1199    }
1200
1201    /// Global-store counterpart of [`Self::merge_remote_record`].
1202    ///
1203    /// Applies the identical newer-wins and additive-child merge, but marks an
1204    /// imported session on the global `global_synced_at` track so a global pull
1205    /// does not affect the per-repo `synced_at` track (and vice versa).
1206    #[allow(clippy::too_many_arguments)]
1207    pub fn merge_remote_record_global(
1208        &mut self,
1209        session: &Session,
1210        messages: &[Message],
1211        links: &[SessionLink],
1212        tags: &[Tag],
1213        annotations: &[Annotation],
1214        summary: Option<&Summary>,
1215        synced_at: DateTime<Utc>,
1216    ) -> Result<bool> {
1217        self.merge_remote_record_tracked(
1218            session,
1219            messages,
1220            links,
1221            tags,
1222            annotations,
1223            summary,
1224            synced_at,
1225            SyncTrack::Global,
1226        )
1227    }
1228
1229    /// Shared implementation for the per-repo and global merge paths.
1230    ///
1231    /// `track` selects which sync-tracking column an imported session row is
1232    /// marked on. Everything else (newer-wins session/message import plus
1233    /// additive, idempotent child merges) is identical across both stores.
1234    #[allow(clippy::too_many_arguments)]
1235    fn merge_remote_record_tracked(
1236        &mut self,
1237        session: &Session,
1238        messages: &[Message],
1239        links: &[SessionLink],
1240        tags: &[Tag],
1241        annotations: &[Annotation],
1242        summary: Option<&Summary>,
1243        synced_at: DateTime<Utc>,
1244        track: SyncTrack,
1245    ) -> Result<bool> {
1246        let tx = self.conn.transaction()?;
1247
1248        // Read the local session's newer-wins keys (if it exists) inside the
1249        // transaction so the decision and the writes are one atomic unit.
1250        let existing: Option<(i32, Option<String>)> = tx
1251            .query_row(
1252                "SELECT message_count, ended_at FROM sessions WHERE id = ?1",
1253                params![session.id.to_string()],
1254                |row| Ok((row.get(0)?, row.get(1)?)),
1255            )
1256            .optional()?;
1257
1258        let import_session = match &existing {
1259            None => true,
1260            Some((local_count, local_ended)) => {
1261                Self::remote_row_is_newer(session, *local_count, local_ended.as_deref())?
1262            }
1263        };
1264
1265        if import_session {
1266            Self::write_session_with_messages(&tx, session, messages, Some(synced_at), track)?;
1267        }
1268
1269        // Child records are additive and idempotent by id: merge them so a
1270        // remote addition to an equal-or-older session is not lost. A child that
1271        // is tombstoned (deleted on this or another machine) is suppressed so a
1272        // stale remote blob cannot resurrect it. Concurrent additions of other
1273        // records are unaffected because suppression matches only the exact
1274        // (child_id, kind) of a deleted record.
1275        for link in links {
1276            if !Self::is_tombstoned(&tx, &link.id, TOMBSTONE_KIND_LINK)? {
1277                Self::write_link(&tx, link, true)?;
1278            }
1279        }
1280        for tag in tags {
1281            if !Self::is_tombstoned(&tx, &tag.id, TOMBSTONE_KIND_TAG)? {
1282                Self::write_tag(&tx, tag, true)?;
1283            }
1284        }
1285        for annotation in annotations {
1286            if !Self::is_tombstoned(&tx, &annotation.id, TOMBSTONE_KIND_ANNOTATION)? {
1287                Self::write_annotation(&tx, annotation, true)?;
1288            }
1289        }
1290        if let Some(summary) = summary {
1291            if !Self::is_tombstoned(&tx, &summary.id, TOMBSTONE_KIND_SUMMARY)? {
1292                Self::write_summary_newer(&tx, summary)?;
1293            }
1294        }
1295
1296        tx.commit()?;
1297        Ok(import_session)
1298    }
1299
1300    /// Newer-wins comparison for a remote session against a local row.
1301    ///
1302    /// Mirrors the historical sync rule: a strictly higher remote message count
1303    /// wins outright; otherwise a later `ended_at` wins (a remote `ended_at`
1304    /// against a local NULL also wins). `local_ended` is the stored RFC3339
1305    /// string, parsed for the comparison.
1306    fn remote_row_is_newer(
1307        remote: &Session,
1308        local_count: i32,
1309        local_ended: Option<&str>,
1310    ) -> Result<bool> {
1311        if remote.message_count > local_count {
1312            return Ok(true);
1313        }
1314        let local_ended = match local_ended {
1315            Some(s) => Some(parse_datetime(s)?),
1316            None => None,
1317        };
1318        Ok(match (remote.ended_at, local_ended) {
1319            (Some(r), Some(l)) => r > l,
1320            (Some(_), None) => true,
1321            _ => false,
1322        })
1323    }
1324
1325    /// Retrieves all messages for a session, ordered by index.
1326    ///
1327    /// Messages are returned in conversation order (by their `index` field).
1328    pub fn get_messages(&self, session_id: &Uuid) -> Result<Vec<Message>> {
1329        let mut stmt = self.conn.prepare(
1330            "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd 
1331             FROM messages 
1332             WHERE session_id = ?1 
1333             ORDER BY idx"
1334        )?;
1335
1336        let rows = stmt.query_map(params![session_id.to_string()], |row| {
1337            let role_str: String = row.get(5)?;
1338            let content_str: String = row.get(6)?;
1339
1340            let parent_id_str: Option<String> = row.get(2)?;
1341            let parent_id = match parent_id_str {
1342                Some(s) => Some(parse_uuid(&s)?),
1343                None => None,
1344            };
1345
1346            Ok(Message {
1347                id: parse_uuid(&row.get::<_, String>(0)?)?,
1348                session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1349                parent_id,
1350                index: row.get(3)?,
1351                timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1352                role: match role_str.as_str() {
1353                    "user" => MessageRole::User,
1354                    "assistant" => MessageRole::Assistant,
1355                    "system" => MessageRole::System,
1356                    _ => MessageRole::User,
1357                },
1358                content: serde_json::from_str(&content_str)
1359                    .unwrap_or(MessageContent::Text(content_str)),
1360                model: row.get(7)?,
1361                git_branch: row.get(8)?,
1362                cwd: row.get(9)?,
1363            })
1364        })?;
1365
1366        rows.collect::<Result<Vec<_>, _>>()
1367            .context("Failed to get messages")
1368    }
1369
1370    /// Returns the ordered list of distinct branches for a session.
1371    ///
1372    /// Branches are returned in the order they first appeared in messages,
1373    /// with consecutive duplicates removed. This shows the branch transitions
1374    /// during a session (e.g., "main -> feat/auth -> main").
1375    ///
1376    /// Returns an empty vector if the session has no messages or all messages
1377    /// have None branches.
1378    pub fn get_session_branch_history(&self, session_id: Uuid) -> Result<Vec<String>> {
1379        let mut stmt = self
1380            .conn
1381            .prepare("SELECT git_branch FROM messages WHERE session_id = ?1 ORDER BY idx")?;
1382
1383        let rows = stmt.query_map(params![session_id.to_string()], |row| {
1384            let branch: Option<String> = row.get(0)?;
1385            Ok(branch)
1386        })?;
1387
1388        // Collect branches, keeping only the first occurrence of consecutive duplicates
1389        let mut branches: Vec<String> = Vec::new();
1390        for row in rows {
1391            if let Some(branch) = row? {
1392                // Only add if different from the last branch (removes consecutive duplicates)
1393                if branches.last() != Some(&branch) {
1394                    branches.push(branch);
1395                }
1396            }
1397        }
1398
1399        Ok(branches)
1400    }
1401
1402    // ==================== Session Links ====================
1403
1404    /// Inserts a link between a session and a git commit.
1405    ///
1406    /// Links can be created manually by users or automatically by
1407    /// the auto-linking system based on time and file overlap heuristics.
1408    pub fn insert_link(&self, link: &SessionLink) -> Result<()> {
1409        Self::write_link(&self.conn, link, false)?;
1410        // Local edit: re-open the parent session for the next sync.
1411        self.mark_session_unsynced(&link.session_id)?;
1412        Ok(())
1413    }
1414
1415    /// Writes a session link using the given connection.
1416    ///
1417    /// When `ignore_conflict` is true an existing id is left untouched
1418    /// (`ON CONFLICT DO NOTHING`), which the remote-merge path needs for
1419    /// idempotency. Shared by [`Self::insert_link`] (local edit) and
1420    /// [`Self::merge_remote_record`] (merge path) so both use identical SQL.
1421    fn write_link(conn: &Connection, link: &SessionLink, ignore_conflict: bool) -> Result<()> {
1422        let sql = if ignore_conflict {
1423            r#"
1424            INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
1425            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1426            ON CONFLICT(id) DO NOTHING
1427            "#
1428        } else {
1429            r#"
1430            INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
1431            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1432            "#
1433        };
1434        conn.execute(
1435            sql,
1436            params![
1437                link.id.to_string(),
1438                link.session_id.to_string(),
1439                format!("{:?}", link.link_type).to_lowercase(),
1440                link.commit_sha,
1441                link.branch,
1442                link.remote,
1443                link.created_at.to_rfc3339(),
1444                format!("{:?}", link.created_by).to_lowercase(),
1445                link.confidence,
1446            ],
1447        )?;
1448        Ok(())
1449    }
1450
1451    /// Retrieves all session links for a commit.
1452    ///
1453    /// Supports prefix matching on the commit SHA, allowing short SHAs
1454    /// (e.g., first 8 characters) to be used for lookup.
1455    pub fn get_links_by_commit(&self, commit_sha: &str) -> Result<Vec<SessionLink>> {
1456        let mut stmt = self.conn.prepare(
1457            "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence 
1458             FROM session_links 
1459             WHERE commit_sha LIKE ?1"
1460        )?;
1461
1462        let pattern = format!("{commit_sha}%");
1463        let rows = stmt.query_map(params![pattern], Self::row_to_link)?;
1464
1465        rows.collect::<Result<Vec<_>, _>>()
1466            .context("Failed to get links")
1467    }
1468
1469    /// Retrieves all links associated with a session.
1470    ///
1471    /// A session can be linked to multiple commits if it spans
1472    /// several git operations.
1473    pub fn get_links_by_session(&self, session_id: &Uuid) -> Result<Vec<SessionLink>> {
1474        let mut stmt = self.conn.prepare(
1475            "SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence 
1476             FROM session_links 
1477             WHERE session_id = ?1"
1478        )?;
1479
1480        let rows = stmt.query_map(params![session_id.to_string()], Self::row_to_link)?;
1481
1482        rows.collect::<Result<Vec<_>, _>>()
1483            .context("Failed to get links")
1484    }
1485
1486    fn row_to_link(row: &rusqlite::Row) -> rusqlite::Result<SessionLink> {
1487        use super::models::{LinkCreator, LinkType};
1488
1489        let link_type_str: String = row.get(2)?;
1490        let created_by_str: String = row.get(7)?;
1491
1492        Ok(SessionLink {
1493            id: parse_uuid(&row.get::<_, String>(0)?)?,
1494            session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1495            link_type: match link_type_str.as_str() {
1496                "commit" => LinkType::Commit,
1497                "branch" => LinkType::Branch,
1498                "pr" => LinkType::Pr,
1499                _ => LinkType::Manual,
1500            },
1501            commit_sha: row.get(3)?,
1502            branch: row.get(4)?,
1503            remote: row.get(5)?,
1504            created_at: parse_datetime(&row.get::<_, String>(6)?)?,
1505            created_by: match created_by_str.as_str() {
1506                "auto" => LinkCreator::Auto,
1507                _ => LinkCreator::User,
1508            },
1509            confidence: row.get(8)?,
1510        })
1511    }
1512
1513    /// Deletes a specific session link by its ID.
1514    ///
1515    /// Returns `true` if a link was deleted, `false` if no link with that ID existed.
1516    ///
1517    /// Note: This method is part of the public API for programmatic use,
1518    /// though the CLI currently uses session/commit-based deletion.
1519    #[allow(dead_code)]
1520    pub fn delete_link(&self, link_id: &Uuid) -> Result<bool> {
1521        // Look up the parent session before deleting so the removal can be
1522        // reflected on the next sync.
1523        let session_id: Option<String> = self
1524            .conn
1525            .query_row(
1526                "SELECT session_id FROM session_links WHERE id = ?1",
1527                params![link_id.to_string()],
1528                |row| row.get(0),
1529            )
1530            .optional()?;
1531        let rows_affected = self.conn.execute(
1532            "DELETE FROM session_links WHERE id = ?1",
1533            params![link_id.to_string()],
1534        )?;
1535        if rows_affected > 0 {
1536            if let Some(sid) = session_id {
1537                let sid = parse_uuid(&sid)?;
1538                // User-facing deletion: tombstone so the removal propagates.
1539                Self::record_tombstone(
1540                    &self.conn,
1541                    &link_id.to_string(),
1542                    TOMBSTONE_KIND_LINK,
1543                    &sid,
1544                    Utc::now(),
1545                )?;
1546                self.mark_session_unsynced(&sid)?;
1547            }
1548        }
1549        Ok(rows_affected > 0)
1550    }
1551
1552    /// Deletes all links for a session.
1553    ///
1554    /// Returns the number of links deleted.
1555    pub fn delete_links_by_session(&self, session_id: &Uuid) -> Result<usize> {
1556        // Capture the link ids before deleting so each removal can be
1557        // tombstoned and propagate to other machines.
1558        let ids = self.child_ids("session_links", session_id)?;
1559        let rows_affected = self.conn.execute(
1560            "DELETE FROM session_links WHERE session_id = ?1",
1561            params![session_id.to_string()],
1562        )?;
1563        if rows_affected > 0 {
1564            let now = Utc::now();
1565            for id in &ids {
1566                Self::record_tombstone(&self.conn, id, TOMBSTONE_KIND_LINK, session_id, now)?;
1567            }
1568            // Local edit: re-open the parent session for the next sync.
1569            self.mark_session_unsynced(session_id)?;
1570        }
1571        Ok(rows_affected)
1572    }
1573
1574    /// Deletes a link between a specific session and commit.
1575    ///
1576    /// The commit_sha is matched as a prefix, so short SHAs work.
1577    /// Returns `true` if a link was deleted, `false` if no matching link existed.
1578    pub fn delete_link_by_session_and_commit(
1579        &self,
1580        session_id: &Uuid,
1581        commit_sha: &str,
1582    ) -> Result<bool> {
1583        let pattern = format!("{commit_sha}%");
1584        // Capture the matching link ids before deleting so each removal can be
1585        // tombstoned and propagate to other machines.
1586        let ids: Vec<String> = {
1587            let mut stmt = self.conn.prepare(
1588                "SELECT id FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1589            )?;
1590            let rows =
1591                stmt.query_map(params![session_id.to_string(), pattern], |row| row.get(0))?;
1592            rows.collect::<Result<Vec<_>, _>>()
1593                .context("Failed to read link ids")?
1594        };
1595        let rows_affected = self.conn.execute(
1596            "DELETE FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
1597            params![session_id.to_string(), pattern],
1598        )?;
1599        if rows_affected > 0 {
1600            let now = Utc::now();
1601            for id in &ids {
1602                Self::record_tombstone(&self.conn, id, TOMBSTONE_KIND_LINK, session_id, now)?;
1603            }
1604            // Local edit: re-open the parent session for the next sync.
1605            self.mark_session_unsynced(session_id)?;
1606        }
1607        Ok(rows_affected > 0)
1608    }
1609
1610    // ==================== Search ====================
1611
1612    /// Searches message content using full-text search.
1613    ///
1614    /// Uses SQLite FTS5 to search for messages matching the query.
1615    /// Returns results ordered by FTS5 relevance ranking.
1616    ///
1617    /// Optional filters:
1618    /// - `working_dir`: Filter by working directory prefix
1619    /// - `since`: Filter by minimum timestamp
1620    /// - `role`: Filter by message role
1621    ///
1622    /// Note: This is the legacy search API. For new code, use `search_with_options`.
1623    #[allow(dead_code)]
1624    pub fn search_messages(
1625        &self,
1626        query: &str,
1627        limit: usize,
1628        working_dir: Option<&str>,
1629        since: Option<chrono::DateTime<chrono::Utc>>,
1630        role: Option<&str>,
1631    ) -> Result<Vec<SearchResult>> {
1632        use super::models::SearchOptions;
1633
1634        // Convert to SearchOptions and use the new method
1635        let options = SearchOptions {
1636            query: query.to_string(),
1637            limit,
1638            repo: working_dir.map(|s| s.to_string()),
1639            since,
1640            role: role.map(|s| s.to_string()),
1641            ..Default::default()
1642        };
1643
1644        self.search_with_options(&options)
1645    }
1646
1647    /// Searches messages and session metadata using full-text search with filters.
1648    ///
1649    /// Uses SQLite FTS5 to search for messages matching the query.
1650    /// Also searches session metadata (tool, project, branch) via sessions_fts.
1651    /// Returns results ordered by FTS5 relevance ranking.
1652    ///
1653    /// Supports extensive filtering via SearchOptions:
1654    /// - `tool`: Filter by AI tool name
1655    /// - `since`/`until`: Filter by date range
1656    /// - `project`: Filter by project name (partial match)
1657    /// - `branch`: Filter by git branch (partial match)
1658    /// - `role`: Filter by message role
1659    /// - `repo`: Filter by working directory prefix
1660    pub fn search_with_options(
1661        &self,
1662        options: &super::models::SearchOptions,
1663    ) -> Result<Vec<SearchResult>> {
1664        // Escape the query for FTS5 to handle special characters
1665        let escaped_query = escape_fts5_query(&options.query);
1666
1667        // Build the query dynamically based on filters
1668        // Use UNION to search both message content and session metadata
1669        let mut sql = String::from(
1670            r#"
1671            SELECT
1672                m.session_id,
1673                m.id as message_id,
1674                m.role,
1675                snippet(messages_fts, 1, '**', '**', '...', 32) as snippet,
1676                m.timestamp,
1677                s.working_directory,
1678                s.tool,
1679                s.git_branch,
1680                s.message_count,
1681                s.started_at,
1682                m.idx as message_index
1683            FROM messages_fts fts
1684            JOIN messages m ON fts.message_id = m.id
1685            JOIN sessions s ON m.session_id = s.id
1686            WHERE messages_fts MATCH ?1
1687            "#,
1688        );
1689
1690        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(escaped_query.clone())];
1691        let mut param_idx = 2;
1692
1693        // Add filters
1694        if options.repo.is_some() {
1695            sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1696            param_idx += 1;
1697        }
1698        if options.tool.is_some() {
1699            sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
1700            param_idx += 1;
1701        }
1702        if options.since.is_some() {
1703            sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
1704            param_idx += 1;
1705        }
1706        if options.until.is_some() {
1707            sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
1708            param_idx += 1;
1709        }
1710        if options.project.is_some() {
1711            sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1712            param_idx += 1;
1713        }
1714        if options.branch.is_some() {
1715            sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
1716            param_idx += 1;
1717        }
1718        if options.role.is_some() {
1719            sql.push_str(&format!(" AND m.role = ?{param_idx}"));
1720            param_idx += 1;
1721        }
1722
1723        // Build first SELECT parameter list (after the FTS query param which is already in params_vec)
1724        if let Some(ref wd) = options.repo {
1725            params_vec.push(Box::new(format!("{wd}%")));
1726        }
1727        if let Some(ref tool) = options.tool {
1728            params_vec.push(Box::new(tool.clone()));
1729        }
1730        if let Some(ts) = options.since {
1731            params_vec.push(Box::new(ts.to_rfc3339()));
1732        }
1733        if let Some(ts) = options.until {
1734            params_vec.push(Box::new(ts.to_rfc3339()));
1735        }
1736        if let Some(ref project) = options.project {
1737            params_vec.push(Box::new(format!("%{project}%")));
1738        }
1739        if let Some(ref branch) = options.branch {
1740            params_vec.push(Box::new(format!("%{branch}%")));
1741        }
1742        if let Some(ref role) = options.role {
1743            params_vec.push(Box::new(role.clone()));
1744        }
1745
1746        // Add UNION for session metadata search (only if not filtering by role)
1747        // This finds sessions where the metadata matches, returning the first message as representative
1748        // Uses LIKE patterns instead of FTS5 for metadata since paths contain special characters
1749        let include_metadata_search = options.role.is_none();
1750        let metadata_query_pattern = format!("%{}%", options.query);
1751
1752        if include_metadata_search {
1753            // For the metadata search, we need 3 separate params for the OR conditions
1754            let meta_param1 = param_idx;
1755            let meta_param2 = param_idx + 1;
1756            let meta_param3 = param_idx + 2;
1757            param_idx += 3;
1758
1759            sql.push_str(&format!(
1760                r#"
1761            UNION
1762            SELECT
1763                s.id as session_id,
1764                (SELECT id FROM messages WHERE session_id = s.id ORDER BY idx LIMIT 1) as message_id,
1765                'user' as role,
1766                substr(s.tool || ' session in ' || s.working_directory || COALESCE(' on branch ' || s.git_branch, ''), 1, 100) as snippet,
1767                s.started_at as timestamp,
1768                s.working_directory,
1769                s.tool,
1770                s.git_branch,
1771                s.message_count,
1772                s.started_at,
1773                0 as message_index
1774            FROM sessions s
1775            WHERE (
1776                s.tool LIKE ?{meta_param1}
1777                OR s.working_directory LIKE ?{meta_param2}
1778                OR s.git_branch LIKE ?{meta_param3}
1779            )
1780            "#
1781            ));
1782
1783            // Add metadata patterns to params
1784            params_vec.push(Box::new(metadata_query_pattern.clone()));
1785            params_vec.push(Box::new(metadata_query_pattern.clone()));
1786            params_vec.push(Box::new(metadata_query_pattern));
1787
1788            // Re-apply session-level filters to the UNION query
1789            if let Some(repo) = &options.repo {
1790                sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1791                params_vec.push(Box::new(format!("{}%", repo)));
1792                param_idx += 1;
1793            }
1794            if let Some(tool) = &options.tool {
1795                sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
1796                params_vec.push(Box::new(tool.clone()));
1797                param_idx += 1;
1798            }
1799            if let Some(since) = options.since {
1800                sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
1801                params_vec.push(Box::new(since.to_rfc3339()));
1802                param_idx += 1;
1803            }
1804            if let Some(until) = options.until {
1805                sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
1806                params_vec.push(Box::new(until.to_rfc3339()));
1807                param_idx += 1;
1808            }
1809            if let Some(project) = &options.project {
1810                sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
1811                params_vec.push(Box::new(format!("%{}%", project)));
1812                param_idx += 1;
1813            }
1814            if let Some(branch) = &options.branch {
1815                sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
1816                params_vec.push(Box::new(format!("%{}%", branch)));
1817                param_idx += 1;
1818            }
1819        }
1820
1821        sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{param_idx}"));
1822        params_vec.push(Box::new(options.limit as i64));
1823
1824        // Prepare and execute
1825        let mut stmt = self.conn.prepare(&sql)?;
1826        let params_refs: Vec<&dyn rusqlite::ToSql> =
1827            params_vec.iter().map(|p| p.as_ref()).collect();
1828
1829        let rows = stmt.query_map(params_refs.as_slice(), |row| {
1830            let role_str: String = row.get(2)?;
1831            let git_branch: Option<String> = row.get(7)?;
1832            let started_at_str: Option<String> = row.get(9)?;
1833
1834            Ok(SearchResult {
1835                session_id: parse_uuid(&row.get::<_, String>(0)?)?,
1836                message_id: parse_uuid(&row.get::<_, String>(1)?)?,
1837                role: match role_str.as_str() {
1838                    "user" => MessageRole::User,
1839                    "assistant" => MessageRole::Assistant,
1840                    "system" => MessageRole::System,
1841                    _ => MessageRole::User,
1842                },
1843                snippet: row.get(3)?,
1844                timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1845                working_directory: row.get(5)?,
1846                tool: row.get(6)?,
1847                git_branch,
1848                session_message_count: row.get(8)?,
1849                session_started_at: started_at_str.map(|s| parse_datetime(&s)).transpose()?,
1850                message_index: row.get(10)?,
1851            })
1852        })?;
1853
1854        rows.collect::<Result<Vec<_>, _>>()
1855            .context("Failed to search messages")
1856    }
1857
1858    /// Gets messages around a specific message for context.
1859    ///
1860    /// Returns N messages before and N messages after the specified message,
1861    /// useful for displaying search results with surrounding context.
1862    pub fn get_context_messages(
1863        &self,
1864        session_id: &Uuid,
1865        message_index: i32,
1866        context_count: usize,
1867    ) -> Result<(Vec<Message>, Vec<Message>)> {
1868        // Get messages before
1869        let mut before_stmt = self.conn.prepare(
1870            "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1871             FROM messages
1872             WHERE session_id = ?1 AND idx < ?2
1873             ORDER BY idx DESC
1874             LIMIT ?3",
1875        )?;
1876
1877        let before_rows = before_stmt.query_map(
1878            params![session_id.to_string(), message_index, context_count as i64],
1879            Self::row_to_message,
1880        )?;
1881
1882        let mut before: Vec<Message> = before_rows
1883            .collect::<Result<Vec<_>, _>>()
1884            .context("Failed to get before messages")?;
1885        before.reverse(); // Put in chronological order
1886
1887        // Get messages after
1888        let mut after_stmt = self.conn.prepare(
1889            "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1890             FROM messages
1891             WHERE session_id = ?1 AND idx > ?2
1892             ORDER BY idx ASC
1893             LIMIT ?3",
1894        )?;
1895
1896        let after_rows = after_stmt.query_map(
1897            params![session_id.to_string(), message_index, context_count as i64],
1898            Self::row_to_message,
1899        )?;
1900
1901        let after: Vec<Message> = after_rows
1902            .collect::<Result<Vec<_>, _>>()
1903            .context("Failed to get after messages")?;
1904
1905        Ok((before, after))
1906    }
1907
1908    /// Gets a single message by its index within a session.
1909    #[allow(dead_code)]
1910    pub fn get_message_by_index(&self, session_id: &Uuid, index: i32) -> Result<Option<Message>> {
1911        self.conn
1912            .query_row(
1913                "SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
1914                 FROM messages
1915                 WHERE session_id = ?1 AND idx = ?2",
1916                params![session_id.to_string(), index],
1917                Self::row_to_message,
1918            )
1919            .optional()
1920            .context("Failed to get message by index")
1921    }
1922
1923    fn row_to_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
1924        let role_str: String = row.get(5)?;
1925        let content_str: String = row.get(6)?;
1926
1927        let parent_id_str: Option<String> = row.get(2)?;
1928        let parent_id = match parent_id_str {
1929            Some(s) => Some(parse_uuid(&s)?),
1930            None => None,
1931        };
1932
1933        Ok(Message {
1934            id: parse_uuid(&row.get::<_, String>(0)?)?,
1935            session_id: parse_uuid(&row.get::<_, String>(1)?)?,
1936            parent_id,
1937            index: row.get(3)?,
1938            timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
1939            role: match role_str.as_str() {
1940                "user" => MessageRole::User,
1941                "assistant" => MessageRole::Assistant,
1942                "system" => MessageRole::System,
1943                _ => MessageRole::User,
1944            },
1945            content: serde_json::from_str(&content_str)
1946                .unwrap_or(MessageContent::Text(content_str)),
1947            model: row.get(7)?,
1948            git_branch: row.get(8)?,
1949            cwd: row.get(9)?,
1950        })
1951    }
1952
1953    /// Rebuilds the full-text search index from existing messages and sessions.
1954    ///
1955    /// This should be called when:
1956    /// - Upgrading from a database without FTS support
1957    /// - The FTS index becomes corrupted or out of sync
1958    ///
1959    /// Returns the number of messages indexed.
1960    pub fn rebuild_search_index(&self) -> Result<usize> {
1961        // Clear existing FTS data
1962        self.conn.execute("DELETE FROM messages_fts", [])?;
1963        self.conn.execute("DELETE FROM sessions_fts", [])?;
1964
1965        // Reindex all messages
1966        let mut msg_stmt = self.conn.prepare("SELECT id, content FROM messages")?;
1967
1968        let rows = msg_stmt.query_map([], |row| {
1969            let id: String = row.get(0)?;
1970            let content_json: String = row.get(1)?;
1971            Ok((id, content_json))
1972        })?;
1973
1974        let mut count = 0;
1975        for row in rows {
1976            let (id, content_json) = row?;
1977            // Parse the content JSON and extract text
1978            let content: MessageContent = serde_json::from_str(&content_json)
1979                .unwrap_or(MessageContent::Text(content_json.clone()));
1980            let text_content = content.text();
1981
1982            if !text_content.is_empty() {
1983                self.conn.execute(
1984                    "INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
1985                    params![id, text_content],
1986                )?;
1987                count += 1;
1988            }
1989        }
1990
1991        // Reindex all sessions for metadata search
1992        let mut session_stmt = self
1993            .conn
1994            .prepare("SELECT id, tool, working_directory, git_branch FROM sessions")?;
1995
1996        let session_rows = session_stmt.query_map([], |row| {
1997            let id: String = row.get(0)?;
1998            let tool: String = row.get(1)?;
1999            let working_directory: String = row.get(2)?;
2000            let git_branch: Option<String> = row.get(3)?;
2001            Ok((id, tool, working_directory, git_branch))
2002        })?;
2003
2004        for row in session_rows {
2005            let (id, tool, working_directory, git_branch) = row?;
2006            self.conn.execute(
2007                "INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
2008                params![id, tool, working_directory, git_branch.unwrap_or_default()],
2009            )?;
2010        }
2011
2012        Ok(count)
2013    }
2014
2015    /// Checks if the search index needs rebuilding.
2016    ///
2017    /// Returns true if there are messages or sessions in the database but the FTS
2018    /// indexes are empty, indicating data was imported before FTS was added.
2019    pub fn search_index_needs_rebuild(&self) -> Result<bool> {
2020        let message_count: i32 =
2021            self.conn
2022                .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
2023
2024        let msg_fts_count: i32 =
2025            self.conn
2026                .query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
2027
2028        let session_count: i32 =
2029            self.conn
2030                .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
2031
2032        let session_fts_count: i32 =
2033            self.conn
2034                .query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
2035
2036        // Rebuild needed if we have messages/sessions but either FTS index is empty
2037        Ok((message_count > 0 && msg_fts_count == 0)
2038            || (session_count > 0 && session_fts_count == 0))
2039    }
2040
2041    // ==================== Memories ====================
2042
2043    /// Maps a database row to a [`Memory`].
2044    fn row_to_memory(row: &rusqlite::Row) -> rusqlite::Result<Memory> {
2045        Ok(Memory {
2046            id: parse_uuid(&row.get::<_, String>(0)?)?,
2047            project_path: row.get(1)?,
2048            source_tool: row.get(2)?,
2049            name: row.get(3)?,
2050            description: row.get(4)?,
2051            memory_type: row.get(5)?,
2052            content: row.get(6)?,
2053            file_path: row.get(7)?,
2054            updated_at: parse_datetime(&row.get::<_, String>(8)?)?,
2055        })
2056    }
2057
2058    /// Inserts a mirrored memory or updates the existing one for the same source
2059    /// file.
2060    ///
2061    /// Memories are uniquely identified by their
2062    /// `(project_path, source_tool, file_path)` triple. If a memory already
2063    /// exists for that source file its existing id is preserved and its fields
2064    /// are updated in place. The `memories_fts` index is kept in sync so search
2065    /// reflects the latest content. Returns the id of the stored memory.
2066    pub fn upsert_memory(&self, memory: &Memory) -> Result<Uuid> {
2067        // Preserve the existing id when the source file is already mirrored so
2068        // that the FTS row and any external references stay stable.
2069        let existing_id: Option<String> = self
2070            .conn
2071            .query_row(
2072                "SELECT id FROM memories WHERE project_path = ?1 AND source_tool = ?2 AND file_path = ?3",
2073                params![memory.project_path, memory.source_tool, memory.file_path],
2074                |row| row.get(0),
2075            )
2076            .optional()?;
2077
2078        let id = match existing_id {
2079            Some(s) => Uuid::parse_str(&s).unwrap_or(memory.id),
2080            None => memory.id,
2081        };
2082
2083        self.conn.execute(
2084            r#"
2085            INSERT INTO memories (id, project_path, source_tool, name, description, memory_type, content, file_path, updated_at)
2086            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2087            ON CONFLICT(project_path, source_tool, file_path) DO UPDATE SET
2088                name = excluded.name,
2089                description = excluded.description,
2090                memory_type = excluded.memory_type,
2091                content = excluded.content,
2092                updated_at = excluded.updated_at
2093            "#,
2094            params![
2095                id.to_string(),
2096                memory.project_path,
2097                memory.source_tool,
2098                memory.name,
2099                memory.description,
2100                memory.memory_type,
2101                memory.content,
2102                memory.file_path,
2103                memory.updated_at.to_rfc3339(),
2104            ],
2105        )?;
2106
2107        // Keep the FTS index in sync: replace any prior row for this memory.
2108        self.conn.execute(
2109            "DELETE FROM memories_fts WHERE memory_id = ?1",
2110            params![id.to_string()],
2111        )?;
2112        self.conn.execute(
2113            "INSERT INTO memories_fts (memory_id, name, description, content) VALUES (?1, ?2, ?3, ?4)",
2114            params![
2115                id.to_string(),
2116                memory.name,
2117                memory.description.as_deref().unwrap_or(""),
2118                memory.content,
2119            ],
2120        )?;
2121
2122        Ok(id)
2123    }
2124
2125    /// Deletes a mirrored memory by id, also removing it from the FTS index.
2126    ///
2127    /// Returns true if a memory was deleted.
2128    pub fn delete_memory(&self, id: &Uuid) -> Result<bool> {
2129        self.conn.execute(
2130            "DELETE FROM memories_fts WHERE memory_id = ?1",
2131            params![id.to_string()],
2132        )?;
2133        let rows = self.conn.execute(
2134            "DELETE FROM memories WHERE id = ?1",
2135            params![id.to_string()],
2136        )?;
2137        Ok(rows > 0)
2138    }
2139
2140    /// Returns all mirrored memories for a project and source tool.
2141    ///
2142    /// Results are ordered by name for stable display.
2143    pub fn get_memories(&self, project_path: &str, source_tool: &str) -> Result<Vec<Memory>> {
2144        let mut stmt = self.conn.prepare(
2145            "SELECT id, project_path, source_tool, name, description, memory_type, content, file_path, updated_at
2146             FROM memories
2147             WHERE project_path = ?1 AND source_tool = ?2
2148             ORDER BY name",
2149        )?;
2150
2151        let memories = stmt
2152            .query_map(params![project_path, source_tool], Self::row_to_memory)?
2153            .collect::<rusqlite::Result<Vec<_>>>()?;
2154
2155        Ok(memories)
2156    }
2157
2158    /// Full-text searches mirrored memories for a project and source tool.
2159    ///
2160    /// Matches against memory name, description, and content using FTS5 and
2161    /// returns results ordered by relevance, limited to `limit` rows.
2162    pub fn search_memories(
2163        &self,
2164        project_path: &str,
2165        source_tool: &str,
2166        query: &str,
2167        limit: usize,
2168    ) -> Result<Vec<Memory>> {
2169        let escaped_query = escape_fts5_query(query);
2170
2171        let mut stmt = self.conn.prepare(
2172            "SELECT m.id, m.project_path, m.source_tool, m.name, m.description, m.memory_type, m.content, m.file_path, m.updated_at
2173             FROM memories_fts fts
2174             JOIN memories m ON fts.memory_id = m.id
2175             WHERE memories_fts MATCH ?1 AND m.project_path = ?2 AND m.source_tool = ?3
2176             ORDER BY rank
2177             LIMIT ?4",
2178        )?;
2179
2180        let memories = stmt
2181            .query_map(
2182                params![escaped_query, project_path, source_tool, limit as i64],
2183                Self::row_to_memory,
2184            )?
2185            .collect::<rusqlite::Result<Vec<_>>>()?;
2186
2187        Ok(memories)
2188    }
2189
2190    // ==================== Sync ====================
2191
2192    /// Returns sessions that have not been synced.
2193    ///
2194    /// Unsynced sessions are those where `synced_at` is NULL. Returns sessions
2195    /// ordered by start time (oldest first) to sync in chronological order.
2196    ///
2197    /// Note: production sync scopes pushes with
2198    /// [`Database::get_unsynced_sessions_for_repo`] (per-repo) or
2199    /// [`Database::get_unsynced_global_sessions`] (global). This unscoped
2200    /// accessor is retained as public API and is exercised by the storage tests.
2201    #[allow(dead_code)]
2202    pub fn get_unsynced_sessions(&self) -> Result<Vec<Session>> {
2203        let mut stmt = self.conn.prepare(
2204            "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
2205             FROM sessions
2206             WHERE synced_at IS NULL
2207             ORDER BY started_at ASC"
2208        )?;
2209
2210        let rows = stmt.query_map([], Self::row_to_session)?;
2211
2212        rows.collect::<Result<Vec<_>, _>>()
2213            .context("Failed to get unsynced sessions")
2214    }
2215
2216    /// Returns unsynced sessions whose working directory is inside `repo_path`.
2217    ///
2218    /// A per-repo lore store must hold only the reasoning history produced in
2219    /// that repository, so an outbound sync scopes its push to sessions whose
2220    /// `working_directory` is the repo root or a descendant of it. Cross-project
2221    /// and cross-tool sessions captured elsewhere are excluded, keeping one
2222    /// repo's store from leaking a user's entire history to teammates.
2223    ///
2224    /// Matching uses the shared repo-scoping predicate (see
2225    /// [`repo_scope_predicate`]), which covers both the repo path as given and
2226    /// its canonicalized form so a session captured under a symlinked path still
2227    /// syncs, while a prefix sibling such as `/x/foobar` never matches the repo
2228    /// `/x/foo`. Results are ordered oldest first to sync in chronological order.
2229    pub fn get_unsynced_sessions_for_repo(&self, repo_path: &Path) -> Result<Vec<Session>> {
2230        let (predicate, binds) = repo_scope_predicate(repo_path);
2231        let sql = format!(
2232            "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
2233             FROM sessions
2234             WHERE synced_at IS NULL
2235               AND ({predicate})
2236             ORDER BY started_at ASC"
2237        );
2238
2239        let mut stmt = self.conn.prepare(&sql)?;
2240        let rows = stmt.query_map(
2241            rusqlite::params_from_iter(binds.iter()),
2242            Self::row_to_session,
2243        )?;
2244
2245        rows.collect::<Result<Vec<_>, _>>()
2246            .context("Failed to get unsynced sessions for repo")
2247    }
2248
2249    /// Returns the ids of ALL sessions (regardless of `synced_at`) whose working
2250    /// directory is inside `repo_path`.
2251    ///
2252    /// Unlike [`Database::get_unsynced_sessions_for_repo`], this ignores the
2253    /// synced state: it answers "which session ids belong to this repo", which
2254    /// the outbound sync uses to decide whether an already-stored, local-only
2255    /// session artifact is in scope to carry forward. It shares the exact same
2256    /// directory scoping (see [`repo_scope_predicate`]) so the two agree on what
2257    /// "in this repo" means.
2258    pub fn get_session_ids_for_repo(&self, repo_path: &Path) -> Result<HashSet<Uuid>> {
2259        let (predicate, binds) = repo_scope_predicate(repo_path);
2260        let sql = format!("SELECT id FROM sessions WHERE {predicate}");
2261
2262        let mut stmt = self.conn.prepare(&sql)?;
2263        let rows = stmt.query_map(rusqlite::params_from_iter(binds.iter()), |row| {
2264            let id: String = row.get(0)?;
2265            parse_uuid(&id)
2266        })?;
2267
2268        rows.collect::<rusqlite::Result<HashSet<Uuid>>>()
2269            .context("Failed to get session ids for repo")
2270    }
2271
2272    /// Returns the ids of ALL sessions in the database, regardless of sync state.
2273    ///
2274    /// The global personal store aggregates every session across all repos and
2275    /// tools, so its carry-forward scope is the entire session set (the global
2276    /// analogue of [`Database::get_session_ids_for_repo`], which scopes to one
2277    /// repo). Used by the global sync to decide which already-stored, local-only
2278    /// session artifacts to carry forward.
2279    pub fn get_all_session_ids(&self) -> Result<HashSet<Uuid>> {
2280        let mut stmt = self.conn.prepare("SELECT id FROM sessions")?;
2281        let rows = stmt.query_map([], |row| {
2282            let id: String = row.get(0)?;
2283            parse_uuid(&id)
2284        })?;
2285
2286        rows.collect::<rusqlite::Result<HashSet<Uuid>>>()
2287            .context("Failed to get all session ids")
2288    }
2289
2290    /// Returns sessions that have not been synced to the global personal store.
2291    ///
2292    /// Unsynced-global sessions are those where `global_synced_at` is NULL. Unlike
2293    /// [`Database::get_unsynced_sessions_for_repo`], this is not scoped to any
2294    /// repository: the global store holds every session regardless of working
2295    /// directory. Returns sessions ordered by start time (oldest first).
2296    pub fn get_unsynced_global_sessions(&self) -> Result<Vec<Session>> {
2297        let mut stmt = self.conn.prepare(
2298            "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
2299             FROM sessions
2300             WHERE global_synced_at IS NULL
2301             ORDER BY started_at ASC"
2302        )?;
2303
2304        let rows = stmt.query_map([], Self::row_to_session)?;
2305
2306        rows.collect::<Result<Vec<_>, _>>()
2307            .context("Failed to get unsynced global sessions")
2308    }
2309
2310    /// Returns the count of sessions not yet synced to the global personal store.
2311    pub fn unsynced_global_count(&self) -> Result<i32> {
2312        let count: i32 = self.conn.query_row(
2313            "SELECT COUNT(*) FROM sessions WHERE global_synced_at IS NULL",
2314            [],
2315            |row| row.get(0),
2316        )?;
2317        Ok(count)
2318    }
2319
2320    /// Marks sessions as synced to the global personal store.
2321    ///
2322    /// Updates the `global_synced_at` column for all specified session IDs,
2323    /// leaving the per-repo `synced_at` track untouched.
2324    pub fn mark_global_synced(
2325        &self,
2326        session_ids: &[Uuid],
2327        synced_at: DateTime<Utc>,
2328    ) -> Result<usize> {
2329        if session_ids.is_empty() {
2330            return Ok(0);
2331        }
2332
2333        let synced_at_str = synced_at.to_rfc3339();
2334        let mut total_updated = 0;
2335
2336        for id in session_ids {
2337            let updated = self.conn.execute(
2338                "UPDATE sessions SET global_synced_at = ?1 WHERE id = ?2",
2339                params![synced_at_str, id.to_string()],
2340            )?;
2341            total_updated += updated;
2342        }
2343
2344        Ok(total_updated)
2345    }
2346
2347    /// Returns the most recent global-store sync timestamp across all sessions.
2348    ///
2349    /// Returns None if no session has been synced to the global store yet.
2350    pub fn last_global_sync_time(&self) -> Result<Option<DateTime<Utc>>> {
2351        let result: Option<String> = self
2352            .conn
2353            .query_row(
2354                "SELECT MAX(global_synced_at) FROM sessions WHERE global_synced_at IS NOT NULL",
2355                [],
2356                |row| row.get(0),
2357            )
2358            .optional()?
2359            .flatten();
2360
2361        match result {
2362            Some(s) => Ok(Some(parse_datetime(&s)?)),
2363            None => Ok(None),
2364        }
2365    }
2366
2367    /// Returns the count of unsynced sessions whose working directory is inside
2368    /// `repo_path`.
2369    ///
2370    /// Uses the same directory scoping as
2371    /// [`Database::get_unsynced_sessions_for_repo`] so `lore sync status` reports
2372    /// what this repo will actually push.
2373    pub fn unsynced_session_count_for_repo(&self, repo_path: &Path) -> Result<i32> {
2374        let (predicate, binds) = repo_scope_predicate(repo_path);
2375        let sql = format!(
2376            "SELECT COUNT(*) FROM sessions
2377             WHERE synced_at IS NULL
2378               AND ({predicate})"
2379        );
2380
2381        let count: i32 =
2382            self.conn
2383                .query_row(&sql, rusqlite::params_from_iter(binds.iter()), |row| {
2384                    row.get(0)
2385                })?;
2386        Ok(count)
2387    }
2388
2389    /// Marks sessions as synced with the given timestamp.
2390    ///
2391    /// Updates the `synced_at` column for all specified session IDs.
2392    pub fn mark_sessions_synced(
2393        &self,
2394        session_ids: &[Uuid],
2395        synced_at: DateTime<Utc>,
2396    ) -> Result<usize> {
2397        if session_ids.is_empty() {
2398            return Ok(0);
2399        }
2400
2401        let synced_at_str = synced_at.to_rfc3339();
2402        let mut total_updated = 0;
2403
2404        for id in session_ids {
2405            let updated = self.conn.execute(
2406                "UPDATE sessions SET synced_at = ?1 WHERE id = ?2",
2407                params![synced_at_str, id.to_string()],
2408            )?;
2409            total_updated += updated;
2410        }
2411
2412        Ok(total_updated)
2413    }
2414
2415    /// Returns the most recent sync timestamp across all sessions.
2416    ///
2417    /// Returns None if no sessions have been synced yet.
2418    pub fn last_sync_time(&self) -> Result<Option<DateTime<Utc>>> {
2419        let result: Option<String> = self
2420            .conn
2421            .query_row(
2422                "SELECT MAX(synced_at) FROM sessions WHERE synced_at IS NOT NULL",
2423                [],
2424                |row| row.get(0),
2425            )
2426            .optional()?
2427            .flatten();
2428
2429        match result {
2430            Some(s) => Ok(Some(parse_datetime(&s)?)),
2431            None => Ok(None),
2432        }
2433    }
2434
2435    // ==================== Stats ====================
2436
2437    /// Returns the total number of sessions in the database.
2438    pub fn session_count(&self) -> Result<i32> {
2439        let count: i32 = self
2440            .conn
2441            .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
2442        Ok(count)
2443    }
2444
2445    /// Returns the total number of messages across all sessions.
2446    pub fn message_count(&self) -> Result<i32> {
2447        let count: i32 = self
2448            .conn
2449            .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
2450        Ok(count)
2451    }
2452
2453    /// Returns the total number of session links in the database.
2454    pub fn link_count(&self) -> Result<i32> {
2455        let count: i32 = self
2456            .conn
2457            .query_row("SELECT COUNT(*) FROM session_links", [], |row| row.get(0))?;
2458        Ok(count)
2459    }
2460
2461    /// Returns the path to the database file, if available.
2462    ///
2463    /// Returns `None` for in-memory databases.
2464    pub fn db_path(&self) -> Option<std::path::PathBuf> {
2465        self.conn.path().map(std::path::PathBuf::from)
2466    }
2467
2468    // ==================== Auto-linking ====================
2469
2470    /// Finds sessions that were active around a commit time.
2471    ///
2472    /// A session is considered active if the commit time falls within the
2473    /// window before and after the session's time range (started_at to ended_at).
2474    ///
2475    /// # Arguments
2476    ///
2477    /// * `commit_time` - The timestamp of the commit
2478    /// * `window_minutes` - The window in minutes before/after the session
2479    /// * `working_dir` - Optional working directory filter (prefix match)
2480    ///
2481    /// # Returns
2482    ///
2483    /// Sessions that were active near the commit time, ordered by proximity.
2484    pub fn find_sessions_near_commit_time(
2485        &self,
2486        commit_time: chrono::DateTime<chrono::Utc>,
2487        window_minutes: i64,
2488        working_dir: Option<&str>,
2489    ) -> Result<Vec<Session>> {
2490        // Convert commit time to RFC3339 for SQLite comparison
2491        let commit_time_str = commit_time.to_rfc3339();
2492
2493        // Calculate the time window boundaries
2494        let window = chrono::Duration::minutes(window_minutes);
2495        let window_start = (commit_time - window).to_rfc3339();
2496        let window_end = (commit_time + window).to_rfc3339();
2497
2498        let sql = if working_dir.is_some() {
2499            r#"
2500            SELECT id, tool, tool_version, started_at, ended_at, model,
2501                   working_directory, git_branch, source_path, message_count, machine_id
2502            FROM sessions
2503            WHERE working_directory LIKE ?1
2504              AND (
2505                  -- Session started before or during the window
2506                  (started_at <= ?3)
2507                  AND
2508                  -- Session ended after or during the window (or is still ongoing)
2509                  (ended_at IS NULL OR ended_at >= ?2)
2510              )
2511            ORDER BY
2512              -- Order by how close the session end (or start) is to commit time
2513              ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?4))
2514            "#
2515        } else {
2516            r#"
2517            SELECT id, tool, tool_version, started_at, ended_at, model,
2518                   working_directory, git_branch, source_path, message_count, machine_id
2519            FROM sessions
2520            WHERE
2521              -- Session started before or during the window
2522              (started_at <= ?2)
2523              AND
2524              -- Session ended after or during the window (or is still ongoing)
2525              (ended_at IS NULL OR ended_at >= ?1)
2526            ORDER BY
2527              -- Order by how close the session end (or start) is to commit time
2528              ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?3))
2529            "#
2530        };
2531
2532        let mut stmt = self.conn.prepare(sql)?;
2533
2534        let rows = if let Some(wd) = working_dir {
2535            stmt.query_map(
2536                params![format!("{wd}%"), window_start, window_end, commit_time_str],
2537                Self::row_to_session,
2538            )?
2539        } else {
2540            stmt.query_map(
2541                params![window_start, window_end, commit_time_str],
2542                Self::row_to_session,
2543            )?
2544        };
2545
2546        rows.collect::<Result<Vec<_>, _>>()
2547            .context("Failed to find sessions near commit time")
2548    }
2549
2550    /// Checks if a link already exists between a session and commit.
2551    ///
2552    /// Used to avoid creating duplicate links during auto-linking.
2553    pub fn link_exists(&self, session_id: &Uuid, commit_sha: &str) -> Result<bool> {
2554        let pattern = format!("{commit_sha}%");
2555        let count: i32 = self.conn.query_row(
2556            "SELECT COUNT(*) FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
2557            params![session_id.to_string(), pattern],
2558            |row| row.get(0),
2559        )?;
2560        Ok(count > 0)
2561    }
2562
2563    /// Finds sessions that are currently active or recently ended for a directory.
2564    ///
2565    /// This is used by forward auto-linking to find sessions to link when a commit
2566    /// is made. A session is considered "active" if:
2567    /// - It has no ended_at timestamp (still ongoing), OR
2568    /// - It ended within the last `recent_minutes` (default 5 minutes)
2569    ///
2570    /// The directory filter uses a prefix match, so sessions in subdirectories
2571    /// of the given path will also be included.
2572    ///
2573    /// # Arguments
2574    ///
2575    /// * `directory` - The repository root path to filter sessions by
2576    /// * `recent_minutes` - How many minutes back to consider "recent" (default 5)
2577    ///
2578    /// # Returns
2579    ///
2580    /// Sessions that are active or recently ended in the given directory.
2581    pub fn find_active_sessions_for_directory(
2582        &self,
2583        directory: &str,
2584        recent_minutes: Option<i64>,
2585    ) -> Result<Vec<Session>> {
2586        let minutes = recent_minutes.unwrap_or(5);
2587        let cutoff = (chrono::Utc::now() - chrono::Duration::minutes(minutes)).to_rfc3339();
2588        let (exact, trailing, like_pattern) = directory_match_params(directory);
2589
2590        let sql = r#"
2591            SELECT id, tool, tool_version, started_at, ended_at, model,
2592                   working_directory, git_branch, source_path, message_count, machine_id
2593            FROM sessions
2594            WHERE (working_directory = ?1
2595               OR working_directory = ?2
2596               OR working_directory LIKE ?3 ESCAPE '|')
2597              AND (ended_at IS NULL OR ended_at >= ?4)
2598            ORDER BY started_at DESC
2599        "#;
2600
2601        let mut stmt = self.conn.prepare(sql)?;
2602        let rows = stmt.query_map(
2603            params![exact, trailing, like_pattern, cutoff],
2604            Self::row_to_session,
2605        )?;
2606
2607        rows.collect::<Result<Vec<_>, _>>()
2608            .context("Failed to find active sessions for directory")
2609    }
2610
2611    // ==================== Session Deletion ====================
2612
2613    /// Deletes a session and all its associated data.
2614    ///
2615    /// Removes the session, all its messages, all FTS index entries, and all
2616    /// session links. Returns the counts of deleted items.
2617    ///
2618    /// # Returns
2619    ///
2620    /// A tuple of (messages_deleted, links_deleted) counts.
2621    pub fn delete_session(&self, session_id: &Uuid) -> Result<(usize, usize)> {
2622        let session_id_str = session_id.to_string();
2623
2624        // Delete from messages_fts first (need message IDs)
2625        self.conn.execute(
2626            "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
2627            params![session_id_str],
2628        )?;
2629
2630        // Delete messages
2631        let messages_deleted = self.conn.execute(
2632            "DELETE FROM messages WHERE session_id = ?1",
2633            params![session_id_str],
2634        )?;
2635
2636        // Delete links
2637        let links_deleted = self.conn.execute(
2638            "DELETE FROM session_links WHERE session_id = ?1",
2639            params![session_id_str],
2640        )?;
2641
2642        // Delete annotations
2643        self.conn.execute(
2644            "DELETE FROM annotations WHERE session_id = ?1",
2645            params![session_id_str],
2646        )?;
2647
2648        // Delete tags
2649        self.conn.execute(
2650            "DELETE FROM tags WHERE session_id = ?1",
2651            params![session_id_str],
2652        )?;
2653
2654        // Delete summary
2655        self.conn.execute(
2656            "DELETE FROM summaries WHERE session_id = ?1",
2657            params![session_id_str],
2658        )?;
2659
2660        // Delete from sessions_fts
2661        self.conn.execute(
2662            "DELETE FROM sessions_fts WHERE session_id = ?1",
2663            params![session_id_str],
2664        )?;
2665
2666        // Delete the session itself
2667        self.conn.execute(
2668            "DELETE FROM sessions WHERE id = ?1",
2669            params![session_id_str],
2670        )?;
2671
2672        Ok((messages_deleted, links_deleted))
2673    }
2674
2675    // ==================== Annotations ====================
2676
2677    /// Inserts a new annotation for a session.
2678    ///
2679    /// Annotations are user-created bookmarks or notes attached to sessions.
2680    pub fn insert_annotation(&self, annotation: &Annotation) -> Result<()> {
2681        Self::write_annotation(&self.conn, annotation, false)?;
2682        // Local edit: re-open the parent session for the next sync.
2683        self.mark_session_unsynced(&annotation.session_id)?;
2684        Ok(())
2685    }
2686
2687    /// Writes an annotation using the given connection.
2688    ///
2689    /// When `ignore_conflict` is true an existing id is left untouched, which the
2690    /// merge path needs for idempotency. Shared by [`Self::insert_annotation`]
2691    /// (local edit) and [`Self::merge_remote_record`] (merge path).
2692    fn write_annotation(
2693        conn: &Connection,
2694        annotation: &Annotation,
2695        ignore_conflict: bool,
2696    ) -> Result<()> {
2697        let sql = if ignore_conflict {
2698            r#"
2699            INSERT INTO annotations (id, session_id, content, created_at)
2700            VALUES (?1, ?2, ?3, ?4)
2701            ON CONFLICT(id) DO NOTHING
2702            "#
2703        } else {
2704            r#"
2705            INSERT INTO annotations (id, session_id, content, created_at)
2706            VALUES (?1, ?2, ?3, ?4)
2707            "#
2708        };
2709        conn.execute(
2710            sql,
2711            params![
2712                annotation.id.to_string(),
2713                annotation.session_id.to_string(),
2714                annotation.content,
2715                annotation.created_at.to_rfc3339(),
2716            ],
2717        )?;
2718        Ok(())
2719    }
2720
2721    /// Retrieves all annotations for a session.
2722    ///
2723    /// Annotations are returned in order of creation (oldest first).
2724    pub fn get_annotations(&self, session_id: &Uuid) -> Result<Vec<Annotation>> {
2725        let mut stmt = self.conn.prepare(
2726            "SELECT id, session_id, content, created_at
2727             FROM annotations
2728             WHERE session_id = ?1
2729             ORDER BY created_at ASC",
2730        )?;
2731
2732        let rows = stmt.query_map(params![session_id.to_string()], |row| {
2733            Ok(Annotation {
2734                id: parse_uuid(&row.get::<_, String>(0)?)?,
2735                session_id: parse_uuid(&row.get::<_, String>(1)?)?,
2736                content: row.get(2)?,
2737                created_at: parse_datetime(&row.get::<_, String>(3)?)?,
2738            })
2739        })?;
2740
2741        rows.collect::<Result<Vec<_>, _>>()
2742            .context("Failed to get annotations")
2743    }
2744
2745    /// Deletes an annotation by its ID.
2746    ///
2747    /// Returns `true` if an annotation was deleted, `false` if not found.
2748    #[allow(dead_code)]
2749    pub fn delete_annotation(&self, annotation_id: &Uuid) -> Result<bool> {
2750        // Look up the parent session before deleting so the removal can be
2751        // reflected on the next sync.
2752        let session_id: Option<String> = self
2753            .conn
2754            .query_row(
2755                "SELECT session_id FROM annotations WHERE id = ?1",
2756                params![annotation_id.to_string()],
2757                |row| row.get(0),
2758            )
2759            .optional()?;
2760        let rows_affected = self.conn.execute(
2761            "DELETE FROM annotations WHERE id = ?1",
2762            params![annotation_id.to_string()],
2763        )?;
2764        if rows_affected > 0 {
2765            if let Some(sid) = session_id {
2766                let sid = parse_uuid(&sid)?;
2767                // User-facing deletion: tombstone so the removal propagates.
2768                Self::record_tombstone(
2769                    &self.conn,
2770                    &annotation_id.to_string(),
2771                    TOMBSTONE_KIND_ANNOTATION,
2772                    &sid,
2773                    Utc::now(),
2774                )?;
2775                self.mark_session_unsynced(&sid)?;
2776            }
2777        }
2778        Ok(rows_affected > 0)
2779    }
2780
2781    /// Deletes all annotations for a session.
2782    ///
2783    /// Returns the number of annotations deleted.
2784    #[allow(dead_code)]
2785    pub fn delete_annotations_by_session(&self, session_id: &Uuid) -> Result<usize> {
2786        // Capture the annotation ids before deleting so each removal can be
2787        // tombstoned and propagate to other machines.
2788        let ids = self.child_ids("annotations", session_id)?;
2789        let rows_affected = self.conn.execute(
2790            "DELETE FROM annotations WHERE session_id = ?1",
2791            params![session_id.to_string()],
2792        )?;
2793        if rows_affected > 0 {
2794            let now = Utc::now();
2795            for id in &ids {
2796                Self::record_tombstone(&self.conn, id, TOMBSTONE_KIND_ANNOTATION, session_id, now)?;
2797            }
2798            // Local edit: re-open the parent session for the next sync.
2799            self.mark_session_unsynced(session_id)?;
2800        }
2801        Ok(rows_affected)
2802    }
2803
2804    // ==================== Tags ====================
2805
2806    /// Inserts a new tag for a session.
2807    ///
2808    /// Tags are unique per session, so attempting to add a duplicate
2809    /// tag label to the same session will fail with a constraint error.
2810    pub fn insert_tag(&self, tag: &Tag) -> Result<()> {
2811        Self::write_tag(&self.conn, tag, false)?;
2812        // Local edit: re-open the parent session for the next sync.
2813        self.mark_session_unsynced(&tag.session_id)?;
2814        Ok(())
2815    }
2816
2817    /// Writes a tag using the given connection.
2818    ///
2819    /// When `ignore_conflict` is true, both the primary key and the
2820    /// `UNIQUE(session_id, label)` conflicts are ignored, which the merge path
2821    /// needs for idempotency. Shared by [`Self::insert_tag`] (local edit) and
2822    /// [`Self::merge_remote_record`] (merge path).
2823    fn write_tag(conn: &Connection, tag: &Tag, ignore_conflict: bool) -> Result<()> {
2824        let sql = if ignore_conflict {
2825            r#"
2826            INSERT INTO tags (id, session_id, label, created_at)
2827            VALUES (?1, ?2, ?3, ?4)
2828            ON CONFLICT DO NOTHING
2829            "#
2830        } else {
2831            r#"
2832            INSERT INTO tags (id, session_id, label, created_at)
2833            VALUES (?1, ?2, ?3, ?4)
2834            "#
2835        };
2836        conn.execute(
2837            sql,
2838            params![
2839                tag.id.to_string(),
2840                tag.session_id.to_string(),
2841                tag.label,
2842                tag.created_at.to_rfc3339(),
2843            ],
2844        )?;
2845        Ok(())
2846    }
2847
2848    /// Retrieves all tags for a session.
2849    ///
2850    /// Tags are returned in alphabetical order by label.
2851    pub fn get_tags(&self, session_id: &Uuid) -> Result<Vec<Tag>> {
2852        let mut stmt = self.conn.prepare(
2853            "SELECT id, session_id, label, created_at
2854             FROM tags
2855             WHERE session_id = ?1
2856             ORDER BY label ASC",
2857        )?;
2858
2859        let rows = stmt.query_map(params![session_id.to_string()], |row| {
2860            Ok(Tag {
2861                id: parse_uuid(&row.get::<_, String>(0)?)?,
2862                session_id: parse_uuid(&row.get::<_, String>(1)?)?,
2863                label: row.get(2)?,
2864                created_at: parse_datetime(&row.get::<_, String>(3)?)?,
2865            })
2866        })?;
2867
2868        rows.collect::<Result<Vec<_>, _>>()
2869            .context("Failed to get tags")
2870    }
2871
2872    /// Checks if a tag with the given label exists for a session.
2873    pub fn tag_exists(&self, session_id: &Uuid, label: &str) -> Result<bool> {
2874        let count: i32 = self.conn.query_row(
2875            "SELECT COUNT(*) FROM tags WHERE session_id = ?1 AND label = ?2",
2876            params![session_id.to_string(), label],
2877            |row| row.get(0),
2878        )?;
2879        Ok(count > 0)
2880    }
2881
2882    /// Deletes a tag by session ID and label.
2883    ///
2884    /// Returns `true` if a tag was deleted, `false` if not found.
2885    pub fn delete_tag(&self, session_id: &Uuid, label: &str) -> Result<bool> {
2886        // Capture the tag id before deleting so the removal can be tombstoned
2887        // and propagate to other machines.
2888        let id: Option<String> = self
2889            .conn
2890            .query_row(
2891                "SELECT id FROM tags WHERE session_id = ?1 AND label = ?2",
2892                params![session_id.to_string(), label],
2893                |row| row.get(0),
2894            )
2895            .optional()?;
2896        let rows_affected = self.conn.execute(
2897            "DELETE FROM tags WHERE session_id = ?1 AND label = ?2",
2898            params![session_id.to_string(), label],
2899        )?;
2900        if rows_affected > 0 {
2901            if let Some(id) = id {
2902                Self::record_tombstone(
2903                    &self.conn,
2904                    &id,
2905                    TOMBSTONE_KIND_TAG,
2906                    session_id,
2907                    Utc::now(),
2908                )?;
2909            }
2910            // Local edit: re-open the parent session for the next sync.
2911            self.mark_session_unsynced(session_id)?;
2912        }
2913        Ok(rows_affected > 0)
2914    }
2915
2916    /// Deletes all tags for a session.
2917    ///
2918    /// Returns the number of tags deleted.
2919    #[allow(dead_code)]
2920    pub fn delete_tags_by_session(&self, session_id: &Uuid) -> Result<usize> {
2921        // Capture the tag ids before deleting so each removal can be tombstoned
2922        // and propagate to other machines.
2923        let ids = self.child_ids("tags", session_id)?;
2924        let rows_affected = self.conn.execute(
2925            "DELETE FROM tags WHERE session_id = ?1",
2926            params![session_id.to_string()],
2927        )?;
2928        if rows_affected > 0 {
2929            let now = Utc::now();
2930            for id in &ids {
2931                Self::record_tombstone(&self.conn, id, TOMBSTONE_KIND_TAG, session_id, now)?;
2932            }
2933            // Local edit: re-open the parent session for the next sync.
2934            self.mark_session_unsynced(session_id)?;
2935        }
2936        Ok(rows_affected)
2937    }
2938
2939    /// Lists sessions with a specific tag label.
2940    ///
2941    /// Returns sessions ordered by start time (most recent first).
2942    pub fn list_sessions_with_tag(&self, label: &str, limit: usize) -> Result<Vec<Session>> {
2943        let mut stmt = self.conn.prepare(
2944            "SELECT s.id, s.tool, s.tool_version, s.started_at, s.ended_at, s.model,
2945                    s.working_directory, s.git_branch, s.source_path, s.message_count, s.machine_id
2946             FROM sessions s
2947             INNER JOIN tags t ON s.id = t.session_id
2948             WHERE t.label = ?1
2949             ORDER BY s.started_at DESC
2950             LIMIT ?2",
2951        )?;
2952
2953        let rows = stmt.query_map(params![label, limit], Self::row_to_session)?;
2954
2955        rows.collect::<Result<Vec<_>, _>>()
2956            .context("Failed to list sessions with tag")
2957    }
2958
2959    // ==================== Summaries ====================
2960
2961    /// Inserts a new summary for a session.
2962    ///
2963    /// Each session can have at most one summary. If a summary already exists
2964    /// for the session, this will fail due to the unique constraint.
2965    pub fn insert_summary(&self, summary: &Summary) -> Result<()> {
2966        self.conn.execute(
2967            r#"
2968            INSERT INTO summaries (id, session_id, content, generated_at)
2969            VALUES (?1, ?2, ?3, ?4)
2970            "#,
2971            params![
2972                summary.id.to_string(),
2973                summary.session_id.to_string(),
2974                summary.content,
2975                summary.generated_at.to_rfc3339(),
2976            ],
2977        )?;
2978        // Local edit: re-open the parent session for the next sync.
2979        self.mark_session_unsynced(&summary.session_id)?;
2980        Ok(())
2981    }
2982
2983    /// Writes a summary using the given connection, keeping the newer of the two.
2984    ///
2985    /// On a `session_id` conflict the existing row is updated only when the
2986    /// incoming `generated_at` is strictly greater. RFC3339 UTC timestamps sort
2987    /// lexicographically, so the string comparison matches chronological order.
2988    /// Comparing timestamps (rather than blindly overwriting) means an older
2989    /// remote summary can never clobber a newer local one. Used by the merge path
2990    /// [`Self::merge_remote_record`].
2991    fn write_summary_newer(conn: &Connection, summary: &Summary) -> Result<()> {
2992        conn.execute(
2993            r#"
2994            INSERT INTO summaries (id, session_id, content, generated_at)
2995            VALUES (?1, ?2, ?3, ?4)
2996            ON CONFLICT(session_id) DO UPDATE SET
2997                content = excluded.content,
2998                generated_at = excluded.generated_at
2999            WHERE excluded.generated_at > generated_at
3000            "#,
3001            params![
3002                summary.id.to_string(),
3003                summary.session_id.to_string(),
3004                summary.content,
3005                summary.generated_at.to_rfc3339(),
3006            ],
3007        )?;
3008        Ok(())
3009    }
3010
3011    /// Retrieves the summary for a session, if one exists.
3012    pub fn get_summary(&self, session_id: &Uuid) -> Result<Option<Summary>> {
3013        self.conn
3014            .query_row(
3015                "SELECT id, session_id, content, generated_at
3016                 FROM summaries
3017                 WHERE session_id = ?1",
3018                params![session_id.to_string()],
3019                |row| {
3020                    Ok(Summary {
3021                        id: parse_uuid(&row.get::<_, String>(0)?)?,
3022                        session_id: parse_uuid(&row.get::<_, String>(1)?)?,
3023                        content: row.get(2)?,
3024                        generated_at: parse_datetime(&row.get::<_, String>(3)?)?,
3025                    })
3026                },
3027            )
3028            .optional()
3029            .context("Failed to get summary")
3030    }
3031
3032    /// Returns the set of session IDs (from the given list) that have summaries.
3033    ///
3034    /// This is a batch alternative to calling `get_summary` per session,
3035    /// avoiding N+1 queries in list views where only existence matters.
3036    pub fn get_sessions_with_summaries(&self, session_ids: &[Uuid]) -> Result<HashSet<Uuid>> {
3037        if session_ids.is_empty() {
3038            return Ok(HashSet::new());
3039        }
3040
3041        let placeholders: Vec<&str> = session_ids.iter().map(|_| "?").collect();
3042        let sql = format!(
3043            "SELECT session_id FROM summaries WHERE session_id IN ({})",
3044            placeholders.join(", ")
3045        );
3046
3047        let params: Vec<Box<dyn rusqlite::types::ToSql>> = session_ids
3048            .iter()
3049            .map(|id| Box::new(id.to_string()) as Box<dyn rusqlite::types::ToSql>)
3050            .collect();
3051
3052        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
3053            params.iter().map(|p| p.as_ref()).collect();
3054
3055        let mut stmt = self.conn.prepare(&sql)?;
3056        let rows = stmt.query_map(param_refs.as_slice(), |row| {
3057            let id_str: String = row.get(0)?;
3058            parse_uuid(&id_str)
3059        })?;
3060
3061        let mut result = HashSet::new();
3062        for row in rows {
3063            result.insert(row?);
3064        }
3065        Ok(result)
3066    }
3067
3068    /// Updates the summary for a session.
3069    ///
3070    /// Updates the content and generated_at timestamp for an existing summary.
3071    /// Returns `true` if a summary was updated, `false` if no summary exists.
3072    pub fn update_summary(&self, session_id: &Uuid, content: &str) -> Result<bool> {
3073        let now = chrono::Utc::now().to_rfc3339();
3074        let rows_affected = self.conn.execute(
3075            "UPDATE summaries SET content = ?1, generated_at = ?2 WHERE session_id = ?3",
3076            params![content, now, session_id.to_string()],
3077        )?;
3078        if rows_affected > 0 {
3079            // Local edit: re-open the parent session for the next sync.
3080            self.mark_session_unsynced(session_id)?;
3081        }
3082        Ok(rows_affected > 0)
3083    }
3084
3085    /// Deletes the summary for a session.
3086    ///
3087    /// Returns `true` if a summary was deleted, `false` if no summary existed.
3088    #[allow(dead_code)]
3089    pub fn delete_summary(&self, session_id: &Uuid) -> Result<bool> {
3090        // Capture the summary id before deleting so the removal can be
3091        // tombstoned and propagate to other machines.
3092        let id: Option<String> = self
3093            .conn
3094            .query_row(
3095                "SELECT id FROM summaries WHERE session_id = ?1",
3096                params![session_id.to_string()],
3097                |row| row.get(0),
3098            )
3099            .optional()?;
3100        let rows_affected = self.conn.execute(
3101            "DELETE FROM summaries WHERE session_id = ?1",
3102            params![session_id.to_string()],
3103        )?;
3104        if rows_affected > 0 {
3105            if let Some(id) = id {
3106                Self::record_tombstone(
3107                    &self.conn,
3108                    &id,
3109                    TOMBSTONE_KIND_SUMMARY,
3110                    session_id,
3111                    Utc::now(),
3112                )?;
3113            }
3114            // Local edit: re-open the parent session for the next sync.
3115            self.mark_session_unsynced(session_id)?;
3116        }
3117        Ok(rows_affected > 0)
3118    }
3119
3120    // ==================== Machines ====================
3121
3122    /// Registers a machine or updates its name if it already exists.
3123    ///
3124    /// Used to store machine identity information for sync deduplication.
3125    /// If a machine with the given ID already exists, updates the name.
3126    pub fn upsert_machine(&self, machine: &Machine) -> Result<()> {
3127        self.conn.execute(
3128            r#"
3129            INSERT INTO machines (id, name, created_at)
3130            VALUES (?1, ?2, ?3)
3131            ON CONFLICT(id) DO UPDATE SET
3132                name = ?2
3133            "#,
3134            params![machine.id, machine.name, machine.created_at],
3135        )?;
3136        Ok(())
3137    }
3138
3139    /// Gets a machine by ID.
3140    ///
3141    /// Returns `None` if no machine with the given ID exists.
3142    #[allow(dead_code)]
3143    pub fn get_machine(&self, id: &str) -> Result<Option<Machine>> {
3144        self.conn
3145            .query_row(
3146                "SELECT id, name, created_at FROM machines WHERE id = ?1",
3147                params![id],
3148                |row| {
3149                    Ok(Machine {
3150                        id: row.get(0)?,
3151                        name: row.get(1)?,
3152                        created_at: row.get(2)?,
3153                    })
3154                },
3155            )
3156            .optional()
3157            .context("Failed to get machine")
3158    }
3159
3160    /// Gets the display name for a machine ID.
3161    ///
3162    /// Returns the machine name if found, otherwise returns a truncated UUID
3163    /// (first 8 characters) for readability.
3164    #[allow(dead_code)]
3165    pub fn get_machine_name(&self, id: &str) -> Result<String> {
3166        if let Some(machine) = self.get_machine(id)? {
3167            Ok(machine.name)
3168        } else {
3169            // Fallback to truncated UUID
3170            if id.len() > 8 {
3171                Ok(id[..8].to_string())
3172            } else {
3173                Ok(id.to_string())
3174            }
3175        }
3176    }
3177
3178    /// Lists all registered machines.
3179    ///
3180    /// Returns machines ordered by creation date (oldest first).
3181    #[allow(dead_code)]
3182    pub fn list_machines(&self) -> Result<Vec<Machine>> {
3183        let mut stmt = self
3184            .conn
3185            .prepare("SELECT id, name, created_at FROM machines ORDER BY created_at ASC")?;
3186
3187        let rows = stmt.query_map([], |row| {
3188            Ok(Machine {
3189                id: row.get(0)?,
3190                name: row.get(1)?,
3191                created_at: row.get(2)?,
3192            })
3193        })?;
3194
3195        rows.collect::<Result<Vec<_>, _>>()
3196            .context("Failed to list machines")
3197    }
3198
3199    /// Gets the most recent session for a given working directory.
3200    ///
3201    /// Returns the session with the most recent started_at timestamp
3202    /// where the working directory matches or is a subdirectory of the given path.
3203    pub fn get_most_recent_session_for_directory(
3204        &self,
3205        working_dir: &str,
3206    ) -> Result<Option<Session>> {
3207        self.conn
3208            .query_row(
3209                "SELECT id, tool, tool_version, started_at, ended_at, model,
3210                        working_directory, git_branch, source_path, message_count, machine_id
3211                 FROM sessions
3212                 WHERE working_directory LIKE ?1
3213                 ORDER BY started_at DESC
3214                 LIMIT 1",
3215                params![format!("{working_dir}%")],
3216                Self::row_to_session,
3217            )
3218            .optional()
3219            .context("Failed to get most recent session for directory")
3220    }
3221
3222    // ==================== Database Maintenance ====================
3223
3224    /// Runs SQLite VACUUM to reclaim unused space and defragment the database.
3225    ///
3226    /// This operation can take some time on large databases and temporarily
3227    /// doubles the disk space used while rebuilding.
3228    pub fn vacuum(&self) -> Result<()> {
3229        self.conn.execute("VACUUM", [])?;
3230        Ok(())
3231    }
3232
3233    /// Returns the file size of the database in bytes.
3234    ///
3235    /// Returns `None` for in-memory databases.
3236    pub fn file_size(&self) -> Result<Option<u64>> {
3237        if let Some(path) = self.db_path() {
3238            let metadata = std::fs::metadata(&path)?;
3239            Ok(Some(metadata.len()))
3240        } else {
3241            Ok(None)
3242        }
3243    }
3244
3245    /// Deletes sessions older than the specified date.
3246    ///
3247    /// Also deletes all associated messages, links, and FTS entries.
3248    ///
3249    /// # Arguments
3250    ///
3251    /// * `before` - Delete sessions that started before this date
3252    ///
3253    /// # Returns
3254    ///
3255    /// The number of sessions deleted.
3256    pub fn delete_sessions_older_than(&self, before: DateTime<Utc>) -> Result<usize> {
3257        let before_str = before.to_rfc3339();
3258
3259        // Get session IDs to delete
3260        let mut stmt = self
3261            .conn
3262            .prepare("SELECT id FROM sessions WHERE started_at < ?1")?;
3263        let session_ids: Vec<String> = stmt
3264            .query_map(params![before_str], |row| row.get(0))?
3265            .collect::<Result<Vec<_>, _>>()?;
3266
3267        if session_ids.is_empty() {
3268            return Ok(0);
3269        }
3270
3271        let count = session_ids.len();
3272
3273        // Delete associated data for each session
3274        for session_id_str in &session_ids {
3275            // Delete from messages_fts
3276            self.conn.execute(
3277                "DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
3278                params![session_id_str],
3279            )?;
3280
3281            // Delete messages
3282            self.conn.execute(
3283                "DELETE FROM messages WHERE session_id = ?1",
3284                params![session_id_str],
3285            )?;
3286
3287            // Delete links
3288            self.conn.execute(
3289                "DELETE FROM session_links WHERE session_id = ?1",
3290                params![session_id_str],
3291            )?;
3292
3293            // Delete annotations
3294            self.conn.execute(
3295                "DELETE FROM annotations WHERE session_id = ?1",
3296                params![session_id_str],
3297            )?;
3298
3299            // Delete tags
3300            self.conn.execute(
3301                "DELETE FROM tags WHERE session_id = ?1",
3302                params![session_id_str],
3303            )?;
3304
3305            // Delete summary
3306            self.conn.execute(
3307                "DELETE FROM summaries WHERE session_id = ?1",
3308                params![session_id_str],
3309            )?;
3310
3311            // Delete from sessions_fts
3312            self.conn.execute(
3313                "DELETE FROM sessions_fts WHERE session_id = ?1",
3314                params![session_id_str],
3315            )?;
3316        }
3317
3318        // Delete the sessions
3319        self.conn.execute(
3320            "DELETE FROM sessions WHERE started_at < ?1",
3321            params![before_str],
3322        )?;
3323
3324        Ok(count)
3325    }
3326
3327    /// Counts sessions older than the specified date (for dry-run preview).
3328    ///
3329    /// # Arguments
3330    ///
3331    /// * `before` - Count sessions that started before this date
3332    ///
3333    /// # Returns
3334    ///
3335    /// The number of sessions that would be deleted.
3336    pub fn count_sessions_older_than(&self, before: DateTime<Utc>) -> Result<i32> {
3337        let before_str = before.to_rfc3339();
3338        let count: i32 = self.conn.query_row(
3339            "SELECT COUNT(*) FROM sessions WHERE started_at < ?1",
3340            params![before_str],
3341            |row| row.get(0),
3342        )?;
3343        Ok(count)
3344    }
3345
3346    /// Returns sessions older than the specified date (for dry-run preview).
3347    ///
3348    /// # Arguments
3349    ///
3350    /// * `before` - Return sessions that started before this date
3351    ///
3352    /// # Returns
3353    ///
3354    /// A vector of sessions that would be deleted, ordered by start date.
3355    pub fn get_sessions_older_than(&self, before: DateTime<Utc>) -> Result<Vec<Session>> {
3356        let before_str = before.to_rfc3339();
3357        let mut stmt = self.conn.prepare(
3358            "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
3359             FROM sessions
3360             WHERE started_at < ?1
3361             ORDER BY started_at ASC",
3362        )?;
3363
3364        let rows = stmt.query_map(params![before_str], Self::row_to_session)?;
3365
3366        rows.collect::<Result<Vec<_>, _>>()
3367            .context("Failed to get sessions older than cutoff")
3368    }
3369
3370    /// Returns database statistics including counts and date ranges.
3371    ///
3372    /// # Returns
3373    ///
3374    /// A `DatabaseStats` struct with session, message, and link counts,
3375    /// plus the date range of sessions and a breakdown by tool.
3376    pub fn stats(&self) -> Result<DatabaseStats> {
3377        let session_count = self.session_count()?;
3378        let message_count = self.message_count()?;
3379        let link_count = self.link_count()?;
3380
3381        // Get date range
3382        let oldest: Option<String> = self
3383            .conn
3384            .query_row("SELECT MIN(started_at) FROM sessions", [], |row| row.get(0))
3385            .optional()?
3386            .flatten();
3387
3388        let newest: Option<String> = self
3389            .conn
3390            .query_row("SELECT MAX(started_at) FROM sessions", [], |row| row.get(0))
3391            .optional()?
3392            .flatten();
3393
3394        let oldest_session = oldest
3395            .map(|s| parse_datetime(&s))
3396            .transpose()
3397            .unwrap_or(None);
3398        let newest_session = newest
3399            .map(|s| parse_datetime(&s))
3400            .transpose()
3401            .unwrap_or(None);
3402
3403        // Get sessions by tool
3404        let mut stmt = self
3405            .conn
3406            .prepare("SELECT tool, COUNT(*) FROM sessions GROUP BY tool ORDER BY COUNT(*) DESC")?;
3407        let sessions_by_tool: Vec<(String, i32)> = stmt
3408            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
3409            .collect::<Result<Vec<_>, _>>()?;
3410
3411        Ok(DatabaseStats {
3412            session_count,
3413            message_count,
3414            link_count,
3415            oldest_session,
3416            newest_session,
3417            sessions_by_tool,
3418        })
3419    }
3420
3421    // ==================== Insights ====================
3422
3423    /// Returns sessions filtered by an optional date range and working directory.
3424    ///
3425    /// Both `since` and `until` are optional bounds on `started_at`.
3426    /// When `working_dir` is provided, only sessions whose working directory
3427    /// starts with the given prefix are returned.
3428    pub fn sessions_in_date_range(
3429        &self,
3430        since: Option<DateTime<Utc>>,
3431        until: Option<DateTime<Utc>>,
3432        working_dir: Option<&str>,
3433    ) -> Result<Vec<Session>> {
3434        let mut conditions = Vec::new();
3435        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
3436
3437        if let Some(since) = since {
3438            conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
3439            param_values.push(Box::new(since.to_rfc3339()));
3440        }
3441        if let Some(until) = until {
3442            conditions.push(format!("started_at <= ?{}", param_values.len() + 1));
3443            param_values.push(Box::new(until.to_rfc3339()));
3444        }
3445        if let Some(wd) = working_dir {
3446            conditions.push(format!(
3447                "working_directory LIKE ?{}",
3448                param_values.len() + 1
3449            ));
3450            param_values.push(Box::new(format!("{}%", wd)));
3451        }
3452
3453        let where_clause = if conditions.is_empty() {
3454            String::new()
3455        } else {
3456            format!(" WHERE {}", conditions.join(" AND "))
3457        };
3458
3459        let sql = format!(
3460            "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
3461             FROM sessions{}
3462             ORDER BY started_at DESC",
3463            where_clause
3464        );
3465
3466        let mut stmt = self.conn.prepare(&sql)?;
3467        let params = rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref()));
3468        let rows = stmt.query_map(params, Self::row_to_session)?;
3469
3470        rows.collect::<Result<Vec<_>, _>>()
3471            .context("Failed to query sessions in date range")
3472    }
3473
3474    /// Returns the average session duration in minutes.
3475    ///
3476    /// Only sessions where `ended_at` is set are included in the calculation.
3477    /// Returns `None` if no matching sessions have an end time.
3478    pub fn average_session_duration_minutes(
3479        &self,
3480        since: Option<DateTime<Utc>>,
3481        working_dir: Option<&str>,
3482    ) -> Result<Option<f64>> {
3483        let mut conditions = vec!["ended_at IS NOT NULL".to_string()];
3484        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
3485
3486        if let Some(since) = since {
3487            conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
3488            param_values.push(Box::new(since.to_rfc3339()));
3489        }
3490        if let Some(wd) = working_dir {
3491            conditions.push(format!(
3492                "working_directory LIKE ?{}",
3493                param_values.len() + 1
3494            ));
3495            param_values.push(Box::new(format!("{}%", wd)));
3496        }
3497
3498        let where_clause = format!(" WHERE {}", conditions.join(" AND "));
3499
3500        let sql = format!(
3501            "SELECT AVG((julianday(ended_at) - julianday(started_at)) * 24 * 60) FROM sessions{}",
3502            where_clause
3503        );
3504
3505        let avg: Option<f64> = self
3506            .conn
3507            .query_row(
3508                &sql,
3509                rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref())),
3510                |row| row.get(0),
3511            )
3512            .optional()?
3513            .flatten();
3514
3515        Ok(avg)
3516    }
3517
3518    /// Returns session counts grouped by tool name.
3519    ///
3520    /// Results are sorted by count in descending order. Optionally filters
3521    /// by a minimum start date and working directory prefix.
3522    pub fn sessions_by_tool_in_range(
3523        &self,
3524        since: Option<DateTime<Utc>>,
3525        working_dir: Option<&str>,
3526    ) -> Result<Vec<(String, i32)>> {
3527        let mut conditions = Vec::new();
3528        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
3529
3530        if let Some(since) = since {
3531            conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
3532            param_values.push(Box::new(since.to_rfc3339()));
3533        }
3534        if let Some(wd) = working_dir {
3535            conditions.push(format!(
3536                "working_directory LIKE ?{}",
3537                param_values.len() + 1
3538            ));
3539            param_values.push(Box::new(format!("{}%", wd)));
3540        }
3541
3542        let where_clause = if conditions.is_empty() {
3543            String::new()
3544        } else {
3545            format!(" WHERE {}", conditions.join(" AND "))
3546        };
3547
3548        let sql = format!(
3549            "SELECT tool, COUNT(*) FROM sessions{} GROUP BY tool ORDER BY COUNT(*) DESC",
3550            where_clause
3551        );
3552
3553        let mut stmt = self.conn.prepare(&sql)?;
3554        let params = rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref()));
3555        let rows = stmt.query_map(params, |row| Ok((row.get(0)?, row.get(1)?)))?;
3556
3557        rows.collect::<Result<Vec<_>, _>>()
3558            .context("Failed to query sessions by tool")
3559    }
3560
3561    /// Returns session counts grouped by weekday number.
3562    ///
3563    /// Uses SQLite `strftime('%w', started_at)` which yields 0 for Sunday
3564    /// through 6 for Saturday. Only weekdays that have at least one session
3565    /// are returned. Optionally filters by a minimum start date and working
3566    /// directory prefix.
3567    pub fn sessions_by_weekday(
3568        &self,
3569        since: Option<DateTime<Utc>>,
3570        working_dir: Option<&str>,
3571    ) -> Result<Vec<(i32, i32)>> {
3572        let mut conditions = Vec::new();
3573        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
3574
3575        if let Some(since) = since {
3576            conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
3577            param_values.push(Box::new(since.to_rfc3339()));
3578        }
3579        if let Some(wd) = working_dir {
3580            conditions.push(format!(
3581                "working_directory LIKE ?{}",
3582                param_values.len() + 1
3583            ));
3584            param_values.push(Box::new(format!("{}%", wd)));
3585        }
3586
3587        let where_clause = if conditions.is_empty() {
3588            String::new()
3589        } else {
3590            format!(" WHERE {}", conditions.join(" AND "))
3591        };
3592
3593        let sql = format!(
3594            "SELECT CAST(strftime('%w', started_at) AS INTEGER), COUNT(*) FROM sessions{} GROUP BY strftime('%w', started_at) ORDER BY strftime('%w', started_at)",
3595            where_clause
3596        );
3597
3598        let mut stmt = self.conn.prepare(&sql)?;
3599        let params = rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref()));
3600        let rows = stmt.query_map(params, |row| Ok((row.get(0)?, row.get(1)?)))?;
3601
3602        rows.collect::<Result<Vec<_>, _>>()
3603            .context("Failed to query sessions by weekday")
3604    }
3605
3606    /// Returns the average message count across sessions.
3607    ///
3608    /// Returns `None` if no sessions match the given filters.
3609    pub fn average_message_count(
3610        &self,
3611        since: Option<DateTime<Utc>>,
3612        working_dir: Option<&str>,
3613    ) -> Result<Option<f64>> {
3614        let mut conditions = Vec::new();
3615        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
3616
3617        if let Some(since) = since {
3618            conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
3619            param_values.push(Box::new(since.to_rfc3339()));
3620        }
3621        if let Some(wd) = working_dir {
3622            conditions.push(format!(
3623                "working_directory LIKE ?{}",
3624                param_values.len() + 1
3625            ));
3626            param_values.push(Box::new(format!("{}%", wd)));
3627        }
3628
3629        let where_clause = if conditions.is_empty() {
3630            String::new()
3631        } else {
3632            format!(" WHERE {}", conditions.join(" AND "))
3633        };
3634
3635        let sql = format!("SELECT AVG(message_count) FROM sessions{}", where_clause);
3636
3637        let avg: Option<f64> = self
3638            .conn
3639            .query_row(
3640                &sql,
3641                rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref())),
3642                |row| row.get(0),
3643            )
3644            .optional()?
3645            .flatten();
3646
3647        Ok(avg)
3648    }
3649}
3650
3651/// Statistics about the Lore database.
3652#[derive(Debug, Clone)]
3653pub struct DatabaseStats {
3654    /// Total number of sessions.
3655    pub session_count: i32,
3656    /// Total number of messages.
3657    pub message_count: i32,
3658    /// Total number of session links.
3659    pub link_count: i32,
3660    /// Timestamp of the oldest session.
3661    pub oldest_session: Option<DateTime<Utc>>,
3662    /// Timestamp of the newest session.
3663    pub newest_session: Option<DateTime<Utc>>,
3664    /// Session counts grouped by tool name.
3665    pub sessions_by_tool: Vec<(String, i32)>,
3666}
3667
3668#[cfg(test)]
3669mod tests {
3670    use super::*;
3671    use crate::storage::models::{
3672        LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
3673    };
3674    use chrono::{Duration, Utc};
3675    use tempfile::tempdir;
3676
3677    /// Creates a test database in a temporary directory.
3678    /// Returns the Database instance and the temp directory (which must be kept alive).
3679    fn create_test_db() -> (Database, tempfile::TempDir) {
3680        let dir = tempdir().expect("Failed to create temp directory");
3681        let db_path = dir.path().join("test.db");
3682        let db = Database::open(&db_path).expect("Failed to open test database");
3683        (db, dir)
3684    }
3685
3686    /// Creates a test session with the given parameters.
3687    fn create_test_session(
3688        tool: &str,
3689        working_directory: &str,
3690        started_at: chrono::DateTime<Utc>,
3691        source_path: Option<&str>,
3692    ) -> Session {
3693        Session {
3694            id: Uuid::new_v4(),
3695            tool: tool.to_string(),
3696            tool_version: Some("1.0.0".to_string()),
3697            started_at,
3698            ended_at: None,
3699            model: Some("test-model".to_string()),
3700            working_directory: working_directory.to_string(),
3701            git_branch: Some("main".to_string()),
3702            source_path: source_path.map(|s| s.to_string()),
3703            message_count: 0,
3704            machine_id: Some("test-machine".to_string()),
3705        }
3706    }
3707
3708    /// Creates a test message for the given session.
3709    fn create_test_message(
3710        session_id: Uuid,
3711        index: i32,
3712        role: MessageRole,
3713        content: &str,
3714    ) -> Message {
3715        Message {
3716            id: Uuid::new_v4(),
3717            session_id,
3718            parent_id: None,
3719            index,
3720            timestamp: Utc::now(),
3721            role,
3722            content: MessageContent::Text(content.to_string()),
3723            model: Some("test-model".to_string()),
3724            git_branch: Some("main".to_string()),
3725            cwd: Some("/test/cwd".to_string()),
3726        }
3727    }
3728
3729    /// Creates a test session link for the given session.
3730    fn create_test_link(
3731        session_id: Uuid,
3732        commit_sha: Option<&str>,
3733        link_type: LinkType,
3734    ) -> SessionLink {
3735        SessionLink {
3736            id: Uuid::new_v4(),
3737            session_id,
3738            link_type,
3739            commit_sha: commit_sha.map(|s| s.to_string()),
3740            branch: Some("main".to_string()),
3741            remote: Some("origin".to_string()),
3742            created_at: Utc::now(),
3743            created_by: LinkCreator::Auto,
3744            confidence: Some(0.95),
3745        }
3746    }
3747
3748    // ==================== Session Tests ====================
3749
3750    #[test]
3751    fn test_insert_and_get_session() {
3752        let (db, _dir) = create_test_db();
3753        let session = create_test_session(
3754            "claude-code",
3755            "/home/user/project",
3756            Utc::now(),
3757            Some("/path/to/source.jsonl"),
3758        );
3759
3760        db.insert_session(&session)
3761            .expect("Failed to insert session");
3762
3763        let retrieved = db
3764            .get_session(&session.id)
3765            .expect("Failed to get session")
3766            .expect("Session should exist");
3767
3768        assert_eq!(retrieved.id, session.id, "Session ID should match");
3769        assert_eq!(retrieved.tool, session.tool, "Tool should match");
3770        assert_eq!(
3771            retrieved.tool_version, session.tool_version,
3772            "Tool version should match"
3773        );
3774        assert_eq!(
3775            retrieved.working_directory, session.working_directory,
3776            "Working directory should match"
3777        );
3778        assert_eq!(
3779            retrieved.git_branch, session.git_branch,
3780            "Git branch should match"
3781        );
3782        assert_eq!(
3783            retrieved.source_path, session.source_path,
3784            "Source path should match"
3785        );
3786    }
3787
3788    #[test]
3789    fn test_list_sessions() {
3790        let (db, _dir) = create_test_db();
3791        let now = Utc::now();
3792
3793        // Insert sessions with different timestamps (oldest first)
3794        let session1 =
3795            create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
3796        let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
3797        let session3 = create_test_session("claude-code", "/project3", now, None);
3798
3799        db.insert_session(&session1)
3800            .expect("Failed to insert session1");
3801        db.insert_session(&session2)
3802            .expect("Failed to insert session2");
3803        db.insert_session(&session3)
3804            .expect("Failed to insert session3");
3805
3806        let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
3807
3808        assert_eq!(sessions.len(), 3, "Should have 3 sessions");
3809        // Sessions should be ordered by started_at DESC (most recent first)
3810        assert_eq!(
3811            sessions[0].id, session3.id,
3812            "Most recent session should be first"
3813        );
3814        assert_eq!(
3815            sessions[1].id, session2.id,
3816            "Second most recent session should be second"
3817        );
3818        assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
3819    }
3820
3821    #[test]
3822    fn test_list_ended_sessions() {
3823        let (db, _dir) = create_test_db();
3824        let now = Utc::now();
3825
3826        let mut ended = create_test_session(
3827            "claude-code",
3828            "/home/user/project",
3829            now - Duration::minutes(60),
3830            None,
3831        );
3832        ended.ended_at = Some(now - Duration::minutes(30));
3833
3834        let ongoing = create_test_session(
3835            "claude-code",
3836            "/home/user/project",
3837            now - Duration::minutes(10),
3838            None,
3839        );
3840
3841        db.insert_session(&ended).expect("insert ended session");
3842        db.insert_session(&ongoing).expect("insert ongoing session");
3843
3844        let sessions = db
3845            .list_ended_sessions(100, None)
3846            .expect("Failed to list ended sessions");
3847
3848        assert_eq!(sessions.len(), 1);
3849        assert_eq!(sessions[0].id, ended.id);
3850    }
3851
3852    #[test]
3853    fn test_list_sessions_with_working_dir_filter() {
3854        let (db, _dir) = create_test_db();
3855        let now = Utc::now();
3856
3857        let session1 = create_test_session(
3858            "claude-code",
3859            "/home/user/project-a",
3860            now - Duration::hours(1),
3861            None,
3862        );
3863        let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
3864        let session3 = create_test_session("claude-code", "/other/path", now, None);
3865
3866        db.insert_session(&session1)
3867            .expect("Failed to insert session1");
3868        db.insert_session(&session2)
3869            .expect("Failed to insert session2");
3870        db.insert_session(&session3)
3871            .expect("Failed to insert session3");
3872
3873        // Filter by working directory prefix
3874        let sessions = db
3875            .list_sessions(10, Some("/home/user"))
3876            .expect("Failed to list sessions");
3877
3878        assert_eq!(
3879            sessions.len(),
3880            2,
3881            "Should have 2 sessions matching /home/user prefix"
3882        );
3883
3884        // Verify both matching sessions are returned
3885        let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
3886        assert!(ids.contains(&session1.id), "Should contain session1");
3887        assert!(ids.contains(&session2.id), "Should contain session2");
3888        assert!(!ids.contains(&session3.id), "Should not contain session3");
3889    }
3890
3891    #[test]
3892    fn test_session_exists_by_source() {
3893        let (db, _dir) = create_test_db();
3894        let source_path = "/path/to/session.jsonl";
3895
3896        let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
3897
3898        // Before insert, should not exist
3899        assert!(
3900            !db.session_exists_by_source(source_path)
3901                .expect("Failed to check existence"),
3902            "Session should not exist before insert"
3903        );
3904
3905        db.insert_session(&session)
3906            .expect("Failed to insert session");
3907
3908        // After insert, should exist
3909        assert!(
3910            db.session_exists_by_source(source_path)
3911                .expect("Failed to check existence"),
3912            "Session should exist after insert"
3913        );
3914
3915        // Different path should not exist
3916        assert!(
3917            !db.session_exists_by_source("/other/path.jsonl")
3918                .expect("Failed to check existence"),
3919            "Different source path should not exist"
3920        );
3921    }
3922
3923    #[test]
3924    fn test_get_session_by_source() {
3925        let (db, _dir) = create_test_db();
3926        let source_path = "/path/to/session.jsonl";
3927
3928        let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
3929
3930        // Before insert, should return None
3931        assert!(
3932            db.get_session_by_source(source_path)
3933                .expect("Failed to get session")
3934                .is_none(),
3935            "Session should not exist before insert"
3936        );
3937
3938        db.insert_session(&session)
3939            .expect("Failed to insert session");
3940
3941        // After insert, should return the session
3942        let retrieved = db
3943            .get_session_by_source(source_path)
3944            .expect("Failed to get session")
3945            .expect("Session should exist after insert");
3946
3947        assert_eq!(retrieved.id, session.id, "Session ID should match");
3948        assert_eq!(
3949            retrieved.source_path,
3950            Some(source_path.to_string()),
3951            "Source path should match"
3952        );
3953
3954        // Different path should return None
3955        assert!(
3956            db.get_session_by_source("/other/path.jsonl")
3957                .expect("Failed to get session")
3958                .is_none(),
3959            "Different source path should return None"
3960        );
3961    }
3962
3963    #[test]
3964    fn test_update_session_branch() {
3965        let (db, _dir) = create_test_db();
3966        let now = Utc::now();
3967
3968        // Create session with initial branch
3969        let mut session = create_test_session("claude-code", "/project", now, None);
3970        session.git_branch = Some("main".to_string());
3971
3972        db.insert_session(&session)
3973            .expect("Failed to insert session");
3974
3975        // Verify initial branch
3976        let fetched = db
3977            .get_session(&session.id)
3978            .expect("Failed to get session")
3979            .expect("Session should exist");
3980        assert_eq!(fetched.git_branch, Some("main".to_string()));
3981
3982        // Update branch
3983        let rows = db
3984            .update_session_branch(session.id, "feature-branch")
3985            .expect("Failed to update branch");
3986        assert_eq!(rows, 1, "Should update exactly one row");
3987
3988        // Verify updated branch
3989        let fetched = db
3990            .get_session(&session.id)
3991            .expect("Failed to get session")
3992            .expect("Session should exist");
3993        assert_eq!(fetched.git_branch, Some("feature-branch".to_string()));
3994    }
3995
3996    #[test]
3997    fn test_update_session_branch_nonexistent() {
3998        let (db, _dir) = create_test_db();
3999        let nonexistent_id = Uuid::new_v4();
4000
4001        // Updating a nonexistent session should return 0 rows
4002        let rows = db
4003            .update_session_branch(nonexistent_id, "some-branch")
4004            .expect("Failed to update branch");
4005        assert_eq!(
4006            rows, 0,
4007            "Should not update any rows for nonexistent session"
4008        );
4009    }
4010
4011    #[test]
4012    fn test_update_session_branch_from_none() {
4013        let (db, _dir) = create_test_db();
4014        let now = Utc::now();
4015
4016        // Create session without initial branch
4017        let mut session = create_test_session("claude-code", "/project", now, None);
4018        session.git_branch = None; // Explicitly set to None for this test
4019
4020        db.insert_session(&session)
4021            .expect("Failed to insert session");
4022
4023        // Verify no initial branch
4024        let fetched = db
4025            .get_session(&session.id)
4026            .expect("Failed to get session")
4027            .expect("Session should exist");
4028        assert_eq!(fetched.git_branch, None);
4029
4030        // Update branch from None to a value
4031        let rows = db
4032            .update_session_branch(session.id, "new-branch")
4033            .expect("Failed to update branch");
4034        assert_eq!(rows, 1, "Should update exactly one row");
4035
4036        // Verify updated branch
4037        let fetched = db
4038            .get_session(&session.id)
4039            .expect("Failed to get session")
4040            .expect("Session should exist");
4041        assert_eq!(fetched.git_branch, Some("new-branch".to_string()));
4042    }
4043
4044    #[test]
4045    fn test_get_nonexistent_session() {
4046        let (db, _dir) = create_test_db();
4047        let nonexistent_id = Uuid::new_v4();
4048
4049        let result = db
4050            .get_session(&nonexistent_id)
4051            .expect("Failed to query for nonexistent session");
4052
4053        assert!(
4054            result.is_none(),
4055            "Should return None for nonexistent session"
4056        );
4057    }
4058
4059    // ==================== Message Tests ====================
4060
4061    #[test]
4062    fn test_insert_and_get_messages() {
4063        let (db, _dir) = create_test_db();
4064
4065        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4066        db.insert_session(&session)
4067            .expect("Failed to insert session");
4068
4069        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
4070        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
4071
4072        db.insert_message(&msg1)
4073            .expect("Failed to insert message 1");
4074        db.insert_message(&msg2)
4075            .expect("Failed to insert message 2");
4076
4077        let messages = db
4078            .get_messages(&session.id)
4079            .expect("Failed to get messages");
4080
4081        assert_eq!(messages.len(), 2, "Should have 2 messages");
4082        assert_eq!(messages[0].id, msg1.id, "First message ID should match");
4083        assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
4084        assert_eq!(
4085            messages[0].role,
4086            MessageRole::User,
4087            "First message role should be User"
4088        );
4089        assert_eq!(
4090            messages[1].role,
4091            MessageRole::Assistant,
4092            "Second message role should be Assistant"
4093        );
4094    }
4095
4096    #[test]
4097    fn test_messages_ordered_by_index() {
4098        let (db, _dir) = create_test_db();
4099
4100        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4101        db.insert_session(&session)
4102            .expect("Failed to insert session");
4103
4104        // Insert messages out of order
4105        let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
4106        let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
4107        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
4108
4109        db.insert_message(&msg3)
4110            .expect("Failed to insert message 3");
4111        db.insert_message(&msg1)
4112            .expect("Failed to insert message 1");
4113        db.insert_message(&msg2)
4114            .expect("Failed to insert message 2");
4115
4116        let messages = db
4117            .get_messages(&session.id)
4118            .expect("Failed to get messages");
4119
4120        assert_eq!(messages.len(), 3, "Should have 3 messages");
4121        assert_eq!(messages[0].index, 0, "First message should have index 0");
4122        assert_eq!(messages[1].index, 1, "Second message should have index 1");
4123        assert_eq!(messages[2].index, 2, "Third message should have index 2");
4124
4125        // Verify content matches expected order
4126        assert_eq!(
4127            messages[0].content.text(),
4128            "First",
4129            "First message content should be 'First'"
4130        );
4131        assert_eq!(
4132            messages[1].content.text(),
4133            "Second",
4134            "Second message content should be 'Second'"
4135        );
4136        assert_eq!(
4137            messages[2].content.text(),
4138            "Third",
4139            "Third message content should be 'Third'"
4140        );
4141    }
4142
4143    // ==================== SessionLink Tests ====================
4144
4145    #[test]
4146    fn test_insert_and_get_links_by_session() {
4147        let (db, _dir) = create_test_db();
4148
4149        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4150        db.insert_session(&session)
4151            .expect("Failed to insert session");
4152
4153        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
4154        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
4155
4156        db.insert_link(&link1).expect("Failed to insert link 1");
4157        db.insert_link(&link2).expect("Failed to insert link 2");
4158
4159        let links = db
4160            .get_links_by_session(&session.id)
4161            .expect("Failed to get links");
4162
4163        assert_eq!(links.len(), 2, "Should have 2 links");
4164
4165        let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
4166        assert!(link_ids.contains(&link1.id), "Should contain link1");
4167        assert!(link_ids.contains(&link2.id), "Should contain link2");
4168
4169        // Verify link properties
4170        let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
4171        assert_eq!(
4172            retrieved_link.commit_sha,
4173            Some("abc123def456".to_string()),
4174            "Commit SHA should match"
4175        );
4176        assert_eq!(
4177            retrieved_link.link_type,
4178            LinkType::Commit,
4179            "Link type should be Commit"
4180        );
4181        assert_eq!(
4182            retrieved_link.created_by,
4183            LinkCreator::Auto,
4184            "Created by should be Auto"
4185        );
4186    }
4187
4188    #[test]
4189    fn test_get_links_by_commit() {
4190        let (db, _dir) = create_test_db();
4191
4192        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4193        db.insert_session(&session)
4194            .expect("Failed to insert session");
4195
4196        let full_sha = "abc123def456789012345678901234567890abcd";
4197        let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
4198        db.insert_link(&link).expect("Failed to insert link");
4199
4200        // Test full SHA match
4201        let links_full = db
4202            .get_links_by_commit(full_sha)
4203            .expect("Failed to get links by full SHA");
4204        assert_eq!(links_full.len(), 1, "Should find link by full SHA");
4205        assert_eq!(links_full[0].id, link.id, "Link ID should match");
4206
4207        // Test partial SHA match (prefix)
4208        let links_partial = db
4209            .get_links_by_commit("abc123")
4210            .expect("Failed to get links by partial SHA");
4211        assert_eq!(
4212            links_partial.len(),
4213            1,
4214            "Should find link by partial SHA prefix"
4215        );
4216        assert_eq!(links_partial[0].id, link.id, "Link ID should match");
4217
4218        // Test non-matching SHA
4219        let links_none = db
4220            .get_links_by_commit("zzz999")
4221            .expect("Failed to get links by non-matching SHA");
4222        assert_eq!(
4223            links_none.len(),
4224            0,
4225            "Should not find link with non-matching SHA"
4226        );
4227    }
4228
4229    // ==================== Database Tests ====================
4230
4231    #[test]
4232    fn test_database_creation() {
4233        let dir = tempdir().expect("Failed to create temp directory");
4234        let db_path = dir.path().join("new_test.db");
4235
4236        // Database should not exist before creation
4237        assert!(
4238            !db_path.exists(),
4239            "Database file should not exist before creation"
4240        );
4241
4242        let db = Database::open(&db_path).expect("Failed to create database");
4243
4244        // Database file should exist after creation
4245        assert!(
4246            db_path.exists(),
4247            "Database file should exist after creation"
4248        );
4249
4250        // Verify tables exist by attempting operations
4251        let session_count = db.session_count().expect("Failed to get session count");
4252        assert_eq!(session_count, 0, "New database should have 0 sessions");
4253
4254        let message_count = db.message_count().expect("Failed to get message count");
4255        assert_eq!(message_count, 0, "New database should have 0 messages");
4256    }
4257
4258    #[test]
4259    fn test_session_count() {
4260        let (db, _dir) = create_test_db();
4261
4262        assert_eq!(
4263            db.session_count().expect("Failed to get count"),
4264            0,
4265            "Initial session count should be 0"
4266        );
4267
4268        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
4269        db.insert_session(&session1)
4270            .expect("Failed to insert session1");
4271
4272        assert_eq!(
4273            db.session_count().expect("Failed to get count"),
4274            1,
4275            "Session count should be 1 after first insert"
4276        );
4277
4278        let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
4279        db.insert_session(&session2)
4280            .expect("Failed to insert session2");
4281
4282        assert_eq!(
4283            db.session_count().expect("Failed to get count"),
4284            2,
4285            "Session count should be 2 after second insert"
4286        );
4287    }
4288
4289    #[test]
4290    fn test_message_count() {
4291        let (db, _dir) = create_test_db();
4292
4293        assert_eq!(
4294            db.message_count().expect("Failed to get count"),
4295            0,
4296            "Initial message count should be 0"
4297        );
4298
4299        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4300        db.insert_session(&session)
4301            .expect("Failed to insert session");
4302
4303        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
4304        db.insert_message(&msg1).expect("Failed to insert message1");
4305
4306        assert_eq!(
4307            db.message_count().expect("Failed to get count"),
4308            1,
4309            "Message count should be 1 after first insert"
4310        );
4311
4312        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
4313        let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
4314        db.insert_message(&msg2).expect("Failed to insert message2");
4315        db.insert_message(&msg3).expect("Failed to insert message3");
4316
4317        assert_eq!(
4318            db.message_count().expect("Failed to get count"),
4319            3,
4320            "Message count should be 3 after all inserts"
4321        );
4322    }
4323
4324    #[test]
4325    fn test_link_count() {
4326        let (db, _dir) = create_test_db();
4327
4328        assert_eq!(
4329            db.link_count().expect("Failed to get count"),
4330            0,
4331            "Initial link count should be 0"
4332        );
4333
4334        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4335        db.insert_session(&session)
4336            .expect("Failed to insert session");
4337
4338        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
4339        db.insert_link(&link1).expect("Failed to insert link1");
4340
4341        assert_eq!(
4342            db.link_count().expect("Failed to get count"),
4343            1,
4344            "Link count should be 1 after first insert"
4345        );
4346
4347        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
4348        db.insert_link(&link2).expect("Failed to insert link2");
4349
4350        assert_eq!(
4351            db.link_count().expect("Failed to get count"),
4352            2,
4353            "Link count should be 2 after second insert"
4354        );
4355    }
4356
4357    #[test]
4358    fn test_db_path() {
4359        let dir = tempdir().expect("Failed to create temp directory");
4360        let db_path = dir.path().join("test.db");
4361        let db = Database::open(&db_path).expect("Failed to open test database");
4362
4363        let retrieved_path = db.db_path();
4364        assert!(
4365            retrieved_path.is_some(),
4366            "Database path should be available"
4367        );
4368
4369        // Canonicalize both paths to handle macOS /var -> /private/var symlinks
4370        let expected = db_path.canonicalize().unwrap_or(db_path);
4371        let actual = retrieved_path.unwrap();
4372        let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
4373
4374        assert_eq!(
4375            actual_canonical, expected,
4376            "Database path should match (after canonicalization)"
4377        );
4378    }
4379
4380    // ==================== Search Tests ====================
4381
4382    #[test]
4383    fn test_search_messages_basic() {
4384        let (db, _dir) = create_test_db();
4385
4386        let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
4387        db.insert_session(&session)
4388            .expect("Failed to insert session");
4389
4390        let msg1 = create_test_message(
4391            session.id,
4392            0,
4393            MessageRole::User,
4394            "How do I implement error handling in Rust?",
4395        );
4396        let msg2 = create_test_message(
4397            session.id,
4398            1,
4399            MessageRole::Assistant,
4400            "You can use Result types for error handling. The anyhow crate is also helpful.",
4401        );
4402
4403        db.insert_message(&msg1)
4404            .expect("Failed to insert message 1");
4405        db.insert_message(&msg2)
4406            .expect("Failed to insert message 2");
4407
4408        // Search for "error"
4409        let results = db
4410            .search_messages("error", 10, None, None, None)
4411            .expect("Failed to search");
4412
4413        assert_eq!(
4414            results.len(),
4415            2,
4416            "Should find 2 messages containing 'error'"
4417        );
4418    }
4419
4420    #[test]
4421    fn test_search_messages_no_results() {
4422        let (db, _dir) = create_test_db();
4423
4424        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4425        db.insert_session(&session)
4426            .expect("Failed to insert session");
4427
4428        let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
4429        db.insert_message(&msg).expect("Failed to insert message");
4430
4431        // Search for something not in the messages
4432        let results = db
4433            .search_messages("nonexistent_term_xyz", 10, None, None, None)
4434            .expect("Failed to search");
4435
4436        assert!(results.is_empty(), "Should find no results");
4437    }
4438
4439    #[test]
4440    fn test_search_messages_with_role_filter() {
4441        let (db, _dir) = create_test_db();
4442
4443        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4444        db.insert_session(&session)
4445            .expect("Failed to insert session");
4446
4447        let msg1 = create_test_message(
4448            session.id,
4449            0,
4450            MessageRole::User,
4451            "Tell me about Rust programming",
4452        );
4453        let msg2 = create_test_message(
4454            session.id,
4455            1,
4456            MessageRole::Assistant,
4457            "Rust is a systems programming language",
4458        );
4459
4460        db.insert_message(&msg1)
4461            .expect("Failed to insert message 1");
4462        db.insert_message(&msg2)
4463            .expect("Failed to insert message 2");
4464
4465        // Search with user role filter
4466        let user_results = db
4467            .search_messages("programming", 10, None, None, Some("user"))
4468            .expect("Failed to search");
4469
4470        assert_eq!(user_results.len(), 1, "Should find 1 user message");
4471        assert_eq!(
4472            user_results[0].role,
4473            MessageRole::User,
4474            "Result should be from user"
4475        );
4476
4477        // Search with assistant role filter
4478        let assistant_results = db
4479            .search_messages("programming", 10, None, None, Some("assistant"))
4480            .expect("Failed to search");
4481
4482        assert_eq!(
4483            assistant_results.len(),
4484            1,
4485            "Should find 1 assistant message"
4486        );
4487        assert_eq!(
4488            assistant_results[0].role,
4489            MessageRole::Assistant,
4490            "Result should be from assistant"
4491        );
4492    }
4493
4494    #[test]
4495    fn test_search_messages_with_repo_filter() {
4496        let (db, _dir) = create_test_db();
4497
4498        let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
4499        let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
4500
4501        db.insert_session(&session1).expect("insert 1");
4502        db.insert_session(&session2).expect("insert 2");
4503
4504        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
4505        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
4506
4507        db.insert_message(&msg1).expect("insert msg 1");
4508        db.insert_message(&msg2).expect("insert msg 2");
4509
4510        // Search with repo filter
4511        let results = db
4512            .search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
4513            .expect("Failed to search");
4514
4515        assert_eq!(results.len(), 1, "Should find 1 message in project-a");
4516        assert!(
4517            results[0].working_directory.contains("project-a"),
4518            "Should be from project-a"
4519        );
4520    }
4521
4522    #[test]
4523    fn test_search_messages_limit() {
4524        let (db, _dir) = create_test_db();
4525
4526        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4527        db.insert_session(&session).expect("insert session");
4528
4529        // Insert 5 messages all containing "test"
4530        for i in 0..5 {
4531            let msg = create_test_message(
4532                session.id,
4533                i,
4534                MessageRole::User,
4535                &format!("This is test message number {i}"),
4536            );
4537            db.insert_message(&msg).expect("insert message");
4538        }
4539
4540        // Search with limit of 3
4541        let results = db
4542            .search_messages("test", 3, None, None, None)
4543            .expect("Failed to search");
4544
4545        assert_eq!(results.len(), 3, "Should respect limit of 3");
4546    }
4547
4548    #[test]
4549    fn test_search_index_needs_rebuild_empty_db() {
4550        let (db, _dir) = create_test_db();
4551
4552        let needs_rebuild = db
4553            .search_index_needs_rebuild()
4554            .expect("Failed to check rebuild status");
4555
4556        assert!(!needs_rebuild, "Empty database should not need rebuild");
4557    }
4558
4559    #[test]
4560    fn test_rebuild_search_index() {
4561        let (db, _dir) = create_test_db();
4562
4563        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4564        db.insert_session(&session).expect("insert session");
4565
4566        let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
4567        let msg2 = create_test_message(
4568            session.id,
4569            1,
4570            MessageRole::Assistant,
4571            "Second test response",
4572        );
4573
4574        db.insert_message(&msg1).expect("insert msg 1");
4575        db.insert_message(&msg2).expect("insert msg 2");
4576
4577        // Clear and rebuild the index
4578        db.conn
4579            .execute("DELETE FROM messages_fts", [])
4580            .expect("clear fts");
4581
4582        // Index should now need rebuilding
4583        assert!(
4584            db.search_index_needs_rebuild().expect("check rebuild"),
4585            "Should need rebuild after clearing FTS"
4586        );
4587
4588        // Rebuild
4589        let count = db.rebuild_search_index().expect("rebuild");
4590        assert_eq!(count, 2, "Should have indexed 2 messages");
4591
4592        // Index should no longer need rebuilding
4593        assert!(
4594            !db.search_index_needs_rebuild().expect("check rebuild"),
4595            "Should not need rebuild after rebuilding"
4596        );
4597
4598        // Search should work
4599        let results = db
4600            .search_messages("test", 10, None, None, None)
4601            .expect("search");
4602        assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
4603    }
4604
4605    #[test]
4606    fn test_search_with_block_content() {
4607        let (db, _dir) = create_test_db();
4608
4609        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4610        db.insert_session(&session).expect("insert session");
4611
4612        // Create a message with block content
4613        let block_content = MessageContent::Blocks(vec![
4614            crate::storage::models::ContentBlock::Text {
4615                text: "Let me help with your database query.".to_string(),
4616            },
4617            crate::storage::models::ContentBlock::ToolUse {
4618                id: "tool_123".to_string(),
4619                name: "Bash".to_string(),
4620                input: serde_json::json!({"command": "ls -la"}),
4621            },
4622        ]);
4623
4624        let msg = Message {
4625            id: Uuid::new_v4(),
4626            session_id: session.id,
4627            parent_id: None,
4628            index: 0,
4629            timestamp: Utc::now(),
4630            role: MessageRole::Assistant,
4631            content: block_content,
4632            model: Some("claude-opus-4".to_string()),
4633            git_branch: Some("main".to_string()),
4634            cwd: Some("/project".to_string()),
4635        };
4636
4637        db.insert_message(&msg).expect("insert message");
4638
4639        // Search should find text from blocks
4640        let results = db
4641            .search_messages("database", 10, None, None, None)
4642            .expect("search");
4643
4644        assert_eq!(results.len(), 1, "Should find message with block content");
4645    }
4646
4647    #[test]
4648    fn test_search_result_contains_session_info() {
4649        let (db, _dir) = create_test_db();
4650
4651        let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
4652        db.insert_session(&session).expect("insert session");
4653
4654        let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
4655        db.insert_message(&msg).expect("insert message");
4656
4657        let results = db
4658            .search_messages("Search", 10, None, None, None)
4659            .expect("search");
4660
4661        assert_eq!(results.len(), 1, "Should find 1 result");
4662        assert_eq!(results[0].session_id, session.id, "Session ID should match");
4663        assert_eq!(results[0].message_id, msg.id, "Message ID should match");
4664        assert_eq!(
4665            results[0].working_directory, "/home/user/my-project",
4666            "Working directory should match"
4667        );
4668        assert_eq!(results[0].role, MessageRole::User, "Role should match");
4669    }
4670
4671    // ==================== Delete Link Tests ====================
4672
4673    #[test]
4674    fn test_delete_link_by_id() {
4675        let (db, _dir) = create_test_db();
4676
4677        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4678        db.insert_session(&session)
4679            .expect("Failed to insert session");
4680
4681        let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
4682        db.insert_link(&link).expect("Failed to insert link");
4683
4684        // Verify link exists
4685        let links_before = db
4686            .get_links_by_session(&session.id)
4687            .expect("Failed to get links");
4688        assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
4689
4690        // Delete the link
4691        let deleted = db.delete_link(&link.id).expect("Failed to delete link");
4692        assert!(deleted, "Should return true when link is deleted");
4693
4694        // Verify link is gone
4695        let links_after = db
4696            .get_links_by_session(&session.id)
4697            .expect("Failed to get links");
4698        assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
4699    }
4700
4701    #[test]
4702    fn test_delete_link_nonexistent() {
4703        let (db, _dir) = create_test_db();
4704
4705        let nonexistent_id = Uuid::new_v4();
4706        let deleted = db
4707            .delete_link(&nonexistent_id)
4708            .expect("Failed to call delete_link");
4709
4710        assert!(!deleted, "Should return false for nonexistent link");
4711    }
4712
4713    #[test]
4714    fn test_delete_links_by_session() {
4715        let (db, _dir) = create_test_db();
4716
4717        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4718        db.insert_session(&session)
4719            .expect("Failed to insert session");
4720
4721        // Create multiple links for the same session
4722        let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
4723        let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
4724        let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
4725
4726        db.insert_link(&link1).expect("Failed to insert link1");
4727        db.insert_link(&link2).expect("Failed to insert link2");
4728        db.insert_link(&link3).expect("Failed to insert link3");
4729
4730        // Verify all links exist
4731        let links_before = db
4732            .get_links_by_session(&session.id)
4733            .expect("Failed to get links");
4734        assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
4735
4736        // Delete all links for the session
4737        let count = db
4738            .delete_links_by_session(&session.id)
4739            .expect("Failed to delete links");
4740        assert_eq!(count, 3, "Should have deleted 3 links");
4741
4742        // Verify all links are gone
4743        let links_after = db
4744            .get_links_by_session(&session.id)
4745            .expect("Failed to get links");
4746        assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
4747    }
4748
4749    #[test]
4750    fn test_delete_links_by_session_no_links() {
4751        let (db, _dir) = create_test_db();
4752
4753        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4754        db.insert_session(&session)
4755            .expect("Failed to insert session");
4756
4757        // Delete links for session that has none
4758        let count = db
4759            .delete_links_by_session(&session.id)
4760            .expect("Failed to call delete_links_by_session");
4761        assert_eq!(count, 0, "Should return 0 when no links exist");
4762    }
4763
4764    #[test]
4765    fn test_delete_links_by_session_preserves_other_sessions() {
4766        let (db, _dir) = create_test_db();
4767
4768        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
4769        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
4770
4771        db.insert_session(&session1)
4772            .expect("Failed to insert session1");
4773        db.insert_session(&session2)
4774            .expect("Failed to insert session2");
4775
4776        let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
4777        let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
4778
4779        db.insert_link(&link1).expect("Failed to insert link1");
4780        db.insert_link(&link2).expect("Failed to insert link2");
4781
4782        // Delete links only for session1
4783        let count = db
4784            .delete_links_by_session(&session1.id)
4785            .expect("Failed to delete links");
4786        assert_eq!(count, 1, "Should have deleted 1 link");
4787
4788        // Verify session2's link is preserved
4789        let session2_links = db
4790            .get_links_by_session(&session2.id)
4791            .expect("Failed to get links");
4792        assert_eq!(
4793            session2_links.len(),
4794            1,
4795            "Session2's link should be preserved"
4796        );
4797        assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
4798    }
4799
4800    #[test]
4801    fn test_delete_link_by_session_and_commit() {
4802        let (db, _dir) = create_test_db();
4803
4804        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4805        db.insert_session(&session)
4806            .expect("Failed to insert session");
4807
4808        let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
4809        let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
4810
4811        db.insert_link(&link1).expect("Failed to insert link1");
4812        db.insert_link(&link2).expect("Failed to insert link2");
4813
4814        // Delete only the first link by commit SHA
4815        let deleted = db
4816            .delete_link_by_session_and_commit(&session.id, "abc123")
4817            .expect("Failed to delete link");
4818        assert!(deleted, "Should return true when link is deleted");
4819
4820        // Verify only link2 remains
4821        let links = db
4822            .get_links_by_session(&session.id)
4823            .expect("Failed to get links");
4824        assert_eq!(links.len(), 1, "Should have 1 link remaining");
4825        assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
4826    }
4827
4828    #[test]
4829    fn test_delete_link_by_session_and_commit_full_sha() {
4830        let (db, _dir) = create_test_db();
4831
4832        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4833        db.insert_session(&session)
4834            .expect("Failed to insert session");
4835
4836        let full_sha = "abc123def456789012345678901234567890abcd";
4837        let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
4838        db.insert_link(&link).expect("Failed to insert link");
4839
4840        // Delete using full SHA
4841        let deleted = db
4842            .delete_link_by_session_and_commit(&session.id, full_sha)
4843            .expect("Failed to delete link");
4844        assert!(deleted, "Should delete with full SHA");
4845
4846        let links = db
4847            .get_links_by_session(&session.id)
4848            .expect("Failed to get links");
4849        assert_eq!(links.len(), 0, "Should have 0 links after delete");
4850    }
4851
4852    #[test]
4853    fn test_delete_link_by_session_and_commit_no_match() {
4854        let (db, _dir) = create_test_db();
4855
4856        let session = create_test_session("claude-code", "/project", Utc::now(), None);
4857        db.insert_session(&session)
4858            .expect("Failed to insert session");
4859
4860        let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
4861        db.insert_link(&link).expect("Failed to insert link");
4862
4863        // Try to delete with non-matching commit
4864        let deleted = db
4865            .delete_link_by_session_and_commit(&session.id, "xyz999")
4866            .expect("Failed to call delete");
4867        assert!(!deleted, "Should return false when no match");
4868
4869        // Verify original link is preserved
4870        let links = db
4871            .get_links_by_session(&session.id)
4872            .expect("Failed to get links");
4873        assert_eq!(links.len(), 1, "Link should be preserved");
4874    }
4875
4876    #[test]
4877    fn test_delete_link_by_session_and_commit_wrong_session() {
4878        let (db, _dir) = create_test_db();
4879
4880        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
4881        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
4882
4883        db.insert_session(&session1)
4884            .expect("Failed to insert session1");
4885        db.insert_session(&session2)
4886            .expect("Failed to insert session2");
4887
4888        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
4889        db.insert_link(&link).expect("Failed to insert link");
4890
4891        // Try to delete from wrong session
4892        let deleted = db
4893            .delete_link_by_session_and_commit(&session2.id, "abc123")
4894            .expect("Failed to call delete");
4895        assert!(!deleted, "Should not delete link from different session");
4896
4897        // Verify original link is preserved
4898        let links = db
4899            .get_links_by_session(&session1.id)
4900            .expect("Failed to get links");
4901        assert_eq!(links.len(), 1, "Link should be preserved");
4902    }
4903
4904    // ==================== Auto-linking Tests ====================
4905
4906    #[test]
4907    fn test_find_sessions_near_commit_time_basic() {
4908        let (db, _dir) = create_test_db();
4909        let now = Utc::now();
4910
4911        // Create a session that ended 10 minutes ago
4912        let mut session = create_test_session(
4913            "claude-code",
4914            "/home/user/project",
4915            now - Duration::hours(1),
4916            None,
4917        );
4918        session.ended_at = Some(now - Duration::minutes(10));
4919
4920        db.insert_session(&session).expect("insert session");
4921
4922        // Find sessions near "now" with a 30 minute window
4923        let found = db
4924            .find_sessions_near_commit_time(now, 30, None)
4925            .expect("find sessions");
4926
4927        assert_eq!(found.len(), 1, "Should find session within window");
4928        assert_eq!(found[0].id, session.id);
4929    }
4930
4931    #[test]
4932    fn test_find_sessions_near_commit_time_outside_window() {
4933        let (db, _dir) = create_test_db();
4934        let now = Utc::now();
4935
4936        // Create a session that ended 2 hours ago
4937        let mut session =
4938            create_test_session("claude-code", "/project", now - Duration::hours(3), None);
4939        session.ended_at = Some(now - Duration::hours(2));
4940
4941        db.insert_session(&session).expect("insert session");
4942
4943        // Find sessions near "now" with a 30 minute window
4944        let found = db
4945            .find_sessions_near_commit_time(now, 30, None)
4946            .expect("find sessions");
4947
4948        assert!(found.is_empty(), "Should not find session outside window");
4949    }
4950
4951    #[test]
4952    fn test_find_sessions_near_commit_time_with_working_dir() {
4953        let (db, _dir) = create_test_db();
4954        let now = Utc::now();
4955
4956        // Create sessions in different directories
4957        let mut session1 = create_test_session(
4958            "claude-code",
4959            "/home/user/project-a",
4960            now - Duration::minutes(30),
4961            None,
4962        );
4963        session1.ended_at = Some(now - Duration::minutes(5));
4964
4965        let mut session2 = create_test_session(
4966            "claude-code",
4967            "/home/user/project-b",
4968            now - Duration::minutes(30),
4969            None,
4970        );
4971        session2.ended_at = Some(now - Duration::minutes(5));
4972
4973        db.insert_session(&session1).expect("insert session1");
4974        db.insert_session(&session2).expect("insert session2");
4975
4976        // Find sessions near "now" filtering by project-a
4977        let found = db
4978            .find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
4979            .expect("find sessions");
4980
4981        assert_eq!(found.len(), 1, "Should find only session in project-a");
4982        assert_eq!(found[0].id, session1.id);
4983    }
4984
4985    #[test]
4986    fn test_find_sessions_near_commit_time_ongoing_session() {
4987        let (db, _dir) = create_test_db();
4988        let now = Utc::now();
4989
4990        // Create an ongoing session (no ended_at)
4991        let session =
4992            create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
4993        // ended_at is None by default
4994
4995        db.insert_session(&session).expect("insert session");
4996
4997        // Find sessions near "now"
4998        let found = db
4999            .find_sessions_near_commit_time(now, 30, None)
5000            .expect("find sessions");
5001
5002        assert_eq!(found.len(), 1, "Should find ongoing session");
5003        assert_eq!(found[0].id, session.id);
5004    }
5005
5006    #[test]
5007    fn test_link_exists_true() {
5008        let (db, _dir) = create_test_db();
5009
5010        let session = create_test_session("claude-code", "/project", Utc::now(), None);
5011        db.insert_session(&session).expect("insert session");
5012
5013        let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
5014        db.insert_link(&link).expect("insert link");
5015
5016        // Check with full SHA
5017        assert!(
5018            db.link_exists(&session.id, "abc123def456")
5019                .expect("check exists"),
5020            "Should find link with full SHA"
5021        );
5022
5023        // Check with partial SHA
5024        assert!(
5025            db.link_exists(&session.id, "abc123").expect("check exists"),
5026            "Should find link with partial SHA"
5027        );
5028    }
5029
5030    #[test]
5031    fn test_link_exists_false() {
5032        let (db, _dir) = create_test_db();
5033
5034        let session = create_test_session("claude-code", "/project", Utc::now(), None);
5035        db.insert_session(&session).expect("insert session");
5036
5037        // No links created
5038        assert!(
5039            !db.link_exists(&session.id, "abc123").expect("check exists"),
5040            "Should not find non-existent link"
5041        );
5042    }
5043
5044    #[test]
5045    fn test_link_exists_different_session() {
5046        let (db, _dir) = create_test_db();
5047
5048        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
5049        let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
5050
5051        db.insert_session(&session1).expect("insert session1");
5052        db.insert_session(&session2).expect("insert session2");
5053
5054        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
5055        db.insert_link(&link).expect("insert link");
5056
5057        // Link exists for session1 but not session2
5058        assert!(
5059            db.link_exists(&session1.id, "abc123").expect("check"),
5060            "Should find link for session1"
5061        );
5062        assert!(
5063            !db.link_exists(&session2.id, "abc123").expect("check"),
5064            "Should not find link for session2"
5065        );
5066    }
5067
5068    // ==================== Forward Auto-linking Tests ====================
5069
5070    #[test]
5071    fn test_find_active_sessions_for_directory_ongoing() {
5072        let (db, _dir) = create_test_db();
5073        let now = Utc::now();
5074
5075        // Create an ongoing session (no ended_at)
5076        let session = create_test_session(
5077            "claude-code",
5078            "/home/user/project",
5079            now - Duration::minutes(30),
5080            None,
5081        );
5082        // ended_at is None by default (ongoing)
5083
5084        db.insert_session(&session).expect("insert session");
5085
5086        // Find active sessions
5087        let found = db
5088            .find_active_sessions_for_directory("/home/user/project", None)
5089            .expect("find active sessions");
5090
5091        assert_eq!(found.len(), 1, "Should find ongoing session");
5092        assert_eq!(found[0].id, session.id);
5093    }
5094
5095    #[test]
5096    fn test_find_active_sessions_for_directory_recently_ended() {
5097        let (db, _dir) = create_test_db();
5098        let now = Utc::now();
5099
5100        // Create a session that ended 2 minutes ago (within default 5 minute window)
5101        let mut session = create_test_session(
5102            "claude-code",
5103            "/home/user/project",
5104            now - Duration::minutes(30),
5105            None,
5106        );
5107        session.ended_at = Some(now - Duration::minutes(2));
5108
5109        db.insert_session(&session).expect("insert session");
5110
5111        // Find active sessions
5112        let found = db
5113            .find_active_sessions_for_directory("/home/user/project", None)
5114            .expect("find active sessions");
5115
5116        assert_eq!(found.len(), 1, "Should find recently ended session");
5117        assert_eq!(found[0].id, session.id);
5118    }
5119
5120    #[test]
5121    fn test_find_active_sessions_for_directory_old_session() {
5122        let (db, _dir) = create_test_db();
5123        let now = Utc::now();
5124
5125        // Create a session that ended 10 minutes ago (outside default 5 minute window)
5126        let mut session = create_test_session(
5127            "claude-code",
5128            "/home/user/project",
5129            now - Duration::minutes(60),
5130            None,
5131        );
5132        session.ended_at = Some(now - Duration::minutes(10));
5133
5134        db.insert_session(&session).expect("insert session");
5135
5136        // Find active sessions
5137        let found = db
5138            .find_active_sessions_for_directory("/home/user/project", None)
5139            .expect("find active sessions");
5140
5141        assert!(found.is_empty(), "Should not find old session");
5142    }
5143
5144    #[test]
5145    fn test_find_active_sessions_for_directory_filters_by_path() {
5146        let (db, _dir) = create_test_db();
5147        let now = Utc::now();
5148
5149        // Create sessions in different directories
5150        let session1 = create_test_session(
5151            "claude-code",
5152            "/home/user/project-a",
5153            now - Duration::minutes(10),
5154            None,
5155        );
5156        let session2 = create_test_session(
5157            "claude-code",
5158            "/home/user/project-b",
5159            now - Duration::minutes(10),
5160            None,
5161        );
5162
5163        db.insert_session(&session1).expect("insert session1");
5164        db.insert_session(&session2).expect("insert session2");
5165
5166        // Find active sessions for project-a only
5167        let found = db
5168            .find_active_sessions_for_directory("/home/user/project-a", None)
5169            .expect("find active sessions");
5170
5171        assert_eq!(found.len(), 1, "Should find only session in project-a");
5172        assert_eq!(found[0].id, session1.id);
5173    }
5174
5175    #[test]
5176    fn test_find_active_sessions_for_directory_trailing_slash_matches() {
5177        let (db, _dir) = create_test_db();
5178        let now = Utc::now();
5179
5180        let session = create_test_session(
5181            "claude-code",
5182            "/home/user/project",
5183            now - Duration::minutes(10),
5184            None,
5185        );
5186        db.insert_session(&session).expect("insert session");
5187
5188        let found = db
5189            .find_active_sessions_for_directory("/home/user/project/", None)
5190            .expect("find active sessions");
5191
5192        assert_eq!(found.len(), 1, "Should match even with trailing slash");
5193        assert_eq!(found[0].id, session.id);
5194    }
5195
5196    #[test]
5197    fn test_find_active_sessions_for_directory_does_not_match_prefix_siblings() {
5198        let (db, _dir) = create_test_db();
5199        let now = Utc::now();
5200
5201        let session_root = create_test_session(
5202            "claude-code",
5203            "/home/user/project",
5204            now - Duration::minutes(10),
5205            None,
5206        );
5207        let session_subdir = create_test_session(
5208            "claude-code",
5209            "/home/user/project/src",
5210            now - Duration::minutes(10),
5211            None,
5212        );
5213        let session_sibling = create_test_session(
5214            "claude-code",
5215            "/home/user/project-old",
5216            now - Duration::minutes(10),
5217            None,
5218        );
5219
5220        db.insert_session(&session_root)
5221            .expect("insert session_root");
5222        db.insert_session(&session_subdir)
5223            .expect("insert session_subdir");
5224        db.insert_session(&session_sibling)
5225            .expect("insert session_sibling");
5226
5227        let found = db
5228            .find_active_sessions_for_directory("/home/user/project", None)
5229            .expect("find active sessions");
5230
5231        let found_ids: std::collections::HashSet<Uuid> =
5232            found.iter().map(|session| session.id).collect();
5233        assert!(found_ids.contains(&session_root.id));
5234        assert!(found_ids.contains(&session_subdir.id));
5235        assert!(!found_ids.contains(&session_sibling.id));
5236    }
5237
5238    #[test]
5239    fn test_get_unsynced_sessions_for_repo_includes_root() {
5240        let (db, _dir) = create_test_db();
5241        let now = Utc::now();
5242
5243        let session = create_test_session("claude-code", "/home/user/project", now, None);
5244        db.insert_session(&session).expect("insert session");
5245
5246        let found = db
5247            .get_unsynced_sessions_for_repo(Path::new("/home/user/project"))
5248            .expect("scoped unsynced");
5249
5250        assert_eq!(found.len(), 1, "session at the repo root must be selected");
5251        assert_eq!(found[0].id, session.id);
5252    }
5253
5254    #[test]
5255    fn test_get_unsynced_sessions_for_repo_excludes_unrelated() {
5256        let (db, _dir) = create_test_db();
5257        let now = Utc::now();
5258
5259        let inside = create_test_session("claude-code", "/home/user/project", now, None);
5260        let unrelated = create_test_session("aider", "/home/user/other-project", now, None);
5261        db.insert_session(&inside).expect("insert inside");
5262        db.insert_session(&unrelated).expect("insert unrelated");
5263
5264        let found = db
5265            .get_unsynced_sessions_for_repo(Path::new("/home/user/project"))
5266            .expect("scoped unsynced");
5267
5268        let ids: std::collections::HashSet<Uuid> = found.iter().map(|s| s.id).collect();
5269        assert!(
5270            ids.contains(&inside.id),
5271            "in-scope session must be selected"
5272        );
5273        assert!(
5274            !ids.contains(&unrelated.id),
5275            "session in an unrelated directory must not be selected"
5276        );
5277    }
5278
5279    #[test]
5280    fn test_get_unsynced_sessions_for_repo_includes_nested_subdir() {
5281        let (db, _dir) = create_test_db();
5282        let now = Utc::now();
5283
5284        let nested = create_test_session("claude-code", "/home/user/project/src/deep", now, None);
5285        db.insert_session(&nested).expect("insert nested");
5286
5287        let found = db
5288            .get_unsynced_sessions_for_repo(Path::new("/home/user/project"))
5289            .expect("scoped unsynced");
5290
5291        assert_eq!(
5292            found.len(),
5293            1,
5294            "session in a nested subdirectory must be selected"
5295        );
5296        assert_eq!(found[0].id, nested.id);
5297    }
5298
5299    #[test]
5300    fn test_get_unsynced_sessions_for_repo_excludes_prefix_sibling() {
5301        let (db, _dir) = create_test_db();
5302        let now = Utc::now();
5303
5304        let root = create_test_session("claude-code", "/home/user/foo", now, None);
5305        let sibling = create_test_session("claude-code", "/home/user/foobar", now, None);
5306        db.insert_session(&root).expect("insert root");
5307        db.insert_session(&sibling).expect("insert sibling");
5308
5309        let found = db
5310            .get_unsynced_sessions_for_repo(Path::new("/home/user/foo"))
5311            .expect("scoped unsynced");
5312
5313        let ids: std::collections::HashSet<Uuid> = found.iter().map(|s| s.id).collect();
5314        assert!(
5315            ids.contains(&root.id),
5316            "the repo root session must be selected"
5317        );
5318        assert!(
5319            !ids.contains(&sibling.id),
5320            "a prefix-sibling directory must not be matched"
5321        );
5322    }
5323
5324    #[test]
5325    fn test_get_unsynced_sessions_for_repo_excludes_already_synced() {
5326        let (db, _dir) = create_test_db();
5327        let now = Utc::now();
5328
5329        let session = create_test_session("claude-code", "/home/user/project", now, None);
5330        db.insert_session(&session).expect("insert session");
5331        db.mark_sessions_synced(&[session.id], now)
5332            .expect("mark synced");
5333
5334        let found = db
5335            .get_unsynced_sessions_for_repo(Path::new("/home/user/project"))
5336            .expect("scoped unsynced");
5337
5338        assert!(
5339            found.is_empty(),
5340            "an already-synced in-scope session must not be selected"
5341        );
5342    }
5343
5344    #[test]
5345    fn test_unsynced_session_count_for_repo_scopes_to_repo() {
5346        let (db, _dir) = create_test_db();
5347        let now = Utc::now();
5348
5349        let inside = create_test_session("claude-code", "/home/user/project", now, None);
5350        let nested = create_test_session("claude-code", "/home/user/project/src", now, None);
5351        let outside = create_test_session("aider", "/home/user/elsewhere", now, None);
5352        db.insert_session(&inside).expect("insert inside");
5353        db.insert_session(&nested).expect("insert nested");
5354        db.insert_session(&outside).expect("insert outside");
5355
5356        let count = db
5357            .unsynced_session_count_for_repo(Path::new("/home/user/project"))
5358            .expect("scoped count");
5359
5360        assert_eq!(
5361            count, 2,
5362            "count must include only in-scope unsynced sessions"
5363        );
5364    }
5365
5366    #[test]
5367    fn test_get_session_ids_for_repo_includes_synced_and_excludes_unrelated() {
5368        let (db, _dir) = create_test_db();
5369        let now = Utc::now();
5370
5371        let unsynced = create_test_session("claude-code", "/home/user/project", now, None);
5372        let synced = create_test_session("claude-code", "/home/user/project/src", now, None);
5373        let outside = create_test_session("aider", "/home/user/other", now, None);
5374        db.insert_session(&unsynced).expect("insert unsynced");
5375        db.insert_session(&synced).expect("insert synced");
5376        db.insert_session(&outside).expect("insert outside");
5377        db.mark_sessions_synced(&[synced.id], now)
5378            .expect("mark synced");
5379
5380        let ids = db
5381            .get_session_ids_for_repo(Path::new("/home/user/project"))
5382            .expect("scoped ids");
5383
5384        assert!(
5385            ids.contains(&unsynced.id),
5386            "an unsynced in-scope id must be included"
5387        );
5388        assert!(
5389            ids.contains(&synced.id),
5390            "an already-synced in-scope id must still be included (scope ignores sync state)"
5391        );
5392        assert!(
5393            !ids.contains(&outside.id),
5394            "an out-of-scope id must be excluded"
5395        );
5396    }
5397
5398    // Fix 2: sessions captured under a non-canonical (symlink) path must still
5399    // be scoped to the repo. macOS temp dirs live under a symlinked `/var`, so
5400    // this also guards the canonical-path branch there.
5401    #[cfg(unix)]
5402    #[test]
5403    fn test_get_unsynced_sessions_for_repo_matches_both_path_variants() {
5404        use std::os::unix::fs::symlink;
5405
5406        let (db, _dir) = create_test_db();
5407        let now = Utc::now();
5408
5409        let base = tempdir().expect("tempdir");
5410        let real = base.path().join("real");
5411        std::fs::create_dir(&real).expect("create real dir");
5412        let link = base.path().join("link");
5413        symlink(&real, &link).expect("create symlink");
5414
5415        // One session captured under the symlinked (as-given) path, one under the
5416        // fully canonicalized path. A repo reported as the symlink form must
5417        // select both, since either variant may appear in a stored session.
5418        let via_link = create_test_session("claude-code", &link.to_string_lossy(), now, None);
5419        let canonical = real.canonicalize().expect("canonicalize real");
5420        let via_real = create_test_session("aider", &canonical.to_string_lossy(), now, None);
5421        db.insert_session(&via_link).expect("insert via link");
5422        db.insert_session(&via_real).expect("insert via real");
5423
5424        let found = db
5425            .get_unsynced_sessions_for_repo(&link)
5426            .expect("scoped unsynced");
5427        let ids: std::collections::HashSet<Uuid> = found.iter().map(|s| s.id).collect();
5428
5429        assert!(
5430            ids.contains(&via_link.id),
5431            "a session captured under the symlinked repo path must match"
5432        );
5433        assert!(
5434            ids.contains(&via_real.id),
5435            "a session captured under the canonicalized repo path must match"
5436        );
5437    }
5438
5439    // Fix 3: LIKE metacharacters in the repo path must not widen the match.
5440    // Regression guard for the ESCAPE '|' logic in directory_match_params.
5441    #[test]
5442    fn test_repo_scope_escapes_like_metacharacters() {
5443        let (db, _dir) = create_test_db();
5444        let now = Utc::now();
5445
5446        // A repo path packed with LIKE metacharacters: '_' (single-char
5447        // wildcard), '%' (multi-char wildcard), and '|' (the ESCAPE character
5448        // itself).
5449        let repo = "/home/user/w_%|k";
5450
5451        let root = create_test_session("claude-code", repo, now, None);
5452        let nested = create_test_session("claude-code", "/home/user/w_%|k/deep/file", now, None);
5453        // Decoys a selector with broken escaping would wrongly match: each
5454        // replaces one metacharacter position with an arbitrary run so it only
5455        // matches if that metacharacter is treated as a wildcard.
5456        let underscore_decoy = create_test_session("aider", "/home/user/wX%|k/x", now, None);
5457        let percent_decoy = create_test_session("aider", "/home/user/w_ANY|k/y", now, None);
5458        for s in [&root, &nested, &underscore_decoy, &percent_decoy] {
5459            db.insert_session(s).expect("insert session");
5460        }
5461
5462        let found = db
5463            .get_unsynced_sessions_for_repo(Path::new(repo))
5464            .expect("scoped unsynced");
5465        let ids: std::collections::HashSet<Uuid> = found.iter().map(|s| s.id).collect();
5466
5467        assert!(ids.contains(&root.id), "the repo root must match");
5468        assert!(
5469            ids.contains(&nested.id),
5470            "a nested path must match (| escaping keeps the pattern valid)"
5471        );
5472        assert!(
5473            !ids.contains(&underscore_decoy.id),
5474            "'_' must be escaped, not treated as a single-char wildcard"
5475        );
5476        assert!(
5477            !ids.contains(&percent_decoy.id),
5478            "'%' must be escaped, not treated as a multi-char wildcard"
5479        );
5480
5481        // The id-scoping selector shares the same predicate, so it must agree.
5482        let id_set = db
5483            .get_session_ids_for_repo(Path::new(repo))
5484            .expect("scoped ids");
5485        assert!(id_set.contains(&root.id));
5486        assert!(id_set.contains(&nested.id));
5487        assert!(!id_set.contains(&underscore_decoy.id));
5488        assert!(!id_set.contains(&percent_decoy.id));
5489    }
5490
5491    #[test]
5492    fn test_find_active_sessions_for_directory_custom_window() {
5493        let (db, _dir) = create_test_db();
5494        let now = Utc::now();
5495
5496        // Create a session that ended 8 minutes ago
5497        let mut session = create_test_session(
5498            "claude-code",
5499            "/home/user/project",
5500            now - Duration::minutes(30),
5501            None,
5502        );
5503        session.ended_at = Some(now - Duration::minutes(8));
5504
5505        db.insert_session(&session).expect("insert session");
5506
5507        // Should not find with default 5 minute window
5508        let found = db
5509            .find_active_sessions_for_directory("/home/user/project", None)
5510            .expect("find with default window");
5511        assert!(found.is_empty(), "Should not find with 5 minute window");
5512
5513        // Should find with 10 minute window
5514        let found = db
5515            .find_active_sessions_for_directory("/home/user/project", Some(10))
5516            .expect("find with 10 minute window");
5517        assert_eq!(found.len(), 1, "Should find with 10 minute window");
5518    }
5519
5520    // ==================== Enhanced Search Tests ====================
5521
5522    #[test]
5523    fn test_search_with_tool_filter() {
5524        let (db, _dir) = create_test_db();
5525
5526        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
5527        let session2 = create_test_session("aider", "/project2", Utc::now(), None);
5528
5529        db.insert_session(&session1).expect("insert session1");
5530        db.insert_session(&session2).expect("insert session2");
5531
5532        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
5533        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
5534
5535        db.insert_message(&msg1).expect("insert msg1");
5536        db.insert_message(&msg2).expect("insert msg2");
5537
5538        // Search with tool filter
5539        let options = SearchOptions {
5540            query: "Hello".to_string(),
5541            limit: 10,
5542            tool: Some("claude-code".to_string()),
5543            ..Default::default()
5544        };
5545        let results = db.search_with_options(&options).expect("search");
5546
5547        assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
5548        assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
5549    }
5550
5551    #[test]
5552    fn test_search_with_date_range() {
5553        let (db, _dir) = create_test_db();
5554
5555        let old_time = Utc::now() - chrono::Duration::days(30);
5556        let new_time = Utc::now() - chrono::Duration::days(1);
5557
5558        let session1 = create_test_session("claude-code", "/project1", old_time, None);
5559        let session2 = create_test_session("claude-code", "/project2", new_time, None);
5560
5561        db.insert_session(&session1).expect("insert session1");
5562        db.insert_session(&session2).expect("insert session2");
5563
5564        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
5565        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
5566
5567        db.insert_message(&msg1).expect("insert msg1");
5568        db.insert_message(&msg2).expect("insert msg2");
5569
5570        // Search with since filter (last 7 days)
5571        let since = Utc::now() - chrono::Duration::days(7);
5572        let options = SearchOptions {
5573            query: "session".to_string(),
5574            limit: 10,
5575            since: Some(since),
5576            ..Default::default()
5577        };
5578        let results = db.search_with_options(&options).expect("search");
5579
5580        assert_eq!(results.len(), 1, "Should find 1 result within date range");
5581        assert!(
5582            results[0].working_directory.contains("project2"),
5583            "Should be from newer project"
5584        );
5585    }
5586
5587    #[test]
5588    fn test_search_with_project_filter() {
5589        let (db, _dir) = create_test_db();
5590
5591        let session1 =
5592            create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
5593        let session2 =
5594            create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
5595
5596        db.insert_session(&session1).expect("insert session1");
5597        db.insert_session(&session2).expect("insert session2");
5598
5599        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
5600        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
5601
5602        db.insert_message(&msg1).expect("insert msg1");
5603        db.insert_message(&msg2).expect("insert msg2");
5604
5605        // Search with project filter
5606        let options = SearchOptions {
5607            query: "Testing".to_string(),
5608            limit: 10,
5609            project: Some("frontend".to_string()),
5610            ..Default::default()
5611        };
5612        let results = db.search_with_options(&options).expect("search");
5613
5614        assert_eq!(results.len(), 1, "Should find 1 result with project filter");
5615        assert!(
5616            results[0].working_directory.contains("frontend"),
5617            "Should be from frontend project"
5618        );
5619    }
5620
5621    #[test]
5622    fn test_search_with_branch_filter() {
5623        let (db, _dir) = create_test_db();
5624
5625        let session1 = Session {
5626            id: Uuid::new_v4(),
5627            tool: "claude-code".to_string(),
5628            tool_version: None,
5629            started_at: Utc::now(),
5630            ended_at: None,
5631            model: None,
5632            working_directory: "/project".to_string(),
5633            git_branch: Some("feat/auth".to_string()),
5634            source_path: None,
5635            message_count: 0,
5636            machine_id: None,
5637        };
5638        let session2 = Session {
5639            id: Uuid::new_v4(),
5640            tool: "claude-code".to_string(),
5641            tool_version: None,
5642            started_at: Utc::now(),
5643            ended_at: None,
5644            model: None,
5645            working_directory: "/project".to_string(),
5646            git_branch: Some("main".to_string()),
5647            source_path: None,
5648            message_count: 0,
5649            machine_id: None,
5650        };
5651
5652        db.insert_session(&session1).expect("insert session1");
5653        db.insert_session(&session2).expect("insert session2");
5654
5655        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
5656        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
5657
5658        db.insert_message(&msg1).expect("insert msg1");
5659        db.insert_message(&msg2).expect("insert msg2");
5660
5661        // Search with branch filter
5662        let options = SearchOptions {
5663            query: "work".to_string(),
5664            limit: 10,
5665            branch: Some("auth".to_string()),
5666            ..Default::default()
5667        };
5668        let results = db.search_with_options(&options).expect("search");
5669
5670        assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
5671        assert_eq!(
5672            results[0].git_branch.as_deref(),
5673            Some("feat/auth"),
5674            "Should be from feat/auth branch"
5675        );
5676    }
5677
5678    #[test]
5679    fn test_search_metadata_matches_project() {
5680        let (db, _dir) = create_test_db();
5681
5682        let session =
5683            create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
5684        db.insert_session(&session).expect("insert session");
5685
5686        // Add a message that does NOT contain "redactyl"
5687        let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
5688        db.insert_message(&msg).expect("insert msg");
5689
5690        // Search for "redactyl" - should match session metadata
5691        let options = SearchOptions {
5692            query: "redactyl".to_string(),
5693            limit: 10,
5694            ..Default::default()
5695        };
5696        let results = db.search_with_options(&options).expect("search");
5697
5698        assert_eq!(
5699            results.len(),
5700            1,
5701            "Should find session via metadata match on project name"
5702        );
5703    }
5704
5705    #[test]
5706    fn test_search_returns_extended_session_info() {
5707        let (db, _dir) = create_test_db();
5708
5709        let started_at = Utc::now();
5710        let session = Session {
5711            id: Uuid::new_v4(),
5712            tool: "claude-code".to_string(),
5713            tool_version: Some("1.0.0".to_string()),
5714            started_at,
5715            ended_at: None,
5716            model: None,
5717            working_directory: "/home/user/myapp".to_string(),
5718            git_branch: Some("develop".to_string()),
5719            source_path: None,
5720            message_count: 5,
5721            machine_id: None,
5722        };
5723        db.insert_session(&session).expect("insert session");
5724
5725        let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
5726        db.insert_message(&msg).expect("insert msg");
5727
5728        let options = SearchOptions {
5729            query: "Test".to_string(),
5730            limit: 10,
5731            ..Default::default()
5732        };
5733        let results = db.search_with_options(&options).expect("search");
5734
5735        assert_eq!(results.len(), 1, "Should find 1 result");
5736        let result = &results[0];
5737
5738        assert_eq!(result.tool, "claude-code", "Tool should be populated");
5739        assert_eq!(
5740            result.git_branch.as_deref(),
5741            Some("develop"),
5742            "Branch should be populated"
5743        );
5744        assert!(
5745            result.session_message_count > 0,
5746            "Message count should be populated"
5747        );
5748        assert!(
5749            result.session_started_at.is_some(),
5750            "Session start time should be populated"
5751        );
5752    }
5753
5754    #[test]
5755    fn test_get_context_messages() {
5756        let (db, _dir) = create_test_db();
5757
5758        let session = create_test_session("claude-code", "/project", Utc::now(), None);
5759        db.insert_session(&session).expect("insert session");
5760
5761        // Create 5 messages in sequence
5762        for i in 0..5 {
5763            let role = if i % 2 == 0 {
5764                MessageRole::User
5765            } else {
5766                MessageRole::Assistant
5767            };
5768            let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
5769            db.insert_message(&msg).expect("insert message");
5770        }
5771
5772        // Get context around message index 2 (the middle one)
5773        let (before, after) = db
5774            .get_context_messages(&session.id, 2, 1)
5775            .expect("get context");
5776
5777        assert_eq!(before.len(), 1, "Should have 1 message before");
5778        assert_eq!(after.len(), 1, "Should have 1 message after");
5779        assert_eq!(before[0].index, 1, "Before message should be index 1");
5780        assert_eq!(after[0].index, 3, "After message should be index 3");
5781    }
5782
5783    #[test]
5784    fn test_get_context_messages_at_start() {
5785        let (db, _dir) = create_test_db();
5786
5787        let session = create_test_session("claude-code", "/project", Utc::now(), None);
5788        db.insert_session(&session).expect("insert session");
5789
5790        for i in 0..3 {
5791            let msg =
5792                create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
5793            db.insert_message(&msg).expect("insert message");
5794        }
5795
5796        // Get context around first message (index 0)
5797        let (before, after) = db
5798            .get_context_messages(&session.id, 0, 2)
5799            .expect("get context");
5800
5801        assert!(
5802            before.is_empty(),
5803            "Should have no messages before first message"
5804        );
5805        assert_eq!(after.len(), 2, "Should have 2 messages after");
5806    }
5807
5808    #[test]
5809    fn test_get_context_messages_at_end() {
5810        let (db, _dir) = create_test_db();
5811
5812        let session = create_test_session("claude-code", "/project", Utc::now(), None);
5813        db.insert_session(&session).expect("insert session");
5814
5815        for i in 0..3 {
5816            let msg =
5817                create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
5818            db.insert_message(&msg).expect("insert message");
5819        }
5820
5821        // Get context around last message (index 2)
5822        let (before, after) = db
5823            .get_context_messages(&session.id, 2, 2)
5824            .expect("get context");
5825
5826        assert_eq!(before.len(), 2, "Should have 2 messages before");
5827        assert!(
5828            after.is_empty(),
5829            "Should have no messages after last message"
5830        );
5831    }
5832
5833    #[test]
5834    fn test_search_combined_filters() {
5835        let (db, _dir) = create_test_db();
5836
5837        let session1 = Session {
5838            id: Uuid::new_v4(),
5839            tool: "claude-code".to_string(),
5840            tool_version: None,
5841            started_at: Utc::now(),
5842            ended_at: None,
5843            model: None,
5844            working_directory: "/home/user/myapp".to_string(),
5845            git_branch: Some("feat/api".to_string()),
5846            source_path: None,
5847            message_count: 1,
5848            machine_id: None,
5849        };
5850        let session2 = Session {
5851            id: Uuid::new_v4(),
5852            tool: "aider".to_string(),
5853            tool_version: None,
5854            started_at: Utc::now(),
5855            ended_at: None,
5856            model: None,
5857            working_directory: "/home/user/myapp".to_string(),
5858            git_branch: Some("feat/api".to_string()),
5859            source_path: None,
5860            message_count: 1,
5861            machine_id: None,
5862        };
5863
5864        db.insert_session(&session1).expect("insert session1");
5865        db.insert_session(&session2).expect("insert session2");
5866
5867        let msg1 =
5868            create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
5869        let msg2 =
5870            create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
5871
5872        db.insert_message(&msg1).expect("insert msg1");
5873        db.insert_message(&msg2).expect("insert msg2");
5874
5875        // Search with multiple filters
5876        let options = SearchOptions {
5877            query: "API".to_string(),
5878            limit: 10,
5879            tool: Some("claude-code".to_string()),
5880            branch: Some("api".to_string()),
5881            project: Some("myapp".to_string()),
5882            ..Default::default()
5883        };
5884        let results = db.search_with_options(&options).expect("search");
5885
5886        // Results may include both message content match and metadata match from same session
5887        assert!(
5888            !results.is_empty(),
5889            "Should find at least 1 result matching all filters"
5890        );
5891        // All results should be from claude-code (the filtered tool)
5892        for result in &results {
5893            assert_eq!(
5894                result.tool, "claude-code",
5895                "All results should be from claude-code"
5896            );
5897        }
5898    }
5899
5900    // ==================== Session Deletion Tests ====================
5901
5902    #[test]
5903    fn test_delete_session_removes_all_data() {
5904        let (db, _dir) = create_test_db();
5905
5906        let session = create_test_session("claude-code", "/project", Utc::now(), None);
5907        db.insert_session(&session).expect("insert session");
5908
5909        // Add messages
5910        let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
5911        let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there");
5912        db.insert_message(&msg1).expect("insert msg1");
5913        db.insert_message(&msg2).expect("insert msg2");
5914
5915        // Add a link
5916        let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
5917        db.insert_link(&link).expect("insert link");
5918
5919        // Verify data exists
5920        assert_eq!(db.session_count().expect("count"), 1);
5921        assert_eq!(db.message_count().expect("count"), 2);
5922        assert_eq!(db.link_count().expect("count"), 1);
5923
5924        // Delete the session
5925        let (msgs_deleted, links_deleted) = db.delete_session(&session.id).expect("delete");
5926        assert_eq!(msgs_deleted, 2, "Should delete 2 messages");
5927        assert_eq!(links_deleted, 1, "Should delete 1 link");
5928
5929        // Verify all data is gone
5930        assert_eq!(db.session_count().expect("count"), 0);
5931        assert_eq!(db.message_count().expect("count"), 0);
5932        assert_eq!(db.link_count().expect("count"), 0);
5933        assert!(db.get_session(&session.id).expect("get").is_none());
5934    }
5935
5936    #[test]
5937    fn test_delete_session_preserves_other_sessions() {
5938        let (db, _dir) = create_test_db();
5939
5940        let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
5941        let session2 = create_test_session("aider", "/project2", Utc::now(), None);
5942
5943        db.insert_session(&session1).expect("insert session1");
5944        db.insert_session(&session2).expect("insert session2");
5945
5946        let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello 1");
5947        let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello 2");
5948        db.insert_message(&msg1).expect("insert msg1");
5949        db.insert_message(&msg2).expect("insert msg2");
5950
5951        // Delete only session1
5952        db.delete_session(&session1.id).expect("delete");
5953
5954        // Verify session2 still exists
5955        assert_eq!(db.session_count().expect("count"), 1);
5956        assert_eq!(db.message_count().expect("count"), 1);
5957        assert!(db.get_session(&session2.id).expect("get").is_some());
5958    }
5959
5960    // ==================== Database Maintenance Tests ====================
5961
5962    #[test]
5963    fn test_file_size() {
5964        let (db, _dir) = create_test_db();
5965
5966        let size = db.file_size().expect("get size");
5967        assert!(size.is_some(), "Should have file size for file-based db");
5968        assert!(size.unwrap() > 0, "Database file should have size > 0");
5969    }
5970
5971    #[test]
5972    fn test_vacuum() {
5973        let (db, _dir) = create_test_db();
5974
5975        // Just verify vacuum runs without error
5976        db.vacuum().expect("vacuum should succeed");
5977    }
5978
5979    #[test]
5980    fn test_count_sessions_older_than() {
5981        let (db, _dir) = create_test_db();
5982        let now = Utc::now();
5983
5984        // Create sessions at different times
5985        let old_session =
5986            create_test_session("claude-code", "/project1", now - Duration::days(100), None);
5987        let recent_session =
5988            create_test_session("claude-code", "/project2", now - Duration::days(10), None);
5989
5990        db.insert_session(&old_session).expect("insert old");
5991        db.insert_session(&recent_session).expect("insert recent");
5992
5993        // Count sessions older than 30 days
5994        let cutoff = now - Duration::days(30);
5995        let count = db.count_sessions_older_than(cutoff).expect("count");
5996        assert_eq!(count, 1, "Should find 1 session older than 30 days");
5997
5998        // Count sessions older than 200 days
5999        let old_cutoff = now - Duration::days(200);
6000        let old_count = db.count_sessions_older_than(old_cutoff).expect("count");
6001        assert_eq!(old_count, 0, "Should find 0 sessions older than 200 days");
6002    }
6003
6004    #[test]
6005    fn test_delete_sessions_older_than() {
6006        let (db, _dir) = create_test_db();
6007        let now = Utc::now();
6008
6009        // Create sessions at different times
6010        let old_session =
6011            create_test_session("claude-code", "/project1", now - Duration::days(100), None);
6012        let recent_session =
6013            create_test_session("claude-code", "/project2", now - Duration::days(10), None);
6014
6015        db.insert_session(&old_session).expect("insert old");
6016        db.insert_session(&recent_session).expect("insert recent");
6017
6018        // Add messages to both
6019        let msg1 = create_test_message(old_session.id, 0, MessageRole::User, "Old message");
6020        let msg2 = create_test_message(recent_session.id, 0, MessageRole::User, "Recent message");
6021        db.insert_message(&msg1).expect("insert msg1");
6022        db.insert_message(&msg2).expect("insert msg2");
6023
6024        // Delete sessions older than 30 days
6025        let cutoff = now - Duration::days(30);
6026        let deleted = db.delete_sessions_older_than(cutoff).expect("delete");
6027        assert_eq!(deleted, 1, "Should delete 1 session");
6028
6029        // Verify only recent session remains
6030        assert_eq!(db.session_count().expect("count"), 1);
6031        assert!(db.get_session(&recent_session.id).expect("get").is_some());
6032        assert!(db.get_session(&old_session.id).expect("get").is_none());
6033
6034        // Verify messages were also deleted
6035        assert_eq!(db.message_count().expect("count"), 1);
6036    }
6037
6038    #[test]
6039    fn test_get_sessions_older_than() {
6040        let (db, _dir) = create_test_db();
6041        let now = Utc::now();
6042
6043        // Create sessions at different times
6044        let old_session = create_test_session(
6045            "claude-code",
6046            "/project/old",
6047            now - Duration::days(100),
6048            None,
6049        );
6050        let medium_session =
6051            create_test_session("aider", "/project/medium", now - Duration::days(50), None);
6052        let recent_session =
6053            create_test_session("gemini", "/project/recent", now - Duration::days(10), None);
6054
6055        db.insert_session(&old_session).expect("insert old");
6056        db.insert_session(&medium_session).expect("insert medium");
6057        db.insert_session(&recent_session).expect("insert recent");
6058
6059        // Get sessions older than 30 days
6060        let cutoff = now - Duration::days(30);
6061        let sessions = db.get_sessions_older_than(cutoff).expect("get sessions");
6062        assert_eq!(
6063            sessions.len(),
6064            2,
6065            "Should find 2 sessions older than 30 days"
6066        );
6067
6068        // Verify sessions are ordered by start date (oldest first)
6069        assert_eq!(sessions[0].id, old_session.id);
6070        assert_eq!(sessions[1].id, medium_session.id);
6071
6072        // Verify session data is returned correctly
6073        assert_eq!(sessions[0].tool, "claude-code");
6074        assert_eq!(sessions[0].working_directory, "/project/old");
6075        assert_eq!(sessions[1].tool, "aider");
6076        assert_eq!(sessions[1].working_directory, "/project/medium");
6077
6078        // Get sessions older than 200 days
6079        let old_cutoff = now - Duration::days(200);
6080        let old_sessions = db
6081            .get_sessions_older_than(old_cutoff)
6082            .expect("get old sessions");
6083        assert_eq!(
6084            old_sessions.len(),
6085            0,
6086            "Should find 0 sessions older than 200 days"
6087        );
6088    }
6089
6090    #[test]
6091    fn test_stats() {
6092        let (db, _dir) = create_test_db();
6093        let now = Utc::now();
6094
6095        // Empty database stats
6096        let empty_stats = db.stats().expect("stats");
6097        assert_eq!(empty_stats.session_count, 0);
6098        assert_eq!(empty_stats.message_count, 0);
6099        assert_eq!(empty_stats.link_count, 0);
6100        assert!(empty_stats.oldest_session.is_none());
6101        assert!(empty_stats.newest_session.is_none());
6102        assert!(empty_stats.sessions_by_tool.is_empty());
6103
6104        // Add some data
6105        let session1 =
6106            create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
6107        let session2 = create_test_session("aider", "/project2", now - Duration::hours(1), None);
6108        let session3 = create_test_session("claude-code", "/project3", now, None);
6109
6110        db.insert_session(&session1).expect("insert 1");
6111        db.insert_session(&session2).expect("insert 2");
6112        db.insert_session(&session3).expect("insert 3");
6113
6114        let msg = create_test_message(session1.id, 0, MessageRole::User, "Hello");
6115        db.insert_message(&msg).expect("insert msg");
6116
6117        let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
6118        db.insert_link(&link).expect("insert link");
6119
6120        // Check stats
6121        let stats = db.stats().expect("stats");
6122        assert_eq!(stats.session_count, 3);
6123        assert_eq!(stats.message_count, 1);
6124        assert_eq!(stats.link_count, 1);
6125        assert!(stats.oldest_session.is_some());
6126        assert!(stats.newest_session.is_some());
6127
6128        // Check sessions by tool
6129        assert_eq!(stats.sessions_by_tool.len(), 2);
6130        // claude-code should come first (most sessions)
6131        assert_eq!(stats.sessions_by_tool[0].0, "claude-code");
6132        assert_eq!(stats.sessions_by_tool[0].1, 2);
6133        assert_eq!(stats.sessions_by_tool[1].0, "aider");
6134        assert_eq!(stats.sessions_by_tool[1].1, 1);
6135    }
6136
6137    // ==================== Branch History Tests ====================
6138
6139    #[test]
6140    fn test_get_session_branch_history_no_messages() {
6141        let (db, _dir) = create_test_db();
6142        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6143        db.insert_session(&session)
6144            .expect("Failed to insert session");
6145
6146        let branches = db
6147            .get_session_branch_history(session.id)
6148            .expect("Failed to get branch history");
6149
6150        assert!(branches.is_empty(), "Empty session should have no branches");
6151    }
6152
6153    #[test]
6154    fn test_get_session_branch_history_single_branch() {
6155        let (db, _dir) = create_test_db();
6156        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6157        db.insert_session(&session)
6158            .expect("Failed to insert session");
6159
6160        // Insert messages all on the same branch
6161        for i in 0..3 {
6162            let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
6163            msg.git_branch = Some("main".to_string());
6164            db.insert_message(&msg).expect("Failed to insert message");
6165        }
6166
6167        let branches = db
6168            .get_session_branch_history(session.id)
6169            .expect("Failed to get branch history");
6170
6171        assert_eq!(branches, vec!["main"], "Should have single branch");
6172    }
6173
6174    #[test]
6175    fn test_get_session_branch_history_multiple_branches() {
6176        let (db, _dir) = create_test_db();
6177        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6178        db.insert_session(&session)
6179            .expect("Failed to insert session");
6180
6181        // Insert messages with branch transitions: main -> feat/auth -> main
6182        let branch_sequence = ["main", "main", "feat/auth", "feat/auth", "main"];
6183        for (i, branch) in branch_sequence.iter().enumerate() {
6184            let mut msg = create_test_message(session.id, i as i32, MessageRole::User, "test");
6185            msg.git_branch = Some(branch.to_string());
6186            db.insert_message(&msg).expect("Failed to insert message");
6187        }
6188
6189        let branches = db
6190            .get_session_branch_history(session.id)
6191            .expect("Failed to get branch history");
6192
6193        assert_eq!(
6194            branches,
6195            vec!["main", "feat/auth", "main"],
6196            "Should show branch transitions without consecutive duplicates"
6197        );
6198    }
6199
6200    #[test]
6201    fn test_get_session_branch_history_with_none_branches() {
6202        let (db, _dir) = create_test_db();
6203        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6204        db.insert_session(&session)
6205            .expect("Failed to insert session");
6206
6207        // Insert messages with a mix of Some and None branches
6208        let mut msg1 = create_test_message(session.id, 0, MessageRole::User, "test");
6209        msg1.git_branch = Some("main".to_string());
6210        db.insert_message(&msg1).expect("Failed to insert message");
6211
6212        let mut msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "test");
6213        msg2.git_branch = None; // No branch info
6214        db.insert_message(&msg2).expect("Failed to insert message");
6215
6216        let mut msg3 = create_test_message(session.id, 2, MessageRole::User, "test");
6217        msg3.git_branch = Some("feat/new".to_string());
6218        db.insert_message(&msg3).expect("Failed to insert message");
6219
6220        let branches = db
6221            .get_session_branch_history(session.id)
6222            .expect("Failed to get branch history");
6223
6224        assert_eq!(
6225            branches,
6226            vec!["main", "feat/new"],
6227            "Should skip None branches and show transitions"
6228        );
6229    }
6230
6231    #[test]
6232    fn test_get_session_branch_history_all_none_branches() {
6233        let (db, _dir) = create_test_db();
6234        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6235        db.insert_session(&session)
6236            .expect("Failed to insert session");
6237
6238        // Insert messages with no branch info
6239        for i in 0..3 {
6240            let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
6241            msg.git_branch = None;
6242            db.insert_message(&msg).expect("Failed to insert message");
6243        }
6244
6245        let branches = db
6246            .get_session_branch_history(session.id)
6247            .expect("Failed to get branch history");
6248
6249        assert!(
6250            branches.is_empty(),
6251            "Session with all None branches should return empty"
6252        );
6253    }
6254
6255    // ==================== Machine ID Tests ====================
6256
6257    #[test]
6258    fn test_session_stores_machine_id() {
6259        let (db, _dir) = create_test_db();
6260        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6261
6262        db.insert_session(&session)
6263            .expect("Failed to insert session");
6264
6265        let retrieved = db
6266            .get_session(&session.id)
6267            .expect("Failed to get session")
6268            .expect("Session should exist");
6269
6270        assert_eq!(
6271            retrieved.machine_id,
6272            Some("test-machine".to_string()),
6273            "Machine ID should be preserved"
6274        );
6275    }
6276
6277    #[test]
6278    fn test_session_with_none_machine_id() {
6279        let (db, _dir) = create_test_db();
6280        let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
6281        session.machine_id = None;
6282
6283        db.insert_session(&session)
6284            .expect("Failed to insert session");
6285
6286        let retrieved = db
6287            .get_session(&session.id)
6288            .expect("Failed to get session")
6289            .expect("Session should exist");
6290
6291        assert!(
6292            retrieved.machine_id.is_none(),
6293            "Session with None machine_id should preserve None"
6294        );
6295    }
6296
6297    #[test]
6298    fn test_migration_adds_machine_id_column() {
6299        // Create a database and verify the machine_id column works
6300        let (db, _dir) = create_test_db();
6301
6302        // Insert a session with machine_id to confirm the column exists
6303        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6304        db.insert_session(&session)
6305            .expect("Should insert session with machine_id column");
6306
6307        // Retrieve and verify
6308        let retrieved = db
6309            .get_session(&session.id)
6310            .expect("Failed to get session")
6311            .expect("Session should exist");
6312
6313        assert_eq!(
6314            retrieved.machine_id,
6315            Some("test-machine".to_string()),
6316            "Machine ID should be stored and retrieved"
6317        );
6318    }
6319
6320    #[test]
6321    fn test_list_sessions_includes_machine_id() {
6322        let (db, _dir) = create_test_db();
6323        let now = Utc::now();
6324
6325        let mut session1 = create_test_session("claude-code", "/project1", now, None);
6326        session1.machine_id = Some("machine-a".to_string());
6327
6328        let mut session2 = create_test_session("claude-code", "/project2", now, None);
6329        session2.machine_id = Some("machine-b".to_string());
6330
6331        db.insert_session(&session1).expect("insert");
6332        db.insert_session(&session2).expect("insert");
6333
6334        let sessions = db.list_sessions(10, None).expect("list");
6335
6336        assert_eq!(sessions.len(), 2);
6337        let machine_ids: Vec<Option<String>> =
6338            sessions.iter().map(|s| s.machine_id.clone()).collect();
6339        assert!(machine_ids.contains(&Some("machine-a".to_string())));
6340        assert!(machine_ids.contains(&Some("machine-b".to_string())));
6341    }
6342
6343    // ==================== Annotation Tests ====================
6344
6345    #[test]
6346    fn test_insert_and_get_annotations() {
6347        let (db, _dir) = create_test_db();
6348        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6349        db.insert_session(&session).expect("insert session");
6350
6351        let annotation = Annotation {
6352            id: Uuid::new_v4(),
6353            session_id: session.id,
6354            content: "This is a test note".to_string(),
6355            created_at: Utc::now(),
6356        };
6357        db.insert_annotation(&annotation)
6358            .expect("insert annotation");
6359
6360        let annotations = db.get_annotations(&session.id).expect("get annotations");
6361        assert_eq!(annotations.len(), 1);
6362        assert_eq!(annotations[0].content, "This is a test note");
6363        assert_eq!(annotations[0].session_id, session.id);
6364    }
6365
6366    #[test]
6367    fn test_delete_annotation() {
6368        let (db, _dir) = create_test_db();
6369        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6370        db.insert_session(&session).expect("insert session");
6371
6372        let annotation = Annotation {
6373            id: Uuid::new_v4(),
6374            session_id: session.id,
6375            content: "Test annotation".to_string(),
6376            created_at: Utc::now(),
6377        };
6378        db.insert_annotation(&annotation).expect("insert");
6379
6380        let deleted = db.delete_annotation(&annotation.id).expect("delete");
6381        assert!(deleted);
6382
6383        let annotations = db.get_annotations(&session.id).expect("get");
6384        assert!(annotations.is_empty());
6385    }
6386
6387    #[test]
6388    fn test_delete_annotations_by_session() {
6389        let (db, _dir) = create_test_db();
6390        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6391        db.insert_session(&session).expect("insert session");
6392
6393        for i in 0..3 {
6394            let annotation = Annotation {
6395                id: Uuid::new_v4(),
6396                session_id: session.id,
6397                content: format!("Annotation {i}"),
6398                created_at: Utc::now(),
6399            };
6400            db.insert_annotation(&annotation).expect("insert");
6401        }
6402
6403        let count = db
6404            .delete_annotations_by_session(&session.id)
6405            .expect("delete all");
6406        assert_eq!(count, 3);
6407
6408        let annotations = db.get_annotations(&session.id).expect("get");
6409        assert!(annotations.is_empty());
6410    }
6411
6412    // ==================== Tag Tests ====================
6413
6414    #[test]
6415    fn test_insert_and_get_tags() {
6416        let (db, _dir) = create_test_db();
6417        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6418        db.insert_session(&session).expect("insert session");
6419
6420        let tag = Tag {
6421            id: Uuid::new_v4(),
6422            session_id: session.id,
6423            label: "bug-fix".to_string(),
6424            created_at: Utc::now(),
6425        };
6426        db.insert_tag(&tag).expect("insert tag");
6427
6428        let tags = db.get_tags(&session.id).expect("get tags");
6429        assert_eq!(tags.len(), 1);
6430        assert_eq!(tags[0].label, "bug-fix");
6431    }
6432
6433    #[test]
6434    fn test_tag_exists() {
6435        let (db, _dir) = create_test_db();
6436        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6437        db.insert_session(&session).expect("insert session");
6438
6439        assert!(!db.tag_exists(&session.id, "bug-fix").expect("check"));
6440
6441        let tag = Tag {
6442            id: Uuid::new_v4(),
6443            session_id: session.id,
6444            label: "bug-fix".to_string(),
6445            created_at: Utc::now(),
6446        };
6447        db.insert_tag(&tag).expect("insert tag");
6448
6449        assert!(db.tag_exists(&session.id, "bug-fix").expect("check"));
6450        assert!(!db.tag_exists(&session.id, "feature").expect("check other"));
6451    }
6452
6453    #[test]
6454    fn test_delete_tag() {
6455        let (db, _dir) = create_test_db();
6456        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6457        db.insert_session(&session).expect("insert session");
6458
6459        let tag = Tag {
6460            id: Uuid::new_v4(),
6461            session_id: session.id,
6462            label: "wip".to_string(),
6463            created_at: Utc::now(),
6464        };
6465        db.insert_tag(&tag).expect("insert tag");
6466
6467        let deleted = db.delete_tag(&session.id, "wip").expect("delete");
6468        assert!(deleted);
6469
6470        let deleted_again = db.delete_tag(&session.id, "wip").expect("delete again");
6471        assert!(!deleted_again);
6472    }
6473
6474    #[test]
6475    fn test_list_sessions_with_tag() {
6476        let (db, _dir) = create_test_db();
6477        let now = Utc::now();
6478
6479        let session1 = create_test_session("claude-code", "/project1", now, None);
6480        let session2 =
6481            create_test_session("claude-code", "/project2", now - Duration::minutes(5), None);
6482        let session3 = create_test_session(
6483            "claude-code",
6484            "/project3",
6485            now - Duration::minutes(10),
6486            None,
6487        );
6488
6489        db.insert_session(&session1).expect("insert");
6490        db.insert_session(&session2).expect("insert");
6491        db.insert_session(&session3).expect("insert");
6492
6493        // Tag session1 and session3 with "feature"
6494        let tag1 = Tag {
6495            id: Uuid::new_v4(),
6496            session_id: session1.id,
6497            label: "feature".to_string(),
6498            created_at: Utc::now(),
6499        };
6500        let tag3 = Tag {
6501            id: Uuid::new_v4(),
6502            session_id: session3.id,
6503            label: "feature".to_string(),
6504            created_at: Utc::now(),
6505        };
6506        db.insert_tag(&tag1).expect("insert tag");
6507        db.insert_tag(&tag3).expect("insert tag");
6508
6509        let sessions = db.list_sessions_with_tag("feature", 10).expect("list");
6510        assert_eq!(sessions.len(), 2);
6511        // Should be ordered by start time descending
6512        assert_eq!(sessions[0].id, session1.id);
6513        assert_eq!(sessions[1].id, session3.id);
6514
6515        let sessions = db.list_sessions_with_tag("nonexistent", 10).expect("list");
6516        assert!(sessions.is_empty());
6517    }
6518
6519    #[test]
6520    fn test_get_most_recent_session_for_directory() {
6521        let (db, _dir) = create_test_db();
6522        let now = Utc::now();
6523
6524        let session1 = create_test_session(
6525            "claude-code",
6526            "/home/user/project",
6527            now - Duration::hours(1),
6528            None,
6529        );
6530        let session2 = create_test_session("claude-code", "/home/user/project", now, None);
6531        let session3 = create_test_session("claude-code", "/home/user/other", now, None);
6532
6533        db.insert_session(&session1).expect("insert");
6534        db.insert_session(&session2).expect("insert");
6535        db.insert_session(&session3).expect("insert");
6536
6537        let result = db
6538            .get_most_recent_session_for_directory("/home/user/project")
6539            .expect("get");
6540        assert!(result.is_some());
6541        assert_eq!(result.unwrap().id, session2.id);
6542
6543        let result = db
6544            .get_most_recent_session_for_directory("/home/user/nonexistent")
6545            .expect("get");
6546        assert!(result.is_none());
6547    }
6548
6549    #[test]
6550    fn test_session_deletion_removes_annotations_and_tags() {
6551        let (db, _dir) = create_test_db();
6552        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6553        db.insert_session(&session).expect("insert session");
6554
6555        // Add annotation
6556        let annotation = Annotation {
6557            id: Uuid::new_v4(),
6558            session_id: session.id,
6559            content: "Test annotation".to_string(),
6560            created_at: Utc::now(),
6561        };
6562        db.insert_annotation(&annotation).expect("insert");
6563
6564        // Add tag
6565        let tag = Tag {
6566            id: Uuid::new_v4(),
6567            session_id: session.id,
6568            label: "test-tag".to_string(),
6569            created_at: Utc::now(),
6570        };
6571        db.insert_tag(&tag).expect("insert");
6572
6573        // Delete the session
6574        db.delete_session(&session.id).expect("delete");
6575
6576        // Verify annotations and tags are gone
6577        let annotations = db.get_annotations(&session.id).expect("get");
6578        assert!(annotations.is_empty());
6579
6580        let tags = db.get_tags(&session.id).expect("get");
6581        assert!(tags.is_empty());
6582    }
6583
6584    #[test]
6585    fn test_insert_and_get_summary() {
6586        let (db, _dir) = create_test_db();
6587        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
6588        db.insert_session(&session).expect("insert session");
6589
6590        let summary = Summary {
6591            id: Uuid::new_v4(),
6592            session_id: session.id,
6593            content: "Test summary content".to_string(),
6594            generated_at: Utc::now(),
6595        };
6596        db.insert_summary(&summary).expect("insert summary");
6597
6598        let retrieved = db.get_summary(&session.id).expect("get summary");
6599        assert!(retrieved.is_some());
6600        let retrieved = retrieved.unwrap();
6601        assert_eq!(retrieved.content, "Test summary content");
6602        assert_eq!(retrieved.session_id, session.id);
6603    }
6604
6605    #[test]
6606    fn test_get_summary_nonexistent() {
6607        let (db, _dir) = create_test_db();
6608        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
6609        db.insert_session(&session).expect("insert session");
6610
6611        let retrieved = db.get_summary(&session.id).expect("get summary");
6612        assert!(retrieved.is_none());
6613    }
6614
6615    #[test]
6616    fn test_update_summary() {
6617        let (db, _dir) = create_test_db();
6618        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
6619        db.insert_session(&session).expect("insert session");
6620
6621        let summary = Summary {
6622            id: Uuid::new_v4(),
6623            session_id: session.id,
6624            content: "Original content".to_string(),
6625            generated_at: Utc::now(),
6626        };
6627        db.insert_summary(&summary).expect("insert summary");
6628
6629        // Update the summary
6630        let updated = db
6631            .update_summary(&session.id, "Updated content")
6632            .expect("update summary");
6633        assert!(updated);
6634
6635        let retrieved = db.get_summary(&session.id).expect("get summary");
6636        assert!(retrieved.is_some());
6637        assert_eq!(retrieved.unwrap().content, "Updated content");
6638    }
6639
6640    #[test]
6641    fn test_update_summary_nonexistent() {
6642        let (db, _dir) = create_test_db();
6643        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
6644        db.insert_session(&session).expect("insert session");
6645
6646        // Try to update a summary that does not exist
6647        let updated = db
6648            .update_summary(&session.id, "New content")
6649            .expect("update summary");
6650        assert!(!updated);
6651    }
6652
6653    #[test]
6654    fn test_delete_summary() {
6655        let (db, _dir) = create_test_db();
6656        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
6657        db.insert_session(&session).expect("insert session");
6658
6659        let summary = Summary {
6660            id: Uuid::new_v4(),
6661            session_id: session.id,
6662            content: "To be deleted".to_string(),
6663            generated_at: Utc::now(),
6664        };
6665        db.insert_summary(&summary).expect("insert summary");
6666
6667        // Delete the summary
6668        let deleted = db.delete_summary(&session.id).expect("delete summary");
6669        assert!(deleted);
6670
6671        // Verify it's gone
6672        let retrieved = db.get_summary(&session.id).expect("get summary");
6673        assert!(retrieved.is_none());
6674    }
6675
6676    #[test]
6677    fn test_delete_session_removes_summary() {
6678        let (db, _dir) = create_test_db();
6679        let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
6680        db.insert_session(&session).expect("insert session");
6681
6682        let summary = Summary {
6683            id: Uuid::new_v4(),
6684            session_id: session.id,
6685            content: "Session summary".to_string(),
6686            generated_at: Utc::now(),
6687        };
6688        db.insert_summary(&summary).expect("insert summary");
6689
6690        // Delete the session
6691        db.delete_session(&session.id).expect("delete session");
6692
6693        // Verify summary is also deleted
6694        let retrieved = db.get_summary(&session.id).expect("get summary");
6695        assert!(retrieved.is_none());
6696    }
6697
6698    // ==================== Machine Tests ====================
6699
6700    #[test]
6701    fn test_upsert_machine_insert() {
6702        let (db, _dir) = create_test_db();
6703
6704        let machine = Machine {
6705            id: "test-uuid-1234".to_string(),
6706            name: "my-laptop".to_string(),
6707            created_at: Utc::now().to_rfc3339(),
6708        };
6709
6710        db.upsert_machine(&machine)
6711            .expect("Failed to upsert machine");
6712
6713        let retrieved = db
6714            .get_machine("test-uuid-1234")
6715            .expect("Failed to get machine")
6716            .expect("Machine should exist");
6717
6718        assert_eq!(retrieved.id, "test-uuid-1234");
6719        assert_eq!(retrieved.name, "my-laptop");
6720    }
6721
6722    #[test]
6723    fn test_upsert_machine_update() {
6724        let (db, _dir) = create_test_db();
6725
6726        // Insert initial machine
6727        let machine1 = Machine {
6728            id: "test-uuid-5678".to_string(),
6729            name: "old-name".to_string(),
6730            created_at: Utc::now().to_rfc3339(),
6731        };
6732        db.upsert_machine(&machine1)
6733            .expect("Failed to upsert machine");
6734
6735        // Update with new name
6736        let machine2 = Machine {
6737            id: "test-uuid-5678".to_string(),
6738            name: "new-name".to_string(),
6739            created_at: Utc::now().to_rfc3339(),
6740        };
6741        db.upsert_machine(&machine2)
6742            .expect("Failed to upsert machine");
6743
6744        // Verify name was updated
6745        let retrieved = db
6746            .get_machine("test-uuid-5678")
6747            .expect("Failed to get machine")
6748            .expect("Machine should exist");
6749
6750        assert_eq!(retrieved.name, "new-name");
6751    }
6752
6753    #[test]
6754    fn test_get_machine() {
6755        let (db, _dir) = create_test_db();
6756
6757        // Machine does not exist initially
6758        let not_found = db.get_machine("nonexistent-uuid").expect("Failed to query");
6759        assert!(not_found.is_none(), "Machine should not exist");
6760
6761        // Insert a machine
6762        let machine = Machine {
6763            id: "existing-uuid".to_string(),
6764            name: "test-machine".to_string(),
6765            created_at: Utc::now().to_rfc3339(),
6766        };
6767        db.upsert_machine(&machine).expect("Failed to upsert");
6768
6769        // Now it should be found
6770        let found = db
6771            .get_machine("existing-uuid")
6772            .expect("Failed to query")
6773            .expect("Machine should exist");
6774
6775        assert_eq!(found.id, "existing-uuid");
6776        assert_eq!(found.name, "test-machine");
6777    }
6778
6779    #[test]
6780    fn test_get_machine_name_found() {
6781        let (db, _dir) = create_test_db();
6782
6783        let machine = Machine {
6784            id: "uuid-for-name-test".to_string(),
6785            name: "my-workstation".to_string(),
6786            created_at: Utc::now().to_rfc3339(),
6787        };
6788        db.upsert_machine(&machine).expect("Failed to upsert");
6789
6790        let name = db
6791            .get_machine_name("uuid-for-name-test")
6792            .expect("Failed to get name");
6793
6794        assert_eq!(name, "my-workstation");
6795    }
6796
6797    #[test]
6798    fn test_get_machine_name_not_found() {
6799        let (db, _dir) = create_test_db();
6800
6801        // Machine does not exist, should return truncated UUID
6802        let name = db
6803            .get_machine_name("abc123def456789")
6804            .expect("Failed to get name");
6805
6806        assert_eq!(name, "abc123de", "Should return first 8 characters");
6807
6808        // Test with short ID
6809        let short_name = db.get_machine_name("short").expect("Failed to get name");
6810
6811        assert_eq!(
6812            short_name, "short",
6813            "Should return full ID if shorter than 8 chars"
6814        );
6815    }
6816
6817    #[test]
6818    fn test_list_machines() {
6819        let (db, _dir) = create_test_db();
6820
6821        // Initially empty
6822        let machines = db.list_machines().expect("Failed to list");
6823        assert!(machines.is_empty(), "Should have no machines initially");
6824
6825        // Add machines
6826        let machine1 = Machine {
6827            id: "uuid-1".to_string(),
6828            name: "machine-1".to_string(),
6829            created_at: "2024-01-01T00:00:00Z".to_string(),
6830        };
6831        let machine2 = Machine {
6832            id: "uuid-2".to_string(),
6833            name: "machine-2".to_string(),
6834            created_at: "2024-01-02T00:00:00Z".to_string(),
6835        };
6836
6837        db.upsert_machine(&machine1).expect("Failed to upsert");
6838        db.upsert_machine(&machine2).expect("Failed to upsert");
6839
6840        // List should return both machines
6841        let machines = db.list_machines().expect("Failed to list");
6842        assert_eq!(machines.len(), 2, "Should have 2 machines");
6843
6844        // Should be ordered by created_at (oldest first)
6845        assert_eq!(machines[0].id, "uuid-1");
6846        assert_eq!(machines[1].id, "uuid-2");
6847    }
6848
6849    // ==================== Session ID Prefix Lookup Tests ====================
6850
6851    #[test]
6852    fn test_find_session_by_id_prefix_full_uuid() {
6853        let (db, _dir) = create_test_db();
6854        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6855        db.insert_session(&session).expect("insert session");
6856
6857        // Find by full UUID string
6858        let found = db
6859            .find_session_by_id_prefix(&session.id.to_string())
6860            .expect("find session")
6861            .expect("session should exist");
6862
6863        assert_eq!(found.id, session.id, "Should find session by full UUID");
6864    }
6865
6866    #[test]
6867    fn test_find_session_by_id_prefix_short_prefix() {
6868        let (db, _dir) = create_test_db();
6869        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6870        db.insert_session(&session).expect("insert session");
6871
6872        // Get a short prefix (first 8 characters)
6873        let prefix = &session.id.to_string()[..8];
6874
6875        let found = db
6876            .find_session_by_id_prefix(prefix)
6877            .expect("find session")
6878            .expect("session should exist");
6879
6880        assert_eq!(found.id, session.id, "Should find session by short prefix");
6881    }
6882
6883    #[test]
6884    fn test_find_session_by_id_prefix_very_short_prefix() {
6885        let (db, _dir) = create_test_db();
6886        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6887        db.insert_session(&session).expect("insert session");
6888
6889        // Get just the first 4 characters
6890        let prefix = &session.id.to_string()[..4];
6891
6892        let found = db
6893            .find_session_by_id_prefix(prefix)
6894            .expect("find session")
6895            .expect("session should exist");
6896
6897        assert_eq!(
6898            found.id, session.id,
6899            "Should find session by very short prefix"
6900        );
6901    }
6902
6903    #[test]
6904    fn test_find_session_by_id_prefix_not_found() {
6905        let (db, _dir) = create_test_db();
6906        let session = create_test_session("claude-code", "/project", Utc::now(), None);
6907        db.insert_session(&session).expect("insert session");
6908
6909        // Try to find with a non-matching prefix
6910        let found = db
6911            .find_session_by_id_prefix("zzz999")
6912            .expect("find session");
6913
6914        assert!(
6915            found.is_none(),
6916            "Should return None for non-matching prefix"
6917        );
6918    }
6919
6920    #[test]
6921    fn test_find_session_by_id_prefix_empty_db() {
6922        let (db, _dir) = create_test_db();
6923
6924        let found = db
6925            .find_session_by_id_prefix("abc123")
6926            .expect("find session");
6927
6928        assert!(found.is_none(), "Should return None for empty database");
6929    }
6930
6931    #[test]
6932    fn test_find_session_by_id_prefix_ambiguous() {
6933        let (db, _dir) = create_test_db();
6934
6935        // Create 100 sessions to increase chance of prefix collision
6936        let mut sessions = Vec::new();
6937        for _ in 0..100 {
6938            let session = create_test_session("claude-code", "/project", Utc::now(), None);
6939            db.insert_session(&session).expect("insert session");
6940            sessions.push(session);
6941        }
6942
6943        // Find two sessions that share a common prefix (first char)
6944        let first_session = &sessions[0];
6945        let first_char = first_session.id.to_string().chars().next().unwrap();
6946
6947        // Count how many sessions start with the same character
6948        let matching_count = sessions
6949            .iter()
6950            .filter(|s| s.id.to_string().starts_with(first_char))
6951            .count();
6952
6953        if matching_count > 1 {
6954            // If we have multiple sessions starting with same character,
6955            // a single-character prefix should return an ambiguity error
6956            let result = db.find_session_by_id_prefix(&first_char.to_string());
6957            assert!(
6958                result.is_err(),
6959                "Should return error for ambiguous single-character prefix"
6960            );
6961            let error_msg = result.unwrap_err().to_string();
6962            assert!(
6963                error_msg.contains("Ambiguous"),
6964                "Error should mention ambiguity"
6965            );
6966        }
6967    }
6968
6969    #[test]
6970    fn test_find_session_by_id_prefix_returns_correct_session_data() {
6971        let (db, _dir) = create_test_db();
6972
6973        let mut session =
6974            create_test_session("claude-code", "/home/user/myproject", Utc::now(), None);
6975        session.tool_version = Some("2.0.0".to_string());
6976        session.model = Some("claude-opus-4".to_string());
6977        session.git_branch = Some("feature/test".to_string());
6978        session.message_count = 42;
6979        db.insert_session(&session).expect("insert session");
6980
6981        // Find by prefix
6982        let prefix = &session.id.to_string()[..8];
6983        let found = db
6984            .find_session_by_id_prefix(prefix)
6985            .expect("find session")
6986            .expect("session should exist");
6987
6988        // Verify all fields are correctly returned
6989        assert_eq!(found.id, session.id);
6990        assert_eq!(found.tool, "claude-code");
6991        assert_eq!(found.tool_version, Some("2.0.0".to_string()));
6992        assert_eq!(found.model, Some("claude-opus-4".to_string()));
6993        assert_eq!(found.working_directory, "/home/user/myproject");
6994        assert_eq!(found.git_branch, Some("feature/test".to_string()));
6995        assert_eq!(found.message_count, 42);
6996    }
6997
6998    #[test]
6999    fn test_find_session_by_id_prefix_many_sessions() {
7000        let (db, _dir) = create_test_db();
7001
7002        // Insert many sessions (more than the old 100/1000 limits)
7003        let mut target_session = None;
7004        for i in 0..200 {
7005            let session =
7006                create_test_session("claude-code", &format!("/project/{i}"), Utc::now(), None);
7007            db.insert_session(&session).expect("insert session");
7008            // Save a session to search for later
7009            if i == 150 {
7010                target_session = Some(session);
7011            }
7012        }
7013
7014        let target = target_session.expect("should have target session");
7015        let prefix = &target.id.to_string()[..8];
7016
7017        // Should still find the session even with many sessions in the database
7018        let found = db
7019            .find_session_by_id_prefix(prefix)
7020            .expect("find session")
7021            .expect("session should exist");
7022
7023        assert_eq!(
7024            found.id, target.id,
7025            "Should find correct session among many"
7026        );
7027        assert_eq!(found.working_directory, "/project/150");
7028    }
7029
7030    #[test]
7031    fn test_import_session_with_messages() {
7032        let (mut db, _dir) = create_test_db();
7033
7034        let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
7035        let messages = vec![
7036            create_test_message(session.id, 0, MessageRole::User, "Hello"),
7037            create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!"),
7038            create_test_message(session.id, 2, MessageRole::User, "How are you?"),
7039        ];
7040
7041        let synced_at = Utc::now();
7042        db.import_session_with_messages(&session, &messages, Some(synced_at))
7043            .expect("Failed to import session with messages");
7044
7045        // Verify session was inserted
7046        let retrieved_session = db.get_session(&session.id).expect("Failed to get session");
7047        assert!(retrieved_session.is_some(), "Session should exist");
7048        let retrieved_session = retrieved_session.unwrap();
7049        assert_eq!(retrieved_session.tool, "claude-code");
7050
7051        // Verify messages were inserted
7052        let retrieved_messages = db
7053            .get_messages(&session.id)
7054            .expect("Failed to get messages");
7055        assert_eq!(retrieved_messages.len(), 3, "Should have 3 messages");
7056        assert_eq!(retrieved_messages[0].content.text(), "Hello");
7057        assert_eq!(retrieved_messages[1].content.text(), "Hi there!");
7058        assert_eq!(retrieved_messages[2].content.text(), "How are you?");
7059
7060        // Verify session is marked as synced
7061        let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
7062        assert!(
7063            !unsynced.iter().any(|s| s.id == session.id),
7064            "Session should be marked as synced"
7065        );
7066    }
7067
7068    #[test]
7069    fn test_import_session_with_messages_no_sync() {
7070        let (mut db, _dir) = create_test_db();
7071
7072        let session = create_test_session("aider", "/tmp/test", Utc::now(), None);
7073        let messages = vec![create_test_message(
7074            session.id,
7075            0,
7076            MessageRole::User,
7077            "Test message",
7078        )];
7079
7080        // Import without marking as synced
7081        db.import_session_with_messages(&session, &messages, None)
7082            .expect("Failed to import session");
7083
7084        // Verify session is NOT synced
7085        let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
7086        assert!(
7087            unsynced.iter().any(|s| s.id == session.id),
7088            "Session should NOT be marked as synced"
7089        );
7090    }
7091
7092    #[test]
7093    fn test_session_update_resets_sync_status() {
7094        let (db, _dir) = create_test_db();
7095
7096        // Create and insert a session
7097        let mut session =
7098            create_test_session("claude-code", "/home/user/project", Utc::now(), None);
7099        session.message_count = 5;
7100        db.insert_session(&session)
7101            .expect("Failed to insert session");
7102
7103        // Mark it as synced
7104        db.mark_sessions_synced(&[session.id], Utc::now())
7105            .expect("Failed to mark synced");
7106
7107        // Verify it's synced (not in unsynced list)
7108        let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
7109        assert!(
7110            !unsynced.iter().any(|s| s.id == session.id),
7111            "Session should be synced initially"
7112        );
7113
7114        // Simulate session being continued with new messages
7115        session.message_count = 10;
7116        session.ended_at = Some(Utc::now());
7117        db.insert_session(&session)
7118            .expect("Failed to update session");
7119
7120        // Verify it's now marked as unsynced (needs re-sync)
7121        let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
7122        assert!(
7123            unsynced.iter().any(|s| s.id == session.id),
7124            "Session should be marked for re-sync after update"
7125        );
7126
7127        // Verify the message count was updated
7128        let retrieved = db
7129            .get_session(&session.id)
7130            .expect("Failed to get session")
7131            .expect("Session should exist");
7132        assert_eq!(
7133            retrieved.message_count, 10,
7134            "Message count should be updated"
7135        );
7136    }
7137
7138    // ==================== Insights Tests ====================
7139
7140    #[test]
7141    fn test_sessions_in_date_range_all() {
7142        let (db, _dir) = create_test_db();
7143        let now = Utc::now();
7144
7145        let s1 = create_test_session("claude-code", "/project/a", now - Duration::hours(3), None);
7146        let s2 = create_test_session("aider", "/project/b", now - Duration::hours(1), None);
7147
7148        db.insert_session(&s1).expect("Failed to insert session");
7149        db.insert_session(&s2).expect("Failed to insert session");
7150
7151        let results = db
7152            .sessions_in_date_range(None, None, None)
7153            .expect("Failed to query sessions");
7154        assert_eq!(
7155            results.len(),
7156            2,
7157            "Should return all sessions when no filters"
7158        );
7159    }
7160
7161    #[test]
7162    fn test_sessions_in_date_range_with_since() {
7163        let (db, _dir) = create_test_db();
7164        let now = Utc::now();
7165
7166        let s1 = create_test_session("claude-code", "/project/a", now - Duration::hours(5), None);
7167        let s2 = create_test_session("aider", "/project/b", now - Duration::hours(1), None);
7168
7169        db.insert_session(&s1).expect("Failed to insert session");
7170        db.insert_session(&s2).expect("Failed to insert session");
7171
7172        let since = now - Duration::hours(3);
7173        let results = db
7174            .sessions_in_date_range(Some(since), None, None)
7175            .expect("Failed to query sessions");
7176        assert_eq!(results.len(), 1, "Should return only sessions after since");
7177        assert_eq!(results[0].id, s2.id, "Should return the newer session");
7178    }
7179
7180    #[test]
7181    fn test_sessions_in_date_range_with_working_dir() {
7182        let (db, _dir) = create_test_db();
7183        let now = Utc::now();
7184
7185        let s1 = create_test_session(
7186            "claude-code",
7187            "/project/alpha",
7188            now - Duration::hours(2),
7189            None,
7190        );
7191        let s2 = create_test_session("aider", "/project/beta", now - Duration::hours(1), None);
7192
7193        db.insert_session(&s1).expect("Failed to insert session");
7194        db.insert_session(&s2).expect("Failed to insert session");
7195
7196        let results = db
7197            .sessions_in_date_range(None, None, Some("/project/alpha"))
7198            .expect("Failed to query sessions");
7199        assert_eq!(results.len(), 1, "Should return only matching working dir");
7200        assert_eq!(results[0].id, s1.id, "Should return the alpha session");
7201    }
7202
7203    #[test]
7204    fn test_sessions_in_date_range_with_until() {
7205        let (db, _dir) = create_test_db();
7206        let now = Utc::now();
7207
7208        let s1 = create_test_session("claude-code", "/project/a", now - Duration::hours(5), None);
7209        let s2 = create_test_session("aider", "/project/b", now - Duration::hours(1), None);
7210
7211        db.insert_session(&s1).expect("Failed to insert session");
7212        db.insert_session(&s2).expect("Failed to insert session");
7213
7214        let until = now - Duration::hours(3);
7215        let results = db
7216            .sessions_in_date_range(None, Some(until), None)
7217            .expect("Failed to query sessions");
7218        assert_eq!(results.len(), 1, "Should return only sessions before until");
7219        assert_eq!(results[0].id, s1.id, "Should return the older session");
7220    }
7221
7222    #[test]
7223    fn test_sessions_in_date_range_with_since_and_until() {
7224        let (db, _dir) = create_test_db();
7225        let now = Utc::now();
7226
7227        let s1 = create_test_session("claude-code", "/project", now - Duration::hours(8), None);
7228        let s2 = create_test_session("aider", "/project", now - Duration::hours(4), None);
7229        let s3 = create_test_session("claude-code", "/project", now - Duration::hours(1), None);
7230
7231        db.insert_session(&s1).expect("Failed to insert session");
7232        db.insert_session(&s2).expect("Failed to insert session");
7233        db.insert_session(&s3).expect("Failed to insert session");
7234
7235        let since = now - Duration::hours(6);
7236        let until = now - Duration::hours(2);
7237        let results = db
7238            .sessions_in_date_range(Some(since), Some(until), None)
7239            .expect("Failed to query sessions");
7240        assert_eq!(
7241            results.len(),
7242            1,
7243            "Should return only sessions in the window"
7244        );
7245        assert_eq!(results[0].id, s2.id, "Should return the middle session");
7246    }
7247
7248    #[test]
7249    fn test_average_session_duration() {
7250        let (db, _dir) = create_test_db();
7251        let now = Utc::now();
7252
7253        let mut s1 = create_test_session("claude-code", "/project", now - Duration::hours(2), None);
7254        s1.ended_at = Some(s1.started_at + Duration::minutes(30));
7255
7256        let mut s2 = create_test_session("aider", "/project", now - Duration::hours(1), None);
7257        s2.ended_at = Some(s2.started_at + Duration::minutes(60));
7258
7259        db.insert_session(&s1).expect("Failed to insert session");
7260        db.insert_session(&s2).expect("Failed to insert session");
7261
7262        let avg = db
7263            .average_session_duration_minutes(None, None)
7264            .expect("Failed to get average duration");
7265        assert!(avg.is_some(), "Should return an average");
7266        let avg_val = avg.unwrap();
7267        // Average of 30 and 60 = 45
7268        assert!(
7269            (avg_val - 45.0).abs() < 1.0,
7270            "Average should be approximately 45 minutes, got {}",
7271            avg_val
7272        );
7273    }
7274
7275    #[test]
7276    fn test_average_session_duration_no_ended_sessions() {
7277        let (db, _dir) = create_test_db();
7278        let now = Utc::now();
7279
7280        // Session without ended_at
7281        let s1 = create_test_session("claude-code", "/project", now, None);
7282        db.insert_session(&s1).expect("Failed to insert session");
7283
7284        let avg = db
7285            .average_session_duration_minutes(None, None)
7286            .expect("Failed to get average duration");
7287        assert!(
7288            avg.is_none(),
7289            "Should return None when no sessions have ended_at"
7290        );
7291    }
7292
7293    #[test]
7294    fn test_sessions_by_tool_in_range() {
7295        let (db, _dir) = create_test_db();
7296        let now = Utc::now();
7297
7298        let s1 = create_test_session("claude-code", "/project", now - Duration::hours(3), None);
7299        let s2 = create_test_session("claude-code", "/project", now - Duration::hours(2), None);
7300        let s3 = create_test_session("aider", "/project", now - Duration::hours(1), None);
7301
7302        db.insert_session(&s1).expect("Failed to insert session");
7303        db.insert_session(&s2).expect("Failed to insert session");
7304        db.insert_session(&s3).expect("Failed to insert session");
7305
7306        let results = db
7307            .sessions_by_tool_in_range(None, None)
7308            .expect("Failed to get sessions by tool");
7309        assert_eq!(results.len(), 2, "Should have two tools");
7310        // Sorted by count descending, claude-code should be first
7311        assert_eq!(results[0].0, "claude-code");
7312        assert_eq!(results[0].1, 2);
7313        assert_eq!(results[1].0, "aider");
7314        assert_eq!(results[1].1, 1);
7315    }
7316
7317    #[test]
7318    fn test_sessions_by_weekday() {
7319        let (db, _dir) = create_test_db();
7320        // Use a known date: 2024-01-15 is a Monday (weekday 1)
7321        let monday = chrono::NaiveDate::from_ymd_opt(2024, 1, 15)
7322            .unwrap()
7323            .and_hms_opt(12, 0, 0)
7324            .unwrap()
7325            .and_utc();
7326
7327        let s1 = create_test_session("claude-code", "/project", monday, None);
7328        let s2 = create_test_session("aider", "/project", monday + Duration::hours(1), None);
7329
7330        db.insert_session(&s1).expect("Failed to insert session");
7331        db.insert_session(&s2).expect("Failed to insert session");
7332
7333        let results = db
7334            .sessions_by_weekday(None, None)
7335            .expect("Failed to get sessions by weekday");
7336        assert_eq!(results.len(), 1, "Should have one weekday entry");
7337        assert_eq!(results[0].0, 1, "Monday is weekday 1");
7338        assert_eq!(results[0].1, 2, "Should have 2 sessions on Monday");
7339    }
7340
7341    #[test]
7342    fn test_average_message_count() {
7343        let (db, _dir) = create_test_db();
7344        let now = Utc::now();
7345
7346        let mut s1 = create_test_session("claude-code", "/project", now - Duration::hours(2), None);
7347        s1.message_count = 10;
7348
7349        let mut s2 = create_test_session("aider", "/project", now - Duration::hours(1), None);
7350        s2.message_count = 20;
7351
7352        db.insert_session(&s1).expect("Failed to insert session");
7353        db.insert_session(&s2).expect("Failed to insert session");
7354
7355        let avg = db
7356            .average_message_count(None, None)
7357            .expect("Failed to get average message count");
7358        assert!(avg.is_some(), "Should return an average");
7359        let avg_val = avg.unwrap();
7360        // Average of 10 and 20 = 15
7361        assert!(
7362            (avg_val - 15.0).abs() < 0.01,
7363            "Average should be 15.0, got {}",
7364            avg_val
7365        );
7366    }
7367
7368    // ============ Remote-Merge Tests (git-ref sync child records) ============
7369
7370    use crate::storage::models::{Annotation, Summary, Tag};
7371
7372    /// Imports a session as already synced so merges can target an existing row.
7373    fn seed_synced_session(db: &mut Database, message_count: i32) -> Session {
7374        let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7375        session.message_count = message_count;
7376        db.import_session_with_messages(&session, &[], Some(Utc::now()))
7377            .unwrap();
7378        session
7379    }
7380
7381    #[test]
7382    fn test_merge_link_is_idempotent() {
7383        let (mut db, _dir) = create_test_db();
7384        let session = seed_synced_session(&mut db, 1);
7385
7386        let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
7387
7388        // First merge inserts the link, a second merge with the same id is a no-op.
7389        let links_in = std::slice::from_ref(&link);
7390        db.merge_remote_record(&session, &[], links_in, &[], &[], None, Utc::now())
7391            .unwrap();
7392        db.merge_remote_record(&session, &[], links_in, &[], &[], None, Utc::now())
7393            .unwrap();
7394
7395        let links = db.get_links_by_session(&session.id).unwrap();
7396        assert_eq!(
7397            links.len(),
7398            1,
7399            "Re-merging the same link must not duplicate"
7400        );
7401        assert_eq!(links[0].id, link.id);
7402    }
7403
7404    #[test]
7405    fn test_merge_tag_idempotent_by_id_and_label() {
7406        let (mut db, _dir) = create_test_db();
7407        let session = seed_synced_session(&mut db, 1);
7408
7409        let tag = Tag {
7410            id: Uuid::new_v4(),
7411            session_id: session.id,
7412            label: "bug-fix".to_string(),
7413            created_at: Utc::now(),
7414        };
7415        // A different id but the same (session, label) also collapses to a no-op.
7416        let tag_same_label = Tag {
7417            id: Uuid::new_v4(),
7418            ..tag.clone()
7419        };
7420
7421        let tags_in = std::slice::from_ref(&tag);
7422        db.merge_remote_record(&session, &[], &[], tags_in, &[], None, Utc::now())
7423            .unwrap();
7424        db.merge_remote_record(&session, &[], &[], tags_in, &[], None, Utc::now())
7425            .unwrap();
7426        db.merge_remote_record(
7427            &session,
7428            &[],
7429            &[],
7430            std::slice::from_ref(&tag_same_label),
7431            &[],
7432            None,
7433            Utc::now(),
7434        )
7435        .unwrap();
7436
7437        let tags = db.get_tags(&session.id).unwrap();
7438        assert_eq!(
7439            tags.len(),
7440            1,
7441            "Duplicate tag label must not be inserted twice"
7442        );
7443        assert_eq!(tags[0].label, "bug-fix");
7444    }
7445
7446    #[test]
7447    fn test_merge_annotation_is_idempotent() {
7448        let (mut db, _dir) = create_test_db();
7449        let session = seed_synced_session(&mut db, 1);
7450
7451        let annotation = Annotation {
7452            id: Uuid::new_v4(),
7453            session_id: session.id,
7454            content: "important".to_string(),
7455            created_at: Utc::now(),
7456        };
7457
7458        let annotations_in = std::slice::from_ref(&annotation);
7459        db.merge_remote_record(&session, &[], &[], &[], annotations_in, None, Utc::now())
7460            .unwrap();
7461        db.merge_remote_record(&session, &[], &[], &[], annotations_in, None, Utc::now())
7462            .unwrap();
7463
7464        let annotations = db.get_annotations(&session.id).unwrap();
7465        assert_eq!(annotations.len(), 1);
7466        assert_eq!(annotations[0].content, "important");
7467    }
7468
7469    #[test]
7470    fn test_merge_summary_newer_wins() {
7471        let (mut db, _dir) = create_test_db();
7472        let session = seed_synced_session(&mut db, 1);
7473
7474        let base = Utc::now();
7475        let summary = Summary {
7476            id: Uuid::new_v4(),
7477            session_id: session.id,
7478            content: "first".to_string(),
7479            generated_at: base,
7480        };
7481        db.merge_remote_record(&session, &[], &[], &[], &[], Some(&summary), Utc::now())
7482            .unwrap();
7483
7484        // A strictly newer summary for the same session refreshes the content.
7485        let updated = Summary {
7486            id: Uuid::new_v4(),
7487            session_id: session.id,
7488            content: "second".to_string(),
7489            generated_at: base + Duration::seconds(1),
7490        };
7491        db.merge_remote_record(&session, &[], &[], &[], &[], Some(&updated), Utc::now())
7492            .unwrap();
7493
7494        let stored = db
7495            .get_summary(&session.id)
7496            .unwrap()
7497            .expect("summary exists");
7498        assert_eq!(stored.content, "second", "Newer content should win");
7499    }
7500
7501    #[test]
7502    fn test_merge_summary_older_does_not_overwrite_newer() {
7503        let (mut db, _dir) = create_test_db();
7504        let session = seed_synced_session(&mut db, 1);
7505
7506        let base = Utc::now();
7507        let newer = Summary {
7508            id: Uuid::new_v4(),
7509            session_id: session.id,
7510            content: "newer local".to_string(),
7511            generated_at: base,
7512        };
7513        db.merge_remote_record(&session, &[], &[], &[], &[], Some(&newer), Utc::now())
7514            .unwrap();
7515
7516        // An older incoming summary must not clobber the newer stored one.
7517        let older = Summary {
7518            id: Uuid::new_v4(),
7519            session_id: session.id,
7520            content: "older remote".to_string(),
7521            generated_at: base - Duration::seconds(60),
7522        };
7523        db.merge_remote_record(&session, &[], &[], &[], &[], Some(&older), Utc::now())
7524            .unwrap();
7525
7526        let stored = db
7527            .get_summary(&session.id)
7528            .unwrap()
7529            .expect("summary exists");
7530        assert_eq!(
7531            stored.content, "newer local",
7532            "older summary must not overwrite a newer one"
7533        );
7534    }
7535
7536    #[test]
7537    fn test_insert_link_clears_parent_synced_at() {
7538        let (mut db, _dir) = create_test_db();
7539        let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7540        session.message_count = 0;
7541
7542        // Import as already synced (synced_at set), then add a link locally.
7543        db.import_session_with_messages(&session, &[], Some(Utc::now()))
7544            .unwrap();
7545        assert_eq!(
7546            db.get_unsynced_sessions().unwrap().len(),
7547            0,
7548            "session should start synced"
7549        );
7550
7551        db.insert_link(&SessionLink {
7552            id: Uuid::new_v4(),
7553            session_id: session.id,
7554            link_type: LinkType::Commit,
7555            commit_sha: Some("abcdef".to_string()),
7556            branch: None,
7557            remote: None,
7558            created_at: Utc::now(),
7559            created_by: LinkCreator::User,
7560            confidence: None,
7561        })
7562        .unwrap();
7563
7564        let unsynced = db.get_unsynced_sessions().unwrap();
7565        assert_eq!(
7566            unsynced.len(),
7567            1,
7568            "adding a link must re-open the parent session for sync"
7569        );
7570        assert_eq!(unsynced[0].id, session.id);
7571    }
7572
7573    #[test]
7574    fn test_merge_remote_record_imports_child_on_equal_session() {
7575        let (mut db, _dir) = create_test_db();
7576        let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7577        session.message_count = 1;
7578
7579        // Local session already present and synced, with no children.
7580        db.import_session_with_messages(&session, &[], Some(Utc::now()))
7581            .unwrap();
7582
7583        // A remote record with the SAME session (not newer) but a new link.
7584        let link = SessionLink {
7585            id: Uuid::new_v4(),
7586            session_id: session.id,
7587            link_type: LinkType::Commit,
7588            commit_sha: Some("cafe".to_string()),
7589            branch: None,
7590            remote: None,
7591            created_at: Utc::now(),
7592            created_by: LinkCreator::Auto,
7593            confidence: None,
7594        };
7595        let imported = db
7596            .merge_remote_record(&session, &[], &[link], &[], &[], None, Utc::now())
7597            .unwrap();
7598
7599        assert!(
7600            !imported,
7601            "an equal session row must not count as a pulled session"
7602        );
7603        let links = db.get_links_by_session(&session.id).unwrap();
7604        assert_eq!(
7605            links.len(),
7606            1,
7607            "the remote-only link must still be merged in"
7608        );
7609        assert_eq!(links[0].commit_sha, Some("cafe".to_string()));
7610    }
7611
7612    #[test]
7613    fn test_merge_remote_record_persists_full_record() {
7614        // A brand-new record merges its session, messages, and every child record
7615        // in one call, all landing together (single transaction).
7616        let (mut db, _dir) = create_test_db();
7617        let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7618        session.message_count = 1;
7619
7620        let message = Message {
7621            id: Uuid::new_v4(),
7622            session_id: session.id,
7623            parent_id: None,
7624            index: 0,
7625            timestamp: Utc::now(),
7626            role: MessageRole::User,
7627            content: MessageContent::Text("hello".to_string()),
7628            model: None,
7629            git_branch: None,
7630            cwd: None,
7631        };
7632        let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
7633        let tag = Tag {
7634            id: Uuid::new_v4(),
7635            session_id: session.id,
7636            label: "feature".to_string(),
7637            created_at: Utc::now(),
7638        };
7639        let annotation = Annotation {
7640            id: Uuid::new_v4(),
7641            session_id: session.id,
7642            content: "note".to_string(),
7643            created_at: Utc::now(),
7644        };
7645        let summary = Summary {
7646            id: Uuid::new_v4(),
7647            session_id: session.id,
7648            content: "did the thing".to_string(),
7649            generated_at: Utc::now(),
7650        };
7651
7652        let imported = db
7653            .merge_remote_record(
7654                &session,
7655                &[message],
7656                &[link],
7657                &[tag],
7658                &[annotation],
7659                Some(&summary),
7660                Utc::now(),
7661            )
7662            .unwrap();
7663
7664        assert!(imported, "a new session must count as pulled");
7665        assert!(db.get_session(&session.id).unwrap().is_some());
7666        assert_eq!(db.get_messages(&session.id).unwrap().len(), 1);
7667        assert_eq!(db.get_links_by_session(&session.id).unwrap().len(), 1);
7668        assert_eq!(db.get_tags(&session.id).unwrap().len(), 1);
7669        assert_eq!(db.get_annotations(&session.id).unwrap().len(), 1);
7670        assert_eq!(
7671            db.get_summary(&session.id).unwrap().unwrap().content,
7672            "did the thing"
7673        );
7674        // The merged session is marked synced, so it is not re-exported.
7675        assert!(
7676            db.get_unsynced_sessions().unwrap().is_empty(),
7677            "a merged session must be marked synced"
7678        );
7679    }
7680
7681    // ==================== Global Store Track Tests ====================
7682
7683    #[test]
7684    fn test_new_session_is_unsynced_on_both_tracks() {
7685        // A freshly imported session (synced_at = None) is pending on both the
7686        // per-repo and global tracks.
7687        let (mut db, _dir) = create_test_db();
7688        let session = create_test_session("claude-code", "/project", Utc::now(), None);
7689        db.import_session_with_messages(&session, &[], None)
7690            .unwrap();
7691
7692        assert_eq!(db.get_unsynced_sessions().unwrap().len(), 1);
7693        let global = db.get_unsynced_global_sessions().unwrap();
7694        assert_eq!(global.len(), 1);
7695        assert_eq!(global[0].id, session.id);
7696        assert_eq!(db.unsynced_global_count().unwrap(), 1);
7697    }
7698
7699    #[test]
7700    fn test_per_repo_and_global_tracks_are_independent() {
7701        // Marking one track must not affect the other: a per-repo sync leaves the
7702        // global track pending, and a global sync leaves the per-repo track
7703        // pending.
7704        let (mut db, _dir) = create_test_db();
7705        let session = create_test_session("claude-code", "/project", Utc::now(), None);
7706        db.import_session_with_messages(&session, &[], None)
7707            .unwrap();
7708
7709        // Mark only the per-repo track.
7710        db.mark_sessions_synced(&[session.id], Utc::now()).unwrap();
7711        assert!(
7712            db.get_unsynced_sessions().unwrap().is_empty(),
7713            "per-repo track must be marked synced"
7714        );
7715        assert_eq!(
7716            db.get_unsynced_global_sessions().unwrap().len(),
7717            1,
7718            "global track must stay pending after a per-repo sync"
7719        );
7720
7721        // Now mark only the global track.
7722        db.mark_global_synced(&[session.id], Utc::now()).unwrap();
7723        assert!(
7724            db.get_unsynced_global_sessions().unwrap().is_empty(),
7725            "global track must be marked synced"
7726        );
7727    }
7728
7729    #[test]
7730    fn test_global_only_sync_leaves_per_repo_pending() {
7731        // The reverse independence direction: marking the global track first must
7732        // not touch the per-repo track.
7733        let (mut db, _dir) = create_test_db();
7734        let session = create_test_session("claude-code", "/project", Utc::now(), None);
7735        db.import_session_with_messages(&session, &[], None)
7736            .unwrap();
7737
7738        db.mark_global_synced(&[session.id], Utc::now()).unwrap();
7739        assert!(
7740            db.get_unsynced_global_sessions().unwrap().is_empty(),
7741            "global track must be marked synced"
7742        );
7743        assert_eq!(
7744            db.get_unsynced_sessions().unwrap().len(),
7745            1,
7746            "per-repo track must stay pending after a global sync"
7747        );
7748    }
7749
7750    #[test]
7751    fn test_insert_link_clears_both_sync_tracks() {
7752        // A local child change (adding a link) must re-open the session on BOTH
7753        // the per-repo and global tracks.
7754        let (mut db, _dir) = create_test_db();
7755        let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7756        session.message_count = 0;
7757        db.import_session_with_messages(&session, &[], Some(Utc::now()))
7758            .unwrap();
7759        db.mark_global_synced(&[session.id], Utc::now()).unwrap();
7760        assert!(db.get_unsynced_sessions().unwrap().is_empty());
7761        assert!(db.get_unsynced_global_sessions().unwrap().is_empty());
7762
7763        db.insert_link(&create_test_link(
7764            session.id,
7765            Some("abcdef"),
7766            LinkType::Commit,
7767        ))
7768        .unwrap();
7769
7770        assert_eq!(
7771            db.get_unsynced_sessions().unwrap().len(),
7772            1,
7773            "adding a link must re-open the per-repo track"
7774        );
7775        assert_eq!(
7776            db.get_unsynced_global_sessions().unwrap().len(),
7777            1,
7778            "adding a link must re-open the global track"
7779        );
7780    }
7781
7782    #[test]
7783    fn test_insert_session_content_change_clears_both_tracks() {
7784        // A message-count change through insert_session must invalidate both the
7785        // per-repo and global sync tracks.
7786        let (mut db, _dir) = create_test_db();
7787        let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7788        session.message_count = 1;
7789        db.import_session_with_messages(&session, &[], Some(Utc::now()))
7790            .unwrap();
7791        db.mark_global_synced(&[session.id], Utc::now()).unwrap();
7792        assert!(db.get_unsynced_sessions().unwrap().is_empty());
7793        assert!(db.get_unsynced_global_sessions().unwrap().is_empty());
7794
7795        // Re-insert with a higher message count (a content change).
7796        session.message_count = 2;
7797        db.insert_session(&session).unwrap();
7798
7799        assert_eq!(
7800            db.get_unsynced_sessions().unwrap().len(),
7801            1,
7802            "a content change must re-open the per-repo track"
7803        );
7804        assert_eq!(
7805            db.get_unsynced_global_sessions().unwrap().len(),
7806            1,
7807            "a content change must re-open the global track"
7808        );
7809    }
7810
7811    #[test]
7812    fn test_merge_remote_record_global_marks_only_global_track() {
7813        // The global merge path must mark global_synced_at and leave synced_at
7814        // NULL, so a pulled global session is still pending for the per-repo store.
7815        let (mut db, _dir) = create_test_db();
7816        let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
7817        session.message_count = 1;
7818
7819        let imported = db
7820            .merge_remote_record_global(&session, &[], &[], &[], &[], None, Utc::now())
7821            .unwrap();
7822        assert!(imported, "a new session must count as pulled");
7823
7824        assert!(
7825            db.get_unsynced_global_sessions().unwrap().is_empty(),
7826            "global merge must mark the global track synced"
7827        );
7828        assert_eq!(
7829            db.get_unsynced_sessions().unwrap().len(),
7830            1,
7831            "global merge must leave the per-repo track pending"
7832        );
7833    }
7834
7835    #[test]
7836    fn test_get_all_session_ids_returns_every_session_regardless_of_sync() {
7837        let (mut db, _dir) = create_test_db();
7838        let a = create_test_session("claude-code", "/a", Utc::now(), None);
7839        let b = create_test_session("claude-code", "/b", Utc::now(), None);
7840        db.import_session_with_messages(&a, &[], None).unwrap();
7841        db.import_session_with_messages(&b, &[], Some(Utc::now()))
7842            .unwrap();
7843        db.mark_global_synced(&[b.id], Utc::now()).unwrap();
7844
7845        let ids = db.get_all_session_ids().unwrap();
7846        assert!(ids.contains(&a.id));
7847        assert!(ids.contains(&b.id));
7848        assert_eq!(ids.len(), 2);
7849    }
7850
7851    #[test]
7852    fn test_last_global_sync_time_tracks_global_column() {
7853        let (mut db, _dir) = create_test_db();
7854        let session = create_test_session("claude-code", "/project", Utc::now(), None);
7855        db.import_session_with_messages(&session, &[], None)
7856            .unwrap();
7857
7858        assert!(
7859            db.last_global_sync_time().unwrap().is_none(),
7860            "no global sync yet"
7861        );
7862
7863        let when = Utc::now();
7864        db.mark_global_synced(&[session.id], when).unwrap();
7865        let last = db
7866            .last_global_sync_time()
7867            .unwrap()
7868            .expect("global sync time recorded");
7869        // Compare at second granularity to avoid RFC3339 sub-second rounding.
7870        assert_eq!(last.timestamp(), when.timestamp());
7871    }
7872
7873    // ==================== Tombstone Tests ====================
7874
7875    /// Seeds a session with one link, tag, annotation, and summary.
7876    ///
7877    /// Returns the ids so a test can delete a specific child and assert on the
7878    /// resulting tombstone.
7879    fn seed_session_with_children(db: &Database) -> (Uuid, Uuid, Uuid, Uuid, Uuid) {
7880        let session = create_test_session("claude-code", "/project", Utc::now(), None);
7881        db.insert_session(&session).expect("insert session");
7882
7883        let link = create_test_link(session.id, Some("deadbeef"), LinkType::Commit);
7884        db.insert_link(&link).expect("insert link");
7885
7886        let tag = Tag {
7887            id: Uuid::new_v4(),
7888            session_id: session.id,
7889            label: "feature".to_string(),
7890            created_at: Utc::now(),
7891        };
7892        db.insert_tag(&tag).expect("insert tag");
7893
7894        let annotation = Annotation {
7895            id: Uuid::new_v4(),
7896            session_id: session.id,
7897            content: "note".to_string(),
7898            created_at: Utc::now(),
7899        };
7900        db.insert_annotation(&annotation)
7901            .expect("insert annotation");
7902
7903        let summary = Summary {
7904            id: Uuid::new_v4(),
7905            session_id: session.id,
7906            content: "summary".to_string(),
7907            generated_at: Utc::now(),
7908        };
7909        db.insert_summary(&summary).expect("insert summary");
7910
7911        (session.id, link.id, tag.id, annotation.id, summary.id)
7912    }
7913
7914    #[test]
7915    fn test_delete_link_records_tombstone() {
7916        let (db, _dir) = create_test_db();
7917        let (_sid, link_id, _tag, _ann, _sum) = seed_session_with_children(&db);
7918
7919        assert!(db.delete_link(&link_id).unwrap());
7920
7921        let tombstones = db.list_tombstones().unwrap();
7922        assert_eq!(tombstones.len(), 1, "one tombstone recorded");
7923        assert_eq!(tombstones[0].child_id, link_id.to_string());
7924        assert_eq!(tombstones[0].kind, TOMBSTONE_KIND_LINK);
7925    }
7926
7927    #[test]
7928    fn test_delete_tag_records_tombstone() {
7929        let (db, _dir) = create_test_db();
7930        let (sid, _link, tag_id, _ann, _sum) = seed_session_with_children(&db);
7931
7932        assert!(db.delete_tag(&sid, "feature").unwrap());
7933
7934        let tombstones = db.list_tombstones().unwrap();
7935        assert_eq!(tombstones.len(), 1);
7936        assert_eq!(tombstones[0].child_id, tag_id.to_string());
7937        assert_eq!(tombstones[0].kind, TOMBSTONE_KIND_TAG);
7938    }
7939
7940    #[test]
7941    fn test_delete_annotation_records_tombstone() {
7942        let (db, _dir) = create_test_db();
7943        let (_sid, _link, _tag, ann_id, _sum) = seed_session_with_children(&db);
7944
7945        assert!(db.delete_annotation(&ann_id).unwrap());
7946
7947        let tombstones = db.list_tombstones().unwrap();
7948        assert_eq!(tombstones.len(), 1);
7949        assert_eq!(tombstones[0].child_id, ann_id.to_string());
7950        assert_eq!(tombstones[0].kind, TOMBSTONE_KIND_ANNOTATION);
7951    }
7952
7953    #[test]
7954    fn test_delete_summary_records_tombstone() {
7955        let (db, _dir) = create_test_db();
7956        let (sid, _link, _tag, _ann, sum_id) = seed_session_with_children(&db);
7957
7958        assert!(db.delete_summary(&sid).unwrap());
7959
7960        let tombstones = db.list_tombstones().unwrap();
7961        assert_eq!(tombstones.len(), 1);
7962        assert_eq!(tombstones[0].child_id, sum_id.to_string());
7963        assert_eq!(tombstones[0].kind, TOMBSTONE_KIND_SUMMARY);
7964    }
7965
7966    #[test]
7967    fn test_bulk_delete_links_records_tombstone_per_link() {
7968        let (db, _dir) = create_test_db();
7969        let session = create_test_session("claude-code", "/project", Utc::now(), None);
7970        db.insert_session(&session).expect("insert session");
7971        let link_a = create_test_link(session.id, Some("aaaa1111"), LinkType::Commit);
7972        let link_b = create_test_link(session.id, Some("bbbb2222"), LinkType::Commit);
7973        db.insert_link(&link_a).unwrap();
7974        db.insert_link(&link_b).unwrap();
7975
7976        assert_eq!(db.delete_links_by_session(&session.id).unwrap(), 2);
7977
7978        let tombstones = db.list_tombstones().unwrap();
7979        assert_eq!(tombstones.len(), 2, "one tombstone per removed link");
7980        let ids: HashSet<String> = tombstones.iter().map(|t| t.child_id.clone()).collect();
7981        assert!(ids.contains(&link_a.id.to_string()));
7982        assert!(ids.contains(&link_b.id.to_string()));
7983        assert!(tombstones.iter().all(|t| t.kind == TOMBSTONE_KIND_LINK));
7984    }
7985
7986    #[test]
7987    fn test_session_cascade_delete_records_no_tombstones() {
7988        // Deleting a whole session is a purely local operation: it must not
7989        // tombstone the session's children, or a teammate sharing the store
7990        // would lose that session's reasoning.
7991        let (db, _dir) = create_test_db();
7992        let (sid, _link, _tag, _ann, _sum) = seed_session_with_children(&db);
7993
7994        db.delete_session(&sid).expect("delete session");
7995
7996        assert!(
7997            db.list_tombstones().unwrap().is_empty(),
7998            "session-cascade delete must not create tombstones"
7999        );
8000    }
8001
8002    #[test]
8003    fn test_delete_sessions_older_than_records_no_tombstones() {
8004        // The bulk age-based session purge is also a cascade delete and must not
8005        // tombstone children.
8006        let (db, _dir) = create_test_db();
8007        let old = create_test_session(
8008            "claude-code",
8009            "/project",
8010            Utc::now() - Duration::days(400),
8011            None,
8012        );
8013        db.insert_session(&old).expect("insert session");
8014        let link = create_test_link(old.id, Some("deadbeef"), LinkType::Commit);
8015        db.insert_link(&link).unwrap();
8016
8017        let cutoff = Utc::now() - Duration::days(30);
8018        assert_eq!(db.delete_sessions_older_than(cutoff).unwrap(), 1);
8019
8020        assert!(
8021            db.list_tombstones().unwrap().is_empty(),
8022            "age-based session purge must not create tombstones"
8023        );
8024    }
8025
8026    #[test]
8027    fn test_add_tombstones_unions_first_wins() {
8028        let (db, _dir) = create_test_db();
8029        let child = Uuid::new_v4().to_string();
8030        let session = Uuid::new_v4().to_string();
8031
8032        let earlier = Utc::now() - Duration::hours(2);
8033        let later = Utc::now();
8034        db.add_tombstones(&[Tombstone {
8035            child_id: child.clone(),
8036            kind: TOMBSTONE_KIND_LINK.to_string(),
8037            session_id: Some(session.clone()),
8038            deleted_at: earlier,
8039        }])
8040        .unwrap();
8041        // A second union of the same (child_id, kind) must not overwrite.
8042        db.add_tombstones(&[Tombstone {
8043            child_id: child.clone(),
8044            kind: TOMBSTONE_KIND_LINK.to_string(),
8045            session_id: Some(session),
8046            deleted_at: later,
8047        }])
8048        .unwrap();
8049
8050        let tombstones = db.list_tombstones().unwrap();
8051        assert_eq!(tombstones.len(), 1, "union is keyed by (child_id, kind)");
8052        assert_eq!(
8053            tombstones[0].deleted_at.timestamp(),
8054            earlier.timestamp(),
8055            "first-wins keeps the earliest recorded deletion"
8056        );
8057    }
8058
8059    #[test]
8060    fn test_apply_tombstones_removes_local_child() {
8061        let (db, _dir) = create_test_db();
8062        let (sid, link_id, _tag, _ann, _sum) = seed_session_with_children(&db);
8063
8064        // A tombstone that arrived from another machine for a link we still hold.
8065        db.apply_tombstones(&[Tombstone {
8066            child_id: link_id.to_string(),
8067            kind: TOMBSTONE_KIND_LINK.to_string(),
8068            session_id: Some(sid.to_string()),
8069            deleted_at: Utc::now(),
8070        }])
8071        .unwrap();
8072
8073        assert!(
8074            db.get_links_by_session(&sid).unwrap().is_empty(),
8075            "apply_tombstones must remove the locally-present tombstoned link"
8076        );
8077        // The cleaned session is re-opened for the next sync.
8078        assert!(
8079            db.get_unsynced_sessions()
8080                .unwrap()
8081                .iter()
8082                .any(|s| s.id == sid),
8083            "cleaning a child must re-open the parent session for re-export"
8084        );
8085    }
8086
8087    #[test]
8088    fn test_apply_tombstones_does_not_record_new_tombstone() {
8089        // Applying a tombstone must not create a fresh tombstone (it is already
8090        // recorded), so the set does not grow on every sync.
8091        let (db, _dir) = create_test_db();
8092        let (sid, link_id, _tag, _ann, _sum) = seed_session_with_children(&db);
8093        // Clear the delete-path tombstones so we start from a known state.
8094        db.prune_tombstones(Utc::now() + Duration::days(1)).unwrap();
8095        assert!(db.list_tombstones().unwrap().is_empty());
8096
8097        db.apply_tombstones(&[Tombstone {
8098            child_id: link_id.to_string(),
8099            kind: TOMBSTONE_KIND_LINK.to_string(),
8100            session_id: Some(sid.to_string()),
8101            deleted_at: Utc::now(),
8102        }])
8103        .unwrap();
8104
8105        assert!(
8106            db.list_tombstones().unwrap().is_empty(),
8107            "apply_tombstones must not record new tombstones"
8108        );
8109    }
8110
8111    #[test]
8112    fn test_merge_suppresses_tombstoned_child() {
8113        // A remote record whose link is tombstoned locally must not re-add it.
8114        let (mut db, _dir) = create_test_db();
8115        let session = create_test_session("claude-code", "/project", Utc::now(), None);
8116        let link = create_test_link(session.id, Some("deadbeef"), LinkType::Commit);
8117
8118        // Tombstone the link before merging a remote record that still holds it.
8119        db.add_tombstones(&[Tombstone {
8120            child_id: link.id.to_string(),
8121            kind: TOMBSTONE_KIND_LINK.to_string(),
8122            session_id: Some(session.id.to_string()),
8123            deleted_at: Utc::now(),
8124        }])
8125        .unwrap();
8126
8127        db.merge_remote_record(
8128            &session,
8129            &[],
8130            std::slice::from_ref(&link),
8131            &[],
8132            &[],
8133            None,
8134            Utc::now(),
8135        )
8136        .unwrap();
8137
8138        assert!(
8139            db.get_links_by_session(&session.id).unwrap().is_empty(),
8140            "merge must suppress a tombstoned link"
8141        );
8142    }
8143
8144    #[test]
8145    fn test_merge_keeps_non_tombstoned_child() {
8146        // A concurrent add (a different link id) must survive even when another
8147        // link on the same session is tombstoned.
8148        let (mut db, _dir) = create_test_db();
8149        let session = create_test_session("claude-code", "/project", Utc::now(), None);
8150        let deleted = create_test_link(session.id, Some("deadbeef"), LinkType::Commit);
8151        let added = create_test_link(session.id, Some("feedface"), LinkType::Commit);
8152
8153        db.add_tombstones(&[Tombstone {
8154            child_id: deleted.id.to_string(),
8155            kind: TOMBSTONE_KIND_LINK.to_string(),
8156            session_id: Some(session.id.to_string()),
8157            deleted_at: Utc::now(),
8158        }])
8159        .unwrap();
8160
8161        db.merge_remote_record(
8162            &session,
8163            &[],
8164            &[deleted.clone(), added.clone()],
8165            &[],
8166            &[],
8167            None,
8168            Utc::now(),
8169        )
8170        .unwrap();
8171
8172        let links = db.get_links_by_session(&session.id).unwrap();
8173        assert_eq!(links.len(), 1, "only the non-tombstoned link survives");
8174        assert_eq!(links[0].id, added.id);
8175    }
8176
8177    #[test]
8178    fn test_prune_tombstones_removes_old_only() {
8179        let (db, _dir) = create_test_db();
8180        let old_child = Uuid::new_v4().to_string();
8181        let fresh_child = Uuid::new_v4().to_string();
8182        db.add_tombstones(&[
8183            Tombstone {
8184                child_id: old_child.clone(),
8185                kind: TOMBSTONE_KIND_TAG.to_string(),
8186                session_id: None,
8187                deleted_at: Utc::now() - Duration::days(120),
8188            },
8189            Tombstone {
8190                child_id: fresh_child.clone(),
8191                kind: TOMBSTONE_KIND_TAG.to_string(),
8192                session_id: None,
8193                deleted_at: Utc::now(),
8194            },
8195        ])
8196        .unwrap();
8197
8198        let pruned = db
8199            .prune_tombstones(Utc::now() - Duration::days(90))
8200            .unwrap();
8201        assert_eq!(pruned, 1, "only the old tombstone is pruned");
8202
8203        let remaining = db.list_tombstones().unwrap();
8204        assert_eq!(remaining.len(), 1);
8205        assert_eq!(remaining[0].child_id, fresh_child);
8206    }
8207}