Skip to main content

migratio_cli/
lib.rs

1//! CLI support library for migratio database migrations.
2//!
3//! This crate provides the runtime support for migration runner binaries
4//! generated by `cargo-migratio`. It handles CLI argument parsing and
5//! executes migration commands against the database.
6//!
7//! # Example
8//!
9//! This crate is typically used by auto-generated runner code, but can
10//! also be used directly:
11//!
12//! ```ignore
13//! use migratio_cli::{CliArgs, run_sqlite};
14//! use clap::Parser;
15//!
16//! fn main() -> Result<(), Box<dyn std::error::Error>> {
17//!     let database_url = std::env::var("DATABASE_URL")?;
18//!     let migrator = my_app::migrations::get_migrator();
19//!     let args = CliArgs::parse();
20//!     run_sqlite(migrator, &database_url, args)
21//! }
22//! ```
23//!
24//! For PostgreSQL:
25//!
26//! ```ignore
27//! use migratio_cli::{CliArgs, run_postgres};
28//! use clap::Parser;
29//!
30//! fn main() -> Result<(), Box<dyn std::error::Error>> {
31//!     let database_url = std::env::var("DATABASE_URL")?;
32//!     let migrator = my_app::migrations::get_postgres_migrator();
33//!     let args = CliArgs::parse();
34//!     run_postgres(migrator, &database_url, args)
35//! }
36//! ```
37
38use clap::{Parser, Subcommand};
39
40/// CLI arguments for the migration runner.
41#[derive(Parser, Debug)]
42#[command(name = "migratio-runner")]
43#[command(about = "Database migration runner")]
44pub struct CliArgs {
45    #[command(subcommand)]
46    pub command: Commands,
47}
48
49/// Available migration commands for the runner binary.
50#[derive(Subcommand, Debug)]
51pub enum Commands {
52    /// Show current migration status (requires database)
53    Status,
54    /// Run pending migrations (requires database)
55    Upgrade {
56        /// Target version (default: latest)
57        #[arg(long)]
58        to: Option<u32>,
59    },
60    /// Rollback migrations (requires database)
61    Downgrade {
62        /// Target version to rollback to
63        #[arg(long)]
64        to: u32,
65    },
66    /// Show migration history (requires database)
67    History,
68    /// Preview pending migrations without running them (requires database)
69    Preview,
70    /// List all migrations defined in the migrator (no database required)
71    List,
72    /// Show the resulting schema after applying all migrations (sqlite only; no database required)
73    Schema {
74        /// Write the schema to a file instead of stdout
75        #[arg(short, long)]
76        output: Option<std::path::PathBuf>,
77    },
78}
79
80#[cfg(feature = "sqlite")]
81pub use sqlite::{run_sqlite, schema_sqlite};
82
83#[cfg(feature = "sqlite")]
84mod sqlite {
85    use super::{CliArgs, Commands};
86    use migratio::sqlite::SqliteMigrator;
87    use rusqlite::Connection;
88
89    /// Run the CLI with a SQLite migrator.
90    ///
91    /// This function handles all CLI commands for SQLite databases.
92    ///
93    /// # Arguments
94    ///
95    /// * `migrator` - The configured SQLite migrator
96    /// * `database_url` - Path to the SQLite database file
97    /// * `args` - Parsed CLI arguments
98    ///
99    /// # Returns
100    ///
101    /// Returns `Ok(())` on success, or an error if the operation fails.
102    pub fn run_sqlite(
103        migrator: SqliteMigrator,
104        database_url: &str,
105        args: CliArgs,
106    ) -> Result<(), Box<dyn std::error::Error>> {
107        // Schema doesn't need the configured database at all - it runs migrations
108        // against a throwaway in-memory database instead.
109        let command = match args.command {
110            Commands::Schema { output } => return schema_sqlite(migrator, output),
111            command => command,
112        };
113
114        // Check if the database file exists before opening
115        // (SQLite will create a new file if it doesn't exist, which we don't want)
116        if !std::path::Path::new(database_url).exists() {
117            return Err(format!(
118                "Database file not found: {}\n\nTo create a new database, first create the file manually or use your application's initialization logic.",
119                database_url
120            ).into());
121        }
122
123        let mut conn = Connection::open(database_url)?;
124
125        match command {
126            Commands::Status => {
127                let version = migrator.get_current_version(&mut conn)?;
128                let pending = migrator.preview_upgrade(&mut conn)?;
129                println!("Current version: {}", version);
130                println!("Pending migrations: {}", pending.len());
131                for m in pending {
132                    println!("  - {} (v{})", m.name(), m.version());
133                }
134            }
135            Commands::Upgrade { to } => {
136                let report = match to {
137                    Some(target) => migrator.upgrade_to(&mut conn, target)?,
138                    None => migrator.upgrade(&mut conn)?,
139                };
140                if report.migrations_run.is_empty() {
141                    println!("No migrations to run.");
142                } else {
143                    println!("Migrations run: {:?}", report.migrations_run);
144                }
145            }
146            Commands::Downgrade { to } => {
147                let report = migrator.downgrade(&mut conn, to)?;
148                if report.migrations_run.is_empty() {
149                    println!("No migrations to roll back.");
150                } else {
151                    println!("Migrations rolled back: {:?}", report.migrations_run);
152                }
153            }
154            Commands::History => {
155                let history = migrator.get_migration_history(&mut conn)?;
156                if history.is_empty() {
157                    println!("No migrations have been applied yet.");
158                } else {
159                    println!("Migration history:");
160                    for entry in history {
161                        println!(
162                            "  v{}: {} [{}] (applied {})",
163                            entry.version, entry.name, entry.migration_type, entry.applied_at
164                        );
165                    }
166                }
167            }
168            Commands::Preview => {
169                let pending = migrator.preview_upgrade(&mut conn)?;
170                if pending.is_empty() {
171                    println!("No pending migrations.");
172                } else {
173                    println!("Pending migrations:");
174                    for m in pending {
175                        println!("  - {} (v{})", m.name(), m.version());
176                        if let Some(desc) = m.description() {
177                            println!("    {}", desc);
178                        }
179                    }
180                }
181            }
182            Commands::List => {
183                // This command is handled before run_sqlite is called.
184                // If we get here, something went wrong.
185                unreachable!("List command should be handled before run_sqlite");
186            }
187            Commands::Schema { .. } => {
188                unreachable!("Schema command is handled before run_sqlite reaches this match")
189            }
190        }
191
192        Ok(())
193    }
194
195    /// Renders the schema DDL as a string. Pure — no I/O beyond reading the given connection.
196    fn render_schema(conn: &Connection, migrations_applied: usize) -> Result<String, rusqlite::Error> {
197        let mut stmt = conn.prepare(
198            "SELECT sql FROM sqlite_master \
199             WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name != '_migratio_version_' \
200             ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'view' THEN 2 WHEN 'trigger' THEN 3 ELSE 4 END, name",
201        )?;
202        let statements: Vec<String> = stmt
203            .query_map([], |row| row.get(0))?
204            .collect::<Result<Vec<_>, _>>()?;
205
206        let mut output = format!(
207            "-- Schema generated by migratio ({} migrations applied)\n\
208             -- Note: the _migratio_version_ bookkeeping table is excluded.\n\n",
209            migrations_applied
210        );
211        output.push_str(
212            &statements
213                .iter()
214                .map(|sql| format!("{};\n", sql))
215                .collect::<Vec<_>>()
216                .join("\n"),
217        );
218
219        Ok(output)
220    }
221
222    /// Runs all migrations against an in-memory sqlite db and outputs the resulting schema.
223    pub fn schema_sqlite(
224        migrator: SqliteMigrator,
225        output: Option<std::path::PathBuf>,
226    ) -> Result<(), Box<dyn std::error::Error>> {
227        let mut conn = Connection::open_in_memory()?;
228        let report = migrator.upgrade(&mut conn)?;
229        let schema = render_schema(&conn, report.migrations_run.len())?;
230        match output {
231            Some(path) => std::fs::write(path, schema)?,
232            None => println!("{}", schema),
233        }
234        Ok(())
235    }
236
237    #[cfg(test)]
238    mod tests {
239        use super::*;
240        use migratio::sql_migration;
241
242        #[test]
243        fn render_schema_single_migration() {
244            sql_migration!(CreateUsers, 1, "Create users",
245                up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
246                down: "DROP TABLE users"
247            );
248
249            let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
250            let mut conn = Connection::open_in_memory().unwrap();
251            let report = migrator.upgrade(&mut conn).unwrap();
252
253            let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
254
255            assert!(schema.contains("CREATE TABLE users"));
256            assert!(schema.contains(';'));
257        }
258
259        #[test]
260        fn render_schema_multiple_migrations() {
261            sql_migration!(CreateUsers, 1, "Create users",
262                up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
263                down: "DROP TABLE users"
264            );
265            sql_migration!(CreatePosts, 2, "Create posts",
266                up: [
267                    "CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT)",
268                    "CREATE INDEX idx_posts_user ON posts(user_id)"
269                ],
270                down: [
271                    "DROP INDEX idx_posts_user",
272                    "DROP TABLE posts"
273                ]
274            );
275
276            let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers), Box::new(CreatePosts)]);
277            let mut conn = Connection::open_in_memory().unwrap();
278            let report = migrator.upgrade(&mut conn).unwrap();
279
280            let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
281
282            assert!(schema.contains("CREATE TABLE users"));
283            assert!(schema.contains("CREATE TABLE posts"));
284            assert!(schema.contains("CREATE INDEX idx_posts_user"));
285        }
286
287        #[test]
288        fn render_schema_orders_tables_then_index_then_view_then_trigger() {
289            sql_migration!(CreateAll, 1, "Create everything, scrambled",
290                up: [
291                    "CREATE TABLE b (id INTEGER PRIMARY KEY)",
292                    "CREATE TABLE a (id INTEGER PRIMARY KEY)",
293                    "CREATE VIEW v AS SELECT * FROM a",
294                    "CREATE INDEX idx_b ON b(id)",
295                    "CREATE TRIGGER trg AFTER INSERT ON a BEGIN SELECT 1; END"
296                ],
297                down: [
298                    "DROP TRIGGER trg",
299                    "DROP INDEX idx_b",
300                    "DROP VIEW v",
301                    "DROP TABLE a",
302                    "DROP TABLE b"
303                ]
304            );
305
306            let migrator = SqliteMigrator::new(vec![Box::new(CreateAll)]);
307            let mut conn = Connection::open_in_memory().unwrap();
308            let report = migrator.upgrade(&mut conn).unwrap();
309
310            let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
311
312            let a = schema.find("CREATE TABLE a").unwrap();
313            let b = schema.find("CREATE TABLE b").unwrap();
314            let idx_b = schema.find("CREATE INDEX idx_b").unwrap();
315            let v = schema.find("CREATE VIEW v").unwrap();
316            let trg = schema.find("CREATE TRIGGER trg").unwrap();
317
318            assert!(a < b, "table a should come before table b");
319            assert!(b < idx_b, "tables should come before indexes");
320            assert!(idx_b < v, "indexes should come before views");
321            assert!(v < trg, "views should come before triggers");
322        }
323
324        #[test]
325        fn render_schema_excludes_migratio_bookkeeping_table() {
326            sql_migration!(CreateUsers, 1, "Create users",
327                up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
328                down: "DROP TABLE users"
329            );
330
331            let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
332            let mut conn = Connection::open_in_memory().unwrap();
333            let report = migrator.upgrade(&mut conn).unwrap();
334
335            let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
336
337            assert!(!schema.contains("CREATE TABLE _migratio_version_"));
338        }
339
340        #[test]
341        fn render_schema_excludes_sqlite_internal_tables() {
342            sql_migration!(CreateUsers, 1, "Create users with autoincrement",
343                up: "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)",
344                down: "DROP TABLE users"
345            );
346
347            let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
348            let mut conn = Connection::open_in_memory().unwrap();
349            let report = migrator.upgrade(&mut conn).unwrap();
350
351            let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
352
353            assert!(!schema.contains("sqlite_sequence"));
354        }
355
356        #[test]
357        fn render_schema_empty_migrator() {
358            let migrator = SqliteMigrator::new(vec![]);
359            let mut conn = Connection::open_in_memory().unwrap();
360            let report = migrator.upgrade(&mut conn).unwrap();
361
362            let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
363
364            assert!(schema.contains("0 migrations applied"));
365            assert!(!schema.contains("CREATE TABLE"));
366        }
367
368        #[test]
369        fn render_schema_banner_reports_migration_count() {
370            sql_migration!(M1, 1, "one", up: "CREATE TABLE t1 (id INTEGER PRIMARY KEY)", down: "DROP TABLE t1");
371            sql_migration!(M2, 2, "two", up: "CREATE TABLE t2 (id INTEGER PRIMARY KEY)", down: "DROP TABLE t2");
372            sql_migration!(M3, 3, "three", up: "CREATE TABLE t3 (id INTEGER PRIMARY KEY)", down: "DROP TABLE t3");
373
374            let migrator =
375                SqliteMigrator::new(vec![Box::new(M1), Box::new(M2), Box::new(M3)]);
376            let mut conn = Connection::open_in_memory().unwrap();
377            let report = migrator.upgrade(&mut conn).unwrap();
378
379            let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
380
381            assert!(schema.contains("3 migrations applied"));
382        }
383
384        #[test]
385        fn render_schema_banner_notes_bookkeeping_table_exclusion() {
386            let migrator = SqliteMigrator::new(vec![]);
387            let mut conn = Connection::open_in_memory().unwrap();
388            let report = migrator.upgrade(&mut conn).unwrap();
389
390            let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
391
392            assert!(schema.contains("_migratio_version_"));
393        }
394
395        #[test]
396        fn schema_sqlite_writes_to_output_file() {
397            sql_migration!(CreateUsers, 1, "Create users",
398                up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
399                down: "DROP TABLE users"
400            );
401
402            let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
403            let path = std::env::temp_dir()
404                .join("migratio_cli_test_schema_sqlite_writes_to_output_file.sql");
405
406            let result = schema_sqlite(migrator, Some(path.clone()));
407
408            assert!(result.is_ok());
409            let contents = std::fs::read_to_string(&path).unwrap();
410            assert!(contents.contains("CREATE TABLE users"));
411
412            std::fs::remove_file(&path).ok();
413        }
414
415        #[test]
416        fn schema_sqlite_overwrites_existing_file_silently() {
417            sql_migration!(CreateUsers, 1, "Create users",
418                up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
419                down: "DROP TABLE users"
420            );
421
422            let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
423            let path = std::env::temp_dir()
424                .join("migratio_cli_test_schema_sqlite_overwrites_existing_file_silently.sql");
425            std::fs::write(&path, "old content").unwrap();
426
427            let result = schema_sqlite(migrator, Some(path.clone()));
428
429            assert!(result.is_ok());
430            let contents = std::fs::read_to_string(&path).unwrap();
431            assert!(!contents.contains("old content"));
432            assert!(contents.contains("CREATE TABLE users"));
433
434            std::fs::remove_file(&path).ok();
435        }
436
437        #[test]
438        fn schema_sqlite_prints_to_stdout_when_no_output_given() {
439            sql_migration!(CreateUsers, 1, "Create users",
440                up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
441                down: "DROP TABLE users"
442            );
443
444            let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
445
446            let result = schema_sqlite(migrator, None);
447
448            assert!(result.is_ok());
449        }
450    }
451}
452
453#[cfg(feature = "mysql")]
454pub use mysql_support::run_mysql;
455
456#[cfg(feature = "postgres")]
457pub use postgres_support::run_postgres;
458
459#[cfg(feature = "mysql")]
460mod mysql_support {
461    use super::{CliArgs, Commands};
462    use migratio::mysql::MysqlMigrator;
463    use mysql::{Conn, Opts};
464
465    /// Run the CLI with a MySQL migrator.
466    ///
467    /// This function handles all CLI commands for MySQL databases.
468    ///
469    /// # Arguments
470    ///
471    /// * `migrator` - The configured MySQL migrator
472    /// * `database_url` - MySQL connection URL
473    /// * `args` - Parsed CLI arguments
474    ///
475    /// # Returns
476    ///
477    /// Returns `Ok(())` on success, or an error if the operation fails.
478    pub fn run_mysql(
479        migrator: MysqlMigrator,
480        database_url: &str,
481        args: CliArgs,
482    ) -> Result<(), Box<dyn std::error::Error>> {
483        if let Commands::Schema { .. } = &args.command {
484            return Err("the `schema` command is only supported for sqlite databases".into());
485        }
486
487        let opts = Opts::from_url(database_url)?;
488        let mut conn = Conn::new(opts)?;
489
490        match args.command {
491            Commands::Status => {
492                let version = migrator.get_current_version(&mut conn)?;
493                let pending = migrator.preview_upgrade(&mut conn)?;
494                println!("Current version: {}", version);
495                println!("Pending migrations: {}", pending.len());
496                for m in pending {
497                    println!("  - {} (v{})", m.name(), m.version());
498                }
499            }
500            Commands::Upgrade { to } => {
501                let report = match to {
502                    Some(target) => migrator.upgrade_to(&mut conn, target)?,
503                    None => migrator.upgrade(&mut conn)?,
504                };
505                if report.migrations_run.is_empty() {
506                    println!("No migrations to run.");
507                } else {
508                    println!("Migrations run: {:?}", report.migrations_run);
509                }
510            }
511            Commands::Downgrade { to } => {
512                let report = migrator.downgrade(&mut conn, to)?;
513                if report.migrations_run.is_empty() {
514                    println!("No migrations to roll back.");
515                } else {
516                    println!("Migrations rolled back: {:?}", report.migrations_run);
517                }
518            }
519            Commands::History => {
520                let history = migrator.get_migration_history(&mut conn)?;
521                if history.is_empty() {
522                    println!("No migrations have been applied yet.");
523                } else {
524                    println!("Migration history:");
525                    for entry in history {
526                        println!(
527                            "  v{}: {} [{}] (applied {})",
528                            entry.version, entry.name, entry.migration_type, entry.applied_at
529                        );
530                    }
531                }
532            }
533            Commands::Preview => {
534                let pending = migrator.preview_upgrade(&mut conn)?;
535                if pending.is_empty() {
536                    println!("No pending migrations.");
537                } else {
538                    println!("Pending migrations:");
539                    for m in pending {
540                        println!("  - {} (v{})", m.name(), m.version());
541                        if let Some(desc) = m.description() {
542                            println!("    {}", desc);
543                        }
544                    }
545                }
546            }
547            Commands::List => {
548                // This command is handled before run_mysql is called.
549                // If we get here, something went wrong.
550                unreachable!("List command should be handled before run_mysql");
551            }
552            Commands::Schema { .. } => {
553                unreachable!("Schema command is rejected before run_mysql reaches this match")
554            }
555        }
556
557        Ok(())
558    }
559}
560
561#[cfg(feature = "postgres")]
562mod postgres_support {
563    use super::{CliArgs, Commands};
564    use migratio::postgres::PostgresMigrator;
565    use postgres::{Client, NoTls};
566
567    /// Run the CLI with a PostgreSQL migrator.
568    ///
569    /// This function handles all CLI commands for PostgreSQL databases.
570    ///
571    /// # Arguments
572    ///
573    /// * `migrator` - The configured PostgreSQL migrator
574    /// * `database_url` - PostgreSQL connection URL
575    /// * `args` - Parsed CLI arguments
576    ///
577    /// # Returns
578    ///
579    /// Returns `Ok(())` on success, or an error if the operation fails.
580    pub fn run_postgres(
581        migrator: PostgresMigrator,
582        database_url: &str,
583        args: CliArgs,
584    ) -> Result<(), Box<dyn std::error::Error>> {
585        if let Commands::Schema { .. } = &args.command {
586            return Err("the `schema` command is only supported for sqlite databases".into());
587        }
588
589        let mut client = Client::connect(database_url, NoTls)?;
590
591        match args.command {
592            Commands::Status => {
593                let version = migrator.get_current_version(&mut client)?;
594                let pending = migrator.preview_upgrade(&mut client)?;
595                println!("Current version: {}", version);
596                println!("Pending migrations: {}", pending.len());
597                for m in pending {
598                    println!("  - {} (v{})", m.name(), m.version());
599                }
600            }
601            Commands::Upgrade { to } => {
602                let report = match to {
603                    Some(target) => migrator.upgrade_to(&mut client, target)?,
604                    None => migrator.upgrade(&mut client)?,
605                };
606                if report.migrations_run.is_empty() {
607                    println!("No migrations to run.");
608                } else {
609                    println!("Migrations run: {:?}", report.migrations_run);
610                }
611            }
612            Commands::Downgrade { to } => {
613                let report = migrator.downgrade(&mut client, to)?;
614                if report.migrations_run.is_empty() {
615                    println!("No migrations to roll back.");
616                } else {
617                    println!("Migrations rolled back: {:?}", report.migrations_run);
618                }
619            }
620            Commands::History => {
621                let history = migrator.get_migration_history(&mut client)?;
622                if history.is_empty() {
623                    println!("No migrations have been applied yet.");
624                } else {
625                    println!("Migration history:");
626                    for entry in history {
627                        println!(
628                            "  v{}: {} [{}] (applied {})",
629                            entry.version, entry.name, entry.migration_type, entry.applied_at
630                        );
631                    }
632                }
633            }
634            Commands::Preview => {
635                let pending = migrator.preview_upgrade(&mut client)?;
636                if pending.is_empty() {
637                    println!("No pending migrations.");
638                } else {
639                    println!("Pending migrations:");
640                    for m in pending {
641                        println!("  - {} (v{})", m.name(), m.version());
642                        if let Some(desc) = m.description() {
643                            println!("    {}", desc);
644                        }
645                    }
646                }
647            }
648            Commands::List => {
649                // This command is handled before run_postgres is called.
650                // If we get here, something went wrong.
651                unreachable!("List command should be handled before run_postgres");
652            }
653            Commands::Schema { .. } => {
654                unreachable!("Schema command is rejected before run_postgres reaches this match")
655            }
656        }
657
658        Ok(())
659    }
660}