1use 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#[cfg(test)]
19const SCHEMA_VERSION: i64 = 3;
20
21pub struct SqliteStorage {
23 conn: Connection,
24}
25
26impl SqliteStorage {
27 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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![], tags: vec![], hourly_rate_cents,
350 status,
351 source,
352 created_at,
353 updated_at,
354 })
355 }
356
357 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 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![], created_at,
397 updated_at,
398 })
399 }
400
401 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 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 fn fmt_ts(ts: &OffsetDateTime) -> String {
438 ts.format(&Rfc3339).unwrap_or_default()
439 }
440}
441
442impl Storage for SqliteStorage {
443 fn create_project(&self, project: &Project) -> Result<(), StorageError> {
446 let tx = self.conn.unchecked_transaction()?;
447
448 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 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 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 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 fn upsert_session(&self, session: &ShellSession) -> Result<(), StorageError> {
810 let tx = self.conn.unchecked_transaction()?;
811
812 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 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 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 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 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 #[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 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 #[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 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 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 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 #[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 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 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 #[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 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 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}