Skip to main content

track/db/
mod.rs

1//! Database initialization and management for the track CLI.
2//!
3//! This module handles SQLite database creation, schema initialization, migrations,
4//! and application state management. The database stores all task, TODO, link, scrap,
5//! and Git repository information.
6
7use 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/// Revision numbers for each section, used for change detection.
17///
18/// Each section has a revision number that is incremented whenever
19/// data in that section is modified. This allows efficient change
20/// detection without complex queries.
21#[derive(Clone, Debug, PartialEq, Default)]
22pub struct SectionRevs {
23    /// Task metadata (description, ticket, alias) revision
24    pub task: i64,
25    /// TODOs section revision
26    pub todos: i64,
27    /// Scraps section revision
28    pub scraps: i64,
29    /// Links section revision
30    pub links: i64,
31    /// Repositories section revision
32    pub repos: i64,
33    /// Worktrees section revision
34    pub worktrees: i64,
35}
36
37/// Database connection and management.
38///
39/// Handles SQLite database operations including schema initialization,
40/// migrations, and application state persistence.
41pub struct Database {
42    conn: Connection,
43}
44
45impl Database {
46    /// Creates a new database instance with the default file location.
47    ///
48    /// The database file is stored in the platform-specific data directory.
49    /// The schema is automatically initialized if the database is new.
50    ///
51    /// # Errors
52    ///
53    /// Returns an error if:
54    /// - The data directory cannot be determined
55    /// - The database file cannot be created or opened
56    /// - Schema initialization fails
57    pub fn new() -> Result<Self> {
58        let db_path = Self::get_db_path()?;
59
60        // Create parent directory if it doesn't exist
61        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    /// Creates a new in-memory database (primarily for testing).
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if schema initialization fails.
77    #[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    /// Configures the SQLite connection for optimal concurrent access.
87    ///
88    /// Enables WAL (Write-Ahead Logging) mode for better read/write concurrency
89    /// and sets a busy timeout to automatically retry on lock contention.
90    fn configure_connection(conn: &Connection) -> Result<()> {
91        // Enable WAL mode for better concurrent access
92        // WAL allows readers to proceed while a writer is active
93        conn.pragma_update(None, "journal_mode", "WAL")?;
94
95        // Set busy timeout to 5 seconds
96        // SQLite will automatically retry if the database is locked
97        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    /// Adds CHECK constraints on task/todo status columns for existing databases.
209    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        // Migrate git_items table to worktrees (for existing databases)
315        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            // Drop old indexes before renaming table
323            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            // Rename git_items table to worktrees
329            self.conn
330                .execute("ALTER TABLE git_items RENAME TO worktrees", [])?;
331
332            // Create new indexes with correct names
333            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        // Migrate repo_links.git_item_id to worktree_id (for existing databases)
344        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            // SQLite doesn't support renaming columns directly in older versions
352            // We need to recreate the table
353            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        // Check for todo_id column in worktrees
382        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        // Check for is_base column in worktrees
397        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        // Check for description column in tasks
411        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        // Check for task_index column in todos
423        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            // Add task_index column
431            self.conn
432                .execute("ALTER TABLE todos ADD COLUMN task_index INTEGER", [])?;
433
434            // Populate task_index for existing TODOs based on creation order
435            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            // Create unique index on (task_id, task_index)
450            self.conn.execute(
451                "CREATE UNIQUE INDEX idx_todos_task_index ON todos(task_id, task_index)",
452                [],
453            )?;
454        }
455
456        // Check for worktree_requested column in todos
457        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        // Check for base_branch column in task_repos
471        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        // Check for base_commit_hash column in task_repos
483        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        // Check for completed_at column in todos
497        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        // Check for task_index column in scraps
509        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            // Add task_index column
517            self.conn
518                .execute("ALTER TABLE scraps ADD COLUMN task_index INTEGER", [])?;
519
520            // Populate task_index for existing scraps based on creation order
521            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            // Create unique index on (task_id, task_index)
536            self.conn.execute(
537                "CREATE UNIQUE INDEX idx_scraps_task_index ON scraps(task_id, task_index)",
538                [],
539            )?;
540        }
541
542        // Ensure repo_links index exists (for both new and migrated databases)
543        self.conn.execute(
544            "CREATE INDEX IF NOT EXISTS idx_repo_links_worktree_id ON repo_links(worktree_id)",
545            [],
546        )?;
547
548        // Check for alias column in tasks
549        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            // Add column without UNIQUE constraint first (SQLite limitation)
557            self.conn
558                .execute("ALTER TABLE tasks ADD COLUMN alias TEXT", [])?;
559        }
560
561        // Create UNIQUE index for alias column
562        self.conn.execute(
563            "CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_alias ON tasks(alias)",
564            [],
565        )?;
566
567        // Check for task_index column in links
568        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            // Add task_index column
576            self.conn
577                .execute("ALTER TABLE links ADD COLUMN task_index INTEGER", [])?;
578
579            // Populate task_index for existing links based on creation order
580            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            // Create unique index on (task_id, task_index)
595            self.conn.execute(
596                "CREATE UNIQUE INDEX idx_links_task_index ON links(task_id, task_index)",
597                [],
598            )?;
599        }
600
601        // Check for task_index column in task_repos
602        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            // Add task_index column
610            self.conn
611                .execute("ALTER TABLE task_repos ADD COLUMN task_index INTEGER", [])?;
612
613            // Populate task_index for existing repos based on creation order
614            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            // Create unique index on (task_id, task_index)
629            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        // Check for active_todo_id column in scraps
636        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            // Add active_todo_id column to track which todo was active when scrap was created
644            // Active todo = the oldest pending todo at the time of scrap creation
645            self.conn
646                .execute("ALTER TABLE scraps ADD COLUMN active_todo_id INTEGER", [])?;
647
648            // For existing scraps, populate active_todo_id based on the oldest pending todo
649            // at the time of scrap creation. We need to find the first todo that was either:
650            // 1. Still pending at scrap creation time, OR
651            // 2. Completed after scrap creation time
652            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        // Create index on active_todo_id for efficient lookups
672        self.conn.execute(
673            "CREATE INDEX IF NOT EXISTS idx_scraps_active_todo_id ON scraps(active_todo_id)",
674            [],
675        )?;
676
677        // Check for is_today_task column in tasks
678        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        // Create index on is_today_task for efficient lookups
692        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    /// Returns a reference to the underlying SQLite connection.
703    pub fn get_connection(&self) -> &Connection {
704        &self.conn
705    }
706
707    /// Execute operations within an IMMEDIATE transaction.
708    ///
709    /// This method wraps the provided closure in a SQLite transaction that:
710    /// - Uses `BEGIN IMMEDIATE` to acquire the write lock upfront
711    /// - Automatically commits on success
712    /// - Automatically rolls back on error
713    ///
714    /// Using IMMEDIATE mode prevents race conditions in read-modify-write
715    /// sequences by acquiring the write lock before reading, ensuring no
716    /// other process can modify the data between the read and write steps.
717    ///
718    /// # Arguments
719    ///
720    /// * `f` - A closure that performs database operations and returns a Result
721    ///
722    /// # Example
723    ///
724    /// ```ignore
725    /// db.with_transaction(|| {
726    ///     let next_index = get_next_index()?;
727    ///     insert_with_index(next_index)?;
728    ///     Ok(())
729    /// })?;
730    /// ```
731    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                // Attempt rollback, but ignore errors (connection may be in bad state)
743                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    /// Returns the configured VCS backend (`jj` by default).
766    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    /// Persists the VCS backend preference.
776    pub fn set_vcs_mode(&self, mode: VcsMode) -> Result<()> {
777        self.set_app_state(VcsMode::KEY, mode.as_str())
778    }
779
780    /// Gets the ID of the current active task.
781    ///
782    /// # Returns
783    ///
784    /// `Some(task_id)` if a task is currently active, `None` otherwise.
785    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    /// Sets the current active task.
795    ///
796    /// # Arguments
797    ///
798    /// * `task_id` - The ID of the task to set as current
799    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    /// Clears the current active task.
804    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    /// Increments the revision number for a section and returns the new value.
811    ///
812    /// # Arguments
813    ///
814    /// * `section` - The section name (e.g., "todos", "scraps", "links", "repos", "worktrees", "task")
815    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    /// Gets the current revision number for a section.
827    ///
828    /// # Arguments
829    ///
830    /// * `section` - The section name
831    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    /// Gets all section revision numbers at once.
840    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        // Verify connection is valid by querying
860        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        // Note: In-memory databases use "memory" journal mode, not "wal"
871        // This test verifies the pragma is set without error
872        // For file-based databases, WAL mode will be properly enabled
873        let mode: String = db
874            .get_connection()
875            .query_row("PRAGMA journal_mode", [], |row| row.get(0))
876            .unwrap();
877        // In-memory databases return "memory" as journal mode
878        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        // Should be 5000ms (5 seconds)
889        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        // Initially should be None
897        let value = db.get_app_state("test_key").unwrap();
898        assert!(value.is_none());
899
900        // Set a value
901        db.set_app_state("test_key", "test_value").unwrap();
902
903        // Get the value back
904        let value = db.get_app_state("test_key").unwrap();
905        assert_eq!(value, Some("test_value".to_string()));
906
907        // Update the value
908        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        // Transaction should commit on success
933        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        // Value should persist after commit
942        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        // Set initial value
951        db.set_app_state("rollback_key", "initial").unwrap();
952
953        // Transaction should rollback on error
954        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        // Value should be rolled back to initial
962        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        // Transaction should return the value from closure
971        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        // Initially should be None
985        let task_id = db.get_current_task_id().unwrap();
986        assert!(task_id.is_none());
987
988        // Set a task ID
989        db.set_current_task_id(42).unwrap();
990
991        // Get the task ID back
992        let task_id = db.get_current_task_id().unwrap();
993        assert_eq!(task_id, Some(42));
994
995        // Clear the task ID
996        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        // Verify all tables exist
1007        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        // Initially should be 0
1033        let rev = db.get_rev("todos").unwrap();
1034        assert_eq!(rev, 0);
1035
1036        // Increment should return 1
1037        let new_rev = db.increment_rev("todos").unwrap();
1038        assert_eq!(new_rev, 1);
1039
1040        // Get should return 1
1041        let rev = db.get_rev("todos").unwrap();
1042        assert_eq!(rev, 1);
1043
1044        // Increment again should return 2
1045        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        // Non-existent sections should return 0
1054        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        // Increment different sections
1063        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        // Verify each section has independent rev
1071        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        // Initially all should be 0
1082        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        // Increment some sections
1091        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        // Verify get_all_revs returns correct values
1097        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}