Skip to main content

kasl/db/
migrations.rs

1//! Database schema migration management and versioning system.
2//!
3//! Provides a comprehensive migration framework for evolving the database schema
4//! over time while maintaining data integrity and consistency.
5//!
6//! ## Usage
7//!
8//! ```rust,no_run
9//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
10//! use kasl::db::migrations::{init_with_migrations, get_db_version};
11//! use rusqlite::Connection;
12//!
13//! let mut conn = Connection::open("kasl.db")?;
14//! init_with_migrations(&mut conn)?;
15//! let version = get_db_version(&conn)?;
16//! # Ok(())
17//! # }
18//! ```
19
20use crate::libs::messages::Message;
21use crate::{msg_debug, msg_error, msg_info, msg_success};
22use anyhow::Result;
23use rusqlite::{Connection, Transaction, params};
24
25/// SQL schema for the migrations tracking table.
26///
27/// This table maintains a complete record of all applied migrations,
28/// enabling version tracking and providing an audit trail of schema changes.
29/// Each migration is recorded with its version, name, and application timestamp.
30const MIGRATIONS_TABLE: &str = "
31CREATE TABLE IF NOT EXISTS migrations (
32    id INTEGER PRIMARY KEY,
33    version INTEGER NOT NULL UNIQUE,
34    name TEXT NOT NULL,
35    applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
36)";
37
38/// Represents a single database migration with execution logic.
39///
40/// Each migration contains the information needed to apply a specific
41/// schema change, including version tracking and the transformation function.
42/// Migrations are designed to be immutable and deterministic.
43#[derive(Debug, Clone)]
44struct Migration {
45    /// Unique version number for ordering and tracking
46    version: u32,
47    /// Human-readable name describing the migration's purpose
48    name: &'static str,
49    /// Function that applies the schema changes within a transaction
50    up: fn(&Transaction) -> Result<()>,
51}
52
53/// Central migration system manager that orchestrates schema evolution.
54///
55/// The `MigrationManager` maintains the complete registry of available migrations
56/// and provides the logic for applying them in the correct order. It ensures
57/// that migrations are applied atomically and tracks their completion status.
58pub struct MigrationManager {
59    /// Ordered list of all available migrations
60    ///
61    /// Migrations are stored in version order to ensure correct application
62    /// sequence. Each migration builds upon the schema state created by
63    /// its predecessors.
64    migrations: Vec<Migration>,
65}
66
67impl Default for MigrationManager {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl MigrationManager {
74    /// Creates a new migration manager with all registered migrations.
75    ///
76    /// # Example
77    ///
78    /// ```rust
79    /// use kasl::db::migrations::MigrationManager;
80    ///
81    /// let manager = MigrationManager::new();
82    /// // Manager is ready to apply migrations
83    /// ```
84    pub fn new() -> Self {
85        let mut manager = Self { migrations: Vec::new() };
86
87        // Register all migrations in chronological order
88        // Each registration adds a migration to the internal registry
89        manager.register_migrations();
90        manager
91    }
92
93    /// Registers all database migrations in chronological order.
94    fn register_migrations(&mut self) {
95        // Initial schema - version 0 is implicit (empty database)
96        // Base tables are created by individual modules as needed
97
98        // Version 1: Base tables and performance indices
99        // Creates fundamental tables and adds indices for better performance
100        self.add_migration(1, "create_tables_and_indices", |tx| {
101            // First, create base tables that individual modules depend on
102            // This ensures tables exist before any indices are created
103
104            // Create tasks table
105            tx.execute(
106                "CREATE TABLE IF NOT EXISTS tasks (
107        id INTEGER NOT NULL PRIMARY KEY,
108        task_id INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0,
109        timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
110        name TEXT NOT NULL,
111        comment TEXT,
112        completeness INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 100,
113        excluded_from_search BOOLEAN NOT NULL ON CONFLICT REPLACE DEFAULT FALSE
114    )",
115                [],
116            )?;
117
118            // Create pauses table
119            tx.execute(
120                "CREATE TABLE IF NOT EXISTS pauses (
121        id INTEGER NOT NULL PRIMARY KEY,
122        start TIMESTAMP NOT NULL,
123        end TIMESTAMP,
124        duration INTEGER
125    )",
126                [],
127            )?;
128
129            // Create workdays table
130            tx.execute(
131                "CREATE TABLE IF NOT EXISTS workdays (
132        id INTEGER PRIMARY KEY,
133        date DATE NOT NULL UNIQUE,
134        start TIMESTAMP NOT NULL,
135        end TIMESTAMP
136    )",
137                [],
138            )?;
139
140            // Now create indices for the tables we just created
141
142            // Index tasks by timestamp for chronological queries
143            tx.execute("CREATE INDEX IF NOT EXISTS idx_tasks_timestamp ON tasks(timestamp)", [])?;
144            // Index tasks by parent task relationship
145            tx.execute("CREATE INDEX IF NOT EXISTS idx_tasks_task_id ON tasks(task_id)", [])?;
146            // Index pauses by start time for temporal queries
147            tx.execute("CREATE INDEX IF NOT EXISTS idx_pauses_start ON pauses(start)", [])?;
148            // Index workdays by date for daily/monthly reporting
149            tx.execute("CREATE INDEX IF NOT EXISTS idx_workdays_date ON workdays(date)", [])?;
150
151            Ok(())
152        });
153
154        // Version 2: Task templates system for reusable task patterns
155        // Introduces the ability to save and reuse common task configurations
156        self.add_migration(2, "add_task_templates", |tx| {
157            tx.execute(
158                "CREATE TABLE IF NOT EXISTS task_templates (
159                    id INTEGER PRIMARY KEY,
160                    name TEXT NOT NULL UNIQUE,
161                    task_name TEXT NOT NULL,
162                    comment TEXT,
163                    completeness INTEGER DEFAULT 100,
164                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
165                )",
166                [],
167            )?;
168            Ok(())
169        });
170
171        // Version 3: Tags and categorization system for task organization
172        // Adds support for tagging tasks with customizable labels and colors
173        self.add_migration(3, "add_tags_system", |tx| {
174            // Main tags table for storing tag definitions
175            tx.execute(
176                "CREATE TABLE IF NOT EXISTS tags (
177                    id INTEGER PRIMARY KEY,
178                    name TEXT NOT NULL UNIQUE,
179                    color TEXT,
180                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
181                )",
182                [],
183            )?;
184
185            // Junction table for many-to-many task-tag relationships
186            tx.execute(
187                "CREATE TABLE IF NOT EXISTS task_tags (
188                    task_id INTEGER NOT NULL,
189                    tag_id INTEGER NOT NULL,
190                    PRIMARY KEY (task_id, tag_id),
191                    FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
192                    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
193                )",
194                [],
195            )?;
196            Ok(())
197        });
198
199        // Version 4: Soft delete functionality for data preservation
200        // Enables logical deletion while maintaining data for auditing
201        self.add_migration(4, "add_soft_delete", |tx| {
202            tx.execute("ALTER TABLE tasks ADD COLUMN deleted_at TIMESTAMP", [])?;
203            tx.execute("CREATE INDEX idx_tasks_deleted_at ON tasks(deleted_at)", [])?;
204            Ok(())
205        });
206
207        // Version 5: Workday notes and annotations for context tracking
208        // Allows users to add contextual notes to their workdays
209        self.add_migration(5, "add_workday_notes", |tx| {
210            tx.execute("ALTER TABLE workdays ADD COLUMN notes TEXT", [])?;
211            Ok(())
212        });
213
214        // Version 6: Manual breaks table for productivity management
215        // Enables users to add manual break periods to improve productivity calculations
216        self.add_migration(6, "add_breaks_table", |tx| {
217            tx.execute(
218                "CREATE TABLE IF NOT EXISTS breaks (
219                    id INTEGER PRIMARY KEY,
220                    date DATE NOT NULL,
221                    start_time DATETIME NOT NULL,
222                    end_time DATETIME NOT NULL,
223                    duration INTEGER NOT NULL,
224                    reason TEXT,
225                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
226                )",
227                [],
228            )?;
229
230            // Create index for efficient daily break lookups
231            tx.execute("CREATE INDEX idx_breaks_date ON breaks(date)", [])?;
232            Ok(())
233        });
234
235        // Version 7: Jira inbox for assigned open issues and toast notifications
236        self.add_migration(7, "add_jira_inbox_table", |tx| {
237            tx.execute(
238                "CREATE TABLE IF NOT EXISTS jira_inbox (
239                    issue_key TEXT PRIMARY KEY NOT NULL,
240                    issue_id TEXT NOT NULL,
241                    summary TEXT NOT NULL,
242                    status TEXT NOT NULL,
243                    priority TEXT,
244                    priority_rank INTEGER NOT NULL DEFAULT 999,
245                    url TEXT NOT NULL,
246                    first_seen TIMESTAMP NOT NULL,
247                    last_seen TIMESTAMP NOT NULL,
248                    notified INTEGER NOT NULL DEFAULT 0,
249                    pinned INTEGER NOT NULL DEFAULT 0,
250                    dismissed INTEGER NOT NULL DEFAULT 0,
251                    raw_updated TEXT
252                )",
253                [],
254            )?;
255            tx.execute(
256                "CREATE INDEX IF NOT EXISTS idx_jira_inbox_active
257                 ON jira_inbox(dismissed, pinned DESC, priority_rank ASC, last_seen DESC)",
258                [],
259            )?;
260            Ok(())
261        });
262
263        // Version 8: status catalog + scoring/sort_value for inbox ranking
264        self.add_migration(8, "jira_inbox_status_id_and_sort_value", |tx| {
265            tx.execute(
266                "CREATE TABLE IF NOT EXISTS jira_statuses (
267                    id TEXT PRIMARY KEY NOT NULL,
268                    name TEXT NOT NULL
269                )",
270                [],
271            )?;
272            tx.execute("ALTER TABLE jira_inbox ADD COLUMN status_id TEXT", [])?;
273            tx.execute("ALTER TABLE jira_inbox ADD COLUMN sort_value REAL", [])?;
274            tx.execute(
275                "CREATE INDEX IF NOT EXISTS idx_jira_inbox_sort
276                 ON jira_inbox(dismissed, pinned DESC, sort_value DESC, priority_rank ASC)",
277                [],
278            )?;
279            Ok(())
280        });
281
282        // Version 9: wipe legacy status name strings; use status_id + jira_statuses only
283        self.add_migration(9, "clear_jira_inbox_legacy_status_text", |tx| {
284            tx.execute("UPDATE jira_inbox SET status = ''", [])?;
285            Ok(())
286        });
287
288        // Version 10: drop unused legacy status text column (canonical: status_id)
289        self.add_migration(10, "drop_jira_inbox_legacy_status_column", |tx| {
290            tx.execute("ALTER TABLE jira_inbox DROP COLUMN status", [])?;
291            Ok(())
292        });
293
294        // Version 11: fold manual breaks into pauses as protected records.
295        //
296        // The separate `breaks` table held synthetic records whose times were
297        // invented by a placement heuristic; downstream code converted them to
298        // pauses anyway. Manual breaks now live in `pauses` with `protected = 1`,
299        // which exempts them from the duration threshold and from merging with
300        // adjacent pauses. Existing break rows are carried over so historical
301        // reports keep their numbers.
302        self.add_migration(11, "fold_breaks_into_protected_pauses", |tx| {
303            tx.execute("ALTER TABLE pauses ADD COLUMN protected INTEGER NOT NULL DEFAULT 0", [])?;
304            tx.execute("ALTER TABLE pauses ADD COLUMN reason TEXT", [])?;
305
306            // Carry over manual breaks; duration is stored in seconds in `pauses`
307            // but was stored in minutes in `breaks`.
308            tx.execute(
309                "INSERT INTO pauses (start, end, duration, protected, reason)
310                 SELECT start_time, end_time, duration * 60, 1, reason FROM breaks",
311                [],
312            )?;
313
314            tx.execute("DROP INDEX IF EXISTS idx_breaks_date", [])?;
315            tx.execute("DROP TABLE IF EXISTS breaks", [])?;
316            Ok(())
317        });
318
319        // Version 12: inbox reconciliation and change tracking.
320        //
321        // `gone_at` marks issues that stopped appearing in the Jira poll
322        // (closed, reassigned) so the list stops showing them instead of
323        // freezing on the first sync. `last_change`/`changed_at` record the
324        // most recent visible change (status, priority, score) for badges
325        // and toasts.
326        self.add_migration(12, "jira_inbox_gone_and_change_tracking", |tx| {
327            tx.execute("ALTER TABLE jira_inbox ADD COLUMN gone_at TIMESTAMP", [])?;
328            tx.execute("ALTER TABLE jira_inbox ADD COLUMN last_change TEXT", [])?;
329            tx.execute("ALTER TABLE jira_inbox ADD COLUMN changed_at TIMESTAMP", [])?;
330            Ok(())
331        });
332
333        // Version 13: the link between an inbox issue and the task it became.
334        //
335        // `take` used to create a task and dismiss the issue, which severed
336        // the two: the key survived only inside the task's name, and the
337        // inbox forgot the issue had ever been picked up. `tasks.jira_key`
338        // records which issue a task came from, and `jira_inbox.taken_at`
339        // keeps the issue in the list marked as taken rather than hiding it.
340        //
341        // Dismissal stays what it always was - "not my problem" - so rows
342        // dismissed before this migration are left alone.
343        self.add_migration(13, "link_taken_issues_to_their_tasks", |tx| {
344            tx.execute("ALTER TABLE tasks ADD COLUMN jira_key TEXT", [])?;
345            tx.execute("CREATE INDEX idx_tasks_jira_key ON tasks(jira_key)", [])?;
346            tx.execute("ALTER TABLE jira_inbox ADD COLUMN taken_at TIMESTAMP", [])?;
347            Ok(())
348        });
349
350        // Version 14: the outbox of days still owed to kasl-server.
351        //
352        // A row is a date, not a payload. The day is rebuilt from the local
353        // tables when it is finally sent, so a week spent offline delivers the
354        // day as it stands at delivery rather than as it stood when the
355        // network first failed - the employee's later correction is the one
356        // that lands, which is also the rule the server plays by (last upload
357        // wins, ADR 0004 in kasl-server).
358        //
359        // `date` is unique: owing a day twice is the same debt, and a queue
360        // that grew a row per failed attempt would send a week's retries as a
361        // week's worth of duplicate days.
362        //
363        // `last_error` and `attempts` are for the person, not the machine.
364        // Nothing branches on them; they answer "why is this still here" when
365        // a day refuses to leave, which is otherwise invisible.
366        self.add_migration(14, "add_server_outbox", |tx| {
367            tx.execute(
368                "CREATE TABLE IF NOT EXISTS server_outbox (
369                    id INTEGER PRIMARY KEY,
370                    date DATE NOT NULL UNIQUE,
371                    queued_at TIMESTAMP NOT NULL,
372                    attempts INTEGER NOT NULL DEFAULT 0,
373                    last_attempt_at TIMESTAMP,
374                    last_error TEXT
375                )",
376                [],
377            )?;
378            // Oldest first is the order the queue is drained in, and the only
379            // order it is ever read in.
380            tx.execute("CREATE INDEX IF NOT EXISTS idx_server_outbox_date ON server_outbox(date)", [])?;
381            Ok(())
382        });
383
384        self.add_migration(15, "snooze_inbox_issues", |tx| {
385            // Snoozing is "not now", which dismissal could never say: a
386            // dismissed issue is gone for good, so the only way to defer one
387            // was to leave it in the list and keep reading past it.
388            tx.execute("ALTER TABLE jira_inbox ADD COLUMN snoozed_until TIMESTAMP", [])?;
389            // When the issue came back, so the return can be announced once
390            // and not on every poll after it.
391            tx.execute("ALTER TABLE jira_inbox ADD COLUMN woke_at TIMESTAMP", [])?;
392            // The waking query asks for rows due before now; the index is what
393            // keeps that off a full scan once the inbox is two hundred rows.
394            tx.execute("CREATE INDEX IF NOT EXISTS idx_jira_inbox_snoozed ON jira_inbox(snoozed_until)", [])?;
395            Ok(())
396        });
397
398        // The pulse's last word, kept so `kasl server status` - a different
399        // process from the watcher that sends it - can say how it is going.
400        //
401        // One row, never a log: the server keeps only the latest claim (ADR
402        // 0014 in kasl-server), and a local minute-by-minute history of when
403        // someone was at their desk would be the record the server refuses
404        // to be. `CHECK (id = 1)` makes a second row unwritable rather than
405        // merely unwritten.
406        //
407        // Times are UTC: the only question asked of them is "how long ago",
408        // and a wall-clock stamp answers that wrongly for an hour twice a year.
409        self.add_migration(16, "add_server_pulse", |tx| {
410            tx.execute(
411                "CREATE TABLE IF NOT EXISTS server_pulse (
412                    id INTEGER PRIMARY KEY CHECK (id = 1),
413                    attempted_at TIMESTAMP NOT NULL,
414                    next_at TIMESTAMP NOT NULL,
415                    sent_at TIMESTAMP,
416                    state TEXT,
417                    stale_after_seconds INTEGER,
418                    clock_skew_seconds INTEGER,
419                    error TEXT
420                )",
421                [],
422            )?;
423            Ok(())
424        });
425
426        // Version 17: why a taken issue left the inbox, and every task its own chain.
427        //
428        // An issue leaves the inbox when it is resolved or when it stops being
429        // yours, and the two ask for opposite things: a resolved issue is a
430        // task to mark done, a reassigned one is not. The poll cannot tell them
431        // apart - both are simply absent - so kasl asks Jira about each taken
432        // issue that left, and keeps the answer. NULL means not asked yet, which
433        // is what makes a question that failed get asked again on the next poll.
434        // The CHECK keeps a third, unreadable answer out.
435        //
436        // `task_id = 0` was the default for every task not created through
437        // `kasl task add`: issues taken from the inbox, imports from `task find`.
438        // The incomplete-task query groups by `task_id`, so all of them were one
439        // task to it, and a single finished import hid every unfinished taken
440        // issue from `task find`. A task with no history of its own is its own
441        // chain; `Tasks::insert` now says so for new rows, and this says it for
442        // the old ones.
443        self.add_migration(17, "close_the_loop_with_jira", |tx| {
444            tx.execute(
445                "ALTER TABLE jira_inbox ADD COLUMN left_reason TEXT CHECK (left_reason IN ('resolved', 'moved'))",
446                [],
447            )?;
448            tx.execute("ALTER TABLE jira_inbox ADD COLUMN resolution TEXT", [])?;
449            tx.execute("UPDATE tasks SET task_id = id WHERE task_id = 0", [])?;
450            Ok(())
451        });
452
453        // Version 18: what inbox rules read, and which rule acted.
454        //
455        // A rule can say "dismiss every Epic" or "tag work from PAY", so the
456        // row has to carry the issue's type and project. Both are the source's
457        // own words, kept as text: a Russian Jira calls a bug «Ошибка», and a
458        // future GitLab source has projects that are paths, not keys.
459        //
460        // Existing rows get their project from the key's prefix, which is what
461        // a Jira key is; the next poll overwrites it with Jira's own answer.
462        // The type is left empty until then - there is nothing to guess it from.
463        //
464        // `ruled_by` names the rule behind the row's pin or dismissal, so
465        // "why is this pinned?" has an answer and an issue a rule hid can be
466        // found again. A pin or dismissal by hand clears it.
467        //
468        // `jira_priorities` is the vocabulary a rule's "High+" is read
469        // against: every priority name the inbox has seen, with its rank, and
470        // never pruned. Read off the rows of the moment instead, it shrank:
471        // the poll that lowered the last High issue made "High" unknown, and
472        // a `High+` rule fell asleep in the very poll that raised another
473        // issue to Highest. It starts with the names the rows carry now.
474        self.add_migration(18, "inbox_rules", |tx| {
475            tx.execute("ALTER TABLE jira_inbox ADD COLUMN issue_type TEXT", [])?;
476            tx.execute("ALTER TABLE jira_inbox ADD COLUMN project TEXT", [])?;
477            tx.execute("ALTER TABLE jira_inbox ADD COLUMN ruled_by TEXT", [])?;
478            tx.execute(
479                "UPDATE jira_inbox SET project = substr(issue_key, 1, instr(issue_key, '-') - 1) WHERE instr(issue_key, '-') > 1",
480                [],
481            )?;
482            tx.execute(
483                "CREATE TABLE IF NOT EXISTS jira_priorities (
484                    name TEXT PRIMARY KEY,
485                    rank INTEGER NOT NULL
486                )",
487                [],
488            )?;
489            tx.execute(
490                "INSERT OR IGNORE INTO jira_priorities (name, rank)
491                 SELECT priority, MIN(priority_rank) FROM jira_inbox WHERE priority IS NOT NULL GROUP BY priority",
492                [],
493            )?;
494            Ok(())
495        });
496    }
497
498    /// Registers a single migration in the migration system.
499    ///
500    /// This helper method adds a migration to the internal registry with
501    /// proper version ordering and validation. It ensures that migrations
502    /// are stored in a consistent format for later execution.
503    ///
504    /// # Panics
505    ///
506    /// Panics if a migration with the same version number is already registered.
507    fn add_migration(&mut self, version: u32, name: &'static str, up: fn(&Transaction) -> Result<()>) {
508        self.migrations.push(Migration { version, name, up });
509    }
510
511    /// Executes all pending migrations in the correct order.
512    ///
513    /// # Example
514    ///
515    /// ```rust
516    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
517    /// use kasl::db::migrations::MigrationManager;
518    /// use rusqlite::Connection;
519    ///
520    /// let manager = MigrationManager::new();
521    /// let mut conn = Connection::open(":memory:")?;
522    /// manager.run_migrations(&mut conn)?;
523    /// # Ok(())
524    /// # }
525    /// ```
526    pub fn run_migrations(&self, conn: &mut Connection) -> Result<()> {
527        // Initialize the migrations tracking table
528        conn.execute(MIGRATIONS_TABLE, [])?;
529
530        // Determine the current schema version
531        let current_version = self.get_current_version(conn)?;
532
533        // Find all migrations that haven't been applied yet
534        let pending: Vec<&Migration> = self.migrations.iter().filter(|m| m.version > current_version).collect();
535
536        // Exit early if no migrations are needed
537        if pending.is_empty() {
538            msg_debug!("Database is up to date");
539            return Ok(());
540        }
541
542        // Notify user about pending migrations
543        msg_info!(Message::MigrationsFound(pending.len()));
544
545        // Execute all pending migrations within a single transaction
546        let tx = conn.transaction()?;
547
548        for migration in pending {
549            msg_info!(Message::RunningMigration(migration.version, migration.name.to_string()));
550
551            match (migration.up)(&tx) {
552                Ok(()) => {
553                    // Record successful migration in tracking table
554                    tx.execute(
555                        "INSERT INTO migrations (version, name) VALUES (?1, ?2)",
556                        params![migration.version, migration.name],
557                    )?;
558                    msg_success!(Message::MigrationCompleted(migration.version));
559                }
560                Err(e) => {
561                    // Log migration failure and propagate error
562                    msg_error!(Message::MigrationFailed(migration.version, e.to_string()));
563                    return Err(e);
564                }
565            }
566        }
567
568        // Commit all successful migrations
569        tx.commit()?;
570        msg_success!(Message::AllMigrationsCompleted);
571
572        Ok(())
573    }
574
575    /// Retrieves the current database schema version.
576    fn get_current_version(&self, conn: &Connection) -> Result<u32> {
577        let version: Option<u32> = conn.query_row("SELECT MAX(version) FROM migrations", [], |row| row.get(0)).unwrap_or(Some(0));
578
579        Ok(version.unwrap_or(0))
580    }
581
582    /// Checks if a specific migration version has been applied.
583    ///
584    /// This utility method allows callers to verify whether a particular
585    /// migration has been successfully applied to the database. Useful
586    /// for conditional logic based on schema capabilities.
587    ///
588    /// # Example
589    ///
590    /// ```rust
591    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
592    /// use kasl::db::migrations::MigrationManager;
593    /// use rusqlite::Connection;
594    ///
595    /// let manager = MigrationManager::new();
596    /// let mut conn = Connection::open(":memory:")?;
597    /// manager.run_migrations(&mut conn)?;
598    /// if manager.is_migration_applied(&conn, 3)? {
599    ///     // Tags system is available
600    /// }
601    /// # Ok(())
602    /// # }
603    /// ```
604    pub fn is_migration_applied(&self, conn: &Connection, version: u32) -> Result<bool> {
605        let count: i32 = conn.query_row("SELECT COUNT(*) FROM migrations WHERE version = ?1", params![version], |row| row.get(0))?;
606
607        Ok(count > 0)
608    }
609
610    /// Retrieves the complete migration history with timestamps.
611    ///
612    /// # Example
613    ///
614    /// ```rust
615    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
616    /// use kasl::db::migrations::MigrationManager;
617    /// use rusqlite::Connection;
618    ///
619    /// let manager = MigrationManager::new();
620    /// let mut conn = Connection::open(":memory:")?;
621    /// manager.run_migrations(&mut conn)?;
622    /// let history = manager.get_migration_history(&conn)?;
623    /// for (version, name, applied_at) in history {
624    ///     println!("v{}: {} ({})", version, name, applied_at);
625    /// }
626    /// # Ok(())
627    /// # }
628    /// ```
629    pub fn get_migration_history(&self, conn: &Connection) -> Result<Vec<(u32, String, String)>> {
630        let mut stmt = conn.prepare("SELECT version, name, applied_at FROM migrations ORDER BY version")?;
631
632        let history = stmt
633            .query_map([], |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)))?
634            .collect::<Result<Vec<_>, _>>()?;
635
636        Ok(history)
637    }
638
639    /// Rolls back migrations to a specific target version (debug builds only).
640    ///
641    /// This development utility allows rolling back migrations to a previous
642    /// schema version by removing migration records from the tracking table.
643    ///
644    /// # Example
645    ///
646    /// ```rust
647    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
648    /// use kasl::db::migrations::MigrationManager;
649    /// use rusqlite::Connection;
650    ///
651    /// #[cfg(debug_assertions)]
652    /// {
653    ///     let manager = MigrationManager::new();
654    ///     let mut conn = Connection::open(":memory:")?;
655    ///     manager.run_migrations(&mut conn)?;
656    ///     manager.rollback_to(&mut conn, 2)?; // Roll back to version 2
657    /// }
658    /// # Ok(())
659    /// # }
660    /// ```
661    #[cfg(debug_assertions)]
662    pub fn rollback_to(&self, conn: &mut Connection, target_version: u32) -> Result<()> {
663        let current_version = self.get_current_version(conn)?;
664
665        if target_version >= current_version {
666            msg_info!(Message::NothingToRollback);
667            return Ok(());
668        }
669
670        msg_info!(Message::RollingBack(current_version, target_version));
671
672        // Remove migration records beyond the target version
673        // Note: This is a simplified rollback that doesn't actually reverse schema changes
674        conn.execute("DELETE FROM migrations WHERE version > ?1", params![target_version])?;
675
676        msg_success!(Message::RollbackCompleted(target_version));
677        Ok(())
678    }
679}
680
681/// Initializes a database connection with all pending migrations applied.
682///
683/// This convenience function creates a migration manager and applies all
684/// pending migrations to the provided connection. It's the recommended
685/// way to ensure a database is up to date with the latest schema.
686///
687/// # Example
688///
689/// ```rust,no_run
690/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
691/// use kasl::db::migrations::init_with_migrations;
692/// use rusqlite::Connection;
693///
694/// let mut conn = Connection::open("kasl.db")?;
695/// init_with_migrations(&mut conn)?;
696/// # Ok(())
697/// # }
698/// ```
699pub fn init_with_migrations(conn: &mut Connection) -> Result<()> {
700    let manager = MigrationManager::new();
701    manager.run_migrations(conn)?;
702    Ok(())
703}
704
705/// Retrieves the current database schema version.
706///
707/// This utility function provides a simple way to check the current
708/// schema version without creating a full migration manager instance.
709///
710/// # Example
711///
712/// ```rust
713/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
714/// use kasl::db::migrations::get_db_version;
715/// use rusqlite::Connection;
716///
717/// let conn = Connection::open(":memory:")?;
718/// let version = get_db_version(&conn)?;
719/// println!("Current schema version: {}", version);
720/// # Ok(())
721/// # }
722/// ```
723pub fn get_db_version(conn: &Connection) -> Result<u32> {
724    let manager = MigrationManager::new();
725    manager.get_current_version(conn)
726}
727
728/// Checks if the database requires migration to the latest schema version.
729///
730/// This utility function compares the current database version with the
731/// latest available migration version to determine if updates are needed.
732///
733/// # Example
734///
735/// ```rust
736/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
737/// use kasl::db::migrations::needs_migration;
738/// use rusqlite::Connection;
739///
740/// let conn = Connection::open(":memory:")?;
741/// if needs_migration(&conn)? {
742///     println!("Database needs migration!");
743/// }
744/// # Ok(())
745/// # }
746/// ```
747pub fn needs_migration(conn: &Connection) -> Result<bool> {
748    let manager = MigrationManager::new();
749    let current = manager.get_current_version(conn)?;
750    let latest = manager.migrations.last().map(|m| m.version).unwrap_or(0);
751    Ok(current < latest)
752}