1use crate::models::{TaskStatus, TodoStatus, VcsMode};
8use crate::utils::Result;
9use directories::ProjectDirs;
10use rusqlite::{params, Connection, OptionalExtension};
11use std::path::PathBuf;
12use std::time::Duration;
13
14pub mod migrate;
15pub mod row_mapping;
16
17#[derive(Clone, Debug, PartialEq, Default)]
23pub struct SectionRevs {
24 pub task: i64,
26 pub todos: i64,
28 pub scraps: i64,
30 pub links: i64,
32 pub repos: i64,
34 pub worktrees: i64,
36}
37
38pub struct Database {
43 conn: Connection,
44}
45
46impl Database {
47 pub fn new() -> Result<Self> {
59 let db_path = Self::get_db_path()?;
60
61 if let Some(parent) = db_path.parent() {
63 std::fs::create_dir_all(parent)?;
64 }
65
66 let conn = Connection::open(&db_path)?;
67 Self::configure_connection(&conn)?;
68 let db = Database { conn };
69 db.initialize_schema()?;
70 Ok(db)
71 }
72
73 #[allow(dead_code)]
79 pub fn new_in_memory() -> Result<Self> {
80 let conn = Connection::open_in_memory()?;
81 Self::configure_connection(&conn)?;
82 let db = Database { conn };
83 db.initialize_schema()?;
84 Ok(db)
85 }
86
87 fn configure_connection(conn: &Connection) -> Result<()> {
92 conn.pragma_update(None, "journal_mode", "WAL")?;
95
96 conn.busy_timeout(Duration::from_secs(5))?;
99
100 Ok(())
101 }
102
103 fn get_db_path() -> Result<PathBuf> {
104 let proj_dirs = ProjectDirs::from("", "", "track")
105 .ok_or(crate::utils::TrackError::DataDirectoryUnavailable)?;
106
107 Ok(proj_dirs.data_dir().join("track.db"))
108 }
109
110 fn initialize_schema(&self) -> Result<()> {
111 let schema = format!(
112 r#"
113 CREATE TABLE IF NOT EXISTS app_state (
114 key TEXT PRIMARY KEY,
115 value TEXT
116 );
117
118 CREATE TABLE IF NOT EXISTS tasks (
119 id INTEGER PRIMARY KEY AUTOINCREMENT,
120 name TEXT NOT NULL,
121 status TEXT NOT NULL DEFAULT '{task_active}' CHECK (status IN ('{task_active}', '{task_archived}')),
122 ticket_id TEXT UNIQUE,
123 ticket_url TEXT,
124 created_at TEXT NOT NULL
125 );
126
127 CREATE TABLE IF NOT EXISTS todos (
128 id INTEGER PRIMARY KEY AUTOINCREMENT,
129 task_id INTEGER NOT NULL,
130 content TEXT NOT NULL,
131 status TEXT NOT NULL DEFAULT '{todo_pending}' CHECK (status IN ('{todo_pending}', '{todo_done}', '{todo_cancelled}')),
132 worktree_requested INTEGER NOT NULL DEFAULT 0,
133 requires_workspace INTEGER NOT NULL DEFAULT 1,
134 created_at TEXT NOT NULL,
135 completed_at TEXT,
136 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
137 );
138
139 CREATE TABLE IF NOT EXISTS links (
140 id INTEGER PRIMARY KEY AUTOINCREMENT,
141 task_id INTEGER NOT NULL,
142 url TEXT NOT NULL,
143 title TEXT NOT NULL,
144 created_at TEXT NOT NULL,
145 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
146 );
147
148 CREATE TABLE IF NOT EXISTS scraps (
149 id INTEGER PRIMARY KEY AUTOINCREMENT,
150 task_id INTEGER NOT NULL,
151 content TEXT NOT NULL,
152 created_at TEXT NOT NULL,
153 active_todo_id INTEGER,
154 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
155 );
156
157 CREATE TABLE IF NOT EXISTS worktrees (
158 id INTEGER PRIMARY KEY AUTOINCREMENT,
159 task_id INTEGER NOT NULL,
160 path TEXT NOT NULL,
161 branch TEXT NOT NULL,
162 base_repo TEXT,
163 status TEXT NOT NULL DEFAULT 'active',
164 created_at TEXT NOT NULL,
165 todo_id INTEGER,
166 is_base INTEGER DEFAULT 0,
167 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
168 FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE SET NULL
169 );
170
171 CREATE TABLE IF NOT EXISTS repo_links (
172 id INTEGER PRIMARY KEY AUTOINCREMENT,
173 worktree_id INTEGER NOT NULL,
174 url TEXT NOT NULL,
175 kind TEXT NOT NULL,
176 created_at TEXT NOT NULL,
177 FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
178 );
179
180 CREATE TABLE IF NOT EXISTS task_repos (
181 id INTEGER PRIMARY KEY AUTOINCREMENT,
182 task_id INTEGER NOT NULL,
183 repo_path TEXT NOT NULL,
184 created_at TEXT NOT NULL,
185 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
186 UNIQUE(task_id, repo_path)
187 );
188
189 CREATE INDEX IF NOT EXISTS idx_todos_task_id ON todos(task_id);
190 CREATE INDEX IF NOT EXISTS idx_links_task_id ON links(task_id);
191 CREATE INDEX IF NOT EXISTS idx_scraps_task_id ON scraps(task_id);
192 CREATE INDEX IF NOT EXISTS idx_worktrees_task_id ON worktrees(task_id);
193 CREATE INDEX IF NOT EXISTS idx_task_repos_task_id ON task_repos(task_id);
194 "#,
195 task_active = TaskStatus::ACTIVE,
196 task_archived = TaskStatus::ARCHIVED,
197 todo_pending = TodoStatus::PENDING,
198 todo_done = TodoStatus::DONE,
199 todo_cancelled = TodoStatus::CANCELLED,
200 );
201
202 self.conn.execute_batch(&schema)?;
203
204 migrate::migrate_schema(&self.conn)?;
205
206 Ok(())
207 }
208
209 pub fn get_connection(&self) -> &Connection {
211 &self.conn
212 }
213
214 pub fn with_transaction<T, F>(&self, f: F) -> Result<T>
239 where
240 F: FnOnce() -> Result<T>,
241 {
242 self.conn.execute("BEGIN IMMEDIATE", [])?;
243 match f() {
244 Ok(value) => {
245 self.conn.execute("COMMIT", [])?;
246 Ok(value)
247 }
248 Err(e) => {
249 let _ = self.conn.execute("ROLLBACK", []);
251 Err(e)
252 }
253 }
254 }
255
256 pub fn get_app_state(&self, key: &str) -> Result<Option<String>> {
257 let mut stmt = self
258 .conn
259 .prepare("SELECT value FROM app_state WHERE key = ?1")?;
260 let result = stmt.query_row(params![key], |row| row.get(0)).optional()?;
261 Ok(result)
262 }
263
264 pub fn set_app_state(&self, key: &str, value: &str) -> Result<()> {
265 self.conn.execute(
266 "INSERT OR REPLACE INTO app_state (key, value) VALUES (?1, ?2)",
267 params![key, value],
268 )?;
269 Ok(())
270 }
271
272 pub fn get_vcs_mode(&self) -> Result<VcsMode> {
274 match self.get_app_state(VcsMode::KEY)? {
275 Some(value) => value
276 .parse()
277 .map_err(crate::utils::TrackError::InvalidVcsMode),
278 None => Ok(VcsMode::default()),
279 }
280 }
281
282 pub fn set_vcs_mode(&self, mode: VcsMode) -> Result<()> {
284 self.set_app_state(VcsMode::KEY, mode.as_str())
285 }
286
287 pub fn get_current_task_id(&self) -> Result<Option<i64>> {
293 match self.get_app_state("current_task_id")? {
294 Some(id_str) => Ok(Some(id_str.parse().map_err(|_| {
295 crate::utils::TrackError::InvalidAppStateValue {
296 key: "current_task_id".to_string(),
297 detail: "expected integer".to_string(),
298 }
299 })?)),
300 None => Ok(None),
301 }
302 }
303
304 pub fn set_current_task_id(&self, task_id: i64) -> Result<()> {
310 self.set_app_state("current_task_id", &task_id.to_string())
311 }
312
313 pub fn clear_current_task_id(&self) -> Result<()> {
315 self.conn
316 .execute("DELETE FROM app_state WHERE key = 'current_task_id'", [])?;
317 Ok(())
318 }
319
320 pub fn increment_rev(&self, section: &str) -> Result<i64> {
326 let key = format!("rev:{}", section);
327 let current: i64 = self
328 .get_app_state(&key)?
329 .and_then(|s| s.parse().ok())
330 .unwrap_or(0);
331 let new_rev = current + 1;
332 self.set_app_state(&key, &new_rev.to_string())?;
333 Ok(new_rev)
334 }
335
336 pub fn get_rev(&self, section: &str) -> Result<i64> {
342 let key = format!("rev:{}", section);
343 Ok(self
344 .get_app_state(&key)?
345 .and_then(|s| s.parse().ok())
346 .unwrap_or(0))
347 }
348
349 pub fn get_all_revs(&self) -> Result<SectionRevs> {
351 Ok(SectionRevs {
352 task: self.get_rev("task")?,
353 todos: self.get_rev("todos")?,
354 scraps: self.get_rev("scraps")?,
355 links: self.get_rev("links")?,
356 repos: self.get_rev("repos")?,
357 worktrees: self.get_rev("worktrees")?,
358 })
359 }
360}
361
362impl crate::ports::AppStateStore for Database {
363 fn get_current_task_id(&self) -> Result<Option<i64>> {
364 <Database>::get_current_task_id(self)
365 }
366
367 fn set_current_task_id(&self, task_id: i64) -> Result<()> {
368 <Database>::set_current_task_id(self, task_id)
369 }
370
371 fn clear_current_task_id(&self) -> Result<()> {
372 <Database>::clear_current_task_id(self)
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn test_new_in_memory() {
382 let db = Database::new_in_memory().unwrap();
383 let result: i64 = db
385 .get_connection()
386 .query_row("SELECT 1", [], |row| row.get(0))
387 .unwrap();
388 assert_eq!(result, 1);
389 }
390
391 #[test]
392 fn test_wal_mode_enabled() {
393 let db = Database::new_in_memory().unwrap();
394 let mode: String = db
398 .get_connection()
399 .query_row("PRAGMA journal_mode", [], |row| row.get(0))
400 .unwrap();
401 assert!(mode == "memory" || mode == "wal");
403 }
404
405 #[test]
406 fn test_busy_timeout_configured() {
407 let db = Database::new_in_memory().unwrap();
408 let timeout: i64 = db
409 .get_connection()
410 .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
411 .unwrap();
412 assert_eq!(timeout, 5000);
414 }
415
416 #[test]
417 fn test_app_state_get_set() {
418 let db = Database::new_in_memory().unwrap();
419
420 let value = db.get_app_state("test_key").unwrap();
422 assert!(value.is_none());
423
424 db.set_app_state("test_key", "test_value").unwrap();
426
427 let value = db.get_app_state("test_key").unwrap();
429 assert_eq!(value, Some("test_value".to_string()));
430
431 db.set_app_state("test_key", "new_value").unwrap();
433 let value = db.get_app_state("test_key").unwrap();
434 assert_eq!(value, Some("new_value".to_string()));
435 }
436
437 #[test]
438 fn test_vcs_mode_defaults_to_jj() {
439 let db = Database::new_in_memory().unwrap();
440 assert_eq!(db.get_vcs_mode().unwrap(), VcsMode::Jj);
441 }
442
443 #[test]
444 fn test_vcs_mode_round_trip() {
445 let db = Database::new_in_memory().unwrap();
446 db.set_vcs_mode(VcsMode::Git).unwrap();
447 assert_eq!(db.get_vcs_mode().unwrap(), VcsMode::Git);
448 db.set_vcs_mode(VcsMode::Jj).unwrap();
449 assert_eq!(db.get_vcs_mode().unwrap(), VcsMode::Jj);
450 }
451
452 #[test]
453 fn test_with_transaction_commit() {
454 let db = Database::new_in_memory().unwrap();
455
456 let result = db.with_transaction(|| {
458 db.set_app_state("tx_key", "tx_value")?;
459 Ok("success")
460 });
461
462 assert!(result.is_ok());
463 assert_eq!(result.unwrap(), "success");
464
465 let value = db.get_app_state("tx_key").unwrap();
467 assert_eq!(value, Some("tx_value".to_string()));
468 }
469
470 #[test]
471 fn test_with_transaction_rollback() {
472 let db = Database::new_in_memory().unwrap();
473
474 db.set_app_state("rollback_key", "initial").unwrap();
476
477 let result: crate::utils::Result<()> = db.with_transaction(|| {
479 db.set_app_state("rollback_key", "changed")?;
480 Err(crate::utils::TrackError::Cancelled)
481 });
482
483 assert!(result.is_err());
484
485 let value = db.get_app_state("rollback_key").unwrap();
487 assert_eq!(value, Some("initial".to_string()));
488 }
489
490 #[test]
491 fn test_with_transaction_returns_value() {
492 let db = Database::new_in_memory().unwrap();
493
494 let result = db.with_transaction(|| {
496 db.set_app_state("key", "value")?;
497 Ok(42i64)
498 });
499
500 assert!(result.is_ok());
501 assert_eq!(result.unwrap(), 42);
502 }
503
504 #[test]
505 fn test_current_task_id() {
506 let db = Database::new_in_memory().unwrap();
507
508 let task_id = db.get_current_task_id().unwrap();
510 assert!(task_id.is_none());
511
512 db.set_current_task_id(42).unwrap();
514
515 let task_id = db.get_current_task_id().unwrap();
517 assert_eq!(task_id, Some(42));
518
519 db.clear_current_task_id().unwrap();
521 let task_id = db.get_current_task_id().unwrap();
522 assert!(task_id.is_none());
523 }
524
525 #[test]
526 fn test_schema_initialization() {
527 let db = Database::new_in_memory().unwrap();
528 let conn = db.get_connection();
529
530 let tables = vec![
532 "app_state",
533 "tasks",
534 "todos",
535 "links",
536 "scraps",
537 "worktrees",
538 "repo_links",
539 ];
540 for table in tables {
541 let result: i64 = conn
542 .query_row(
543 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
544 [table],
545 |row| row.get(0),
546 )
547 .unwrap();
548 assert_eq!(result, 1, "Table {} should exist", table);
549 }
550 }
551
552 #[test]
553 fn test_increment_rev() {
554 let db = Database::new_in_memory().unwrap();
555
556 let rev = db.get_rev("todos").unwrap();
558 assert_eq!(rev, 0);
559
560 let new_rev = db.increment_rev("todos").unwrap();
562 assert_eq!(new_rev, 1);
563
564 let rev = db.get_rev("todos").unwrap();
566 assert_eq!(rev, 1);
567
568 let new_rev = db.increment_rev("todos").unwrap();
570 assert_eq!(new_rev, 2);
571 }
572
573 #[test]
574 fn test_get_rev_default() {
575 let db = Database::new_in_memory().unwrap();
576
577 let rev = db.get_rev("nonexistent").unwrap();
579 assert_eq!(rev, 0);
580 }
581
582 #[test]
583 fn test_increment_rev_different_sections() {
584 let db = Database::new_in_memory().unwrap();
585
586 db.increment_rev("todos").unwrap();
588 db.increment_rev("todos").unwrap();
589 db.increment_rev("scraps").unwrap();
590 db.increment_rev("links").unwrap();
591 db.increment_rev("links").unwrap();
592 db.increment_rev("links").unwrap();
593
594 assert_eq!(db.get_rev("todos").unwrap(), 2);
596 assert_eq!(db.get_rev("scraps").unwrap(), 1);
597 assert_eq!(db.get_rev("links").unwrap(), 3);
598 assert_eq!(db.get_rev("repos").unwrap(), 0);
599 }
600
601 #[test]
602 fn test_get_all_revs() {
603 let db = Database::new_in_memory().unwrap();
604
605 let revs = db.get_all_revs().unwrap();
607 assert_eq!(revs.task, 0);
608 assert_eq!(revs.todos, 0);
609 assert_eq!(revs.scraps, 0);
610 assert_eq!(revs.links, 0);
611 assert_eq!(revs.repos, 0);
612 assert_eq!(revs.worktrees, 0);
613
614 db.increment_rev("task").unwrap();
616 db.increment_rev("todos").unwrap();
617 db.increment_rev("todos").unwrap();
618 db.increment_rev("worktrees").unwrap();
619
620 let revs = db.get_all_revs().unwrap();
622 assert_eq!(revs.task, 1);
623 assert_eq!(revs.todos, 2);
624 assert_eq!(revs.scraps, 0);
625 assert_eq!(revs.links, 0);
626 assert_eq!(revs.repos, 0);
627 assert_eq!(revs.worktrees, 1);
628 }
629
630 #[test]
631 fn test_section_revs_equality() {
632 let db = Database::new_in_memory().unwrap();
633
634 let revs1 = db.get_all_revs().unwrap();
635 let revs2 = db.get_all_revs().unwrap();
636 assert_eq!(revs1, revs2);
637
638 db.increment_rev("todos").unwrap();
639 let revs3 = db.get_all_revs().unwrap();
640 assert_ne!(revs1, revs3);
641 }
642
643 #[test]
644 fn test_status_check_constraints_enforced() {
645 use crate::models::{TaskStatus, TodoStatus};
646
647 let db = Database::new_in_memory().unwrap();
648 let conn = db.get_connection();
649
650 let tasks_sql: String = conn
651 .query_row(
652 "SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'",
653 [],
654 |row| row.get(0),
655 )
656 .unwrap();
657 assert!(tasks_sql.contains("CHECK"));
658
659 conn.execute(
660 "INSERT INTO tasks (name, status, created_at) VALUES ('Bad', ?1, datetime('now'))",
661 rusqlite::params![TaskStatus::ACTIVE],
662 )
663 .unwrap();
664
665 let err = conn
666 .execute(
667 "INSERT INTO tasks (name, status, created_at) VALUES ('Bad', 'invalid', datetime('now'))",
668 [],
669 )
670 .unwrap_err();
671 assert!(err.to_string().contains("CHECK constraint failed"));
672
673 conn.execute(
674 "INSERT INTO todos (task_id, task_index, content, status, worktree_requested, created_at) VALUES (1, 1, 'Ok', ?1, 0, datetime('now'))",
675 rusqlite::params![TodoStatus::PENDING],
676 )
677 .unwrap();
678
679 let err = conn
680 .execute(
681 "INSERT INTO todos (task_id, task_index, content, status, worktree_requested, created_at) VALUES (1, 2, 'Bad', 'reopened', 0, datetime('now'))",
682 [],
683 )
684 .unwrap_err();
685 assert!(err.to_string().contains("CHECK constraint failed"));
686 }
687}