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    // 4. Generate migration
70    output::step(4, total_steps, "Generating migration...");
71    let migration_path = create_migration(&migrations_dir, &migration_name, &schema)?;
72
73    // 5. Apply migration (if not --create-only)
74    if !args.create_only {
75        output::step(5, total_steps, "Applying migration...");
76        apply_migration(&migration_path, &config).await?;
77    } else {
78        output::step(5, total_steps, "Skipping apply (--create-only)...");
79    }
80
81    // 6. Run seed (if not --skip-seed)
82    if !args.skip_seed && !args.create_only {
83        output::step(6, total_steps, "Running seed...");
84
85        if let Some(seed_path) = find_seed_file(&cwd, &config) {
86            let database_url = get_database_url(&config)?;
87            let runner = SeedRunner::new(
88                seed_path,
89                database_url,
90                config.database.provider.clone(),
91                cwd.clone(),
92            )?;
93
94            match runner.run().await {
95                Ok(result) => {
96                    output::list_item(&format!("Seeded {} records", result.records_affected));
97                }
98                Err(e) => {
99                    output::warn(&format!("Seed failed: {}. Continuing...", e));
100                }
101            }
102        } else {
103            output::list_item("No seed file found, skipping");
104        }
105    }
106
107    output::newline();
108    success(&format!("Migration '{}' created", migration_name));
109
110    output::newline();
111    output::section("Next steps");
112    output::list_item("Review the generated migration SQL");
113    output::list_item("Run `prax generate` to update your client");
114
115    Ok(())
116}
117
118/// Run `prax migrate deploy` - production deployment
119async fn run_deploy() -> CliResult<()> {
120    output::header("Migrate Deploy");
121
122    let cwd = std::env::current_dir()?;
123    let config = load_config(&cwd)?;
124    let migrations_dir = cwd.join(MIGRATIONS_DIR);
125
126    output::kv("Migrations", &migrations_dir.display().to_string());
127    output::newline();
128
129    // Check for pending migrations
130    output::step(1, 3, "Checking for pending migrations...");
131    let pending = check_pending_migrations(&migrations_dir)?;
132
133    if pending.is_empty() {
134        output::newline();
135        success("No pending migrations to apply.");
136        return Ok(());
137    }
138
139    output::list(&format!("{} pending migrations:", pending.len()));
140    for migration in &pending {
141        output::list_item(&migration.file_name().unwrap().to_string_lossy());
142    }
143    output::newline();
144
145    // Apply migrations
146    output::step(2, 3, "Applying migrations...");
147    for migration in &pending {
148        output::list_item(&format!(
149            "Applying {}",
150            migration.file_name().unwrap().to_string_lossy()
151        ));
152        apply_migration(migration, &config).await?;
153    }
154
155    // Verify
156    output::step(3, 3, "Verifying migrations...");
157
158    output::newline();
159    success(&format!(
160        "Applied {} migrations successfully!",
161        pending.len()
162    ));
163
164    Ok(())
165}
166
167/// Run `prax migrate reset` - reset database
168async fn run_reset(args: crate::cli::MigrateResetArgs) -> CliResult<()> {
169    output::header("Migrate Reset");
170
171    let cwd = std::env::current_dir()?;
172    let _config = load_config(&cwd)?;
173
174    if !args.force {
175        warn("This will delete all data in the database!");
176        output::newline();
177        if !output::confirm("Are you sure you want to reset the database?") {
178            output::newline();
179            output::info("Reset cancelled.");
180            return Ok(());
181        }
182    }
183
184    output::newline();
185
186    // Honest failure: drop/create database and re-applying migrations require a
187    // database executor that is not yet wired into the CLI. No changes are made.
188    Err(CliError::Migration(
189        "migrate reset is not yet implemented: dropping and recreating the database \
190         requires a database executor that is not yet wired into the CLI. No changes \
191         were made to the database."
192            .to_string(),
193    ))
194}
195
196/// Run `prax migrate status` - show migration status
197async fn run_status() -> CliResult<()> {
198    output::header("Migration Status");
199
200    let cwd = std::env::current_dir()?;
201    let _config = load_config(&cwd)?;
202    let migrations_dir = cwd.join(MIGRATIONS_DIR);
203
204    // List all migrations
205    let mut migrations = Vec::new();
206    if migrations_dir.exists() {
207        for entry in std::fs::read_dir(&migrations_dir)? {
208            let entry = entry?;
209            let path = entry.path();
210            if path.is_dir() {
211                migrations.push(path);
212            }
213        }
214    }
215    migrations.sort();
216
217    if migrations.is_empty() {
218        output::info("No migrations found.");
219        output::newline();
220        output::section("Getting started");
221        output::list_item("Run `prax migrate dev` to create your first migration");
222        return Ok(());
223    }
224
225    output::section("Migrations");
226
227    for (i, migration) in migrations.iter().enumerate() {
228        let name = migration.file_name().unwrap().to_string_lossy();
229        let applied = is_migration_applied(migration)?;
230
231        let status = if applied {
232            output::style_success("✓ Applied")
233        } else {
234            output::style_pending("○ Pending")
235        };
236
237        output::numbered_item(i + 1, &format!("{} - {}", name, status));
238    }
239
240    output::newline();
241
242    let applied_count = migrations
243        .iter()
244        .filter(|m| is_migration_applied(m).unwrap_or(false))
245        .count();
246    let pending_count = migrations.len() - applied_count;
247
248    output::kv("Total", &migrations.len().to_string());
249    output::kv("Applied", &applied_count.to_string());
250    output::kv("Pending", &pending_count.to_string());
251
252    Ok(())
253}
254
255/// Run `prax migrate resolve` - resolve migration issues
256async fn run_resolve(args: crate::cli::MigrateResolveArgs) -> CliResult<()> {
257    output::header("Migrate Resolve");
258
259    if !args.applied && !args.rolled_back {
260        return Err(CliError::Command(
261            "Must specify --applied or --rolled-back".to_string(),
262        ));
263    }
264
265    // Honest failure: resolving requires writing to the migration history table,
266    // which is not yet wired into the CLI. No changes are made.
267    Err(CliError::Migration(format!(
268        "migrate resolve is not yet implemented: marking migration '{}' as {} \
269         requires updating the _prax_migrations history table, which is not yet \
270         wired into the CLI. No changes were made.",
271        args.migration,
272        if args.applied {
273            "applied"
274        } else {
275            "rolled back"
276        }
277    )))
278}
279
280/// Run `prax migrate diff` - generate schema DDL without applying
281async fn run_diff(args: crate::cli::MigrateDiffArgs) -> CliResult<()> {
282    output::header("Migrate Diff");
283
284    let _cwd = std::env::current_dir()?;
285
286    // Diffing against a stored migration requires database introspection and a
287    // migration snapshot store, neither of which is wired into the CLI.
288    if let Some(from_migration) = &args.from_migration {
289        return Err(CliError::Migration(format!(
290            "--from-migration '{}' is not supported: diffing against a specific \
291             migration requires database introspection and a migration snapshot \
292             store, which are not yet wired into the CLI.",
293            from_migration
294        )));
295    }
296
297    // Parse schema
298    output::step(1, 2, "Parsing schema...");
299    let loaded = crate::schema_loader::load_schema(args.schema.as_deref())?;
300    let schema = loaded.schema;
301
302    // Generate DDL. Note: this is NOT a database diff — database introspection
303    // is not yet implemented, so nothing is compared against live state. The
304    // output is PostgreSQL-flavored DDL for the full schema.
305    output::step(2, 2, "Generating schema DDL (PostgreSQL dialect)...");
306    let ddl_sql = generate_schema_diff(&schema)?;
307
308    output::newline();
309    output::info(
310        "This generates PostgreSQL-flavored DDL for the entire schema; it is not a \
311         diff against database state (database introspection is not yet implemented).",
312    );
313
314    output::newline();
315    output::section("Generated DDL");
316    output::code(&ddl_sql, "sql");
317
318    if let Some(output_path) = args.output {
319        std::fs::write(&output_path, &ddl_sql)?;
320        output::newline();
321        success(&format!("DDL written to {}", output_path.display()));
322    }
323
324    Ok(())
325}
326
327/// Run `prax migrate rollback` - rollback the last applied migration
328async fn run_rollback(args: crate::cli::MigrateRollbackArgs) -> CliResult<()> {
329    output::header("Migrate Rollback");
330
331    output::newline();
332
333    if let Some(to_migration) = &args.to {
334        output::info(&format!("Rolling back to migration: {}", to_migration));
335    } else {
336        output::info("Rolling back last applied migration...");
337    }
338
339    if let Some(reason) = &args.reason {
340        output::kv("Reason", reason);
341    }
342
343    if let Some(user) = &args.user {
344        output::kv("User", user);
345    }
346
347    output::newline();
348
349    // TODO: Implement actual rollback logic using event sourcing
350    // The real implementation would:
351    // 1. Load the event store
352    // 2. Find the last applied migration (or specified migration)
353    // 3. Append a RolledBack event
354    // 4. Execute the down migration SQL
355    // 5. Update migration state
356
357    // Honest failure: rollback requires the prax-migrate event-sourcing engine,
358    // which is not yet wired into the CLI. Exit non-zero — no changes are made.
359    Err(CliError::Migration(
360        "migrate rollback is not yet implemented: rolling back requires the \
361         prax-migrate event-sourcing engine (event store and down-migration \
362         execution), which is not yet wired into the CLI. No changes were made."
363            .to_string(),
364    ))
365}
366
367/// Run `prax migrate history` - view migration history
368async fn run_history(args: crate::cli::MigrateHistoryArgs) -> CliResult<()> {
369    output::header("Migration History");
370
371    output::newline();
372
373    if let Some(migration) = &args.migration {
374        output::section(&format!("History for migration: {}", migration));
375    } else {
376        output::section("All migrations");
377    }
378
379    output::newline();
380
381    // TODO: Implement actual history viewing using event sourcing
382    // The real implementation would:
383    // 1. Load the event store
384    // 2. Query events for the specified migration (or all)
385    // 3. Display events in chronological order
386    // 4. Show event type, timestamp, and event-specific data
387
388    // Honest failure: history requires reading the _prax_migrations event log,
389    // which is not yet wired into the CLI. Exit non-zero.
390    Err(CliError::Migration(
391        "migrate history is not yet implemented: viewing history requires reading \
392         the _prax_migrations event log, which is not yet wired into the CLI."
393            .to_string(),
394    ))
395}
396
397// =============================================================================
398// Helper Functions
399// =============================================================================
400
401fn load_config(cwd: &Path) -> CliResult<Config> {
402    let config_path = cwd.join(CONFIG_FILE_NAME);
403    if config_path.exists() {
404        Config::load(&config_path)
405    } else {
406        Ok(Config::default())
407    }
408}
409
410fn check_pending_migrations(migrations_dir: &Path) -> CliResult<Vec<PathBuf>> {
411    let mut pending = Vec::new();
412
413    if !migrations_dir.exists() {
414        return Ok(pending);
415    }
416
417    for entry in std::fs::read_dir(migrations_dir)? {
418        let entry = entry?;
419        let path = entry.path();
420        if path.is_dir() && !is_migration_applied(&path)? {
421            pending.push(path);
422        }
423    }
424
425    pending.sort();
426    Ok(pending)
427}
428
429fn is_migration_applied(migration_path: &Path) -> CliResult<bool> {
430    // Check for a marker file indicating the migration has been applied
431    // In production, this would check the migration history table
432    let marker = migration_path.join(".applied");
433    Ok(marker.exists())
434}
435
436fn create_migration(
437    migrations_dir: &Path,
438    name: &str,
439    schema: &prax_schema::ast::Schema,
440) -> CliResult<PathBuf> {
441    // Create migration directory
442    let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
443    let migration_name = format!("{}_{}", timestamp, name);
444    let migration_path = migrations_dir.join(&migration_name);
445
446    std::fs::create_dir_all(&migration_path)?;
447
448    // Generate migration SQL
449    let sql = generate_schema_diff(schema)?;
450
451    // Write migration.sql
452    let sql_path = migration_path.join("migration.sql");
453    std::fs::write(&sql_path, &sql)?;
454
455    Ok(migration_path)
456}
457
458fn generate_schema_diff(schema: &prax_schema::ast::Schema) -> CliResult<String> {
459    use prax_schema::ast::{FieldType, ScalarType};
460
461    let mut sql = String::new();
462
463    sql.push_str("-- Migration generated by Prax\n\n");
464
465    // Generate enums FIRST (before tables that reference them)
466    if !schema.enums.is_empty() {
467        sql.push_str("-- Enum types\n");
468        for enum_def in schema.enums.values() {
469            let enum_name = enum_def
470                .attributes
471                .iter()
472                .find(|a| a.is("map"))
473                .and_then(|a: &prax_schema::ast::Attribute| a.first_arg())
474                .and_then(|v: &prax_schema::ast::AttributeValue| v.as_string())
475                .map(|s| s.to_string())
476                .unwrap_or_else(|| to_snake_case(enum_def.name()));
477
478            sql.push_str(&format!(
479                "DO $$ BEGIN\n    CREATE TYPE \"{}\" AS ENUM (",
480                enum_name
481            ));
482
483            let variants: Vec<String> = enum_def
484                .variants
485                .iter()
486                .map(|v| format!("'{}'", v.name()))
487                .collect();
488
489            sql.push_str(&variants.join(", "));
490            sql.push_str(");\nEXCEPTION\n    WHEN duplicate_object THEN null;\nEND $$;\n\n");
491        }
492        sql.push('\n');
493    }
494
495    // Generate CREATE TABLE statements for each model
496    sql.push_str("-- Tables\n");
497    for model in schema.models.values() {
498        let table_name = model.table_name();
499
500        sql.push_str(&format!(
501            "CREATE TABLE IF NOT EXISTS \"{}\" (\n",
502            table_name
503        ));
504
505        let mut columns = Vec::new();
506        let mut primary_keys = Vec::new();
507
508        for field in model.fields.values() {
509            if field.is_relation() {
510                continue;
511            }
512
513            let column_name = field
514                .get_attribute("map")
515                .and_then(|a| a.first_arg())
516                .and_then(|v| v.as_string())
517                .map(|s| s.to_string())
518                .unwrap_or_else(|| to_snake_case(field.name()));
519
520            let sql_type = field_type_to_sql(&field.field_type);
521            let mut column_def = format!("    \"{}\" {}", column_name, sql_type);
522
523            // Add constraints
524            if field.is_id() {
525                primary_keys.push(column_name.clone());
526            }
527
528            if field.has_attribute("auto") || field.has_attribute("autoincrement") {
529                // PostgreSQL uses SERIAL types
530                column_def = format!("    \"{}\" SERIAL", column_name);
531            }
532
533            if field.has_attribute("unique") {
534                column_def.push_str(" UNIQUE");
535            }
536
537            if !field.is_optional() && !field.is_id() {
538                column_def.push_str(" NOT NULL");
539            }
540
541            // Default values
542            if let Some(default_attr) = field.get_attribute("default")
543                && let Some(value) = default_attr.first_arg()
544            {
545                let value_str = format_attribute_value(value);
546                column_def.push_str(&format!(
547                    " DEFAULT {}",
548                    sql_default_value(&value_str, &field.field_type)
549                ));
550            }
551
552            columns.push(column_def);
553        }
554
555        sql.push_str(&columns.join(",\n"));
556
557        if !primary_keys.is_empty() {
558            sql.push_str(",\n");
559            sql.push_str(&format!(
560                "    PRIMARY KEY (\"{}\")",
561                primary_keys.join("\", \"")
562            ));
563        }
564
565        sql.push_str("\n);\n\n");
566    }
567
568    return Ok(sql);
569
570    fn field_type_to_sql(field_type: &FieldType) -> String {
571        match field_type {
572            FieldType::Scalar(scalar) => match scalar {
573                ScalarType::Int => "INTEGER".to_string(),
574                ScalarType::BigInt => "BIGINT".to_string(),
575                ScalarType::Float => "DOUBLE PRECISION".to_string(),
576                ScalarType::String => "TEXT".to_string(),
577                ScalarType::Boolean => "BOOLEAN".to_string(),
578                ScalarType::DateTime => "TIMESTAMP WITH TIME ZONE".to_string(),
579                ScalarType::Date => "DATE".to_string(),
580                ScalarType::Time => "TIME".to_string(),
581                ScalarType::Json => "JSONB".to_string(),
582                ScalarType::Bytes => "BYTEA".to_string(),
583                ScalarType::Decimal => "DECIMAL".to_string(),
584                ScalarType::Uuid => "UUID".to_string(),
585                ScalarType::Cuid | ScalarType::Cuid2 | ScalarType::NanoId | ScalarType::Ulid => {
586                    "TEXT".to_string()
587                }
588                ScalarType::Vector(dim) => match dim {
589                    Some(d) => format!("vector({})", d),
590                    None => "vector".to_string(),
591                },
592                ScalarType::HalfVector(dim) => match dim {
593                    Some(d) => format!("halfvec({})", d),
594                    None => "halfvec".to_string(),
595                },
596                ScalarType::SparseVector(dim) => match dim {
597                    Some(d) => format!("sparsevec({})", d),
598                    None => "sparsevec".to_string(),
599                },
600                ScalarType::Bit(dim) => match dim {
601                    Some(d) => format!("bit({})", d),
602                    None => "bit".to_string(),
603                },
604            },
605            FieldType::Enum(name) => format!("\"{}\"", to_snake_case(name)),
606            _ => "TEXT".to_string(),
607        }
608    }
609}
610
611async fn apply_migration(migration_path: &Path, _config: &Config) -> CliResult<()> {
612    let sql_path = migration_path.join("migration.sql");
613
614    if !sql_path.exists() {
615        return Err(CliError::Migration(format!(
616            "Migration file not found: {}",
617            sql_path.display()
618        )));
619    }
620
621    // Honest failure: applying migrations requires a database executor (driver /
622    // prax-migrate engine) that is not yet wired into the CLI. Do NOT write the
623    // `.applied` marker or report success for work that was not performed.
624    Err(CliError::Migration(format!(
625        "Applying migration '{}' is not yet implemented: executing migration SQL \
626         requires a database executor that is not yet wired into the CLI. The \
627         migration SQL is at {}; apply it with an external tool for now.",
628        migration_path.display(),
629        sql_path.display()
630    )))
631}
632
633fn sql_default_value(value: &str, field_type: &prax_schema::ast::FieldType) -> String {
634    use prax_schema::ast::{FieldType, ScalarType};
635
636    // Handle enum defaults - need to be quoted as strings
637    if matches!(field_type, FieldType::Enum(_)) {
638        return format!("'{}'", value);
639    }
640
641    match value.to_lowercase().as_str() {
642        "now()" => "CURRENT_TIMESTAMP".to_string(),
643        "uuid()" => "gen_random_uuid()".to_string(),
644        "cuid()" | "cuid2()" | "nanoid()" | "ulid()" => {
645            // These need application-level generation
646            "''".to_string()
647        }
648        "true" => "TRUE".to_string(),
649        "false" => "FALSE".to_string(),
650        _ => {
651            // String-typed defaults arrive double-quoted from
652            // `format_attribute_value`. In SQL, double quotes denote an
653            // identifier, so re-quote as a string literal with single quotes
654            // and escape embedded single quotes (' -> '').
655            let is_string_typed = matches!(
656                field_type,
657                FieldType::Scalar(
658                    ScalarType::String
659                        | ScalarType::Cuid
660                        | ScalarType::Cuid2
661                        | ScalarType::NanoId
662                        | ScalarType::Ulid
663                )
664            );
665
666            if is_string_typed {
667                let inner = value
668                    .strip_prefix('"')
669                    .and_then(|v| v.strip_suffix('"'))
670                    .unwrap_or(value);
671                format!("'{}'", inner.replace('\'', "''"))
672            } else {
673                value.to_string()
674            }
675        }
676    }
677}
678
679fn to_snake_case(name: &str) -> String {
680    let mut result = String::new();
681    for (i, c) in name.chars().enumerate() {
682        if c.is_uppercase() {
683            if i > 0 {
684                result.push('_');
685            }
686            result.push(c.to_lowercase().next().unwrap());
687        } else {
688            result.push(c);
689        }
690    }
691    result
692}
693
694fn format_attribute_value(value: &prax_schema::ast::AttributeValue) -> String {
695    use prax_schema::ast::AttributeValue;
696
697    match value {
698        AttributeValue::String(s) => format!("\"{}\"", s),
699        AttributeValue::Int(i) => i.to_string(),
700        AttributeValue::Float(f) => f.to_string(),
701        AttributeValue::Boolean(b) => b.to_string(),
702        AttributeValue::Ident(id) => id.to_string(),
703        AttributeValue::Function(name, args) => {
704            if args.is_empty() {
705                format!("{}()", name)
706            } else {
707                let arg_strs: Vec<String> = args.iter().map(format_attribute_value).collect();
708                format!("{}({})", name, arg_strs.join(", "))
709            }
710        }
711        AttributeValue::Array(items) => {
712            let item_strs: Vec<String> = items.iter().map(format_attribute_value).collect();
713            format!("[{}]", item_strs.join(", "))
714        }
715        AttributeValue::FieldRef(field) => field.to_string(),
716        AttributeValue::FieldRefList(fields) => {
717            format!(
718                "[{}]",
719                fields
720                    .iter()
721                    .map(|f| f.to_string())
722                    .collect::<Vec<_>>()
723                    .join(", ")
724            )
725        }
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use prax_schema::ast::{FieldType, ScalarType};
733
734    // -- sql_default_value --------------------------------------------------
735
736    #[test]
737    fn test_sql_default_value_string_single_quoted() {
738        // format_attribute_value emits string defaults double-quoted; SQL string
739        // literals must be single-quoted (double quotes denote an identifier).
740        let ty = FieldType::Scalar(ScalarType::String);
741        assert_eq!(sql_default_value("\"active\"", &ty), "'active'");
742    }
743
744    #[test]
745    fn test_sql_default_value_string_escapes_quotes() {
746        let ty = FieldType::Scalar(ScalarType::String);
747        assert_eq!(sql_default_value("\"it's\"", &ty), "'it''s'");
748    }
749
750    #[test]
751    fn test_sql_default_value_non_string_passthrough() {
752        let ty = FieldType::Scalar(ScalarType::Int);
753        assert_eq!(sql_default_value("42", &ty), "42");
754    }
755
756    #[test]
757    fn test_sql_default_value_enum_single_quoted() {
758        let ty = FieldType::Enum("Role".into());
759        assert_eq!(sql_default_value("ADMIN", &ty), "'ADMIN'");
760    }
761
762    #[test]
763    fn test_sql_default_value_function_and_boolean_defaults() {
764        let ty = FieldType::Scalar(ScalarType::DateTime);
765        assert_eq!(sql_default_value("now()", &ty), "CURRENT_TIMESTAMP");
766
767        let ty = FieldType::Scalar(ScalarType::Boolean);
768        assert_eq!(sql_default_value("true", &ty), "TRUE");
769        assert_eq!(sql_default_value("false", &ty), "FALSE");
770    }
771
772    // -- honest-error paths ---------------------------------------------------
773
774    #[tokio::test]
775    async fn test_apply_migration_fails_without_executor() {
776        let dir = tempfile::tempdir().unwrap();
777        let migration_path = dir.path().join("20240101000000_init");
778        std::fs::create_dir_all(&migration_path).unwrap();
779        std::fs::write(
780            migration_path.join("migration.sql"),
781            "CREATE TABLE t (id INT);",
782        )
783        .unwrap();
784
785        let result = apply_migration(&migration_path, &Config::default()).await;
786
787        match result {
788            Err(CliError::Migration(msg)) => {
789                assert!(
790                    msg.contains("not yet implemented"),
791                    "unexpected message: {msg}"
792                );
793            }
794            other => panic!("expected CliError::Migration, got {other:?}"),
795        }
796
797        // The .applied marker must NOT be written for work that was not done.
798        assert!(!migration_path.join(".applied").exists());
799    }
800
801    #[tokio::test]
802    async fn test_apply_migration_missing_file() {
803        let dir = tempfile::tempdir().unwrap();
804
805        match apply_migration(dir.path(), &Config::default()).await {
806            Err(CliError::Migration(msg)) => assert!(msg.contains("not found")),
807            other => panic!("expected CliError::Migration, got {other:?}"),
808        }
809    }
810
811    #[tokio::test]
812    async fn test_run_reset_not_implemented() {
813        let args = crate::cli::MigrateResetArgs {
814            force: true,
815            seed: false,
816            skip_migrations: false,
817        };
818
819        match run_reset(args).await {
820            Err(CliError::Migration(msg)) => {
821                assert!(
822                    msg.contains("not yet implemented"),
823                    "unexpected message: {msg}"
824                );
825            }
826            other => panic!("expected CliError::Migration, got {other:?}"),
827        }
828    }
829
830    #[tokio::test]
831    async fn test_run_resolve_not_implemented() {
832        let args = crate::cli::MigrateResolveArgs {
833            migration: "20240101000000_init".to_string(),
834            applied: true,
835            rolled_back: false,
836        };
837
838        match run_resolve(args).await {
839            Err(CliError::Migration(msg)) => {
840                assert!(
841                    msg.contains("not yet implemented"),
842                    "unexpected message: {msg}"
843                );
844                assert!(msg.contains("20240101000000_init"));
845            }
846            other => panic!("expected CliError::Migration, got {other:?}"),
847        }
848    }
849
850    #[tokio::test]
851    async fn test_run_resolve_requires_a_flag() {
852        let args = crate::cli::MigrateResolveArgs {
853            migration: "m".to_string(),
854            applied: false,
855            rolled_back: false,
856        };
857
858        match run_resolve(args).await {
859            Err(CliError::Command(msg)) => assert!(msg.contains("--applied or --rolled-back")),
860            other => panic!("expected CliError::Command, got {other:?}"),
861        }
862    }
863
864    #[tokio::test]
865    async fn test_run_rollback_not_implemented() {
866        let args = crate::cli::MigrateRollbackArgs {
867            reason: None,
868            user: None,
869            to: None,
870        };
871
872        match run_rollback(args).await {
873            Err(CliError::Migration(msg)) => {
874                assert!(
875                    msg.contains("not yet implemented"),
876                    "unexpected message: {msg}"
877                );
878            }
879            other => panic!("expected CliError::Migration, got {other:?}"),
880        }
881    }
882
883    #[tokio::test]
884    async fn test_run_history_not_implemented() {
885        let args = crate::cli::MigrateHistoryArgs { migration: None };
886
887        match run_history(args).await {
888            Err(CliError::Migration(msg)) => {
889                assert!(
890                    msg.contains("_prax_migrations"),
891                    "unexpected message: {msg}"
892                );
893            }
894            other => panic!("expected CliError::Migration, got {other:?}"),
895        }
896    }
897
898    #[tokio::test]
899    async fn test_run_diff_from_migration_unsupported() {
900        let args = crate::cli::MigrateDiffArgs {
901            schema: None,
902            output: None,
903            from_migration: Some("20240101000000_init".to_string()),
904        };
905
906        match run_diff(args).await {
907            Err(CliError::Migration(msg)) => {
908                assert!(
909                    msg.contains("--from-migration"),
910                    "unexpected message: {msg}"
911                );
912            }
913            other => panic!("expected CliError::Migration, got {other:?}"),
914        }
915    }
916}