use crate::models::{TaskStatus, TodoStatus, VcsMode};
use crate::utils::Result;
use directories::ProjectDirs;
use rusqlite::{params, Connection, OptionalExtension};
use std::path::PathBuf;
use std::time::Duration;
pub mod row_mapping;
#[derive(Clone, Debug, PartialEq, Default)]
pub struct SectionRevs {
pub task: i64,
pub todos: i64,
pub scraps: i64,
pub links: i64,
pub repos: i64,
pub worktrees: i64,
}
pub struct Database {
conn: Connection,
}
impl Database {
pub fn new() -> Result<Self> {
let db_path = Self::get_db_path()?;
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(&db_path)?;
Self::configure_connection(&conn)?;
let db = Database { conn };
db.initialize_schema()?;
Ok(db)
}
#[allow(dead_code)]
pub fn new_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory()?;
Self::configure_connection(&conn)?;
let db = Database { conn };
db.initialize_schema()?;
Ok(db)
}
fn configure_connection(conn: &Connection) -> Result<()> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.busy_timeout(Duration::from_secs(5))?;
Ok(())
}
fn get_db_path() -> Result<PathBuf> {
let proj_dirs = ProjectDirs::from("", "", "track").ok_or_else(|| {
crate::utils::TrackError::Other("Failed to determine data directory".to_string())
})?;
Ok(proj_dirs.data_dir().join("track.db"))
}
fn initialize_schema(&self) -> Result<()> {
let schema = format!(
r#"
CREATE TABLE IF NOT EXISTS app_state (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '{task_active}' CHECK (status IN ('{task_active}', '{task_archived}')),
ticket_id TEXT UNIQUE,
ticket_url TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '{todo_pending}' CHECK (status IN ('{todo_pending}', '{todo_done}', '{todo_cancelled}')),
worktree_requested INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
url TEXT NOT NULL,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS scraps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
active_todo_id INTEGER,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS worktrees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
path TEXT NOT NULL,
branch TEXT NOT NULL,
base_repo TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
todo_id INTEGER,
is_base INTEGER DEFAULT 0,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS repo_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worktree_id INTEGER NOT NULL,
url TEXT NOT NULL,
kind TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_repos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
repo_path TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
UNIQUE(task_id, repo_path)
);
CREATE INDEX IF NOT EXISTS idx_todos_task_id ON todos(task_id);
CREATE INDEX IF NOT EXISTS idx_links_task_id ON links(task_id);
CREATE INDEX IF NOT EXISTS idx_scraps_task_id ON scraps(task_id);
CREATE INDEX IF NOT EXISTS idx_worktrees_task_id ON worktrees(task_id);
CREATE INDEX IF NOT EXISTS idx_task_repos_task_id ON task_repos(task_id);
"#,
task_active = TaskStatus::ACTIVE,
task_archived = TaskStatus::ARCHIVED,
todo_pending = TodoStatus::PENDING,
todo_done = TodoStatus::DONE,
todo_cancelled = TodoStatus::CANCELLED,
);
self.conn.execute_batch(&schema)?;
self.migrate_schema()?;
Ok(())
}
fn migrate_status_check_constraints(&self) -> Result<()> {
let tasks_sql: String = self
.conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'",
[],
|row| row.get(0),
)
.unwrap_or_default();
if tasks_sql.contains("CHECK") {
return Ok(());
}
let invalid_tasks: i64 = self.conn.query_row(
&format!(
"SELECT COUNT(*) FROM tasks WHERE status NOT IN ('{}', '{}')",
TaskStatus::ACTIVE,
TaskStatus::ARCHIVED
),
[],
|row| row.get(0),
)?;
if invalid_tasks > 0 {
return Err(crate::utils::TrackError::Other(format!(
"Cannot migrate status constraints: {invalid_tasks} tasks have invalid status values"
)));
}
let invalid_todos: i64 = self.conn.query_row(
&format!(
"SELECT COUNT(*) FROM todos WHERE status NOT IN ('{}', '{}', '{}')",
TodoStatus::PENDING,
TodoStatus::DONE,
TodoStatus::CANCELLED
),
[],
|row| row.get(0),
)?;
if invalid_todos > 0 {
return Err(crate::utils::TrackError::Other(format!(
"Cannot migrate status constraints: {invalid_todos} todos have invalid status values"
)));
}
let task_check = format!(
"CHECK (status IN ('{}', '{}'))",
TaskStatus::ACTIVE,
TaskStatus::ARCHIVED
);
let todo_check = format!(
"CHECK (status IN ('{}', '{}', '{}'))",
TodoStatus::PENDING,
TodoStatus::DONE,
TodoStatus::CANCELLED
);
self.conn.execute_batch(&format!(
r#"
CREATE TABLE tasks_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT '{task_active}' {task_check},
ticket_id TEXT,
ticket_url TEXT,
alias TEXT,
is_today_task INTEGER DEFAULT 0,
created_at TEXT NOT NULL
);
INSERT INTO tasks_new (id, name, description, status, ticket_id, ticket_url, alias, is_today_task, created_at)
SELECT id, name, description, status, ticket_id, ticket_url, alias, is_today_task, created_at FROM tasks;
DROP TABLE tasks;
ALTER TABLE tasks_new RENAME TO tasks;
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_alias ON tasks(alias);
CREATE INDEX IF NOT EXISTS idx_tasks_is_today_task ON tasks(is_today_task);
CREATE TABLE todos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
task_index INTEGER,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '{todo_pending}' {todo_check},
worktree_requested INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
INSERT INTO todos_new (id, task_id, task_index, content, status, worktree_requested, created_at, completed_at)
SELECT id, task_id, task_index, content, status, worktree_requested, created_at, completed_at FROM todos;
DROP TABLE todos;
ALTER TABLE todos_new RENAME TO todos;
CREATE INDEX IF NOT EXISTS idx_todos_task_id ON todos(task_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_task_index ON todos(task_id, task_index);
"#,
task_active = TaskStatus::ACTIVE,
task_check = task_check,
todo_pending = TodoStatus::PENDING,
todo_check = todo_check,
))?;
Ok(())
}
fn migrate_schema(&self) -> Result<()> {
let git_items_exists: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='git_items'",
[],
|row| row.get(0),
)?;
if git_items_exists > 0 {
self.conn
.execute("DROP INDEX IF EXISTS idx_git_items_task_id", [])?;
self.conn
.execute("DROP INDEX IF EXISTS idx_git_items_todo_id", [])?;
self.conn
.execute("ALTER TABLE git_items RENAME TO worktrees", [])?;
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_worktrees_task_id ON worktrees(task_id)",
[],
)?;
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_worktrees_todo_id ON worktrees(todo_id)",
[],
)?;
}
let git_item_id_exists: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('repo_links') WHERE name='git_item_id'",
[],
|row| row.get(0),
)?;
if git_item_id_exists > 0 {
self.conn.execute_batch(
r#"
-- Create new repo_links table with worktree_id
CREATE TABLE repo_links_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worktree_id INTEGER NOT NULL,
url TEXT NOT NULL,
kind TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
);
-- Copy data from old table
INSERT INTO repo_links_new (id, worktree_id, url, kind, created_at)
SELECT id, git_item_id, url, kind, created_at FROM repo_links;
-- Drop old table
DROP TABLE repo_links;
-- Rename new table to repo_links
ALTER TABLE repo_links_new RENAME TO repo_links;
-- Recreate index
CREATE INDEX IF NOT EXISTS idx_repo_links_worktree_id ON repo_links(worktree_id);
"#,
)?;
}
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('worktrees') WHERE name='todo_id'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn.execute("ALTER TABLE worktrees ADD COLUMN todo_id INTEGER REFERENCES todos(id) ON DELETE SET NULL", [])?;
}
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_worktrees_todo_id ON worktrees(todo_id)",
[],
)?;
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('worktrees') WHERE name='is_base'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn.execute(
"ALTER TABLE worktrees ADD COLUMN is_base INTEGER DEFAULT 0",
[],
)?;
}
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name='description'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn
.execute("ALTER TABLE tasks ADD COLUMN description TEXT", [])?;
}
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('todos') WHERE name='task_index'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn
.execute("ALTER TABLE todos ADD COLUMN task_index INTEGER", [])?;
self.conn.execute_batch(
r#"
WITH numbered_todos AS (
SELECT id, task_id,
ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at) as idx
FROM todos
)
UPDATE todos
SET task_index = (
SELECT idx FROM numbered_todos WHERE numbered_todos.id = todos.id
)
"#,
)?;
self.conn.execute(
"CREATE UNIQUE INDEX idx_todos_task_index ON todos(task_id, task_index)",
[],
)?;
}
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('todos') WHERE name='worktree_requested'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn.execute(
"ALTER TABLE todos ADD COLUMN worktree_requested INTEGER NOT NULL DEFAULT 0",
[],
)?;
}
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('task_repos') WHERE name='base_branch'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn
.execute("ALTER TABLE task_repos ADD COLUMN base_branch TEXT", [])?;
}
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('task_repos') WHERE name='base_commit_hash'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn.execute(
"ALTER TABLE task_repos ADD COLUMN base_commit_hash TEXT",
[],
)?;
}
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('todos') WHERE name='completed_at'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn
.execute("ALTER TABLE todos ADD COLUMN completed_at TEXT", [])?;
}
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('scraps') WHERE name='task_index'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn
.execute("ALTER TABLE scraps ADD COLUMN task_index INTEGER", [])?;
self.conn.execute_batch(
r#"
WITH numbered_scraps AS (
SELECT id, task_id,
ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at) as idx
FROM scraps
)
UPDATE scraps
SET task_index = (
SELECT idx FROM numbered_scraps WHERE numbered_scraps.id = scraps.id
)
"#,
)?;
self.conn.execute(
"CREATE UNIQUE INDEX idx_scraps_task_index ON scraps(task_id, task_index)",
[],
)?;
}
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_repo_links_worktree_id ON repo_links(worktree_id)",
[],
)?;
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name='alias'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn
.execute("ALTER TABLE tasks ADD COLUMN alias TEXT", [])?;
}
self.conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_alias ON tasks(alias)",
[],
)?;
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('links') WHERE name='task_index'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn
.execute("ALTER TABLE links ADD COLUMN task_index INTEGER", [])?;
self.conn.execute_batch(
r#"
WITH numbered_links AS (
SELECT id, task_id,
ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at) as idx
FROM links
)
UPDATE links
SET task_index = (
SELECT idx FROM numbered_links WHERE numbered_links.id = links.id
)
"#,
)?;
self.conn.execute(
"CREATE UNIQUE INDEX idx_links_task_index ON links(task_id, task_index)",
[],
)?;
}
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('task_repos') WHERE name='task_index'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn
.execute("ALTER TABLE task_repos ADD COLUMN task_index INTEGER", [])?;
self.conn.execute_batch(
r#"
WITH numbered_repos AS (
SELECT id, task_id,
ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at) as idx
FROM task_repos
)
UPDATE task_repos
SET task_index = (
SELECT idx FROM numbered_repos WHERE numbered_repos.id = task_repos.id
)
"#,
)?;
self.conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_task_repos_task_index ON task_repos(task_id, task_index)",
[],
)?;
}
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('scraps') WHERE name='active_todo_id'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn
.execute("ALTER TABLE scraps ADD COLUMN active_todo_id INTEGER", [])?;
self.conn.execute_batch(
r#"
UPDATE scraps
SET active_todo_id = (
SELECT task_index
FROM todos
WHERE todos.task_id = scraps.task_id
AND (
todos.status = 'pending'
OR todos.completed_at IS NULL
OR todos.completed_at > scraps.created_at
)
ORDER BY todos.task_index ASC
LIMIT 1
)
"#,
)?;
}
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_scraps_active_todo_id ON scraps(active_todo_id)",
[],
)?;
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name='is_today_task'",
[],
|row| row.get(0),
)?;
if count == 0 {
self.conn.execute(
"ALTER TABLE tasks ADD COLUMN is_today_task INTEGER DEFAULT 0",
[],
)?;
}
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_is_today_task ON tasks(is_today_task)",
[],
)?;
self.migrate_status_check_constraints()?;
Ok(())
}
pub fn get_connection(&self) -> &Connection {
&self.conn
}
pub fn with_transaction<T, F>(&self, f: F) -> Result<T>
where
F: FnOnce() -> Result<T>,
{
self.conn.execute("BEGIN IMMEDIATE", [])?;
match f() {
Ok(value) => {
self.conn.execute("COMMIT", [])?;
Ok(value)
}
Err(e) => {
let _ = self.conn.execute("ROLLBACK", []);
Err(e)
}
}
}
pub fn get_app_state(&self, key: &str) -> Result<Option<String>> {
let mut stmt = self
.conn
.prepare("SELECT value FROM app_state WHERE key = ?1")?;
let result = stmt.query_row(params![key], |row| row.get(0)).optional()?;
Ok(result)
}
pub fn set_app_state(&self, key: &str, value: &str) -> Result<()> {
self.conn.execute(
"INSERT OR REPLACE INTO app_state (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
Ok(())
}
pub fn get_vcs_mode(&self) -> Result<VcsMode> {
match self.get_app_state(VcsMode::KEY)? {
Some(value) => value.parse().map_err(|err: String| {
crate::utils::TrackError::Other(format!("Invalid vcs_mode in database: {err}"))
}),
None => Ok(VcsMode::default()),
}
}
pub fn set_vcs_mode(&self, mode: VcsMode) -> Result<()> {
self.set_app_state(VcsMode::KEY, mode.as_str())
}
pub fn get_current_task_id(&self) -> Result<Option<i64>> {
match self.get_app_state("current_task_id")? {
Some(id_str) => Ok(Some(id_str.parse().map_err(|_| {
crate::utils::TrackError::Other("Invalid task ID in app_state".to_string())
})?)),
None => Ok(None),
}
}
pub fn set_current_task_id(&self, task_id: i64) -> Result<()> {
self.set_app_state("current_task_id", &task_id.to_string())
}
pub fn clear_current_task_id(&self) -> Result<()> {
self.conn
.execute("DELETE FROM app_state WHERE key = 'current_task_id'", [])?;
Ok(())
}
pub fn increment_rev(&self, section: &str) -> Result<i64> {
let key = format!("rev:{}", section);
let current: i64 = self
.get_app_state(&key)?
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let new_rev = current + 1;
self.set_app_state(&key, &new_rev.to_string())?;
Ok(new_rev)
}
pub fn get_rev(&self, section: &str) -> Result<i64> {
let key = format!("rev:{}", section);
Ok(self
.get_app_state(&key)?
.and_then(|s| s.parse().ok())
.unwrap_or(0))
}
pub fn get_all_revs(&self) -> Result<SectionRevs> {
Ok(SectionRevs {
task: self.get_rev("task")?,
todos: self.get_rev("todos")?,
scraps: self.get_rev("scraps")?,
links: self.get_rev("links")?,
repos: self.get_rev("repos")?,
worktrees: self.get_rev("worktrees")?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_in_memory() {
let db = Database::new_in_memory().unwrap();
let result: i64 = db
.get_connection()
.query_row("SELECT 1", [], |row| row.get(0))
.unwrap();
assert_eq!(result, 1);
}
#[test]
fn test_wal_mode_enabled() {
let db = Database::new_in_memory().unwrap();
let mode: String = db
.get_connection()
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
.unwrap();
assert!(mode == "memory" || mode == "wal");
}
#[test]
fn test_busy_timeout_configured() {
let db = Database::new_in_memory().unwrap();
let timeout: i64 = db
.get_connection()
.query_row("PRAGMA busy_timeout", [], |row| row.get(0))
.unwrap();
assert_eq!(timeout, 5000);
}
#[test]
fn test_app_state_get_set() {
let db = Database::new_in_memory().unwrap();
let value = db.get_app_state("test_key").unwrap();
assert!(value.is_none());
db.set_app_state("test_key", "test_value").unwrap();
let value = db.get_app_state("test_key").unwrap();
assert_eq!(value, Some("test_value".to_string()));
db.set_app_state("test_key", "new_value").unwrap();
let value = db.get_app_state("test_key").unwrap();
assert_eq!(value, Some("new_value".to_string()));
}
#[test]
fn test_vcs_mode_defaults_to_jj() {
let db = Database::new_in_memory().unwrap();
assert_eq!(db.get_vcs_mode().unwrap(), VcsMode::Jj);
}
#[test]
fn test_vcs_mode_round_trip() {
let db = Database::new_in_memory().unwrap();
db.set_vcs_mode(VcsMode::Git).unwrap();
assert_eq!(db.get_vcs_mode().unwrap(), VcsMode::Git);
db.set_vcs_mode(VcsMode::Jj).unwrap();
assert_eq!(db.get_vcs_mode().unwrap(), VcsMode::Jj);
}
#[test]
fn test_with_transaction_commit() {
let db = Database::new_in_memory().unwrap();
let result = db.with_transaction(|| {
db.set_app_state("tx_key", "tx_value")?;
Ok("success")
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), "success");
let value = db.get_app_state("tx_key").unwrap();
assert_eq!(value, Some("tx_value".to_string()));
}
#[test]
fn test_with_transaction_rollback() {
let db = Database::new_in_memory().unwrap();
db.set_app_state("rollback_key", "initial").unwrap();
let result: crate::utils::Result<()> = db.with_transaction(|| {
db.set_app_state("rollback_key", "changed")?;
Err(crate::utils::TrackError::Other("forced error".to_string()))
});
assert!(result.is_err());
let value = db.get_app_state("rollback_key").unwrap();
assert_eq!(value, Some("initial".to_string()));
}
#[test]
fn test_with_transaction_returns_value() {
let db = Database::new_in_memory().unwrap();
let result = db.with_transaction(|| {
db.set_app_state("key", "value")?;
Ok(42i64)
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), 42);
}
#[test]
fn test_current_task_id() {
let db = Database::new_in_memory().unwrap();
let task_id = db.get_current_task_id().unwrap();
assert!(task_id.is_none());
db.set_current_task_id(42).unwrap();
let task_id = db.get_current_task_id().unwrap();
assert_eq!(task_id, Some(42));
db.clear_current_task_id().unwrap();
let task_id = db.get_current_task_id().unwrap();
assert!(task_id.is_none());
}
#[test]
fn test_schema_initialization() {
let db = Database::new_in_memory().unwrap();
let conn = db.get_connection();
let tables = vec![
"app_state",
"tasks",
"todos",
"links",
"scraps",
"worktrees",
"repo_links",
];
for table in tables {
let result: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
[table],
|row| row.get(0),
)
.unwrap();
assert_eq!(result, 1, "Table {} should exist", table);
}
}
#[test]
fn test_increment_rev() {
let db = Database::new_in_memory().unwrap();
let rev = db.get_rev("todos").unwrap();
assert_eq!(rev, 0);
let new_rev = db.increment_rev("todos").unwrap();
assert_eq!(new_rev, 1);
let rev = db.get_rev("todos").unwrap();
assert_eq!(rev, 1);
let new_rev = db.increment_rev("todos").unwrap();
assert_eq!(new_rev, 2);
}
#[test]
fn test_get_rev_default() {
let db = Database::new_in_memory().unwrap();
let rev = db.get_rev("nonexistent").unwrap();
assert_eq!(rev, 0);
}
#[test]
fn test_increment_rev_different_sections() {
let db = Database::new_in_memory().unwrap();
db.increment_rev("todos").unwrap();
db.increment_rev("todos").unwrap();
db.increment_rev("scraps").unwrap();
db.increment_rev("links").unwrap();
db.increment_rev("links").unwrap();
db.increment_rev("links").unwrap();
assert_eq!(db.get_rev("todos").unwrap(), 2);
assert_eq!(db.get_rev("scraps").unwrap(), 1);
assert_eq!(db.get_rev("links").unwrap(), 3);
assert_eq!(db.get_rev("repos").unwrap(), 0);
}
#[test]
fn test_get_all_revs() {
let db = Database::new_in_memory().unwrap();
let revs = db.get_all_revs().unwrap();
assert_eq!(revs.task, 0);
assert_eq!(revs.todos, 0);
assert_eq!(revs.scraps, 0);
assert_eq!(revs.links, 0);
assert_eq!(revs.repos, 0);
assert_eq!(revs.worktrees, 0);
db.increment_rev("task").unwrap();
db.increment_rev("todos").unwrap();
db.increment_rev("todos").unwrap();
db.increment_rev("worktrees").unwrap();
let revs = db.get_all_revs().unwrap();
assert_eq!(revs.task, 1);
assert_eq!(revs.todos, 2);
assert_eq!(revs.scraps, 0);
assert_eq!(revs.links, 0);
assert_eq!(revs.repos, 0);
assert_eq!(revs.worktrees, 1);
}
#[test]
fn test_section_revs_equality() {
let db = Database::new_in_memory().unwrap();
let revs1 = db.get_all_revs().unwrap();
let revs2 = db.get_all_revs().unwrap();
assert_eq!(revs1, revs2);
db.increment_rev("todos").unwrap();
let revs3 = db.get_all_revs().unwrap();
assert_ne!(revs1, revs3);
}
#[test]
fn test_status_check_constraints_enforced() {
use crate::models::{TaskStatus, TodoStatus};
let db = Database::new_in_memory().unwrap();
let conn = db.get_connection();
let tasks_sql: String = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'",
[],
|row| row.get(0),
)
.unwrap();
assert!(tasks_sql.contains("CHECK"));
conn.execute(
"INSERT INTO tasks (name, status, created_at) VALUES ('Bad', ?1, datetime('now'))",
rusqlite::params![TaskStatus::ACTIVE],
)
.unwrap();
let err = conn
.execute(
"INSERT INTO tasks (name, status, created_at) VALUES ('Bad', 'invalid', datetime('now'))",
[],
)
.unwrap_err();
assert!(err.to_string().contains("CHECK constraint failed"));
conn.execute(
"INSERT INTO todos (task_id, task_index, content, status, worktree_requested, created_at) VALUES (1, 1, 'Ok', ?1, 0, datetime('now'))",
rusqlite::params![TodoStatus::PENDING],
)
.unwrap();
let err = conn
.execute(
"INSERT INTO todos (task_id, task_index, content, status, worktree_requested, created_at) VALUES (1, 2, 'Bad', 'reopened', 0, datetime('now'))",
[],
)
.unwrap_err();
assert!(err.to_string().contains("CHECK constraint failed"));
}
}