1use 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
11pub 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
25async 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 let total_steps = if args.skip_seed { 5 } else { 6 };
45
46 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 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 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 output::step(4, total_steps, "Generating migration...");
71 let migration_path = create_migration(&migrations_dir, &migration_name, &schema)?;
72
73 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 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
118async 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 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 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 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
167async 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 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
196async 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 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
255async 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 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
280async fn run_diff(args: crate::cli::MigrateDiffArgs) -> CliResult<()> {
282 output::header("Migrate Diff");
283
284 let _cwd = std::env::current_dir()?;
285
286 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 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 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
327async 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 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
367async 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 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
397fn 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 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 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 let sql = generate_schema_diff(schema)?;
450
451 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 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 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 if field.is_id() {
525 primary_keys.push(column_name.clone());
526 }
527
528 if field.has_attribute("auto") || field.has_attribute("autoincrement") {
529 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 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 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 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 "''".to_string()
647 }
648 "true" => "TRUE".to_string(),
649 "false" => "FALSE".to_string(),
650 _ => {
651 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 #[test]
737 fn test_sql_default_value_string_single_quoted() {
738 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 #[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 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}