Skip to main content

stint_core/storage/
sqlite.rs

1//! SQLite-backed storage implementation.
2
3use std::path::{Path, PathBuf};
4
5use rusqlite::{params, Connection, OptionalExtension};
6use time::format_description::well_known::Rfc3339;
7use time::OffsetDateTime;
8
9use crate::models::entry::{EntryFilter, EntrySource, TimeEntry};
10use crate::models::project::{Project, ProjectSource, ProjectStatus};
11use crate::models::session::ShellSession;
12use crate::models::types::{EntryId, ProjectId, SessionId};
13
14use super::error::StorageError;
15use super::Storage;
16
17/// Current schema version. Increment when adding migrations.
18#[cfg(test)]
19const SCHEMA_VERSION: i64 = 3;
20
21/// SQLite-backed storage for Stint.
22pub struct SqliteStorage {
23    conn: Connection,
24}
25
26impl SqliteStorage {
27    /// Opens or creates the database at the given path and runs migrations.
28    pub fn open(path: &Path) -> Result<Self, StorageError> {
29        if let Some(parent) = path.parent() {
30            std::fs::create_dir_all(parent).map_err(|e| {
31                StorageError::Migration(format!("failed to create database directory: {e}"))
32            })?;
33        }
34        let conn = Connection::open(path)?;
35        let storage = Self { conn };
36        storage.migrate()?;
37        Ok(storage)
38    }
39
40    /// Opens an existing database without creating directories or running migrations.
41    ///
42    /// Returns `Err` if the database file does not exist or cannot be opened.
43    /// Intended for the shell hook fast path where the DB should already exist.
44    pub fn open_existing(path: &Path) -> Result<Self, StorageError> {
45        if !path.exists() {
46            return Err(StorageError::Migration(
47                "database does not exist".to_string(),
48            ));
49        }
50        let conn = Connection::open(path)?;
51        conn.execute_batch("PRAGMA journal_mode = WAL;")?;
52        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
53        Ok(Self { conn })
54    }
55
56    /// Opens an in-memory database for testing.
57    pub fn open_in_memory() -> Result<Self, StorageError> {
58        let conn = Connection::open_in_memory()?;
59        let storage = Self { conn };
60        storage.migrate()?;
61        Ok(storage)
62    }
63
64    /// Returns the XDG-compliant default database path.
65    ///
66    /// Checks `STINT_DB_PATH` env var first, then falls back to
67    /// `~/.local/share/stint/stint.db` (or platform equivalent).
68    pub fn default_path() -> PathBuf {
69        if let Ok(path) = std::env::var("STINT_DB_PATH") {
70            return PathBuf::from(path);
71        }
72        let data_dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
73        data_dir.join("stint").join("stint.db")
74    }
75
76    /// Runs schema migrations up to the current version.
77    fn migrate(&self) -> Result<(), StorageError> {
78        self.conn.execute_batch("PRAGMA journal_mode = WAL;")?;
79        self.conn.execute_batch("PRAGMA foreign_keys = ON;")?;
80
81        // Create the meta table if it doesn't exist
82        self.conn.execute_batch(
83            "CREATE TABLE IF NOT EXISTS _stint_meta (
84                key   TEXT PRIMARY KEY,
85                value TEXT NOT NULL
86            );",
87        )?;
88
89        let current_version: i64 = self
90            .conn
91            .query_row(
92                "SELECT CAST(value AS INTEGER) FROM _stint_meta WHERE key = 'schema_version'",
93                [],
94                |row| row.get(0),
95            )
96            .unwrap_or(0);
97
98        if current_version < 1 {
99            self.migrate_v1()?;
100        }
101        if current_version < 2 {
102            self.migrate_v2()?;
103        }
104        if current_version < 3 {
105            self.migrate_v3()?;
106        }
107
108        Ok(())
109    }
110
111    /// Initial schema: all tables for Phase 0.
112    fn migrate_v1(&self) -> Result<(), StorageError> {
113        self.conn.execute_batch(
114            "CREATE TABLE IF NOT EXISTS projects (
115                id               TEXT PRIMARY KEY,
116                name             TEXT NOT NULL UNIQUE COLLATE NOCASE,
117                hourly_rate_cents INTEGER,
118                status           TEXT NOT NULL DEFAULT 'active'
119                                     CHECK(status IN ('active', 'archived')),
120                created_at       TEXT NOT NULL,
121                updated_at       TEXT NOT NULL
122            );
123
124            CREATE TABLE IF NOT EXISTS project_paths (
125                project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
126                path       TEXT NOT NULL,
127                PRIMARY KEY (project_id, path)
128            );
129
130            CREATE INDEX IF NOT EXISTS idx_project_paths_path
131                ON project_paths(path);
132
133            CREATE TABLE IF NOT EXISTS project_tags (
134                project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
135                tag        TEXT NOT NULL,
136                PRIMARY KEY (project_id, tag)
137            );
138
139            CREATE TABLE IF NOT EXISTS entries (
140                id            TEXT PRIMARY KEY,
141                project_id    TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
142                session_id    TEXT,
143                start         TEXT NOT NULL,
144                end_time      TEXT,
145                duration_secs INTEGER,
146                source        TEXT NOT NULL CHECK(source IN ('manual', 'hook', 'added')),
147                notes         TEXT,
148                created_at    TEXT NOT NULL,
149                updated_at    TEXT NOT NULL
150            );
151
152            CREATE INDEX IF NOT EXISTS idx_entries_project_running
153                ON entries(project_id) WHERE end_time IS NULL;
154
155            CREATE INDEX IF NOT EXISTS idx_entries_running
156                ON entries(end_time) WHERE end_time IS NULL;
157
158            CREATE INDEX IF NOT EXISTS idx_entries_start
159                ON entries(start);
160
161            CREATE INDEX IF NOT EXISTS idx_entries_project_start
162                ON entries(project_id, start);
163
164            CREATE TABLE IF NOT EXISTS entry_tags (
165                entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
166                tag      TEXT NOT NULL,
167                PRIMARY KEY (entry_id, tag)
168            );
169
170            CREATE TABLE IF NOT EXISTS sessions (
171                id                  TEXT PRIMARY KEY,
172                pid                 INTEGER NOT NULL,
173                shell               TEXT,
174                cwd                 TEXT NOT NULL,
175                current_project_id  TEXT REFERENCES projects(id) ON DELETE SET NULL,
176                started_at          TEXT NOT NULL,
177                last_heartbeat      TEXT NOT NULL,
178                ended_at            TEXT
179            );
180
181            CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_pid
182                ON sessions(pid) WHERE ended_at IS NULL;
183
184            INSERT OR REPLACE INTO _stint_meta (key, value) VALUES ('schema_version', '1');",
185        )?;
186
187        Ok(())
188    }
189
190    /// Migration v2: indexes for session and hook queries.
191    fn migrate_v2(&self) -> Result<(), StorageError> {
192        self.conn.execute_batch(
193            "CREATE INDEX IF NOT EXISTS idx_sessions_active_project
194                ON sessions(current_project_id) WHERE ended_at IS NULL;
195
196            CREATE INDEX IF NOT EXISTS idx_sessions_active_heartbeat
197                ON sessions(last_heartbeat) WHERE ended_at IS NULL;
198
199            CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_one_running_per_project
200                ON entries(project_id) WHERE end_time IS NULL;
201
202            INSERT OR REPLACE INTO _stint_meta (key, value) VALUES ('schema_version', '2');",
203        )?;
204
205        Ok(())
206    }
207
208    /// Migration v3: ignored paths table and project source column for auto-discovery.
209    fn migrate_v3(&self) -> Result<(), StorageError> {
210        self.conn.execute_batch(
211            "CREATE TABLE IF NOT EXISTS ignored_paths (
212                path TEXT PRIMARY KEY
213            );",
214        )?;
215
216        // Only add the source column if it doesn't already exist
217        let has_source: bool = self
218            .conn
219            .prepare("PRAGMA table_info('projects')")?
220            .query_map([], |row| row.get::<_, String>(1))?
221            .any(|col| col.as_deref() == Ok("source"));
222
223        if !has_source {
224            self.conn.execute_batch(
225                "ALTER TABLE projects ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';",
226            )?;
227        }
228
229        self.conn.execute_batch(
230            "INSERT OR REPLACE INTO _stint_meta (key, value) VALUES ('schema_version', '3');",
231        )?;
232
233        Ok(())
234    }
235
236    // --- Helper methods ---
237
238    /// Loads paths for a project from the project_paths table.
239    fn load_project_paths(&self, project_id: &ProjectId) -> Result<Vec<PathBuf>, StorageError> {
240        let mut stmt = self
241            .conn
242            .prepare("SELECT path FROM project_paths WHERE project_id = ?1")?;
243        let paths = stmt
244            .query_map(params![project_id.as_str()], |row| {
245                let p: String = row.get(0)?;
246                Ok(PathBuf::from(p))
247            })?
248            .collect::<Result<Vec<_>, _>>()?;
249        Ok(paths)
250    }
251
252    /// Loads tags for a project from the project_tags table.
253    fn load_project_tags(&self, project_id: &ProjectId) -> Result<Vec<String>, StorageError> {
254        let mut stmt = self
255            .conn
256            .prepare("SELECT tag FROM project_tags WHERE project_id = ?1 ORDER BY tag")?;
257        let tags = stmt
258            .query_map(params![project_id.as_str()], |row| row.get(0))?
259            .collect::<Result<Vec<_>, _>>()?;
260        Ok(tags)
261    }
262
263    /// Loads tags for an entry from the entry_tags table.
264    fn load_entry_tags(&self, entry_id: &EntryId) -> Result<Vec<String>, StorageError> {
265        let mut stmt = self
266            .conn
267            .prepare("SELECT tag FROM entry_tags WHERE entry_id = ?1 ORDER BY tag")?;
268        let tags = stmt
269            .query_map(params![entry_id.as_str()], |row| row.get(0))?
270            .collect::<Result<Vec<_>, _>>()?;
271        Ok(tags)
272    }
273
274    /// Saves paths for a project within a transaction, replacing any existing paths.
275    fn save_project_paths_tx(
276        conn: &Connection,
277        project_id: &ProjectId,
278        paths: &[PathBuf],
279    ) -> Result<(), StorageError> {
280        conn.execute(
281            "DELETE FROM project_paths WHERE project_id = ?1",
282            params![project_id.as_str()],
283        )?;
284        let mut stmt =
285            conn.prepare("INSERT INTO project_paths (project_id, path) VALUES (?1, ?2)")?;
286        for path in paths {
287            stmt.execute(params![project_id.as_str(), path.to_string_lossy()])?;
288        }
289        Ok(())
290    }
291
292    /// Saves tags for a project within a transaction, replacing any existing tags.
293    fn save_project_tags_tx(
294        conn: &Connection,
295        project_id: &ProjectId,
296        tags: &[String],
297    ) -> Result<(), StorageError> {
298        conn.execute(
299            "DELETE FROM project_tags WHERE project_id = ?1",
300            params![project_id.as_str()],
301        )?;
302        let mut stmt =
303            conn.prepare("INSERT INTO project_tags (project_id, tag) VALUES (?1, ?2)")?;
304        for tag in tags {
305            stmt.execute(params![project_id.as_str(), tag])?;
306        }
307        Ok(())
308    }
309
310    /// Saves tags for an entry within a transaction, replacing any existing tags.
311    fn save_entry_tags_tx(
312        conn: &Connection,
313        entry_id: &EntryId,
314        tags: &[String],
315    ) -> Result<(), StorageError> {
316        conn.execute(
317            "DELETE FROM entry_tags WHERE entry_id = ?1",
318            params![entry_id.as_str()],
319        )?;
320        let mut stmt = conn.prepare("INSERT INTO entry_tags (entry_id, tag) VALUES (?1, ?2)")?;
321        for tag in tags {
322            stmt.execute(params![entry_id.as_str(), tag])?;
323        }
324        Ok(())
325    }
326
327    /// Builds a Project from a row and loads its paths and tags.
328    fn project_from_row(&self, row: &rusqlite::Row) -> Result<Project, rusqlite::Error> {
329        let id: String = row.get("id")?;
330        let name: String = row.get("name")?;
331        let hourly_rate_cents: Option<i64> = row.get("hourly_rate_cents")?;
332        let status_str: String = row.get("status")?;
333        let source_str: String = row.get("source").unwrap_or_else(|_| "manual".to_string());
334        let created_at_str: String = row.get("created_at")?;
335        let updated_at_str: String = row.get("updated_at")?;
336
337        let status = ProjectStatus::from_str_value(&status_str).unwrap_or(ProjectStatus::Active);
338        let source = ProjectSource::from_str_value(&source_str);
339        let created_at =
340            OffsetDateTime::parse(&created_at_str, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH);
341        let updated_at =
342            OffsetDateTime::parse(&updated_at_str, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH);
343
344        Ok(Project {
345            id: ProjectId::from_storage(id),
346            name,
347            paths: vec![], // loaded separately
348            tags: vec![],  // loaded separately
349            hourly_rate_cents,
350            status,
351            source,
352            created_at,
353            updated_at,
354        })
355    }
356
357    /// Hydrates a project's paths and tags after loading the core row.
358    fn hydrate_project(&self, mut project: Project) -> Result<Project, StorageError> {
359        project.paths = self.load_project_paths(&project.id)?;
360        project.tags = self.load_project_tags(&project.id)?;
361        Ok(project)
362    }
363
364    /// Builds a TimeEntry from a row.
365    fn entry_from_row(&self, row: &rusqlite::Row) -> Result<TimeEntry, rusqlite::Error> {
366        let id: String = row.get("id")?;
367        let project_id: String = row.get("project_id")?;
368        let session_id: Option<String> = row.get("session_id")?;
369        let start_str: String = row.get("start")?;
370        let end_str: Option<String> = row.get("end_time")?;
371        let duration_secs: Option<i64> = row.get("duration_secs")?;
372        let source_str: String = row.get("source")?;
373        let notes: Option<String> = row.get("notes")?;
374        let created_at_str: String = row.get("created_at")?;
375        let updated_at_str: String = row.get("updated_at")?;
376
377        let start =
378            OffsetDateTime::parse(&start_str, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH);
379        let end = end_str.and_then(|s| OffsetDateTime::parse(&s, &Rfc3339).ok());
380        let source = EntrySource::from_str_value(&source_str).unwrap_or(EntrySource::Manual);
381        let created_at =
382            OffsetDateTime::parse(&created_at_str, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH);
383        let updated_at =
384            OffsetDateTime::parse(&updated_at_str, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH);
385
386        Ok(TimeEntry {
387            id: EntryId::from_storage(id),
388            project_id: ProjectId::from_storage(project_id),
389            session_id: session_id.map(SessionId::from_storage),
390            start,
391            end,
392            duration_secs,
393            source,
394            notes,
395            tags: vec![], // loaded separately
396            created_at,
397            updated_at,
398        })
399    }
400
401    /// Hydrates an entry's tags after loading the core row.
402    fn hydrate_entry(&self, mut entry: TimeEntry) -> Result<TimeEntry, StorageError> {
403        entry.tags = self.load_entry_tags(&entry.id)?;
404        Ok(entry)
405    }
406
407    /// Builds a ShellSession from a row.
408    fn session_from_row(&self, row: &rusqlite::Row) -> Result<ShellSession, rusqlite::Error> {
409        let id: String = row.get("id")?;
410        let pid: u32 = row.get("pid")?;
411        let shell: Option<String> = row.get("shell")?;
412        let cwd: String = row.get("cwd")?;
413        let current_project_id: Option<String> = row.get("current_project_id")?;
414        let started_at_str: String = row.get("started_at")?;
415        let heartbeat_str: String = row.get("last_heartbeat")?;
416        let ended_at_str: Option<String> = row.get("ended_at")?;
417
418        let started_at =
419            OffsetDateTime::parse(&started_at_str, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH);
420        let last_heartbeat =
421            OffsetDateTime::parse(&heartbeat_str, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH);
422        let ended_at = ended_at_str.and_then(|s| OffsetDateTime::parse(&s, &Rfc3339).ok());
423
424        Ok(ShellSession {
425            id: SessionId::from_storage(id),
426            pid,
427            shell,
428            cwd: PathBuf::from(cwd),
429            current_project_id: current_project_id.map(ProjectId::from_storage),
430            started_at,
431            last_heartbeat,
432            ended_at,
433        })
434    }
435
436    /// Formats an OffsetDateTime as RFC 3339 for storage.
437    fn fmt_ts(ts: &OffsetDateTime) -> String {
438        ts.format(&Rfc3339).unwrap_or_default()
439    }
440}
441
442impl Storage for SqliteStorage {
443    // --- Projects ---
444
445    fn create_project(&self, project: &Project) -> Result<(), StorageError> {
446        let tx = self.conn.unchecked_transaction()?;
447
448        // Check for duplicate name
449        let existing: Option<String> = tx
450            .query_row(
451                "SELECT id FROM projects WHERE name = ?1 COLLATE NOCASE",
452                params![&project.name],
453                |row| row.get(0),
454            )
455            .optional()?;
456        if existing.is_some() {
457            return Err(StorageError::DuplicateProjectName(project.name.clone()));
458        }
459
460        tx.execute(
461            "INSERT INTO projects (id, name, hourly_rate_cents, status, source, created_at, updated_at)
462             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
463            params![
464                project.id.as_str(),
465                &project.name,
466                project.hourly_rate_cents,
467                project.status.as_str(),
468                project.source.as_str(),
469                Self::fmt_ts(&project.created_at),
470                Self::fmt_ts(&project.updated_at),
471            ],
472        )?;
473
474        Self::save_project_paths_tx(&tx, &project.id, &project.paths)?;
475        Self::save_project_tags_tx(&tx, &project.id, &project.tags)?;
476
477        tx.commit()?;
478        Ok(())
479    }
480
481    fn get_project(&self, id: &ProjectId) -> Result<Option<Project>, StorageError> {
482        let project = self
483            .conn
484            .query_row(
485                "SELECT * FROM projects WHERE id = ?1",
486                params![id.as_str()],
487                |row| self.project_from_row(row),
488            )
489            .optional()?;
490
491        match project {
492            Some(p) => Ok(Some(self.hydrate_project(p)?)),
493            None => Ok(None),
494        }
495    }
496
497    fn get_project_by_name(&self, name: &str) -> Result<Option<Project>, StorageError> {
498        let project = self
499            .conn
500            .query_row(
501                "SELECT * FROM projects WHERE name = ?1 COLLATE NOCASE",
502                params![name],
503                |row| self.project_from_row(row),
504            )
505            .optional()?;
506
507        match project {
508            Some(p) => Ok(Some(self.hydrate_project(p)?)),
509            None => Ok(None),
510        }
511    }
512
513    fn get_project_by_path(&self, path: &Path) -> Result<Option<Project>, StorageError> {
514        let path_str = path.to_string_lossy();
515
516        // Longest-prefix match: find the project whose registered path is the
517        // most specific ancestor of (or exact match for) the given path.
518        let project = self
519            .conn
520            .query_row(
521                "SELECT p.* FROM projects p
522                 JOIN project_paths pp ON p.id = pp.project_id
523                 WHERE ?1 = pp.path
524                    OR (LENGTH(?1) > LENGTH(pp.path)
525                        AND SUBSTR(?1, 1, LENGTH(pp.path) + 1) = pp.path || '/')
526                 ORDER BY LENGTH(pp.path) DESC
527                 LIMIT 1",
528                params![path_str],
529                |row| self.project_from_row(row),
530            )
531            .optional()?;
532
533        match project {
534            Some(p) => Ok(Some(self.hydrate_project(p)?)),
535            None => Ok(None),
536        }
537    }
538
539    fn list_projects(&self, status: Option<ProjectStatus>) -> Result<Vec<Project>, StorageError> {
540        let mut projects = if let Some(ref s) = status {
541            let mut stmt = self
542                .conn
543                .prepare("SELECT * FROM projects WHERE status = ?1 ORDER BY name")?;
544            let result = stmt
545                .query_map(params![s.as_str()], |row| self.project_from_row(row))?
546                .collect::<Result<Vec<_>, _>>()?;
547            result
548        } else {
549            let mut stmt = self.conn.prepare("SELECT * FROM projects ORDER BY name")?;
550            let result = stmt
551                .query_map([], |row| self.project_from_row(row))?
552                .collect::<Result<Vec<_>, _>>()?;
553            result
554        };
555
556        for project in &mut projects {
557            project.paths = self.load_project_paths(&project.id)?;
558            project.tags = self.load_project_tags(&project.id)?;
559        }
560
561        Ok(projects)
562    }
563
564    fn update_project(&self, project: &Project) -> Result<(), StorageError> {
565        let tx = self.conn.unchecked_transaction()?;
566
567        let changed = tx.execute(
568            "UPDATE projects SET name = ?1, hourly_rate_cents = ?2, status = ?3, updated_at = ?4
569             WHERE id = ?5",
570            params![
571                &project.name,
572                project.hourly_rate_cents,
573                project.status.as_str(),
574                Self::fmt_ts(&project.updated_at),
575                project.id.as_str(),
576            ],
577        )?;
578
579        if changed == 0 {
580            return Err(StorageError::ProjectNotFound(project.id.to_string()));
581        }
582
583        Self::save_project_paths_tx(&tx, &project.id, &project.paths)?;
584        Self::save_project_tags_tx(&tx, &project.id, &project.tags)?;
585
586        tx.commit()?;
587        Ok(())
588    }
589
590    fn delete_project(&self, id: &ProjectId) -> Result<(), StorageError> {
591        let changed = self
592            .conn
593            .execute("DELETE FROM projects WHERE id = ?1", params![id.as_str()])?;
594
595        if changed == 0 {
596            return Err(StorageError::ProjectNotFound(id.to_string()));
597        }
598
599        Ok(())
600    }
601
602    // --- Time Entries ---
603
604    fn create_entry(&self, entry: &TimeEntry) -> Result<(), StorageError> {
605        let tx = self.conn.unchecked_transaction()?;
606        let end_str = entry.end.as_ref().map(Self::fmt_ts);
607
608        tx.execute(
609            "INSERT INTO entries
610                (id, project_id, session_id, start, end_time, duration_secs, source, notes,
611                 created_at, updated_at)
612             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
613            params![
614                entry.id.as_str(),
615                entry.project_id.as_str(),
616                entry.session_id.as_ref().map(|s| s.as_str().to_owned()),
617                Self::fmt_ts(&entry.start),
618                end_str,
619                entry.duration_secs,
620                entry.source.as_str(),
621                &entry.notes,
622                Self::fmt_ts(&entry.created_at),
623                Self::fmt_ts(&entry.updated_at),
624            ],
625        )?;
626
627        Self::save_entry_tags_tx(&tx, &entry.id, &entry.tags)?;
628
629        tx.commit()?;
630        Ok(())
631    }
632
633    fn get_entry(&self, id: &EntryId) -> Result<Option<TimeEntry>, StorageError> {
634        let entry = self
635            .conn
636            .query_row(
637                "SELECT * FROM entries WHERE id = ?1",
638                params![id.as_str()],
639                |row| self.entry_from_row(row),
640            )
641            .optional()?;
642
643        match entry {
644            Some(e) => Ok(Some(self.hydrate_entry(e)?)),
645            None => Ok(None),
646        }
647    }
648
649    fn get_running_entry(&self, project_id: &ProjectId) -> Result<Option<TimeEntry>, StorageError> {
650        let entry = self
651            .conn
652            .query_row(
653                "SELECT * FROM entries WHERE project_id = ?1 AND end_time IS NULL LIMIT 1",
654                params![project_id.as_str()],
655                |row| self.entry_from_row(row),
656            )
657            .optional()?;
658
659        match entry {
660            Some(e) => Ok(Some(self.hydrate_entry(e)?)),
661            None => Ok(None),
662        }
663    }
664
665    fn get_running_hook_entry(
666        &self,
667        project_id: &ProjectId,
668    ) -> Result<Option<TimeEntry>, StorageError> {
669        let entry = self
670            .conn
671            .query_row(
672                "SELECT * FROM entries WHERE project_id = ?1 AND end_time IS NULL AND source = 'hook' LIMIT 1",
673                params![project_id.as_str()],
674                |row| self.entry_from_row(row),
675            )
676            .optional()?;
677
678        match entry {
679            Some(e) => Ok(Some(self.hydrate_entry(e)?)),
680            None => Ok(None),
681        }
682    }
683
684    fn get_any_running_entry(&self) -> Result<Option<TimeEntry>, StorageError> {
685        let entry = self
686            .conn
687            .query_row(
688                "SELECT * FROM entries WHERE end_time IS NULL LIMIT 1",
689                [],
690                |row| self.entry_from_row(row),
691            )
692            .optional()?;
693
694        match entry {
695            Some(e) => Ok(Some(self.hydrate_entry(e)?)),
696            None => Ok(None),
697        }
698    }
699
700    fn list_entries(&self, filter: &EntryFilter) -> Result<Vec<TimeEntry>, StorageError> {
701        let mut sql = String::from("SELECT * FROM entries WHERE 1=1");
702        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
703
704        if let Some(ref pid) = filter.project_id {
705            param_values.push(Box::new(pid.as_str().to_owned()));
706            sql.push_str(&format!(" AND project_id = ?{}", param_values.len()));
707        }
708        if let Some(ref from) = filter.from {
709            param_values.push(Box::new(Self::fmt_ts(from)));
710            sql.push_str(&format!(" AND start >= ?{}", param_values.len()));
711        }
712        if let Some(ref to) = filter.to {
713            param_values.push(Box::new(Self::fmt_ts(to)));
714            sql.push_str(&format!(" AND start < ?{}", param_values.len()));
715        }
716        if let Some(ref source) = filter.source {
717            param_values.push(Box::new(source.as_str().to_owned()));
718            sql.push_str(&format!(" AND source = ?{}", param_values.len()));
719        }
720
721        // Tag filtering: entries must have ALL specified tags
722        for tag in &filter.tags {
723            param_values.push(Box::new(tag.clone()));
724            sql.push_str(&format!(
725                " AND id IN (SELECT entry_id FROM entry_tags WHERE tag = ?{})",
726                param_values.len()
727            ));
728        }
729
730        sql.push_str(" ORDER BY start DESC");
731
732        let params_refs: Vec<&dyn rusqlite::types::ToSql> =
733            param_values.iter().map(|p| p.as_ref()).collect();
734
735        let mut stmt = self.conn.prepare(&sql)?;
736        let entries = stmt
737            .query_map(params_refs.as_slice(), |row| self.entry_from_row(row))?
738            .collect::<Result<Vec<_>, _>>()?;
739
740        let mut hydrated = Vec::with_capacity(entries.len());
741        for entry in entries {
742            hydrated.push(self.hydrate_entry(entry)?);
743        }
744
745        Ok(hydrated)
746    }
747
748    fn get_last_entry(&self) -> Result<Option<TimeEntry>, StorageError> {
749        let entry = self
750            .conn
751            .query_row(
752                "SELECT * FROM entries ORDER BY start DESC LIMIT 1",
753                [],
754                |row| self.entry_from_row(row),
755            )
756            .optional()?;
757
758        match entry {
759            Some(e) => Ok(Some(self.hydrate_entry(e)?)),
760            None => Ok(None),
761        }
762    }
763
764    fn update_entry(&self, entry: &TimeEntry) -> Result<(), StorageError> {
765        let tx = self.conn.unchecked_transaction()?;
766        let end_str = entry.end.as_ref().map(Self::fmt_ts);
767
768        let changed = tx.execute(
769            "UPDATE entries SET project_id = ?1, session_id = ?2, start = ?3, end_time = ?4,
770                duration_secs = ?5, source = ?6, notes = ?7, updated_at = ?8
771             WHERE id = ?9",
772            params![
773                entry.project_id.as_str(),
774                entry.session_id.as_ref().map(|s| s.as_str().to_owned()),
775                Self::fmt_ts(&entry.start),
776                end_str,
777                entry.duration_secs,
778                entry.source.as_str(),
779                &entry.notes,
780                Self::fmt_ts(&entry.updated_at),
781                entry.id.as_str(),
782            ],
783        )?;
784
785        if changed == 0 {
786            return Err(StorageError::EntryNotFound(entry.id.to_string()));
787        }
788
789        Self::save_entry_tags_tx(&tx, &entry.id, &entry.tags)?;
790
791        tx.commit()?;
792        Ok(())
793    }
794
795    fn delete_entry(&self, id: &EntryId) -> Result<(), StorageError> {
796        let changed = self
797            .conn
798            .execute("DELETE FROM entries WHERE id = ?1", params![id.as_str()])?;
799
800        if changed == 0 {
801            return Err(StorageError::EntryNotFound(id.to_string()));
802        }
803
804        Ok(())
805    }
806
807    // --- Sessions ---
808
809    fn upsert_session(&self, session: &ShellSession) -> Result<(), StorageError> {
810        let tx = self.conn.unchecked_transaction()?;
811
812        // Close any existing active session with the same PID (handles PID reuse)
813        tx.execute(
814            "UPDATE sessions SET ended_at = ?1 WHERE pid = ?2 AND ended_at IS NULL AND id != ?3",
815            params![
816                Self::fmt_ts(&session.started_at),
817                session.pid,
818                session.id.as_str(),
819            ],
820        )?;
821
822        tx.execute(
823            "INSERT INTO sessions
824                (id, pid, shell, cwd, current_project_id, started_at, last_heartbeat, ended_at)
825             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
826             ON CONFLICT(id) DO UPDATE SET
827                cwd = excluded.cwd,
828                current_project_id = excluded.current_project_id,
829                last_heartbeat = excluded.last_heartbeat,
830                ended_at = excluded.ended_at",
831            params![
832                session.id.as_str(),
833                session.pid,
834                &session.shell,
835                session.cwd.to_string_lossy(),
836                session
837                    .current_project_id
838                    .as_ref()
839                    .map(|p| p.as_str().to_owned()),
840                Self::fmt_ts(&session.started_at),
841                Self::fmt_ts(&session.last_heartbeat),
842                session.ended_at.as_ref().map(Self::fmt_ts),
843            ],
844        )?;
845
846        tx.commit()?;
847        Ok(())
848    }
849
850    fn get_session(&self, id: &SessionId) -> Result<Option<ShellSession>, StorageError> {
851        let session = self
852            .conn
853            .query_row(
854                "SELECT * FROM sessions WHERE id = ?1",
855                params![id.as_str()],
856                |row| self.session_from_row(row),
857            )
858            .optional()?;
859
860        Ok(session)
861    }
862
863    fn get_session_by_pid(&self, pid: u32) -> Result<Option<ShellSession>, StorageError> {
864        let session = self
865            .conn
866            .query_row(
867                "SELECT * FROM sessions WHERE pid = ?1 AND ended_at IS NULL LIMIT 1",
868                params![pid],
869                |row| self.session_from_row(row),
870            )
871            .optional()?;
872
873        Ok(session)
874    }
875
876    fn end_session(&self, id: &SessionId, ended_at: OffsetDateTime) -> Result<(), StorageError> {
877        let changed = self.conn.execute(
878            "UPDATE sessions SET ended_at = ?1 WHERE id = ?2",
879            params![Self::fmt_ts(&ended_at), id.as_str()],
880        )?;
881
882        if changed == 0 {
883            return Err(StorageError::SessionNotFound(id.to_string()));
884        }
885
886        Ok(())
887    }
888
889    fn count_active_sessions_for_project(
890        &self,
891        project_id: &ProjectId,
892        exclude_session_id: &SessionId,
893    ) -> Result<usize, StorageError> {
894        let count: usize = self.conn.query_row(
895            "SELECT COUNT(*) FROM sessions
896             WHERE current_project_id = ?1 AND ended_at IS NULL AND id != ?2",
897            params![project_id.as_str(), exclude_session_id.as_str()],
898            |row| row.get(0),
899        )?;
900        Ok(count)
901    }
902
903    fn get_stale_sessions(
904        &self,
905        older_than: OffsetDateTime,
906    ) -> Result<Vec<ShellSession>, StorageError> {
907        let mut stmt = self
908            .conn
909            .prepare("SELECT * FROM sessions WHERE ended_at IS NULL AND last_heartbeat < ?1")?;
910        let sessions = stmt
911            .query_map(params![Self::fmt_ts(&older_than)], |row| {
912                self.session_from_row(row)
913            })?
914            .collect::<Result<Vec<_>, _>>()?;
915        Ok(sessions)
916    }
917
918    // --- Ignored Paths ---
919
920    fn add_ignored_path(&self, path: &Path) -> Result<(), StorageError> {
921        self.conn.execute(
922            "INSERT OR IGNORE INTO ignored_paths (path) VALUES (?1)",
923            params![path.to_string_lossy()],
924        )?;
925        Ok(())
926    }
927
928    fn remove_ignored_path(&self, path: &Path) -> Result<bool, StorageError> {
929        let changed = self.conn.execute(
930            "DELETE FROM ignored_paths WHERE path = ?1",
931            params![path.to_string_lossy()],
932        )?;
933        Ok(changed > 0)
934    }
935
936    fn is_path_ignored(&self, path: &Path) -> Result<bool, StorageError> {
937        let path_str = path.to_string_lossy();
938        // Check if the path itself or any of its ancestors is ignored
939        let ignored: bool = self.conn.query_row(
940            "SELECT EXISTS(
941                    SELECT 1 FROM ignored_paths
942                    WHERE ?1 = path
943                       OR (LENGTH(?1) > LENGTH(path)
944                           AND SUBSTR(?1, 1, LENGTH(path) + 1) = path || '/')
945                )",
946            params![path_str],
947            |row| row.get(0),
948        )?;
949        Ok(ignored)
950    }
951
952    fn list_ignored_paths(&self) -> Result<Vec<PathBuf>, StorageError> {
953        let mut stmt = self
954            .conn
955            .prepare("SELECT path FROM ignored_paths ORDER BY path")?;
956        let paths = stmt
957            .query_map([], |row| {
958                let p: String = row.get(0)?;
959                Ok(PathBuf::from(p))
960            })?
961            .collect::<Result<Vec<_>, _>>()?;
962        Ok(paths)
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969    use time::macros::datetime;
970
971    /// Creates a test project with sensible defaults.
972    fn test_project(name: &str, paths: Vec<&str>) -> Project {
973        let now = OffsetDateTime::now_utc();
974        Project {
975            id: ProjectId::new(),
976            name: name.to_string(),
977            paths: paths.into_iter().map(PathBuf::from).collect(),
978            tags: vec![],
979            hourly_rate_cents: None,
980            status: ProjectStatus::Active,
981            source: ProjectSource::Manual,
982            created_at: now,
983            updated_at: now,
984        }
985    }
986
987    /// Creates a test entry with sensible defaults.
988    fn test_entry(project_id: &ProjectId, start: OffsetDateTime) -> TimeEntry {
989        let now = OffsetDateTime::now_utc();
990        let end = start + time::Duration::hours(1);
991        TimeEntry {
992            id: EntryId::new(),
993            project_id: project_id.clone(),
994            session_id: None,
995            start,
996            end: Some(end),
997            duration_secs: Some(3600),
998            source: EntrySource::Manual,
999            notes: None,
1000            tags: vec![],
1001            created_at: now,
1002            updated_at: now,
1003        }
1004    }
1005
1006    // --- Migration tests ---
1007
1008    #[test]
1009    fn migration_creates_all_tables() {
1010        let storage = SqliteStorage::open_in_memory().unwrap();
1011        let tables: Vec<String> = {
1012            let mut stmt = storage
1013                .conn
1014                .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
1015                .unwrap();
1016            stmt.query_map([], |row| row.get(0))
1017                .unwrap()
1018                .collect::<Result<Vec<_>, _>>()
1019                .unwrap()
1020        };
1021
1022        assert!(tables.contains(&"projects".to_string()));
1023        assert!(tables.contains(&"project_paths".to_string()));
1024        assert!(tables.contains(&"project_tags".to_string()));
1025        assert!(tables.contains(&"entries".to_string()));
1026        assert!(tables.contains(&"entry_tags".to_string()));
1027        assert!(tables.contains(&"sessions".to_string()));
1028        assert!(tables.contains(&"_stint_meta".to_string()));
1029    }
1030
1031    #[test]
1032    fn migration_is_idempotent() {
1033        let storage = SqliteStorage::open_in_memory().unwrap();
1034        // Run migrate again — should not error
1035        storage.migrate().unwrap();
1036    }
1037
1038    #[test]
1039    fn schema_version_is_set() {
1040        let storage = SqliteStorage::open_in_memory().unwrap();
1041        let version: i64 = storage
1042            .conn
1043            .query_row(
1044                "SELECT CAST(value AS INTEGER) FROM _stint_meta WHERE key = 'schema_version'",
1045                [],
1046                |row| row.get(0),
1047            )
1048            .unwrap();
1049        assert_eq!(version, SCHEMA_VERSION);
1050    }
1051
1052    // --- Project tests ---
1053
1054    #[test]
1055    fn create_and_get_project() {
1056        let storage = SqliteStorage::open_in_memory().unwrap();
1057        let project = test_project("my-app", vec!["/home/user/projects/my-app"]);
1058
1059        storage.create_project(&project).unwrap();
1060
1061        let loaded = storage.get_project(&project.id).unwrap().unwrap();
1062        assert_eq!(loaded.name, "my-app");
1063        assert_eq!(loaded.paths, project.paths);
1064    }
1065
1066    #[test]
1067    fn create_project_with_tags_and_rate() {
1068        let storage = SqliteStorage::open_in_memory().unwrap();
1069        let mut project = test_project("client-work", vec!["/home/user/client"]);
1070        project.tags = vec!["client".to_string(), "frontend".to_string()];
1071        project.hourly_rate_cents = Some(15000);
1072
1073        storage.create_project(&project).unwrap();
1074
1075        let loaded = storage.get_project(&project.id).unwrap().unwrap();
1076        assert_eq!(loaded.tags, vec!["client", "frontend"]);
1077        assert_eq!(loaded.hourly_rate_cents, Some(15000));
1078    }
1079
1080    #[test]
1081    fn create_project_duplicate_name_errors() {
1082        let storage = SqliteStorage::open_in_memory().unwrap();
1083        let p1 = test_project("my-app", vec!["/path/a"]);
1084        let p2 = test_project("my-app", vec!["/path/b"]);
1085
1086        storage.create_project(&p1).unwrap();
1087        let result = storage.create_project(&p2);
1088
1089        assert!(matches!(result, Err(StorageError::DuplicateProjectName(_))));
1090    }
1091
1092    #[test]
1093    fn get_project_by_name_case_insensitive() {
1094        let storage = SqliteStorage::open_in_memory().unwrap();
1095        let project = test_project("My-App", vec![]);
1096        storage.create_project(&project).unwrap();
1097
1098        let loaded = storage.get_project_by_name("my-app").unwrap().unwrap();
1099        assert_eq!(loaded.id, project.id);
1100    }
1101
1102    #[test]
1103    fn get_project_by_path_exact_match() {
1104        let storage = SqliteStorage::open_in_memory().unwrap();
1105        let project = test_project("my-app", vec!["/home/user/projects/my-app"]);
1106        storage.create_project(&project).unwrap();
1107
1108        let loaded = storage
1109            .get_project_by_path(Path::new("/home/user/projects/my-app"))
1110            .unwrap()
1111            .unwrap();
1112        assert_eq!(loaded.id, project.id);
1113    }
1114
1115    #[test]
1116    fn get_project_by_path_subdirectory_match() {
1117        let storage = SqliteStorage::open_in_memory().unwrap();
1118        let project = test_project("my-app", vec!["/home/user/projects/my-app"]);
1119        storage.create_project(&project).unwrap();
1120
1121        let loaded = storage
1122            .get_project_by_path(Path::new("/home/user/projects/my-app/src/components"))
1123            .unwrap()
1124            .unwrap();
1125        assert_eq!(loaded.id, project.id);
1126    }
1127
1128    #[test]
1129    fn get_project_by_path_longest_prefix_wins() {
1130        let storage = SqliteStorage::open_in_memory().unwrap();
1131        let monorepo = test_project("monorepo", vec!["/home/user/monorepo"]);
1132        let frontend = test_project("frontend", vec!["/home/user/monorepo/packages/frontend"]);
1133
1134        storage.create_project(&monorepo).unwrap();
1135        storage.create_project(&frontend).unwrap();
1136
1137        // Deep inside frontend — frontend should win
1138        let loaded = storage
1139            .get_project_by_path(Path::new(
1140                "/home/user/monorepo/packages/frontend/src/index.ts",
1141            ))
1142            .unwrap()
1143            .unwrap();
1144        assert_eq!(loaded.name, "frontend");
1145
1146        // Inside monorepo but not frontend — monorepo should win
1147        let loaded = storage
1148            .get_project_by_path(Path::new("/home/user/monorepo/packages/backend"))
1149            .unwrap()
1150            .unwrap();
1151        assert_eq!(loaded.name, "monorepo");
1152    }
1153
1154    #[test]
1155    fn get_project_by_path_no_partial_name_match() {
1156        let storage = SqliteStorage::open_in_memory().unwrap();
1157        let project = test_project("my-app", vec!["/home/user/project"]);
1158        storage.create_project(&project).unwrap();
1159
1160        // "/home/user/project-foo" should NOT match "/home/user/project"
1161        let loaded = storage
1162            .get_project_by_path(Path::new("/home/user/project-foo"))
1163            .unwrap();
1164        assert!(loaded.is_none());
1165    }
1166
1167    #[test]
1168    fn get_project_by_path_no_match() {
1169        let storage = SqliteStorage::open_in_memory().unwrap();
1170        let project = test_project("my-app", vec!["/home/user/projects/my-app"]);
1171        storage.create_project(&project).unwrap();
1172
1173        let loaded = storage
1174            .get_project_by_path(Path::new("/home/user/other"))
1175            .unwrap();
1176        assert!(loaded.is_none());
1177    }
1178
1179    #[test]
1180    fn list_projects_filters_by_status() {
1181        let storage = SqliteStorage::open_in_memory().unwrap();
1182
1183        let active = test_project("active-proj", vec![]);
1184        let mut archived = test_project("archived-proj", vec![]);
1185        archived.status = ProjectStatus::Archived;
1186
1187        storage.create_project(&active).unwrap();
1188        storage.create_project(&archived).unwrap();
1189
1190        let active_list = storage.list_projects(Some(ProjectStatus::Active)).unwrap();
1191        assert_eq!(active_list.len(), 1);
1192        assert_eq!(active_list[0].name, "active-proj");
1193
1194        let archived_list = storage
1195            .list_projects(Some(ProjectStatus::Archived))
1196            .unwrap();
1197        assert_eq!(archived_list.len(), 1);
1198        assert_eq!(archived_list[0].name, "archived-proj");
1199
1200        let all = storage.list_projects(None).unwrap();
1201        assert_eq!(all.len(), 2);
1202    }
1203
1204    #[test]
1205    fn update_project() {
1206        let storage = SqliteStorage::open_in_memory().unwrap();
1207        let mut project = test_project("my-app", vec!["/home/user/my-app"]);
1208        storage.create_project(&project).unwrap();
1209
1210        project.name = "renamed-app".to_string();
1211        project.hourly_rate_cents = Some(20000);
1212        project.paths = vec![
1213            PathBuf::from("/home/user/my-app"),
1214            PathBuf::from("/home/user/my-app-v2"),
1215        ];
1216        storage.update_project(&project).unwrap();
1217
1218        let loaded = storage.get_project(&project.id).unwrap().unwrap();
1219        assert_eq!(loaded.name, "renamed-app");
1220        assert_eq!(loaded.hourly_rate_cents, Some(20000));
1221        assert_eq!(loaded.paths.len(), 2);
1222    }
1223
1224    #[test]
1225    fn delete_project_cascades() {
1226        let storage = SqliteStorage::open_in_memory().unwrap();
1227        let project = test_project("my-app", vec!["/home/user/my-app"]);
1228        storage.create_project(&project).unwrap();
1229
1230        let entry = test_entry(&project.id, datetime!(2026-01-01 9:00 UTC));
1231        storage.create_entry(&entry).unwrap();
1232
1233        storage.delete_project(&project.id).unwrap();
1234
1235        assert!(storage.get_project(&project.id).unwrap().is_none());
1236        assert!(storage.get_entry(&entry.id).unwrap().is_none());
1237    }
1238
1239    // --- Entry tests ---
1240
1241    #[test]
1242    fn create_and_get_entry() {
1243        let storage = SqliteStorage::open_in_memory().unwrap();
1244        let project = test_project("my-app", vec![]);
1245        storage.create_project(&project).unwrap();
1246
1247        let mut entry = test_entry(&project.id, datetime!(2026-01-01 9:00 UTC));
1248        entry.tags = vec!["bugfix".to_string()];
1249        entry.notes = Some("Fixed the login bug".to_string());
1250        storage.create_entry(&entry).unwrap();
1251
1252        let loaded = storage.get_entry(&entry.id).unwrap().unwrap();
1253        assert_eq!(loaded.project_id, project.id);
1254        assert_eq!(loaded.tags, vec!["bugfix"]);
1255        assert_eq!(loaded.notes.as_deref(), Some("Fixed the login bug"));
1256    }
1257
1258    #[test]
1259    fn get_running_entry_for_project() {
1260        let storage = SqliteStorage::open_in_memory().unwrap();
1261        let project = test_project("my-app", vec![]);
1262        storage.create_project(&project).unwrap();
1263
1264        let mut entry = test_entry(&project.id, datetime!(2026-01-01 9:00 UTC));
1265        entry.end = None;
1266        entry.duration_secs = None;
1267        storage.create_entry(&entry).unwrap();
1268
1269        let running = storage.get_running_entry(&project.id).unwrap().unwrap();
1270        assert_eq!(running.id, entry.id);
1271    }
1272
1273    #[test]
1274    fn get_running_entry_none_when_stopped() {
1275        let storage = SqliteStorage::open_in_memory().unwrap();
1276        let project = test_project("my-app", vec![]);
1277        storage.create_project(&project).unwrap();
1278
1279        let mut entry = test_entry(&project.id, datetime!(2026-01-01 9:00 UTC));
1280        entry.end = Some(datetime!(2026-01-01 10:00 UTC));
1281        entry.duration_secs = Some(3600);
1282        storage.create_entry(&entry).unwrap();
1283
1284        let running = storage.get_running_entry(&project.id).unwrap();
1285        assert!(running.is_none());
1286    }
1287
1288    #[test]
1289    fn get_any_running_entry() {
1290        let storage = SqliteStorage::open_in_memory().unwrap();
1291        let p1 = test_project("app-1", vec![]);
1292        let p2 = test_project("app-2", vec![]);
1293        storage.create_project(&p1).unwrap();
1294        storage.create_project(&p2).unwrap();
1295
1296        let mut entry = test_entry(&p2.id, datetime!(2026-01-01 9:00 UTC));
1297        entry.end = None;
1298        entry.duration_secs = None;
1299        storage.create_entry(&entry).unwrap();
1300
1301        let running = storage.get_any_running_entry().unwrap().unwrap();
1302        assert_eq!(running.project_id, p2.id);
1303    }
1304
1305    #[test]
1306    fn list_entries_by_project() {
1307        let storage = SqliteStorage::open_in_memory().unwrap();
1308        let p1 = test_project("app-1", vec![]);
1309        let p2 = test_project("app-2", vec![]);
1310        storage.create_project(&p1).unwrap();
1311        storage.create_project(&p2).unwrap();
1312
1313        let e1 = test_entry(&p1.id, datetime!(2026-01-01 9:00 UTC));
1314        let e2 = test_entry(&p2.id, datetime!(2026-01-01 10:00 UTC));
1315        storage.create_entry(&e1).unwrap();
1316        storage.create_entry(&e2).unwrap();
1317
1318        let filter = EntryFilter {
1319            project_id: Some(p1.id.clone()),
1320            ..Default::default()
1321        };
1322        let entries = storage.list_entries(&filter).unwrap();
1323        assert_eq!(entries.len(), 1);
1324        assert_eq!(entries[0].project_id, p1.id);
1325    }
1326
1327    #[test]
1328    fn list_entries_by_date_range() {
1329        let storage = SqliteStorage::open_in_memory().unwrap();
1330        let project = test_project("my-app", vec![]);
1331        storage.create_project(&project).unwrap();
1332
1333        let e1 = test_entry(&project.id, datetime!(2026-01-01 9:00 UTC));
1334        let e2 = test_entry(&project.id, datetime!(2026-01-02 9:00 UTC));
1335        let e3 = test_entry(&project.id, datetime!(2026-01-03 9:00 UTC));
1336        storage.create_entry(&e1).unwrap();
1337        storage.create_entry(&e2).unwrap();
1338        storage.create_entry(&e3).unwrap();
1339
1340        let filter = EntryFilter {
1341            from: Some(datetime!(2026-01-02 0:00 UTC)),
1342            to: Some(datetime!(2026-01-03 0:00 UTC)),
1343            ..Default::default()
1344        };
1345        let entries = storage.list_entries(&filter).unwrap();
1346        assert_eq!(entries.len(), 1);
1347        assert_eq!(entries[0].id, e2.id);
1348    }
1349
1350    #[test]
1351    fn list_entries_by_tag() {
1352        let storage = SqliteStorage::open_in_memory().unwrap();
1353        let project = test_project("my-app", vec![]);
1354        storage.create_project(&project).unwrap();
1355
1356        let mut e1 = test_entry(&project.id, datetime!(2026-01-01 9:00 UTC));
1357        e1.tags = vec!["bugfix".to_string(), "urgent".to_string()];
1358        let mut e2 = test_entry(&project.id, datetime!(2026-01-02 9:00 UTC));
1359        e2.tags = vec!["feature".to_string()];
1360        storage.create_entry(&e1).unwrap();
1361        storage.create_entry(&e2).unwrap();
1362
1363        // Filter by single tag
1364        let filter = EntryFilter {
1365            tags: vec!["bugfix".to_string()],
1366            ..Default::default()
1367        };
1368        let entries = storage.list_entries(&filter).unwrap();
1369        assert_eq!(entries.len(), 1);
1370        assert_eq!(entries[0].id, e1.id);
1371
1372        // Filter by multiple tags (AND)
1373        let filter = EntryFilter {
1374            tags: vec!["bugfix".to_string(), "urgent".to_string()],
1375            ..Default::default()
1376        };
1377        let entries = storage.list_entries(&filter).unwrap();
1378        assert_eq!(entries.len(), 1);
1379    }
1380
1381    #[test]
1382    fn update_entry_stop() {
1383        let storage = SqliteStorage::open_in_memory().unwrap();
1384        let project = test_project("my-app", vec![]);
1385        storage.create_project(&project).unwrap();
1386
1387        let mut entry = test_entry(&project.id, datetime!(2026-01-01 9:00 UTC));
1388        storage.create_entry(&entry).unwrap();
1389
1390        entry.end = Some(datetime!(2026-01-01 10:30 UTC));
1391        entry.duration_secs = Some(5400);
1392        storage.update_entry(&entry).unwrap();
1393
1394        let loaded = storage.get_entry(&entry.id).unwrap().unwrap();
1395        assert!(!loaded.is_running());
1396        assert_eq!(loaded.duration_secs, Some(5400));
1397    }
1398
1399    #[test]
1400    fn delete_entry() {
1401        let storage = SqliteStorage::open_in_memory().unwrap();
1402        let project = test_project("my-app", vec![]);
1403        storage.create_project(&project).unwrap();
1404
1405        let mut entry = test_entry(&project.id, datetime!(2026-01-01 9:00 UTC));
1406        entry.tags = vec!["test".to_string()];
1407        storage.create_entry(&entry).unwrap();
1408
1409        storage.delete_entry(&entry.id).unwrap();
1410
1411        assert!(storage.get_entry(&entry.id).unwrap().is_none());
1412    }
1413
1414    // --- Session tests ---
1415
1416    #[test]
1417    fn upsert_session_creates() {
1418        let storage = SqliteStorage::open_in_memory().unwrap();
1419        let now = OffsetDateTime::now_utc();
1420        let session = ShellSession {
1421            id: SessionId::new(),
1422            pid: 12345,
1423            shell: Some("zsh".to_string()),
1424            cwd: PathBuf::from("/home/user/projects"),
1425            current_project_id: None,
1426            started_at: now,
1427            last_heartbeat: now,
1428            ended_at: None,
1429        };
1430
1431        storage.upsert_session(&session).unwrap();
1432
1433        let loaded = storage.get_session(&session.id).unwrap().unwrap();
1434        assert_eq!(loaded.pid, 12345);
1435        assert_eq!(loaded.shell.as_deref(), Some("zsh"));
1436    }
1437
1438    #[test]
1439    fn upsert_session_updates_heartbeat() {
1440        let storage = SqliteStorage::open_in_memory().unwrap();
1441        let now = OffsetDateTime::now_utc();
1442        let mut session = ShellSession {
1443            id: SessionId::new(),
1444            pid: 12345,
1445            shell: Some("bash".to_string()),
1446            cwd: PathBuf::from("/home/user"),
1447            current_project_id: None,
1448            started_at: now,
1449            last_heartbeat: now,
1450            ended_at: None,
1451        };
1452
1453        storage.upsert_session(&session).unwrap();
1454
1455        // Simulate heartbeat update
1456        session.cwd = PathBuf::from("/home/user/projects/my-app");
1457        session.last_heartbeat = now + time::Duration::seconds(30);
1458        storage.upsert_session(&session).unwrap();
1459
1460        let loaded = storage.get_session(&session.id).unwrap().unwrap();
1461        assert_eq!(loaded.cwd, PathBuf::from("/home/user/projects/my-app"));
1462    }
1463
1464    #[test]
1465    fn get_session_by_pid() {
1466        let storage = SqliteStorage::open_in_memory().unwrap();
1467        let now = OffsetDateTime::now_utc();
1468        let session = ShellSession {
1469            id: SessionId::new(),
1470            pid: 99999,
1471            shell: Some("fish".to_string()),
1472            cwd: PathBuf::from("/tmp"),
1473            current_project_id: None,
1474            started_at: now,
1475            last_heartbeat: now,
1476            ended_at: None,
1477        };
1478
1479        storage.upsert_session(&session).unwrap();
1480
1481        let loaded = storage.get_session_by_pid(99999).unwrap().unwrap();
1482        assert_eq!(loaded.id, session.id);
1483
1484        // Ended sessions should not be found by PID
1485        storage.end_session(&session.id, now).unwrap();
1486        let loaded = storage.get_session_by_pid(99999).unwrap();
1487        assert!(loaded.is_none());
1488    }
1489
1490    #[test]
1491    fn end_session() {
1492        let storage = SqliteStorage::open_in_memory().unwrap();
1493        let now = OffsetDateTime::now_utc();
1494        let session = ShellSession {
1495            id: SessionId::new(),
1496            pid: 11111,
1497            shell: None,
1498            cwd: PathBuf::from("/home/user"),
1499            current_project_id: None,
1500            started_at: now,
1501            last_heartbeat: now,
1502            ended_at: None,
1503        };
1504
1505        storage.upsert_session(&session).unwrap();
1506        storage.end_session(&session.id, now).unwrap();
1507
1508        let loaded = storage.get_session(&session.id).unwrap().unwrap();
1509        assert!(loaded.ended_at.is_some());
1510    }
1511}