use crate::libs::messages::Message;
use crate::{msg_debug, msg_error, msg_info, msg_success};
use anyhow::Result;
use rusqlite::{Connection, Transaction, params};
const MIGRATIONS_TABLE: &str = "
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY,
version INTEGER NOT NULL UNIQUE,
name TEXT NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)";
#[derive(Debug, Clone)]
struct Migration {
version: u32,
name: &'static str,
up: fn(&Transaction) -> Result<()>,
}
pub struct MigrationManager {
migrations: Vec<Migration>,
}
impl Default for MigrationManager {
fn default() -> Self {
Self::new()
}
}
impl MigrationManager {
pub fn new() -> Self {
let mut manager = Self { migrations: Vec::new() };
manager.register_migrations();
manager
}
fn register_migrations(&mut self) {
self.add_migration(1, "create_tables_and_indices", |tx| {
tx.execute(
"CREATE TABLE IF NOT EXISTS tasks (
id INTEGER NOT NULL PRIMARY KEY,
task_id INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
name TEXT NOT NULL,
comment TEXT,
completeness INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 100,
excluded_from_search BOOLEAN NOT NULL ON CONFLICT REPLACE DEFAULT FALSE
)",
[],
)?;
tx.execute(
"CREATE TABLE IF NOT EXISTS pauses (
id INTEGER NOT NULL PRIMARY KEY,
start TIMESTAMP NOT NULL,
end TIMESTAMP,
duration INTEGER
)",
[],
)?;
tx.execute(
"CREATE TABLE IF NOT EXISTS workdays (
id INTEGER PRIMARY KEY,
date DATE NOT NULL UNIQUE,
start TIMESTAMP NOT NULL,
end TIMESTAMP
)",
[],
)?;
tx.execute("CREATE INDEX IF NOT EXISTS idx_tasks_timestamp ON tasks(timestamp)", [])?;
tx.execute("CREATE INDEX IF NOT EXISTS idx_tasks_task_id ON tasks(task_id)", [])?;
tx.execute("CREATE INDEX IF NOT EXISTS idx_pauses_start ON pauses(start)", [])?;
tx.execute("CREATE INDEX IF NOT EXISTS idx_workdays_date ON workdays(date)", [])?;
Ok(())
});
self.add_migration(2, "add_task_templates", |tx| {
tx.execute(
"CREATE TABLE IF NOT EXISTS task_templates (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
task_name TEXT NOT NULL,
comment TEXT,
completeness INTEGER DEFAULT 100,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
Ok(())
});
self.add_migration(3, "add_tags_system", |tx| {
tx.execute(
"CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
tx.execute(
"CREATE TABLE IF NOT EXISTS task_tags (
task_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (task_id, tag_id),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
)",
[],
)?;
Ok(())
});
self.add_migration(4, "add_soft_delete", |tx| {
tx.execute("ALTER TABLE tasks ADD COLUMN deleted_at TIMESTAMP", [])?;
tx.execute("CREATE INDEX idx_tasks_deleted_at ON tasks(deleted_at)", [])?;
Ok(())
});
self.add_migration(5, "add_workday_notes", |tx| {
tx.execute("ALTER TABLE workdays ADD COLUMN notes TEXT", [])?;
Ok(())
});
self.add_migration(6, "add_breaks_table", |tx| {
tx.execute(
"CREATE TABLE IF NOT EXISTS breaks (
id INTEGER PRIMARY KEY,
date DATE NOT NULL,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
duration INTEGER NOT NULL,
reason TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
tx.execute("CREATE INDEX idx_breaks_date ON breaks(date)", [])?;
Ok(())
});
self.add_migration(7, "add_jira_inbox_table", |tx| {
tx.execute(
"CREATE TABLE IF NOT EXISTS jira_inbox (
issue_key TEXT PRIMARY KEY NOT NULL,
issue_id TEXT NOT NULL,
summary TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT,
priority_rank INTEGER NOT NULL DEFAULT 999,
url TEXT NOT NULL,
first_seen TIMESTAMP NOT NULL,
last_seen TIMESTAMP NOT NULL,
notified INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
dismissed INTEGER NOT NULL DEFAULT 0,
raw_updated TEXT
)",
[],
)?;
tx.execute(
"CREATE INDEX IF NOT EXISTS idx_jira_inbox_active
ON jira_inbox(dismissed, pinned DESC, priority_rank ASC, last_seen DESC)",
[],
)?;
Ok(())
});
self.add_migration(8, "jira_inbox_status_id_and_sort_value", |tx| {
tx.execute(
"CREATE TABLE IF NOT EXISTS jira_statuses (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL
)",
[],
)?;
tx.execute("ALTER TABLE jira_inbox ADD COLUMN status_id TEXT", [])?;
tx.execute("ALTER TABLE jira_inbox ADD COLUMN sort_value REAL", [])?;
tx.execute(
"CREATE INDEX IF NOT EXISTS idx_jira_inbox_sort
ON jira_inbox(dismissed, pinned DESC, sort_value DESC, priority_rank ASC)",
[],
)?;
Ok(())
});
self.add_migration(9, "clear_jira_inbox_legacy_status_text", |tx| {
tx.execute("UPDATE jira_inbox SET status = ''", [])?;
Ok(())
});
self.add_migration(10, "drop_jira_inbox_legacy_status_column", |tx| {
tx.execute("ALTER TABLE jira_inbox DROP COLUMN status", [])?;
Ok(())
});
self.add_migration(11, "fold_breaks_into_protected_pauses", |tx| {
tx.execute("ALTER TABLE pauses ADD COLUMN protected INTEGER NOT NULL DEFAULT 0", [])?;
tx.execute("ALTER TABLE pauses ADD COLUMN reason TEXT", [])?;
tx.execute(
"INSERT INTO pauses (start, end, duration, protected, reason)
SELECT start_time, end_time, duration * 60, 1, reason FROM breaks",
[],
)?;
tx.execute("DROP INDEX IF EXISTS idx_breaks_date", [])?;
tx.execute("DROP TABLE IF EXISTS breaks", [])?;
Ok(())
});
}
fn add_migration(&mut self, version: u32, name: &'static str, up: fn(&Transaction) -> Result<()>) {
self.migrations.push(Migration { version, name, up });
}
pub fn run_migrations(&self, conn: &mut Connection) -> Result<()> {
conn.execute(MIGRATIONS_TABLE, [])?;
let current_version = self.get_current_version(conn)?;
let pending: Vec<&Migration> = self.migrations.iter().filter(|m| m.version > current_version).collect();
if pending.is_empty() {
msg_debug!("Database is up to date");
return Ok(());
}
msg_info!(Message::MigrationsFound(pending.len()));
let tx = conn.transaction()?;
for migration in pending {
msg_info!(Message::RunningMigration(migration.version, migration.name.to_string()));
match (migration.up)(&tx) {
Ok(()) => {
tx.execute(
"INSERT INTO migrations (version, name) VALUES (?1, ?2)",
params![migration.version, migration.name],
)?;
msg_success!(Message::MigrationCompleted(migration.version));
}
Err(e) => {
msg_error!(Message::MigrationFailed(migration.version, e.to_string()));
return Err(e);
}
}
}
tx.commit()?;
msg_success!(Message::AllMigrationsCompleted);
Ok(())
}
fn get_current_version(&self, conn: &Connection) -> Result<u32> {
let version: Option<u32> = conn.query_row("SELECT MAX(version) FROM migrations", [], |row| row.get(0)).unwrap_or(Some(0));
Ok(version.unwrap_or(0))
}
pub fn is_migration_applied(&self, conn: &Connection, version: u32) -> Result<bool> {
let count: i32 = conn.query_row("SELECT COUNT(*) FROM migrations WHERE version = ?1", params![version], |row| row.get(0))?;
Ok(count > 0)
}
pub fn get_migration_history(&self, conn: &Connection) -> Result<Vec<(u32, String, String)>> {
let mut stmt = conn.prepare("SELECT version, name, applied_at FROM migrations ORDER BY version")?;
let history = stmt
.query_map([], |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)))?
.collect::<Result<Vec<_>, _>>()?;
Ok(history)
}
#[cfg(debug_assertions)]
pub fn rollback_to(&self, conn: &mut Connection, target_version: u32) -> Result<()> {
let current_version = self.get_current_version(conn)?;
if target_version >= current_version {
msg_info!(Message::NothingToRollback);
return Ok(());
}
msg_info!(Message::RollingBack(current_version, target_version));
conn.execute("DELETE FROM migrations WHERE version > ?1", params![target_version])?;
msg_success!(Message::RollbackCompleted(target_version));
Ok(())
}
}
pub fn init_with_migrations(conn: &mut Connection) -> Result<()> {
let manager = MigrationManager::new();
manager.run_migrations(conn)?;
Ok(())
}
pub fn get_db_version(conn: &Connection) -> Result<u32> {
let manager = MigrationManager::new();
manager.get_current_version(conn)
}
pub fn needs_migration(conn: &Connection) -> Result<bool> {
let manager = MigrationManager::new();
let current = manager.get_current_version(conn)?;
let latest = manager.migrations.last().map(|m| m.version).unwrap_or(0);
Ok(current < latest)
}