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 migrate;
15pub mod row_mapping;
16
17/// Revision numbers for each section, used for change detection.
18///
19/// Each section has a revision number that is incremented whenever
20/// data in that section is modified. This allows efficient change
21/// detection without complex queries.
22#[derive(Clone, Debug, PartialEq, Default)]
23pub struct SectionRevs {
24    /// Task metadata (description, ticket, alias) revision
25    pub task: i64,
26    /// TODOs section revision
27    pub todos: i64,
28    /// Scraps section revision
29    pub scraps: i64,
30    /// Links section revision
31    pub links: i64,
32    /// Repositories section revision
33    pub repos: i64,
34    /// Worktrees section revision
35    pub worktrees: i64,
36}
37
38/// Database connection and management.
39///
40/// Handles SQLite database operations including schema initialization,
41/// migrations, and application state persistence.
42pub struct Database {
43    conn: Connection,
44}
45
46impl Database {
47    /// Creates a new database instance with the default file location.
48    ///
49    /// The database file is stored in the platform-specific data directory.
50    /// The schema is automatically initialized if the database is new.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if:
55    /// - The data directory cannot be determined
56    /// - The database file cannot be created or opened
57    /// - Schema initialization fails
58    pub fn new() -> Result<Self> {
59        let db_path = Self::get_db_path()?;
60
61        // Create parent directory if it doesn't exist
62        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    /// Creates a new in-memory database (primarily for testing).
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if schema initialization fails.
78    #[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    /// Configures the SQLite connection for optimal concurrent access.
88    ///
89    /// Enables WAL (Write-Ahead Logging) mode for better read/write concurrency
90    /// and sets a busy timeout to automatically retry on lock contention.
91    fn configure_connection(conn: &Connection) -> Result<()> {
92        // Enable WAL mode for better concurrent access
93        // WAL allows readers to proceed while a writer is active
94        conn.pragma_update(None, "journal_mode", "WAL")?;
95
96        // Set busy timeout to 5 seconds
97        // SQLite will automatically retry if the database is locked
98        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    /// Returns a reference to the underlying SQLite connection.
210    pub fn get_connection(&self) -> &Connection {
211        &self.conn
212    }
213
214    /// Execute operations within an IMMEDIATE transaction.
215    ///
216    /// This method wraps the provided closure in a SQLite transaction that:
217    /// - Uses `BEGIN IMMEDIATE` to acquire the write lock upfront
218    /// - Automatically commits on success
219    /// - Automatically rolls back on error
220    ///
221    /// Using IMMEDIATE mode prevents race conditions in read-modify-write
222    /// sequences by acquiring the write lock before reading, ensuring no
223    /// other process can modify the data between the read and write steps.
224    ///
225    /// # Arguments
226    ///
227    /// * `f` - A closure that performs database operations and returns a Result
228    ///
229    /// # Example
230    ///
231    /// ```ignore
232    /// db.with_transaction(|| {
233    ///     let next_index = get_next_index()?;
234    ///     insert_with_index(next_index)?;
235    ///     Ok(())
236    /// })?;
237    /// ```
238    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                // Attempt rollback, but ignore errors (connection may be in bad state)
250                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    /// Returns the configured VCS backend (`jj` by default).
273    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    /// Persists the VCS backend preference.
283    pub fn set_vcs_mode(&self, mode: VcsMode) -> Result<()> {
284        self.set_app_state(VcsMode::KEY, mode.as_str())
285    }
286
287    /// Gets the ID of the current active task.
288    ///
289    /// # Returns
290    ///
291    /// `Some(task_id)` if a task is currently active, `None` otherwise.
292    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    /// Sets the current active task.
305    ///
306    /// # Arguments
307    ///
308    /// * `task_id` - The ID of the task to set as current
309    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    /// Clears the current active task.
314    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    /// Increments the revision number for a section and returns the new value.
321    ///
322    /// # Arguments
323    ///
324    /// * `section` - The section name (e.g., "todos", "scraps", "links", "repos", "worktrees", "task")
325    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    /// Gets the current revision number for a section.
337    ///
338    /// # Arguments
339    ///
340    /// * `section` - The section name
341    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    /// Gets all section revision numbers at once.
350    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        // Verify connection is valid by querying
384        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        // Note: In-memory databases use "memory" journal mode, not "wal"
395        // This test verifies the pragma is set without error
396        // For file-based databases, WAL mode will be properly enabled
397        let mode: String = db
398            .get_connection()
399            .query_row("PRAGMA journal_mode", [], |row| row.get(0))
400            .unwrap();
401        // In-memory databases return "memory" as journal mode
402        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        // Should be 5000ms (5 seconds)
413        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        // Initially should be None
421        let value = db.get_app_state("test_key").unwrap();
422        assert!(value.is_none());
423
424        // Set a value
425        db.set_app_state("test_key", "test_value").unwrap();
426
427        // Get the value back
428        let value = db.get_app_state("test_key").unwrap();
429        assert_eq!(value, Some("test_value".to_string()));
430
431        // Update the value
432        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        // Transaction should commit on success
457        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        // Value should persist after commit
466        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        // Set initial value
475        db.set_app_state("rollback_key", "initial").unwrap();
476
477        // Transaction should rollback on error
478        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        // Value should be rolled back to initial
486        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        // Transaction should return the value from closure
495        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        // Initially should be None
509        let task_id = db.get_current_task_id().unwrap();
510        assert!(task_id.is_none());
511
512        // Set a task ID
513        db.set_current_task_id(42).unwrap();
514
515        // Get the task ID back
516        let task_id = db.get_current_task_id().unwrap();
517        assert_eq!(task_id, Some(42));
518
519        // Clear the task ID
520        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        // Verify all tables exist
531        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        // Initially should be 0
557        let rev = db.get_rev("todos").unwrap();
558        assert_eq!(rev, 0);
559
560        // Increment should return 1
561        let new_rev = db.increment_rev("todos").unwrap();
562        assert_eq!(new_rev, 1);
563
564        // Get should return 1
565        let rev = db.get_rev("todos").unwrap();
566        assert_eq!(rev, 1);
567
568        // Increment again should return 2
569        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        // Non-existent sections should return 0
578        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        // Increment different sections
587        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        // Verify each section has independent rev
595        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        // Initially all should be 0
606        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        // Increment some sections
615        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        // Verify get_all_revs returns correct values
621        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}