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 row_mapping;
15
16#[derive(Clone, Debug, PartialEq, Default)]
22pub struct SectionRevs {
23 pub task: i64,
25 pub todos: i64,
27 pub scraps: i64,
29 pub links: i64,
31 pub repos: i64,
33 pub worktrees: i64,
35}
36
37pub struct Database {
42 conn: Connection,
43}
44
45impl Database {
46 pub fn new() -> Result<Self> {
58 let db_path = Self::get_db_path()?;
59
60 if let Some(parent) = db_path.parent() {
62 std::fs::create_dir_all(parent)?;
63 }
64
65 let conn = Connection::open(&db_path)?;
66 Self::configure_connection(&conn)?;
67 let db = Database { conn };
68 db.initialize_schema()?;
69 Ok(db)
70 }
71
72 #[allow(dead_code)]
78 pub fn new_in_memory() -> Result<Self> {
79 let conn = Connection::open_in_memory()?;
80 Self::configure_connection(&conn)?;
81 let db = Database { conn };
82 db.initialize_schema()?;
83 Ok(db)
84 }
85
86 fn configure_connection(conn: &Connection) -> Result<()> {
91 conn.pragma_update(None, "journal_mode", "WAL")?;
94
95 conn.busy_timeout(Duration::from_secs(5))?;
98
99 Ok(())
100 }
101
102 fn get_db_path() -> Result<PathBuf> {
103 let proj_dirs = ProjectDirs::from("", "", "track").ok_or_else(|| {
104 crate::utils::TrackError::Other("Failed to determine data directory".to_string())
105 })?;
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 created_at TEXT NOT NULL,
134 completed_at TEXT,
135 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
136 );
137
138 CREATE TABLE IF NOT EXISTS links (
139 id INTEGER PRIMARY KEY AUTOINCREMENT,
140 task_id INTEGER NOT NULL,
141 url TEXT NOT NULL,
142 title TEXT NOT NULL,
143 created_at TEXT NOT NULL,
144 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
145 );
146
147 CREATE TABLE IF NOT EXISTS scraps (
148 id INTEGER PRIMARY KEY AUTOINCREMENT,
149 task_id INTEGER NOT NULL,
150 content TEXT NOT NULL,
151 created_at TEXT NOT NULL,
152 active_todo_id INTEGER,
153 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
154 );
155
156 CREATE TABLE IF NOT EXISTS worktrees (
157 id INTEGER PRIMARY KEY AUTOINCREMENT,
158 task_id INTEGER NOT NULL,
159 path TEXT NOT NULL,
160 branch TEXT NOT NULL,
161 base_repo TEXT,
162 status TEXT NOT NULL DEFAULT 'active',
163 created_at TEXT NOT NULL,
164 todo_id INTEGER,
165 is_base INTEGER DEFAULT 0,
166 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
167 FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE SET NULL
168 );
169
170 CREATE TABLE IF NOT EXISTS repo_links (
171 id INTEGER PRIMARY KEY AUTOINCREMENT,
172 worktree_id INTEGER NOT NULL,
173 url TEXT NOT NULL,
174 kind TEXT NOT NULL,
175 created_at TEXT NOT NULL,
176 FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
177 );
178
179 CREATE TABLE IF NOT EXISTS task_repos (
180 id INTEGER PRIMARY KEY AUTOINCREMENT,
181 task_id INTEGER NOT NULL,
182 repo_path TEXT NOT NULL,
183 created_at TEXT NOT NULL,
184 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
185 UNIQUE(task_id, repo_path)
186 );
187
188 CREATE INDEX IF NOT EXISTS idx_todos_task_id ON todos(task_id);
189 CREATE INDEX IF NOT EXISTS idx_links_task_id ON links(task_id);
190 CREATE INDEX IF NOT EXISTS idx_scraps_task_id ON scraps(task_id);
191 CREATE INDEX IF NOT EXISTS idx_worktrees_task_id ON worktrees(task_id);
192 CREATE INDEX IF NOT EXISTS idx_task_repos_task_id ON task_repos(task_id);
193 "#,
194 task_active = TaskStatus::ACTIVE,
195 task_archived = TaskStatus::ARCHIVED,
196 todo_pending = TodoStatus::PENDING,
197 todo_done = TodoStatus::DONE,
198 todo_cancelled = TodoStatus::CANCELLED,
199 );
200
201 self.conn.execute_batch(&schema)?;
202
203 self.migrate_schema()?;
204
205 Ok(())
206 }
207
208 fn migrate_status_check_constraints(&self) -> Result<()> {
210 let tasks_sql: String = self
211 .conn
212 .query_row(
213 "SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'",
214 [],
215 |row| row.get(0),
216 )
217 .unwrap_or_default();
218
219 if tasks_sql.contains("CHECK") {
220 return Ok(());
221 }
222
223 let invalid_tasks: i64 = self.conn.query_row(
224 &format!(
225 "SELECT COUNT(*) FROM tasks WHERE status NOT IN ('{}', '{}')",
226 TaskStatus::ACTIVE,
227 TaskStatus::ARCHIVED
228 ),
229 [],
230 |row| row.get(0),
231 )?;
232 if invalid_tasks > 0 {
233 return Err(crate::utils::TrackError::Other(format!(
234 "Cannot migrate status constraints: {invalid_tasks} tasks have invalid status values"
235 )));
236 }
237
238 let invalid_todos: i64 = self.conn.query_row(
239 &format!(
240 "SELECT COUNT(*) FROM todos WHERE status NOT IN ('{}', '{}', '{}')",
241 TodoStatus::PENDING,
242 TodoStatus::DONE,
243 TodoStatus::CANCELLED
244 ),
245 [],
246 |row| row.get(0),
247 )?;
248 if invalid_todos > 0 {
249 return Err(crate::utils::TrackError::Other(format!(
250 "Cannot migrate status constraints: {invalid_todos} todos have invalid status values"
251 )));
252 }
253
254 let task_check = format!(
255 "CHECK (status IN ('{}', '{}'))",
256 TaskStatus::ACTIVE,
257 TaskStatus::ARCHIVED
258 );
259 let todo_check = format!(
260 "CHECK (status IN ('{}', '{}', '{}'))",
261 TodoStatus::PENDING,
262 TodoStatus::DONE,
263 TodoStatus::CANCELLED
264 );
265
266 self.conn.execute_batch(&format!(
267 r#"
268 CREATE TABLE tasks_new (
269 id INTEGER PRIMARY KEY AUTOINCREMENT,
270 name TEXT NOT NULL,
271 description TEXT,
272 status TEXT NOT NULL DEFAULT '{task_active}' {task_check},
273 ticket_id TEXT,
274 ticket_url TEXT,
275 alias TEXT,
276 is_today_task INTEGER DEFAULT 0,
277 created_at TEXT NOT NULL
278 );
279 INSERT INTO tasks_new (id, name, description, status, ticket_id, ticket_url, alias, is_today_task, created_at)
280 SELECT id, name, description, status, ticket_id, ticket_url, alias, is_today_task, created_at FROM tasks;
281 DROP TABLE tasks;
282 ALTER TABLE tasks_new RENAME TO tasks;
283 CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_alias ON tasks(alias);
284 CREATE INDEX IF NOT EXISTS idx_tasks_is_today_task ON tasks(is_today_task);
285
286 CREATE TABLE todos_new (
287 id INTEGER PRIMARY KEY AUTOINCREMENT,
288 task_id INTEGER NOT NULL,
289 task_index INTEGER,
290 content TEXT NOT NULL,
291 status TEXT NOT NULL DEFAULT '{todo_pending}' {todo_check},
292 worktree_requested INTEGER NOT NULL DEFAULT 0,
293 created_at TEXT NOT NULL,
294 completed_at TEXT,
295 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
296 );
297 INSERT INTO todos_new (id, task_id, task_index, content, status, worktree_requested, created_at, completed_at)
298 SELECT id, task_id, task_index, content, status, worktree_requested, created_at, completed_at FROM todos;
299 DROP TABLE todos;
300 ALTER TABLE todos_new RENAME TO todos;
301 CREATE INDEX IF NOT EXISTS idx_todos_task_id ON todos(task_id);
302 CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_task_index ON todos(task_id, task_index);
303 "#,
304 task_active = TaskStatus::ACTIVE,
305 task_check = task_check,
306 todo_pending = TodoStatus::PENDING,
307 todo_check = todo_check,
308 ))?;
309
310 Ok(())
311 }
312
313 fn migrate_schema(&self) -> Result<()> {
314 let git_items_exists: i64 = self.conn.query_row(
316 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='git_items'",
317 [],
318 |row| row.get(0),
319 )?;
320
321 if git_items_exists > 0 {
322 self.conn
324 .execute("DROP INDEX IF EXISTS idx_git_items_task_id", [])?;
325 self.conn
326 .execute("DROP INDEX IF EXISTS idx_git_items_todo_id", [])?;
327
328 self.conn
330 .execute("ALTER TABLE git_items RENAME TO worktrees", [])?;
331
332 self.conn.execute(
334 "CREATE INDEX IF NOT EXISTS idx_worktrees_task_id ON worktrees(task_id)",
335 [],
336 )?;
337 self.conn.execute(
338 "CREATE INDEX IF NOT EXISTS idx_worktrees_todo_id ON worktrees(todo_id)",
339 [],
340 )?;
341 }
342
343 let git_item_id_exists: i64 = self.conn.query_row(
345 "SELECT COUNT(*) FROM pragma_table_info('repo_links') WHERE name='git_item_id'",
346 [],
347 |row| row.get(0),
348 )?;
349
350 if git_item_id_exists > 0 {
351 self.conn.execute_batch(
354 r#"
355 -- Create new repo_links table with worktree_id
356 CREATE TABLE repo_links_new (
357 id INTEGER PRIMARY KEY AUTOINCREMENT,
358 worktree_id INTEGER NOT NULL,
359 url TEXT NOT NULL,
360 kind TEXT NOT NULL,
361 created_at TEXT NOT NULL,
362 FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
363 );
364
365 -- Copy data from old table
366 INSERT INTO repo_links_new (id, worktree_id, url, kind, created_at)
367 SELECT id, git_item_id, url, kind, created_at FROM repo_links;
368
369 -- Drop old table
370 DROP TABLE repo_links;
371
372 -- Rename new table to repo_links
373 ALTER TABLE repo_links_new RENAME TO repo_links;
374
375 -- Recreate index
376 CREATE INDEX IF NOT EXISTS idx_repo_links_worktree_id ON repo_links(worktree_id);
377 "#,
378 )?;
379 }
380
381 let count: i64 = self.conn.query_row(
383 "SELECT COUNT(*) FROM pragma_table_info('worktrees') WHERE name='todo_id'",
384 [],
385 |row| row.get(0),
386 )?;
387
388 if count == 0 {
389 self.conn.execute("ALTER TABLE worktrees ADD COLUMN todo_id INTEGER REFERENCES todos(id) ON DELETE SET NULL", [])?;
390 }
391 self.conn.execute(
392 "CREATE INDEX IF NOT EXISTS idx_worktrees_todo_id ON worktrees(todo_id)",
393 [],
394 )?;
395
396 let count: i64 = self.conn.query_row(
398 "SELECT COUNT(*) FROM pragma_table_info('worktrees') WHERE name='is_base'",
399 [],
400 |row| row.get(0),
401 )?;
402
403 if count == 0 {
404 self.conn.execute(
405 "ALTER TABLE worktrees ADD COLUMN is_base INTEGER DEFAULT 0",
406 [],
407 )?;
408 }
409
410 let count: i64 = self.conn.query_row(
412 "SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name='description'",
413 [],
414 |row| row.get(0),
415 )?;
416
417 if count == 0 {
418 self.conn
419 .execute("ALTER TABLE tasks ADD COLUMN description TEXT", [])?;
420 }
421
422 let count: i64 = self.conn.query_row(
424 "SELECT COUNT(*) FROM pragma_table_info('todos') WHERE name='task_index'",
425 [],
426 |row| row.get(0),
427 )?;
428
429 if count == 0 {
430 self.conn
432 .execute("ALTER TABLE todos ADD COLUMN task_index INTEGER", [])?;
433
434 self.conn.execute_batch(
436 r#"
437 WITH numbered_todos AS (
438 SELECT id, task_id,
439 ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at) as idx
440 FROM todos
441 )
442 UPDATE todos
443 SET task_index = (
444 SELECT idx FROM numbered_todos WHERE numbered_todos.id = todos.id
445 )
446 "#,
447 )?;
448
449 self.conn.execute(
451 "CREATE UNIQUE INDEX idx_todos_task_index ON todos(task_id, task_index)",
452 [],
453 )?;
454 }
455
456 let count: i64 = self.conn.query_row(
458 "SELECT COUNT(*) FROM pragma_table_info('todos') WHERE name='worktree_requested'",
459 [],
460 |row| row.get(0),
461 )?;
462
463 if count == 0 {
464 self.conn.execute(
465 "ALTER TABLE todos ADD COLUMN worktree_requested INTEGER NOT NULL DEFAULT 0",
466 [],
467 )?;
468 }
469
470 let count: i64 = self.conn.query_row(
472 "SELECT COUNT(*) FROM pragma_table_info('task_repos') WHERE name='base_branch'",
473 [],
474 |row| row.get(0),
475 )?;
476
477 if count == 0 {
478 self.conn
479 .execute("ALTER TABLE task_repos ADD COLUMN base_branch TEXT", [])?;
480 }
481
482 let count: i64 = self.conn.query_row(
484 "SELECT COUNT(*) FROM pragma_table_info('task_repos') WHERE name='base_commit_hash'",
485 [],
486 |row| row.get(0),
487 )?;
488
489 if count == 0 {
490 self.conn.execute(
491 "ALTER TABLE task_repos ADD COLUMN base_commit_hash TEXT",
492 [],
493 )?;
494 }
495
496 let count: i64 = self.conn.query_row(
498 "SELECT COUNT(*) FROM pragma_table_info('todos') WHERE name='completed_at'",
499 [],
500 |row| row.get(0),
501 )?;
502
503 if count == 0 {
504 self.conn
505 .execute("ALTER TABLE todos ADD COLUMN completed_at TEXT", [])?;
506 }
507
508 let count: i64 = self.conn.query_row(
510 "SELECT COUNT(*) FROM pragma_table_info('scraps') WHERE name='task_index'",
511 [],
512 |row| row.get(0),
513 )?;
514
515 if count == 0 {
516 self.conn
518 .execute("ALTER TABLE scraps ADD COLUMN task_index INTEGER", [])?;
519
520 self.conn.execute_batch(
522 r#"
523 WITH numbered_scraps AS (
524 SELECT id, task_id,
525 ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at) as idx
526 FROM scraps
527 )
528 UPDATE scraps
529 SET task_index = (
530 SELECT idx FROM numbered_scraps WHERE numbered_scraps.id = scraps.id
531 )
532 "#,
533 )?;
534
535 self.conn.execute(
537 "CREATE UNIQUE INDEX idx_scraps_task_index ON scraps(task_id, task_index)",
538 [],
539 )?;
540 }
541
542 self.conn.execute(
544 "CREATE INDEX IF NOT EXISTS idx_repo_links_worktree_id ON repo_links(worktree_id)",
545 [],
546 )?;
547
548 let count: i64 = self.conn.query_row(
550 "SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name='alias'",
551 [],
552 |row| row.get(0),
553 )?;
554
555 if count == 0 {
556 self.conn
558 .execute("ALTER TABLE tasks ADD COLUMN alias TEXT", [])?;
559 }
560
561 self.conn.execute(
563 "CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_alias ON tasks(alias)",
564 [],
565 )?;
566
567 let count: i64 = self.conn.query_row(
569 "SELECT COUNT(*) FROM pragma_table_info('links') WHERE name='task_index'",
570 [],
571 |row| row.get(0),
572 )?;
573
574 if count == 0 {
575 self.conn
577 .execute("ALTER TABLE links ADD COLUMN task_index INTEGER", [])?;
578
579 self.conn.execute_batch(
581 r#"
582 WITH numbered_links AS (
583 SELECT id, task_id,
584 ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at) as idx
585 FROM links
586 )
587 UPDATE links
588 SET task_index = (
589 SELECT idx FROM numbered_links WHERE numbered_links.id = links.id
590 )
591 "#,
592 )?;
593
594 self.conn.execute(
596 "CREATE UNIQUE INDEX idx_links_task_index ON links(task_id, task_index)",
597 [],
598 )?;
599 }
600
601 let count: i64 = self.conn.query_row(
603 "SELECT COUNT(*) FROM pragma_table_info('task_repos') WHERE name='task_index'",
604 [],
605 |row| row.get(0),
606 )?;
607
608 if count == 0 {
609 self.conn
611 .execute("ALTER TABLE task_repos ADD COLUMN task_index INTEGER", [])?;
612
613 self.conn.execute_batch(
615 r#"
616 WITH numbered_repos AS (
617 SELECT id, task_id,
618 ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at) as idx
619 FROM task_repos
620 )
621 UPDATE task_repos
622 SET task_index = (
623 SELECT idx FROM numbered_repos WHERE numbered_repos.id = task_repos.id
624 )
625 "#,
626 )?;
627
628 self.conn.execute(
630 "CREATE UNIQUE INDEX IF NOT EXISTS idx_task_repos_task_index ON task_repos(task_id, task_index)",
631 [],
632 )?;
633 }
634
635 let count: i64 = self.conn.query_row(
637 "SELECT COUNT(*) FROM pragma_table_info('scraps') WHERE name='active_todo_id'",
638 [],
639 |row| row.get(0),
640 )?;
641
642 if count == 0 {
643 self.conn
646 .execute("ALTER TABLE scraps ADD COLUMN active_todo_id INTEGER", [])?;
647
648 self.conn.execute_batch(
653 r#"
654 UPDATE scraps
655 SET active_todo_id = (
656 SELECT task_index
657 FROM todos
658 WHERE todos.task_id = scraps.task_id
659 AND (
660 todos.status = 'pending'
661 OR todos.completed_at IS NULL
662 OR todos.completed_at > scraps.created_at
663 )
664 ORDER BY todos.task_index ASC
665 LIMIT 1
666 )
667 "#,
668 )?;
669 }
670
671 self.conn.execute(
673 "CREATE INDEX IF NOT EXISTS idx_scraps_active_todo_id ON scraps(active_todo_id)",
674 [],
675 )?;
676
677 let count: i64 = self.conn.query_row(
679 "SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name='is_today_task'",
680 [],
681 |row| row.get(0),
682 )?;
683
684 if count == 0 {
685 self.conn.execute(
686 "ALTER TABLE tasks ADD COLUMN is_today_task INTEGER DEFAULT 0",
687 [],
688 )?;
689 }
690
691 self.conn.execute(
693 "CREATE INDEX IF NOT EXISTS idx_tasks_is_today_task ON tasks(is_today_task)",
694 [],
695 )?;
696
697 self.migrate_status_check_constraints()?;
698
699 Ok(())
700 }
701
702 pub fn get_connection(&self) -> &Connection {
704 &self.conn
705 }
706
707 pub fn with_transaction<T, F>(&self, f: F) -> Result<T>
732 where
733 F: FnOnce() -> Result<T>,
734 {
735 self.conn.execute("BEGIN IMMEDIATE", [])?;
736 match f() {
737 Ok(value) => {
738 self.conn.execute("COMMIT", [])?;
739 Ok(value)
740 }
741 Err(e) => {
742 let _ = self.conn.execute("ROLLBACK", []);
744 Err(e)
745 }
746 }
747 }
748
749 pub fn get_app_state(&self, key: &str) -> Result<Option<String>> {
750 let mut stmt = self
751 .conn
752 .prepare("SELECT value FROM app_state WHERE key = ?1")?;
753 let result = stmt.query_row(params![key], |row| row.get(0)).optional()?;
754 Ok(result)
755 }
756
757 pub fn set_app_state(&self, key: &str, value: &str) -> Result<()> {
758 self.conn.execute(
759 "INSERT OR REPLACE INTO app_state (key, value) VALUES (?1, ?2)",
760 params![key, value],
761 )?;
762 Ok(())
763 }
764
765 pub fn get_vcs_mode(&self) -> Result<VcsMode> {
767 match self.get_app_state(VcsMode::KEY)? {
768 Some(value) => value.parse().map_err(|err: String| {
769 crate::utils::TrackError::Other(format!("Invalid vcs_mode in database: {err}"))
770 }),
771 None => Ok(VcsMode::default()),
772 }
773 }
774
775 pub fn set_vcs_mode(&self, mode: VcsMode) -> Result<()> {
777 self.set_app_state(VcsMode::KEY, mode.as_str())
778 }
779
780 pub fn get_current_task_id(&self) -> Result<Option<i64>> {
786 match self.get_app_state("current_task_id")? {
787 Some(id_str) => Ok(Some(id_str.parse().map_err(|_| {
788 crate::utils::TrackError::Other("Invalid task ID in app_state".to_string())
789 })?)),
790 None => Ok(None),
791 }
792 }
793
794 pub fn set_current_task_id(&self, task_id: i64) -> Result<()> {
800 self.set_app_state("current_task_id", &task_id.to_string())
801 }
802
803 pub fn clear_current_task_id(&self) -> Result<()> {
805 self.conn
806 .execute("DELETE FROM app_state WHERE key = 'current_task_id'", [])?;
807 Ok(())
808 }
809
810 pub fn increment_rev(&self, section: &str) -> Result<i64> {
816 let key = format!("rev:{}", section);
817 let current: i64 = self
818 .get_app_state(&key)?
819 .and_then(|s| s.parse().ok())
820 .unwrap_or(0);
821 let new_rev = current + 1;
822 self.set_app_state(&key, &new_rev.to_string())?;
823 Ok(new_rev)
824 }
825
826 pub fn get_rev(&self, section: &str) -> Result<i64> {
832 let key = format!("rev:{}", section);
833 Ok(self
834 .get_app_state(&key)?
835 .and_then(|s| s.parse().ok())
836 .unwrap_or(0))
837 }
838
839 pub fn get_all_revs(&self) -> Result<SectionRevs> {
841 Ok(SectionRevs {
842 task: self.get_rev("task")?,
843 todos: self.get_rev("todos")?,
844 scraps: self.get_rev("scraps")?,
845 links: self.get_rev("links")?,
846 repos: self.get_rev("repos")?,
847 worktrees: self.get_rev("worktrees")?,
848 })
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855
856 #[test]
857 fn test_new_in_memory() {
858 let db = Database::new_in_memory().unwrap();
859 let result: i64 = db
861 .get_connection()
862 .query_row("SELECT 1", [], |row| row.get(0))
863 .unwrap();
864 assert_eq!(result, 1);
865 }
866
867 #[test]
868 fn test_wal_mode_enabled() {
869 let db = Database::new_in_memory().unwrap();
870 let mode: String = db
874 .get_connection()
875 .query_row("PRAGMA journal_mode", [], |row| row.get(0))
876 .unwrap();
877 assert!(mode == "memory" || mode == "wal");
879 }
880
881 #[test]
882 fn test_busy_timeout_configured() {
883 let db = Database::new_in_memory().unwrap();
884 let timeout: i64 = db
885 .get_connection()
886 .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
887 .unwrap();
888 assert_eq!(timeout, 5000);
890 }
891
892 #[test]
893 fn test_app_state_get_set() {
894 let db = Database::new_in_memory().unwrap();
895
896 let value = db.get_app_state("test_key").unwrap();
898 assert!(value.is_none());
899
900 db.set_app_state("test_key", "test_value").unwrap();
902
903 let value = db.get_app_state("test_key").unwrap();
905 assert_eq!(value, Some("test_value".to_string()));
906
907 db.set_app_state("test_key", "new_value").unwrap();
909 let value = db.get_app_state("test_key").unwrap();
910 assert_eq!(value, Some("new_value".to_string()));
911 }
912
913 #[test]
914 fn test_vcs_mode_defaults_to_jj() {
915 let db = Database::new_in_memory().unwrap();
916 assert_eq!(db.get_vcs_mode().unwrap(), VcsMode::Jj);
917 }
918
919 #[test]
920 fn test_vcs_mode_round_trip() {
921 let db = Database::new_in_memory().unwrap();
922 db.set_vcs_mode(VcsMode::Git).unwrap();
923 assert_eq!(db.get_vcs_mode().unwrap(), VcsMode::Git);
924 db.set_vcs_mode(VcsMode::Jj).unwrap();
925 assert_eq!(db.get_vcs_mode().unwrap(), VcsMode::Jj);
926 }
927
928 #[test]
929 fn test_with_transaction_commit() {
930 let db = Database::new_in_memory().unwrap();
931
932 let result = db.with_transaction(|| {
934 db.set_app_state("tx_key", "tx_value")?;
935 Ok("success")
936 });
937
938 assert!(result.is_ok());
939 assert_eq!(result.unwrap(), "success");
940
941 let value = db.get_app_state("tx_key").unwrap();
943 assert_eq!(value, Some("tx_value".to_string()));
944 }
945
946 #[test]
947 fn test_with_transaction_rollback() {
948 let db = Database::new_in_memory().unwrap();
949
950 db.set_app_state("rollback_key", "initial").unwrap();
952
953 let result: crate::utils::Result<()> = db.with_transaction(|| {
955 db.set_app_state("rollback_key", "changed")?;
956 Err(crate::utils::TrackError::Other("forced error".to_string()))
957 });
958
959 assert!(result.is_err());
960
961 let value = db.get_app_state("rollback_key").unwrap();
963 assert_eq!(value, Some("initial".to_string()));
964 }
965
966 #[test]
967 fn test_with_transaction_returns_value() {
968 let db = Database::new_in_memory().unwrap();
969
970 let result = db.with_transaction(|| {
972 db.set_app_state("key", "value")?;
973 Ok(42i64)
974 });
975
976 assert!(result.is_ok());
977 assert_eq!(result.unwrap(), 42);
978 }
979
980 #[test]
981 fn test_current_task_id() {
982 let db = Database::new_in_memory().unwrap();
983
984 let task_id = db.get_current_task_id().unwrap();
986 assert!(task_id.is_none());
987
988 db.set_current_task_id(42).unwrap();
990
991 let task_id = db.get_current_task_id().unwrap();
993 assert_eq!(task_id, Some(42));
994
995 db.clear_current_task_id().unwrap();
997 let task_id = db.get_current_task_id().unwrap();
998 assert!(task_id.is_none());
999 }
1000
1001 #[test]
1002 fn test_schema_initialization() {
1003 let db = Database::new_in_memory().unwrap();
1004 let conn = db.get_connection();
1005
1006 let tables = vec![
1008 "app_state",
1009 "tasks",
1010 "todos",
1011 "links",
1012 "scraps",
1013 "worktrees",
1014 "repo_links",
1015 ];
1016 for table in tables {
1017 let result: i64 = conn
1018 .query_row(
1019 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
1020 [table],
1021 |row| row.get(0),
1022 )
1023 .unwrap();
1024 assert_eq!(result, 1, "Table {} should exist", table);
1025 }
1026 }
1027
1028 #[test]
1029 fn test_increment_rev() {
1030 let db = Database::new_in_memory().unwrap();
1031
1032 let rev = db.get_rev("todos").unwrap();
1034 assert_eq!(rev, 0);
1035
1036 let new_rev = db.increment_rev("todos").unwrap();
1038 assert_eq!(new_rev, 1);
1039
1040 let rev = db.get_rev("todos").unwrap();
1042 assert_eq!(rev, 1);
1043
1044 let new_rev = db.increment_rev("todos").unwrap();
1046 assert_eq!(new_rev, 2);
1047 }
1048
1049 #[test]
1050 fn test_get_rev_default() {
1051 let db = Database::new_in_memory().unwrap();
1052
1053 let rev = db.get_rev("nonexistent").unwrap();
1055 assert_eq!(rev, 0);
1056 }
1057
1058 #[test]
1059 fn test_increment_rev_different_sections() {
1060 let db = Database::new_in_memory().unwrap();
1061
1062 db.increment_rev("todos").unwrap();
1064 db.increment_rev("todos").unwrap();
1065 db.increment_rev("scraps").unwrap();
1066 db.increment_rev("links").unwrap();
1067 db.increment_rev("links").unwrap();
1068 db.increment_rev("links").unwrap();
1069
1070 assert_eq!(db.get_rev("todos").unwrap(), 2);
1072 assert_eq!(db.get_rev("scraps").unwrap(), 1);
1073 assert_eq!(db.get_rev("links").unwrap(), 3);
1074 assert_eq!(db.get_rev("repos").unwrap(), 0);
1075 }
1076
1077 #[test]
1078 fn test_get_all_revs() {
1079 let db = Database::new_in_memory().unwrap();
1080
1081 let revs = db.get_all_revs().unwrap();
1083 assert_eq!(revs.task, 0);
1084 assert_eq!(revs.todos, 0);
1085 assert_eq!(revs.scraps, 0);
1086 assert_eq!(revs.links, 0);
1087 assert_eq!(revs.repos, 0);
1088 assert_eq!(revs.worktrees, 0);
1089
1090 db.increment_rev("task").unwrap();
1092 db.increment_rev("todos").unwrap();
1093 db.increment_rev("todos").unwrap();
1094 db.increment_rev("worktrees").unwrap();
1095
1096 let revs = db.get_all_revs().unwrap();
1098 assert_eq!(revs.task, 1);
1099 assert_eq!(revs.todos, 2);
1100 assert_eq!(revs.scraps, 0);
1101 assert_eq!(revs.links, 0);
1102 assert_eq!(revs.repos, 0);
1103 assert_eq!(revs.worktrees, 1);
1104 }
1105
1106 #[test]
1107 fn test_section_revs_equality() {
1108 let db = Database::new_in_memory().unwrap();
1109
1110 let revs1 = db.get_all_revs().unwrap();
1111 let revs2 = db.get_all_revs().unwrap();
1112 assert_eq!(revs1, revs2);
1113
1114 db.increment_rev("todos").unwrap();
1115 let revs3 = db.get_all_revs().unwrap();
1116 assert_ne!(revs1, revs3);
1117 }
1118
1119 #[test]
1120 fn test_status_check_constraints_enforced() {
1121 use crate::models::{TaskStatus, TodoStatus};
1122
1123 let db = Database::new_in_memory().unwrap();
1124 let conn = db.get_connection();
1125
1126 let tasks_sql: String = conn
1127 .query_row(
1128 "SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'",
1129 [],
1130 |row| row.get(0),
1131 )
1132 .unwrap();
1133 assert!(tasks_sql.contains("CHECK"));
1134
1135 conn.execute(
1136 "INSERT INTO tasks (name, status, created_at) VALUES ('Bad', ?1, datetime('now'))",
1137 rusqlite::params![TaskStatus::ACTIVE],
1138 )
1139 .unwrap();
1140
1141 let err = conn
1142 .execute(
1143 "INSERT INTO tasks (name, status, created_at) VALUES ('Bad', 'invalid', datetime('now'))",
1144 [],
1145 )
1146 .unwrap_err();
1147 assert!(err.to_string().contains("CHECK constraint failed"));
1148
1149 conn.execute(
1150 "INSERT INTO todos (task_id, task_index, content, status, worktree_requested, created_at) VALUES (1, 1, 'Ok', ?1, 0, datetime('now'))",
1151 rusqlite::params![TodoStatus::PENDING],
1152 )
1153 .unwrap();
1154
1155 let err = conn
1156 .execute(
1157 "INSERT INTO todos (task_id, task_index, content, status, worktree_requested, created_at) VALUES (1, 2, 'Bad', 'reopened', 0, datetime('now'))",
1158 [],
1159 )
1160 .unwrap_err();
1161 assert!(err.to_string().contains("CHECK constraint failed"));
1162 }
1163}