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//! ## Features
7//!
8//! - **Version Tracking**: Maintains precise records of applied migrations
9//! - **Automatic Application**: Runs pending migrations during database initialization
10//! - **Transaction Safety**: All migrations run within database transactions
11//! - **Rollback Support**: Development-time rollback capabilities (debug builds only)
12//! - **History Tracking**: Complete audit trail of schema changes
13//!
14//! ## Usage
15//!
16//! ```rust,no_run
17//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
18//! use kasl::db::migrations::{init_with_migrations, get_db_version};
19//! use rusqlite::Connection;
20//!
21//! let mut conn = Connection::open("kasl.db")?;
22//! init_with_migrations(&mut conn)?;
23//! let version = get_db_version(&conn)?;
24//! # Ok(())
25//! # }
26//! ```
27
28use crate::libs::messages::Message;
29use crate::{msg_debug, msg_error, msg_info, msg_success};
30use anyhow::Result;
31use rusqlite::{Connection, Transaction, params};
32
33/// SQL schema for the migrations tracking table.
34///
35/// This table maintains a complete record of all applied migrations,
36/// enabling version tracking and providing an audit trail of schema changes.
37/// Each migration is recorded with its version, name, and application timestamp.
38const MIGRATIONS_TABLE: &str = "
39CREATE TABLE IF NOT EXISTS migrations (
40    id INTEGER PRIMARY KEY,
41    version INTEGER NOT NULL UNIQUE,
42    name TEXT NOT NULL,
43    applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
44)";
45
46/// Represents a single database migration with execution logic.
47///
48/// Each migration contains the information needed to apply a specific
49/// schema change, including version tracking and the transformation function.
50/// Migrations are designed to be immutable and deterministic.
51#[derive(Debug, Clone)]
52struct Migration {
53    /// Unique version number for ordering and tracking
54    version: u32,
55    /// Human-readable name describing the migration's purpose
56    name: &'static str,
57    /// Function that applies the schema changes within a transaction
58    up: fn(&Transaction) -> Result<()>,
59}
60
61/// Central migration system manager that orchestrates schema evolution.
62///
63/// The `MigrationManager` maintains the complete registry of available migrations
64/// and provides the logic for applying them in the correct order. It ensures
65/// that migrations are applied atomically and tracks their completion status.
66///
67/// ## Architecture
68///
69/// - **Migration Registry**: Stores all available migrations in version order
70/// - **Version Control**: Tracks current schema version and pending changes  
71/// - **Transaction Management**: Ensures each migration is atomic
72/// - **Error Recovery**: Provides rollback on migration failures
73///
74/// ## Thread Safety
75///
76/// The migration manager is designed for single-threaded use during application
77/// startup. Multiple concurrent migration attempts should be avoided.
78pub struct MigrationManager {
79    /// Ordered list of all available migrations
80    ///
81    /// Migrations are stored in version order to ensure correct application
82    /// sequence. Each migration builds upon the schema state created by
83    /// its predecessors.
84    migrations: Vec<Migration>,
85}
86
87impl Default for MigrationManager {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl MigrationManager {
94    /// Creates a new migration manager with all registered migrations.
95    ///
96    /// This constructor automatically registers all available migrations
97    /// in the correct order. The registration process is deterministic
98    /// and ensures consistent schema evolution across all environments.
99    ///
100    /// # Returns
101    ///
102    /// Returns a fully initialized migration manager ready to apply
103    /// pending schema changes.
104    ///
105    /// # Example
106    ///
107    /// ```rust
108    /// use kasl::db::migrations::MigrationManager;
109    ///
110    /// let manager = MigrationManager::new();
111    /// // Manager is ready to apply migrations
112    /// ```
113    pub fn new() -> Self {
114        let mut manager = Self { migrations: Vec::new() };
115
116        // Register all migrations in chronological order
117        // Each registration adds a migration to the internal registry
118        manager.register_migrations();
119        manager
120    }
121
122    /// Registers all database migrations in chronological order.
123    ///
124    /// This method defines the complete schema evolution history by registering
125    /// each migration version with its transformation logic. Migrations must
126    /// be registered in sequential version order to ensure correct application.
127    ///
128    /// ## Migration Design Principles
129    ///
130    /// - **Incremental**: Each migration makes small, focused changes
131    /// - **Idempotent**: Migrations can be safely re-run if needed
132    /// - **Forward-Only**: No backward compatibility requirements
133    /// - **Atomic**: Each migration succeeds or fails completely
134    fn register_migrations(&mut self) {
135        // Initial schema - version 0 is implicit (empty database)
136        // Base tables are created by individual modules as needed
137
138        // Version 1: Base tables and performance indices
139        // Creates fundamental tables and adds indices for better performance
140        self.add_migration(1, "create_tables_and_indices", |tx| {
141            // First, create base tables that individual modules depend on
142            // This ensures tables exist before any indices are created
143
144            // Create tasks table
145            tx.execute(
146                "CREATE TABLE IF NOT EXISTS tasks (
147        id INTEGER NOT NULL PRIMARY KEY,
148        task_id INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0,
149        timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
150        name TEXT NOT NULL,
151        comment TEXT,
152        completeness INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 100,
153        excluded_from_search BOOLEAN NOT NULL ON CONFLICT REPLACE DEFAULT FALSE
154    )",
155                [],
156            )?;
157
158            // Create pauses table
159            tx.execute(
160                "CREATE TABLE IF NOT EXISTS pauses (
161        id INTEGER NOT NULL PRIMARY KEY,
162        start TIMESTAMP NOT NULL,
163        end TIMESTAMP,
164        duration INTEGER
165    )",
166                [],
167            )?;
168
169            // Create workdays table
170            tx.execute(
171                "CREATE TABLE IF NOT EXISTS workdays (
172        id INTEGER PRIMARY KEY,
173        date DATE NOT NULL UNIQUE,
174        start TIMESTAMP NOT NULL,
175        end TIMESTAMP
176    )",
177                [],
178            )?;
179
180            // Now create indices for the tables we just created
181
182            // Index tasks by timestamp for chronological queries
183            tx.execute("CREATE INDEX IF NOT EXISTS idx_tasks_timestamp ON tasks(timestamp)", [])?;
184            // Index tasks by parent task relationship
185            tx.execute("CREATE INDEX IF NOT EXISTS idx_tasks_task_id ON tasks(task_id)", [])?;
186            // Index pauses by start time for temporal queries
187            tx.execute("CREATE INDEX IF NOT EXISTS idx_pauses_start ON pauses(start)", [])?;
188            // Index workdays by date for daily/monthly reporting
189            tx.execute("CREATE INDEX IF NOT EXISTS idx_workdays_date ON workdays(date)", [])?;
190
191            Ok(())
192        });
193
194        // Version 2: Task templates system for reusable task patterns
195        // Introduces the ability to save and reuse common task configurations
196        self.add_migration(2, "add_task_templates", |tx| {
197            tx.execute(
198                "CREATE TABLE IF NOT EXISTS task_templates (
199                    id INTEGER PRIMARY KEY,
200                    name TEXT NOT NULL UNIQUE,
201                    task_name TEXT NOT NULL,
202                    comment TEXT,
203                    completeness INTEGER DEFAULT 100,
204                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
205                )",
206                [],
207            )?;
208            Ok(())
209        });
210
211        // Version 3: Tags and categorization system for task organization
212        // Adds support for tagging tasks with customizable labels and colors
213        self.add_migration(3, "add_tags_system", |tx| {
214            // Main tags table for storing tag definitions
215            tx.execute(
216                "CREATE TABLE IF NOT EXISTS tags (
217                    id INTEGER PRIMARY KEY,
218                    name TEXT NOT NULL UNIQUE,
219                    color TEXT,
220                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
221                )",
222                [],
223            )?;
224
225            // Junction table for many-to-many task-tag relationships
226            tx.execute(
227                "CREATE TABLE IF NOT EXISTS task_tags (
228                    task_id INTEGER NOT NULL,
229                    tag_id INTEGER NOT NULL,
230                    PRIMARY KEY (task_id, tag_id),
231                    FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
232                    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
233                )",
234                [],
235            )?;
236            Ok(())
237        });
238
239        // Version 4: Soft delete functionality for data preservation
240        // Enables logical deletion while maintaining data for auditing
241        self.add_migration(4, "add_soft_delete", |tx| {
242            tx.execute("ALTER TABLE tasks ADD COLUMN deleted_at TIMESTAMP", [])?;
243            tx.execute("CREATE INDEX idx_tasks_deleted_at ON tasks(deleted_at)", [])?;
244            Ok(())
245        });
246
247        // Version 5: Workday notes and annotations for context tracking
248        // Allows users to add contextual notes to their workdays
249        self.add_migration(5, "add_workday_notes", |tx| {
250            tx.execute("ALTER TABLE workdays ADD COLUMN notes TEXT", [])?;
251            Ok(())
252        });
253
254        // Version 6: Manual breaks table for productivity management
255        // Enables users to add manual break periods to improve productivity calculations
256        self.add_migration(6, "add_breaks_table", |tx| {
257            tx.execute(
258                "CREATE TABLE IF NOT EXISTS breaks (
259                    id INTEGER PRIMARY KEY,
260                    date DATE NOT NULL,
261                    start_time DATETIME NOT NULL,
262                    end_time DATETIME NOT NULL,
263                    duration INTEGER NOT NULL,
264                    reason TEXT,
265                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
266                )",
267                [],
268            )?;
269
270            // Create index for efficient daily break lookups
271            tx.execute("CREATE INDEX idx_breaks_date ON breaks(date)", [])?;
272            Ok(())
273        });
274
275        // Version 7: Jira inbox for assigned open issues and toast notifications
276        self.add_migration(7, "add_jira_inbox_table", |tx| {
277            tx.execute(
278                "CREATE TABLE IF NOT EXISTS jira_inbox (
279                    issue_key TEXT PRIMARY KEY NOT NULL,
280                    issue_id TEXT NOT NULL,
281                    summary TEXT NOT NULL,
282                    status TEXT NOT NULL,
283                    priority TEXT,
284                    priority_rank INTEGER NOT NULL DEFAULT 999,
285                    url TEXT NOT NULL,
286                    first_seen TIMESTAMP NOT NULL,
287                    last_seen TIMESTAMP NOT NULL,
288                    notified INTEGER NOT NULL DEFAULT 0,
289                    pinned INTEGER NOT NULL DEFAULT 0,
290                    dismissed INTEGER NOT NULL DEFAULT 0,
291                    raw_updated TEXT
292                )",
293                [],
294            )?;
295            tx.execute(
296                "CREATE INDEX IF NOT EXISTS idx_jira_inbox_active
297                 ON jira_inbox(dismissed, pinned DESC, priority_rank ASC, last_seen DESC)",
298                [],
299            )?;
300            Ok(())
301        });
302
303        // Version 8: status catalog + scoring/sort_value for inbox ranking
304        self.add_migration(8, "jira_inbox_status_id_and_sort_value", |tx| {
305            tx.execute(
306                "CREATE TABLE IF NOT EXISTS jira_statuses (
307                    id TEXT PRIMARY KEY NOT NULL,
308                    name TEXT NOT NULL
309                )",
310                [],
311            )?;
312            tx.execute("ALTER TABLE jira_inbox ADD COLUMN status_id TEXT", [])?;
313            tx.execute("ALTER TABLE jira_inbox ADD COLUMN sort_value REAL", [])?;
314            tx.execute(
315                "CREATE INDEX IF NOT EXISTS idx_jira_inbox_sort
316                 ON jira_inbox(dismissed, pinned DESC, sort_value DESC, priority_rank ASC)",
317                [],
318            )?;
319            Ok(())
320        });
321
322        // Version 9: wipe legacy status name strings; use status_id + jira_statuses only
323        self.add_migration(9, "clear_jira_inbox_legacy_status_text", |tx| {
324            tx.execute("UPDATE jira_inbox SET status = ''", [])?;
325            Ok(())
326        });
327
328        // Version 10: drop unused legacy status text column (canonical: status_id)
329        self.add_migration(10, "drop_jira_inbox_legacy_status_column", |tx| {
330            tx.execute("ALTER TABLE jira_inbox DROP COLUMN status", [])?;
331            Ok(())
332        });
333
334        // Version 11: fold manual breaks into pauses as protected records.
335        //
336        // The separate `breaks` table held synthetic records whose times were
337        // invented by a placement heuristic; downstream code converted them to
338        // pauses anyway. Manual breaks now live in `pauses` with `protected = 1`,
339        // which exempts them from the duration threshold and from merging with
340        // adjacent pauses. Existing break rows are carried over so historical
341        // reports keep their numbers.
342        self.add_migration(11, "fold_breaks_into_protected_pauses", |tx| {
343            tx.execute("ALTER TABLE pauses ADD COLUMN protected INTEGER NOT NULL DEFAULT 0", [])?;
344            tx.execute("ALTER TABLE pauses ADD COLUMN reason TEXT", [])?;
345
346            // Carry over manual breaks; duration is stored in seconds in `pauses`
347            // but was stored in minutes in `breaks`.
348            tx.execute(
349                "INSERT INTO pauses (start, end, duration, protected, reason)
350                 SELECT start_time, end_time, duration * 60, 1, reason FROM breaks",
351                [],
352            )?;
353
354            tx.execute("DROP INDEX IF EXISTS idx_breaks_date", [])?;
355            tx.execute("DROP TABLE IF EXISTS breaks", [])?;
356            Ok(())
357        });
358
359        // Version 12: inbox reconciliation and change tracking.
360        //
361        // `gone_at` marks issues that stopped appearing in the Jira poll
362        // (closed, reassigned) so the list stops showing them instead of
363        // freezing on the first sync. `last_change`/`changed_at` record the
364        // most recent visible change (status, priority, score) for badges
365        // and toasts.
366        self.add_migration(12, "jira_inbox_gone_and_change_tracking", |tx| {
367            tx.execute("ALTER TABLE jira_inbox ADD COLUMN gone_at TIMESTAMP", [])?;
368            tx.execute("ALTER TABLE jira_inbox ADD COLUMN last_change TEXT", [])?;
369            tx.execute("ALTER TABLE jira_inbox ADD COLUMN changed_at TIMESTAMP", [])?;
370            Ok(())
371        });
372    }
373
374    /// Registers a single migration in the migration system.
375    ///
376    /// This helper method adds a migration to the internal registry with
377    /// proper version ordering and validation. It ensures that migrations
378    /// are stored in a consistent format for later execution.
379    ///
380    /// # Arguments
381    ///
382    /// * `version` - Unique version number for this migration
383    /// * `name` - Descriptive name for the migration's purpose
384    /// * `up` - Function that performs the actual schema transformation
385    ///
386    /// # Panics
387    ///
388    /// Panics if a migration with the same version number is already registered.
389    fn add_migration(&mut self, version: u32, name: &'static str, up: fn(&Transaction) -> Result<()>) {
390        self.migrations.push(Migration { version, name, up });
391    }
392
393    /// Executes all pending migrations in the correct order.
394    ///
395    /// This method performs the complete migration process:
396    /// 1. Creates the migrations tracking table if needed
397    /// 2. Determines current database version
398    /// 3. Identifies pending migrations
399    /// 4. Applies each migration within a transaction
400    /// 5. Records successful migrations in the tracking table
401    ///
402    /// ## Transaction Safety
403    ///
404    /// Each migration runs in its own transaction, ensuring that partial
405    /// failures don't leave the database in an inconsistent state. If any
406    /// migration fails, all changes are rolled back automatically.
407    ///
408    /// # Arguments
409    ///
410    /// * `conn` - Mutable database connection for applying migrations
411    ///
412    /// # Returns
413    ///
414    /// Returns `Ok(())` if all migrations succeed, or an error if any
415    /// migration fails during application.
416    ///
417    /// # Example
418    ///
419    /// ```rust
420    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
421    /// use kasl::db::migrations::MigrationManager;
422    /// use rusqlite::Connection;
423    ///
424    /// let manager = MigrationManager::new();
425    /// let mut conn = Connection::open(":memory:")?;
426    /// manager.run_migrations(&mut conn)?;
427    /// # Ok(())
428    /// # }
429    /// ```
430    pub fn run_migrations(&self, conn: &mut Connection) -> Result<()> {
431        // Initialize the migrations tracking table
432        conn.execute(MIGRATIONS_TABLE, [])?;
433
434        // Determine the current schema version
435        let current_version = self.get_current_version(conn)?;
436
437        // Find all migrations that haven't been applied yet
438        let pending: Vec<&Migration> = self.migrations.iter().filter(|m| m.version > current_version).collect();
439
440        // Exit early if no migrations are needed
441        if pending.is_empty() {
442            msg_debug!("Database is up to date");
443            return Ok(());
444        }
445
446        // Notify user about pending migrations
447        msg_info!(Message::MigrationsFound(pending.len()));
448
449        // Execute all pending migrations within a single transaction
450        let tx = conn.transaction()?;
451
452        for migration in pending {
453            msg_info!(Message::RunningMigration(migration.version, migration.name.to_string()));
454
455            match (migration.up)(&tx) {
456                Ok(()) => {
457                    // Record successful migration in tracking table
458                    tx.execute(
459                        "INSERT INTO migrations (version, name) VALUES (?1, ?2)",
460                        params![migration.version, migration.name],
461                    )?;
462                    msg_success!(Message::MigrationCompleted(migration.version));
463                }
464                Err(e) => {
465                    // Log migration failure and propagate error
466                    msg_error!(Message::MigrationFailed(migration.version, e.to_string()));
467                    return Err(e);
468                }
469            }
470        }
471
472        // Commit all successful migrations
473        tx.commit()?;
474        msg_success!(Message::AllMigrationsCompleted);
475
476        Ok(())
477    }
478
479    /// Retrieves the current database schema version.
480    ///
481    /// This method queries the migrations table to determine the highest
482    /// version number that has been successfully applied. It handles the
483    /// case where no migrations have been applied yet (version 0).
484    ///
485    /// # Arguments
486    ///
487    /// * `conn` - Database connection for querying migration status
488    ///
489    /// # Returns
490    ///
491    /// Returns the current schema version number, or 0 if no migrations
492    /// have been applied yet.
493    fn get_current_version(&self, conn: &Connection) -> Result<u32> {
494        let version: Option<u32> = conn.query_row("SELECT MAX(version) FROM migrations", [], |row| row.get(0)).unwrap_or(Some(0));
495
496        Ok(version.unwrap_or(0))
497    }
498
499    /// Checks if a specific migration version has been applied.
500    ///
501    /// This utility method allows callers to verify whether a particular
502    /// migration has been successfully applied to the database. Useful
503    /// for conditional logic based on schema capabilities.
504    ///
505    /// # Arguments
506    ///
507    /// * `conn` - Database connection for querying migration status  
508    /// * `version` - Migration version number to check
509    ///
510    /// # Returns
511    ///
512    /// Returns `true` if the migration has been applied, `false` otherwise.
513    ///
514    /// # Example
515    ///
516    /// ```rust
517    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
518    /// use kasl::db::migrations::MigrationManager;
519    /// use rusqlite::Connection;
520    ///
521    /// let manager = MigrationManager::new();
522    /// let mut conn = Connection::open(":memory:")?;
523    /// manager.run_migrations(&mut conn)?;
524    /// if manager.is_migration_applied(&conn, 3)? {
525    ///     // Tags system is available
526    /// }
527    /// # Ok(())
528    /// # }
529    /// ```
530    pub fn is_migration_applied(&self, conn: &Connection, version: u32) -> Result<bool> {
531        let count: i32 = conn.query_row("SELECT COUNT(*) FROM migrations WHERE version = ?1", params![version], |row| row.get(0))?;
532
533        Ok(count > 0)
534    }
535
536    /// Retrieves the complete migration history with timestamps.
537    ///
538    /// This method returns a chronological list of all applied migrations,
539    /// including their version numbers, names, and application timestamps.
540    /// Useful for auditing and debugging schema evolution.
541    ///
542    /// # Arguments
543    ///
544    /// * `conn` - Database connection for querying migration history
545    ///
546    /// # Returns
547    ///
548    /// Returns a vector of tuples containing (version, name, applied_at)
549    /// for each applied migration, ordered by version number.
550    ///
551    /// # Example
552    ///
553    /// ```rust
554    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
555    /// use kasl::db::migrations::MigrationManager;
556    /// use rusqlite::Connection;
557    ///
558    /// let manager = MigrationManager::new();
559    /// let mut conn = Connection::open(":memory:")?;
560    /// manager.run_migrations(&mut conn)?;
561    /// let history = manager.get_migration_history(&conn)?;
562    /// for (version, name, applied_at) in history {
563    ///     println!("v{}: {} ({})", version, name, applied_at);
564    /// }
565    /// # Ok(())
566    /// # }
567    /// ```
568    pub fn get_migration_history(&self, conn: &Connection) -> Result<Vec<(u32, String, String)>> {
569        let mut stmt = conn.prepare("SELECT version, name, applied_at FROM migrations ORDER BY version")?;
570
571        let history = stmt
572            .query_map([], |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)))?
573            .collect::<Result<Vec<_>, _>>()?;
574
575        Ok(history)
576    }
577
578    /// Rolls back migrations to a specific target version (debug builds only).
579    ///
580    /// This development utility allows rolling back migrations to a previous
581    /// schema version by removing migration records from the tracking table.
582    ///
583    /// ## ⚠️ Important Notes
584    ///
585    /// - Only available in debug builds for safety
586    /// - This is a simplified rollback that removes migration records
587    /// - Does not actually reverse schema changes (no down() functions)
588    /// - Primarily useful for development and testing scenarios
589    ///
590    /// # Arguments
591    ///
592    /// * `conn` - Mutable database connection for rollback operations
593    /// * `target_version` - Target version to roll back to
594    ///
595    /// # Returns
596    ///
597    /// Returns `Ok(())` if rollback succeeds, or an error if the operation fails.
598    ///
599    /// # Example
600    ///
601    /// ```rust
602    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
603    /// use kasl::db::migrations::MigrationManager;
604    /// use rusqlite::Connection;
605    ///
606    /// #[cfg(debug_assertions)]
607    /// {
608    ///     let manager = MigrationManager::new();
609    ///     let mut conn = Connection::open(":memory:")?;
610    ///     manager.run_migrations(&mut conn)?;
611    ///     manager.rollback_to(&mut conn, 2)?; // Roll back to version 2
612    /// }
613    /// # Ok(())
614    /// # }
615    /// ```
616    #[cfg(debug_assertions)]
617    pub fn rollback_to(&self, conn: &mut Connection, target_version: u32) -> Result<()> {
618        let current_version = self.get_current_version(conn)?;
619
620        if target_version >= current_version {
621            msg_info!(Message::NothingToRollback);
622            return Ok(());
623        }
624
625        msg_info!(Message::RollingBack(current_version, target_version));
626
627        // Remove migration records beyond the target version
628        // Note: This is a simplified rollback that doesn't actually reverse schema changes
629        conn.execute("DELETE FROM migrations WHERE version > ?1", params![target_version])?;
630
631        msg_success!(Message::RollbackCompleted(target_version));
632        Ok(())
633    }
634}
635
636/// Initializes a database connection with all pending migrations applied.
637///
638/// This convenience function creates a migration manager and applies all
639/// pending migrations to the provided connection. It's the recommended
640/// way to ensure a database is up to date with the latest schema.
641///
642/// # Arguments
643///
644/// * `conn` - Mutable database connection to initialize
645///
646/// # Returns
647///
648/// Returns `Ok(())` if initialization succeeds, or an error if migration fails.
649///
650/// # Example
651///
652/// ```rust,no_run
653/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
654/// use kasl::db::migrations::init_with_migrations;
655/// use rusqlite::Connection;
656///
657/// let mut conn = Connection::open("kasl.db")?;
658/// init_with_migrations(&mut conn)?;
659/// # Ok(())
660/// # }
661/// ```
662pub fn init_with_migrations(conn: &mut Connection) -> Result<()> {
663    let manager = MigrationManager::new();
664    manager.run_migrations(conn)?;
665    Ok(())
666}
667
668/// Retrieves the current database schema version.
669///
670/// This utility function provides a simple way to check the current
671/// schema version without creating a full migration manager instance.
672///
673/// # Arguments
674///
675/// * `conn` - Database connection to query
676///
677/// # Returns
678///
679/// Returns the current schema version number.
680///
681/// # Example
682///
683/// ```rust
684/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
685/// use kasl::db::migrations::get_db_version;
686/// use rusqlite::Connection;
687///
688/// let conn = Connection::open(":memory:")?;
689/// let version = get_db_version(&conn)?;
690/// println!("Current schema version: {}", version);
691/// # Ok(())
692/// # }
693/// ```
694pub fn get_db_version(conn: &Connection) -> Result<u32> {
695    let manager = MigrationManager::new();
696    manager.get_current_version(conn)
697}
698
699/// Checks if the database requires migration to the latest schema version.
700///
701/// This utility function compares the current database version with the
702/// latest available migration version to determine if updates are needed.
703///
704/// # Arguments
705///
706/// * `conn` - Database connection to check
707///
708/// # Returns
709///
710/// Returns `true` if migrations are needed, `false` if up to date.
711///
712/// # Example
713///
714/// ```rust
715/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
716/// use kasl::db::migrations::needs_migration;
717/// use rusqlite::Connection;
718///
719/// let conn = Connection::open(":memory:")?;
720/// if needs_migration(&conn)? {
721///     println!("Database needs migration!");
722/// }
723/// # Ok(())
724/// # }
725/// ```
726pub fn needs_migration(conn: &Connection) -> Result<bool> {
727    let manager = MigrationManager::new();
728    let current = manager.get_current_version(conn)?;
729    let latest = manager.migrations.last().map(|m| m.version).unwrap_or(0);
730    Ok(current < latest)
731}