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
360    /// Registers a single migration in the migration system.
361    ///
362    /// This helper method adds a migration to the internal registry with
363    /// proper version ordering and validation. It ensures that migrations
364    /// are stored in a consistent format for later execution.
365    ///
366    /// # Arguments
367    ///
368    /// * `version` - Unique version number for this migration
369    /// * `name` - Descriptive name for the migration's purpose
370    /// * `up` - Function that performs the actual schema transformation
371    ///
372    /// # Panics
373    ///
374    /// Panics if a migration with the same version number is already registered.
375    fn add_migration(&mut self, version: u32, name: &'static str, up: fn(&Transaction) -> Result<()>) {
376        self.migrations.push(Migration { version, name, up });
377    }
378
379    /// Executes all pending migrations in the correct order.
380    ///
381    /// This method performs the complete migration process:
382    /// 1. Creates the migrations tracking table if needed
383    /// 2. Determines current database version
384    /// 3. Identifies pending migrations
385    /// 4. Applies each migration within a transaction
386    /// 5. Records successful migrations in the tracking table
387    ///
388    /// ## Transaction Safety
389    ///
390    /// Each migration runs in its own transaction, ensuring that partial
391    /// failures don't leave the database in an inconsistent state. If any
392    /// migration fails, all changes are rolled back automatically.
393    ///
394    /// # Arguments
395    ///
396    /// * `conn` - Mutable database connection for applying migrations
397    ///
398    /// # Returns
399    ///
400    /// Returns `Ok(())` if all migrations succeed, or an error if any
401    /// migration fails during application.
402    ///
403    /// # Example
404    ///
405    /// ```rust
406    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
407    /// use kasl::db::migrations::MigrationManager;
408    /// use rusqlite::Connection;
409    ///
410    /// let manager = MigrationManager::new();
411    /// let mut conn = Connection::open(":memory:")?;
412    /// manager.run_migrations(&mut conn)?;
413    /// # Ok(())
414    /// # }
415    /// ```
416    pub fn run_migrations(&self, conn: &mut Connection) -> Result<()> {
417        // Initialize the migrations tracking table
418        conn.execute(MIGRATIONS_TABLE, [])?;
419
420        // Determine the current schema version
421        let current_version = self.get_current_version(conn)?;
422
423        // Find all migrations that haven't been applied yet
424        let pending: Vec<&Migration> = self.migrations.iter().filter(|m| m.version > current_version).collect();
425
426        // Exit early if no migrations are needed
427        if pending.is_empty() {
428            msg_debug!("Database is up to date");
429            return Ok(());
430        }
431
432        // Notify user about pending migrations
433        msg_info!(Message::MigrationsFound(pending.len()));
434
435        // Execute all pending migrations within a single transaction
436        let tx = conn.transaction()?;
437
438        for migration in pending {
439            msg_info!(Message::RunningMigration(migration.version, migration.name.to_string()));
440
441            match (migration.up)(&tx) {
442                Ok(()) => {
443                    // Record successful migration in tracking table
444                    tx.execute(
445                        "INSERT INTO migrations (version, name) VALUES (?1, ?2)",
446                        params![migration.version, migration.name],
447                    )?;
448                    msg_success!(Message::MigrationCompleted(migration.version));
449                }
450                Err(e) => {
451                    // Log migration failure and propagate error
452                    msg_error!(Message::MigrationFailed(migration.version, e.to_string()));
453                    return Err(e);
454                }
455            }
456        }
457
458        // Commit all successful migrations
459        tx.commit()?;
460        msg_success!(Message::AllMigrationsCompleted);
461
462        Ok(())
463    }
464
465    /// Retrieves the current database schema version.
466    ///
467    /// This method queries the migrations table to determine the highest
468    /// version number that has been successfully applied. It handles the
469    /// case where no migrations have been applied yet (version 0).
470    ///
471    /// # Arguments
472    ///
473    /// * `conn` - Database connection for querying migration status
474    ///
475    /// # Returns
476    ///
477    /// Returns the current schema version number, or 0 if no migrations
478    /// have been applied yet.
479    fn get_current_version(&self, conn: &Connection) -> Result<u32> {
480        let version: Option<u32> = conn.query_row("SELECT MAX(version) FROM migrations", [], |row| row.get(0)).unwrap_or(Some(0));
481
482        Ok(version.unwrap_or(0))
483    }
484
485    /// Checks if a specific migration version has been applied.
486    ///
487    /// This utility method allows callers to verify whether a particular
488    /// migration has been successfully applied to the database. Useful
489    /// for conditional logic based on schema capabilities.
490    ///
491    /// # Arguments
492    ///
493    /// * `conn` - Database connection for querying migration status  
494    /// * `version` - Migration version number to check
495    ///
496    /// # Returns
497    ///
498    /// Returns `true` if the migration has been applied, `false` otherwise.
499    ///
500    /// # Example
501    ///
502    /// ```rust
503    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
504    /// use kasl::db::migrations::MigrationManager;
505    /// use rusqlite::Connection;
506    ///
507    /// let manager = MigrationManager::new();
508    /// let mut conn = Connection::open(":memory:")?;
509    /// manager.run_migrations(&mut conn)?;
510    /// if manager.is_migration_applied(&conn, 3)? {
511    ///     // Tags system is available
512    /// }
513    /// # Ok(())
514    /// # }
515    /// ```
516    pub fn is_migration_applied(&self, conn: &Connection, version: u32) -> Result<bool> {
517        let count: i32 = conn.query_row("SELECT COUNT(*) FROM migrations WHERE version = ?1", params![version], |row| row.get(0))?;
518
519        Ok(count > 0)
520    }
521
522    /// Retrieves the complete migration history with timestamps.
523    ///
524    /// This method returns a chronological list of all applied migrations,
525    /// including their version numbers, names, and application timestamps.
526    /// Useful for auditing and debugging schema evolution.
527    ///
528    /// # Arguments
529    ///
530    /// * `conn` - Database connection for querying migration history
531    ///
532    /// # Returns
533    ///
534    /// Returns a vector of tuples containing (version, name, applied_at)
535    /// for each applied migration, ordered by version number.
536    ///
537    /// # Example
538    ///
539    /// ```rust
540    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
541    /// use kasl::db::migrations::MigrationManager;
542    /// use rusqlite::Connection;
543    ///
544    /// let manager = MigrationManager::new();
545    /// let mut conn = Connection::open(":memory:")?;
546    /// manager.run_migrations(&mut conn)?;
547    /// let history = manager.get_migration_history(&conn)?;
548    /// for (version, name, applied_at) in history {
549    ///     println!("v{}: {} ({})", version, name, applied_at);
550    /// }
551    /// # Ok(())
552    /// # }
553    /// ```
554    pub fn get_migration_history(&self, conn: &Connection) -> Result<Vec<(u32, String, String)>> {
555        let mut stmt = conn.prepare("SELECT version, name, applied_at FROM migrations ORDER BY version")?;
556
557        let history = stmt
558            .query_map([], |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)))?
559            .collect::<Result<Vec<_>, _>>()?;
560
561        Ok(history)
562    }
563
564    /// Rolls back migrations to a specific target version (debug builds only).
565    ///
566    /// This development utility allows rolling back migrations to a previous
567    /// schema version by removing migration records from the tracking table.
568    ///
569    /// ## ⚠️ Important Notes
570    ///
571    /// - Only available in debug builds for safety
572    /// - This is a simplified rollback that removes migration records
573    /// - Does not actually reverse schema changes (no down() functions)
574    /// - Primarily useful for development and testing scenarios
575    ///
576    /// # Arguments
577    ///
578    /// * `conn` - Mutable database connection for rollback operations
579    /// * `target_version` - Target version to roll back to
580    ///
581    /// # Returns
582    ///
583    /// Returns `Ok(())` if rollback succeeds, or an error if the operation fails.
584    ///
585    /// # Example
586    ///
587    /// ```rust
588    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
589    /// use kasl::db::migrations::MigrationManager;
590    /// use rusqlite::Connection;
591    ///
592    /// #[cfg(debug_assertions)]
593    /// {
594    ///     let manager = MigrationManager::new();
595    ///     let mut conn = Connection::open(":memory:")?;
596    ///     manager.run_migrations(&mut conn)?;
597    ///     manager.rollback_to(&mut conn, 2)?; // Roll back to version 2
598    /// }
599    /// # Ok(())
600    /// # }
601    /// ```
602    #[cfg(debug_assertions)]
603    pub fn rollback_to(&self, conn: &mut Connection, target_version: u32) -> Result<()> {
604        let current_version = self.get_current_version(conn)?;
605
606        if target_version >= current_version {
607            msg_info!(Message::NothingToRollback);
608            return Ok(());
609        }
610
611        msg_info!(Message::RollingBack(current_version, target_version));
612
613        // Remove migration records beyond the target version
614        // Note: This is a simplified rollback that doesn't actually reverse schema changes
615        conn.execute("DELETE FROM migrations WHERE version > ?1", params![target_version])?;
616
617        msg_success!(Message::RollbackCompleted(target_version));
618        Ok(())
619    }
620}
621
622/// Initializes a database connection with all pending migrations applied.
623///
624/// This convenience function creates a migration manager and applies all
625/// pending migrations to the provided connection. It's the recommended
626/// way to ensure a database is up to date with the latest schema.
627///
628/// # Arguments
629///
630/// * `conn` - Mutable database connection to initialize
631///
632/// # Returns
633///
634/// Returns `Ok(())` if initialization succeeds, or an error if migration fails.
635///
636/// # Example
637///
638/// ```rust,no_run
639/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
640/// use kasl::db::migrations::init_with_migrations;
641/// use rusqlite::Connection;
642///
643/// let mut conn = Connection::open("kasl.db")?;
644/// init_with_migrations(&mut conn)?;
645/// # Ok(())
646/// # }
647/// ```
648pub fn init_with_migrations(conn: &mut Connection) -> Result<()> {
649    let manager = MigrationManager::new();
650    manager.run_migrations(conn)?;
651    Ok(())
652}
653
654/// Retrieves the current database schema version.
655///
656/// This utility function provides a simple way to check the current
657/// schema version without creating a full migration manager instance.
658///
659/// # Arguments
660///
661/// * `conn` - Database connection to query
662///
663/// # Returns
664///
665/// Returns the current schema version number.
666///
667/// # Example
668///
669/// ```rust
670/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
671/// use kasl::db::migrations::get_db_version;
672/// use rusqlite::Connection;
673///
674/// let conn = Connection::open(":memory:")?;
675/// let version = get_db_version(&conn)?;
676/// println!("Current schema version: {}", version);
677/// # Ok(())
678/// # }
679/// ```
680pub fn get_db_version(conn: &Connection) -> Result<u32> {
681    let manager = MigrationManager::new();
682    manager.get_current_version(conn)
683}
684
685/// Checks if the database requires migration to the latest schema version.
686///
687/// This utility function compares the current database version with the
688/// latest available migration version to determine if updates are needed.
689///
690/// # Arguments
691///
692/// * `conn` - Database connection to check
693///
694/// # Returns
695///
696/// Returns `true` if migrations are needed, `false` if up to date.
697///
698/// # Example
699///
700/// ```rust
701/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
702/// use kasl::db::migrations::needs_migration;
703/// use rusqlite::Connection;
704///
705/// let conn = Connection::open(":memory:")?;
706/// if needs_migration(&conn)? {
707///     println!("Database needs migration!");
708/// }
709/// # Ok(())
710/// # }
711/// ```
712pub fn needs_migration(conn: &Connection) -> Result<bool> {
713    let manager = MigrationManager::new();
714    let current = manager.get_current_version(conn)?;
715    let latest = manager.migrations.last().map(|m| m.version).unwrap_or(0);
716    Ok(current < latest)
717}