Skip to main content

prax_cli/commands/
migrate.rs

1//! `prax migrate` commands - Database migration management.
2
3use std::path::{Path, PathBuf};
4
5use crate::cli::MigrateArgs;
6use crate::commands::seed::{SeedRunner, find_seed_file, get_database_url};
7use crate::config::{CONFIG_FILE_NAME, Config, MIGRATIONS_DIR, SCHEMA_FILE_PATH};
8use crate::error::{CliError, CliResult};
9use crate::output::{self, success, warn};
10
11/// Run the migrate command
12pub async fn run(args: MigrateArgs) -> CliResult<()> {
13    match args.command {
14        crate::cli::MigrateSubcommand::Dev(dev_args) => run_dev(dev_args).await,
15        crate::cli::MigrateSubcommand::Deploy => run_deploy().await,
16        crate::cli::MigrateSubcommand::Reset(reset_args) => run_reset(reset_args).await,
17        crate::cli::MigrateSubcommand::Status => run_status().await,
18        crate::cli::MigrateSubcommand::Resolve(resolve_args) => run_resolve(resolve_args).await,
19        crate::cli::MigrateSubcommand::Diff(diff_args) => run_diff(diff_args).await,
20        crate::cli::MigrateSubcommand::Rollback(rollback_args) => run_rollback(rollback_args).await,
21        crate::cli::MigrateSubcommand::History(history_args) => run_history(history_args).await,
22    }
23}
24
25/// Run `prax migrate dev` - development migration workflow
26async fn run_dev(args: crate::cli::MigrateDevArgs) -> CliResult<()> {
27    output::header("Migrate Dev");
28
29    let cwd = std::env::current_dir()?;
30    let config = load_config(&cwd)?;
31
32    let migrations_dir = cwd.join(MIGRATIONS_DIR);
33
34    let display_path = args
35        .schema
36        .as_deref()
37        .map(|p| p.display().to_string())
38        .unwrap_or_else(|| SCHEMA_FILE_PATH.to_string());
39    output::kv("Schema", &display_path);
40    output::kv("Migrations", &migrations_dir.display().to_string());
41    output::newline();
42
43    // Determine total steps (5 or 6 depending on seed)
44    let total_steps = if args.skip_seed { 5 } else { 6 };
45
46    // 1. Parse and validate schema
47    output::step(1, total_steps, "Parsing schema...");
48    let loaded = crate::schema_loader::load_schema(args.schema.as_deref())?;
49    let schema = loaded.schema;
50
51    // 2. Check for pending migrations
52    output::step(2, total_steps, "Checking migration status...");
53    let pending = check_pending_migrations(&migrations_dir)?;
54
55    if !pending.is_empty() {
56        output::list(&format!("{} pending migrations found:", pending.len()));
57        for migration in &pending {
58            output::list_item(&migration.display().to_string());
59        }
60        output::newline();
61    }
62
63    // 3. Diff schema against database
64    output::step(3, total_steps, "Comparing schema to database...");
65    let migration_name = args
66        .name
67        .unwrap_or_else(|| format!("migration_{}", chrono::Utc::now().format("%Y%m%d%H%M%S")));
68
69    // Resolve the current database structure as the diff source (introspected),
70    // or None when no database is reachable (greenfield → full-creation DDL).
71    let source = resolve_source_schema(&config).await?;
72    let migration_sql = generate_migration_sql(&schema, source, &config, args.allow_destructive)?;
73
74    // 4. Generate migration
75    output::step(4, total_steps, "Generating migration...");
76    if migration_sql.trim().is_empty() {
77        output::newline();
78        success("No changes: the database already matches the schema. No migration created.");
79        return Ok(());
80    }
81    let migration_path = create_migration(&migrations_dir, &migration_name, &migration_sql)?;
82
83    // 5. Apply migration (if not --create-only)
84    if !args.create_only {
85        output::step(5, total_steps, "Applying migration...");
86        apply_migration(&migration_path, &config).await?;
87    } else {
88        output::step(5, total_steps, "Skipping apply (--create-only)...");
89    }
90
91    // 6. Run seed (if not --skip-seed)
92    if !args.skip_seed && !args.create_only {
93        output::step(6, total_steps, "Running seed...");
94
95        if let Some(seed_path) = find_seed_file(&cwd, &config) {
96            let database_url = get_database_url(&config)?;
97            let runner = SeedRunner::new(
98                seed_path,
99                database_url,
100                config.database.provider.clone(),
101                cwd.clone(),
102            )?;
103
104            match runner.run().await {
105                Ok(result) => {
106                    output::list_item(&format!("Seeded {} records", result.records_affected));
107                }
108                Err(e) => {
109                    output::warn(&format!("Seed failed: {}. Continuing...", e));
110                }
111            }
112        } else {
113            output::list_item("No seed file found, skipping");
114        }
115    }
116
117    output::newline();
118    success(&format!("Migration '{}' created", migration_name));
119
120    output::newline();
121    output::section("Next steps");
122    output::list_item("Review the generated migration SQL");
123    output::list_item("Run `prax generate` to update your client");
124
125    Ok(())
126}
127
128/// Run `prax migrate deploy` - production deployment
129async fn run_deploy() -> CliResult<()> {
130    output::header("Migrate Deploy");
131
132    let cwd = std::env::current_dir()?;
133    let config = load_config(&cwd)?;
134    let migrations_dir = cwd.join(MIGRATIONS_DIR);
135
136    output::kv("Migrations", &migrations_dir.display().to_string());
137    output::newline();
138
139    // Check for pending migrations
140    output::step(1, 3, "Checking for pending migrations...");
141    let pending = check_pending_migrations(&migrations_dir)?;
142
143    if pending.is_empty() {
144        output::newline();
145        success("No pending migrations to apply.");
146        return Ok(());
147    }
148
149    output::list(&format!("{} pending migrations:", pending.len()));
150    for migration in &pending {
151        output::list_item(&migration.file_name().unwrap().to_string_lossy());
152    }
153    output::newline();
154
155    // Apply migrations
156    output::step(2, 3, "Applying migrations...");
157    for migration in &pending {
158        output::list_item(&format!(
159            "Applying {}",
160            migration.file_name().unwrap().to_string_lossy()
161        ));
162        apply_migration(migration, &config).await?;
163    }
164
165    // Verify
166    output::step(3, 3, "Verifying migrations...");
167
168    output::newline();
169    success(&format!(
170        "Applied {} migrations successfully!",
171        pending.len()
172    ));
173
174    Ok(())
175}
176
177/// Run `prax migrate reset` - reset database
178async fn run_reset(args: crate::cli::MigrateResetArgs) -> CliResult<()> {
179    output::header("Migrate Reset");
180
181    let cwd = std::env::current_dir()?;
182    let _config = load_config(&cwd)?;
183
184    if !args.force {
185        warn("This will delete all data in the database!");
186        output::newline();
187        if !output::confirm("Are you sure you want to reset the database?") {
188            output::newline();
189            output::info("Reset cancelled.");
190            return Ok(());
191        }
192    }
193
194    output::newline();
195
196    // Honest failure: drop/create database and re-applying migrations require a
197    // database executor that is not yet wired into the CLI. No changes are made.
198    Err(CliError::Migration(
199        "migrate reset is not yet implemented: dropping and recreating the database \
200         requires a database executor that is not yet wired into the CLI. No changes \
201         were made to the database."
202            .to_string(),
203    ))
204}
205
206/// Run `prax migrate status` - show migration status
207async fn run_status() -> CliResult<()> {
208    output::header("Migration Status");
209
210    let cwd = std::env::current_dir()?;
211    let _config = load_config(&cwd)?;
212    let migrations_dir = cwd.join(MIGRATIONS_DIR);
213
214    // List all migrations
215    let mut migrations = Vec::new();
216    if migrations_dir.exists() {
217        for entry in std::fs::read_dir(&migrations_dir)? {
218            let entry = entry?;
219            let path = entry.path();
220            if path.is_dir() {
221                migrations.push(path);
222            }
223        }
224    }
225    migrations.sort();
226
227    if migrations.is_empty() {
228        output::info("No migrations found.");
229        output::newline();
230        output::section("Getting started");
231        output::list_item("Run `prax migrate dev` to create your first migration");
232        return Ok(());
233    }
234
235    output::section("Migrations");
236
237    for (i, migration) in migrations.iter().enumerate() {
238        let name = migration.file_name().unwrap().to_string_lossy();
239        let applied = is_migration_applied(migration)?;
240
241        let status = if applied {
242            output::style_success("✓ Applied")
243        } else {
244            output::style_pending("○ Pending")
245        };
246
247        output::numbered_item(i + 1, &format!("{} - {}", name, status));
248    }
249
250    output::newline();
251
252    let applied_count = migrations
253        .iter()
254        .filter(|m| is_migration_applied(m).unwrap_or(false))
255        .count();
256    let pending_count = migrations.len() - applied_count;
257
258    output::kv("Total", &migrations.len().to_string());
259    output::kv("Applied", &applied_count.to_string());
260    output::kv("Pending", &pending_count.to_string());
261
262    Ok(())
263}
264
265/// Run `prax migrate resolve` - resolve migration issues
266async fn run_resolve(args: crate::cli::MigrateResolveArgs) -> CliResult<()> {
267    output::header("Migrate Resolve");
268
269    if !args.applied && !args.rolled_back {
270        return Err(CliError::Command(
271            "Must specify --applied or --rolled-back".to_string(),
272        ));
273    }
274
275    // Honest failure: resolving requires writing to the migration history table,
276    // which is not yet wired into the CLI. No changes are made.
277    Err(CliError::Migration(format!(
278        "migrate resolve is not yet implemented: marking migration '{}' as {} \
279         requires updating the _prax_migrations history table, which is not yet \
280         wired into the CLI. No changes were made.",
281        args.migration,
282        if args.applied {
283            "applied"
284        } else {
285            "rolled back"
286        }
287    )))
288}
289
290/// Run `prax migrate diff` - generate schema DDL without applying
291async fn run_diff(args: crate::cli::MigrateDiffArgs) -> CliResult<()> {
292    output::header("Migrate Diff");
293
294    let cwd = std::env::current_dir()?;
295    let config = load_config(&cwd)?;
296
297    // Diffing against a stored migration requires a migration snapshot store,
298    // which is not wired into the CLI. The live-database source is supported
299    // (that is the whole point of this command); a *specific past migration*
300    // as the source is not.
301    if let Some(from_migration) = &args.from_migration {
302        return Err(CliError::Migration(format!(
303            "--from-migration '{}' is not supported: diffing against a specific \
304             migration requires a migration snapshot store, which is not yet \
305             wired into the CLI. Omit --from-migration to diff against the live \
306             database (or an empty schema when no database is reachable).",
307            from_migration
308        )));
309    }
310
311    // Parse the desired (target) schema.
312    output::step(1, 2, "Parsing schema...");
313    let loaded = crate::schema_loader::load_schema(args.schema.as_deref())?;
314    let schema = loaded.schema;
315
316    // Resolve the current database structure as the diff source (introspected),
317    // or None when no database is reachable (greenfield → full-creation DDL).
318    output::step(2, 2, "Comparing schema to database...");
319    let source = resolve_source_schema(&config).await?;
320    let ddl_sql = generate_migration_sql(&schema, source, &config, args.allow_destructive)?;
321
322    output::newline();
323    if ddl_sql.trim().is_empty() {
324        output::info("No changes: the database already matches the schema.");
325    }
326
327    output::newline();
328    output::section("Generated DDL");
329    output::code(&ddl_sql, "sql");
330
331    if let Some(output_path) = args.output {
332        std::fs::write(&output_path, &ddl_sql)?;
333        output::newline();
334        success(&format!("DDL written to {}", output_path.display()));
335    }
336
337    Ok(())
338}
339
340/// Run `prax migrate rollback` - rollback the last applied migration
341async fn run_rollback(args: crate::cli::MigrateRollbackArgs) -> CliResult<()> {
342    output::header("Migrate Rollback");
343
344    output::newline();
345
346    if let Some(to_migration) = &args.to {
347        output::info(&format!("Rolling back to migration: {}", to_migration));
348    } else {
349        output::info("Rolling back last applied migration...");
350    }
351
352    if let Some(reason) = &args.reason {
353        output::kv("Reason", reason);
354    }
355
356    if let Some(user) = &args.user {
357        output::kv("User", user);
358    }
359
360    output::newline();
361
362    // TODO: Implement actual rollback logic using event sourcing
363    // The real implementation would:
364    // 1. Load the event store
365    // 2. Find the last applied migration (or specified migration)
366    // 3. Append a RolledBack event
367    // 4. Execute the down migration SQL
368    // 5. Update migration state
369
370    // Honest failure: rollback requires the prax-migrate event-sourcing engine,
371    // which is not yet wired into the CLI. Exit non-zero — no changes are made.
372    Err(CliError::Migration(
373        "migrate rollback is not yet implemented: rolling back requires the \
374         prax-migrate event-sourcing engine (event store and down-migration \
375         execution), which is not yet wired into the CLI. No changes were made."
376            .to_string(),
377    ))
378}
379
380/// Run `prax migrate history` - view migration history
381async fn run_history(args: crate::cli::MigrateHistoryArgs) -> CliResult<()> {
382    output::header("Migration History");
383
384    output::newline();
385
386    if let Some(migration) = &args.migration {
387        output::section(&format!("History for migration: {}", migration));
388    } else {
389        output::section("All migrations");
390    }
391
392    output::newline();
393
394    // TODO: Implement actual history viewing using event sourcing
395    // The real implementation would:
396    // 1. Load the event store
397    // 2. Query events for the specified migration (or all)
398    // 3. Display events in chronological order
399    // 4. Show event type, timestamp, and event-specific data
400
401    // Honest failure: history requires reading the _prax_migrations event log,
402    // which is not yet wired into the CLI. Exit non-zero.
403    Err(CliError::Migration(
404        "migrate history is not yet implemented: viewing history requires reading \
405         the _prax_migrations event log, which is not yet wired into the CLI."
406            .to_string(),
407    ))
408}
409
410// =============================================================================
411// Helper Functions
412// =============================================================================
413
414fn load_config(cwd: &Path) -> CliResult<Config> {
415    let config_path = cwd.join(CONFIG_FILE_NAME);
416    if config_path.exists() {
417        Config::load(&config_path)
418    } else {
419        Ok(Config::default())
420    }
421}
422
423fn check_pending_migrations(migrations_dir: &Path) -> CliResult<Vec<PathBuf>> {
424    let mut pending = Vec::new();
425
426    if !migrations_dir.exists() {
427        return Ok(pending);
428    }
429
430    for entry in std::fs::read_dir(migrations_dir)? {
431        let entry = entry?;
432        let path = entry.path();
433        if path.is_dir() && !is_migration_applied(&path)? {
434            pending.push(path);
435        }
436    }
437
438    pending.sort();
439    Ok(pending)
440}
441
442fn is_migration_applied(migration_path: &Path) -> CliResult<bool> {
443    // Check for a marker file indicating the migration has been applied
444    // In production, this would check the migration history table
445    let marker = migration_path.join(".applied");
446    Ok(marker.exists())
447}
448
449fn create_migration(migrations_dir: &Path, name: &str, sql: &str) -> CliResult<PathBuf> {
450    // Create migration directory
451    let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
452    let migration_name = format!("{}_{}", timestamp, name);
453    let migration_path = migrations_dir.join(&migration_name);
454
455    std::fs::create_dir_all(&migration_path)?;
456
457    // Write migration.sql
458    let sql_path = migration_path.join("migration.sql");
459    std::fs::write(&sql_path, sql)?;
460
461    Ok(migration_path)
462}
463
464/// Resolve the current database structure as a diff *source* schema.
465///
466/// Resolution order per the incremental-migrations design:
467/// 1. Introspect the database (default when a `DATABASE_URL` resolves and the
468///    provider is supported) and map the result to a `prax_schema::Schema`.
469/// 2. When no database URL is configured, or the database is unreachable, or
470///    the provider does not support introspection yet, return `None` — the
471///    differ then emits full-creation DDL, preserving the greenfield
472///    `init` → first `migrate dev` flow.
473///
474/// The database itself (via introspection) — not the `_prax_migrations`
475/// history — is the source of truth for structure, so a database migrated by
476/// a foreign runner (empty/absent prax history) still diffs off its real
477/// structure.
478async fn resolve_source_schema(config: &Config) -> CliResult<Option<prax_schema::ast::Schema>> {
479    // No URL configured/available → greenfield source.
480    let Ok(database_url) = get_database_url(config) else {
481        output::list_item("No DATABASE_URL configured; treating as a new database.");
482        return Ok(None);
483    };
484
485    introspect_source_schema(config, &database_url).await
486}
487
488/// Introspect `database_url` and map the result to a diff-source schema.
489///
490/// Dispatches to the backend matching the configured provider (PostgreSQL,
491/// MySQL, SQLite, MSSQL), each behind its cargo feature.
492///
493/// Error handling splits two cases so `migrate dev` is both safe and honest:
494/// - **Unreachable database** ([`CliError::Unreachable`]) or a provider whose
495///   introspection feature was not compiled in → treated as "no source"
496///   (greenfield, with a warning), so first-time creation still works offline.
497/// - **Reachable but the introspection query/permission failed**
498///   ([`CliError::Database`] and anything else) → propagated as a hard error,
499///   rather than silently emitting full-creation DDL against a populated
500///   database.
501async fn introspect_source_schema(
502    config: &Config,
503    database_url: &str,
504) -> CliResult<Option<prax_schema::ast::Schema>> {
505    use crate::commands::introspect::{IntrospectionOptions, introspect_database};
506    use crate::commands::schema_from_db::schema_from_database;
507
508    // Scope introspection to a single schema/database where the provider needs
509    // it. MySQL's `information_schema` spans every database on the server, so
510    // without a `table_schema` filter the diff source would pull in unrelated
511    // schemas; derive the database name from the connection URL.
512    let mut options = IntrospectionOptions::default();
513    if is_mysql(&config.database.provider) {
514        options.schema = mysql_database_from_url(database_url);
515    }
516
517    match introspect_database(&config.database.provider, database_url, &options).await {
518        Ok(db_schema) => {
519            let result = schema_from_database(&db_schema, Default::default())?;
520            for warning in &result.warnings {
521                output::warn(warning);
522            }
523            Ok(Some(result.schema))
524        }
525        // Only "no database reachable", or a backend whose introspection
526        // feature is not compiled in, falls back to greenfield. A reachable
527        // database whose query/permission failed is a real error.
528        Err(CliError::Unreachable(msg)) => {
529            output::warn(&format!(
530                "Database not reachable ({msg}); treating as a new database. \
531                 Generated SQL will be full-creation DDL."
532            ));
533            Ok(None)
534        }
535        Err(CliError::FeatureUnavailable(msg)) => {
536            output::list_item(&format!("{msg} Using an empty source (full-creation DDL)."));
537            Ok(None)
538        }
539        Err(e) => Err(e),
540    }
541}
542
543/// Whether the provider string denotes MySQL/MariaDB.
544fn is_mysql(provider: &str) -> bool {
545    matches!(provider.to_lowercase().as_str(), "mysql" | "mariadb")
546}
547
548/// Extract the database name from a MySQL connection URL
549/// (`mysql://user:pass@host:port/DBNAME?params`). Returns `None` when no
550/// path segment is present.
551///
552/// Credentials are stripped first (split at the last `@`) so a userinfo
553/// component containing a `/` does not get mistaken for the path separator.
554fn mysql_database_from_url(url: &str) -> Option<String> {
555    let after_scheme = url.split("://").nth(1)?;
556    // Drop the `user:pass@` userinfo so its characters can't be read as the
557    // path. The host/port/path remainder is everything after the last `@`.
558    let host_and_path = match after_scheme.rsplit_once('@') {
559        Some((_userinfo, rest)) => rest,
560        None => after_scheme,
561    };
562    let after_authority = host_and_path.split_once('/')?.1;
563    let db = after_authority
564        .split(['?', '#'])
565        .next()
566        .unwrap_or("")
567        .trim_matches('/');
568    if db.is_empty() {
569        None
570    } else {
571        Some(db.to_string())
572    }
573}
574
575/// Map a datasource provider string to a migration `SqlBackend`.
576///
577/// The provider table is kept in sync with
578/// [`crate::commands::introspect::get_database_type`]: every provider that can
579/// be a diff *source* (introspected) must also render *target* SQL here.
580/// DuckDB is intentionally absent from both — it has no introspector yet — so
581/// `migrate dev` against DuckDB fails cleanly rather than diffing against an
582/// empty source.
583fn sql_backend_for_provider(provider: &str) -> CliResult<prax_migrate::SqlBackend> {
584    use prax_migrate::SqlBackend;
585    match provider.to_lowercase().as_str() {
586        "postgresql" | "postgres" | "pg" => Ok(SqlBackend::Postgres),
587        "mysql" | "mariadb" => Ok(SqlBackend::MySql),
588        "sqlite" | "sqlite3" => Ok(SqlBackend::Sqlite),
589        "mssql" | "sqlserver" | "sql_server" => Ok(SqlBackend::Mssql),
590        other => Err(CliError::Config(format!(
591            "Unsupported database provider for migration generation: '{}'",
592            other
593        ))),
594    }
595}
596
597/// Diff the desired `schema` (target) against an optional introspected
598/// `source`, render the resulting `SchemaDiff` through the provider's dialect
599/// generator, and return the `up` SQL.
600///
601/// When `allow_destructive` is false (the default), drops (tables, columns,
602/// enums, foreign keys, indexes, enum-value removals) are stripped from the
603/// diff before generation so a stale schema never silently destroys data.
604fn generate_migration_sql(
605    schema: &prax_schema::ast::Schema,
606    source: Option<prax_schema::ast::Schema>,
607    config: &Config,
608    allow_destructive: bool,
609) -> CliResult<String> {
610    use prax_migrate::{SchemaDiffer, SqlDialect};
611
612    let backend = sql_backend_for_provider(&config.database.provider)?;
613
614    let differ = SchemaDiffer::new(schema.clone());
615    let differ = match source {
616        Some(src) => differ.with_source(src),
617        None => differ,
618    };
619
620    let mut diff = differ
621        .diff()
622        .map_err(|e| CliError::Migration(format!("Failed to diff schema against database: {e}")))?;
623
624    if !allow_destructive {
625        strip_destructive(&mut diff);
626    }
627
628    let migration = SqlDialect::for_backend(backend).generate_migration(&diff);
629
630    let mut out = String::from("-- Migration generated by Prax\n");
631    if !allow_destructive {
632        out.push_str(
633            "-- Additive-only: destructive statements (DROP) are omitted. Re-run with \
634             --allow-destructive to include them.\n",
635        );
636    }
637    for warning in &migration.warnings {
638        out.push_str(&format!("-- WARNING: {}\n", warning));
639    }
640    out.push('\n');
641    out.push_str(migration.up.trim_end());
642    if !migration.up.trim_end().is_empty() {
643        out.push('\n');
644    }
645
646    // A header-only result (no statements) counts as "no changes" to callers.
647    if migration.up.trim().is_empty() {
648        return Ok(String::new());
649    }
650
651    Ok(out)
652}
653
654/// Remove every destructive operation from a `SchemaDiff` in place, leaving
655/// only additive/altering changes. Column *type/nullability/default* alters
656/// are kept (they are not drops); dropped columns, tables, enums, enum values,
657/// foreign keys, indexes, extensions, procedures, and triggers are removed.
658///
659/// `alter_views` is intentionally **kept**: a view alter recreates the view
660/// (drop + create), but views hold no data, so recreation is non-destructive
661/// and safe under the additive-only default.
662fn strip_destructive(diff: &mut prax_migrate::SchemaDiff) {
663    diff.drop_models.clear();
664    diff.drop_enums.clear();
665    diff.drop_views.clear();
666    diff.drop_extensions.clear();
667    diff.drop_indexes.clear();
668
669    for alter in &mut diff.alter_models {
670        alter.drop_fields.clear();
671        alter.drop_indexes.clear();
672        alter.drop_foreign_keys.clear();
673    }
674    // An alter that now carries no changes would still be harmless (the
675    // generator emits nothing for it), but drop the empties for a clean diff.
676    diff.alter_models.retain(|a| {
677        !a.add_fields.is_empty()
678            || !a.alter_fields.is_empty()
679            || !a.add_indexes.is_empty()
680            || !a.add_foreign_keys.is_empty()
681    });
682
683    for alter in &mut diff.alter_enums {
684        alter.remove_values.clear();
685    }
686    diff.alter_enums.retain(|a| !a.add_values.is_empty());
687
688    // Procedure and trigger drops are destructive too. The differ carries them
689    // on `SchemaDiff::procedures`; clear both channels and drop the whole
690    // procedure diff if nothing additive/altering remains.
691    if let Some(procs) = diff.procedures.as_mut() {
692        procs.drop.clear();
693        procs.drop_triggers.clear();
694        if procs.is_empty() {
695            diff.procedures = None;
696        }
697    }
698}
699
700async fn apply_migration(migration_path: &Path, _config: &Config) -> CliResult<()> {
701    let sql_path = migration_path.join("migration.sql");
702
703    if !sql_path.exists() {
704        return Err(CliError::Migration(format!(
705            "Migration file not found: {}",
706            sql_path.display()
707        )));
708    }
709
710    // Honest failure: applying migrations requires a database executor (driver /
711    // prax-migrate engine) that is not yet wired into the CLI. Do NOT write the
712    // `.applied` marker or report success for work that was not performed.
713    Err(CliError::Migration(format!(
714        "Applying migration '{}' is not yet implemented: executing migration SQL \
715         requires a database executor that is not yet wired into the CLI. The \
716         migration SQL is at {}; apply it with an external tool for now.",
717        migration_path.display(),
718        sql_path.display()
719    )))
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725
726    // -- provider -> SqlBackend --------------------------------------------
727
728    #[test]
729    fn test_sql_backend_for_provider() {
730        use prax_migrate::SqlBackend;
731        assert_eq!(
732            sql_backend_for_provider("postgresql").unwrap(),
733            SqlBackend::Postgres
734        );
735        assert_eq!(
736            sql_backend_for_provider("postgres").unwrap(),
737            SqlBackend::Postgres
738        );
739        assert_eq!(
740            sql_backend_for_provider("mysql").unwrap(),
741            SqlBackend::MySql
742        );
743        assert_eq!(
744            sql_backend_for_provider("sqlite").unwrap(),
745            SqlBackend::Sqlite
746        );
747        assert!(sql_backend_for_provider("nonsense").is_err());
748        // DuckDB is intentionally not a supported migrate backend (no
749        // introspector) and must be rejected here, matching get_database_type.
750        assert!(sql_backend_for_provider("duckdb").is_err());
751    }
752
753    #[test]
754    fn test_mysql_database_from_url() {
755        assert_eq!(
756            mysql_database_from_url("mysql://u:p@host:3306/mydb"),
757            Some("mydb".to_string())
758        );
759        assert_eq!(
760            mysql_database_from_url("mysql://u:p@host:3306/mydb?ssl=true"),
761            Some("mydb".to_string())
762        );
763        // No database path segment → None (falls back to unscoped/default).
764        assert_eq!(mysql_database_from_url("mysql://u:p@host:3306"), None);
765        assert_eq!(mysql_database_from_url("mysql://u:p@host:3306/"), None);
766        // A `/` inside the credentials must not be mistaken for the path.
767        assert_eq!(
768            mysql_database_from_url("mysql://u:p/w@host:3306/mydb"),
769            Some("mydb".to_string())
770        );
771    }
772
773    #[tokio::test]
774    async fn test_introspect_database_unsupported_provider_errors() {
775        use crate::commands::introspect::{IntrospectionOptions, introspect_database};
776        // A provider that has no SQL introspector (mongodb) must return a
777        // Config error, not silently succeed.
778        let opts = IntrospectionOptions::default();
779        match introspect_database("mongodb", "mongodb://x/y", &opts).await {
780            Err(CliError::Config(msg)) => {
781                assert!(
782                    msg.contains("Unsupported database provider"),
783                    "unexpected message: {msg}"
784                );
785            }
786            other => panic!("expected CliError::Config, got {other:?}"),
787        }
788    }
789
790    // -- generate_migration_sql: greenfield (no source) --------------------
791
792    fn pg_config() -> Config {
793        Config::default()
794    }
795
796    fn parse(schema: &str) -> prax_schema::ast::Schema {
797        prax_schema::parse_schema(schema).expect("schema parses")
798    }
799
800    const USERS_V1: &str = r#"
801        model User {
802            id    Int    @id @auto
803            email String @unique
804
805            @@map("users")
806        }
807    "#;
808
809    #[test]
810    fn greenfield_generates_full_create_table() {
811        // No source (None) => differ emits full creation DDL.
812        let schema = parse(USERS_V1);
813        let sql = generate_migration_sql(&schema, None, &pg_config(), false).unwrap();
814        assert!(sql.contains("CREATE TABLE \"users\""), "sql: {sql}");
815        assert!(sql.contains("SERIAL"), "auto id -> SERIAL: {sql}");
816        // Incremental generator never uses IF NOT EXISTS (the old bug).
817        assert!(!sql.contains("IF NOT EXISTS"), "sql: {sql}");
818    }
819
820    #[test]
821    fn empty_diff_against_identical_source_yields_no_sql() {
822        // Source == target => empty diff => empty SQL (no spurious churn).
823        let schema = parse(USERS_V1);
824        let source = parse(USERS_V1);
825        let sql = generate_migration_sql(&schema, Some(source), &pg_config(), false).unwrap();
826        assert!(sql.trim().is_empty(), "expected no changes, got: {sql}");
827    }
828
829    #[test]
830    fn incremental_diff_emits_only_the_delta() {
831        // v2 adds a nullable column and a whole new table with an FK +
832        // composite PK. The migration must ALTER the existing table and
833        // CREATE the new one — and touch nothing that already exists.
834        let source = parse(USERS_V1);
835        let target = parse(
836            r#"
837            model User {
838                id       Int     @id @auto
839                email    String  @unique
840                nickname String?
841
842                @@map("users")
843            }
844
845            model Membership {
846                userId Int  @map("user_id")
847                teamId Int  @map("team_id")
848                user   User @relation(fields: [userId], references: [id])
849
850                @@id([userId, teamId])
851                @@map("memberships")
852            }
853            "#,
854        );
855
856        let sql = generate_migration_sql(&target, Some(source), &pg_config(), false).unwrap();
857
858        // Added column on the existing table.
859        assert!(
860            sql.contains("ALTER TABLE \"users\" ADD COLUMN \"nickname\""),
861            "sql: {sql}"
862        );
863        // New table created with a composite primary key.
864        assert!(sql.contains("CREATE TABLE \"memberships\""), "sql: {sql}");
865        assert!(
866            sql.contains("PRIMARY KEY (\"user_id\", \"team_id\")"),
867            "composite PK: {sql}"
868        );
869        // FK constraint present.
870        assert!(sql.contains("FOREIGN KEY"), "fk: {sql}");
871        // Nothing recreates the pre-existing users table.
872        assert!(!sql.contains("CREATE TABLE \"users\""), "sql: {sql}");
873    }
874
875    #[test]
876    fn additive_only_strips_drops_by_default() {
877        // Source has an extra table + extra column the target no longer
878        // declares. Default (additive-only) must NOT emit any DROP.
879        let source = parse(
880            r#"
881            model User {
882                id       Int     @id @auto
883                email    String  @unique
884                obsolete String?
885
886                @@map("users")
887            }
888            model Legacy {
889                id Int @id @auto
890
891                @@map("legacy")
892            }
893            "#,
894        );
895        let target = parse(USERS_V1);
896
897        let sql =
898            generate_migration_sql(&target, Some(source.clone()), &pg_config(), false).unwrap();
899        assert!(
900            !sql.to_uppercase().contains("DROP"),
901            "additive-only must not drop: {sql}"
902        );
903
904        // With --allow-destructive the drops appear.
905        let sql_destructive =
906            generate_migration_sql(&target, Some(source), &pg_config(), true).unwrap();
907        assert!(
908            sql_destructive.contains("DROP TABLE") && sql_destructive.contains("DROP COLUMN"),
909            "destructive should drop: {sql_destructive}"
910        );
911    }
912
913    #[test]
914    fn destructive_emits_fk_and_index_drops_stripped_by_default() {
915        // Source declares an FK and a named index that the target drops.
916        // Additive-only must strip both DROP CONSTRAINT and DROP INDEX;
917        // --allow-destructive must emit them.
918        let source = parse(
919            r#"
920            model User {
921                id Int @id @auto
922                @@map("users")
923            }
924            model Post {
925                id       Int @id @auto
926                authorId Int @map("author_id")
927                title    String
928                author   User @relation(fields: [authorId], references: [id], map: "post_author_fk")
929                @@index([title], map: "post_title_idx")
930                @@map("posts")
931            }
932            "#,
933        );
934        // Target keeps the tables but drops the relation (FK) and the index.
935        let target = parse(
936            r#"
937            model User {
938                id Int @id @auto
939                @@map("users")
940            }
941            model Post {
942                id       Int @id @auto
943                authorId Int @map("author_id")
944                title    String
945                @@map("posts")
946            }
947            "#,
948        );
949
950        let additive =
951            generate_migration_sql(&target, Some(source.clone()), &pg_config(), false).unwrap();
952        assert!(
953            !additive.to_uppercase().contains("DROP"),
954            "additive-only must strip FK/index drops: {additive}"
955        );
956
957        let destructive =
958            generate_migration_sql(&target, Some(source), &pg_config(), true).unwrap();
959        assert!(
960            destructive.contains("DROP CONSTRAINT") && destructive.contains("post_author_fk"),
961            "destructive should drop the FK constraint: {destructive}"
962        );
963        assert!(
964            destructive.contains("DROP INDEX") && destructive.contains("post_title_idx"),
965            "destructive should drop the index: {destructive}"
966        );
967    }
968
969    #[test]
970    fn strip_destructive_clears_procedure_and_trigger_drops() {
971        use prax_migrate::{ProcedureDiff, SchemaDiff};
972        let mut diff = SchemaDiff {
973            procedures: Some(ProcedureDiff {
974                drop: vec!["old_proc".into()],
975                drop_triggers: vec!["old_trigger".into()],
976                ..Default::default()
977            }),
978            ..Default::default()
979        };
980        strip_destructive(&mut diff);
981        // With only drops, the procedure diff is emptied and cleared entirely.
982        assert!(
983            diff.procedures.is_none(),
984            "procedure drops must be stripped and the empty diff removed"
985        );
986    }
987
988    #[test]
989    fn diff_and_dev_share_identical_sql_for_same_inputs() {
990        // `migrate diff` and `migrate dev --create-only` both route through
991        // generate_migration_sql, so parity reduces to this helper being
992        // deterministic for identical (target, source, config, flag) inputs.
993        let source = parse(USERS_V1);
994        let target = parse(
995            r#"
996            model User {
997                id       Int     @id @auto
998                email    String  @unique
999                nickname String?
1000
1001                @@map("users")
1002            }
1003            "#,
1004        );
1005
1006        let a = generate_migration_sql(&target, Some(source.clone()), &pg_config(), false).unwrap();
1007        let b = generate_migration_sql(&target, Some(source), &pg_config(), false).unwrap();
1008        assert_eq!(a, b, "same inputs must yield identical SQL");
1009        assert!(a.contains("ADD COLUMN \"nickname\""), "sql: {a}");
1010    }
1011
1012    #[test]
1013    fn strip_destructive_clears_all_drop_channels() {
1014        use prax_migrate::{EnumAlterDiff, ModelAlterDiff, SchemaDiff};
1015        let mut diff = SchemaDiff {
1016            drop_models: vec!["Legacy".into()],
1017            drop_enums: vec!["OldEnum".into()],
1018            drop_views: vec!["OldView".into()],
1019            drop_extensions: vec!["pgcrypto".into()],
1020            alter_enums: vec![EnumAlterDiff {
1021                name: "Status".into(),
1022                add_values: Vec::new(),
1023                remove_values: vec!["DEPRECATED".into()],
1024            }],
1025            alter_models: vec![ModelAlterDiff {
1026                name: "User".into(),
1027                table_name: "users".into(),
1028                add_fields: Vec::new(),
1029                drop_fields: vec!["obsolete".into()],
1030                alter_fields: Vec::new(),
1031                add_indexes: Vec::new(),
1032                drop_indexes: vec!["idx_old".into()],
1033                add_foreign_keys: Vec::new(),
1034                drop_foreign_keys: vec!["fk_old".into()],
1035            }],
1036            ..Default::default()
1037        };
1038
1039        strip_destructive(&mut diff);
1040
1041        assert!(diff.drop_models.is_empty());
1042        assert!(diff.drop_enums.is_empty());
1043        assert!(diff.drop_views.is_empty());
1044        assert!(diff.drop_extensions.is_empty());
1045        // The alter_model had only drops -> pruned entirely.
1046        assert!(diff.alter_models.is_empty());
1047        // The alter_enum had only removals -> pruned entirely.
1048        assert!(diff.alter_enums.is_empty());
1049    }
1050
1051    // -- honest-error paths ---------------------------------------------------
1052
1053    #[tokio::test]
1054    async fn test_apply_migration_fails_without_executor() {
1055        let dir = tempfile::tempdir().unwrap();
1056        let migration_path = dir.path().join("20240101000000_init");
1057        std::fs::create_dir_all(&migration_path).unwrap();
1058        std::fs::write(
1059            migration_path.join("migration.sql"),
1060            "CREATE TABLE t (id INT);",
1061        )
1062        .unwrap();
1063
1064        let result = apply_migration(&migration_path, &Config::default()).await;
1065
1066        match result {
1067            Err(CliError::Migration(msg)) => {
1068                assert!(
1069                    msg.contains("not yet implemented"),
1070                    "unexpected message: {msg}"
1071                );
1072            }
1073            other => panic!("expected CliError::Migration, got {other:?}"),
1074        }
1075
1076        // The .applied marker must NOT be written for work that was not done.
1077        assert!(!migration_path.join(".applied").exists());
1078    }
1079
1080    #[tokio::test]
1081    async fn test_apply_migration_missing_file() {
1082        let dir = tempfile::tempdir().unwrap();
1083
1084        match apply_migration(dir.path(), &Config::default()).await {
1085            Err(CliError::Migration(msg)) => assert!(msg.contains("not found")),
1086            other => panic!("expected CliError::Migration, got {other:?}"),
1087        }
1088    }
1089
1090    #[tokio::test]
1091    async fn test_run_reset_not_implemented() {
1092        let args = crate::cli::MigrateResetArgs {
1093            force: true,
1094            seed: false,
1095            skip_migrations: false,
1096        };
1097
1098        match run_reset(args).await {
1099            Err(CliError::Migration(msg)) => {
1100                assert!(
1101                    msg.contains("not yet implemented"),
1102                    "unexpected message: {msg}"
1103                );
1104            }
1105            other => panic!("expected CliError::Migration, got {other:?}"),
1106        }
1107    }
1108
1109    #[tokio::test]
1110    async fn test_run_resolve_not_implemented() {
1111        let args = crate::cli::MigrateResolveArgs {
1112            migration: "20240101000000_init".to_string(),
1113            applied: true,
1114            rolled_back: false,
1115        };
1116
1117        match run_resolve(args).await {
1118            Err(CliError::Migration(msg)) => {
1119                assert!(
1120                    msg.contains("not yet implemented"),
1121                    "unexpected message: {msg}"
1122                );
1123                assert!(msg.contains("20240101000000_init"));
1124            }
1125            other => panic!("expected CliError::Migration, got {other:?}"),
1126        }
1127    }
1128
1129    #[tokio::test]
1130    async fn test_run_resolve_requires_a_flag() {
1131        let args = crate::cli::MigrateResolveArgs {
1132            migration: "m".to_string(),
1133            applied: false,
1134            rolled_back: false,
1135        };
1136
1137        match run_resolve(args).await {
1138            Err(CliError::Command(msg)) => assert!(msg.contains("--applied or --rolled-back")),
1139            other => panic!("expected CliError::Command, got {other:?}"),
1140        }
1141    }
1142
1143    #[tokio::test]
1144    async fn test_run_rollback_not_implemented() {
1145        let args = crate::cli::MigrateRollbackArgs {
1146            reason: None,
1147            user: None,
1148            to: None,
1149        };
1150
1151        match run_rollback(args).await {
1152            Err(CliError::Migration(msg)) => {
1153                assert!(
1154                    msg.contains("not yet implemented"),
1155                    "unexpected message: {msg}"
1156                );
1157            }
1158            other => panic!("expected CliError::Migration, got {other:?}"),
1159        }
1160    }
1161
1162    #[tokio::test]
1163    async fn test_run_history_not_implemented() {
1164        let args = crate::cli::MigrateHistoryArgs { migration: None };
1165
1166        match run_history(args).await {
1167            Err(CliError::Migration(msg)) => {
1168                assert!(
1169                    msg.contains("_prax_migrations"),
1170                    "unexpected message: {msg}"
1171                );
1172            }
1173            other => panic!("expected CliError::Migration, got {other:?}"),
1174        }
1175    }
1176
1177    #[tokio::test]
1178    async fn test_run_diff_from_migration_unsupported() {
1179        let args = crate::cli::MigrateDiffArgs {
1180            schema: None,
1181            output: None,
1182            from_migration: Some("20240101000000_init".to_string()),
1183            allow_destructive: false,
1184        };
1185
1186        match run_diff(args).await {
1187            Err(CliError::Migration(msg)) => {
1188                assert!(
1189                    msg.contains("--from-migration"),
1190                    "unexpected message: {msg}"
1191                );
1192            }
1193            other => panic!("expected CliError::Migration, got {other:?}"),
1194        }
1195    }
1196}