use crate::db::db_utils;
use crate::ui::messages::{error, success, warning};
use rusqlite::{Connection, Error, OptionalExtension, Result};
fn ensure_log_table(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
operation TEXT NOT NULL,
target TEXT DEFAULT '',
message TEXT NOT NULL
);
"#,
)?;
Ok(())
}
fn work_sessions_table_exists(conn: &Connection) -> Result<bool> {
let mut stmt =
conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='work_sessions'")?;
let exists: Option<String> = stmt.query_row([], |row| row.get(0)).optional()?;
Ok(exists.is_some())
}
fn events_table_exists(conn: &Connection) -> Result<bool> {
let mut stmt =
conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='events'")?;
let exists: Option<String> = stmt.query_row([], |row| row.get(0)).optional()?;
Ok(exists.is_some())
}
fn events_has_pair_column(conn: &Connection) -> Result<bool> {
let mut stmt = conn.prepare("PRAGMA table_info('events')")?;
let cols = stmt.query_map([], |row| row.get::<_, String>(1))?;
for c in cols {
if c? == "pair" {
return Ok(true);
}
}
Ok(false)
}
fn create_events_table(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
time TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('in','out')),
position TEXT NOT NULL DEFAULT 'O' CHECK(position IN ('O','R','H','C','M')),
lunch_break INTEGER NOT NULL DEFAULT 0,
pair INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'cli',
meta TEXT DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_date_time ON events(date, time);
CREATE INDEX IF NOT EXISTS idx_events_date_kind ON events(date, kind);
"#,
)?;
Ok(())
}
fn migrate_add_pair_to_events(conn: &Connection) -> Result<()> {
if !events_table_exists(conn)? {
return Ok(()); }
if events_has_pair_column(conn)? {
return Ok(()); }
warning("Adding 'pair' column to events table...");
conn.execute_batch(
r#"
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE events RENAME TO events_old;
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
time TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('in','out')),
position TEXT NOT NULL DEFAULT 'O' CHECK(position IN ('O','R','H','C','M')),
lunch_break INTEGER NOT NULL DEFAULT 0,
pair INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'cli',
meta TEXT DEFAULT '',
created_at TEXT NOT NULL
);
INSERT INTO events (id, date, time, kind, position, lunch_break, source, meta, created_at)
SELECT id, date, time, kind, position, lunch_break, source, meta, created_at
FROM events_old;
DROP TABLE events_old;
CREATE INDEX IF NOT EXISTS idx_events_date_time ON events(date, time);
CREATE INDEX IF NOT EXISTS idx_events_date_kind ON events(date, kind);
UPDATE sqlite_sequence
SET seq = (SELECT IFNULL(MAX(id), 0) FROM events)
WHERE name = 'events';
COMMIT;
PRAGMA foreign_keys=ON;
"#,
)?;
success("'pair' column added.");
warning("Rebuilding pairs using the new timeline logic...");
let db_path: String = conn
.query_row("PRAGMA database_list;", [], |row| row.get(2))
.unwrap_or_else(|_| "".to_string());
if db_path.is_empty() {
warning("Could not determine DB path — skipping pair rebuild.");
return Ok(());
}
let mut pool = match crate::db::pool::DbPool::new(&db_path) {
Ok(p) => p,
Err(e) => {
error(format!("Failed to create DbPool for pair rebuild: {}", e));
return Ok(());
}
};
match db_utils::rebuild_all_pairs(&mut pool) {
Ok(_) => success("Populated 'pair' column for existing events."),
Err(e) => error(format!("Failed to rebuild pairs: {}", e)),
}
Ok(())
}
fn align_db_schemas_to_080_version(conn: &Connection) -> Result<()> {
let mut stmt =
conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='work_sessions'")?;
let exists: Option<String> = stmt.query_row([], |row| row.get(0)).optional()?;
if exists.is_some() {
conn.execute_batch("DROP TABLE work_sessions;")?;
success("Dropped obsolete work_sessions table.");
}
Ok(())
}
fn backup_before_migration(db_path: &str) -> Result<()> {
use chrono::Local;
use std::fs::{self, File};
use std::io::Write;
use zip::CompressionMethod;
use zip::ZipWriter;
use zip::write::FileOptions;
let backup_name = format!(
"{}-backup_db_pre_080-beta1.zip",
Local::now().format("%Y%m%d_%H%M%S")
);
let backup_path = std::path::Path::new(db_path)
.parent()
.unwrap()
.join(&backup_name);
let file = File::create(&backup_path).map_err(|e| {
Error::ToSqlConversionFailure(Box::new(std::io::Error::new(
e.kind(),
format!("Backup failed (create): {}", e),
)))
})?;
let mut zip = ZipWriter::new(file);
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(CompressionMethod::Deflated);
zip.start_file("database.sqlite", options).map_err(|e| {
Error::ToSqlConversionFailure(Box::new(std::io::Error::other(format!(
"Backup failed (start_file): {}",
e
))))
})?;
let db_content = fs::read(db_path).map_err(|e| {
Error::ToSqlConversionFailure(Box::new(std::io::Error::other(format!(
"Backup failed (read): {}",
e
))))
})?;
zip.write_all(&db_content).map_err(|e| {
Error::ToSqlConversionFailure(Box::new(std::io::Error::other(format!(
"Backup failed (write_all): {}",
e
))))
})?;
zip.finish().map_err(|e| {
Error::ToSqlConversionFailure(Box::new(std::io::Error::other(format!(
"Backup failed (finish): {}",
e
))))
})?;
success(format!("📦 Backup created: {}", backup_path.display()));
Ok(())
}
fn migrate_add_work_gap_column(conn: &Connection) -> Result<(), Error> {
let version = "20250215_0012_add_work_gap_flag";
let mut chk = conn.prepare(
"SELECT 1 FROM log
WHERE operation = 'migration_applied' AND target = ?1
LIMIT 1",
)?;
if chk.query_row([version], |_| Ok(())).optional()?.is_some() {
return Ok(()); }
conn.execute(
"ALTER TABLE events ADD COLUMN work_gap INTEGER NOT NULL DEFAULT 0;",
[],
)
.map_err(|e| {
Error::SqliteFailure(
rusqlite::ffi::Error::new(1),
Some(format!("Failed to add 'work_gap' column: {}", e)),
)
})?;
conn.execute(
"INSERT INTO log (date, operation, target, message)
VALUES (datetime('now'), 'migration_applied', ?1, 'Added work_gap flag to events')",
[version],
)?;
success(format!(
"Migration applied: {} → added 'work_gap' to events table",
version
));
Ok(())
}
pub fn run_pending_migrations(conn: &Connection) -> Result<()> {
ensure_log_table(conn)?;
let events_exists = events_table_exists(conn)?;
let events_has_pair = if events_exists {
events_has_pair_column(conn)?
} else {
false
};
let work_sessions_exists = work_sessions_table_exists(conn)?;
let is_legacy_schema = work_sessions_exists || !events_has_pair;
if is_legacy_schema {
warning("Legacy schema detected — creating safety backup before migration...");
let db_path: String = conn
.query_row("PRAGMA database_list;", [], |row| row.get::<_, String>(2))
.unwrap_or_default();
if !db_path.is_empty() {
backup_before_migration(&db_path)?;
} else {
warning("Could not determine DB path — backup skipped.");
}
}
if !events_exists {
create_events_table(conn)?;
success("Created events table (modern schema).");
} else if !events_has_pair {
migrate_add_pair_to_events(conn)?;
} else {
conn.execute_batch(
r#"
CREATE INDEX IF NOT EXISTS idx_events_date_time ON events(date, time);
CREATE INDEX IF NOT EXISTS idx_events_date_kind ON events(date, kind);
"#,
)?;
migrate_add_work_gap_column(conn)?;
}
align_db_schemas_to_080_version(conn)?;
Ok(())
}