use anyhow::{bail, Context, Result};
use colored::Colorize;
use std::path::Path;
use crate::config::{generate_migration_id, CliConfig};
use crate::generator::MigrationGenerator;
use ormada_schema::{discover_models, DiscoveryConfig};
pub async fn migrate_init(custom_path: Option<&str>) -> Result<()> {
let mut config = CliConfig::load()?;
if let Some(path) = custom_path {
config.migrations_dir = std::path::PathBuf::from(path);
}
let migrations_path = config.migrations_path();
let config_path = config.project_root.join("ormada.toml");
if config_path.exists() {
let existing_config = std::fs::read_to_string(&config_path)?;
if let Some(existing_path) = parse_migrations_path_from_toml(&existing_config) {
let new_path = config.migrations_dir.to_string_lossy();
if existing_path != new_path {
println!(
"{} ormada.toml already exists with migrations_dir = \"{}\"",
"!".yellow(),
existing_path
);
println!(" To use a different path, edit ormada.toml or delete it first");
return Ok(());
}
}
}
if migrations_path.exists() {
println!(
"{} Migrations directory already exists at {}",
"✓".green(),
migrations_path.display()
);
if !config_path.exists() {
write_ormada_toml(&config_path, &config.migrations_dir)?;
println!("{} Created ormada.toml", "✓".green());
}
return Ok(());
}
config.ensure_migrations_dir()?;
let mod_path = migrations_path.join("mod.rs");
std::fs::write(
&mod_path,
"//! Database migrations\n//!\n//! Generated by `ormada migrate init`\n",
)?;
write_ormada_toml(&config_path, &config.migrations_dir)?;
println!("{} Created migrations directory at {}", "✓".green(), migrations_path.display());
println!("{} Created ormada.toml", "✓".green());
println!(
" Run {} to generate your first migration",
"ormada migrate make \"initial\"".cyan()
);
Ok(())
}
fn parse_migrations_path_from_toml(content: &str) -> Option<String> {
for line in content.lines() {
let line = line.trim();
if line.starts_with("migrations_dir") {
if let Some((_key, value)) = line.split_once('=') {
let value = value.trim().trim_matches('"');
return Some(value.to_string());
}
}
}
None
}
fn write_ormada_toml(path: &std::path::Path, migrations_dir: &std::path::Path) -> Result<()> {
let content = format!(
r#"# Ormada ORM Configuration
# Generated by `ormada migrate init`
[migrations]
# Directory containing migration files
migrations_dir = "{}"
# [database]
# url = "postgres://user:pass@localhost/db"
"#,
migrations_dir.display()
);
std::fs::write(path, content)?;
Ok(())
}
pub async fn migrate_make(name: &str, _yes: bool) -> Result<()> {
let config = CliConfig::load()?;
config.ensure_migrations_dir()?;
println!("{} Scanning models...", "→".blue());
let discovery_config = DiscoveryConfig::default();
let models = discover_models(&config.project_root, &discovery_config)
.context("Failed to discover models")?;
if models.is_empty() {
println!("{} No models found with #[ormada_model] attribute", "!".yellow());
println!(
" Make sure your models are in {} and use #[ormada_model(table = \"...\")]",
config
.model_paths
.first()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "src".to_string())
);
return Ok(());
}
println!(" Found {} model(s)", models.len());
for model in &models {
println!(" {} {}", "•".dimmed(), model.name);
}
let migrations_path = config.migrations_path();
let existing_schema = load_existing_schema(&migrations_path)?;
let operations = ormada_schema::generate_diff(&existing_schema, &models);
if operations.is_empty() {
println!("{} No changes detected", "✓".green());
return Ok(());
}
println!("\n{} Detected {} change(s):", "→".blue(), operations.len());
for op in &operations {
println!(" {} {}", "•".dimmed(), format_operation(op));
}
let migration_id = generate_migration_id(name);
let generator = MigrationGenerator::new(&migrations_path);
let migration_path = generator.generate(&migration_id, &models, &operations)?;
println!("\n{} Created migration: {}", "✓".green(), migration_path.display());
println!(" Review the migration file and run {} to apply", "ormada migrate run".cyan());
Ok(())
}
pub async fn migrate_status() -> Result<()> {
use crate::tracker::MigrationRunner;
let config = CliConfig::load()?;
let migrations_path = config.migrations_path();
if !migrations_path.exists() {
println!("{} No migrations directory found", "!".yellow());
println!(" Run {} to initialize", "ormada migrate init".cyan());
return Ok(());
}
let mut migrations: Vec<_> = std::fs::read_dir(&migrations_path)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "rs") && e.file_name() != "mod.rs"
})
.collect();
migrations.sort_by_key(|e| e.file_name());
if migrations.is_empty() {
println!("{} No migrations found", "!".yellow());
println!(
" Run {} to create your first migration",
"ormada migrate make \"initial\"".cyan()
);
return Ok(());
}
let applied_versions: std::collections::HashSet<String> = if let Some(ref db_url) =
config.database_url
{
match MigrationRunner::new(db_url).await {
Ok(runner) => match runner.get_applied_migrations().await {
Ok(applied) => applied.into_iter().map(|m| m.version).collect(),
Err(_) => std::collections::HashSet::new(),
},
Err(_) => {
println!("{} Could not connect to database - showing all as pending", "!".yellow());
std::collections::HashSet::new()
}
}
} else {
println!("{} DATABASE_URL not set - cannot check applied status", "!".yellow());
std::collections::HashSet::new()
};
let mut applied_count = 0;
let mut pending_count = 0;
println!("{} Migrations:", "→".blue());
for entry in &migrations {
let name = entry.file_name();
let name_str = name.to_string_lossy();
let version = name_str.trim_end_matches(".rs");
if applied_versions.contains(version) {
println!(" {} {}", "●".green(), version);
applied_count += 1;
} else {
println!(" {} {}", "○".dimmed(), version);
pending_count += 1;
}
}
println!();
if applied_count > 0 {
println!(" {} applied, {} pending", applied_count.to_string().green(), pending_count);
} else {
println!(" {} = pending, {} = applied", "○".dimmed(), "●".green());
}
Ok(())
}
pub async fn migrate_run(_migration: Option<&str>, dry_run: bool) -> Result<()> {
use crate::sql_generator::generate_sql;
use crate::tracker::{MigrationRunner, PendingMigration};
let config = CliConfig::load()?;
let database_url = config.database_url.as_ref().ok_or_else(|| {
anyhow::anyhow!("DATABASE_URL not set. Set it in environment or ormada.toml")
})?;
let migrations_path = config.migrations_path();
if !migrations_path.exists() {
bail!("No migrations directory found. Run `ormada migrate init` first.");
}
println!("{} Connecting to database...", "→".blue());
let runner = MigrationRunner::new(database_url).await?;
println!("{} Scanning migrations...", "→".blue());
let discovery_config = DiscoveryConfig {
include_paths: vec![migrations_path.to_string_lossy().to_string()],
skip_non_migratable: false,
skip_test_models: true,
..Default::default()
};
let models = discover_models(&migrations_path, &discovery_config)
.context("Failed to parse migration files")?;
if models.is_empty() {
println!("{} No migrations found", "!".yellow());
return Ok(());
}
let mut pending_migrations = Vec::new();
let mut migration_files: Vec<_> = std::fs::read_dir(&migrations_path)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "rs") && e.file_name() != "mod.rs"
})
.collect();
migration_files.sort_by_key(|e| e.file_name());
for entry in &migration_files {
let file_name = entry.file_name();
let version = file_name.to_string_lossy().trim_end_matches(".rs").to_string();
let file_path = entry.path();
let content = std::fs::read_to_string(&file_path)?;
let file_models =
ormada_schema::parse_source(&content, &version, &discovery_config).unwrap_or_default();
if file_models.is_empty() {
continue;
}
let operations: Vec<_> = file_models
.iter()
.map(|m| ormada_schema::SchemaOperation::CreateTable(m.clone()))
.collect();
let sql = generate_sql(&operations);
if !sql.is_empty() {
let name = version.split('_').skip(2).collect::<Vec<_>>().join("_");
pending_migrations.push(PendingMigration::new(&version, &name, &sql));
}
}
if pending_migrations.is_empty() {
println!("{} No migrations to apply", "✓".green());
return Ok(());
}
println!(" Found {} migration(s)", pending_migrations.len());
let report = runner.run_migrations(&pending_migrations, dry_run).await?;
println!("\n{}", report.format());
if dry_run && !report.applied.is_empty() {
println!("\n{}", "SQL that would be executed:".cyan());
for m in &pending_migrations {
if report.applied.iter().any(|a| a.version == m.version) {
println!("\n-- {} --", m.version.green());
println!("{}", m.sql);
}
}
}
Ok(())
}
pub async fn migrate_rollback(steps: u32, yes: bool) -> Result<()> {
use crate::tracker::MigrationRunner;
use dialoguer::Confirm;
let config = CliConfig::load()?;
let database_url = config
.database_url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("DATABASE_URL not set"))?;
println!("{} Connecting to database...", "→".blue());
let runner = MigrationRunner::new(database_url).await?;
let applied = runner.get_applied_migrations().await?;
if applied.is_empty() {
println!("{} No migrations to rollback", "!".yellow());
return Ok(());
}
let to_rollback: Vec<_> = applied.iter().rev().take(steps as usize).collect();
if to_rollback.is_empty() {
println!("{} No migrations to rollback", "!".yellow());
return Ok(());
}
println!("{} Migrations to rollback:", "→".blue());
for m in &to_rollback {
println!(" {} {} ({})", "●".red(), m.version, m.name);
}
if !yes {
let confirm = Confirm::new()
.with_prompt("Are you sure you want to rollback these migrations?")
.default(false)
.interact()?;
if !confirm {
println!("{} Rollback cancelled", "!".yellow());
return Ok(());
}
}
println!(
"{} Rollback removes migration records but does NOT reverse schema changes",
"!".yellow()
);
println!(" You must manually reverse the schema changes or restore from backup");
for m in &to_rollback {
runner.remove_migration_record(&m.version).await?;
println!(" {} Removed: {}", "✓".green(), m.version);
}
println!("\n{} Rolled back {} migration(s)", "✓".green(), to_rollback.len());
Ok(())
}
pub async fn migrate_sql(output: Option<&str>) -> Result<()> {
use crate::sql_generator::generate_sql;
use crate::tracker::{MigrationRunner, PendingMigration};
let config = CliConfig::load()?;
let migrations_path = config.migrations_path();
if !migrations_path.exists() {
bail!("No migrations directory found. Run `ormada migrate init` first.");
}
let discovery_config = DiscoveryConfig {
include_paths: vec![migrations_path.to_string_lossy().to_string()],
skip_non_migratable: false,
skip_test_models: true,
..Default::default()
};
let mut migration_files: Vec<_> = std::fs::read_dir(&migrations_path)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "rs") && e.file_name() != "mod.rs"
})
.collect();
migration_files.sort_by_key(|e| e.file_name());
let mut all_migrations = Vec::new();
for entry in &migration_files {
let file_name = entry.file_name();
let version = file_name.to_string_lossy().trim_end_matches(".rs").to_string();
let file_path = entry.path();
let content = std::fs::read_to_string(&file_path)?;
let file_models =
ormada_schema::parse_source(&content, &version, &discovery_config).unwrap_or_default();
if file_models.is_empty() {
continue;
}
let operations: Vec<_> = file_models
.iter()
.map(|m| ormada_schema::SchemaOperation::CreateTable(m.clone()))
.collect();
let sql = generate_sql(&operations);
if !sql.is_empty() {
let name = version.split('_').skip(2).collect::<Vec<_>>().join("_");
all_migrations.push(PendingMigration::new(&version, &name, &sql));
}
}
let pending_migrations = if let Some(ref db_url) = config.database_url {
match MigrationRunner::new(db_url).await {
Ok(runner) => {
runner.get_pending_migrations(&all_migrations).await.unwrap_or(all_migrations)
}
Err(_) => all_migrations,
}
} else {
all_migrations
};
if pending_migrations.is_empty() {
println!("{} No pending migrations", "✓".green());
return Ok(());
}
let mut combined_sql = String::new();
combined_sql.push_str("-- Ormada Migration SQL\n");
combined_sql.push_str(&format!(
"-- Generated: {}\n\n",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
));
for m in &pending_migrations {
combined_sql.push_str(&format!("-- Migration: {} ({})\n", m.version, m.name));
combined_sql.push_str(&format!("-- Checksum: {}\n", &m.checksum[..16]));
combined_sql.push_str("-- ----------------------------------------\n\n");
combined_sql.push_str(&m.sql);
combined_sql.push_str("\n\n");
}
if let Some(path) = output {
std::fs::write(path, &combined_sql)?;
println!("{} SQL written to: {}", "✓".green(), path);
} else {
println!("{}", combined_sql);
}
Ok(())
}
fn load_existing_schema(migrations_path: &Path) -> Result<Vec<ormada_schema::TableSchema>> {
if !migrations_path.exists() {
return Ok(Vec::new());
}
let discovery_config = DiscoveryConfig {
include_paths: vec![migrations_path.to_string_lossy().to_string()],
exclude_paths: vec![],
skip_non_migratable: false,
skip_test_models: true,
};
let mut migration_files: Vec<_> = std::fs::read_dir(migrations_path)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "rs") && e.file_name() != "mod.rs"
})
.collect();
migration_files.sort_by_key(|e| e.file_name());
let mut schemas: std::collections::HashMap<String, ormada_schema::TableSchema> =
std::collections::HashMap::new();
for entry in migration_files {
let file_path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
let content = match std::fs::read_to_string(&file_path) {
Ok(c) => c,
Err(_) => continue,
};
let tables = ormada_schema::parse_source(&content, &file_name, &discovery_config)
.unwrap_or_default();
for table in tables {
schemas.insert(table.name.clone(), table);
}
}
Ok(schemas.into_values().collect())
}
fn format_operation(op: &ormada_schema::SchemaOperation) -> String {
use ormada_schema::SchemaOperation::*;
match op {
CreateTable(schema) => format!("Create table '{}'", schema.name),
DropTable(name) => format!("Drop table '{}'", name),
RenameTable { from, to } => format!("Rename table '{}' to '{}'", from, to),
AddColumn { table, column } => format!("Add column '{}' to '{}'", column.name, table),
DropColumn { table, column } => format!("Drop column '{}' from '{}'", column, table),
RenameColumn { table, from, to } => {
format!("Rename column '{}' to '{}' in '{}'", from, to, table)
}
AlterColumn { table, column, .. } => format!("Alter column '{}' in '{}'", column, table),
CreateIndex { table, index } => format!("Create index '{}' on '{}'", index.name, table),
DropIndex { table, name } => format!("Drop index '{}' from '{}'", name, table),
AddForeignKey { table, foreign_key } => {
format!(
"Add foreign key '{}' -> '{}' on '{}'",
foreign_key.column, foreign_key.references_table, table
)
}
DropForeignKey { table, name } => format!("Drop foreign key '{}' from '{}'", name, table),
}
}