use std::{path::Path, process::Command};
use anyhow::{Context, Result};
use tracing::info;
use crate::output::OutputFormatter;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum MigrateAction {
Up {
database_url: String,
dir: String,
},
Down {
database_url: String,
dir: String,
steps: u32,
},
Status {
database_url: String,
dir: String,
},
Create {
name: String,
dir: String,
},
Generate {
name: String,
dir: String,
},
Validate {
dir: String,
},
Preflight {
dir: String,
},
}
pub fn run(action: &MigrateAction, formatter: &OutputFormatter) -> Result<()> {
if !is_confiture_installed() {
print_install_instructions(formatter);
anyhow::bail!("confiture is not installed. See instructions above.");
}
match action {
MigrateAction::Up { database_url, dir } => run_up(database_url, dir, formatter),
MigrateAction::Down {
database_url,
dir,
steps,
} => run_down(database_url, dir, *steps, formatter),
MigrateAction::Status { database_url, dir } => run_status(database_url, dir),
MigrateAction::Create { name, dir } => run_create(name, dir, formatter),
MigrateAction::Generate { name, dir } => run_generate(name, dir, formatter),
MigrateAction::Validate { dir } => run_validate(dir),
MigrateAction::Preflight { dir } => run_preflight(dir, formatter),
}
}
pub fn resolve_database_url(explicit: Option<&str>) -> Result<String> {
if let Some(url) = explicit {
return Ok(url.to_string());
}
let toml_path = Path::new("fraiseql.toml");
if toml_path.exists() {
let content = std::fs::read_to_string(toml_path).context("Failed to read fraiseql.toml")?;
let parsed: toml::Value =
toml::from_str(&content).context("Failed to parse fraiseql.toml")?;
if let Some(url) = parsed
.get("database")
.and_then(|db| db.get("url"))
.and_then(toml::Value::as_str)
{
info!("Using database URL from fraiseql.toml");
return Ok(url.to_string());
}
}
if let Ok(url) = std::env::var("DATABASE_URL") {
info!("Using DATABASE_URL environment variable");
return Ok(url);
}
anyhow::bail!(
"No database URL provided. Use --database, set [database].url in fraiseql.toml, \
or set DATABASE_URL environment variable."
)
}
pub fn resolve_migration_dir(explicit: Option<&str>) -> String {
if let Some(dir) = explicit {
return dir.to_string();
}
for candidate in &["db/0_schema", "db/migrations", "migrations"] {
if Path::new(candidate).is_dir() {
info!("Auto-discovered migration directory: {candidate}");
return (*candidate).to_string();
}
}
"db/0_schema".to_string()
}
fn is_confiture_installed() -> bool {
Command::new("confiture")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn print_install_instructions(formatter: &OutputFormatter) {
formatter.progress("confiture is not installed.");
formatter.progress("");
formatter.progress("Install it with one of:");
formatter.progress(" cargo install confiture # From crates.io");
formatter.progress(" brew install confiture # macOS (if available)");
formatter.progress("");
formatter.progress("Learn more: https://github.com/fraiseql/confiture");
}
fn run_up(database_url: &str, dir: &str, formatter: &OutputFormatter) -> Result<()> {
info!("Running migrations up from {dir}");
formatter.progress(&format!("Applying migrations from {dir}..."));
let status = Command::new("confiture")
.args(["up", "--source", dir])
.env("DATABASE_URL", database_url)
.status()
.context("Failed to execute confiture")?;
if status.success() {
formatter.progress("Migrations applied successfully.");
Ok(())
} else {
anyhow::bail!("Migration failed. Check the output above for details.")
}
}
fn run_down(database_url: &str, dir: &str, steps: u32, formatter: &OutputFormatter) -> Result<()> {
info!("Rolling back {steps} migration(s) from {dir}");
formatter.progress(&format!("Rolling back {steps} migration(s)..."));
let steps_str = steps.to_string();
let status = Command::new("confiture")
.args(["down", "--source", dir, "--steps", &steps_str])
.env("DATABASE_URL", database_url)
.status()
.context("Failed to execute confiture")?;
if status.success() {
formatter.progress("Rollback completed successfully.");
Ok(())
} else {
anyhow::bail!("Rollback failed. Check the output above for details.")
}
}
fn run_status(database_url: &str, dir: &str) -> Result<()> {
info!("Checking migration status for {dir}");
let status = Command::new("confiture")
.args(["status", "--source", dir])
.env("DATABASE_URL", database_url)
.status()
.context("Failed to execute confiture")?;
if status.success() {
Ok(())
} else {
anyhow::bail!("Failed to get migration status.")
}
}
fn run_create(name: &str, dir: &str, formatter: &OutputFormatter) -> Result<()> {
info!("Creating migration: {name} in {dir}");
std::fs::create_dir_all(dir).context(format!("Failed to create migration directory: {dir}"))?;
let status = Command::new("confiture")
.args(["create", name, "--source", dir])
.status()
.context("Failed to execute confiture")?;
if status.success() {
formatter.progress(&format!("Migration created in {dir}/"));
Ok(())
} else {
anyhow::bail!("Failed to create migration.")
}
}
fn run_generate(name: &str, dir: &str, formatter: &OutputFormatter) -> Result<()> {
info!("Generating migration: {name} in {dir}");
std::fs::create_dir_all(dir).context(format!("Failed to create migration directory: {dir}"))?;
formatter.progress(&format!("Generating migration '{name}' in {dir}..."));
let status = Command::new("confiture")
.args(["migrate", "generate", name, "--migrations-dir", dir])
.status()
.context("Failed to execute confiture")?;
if status.success() {
formatter.progress(&format!("Migration generated in {dir}/"));
Ok(())
} else {
anyhow::bail!("Failed to generate migration.")
}
}
fn run_validate(dir: &str) -> Result<()> {
info!("Validating migrations in {dir}");
let status = Command::new("confiture")
.args(["migrate", "validate", "--source", dir])
.status()
.context("Failed to execute confiture")?;
if status.success() {
Ok(())
} else {
anyhow::bail!("Migration validation failed. Check the output above for details.")
}
}
fn run_preflight(dir: &str, formatter: &OutputFormatter) -> Result<()> {
info!("Running preflight checks for {dir}");
formatter.progress(&format!("Running preflight checks on {dir}..."));
let status = Command::new("confiture")
.args(["migrate", "preflight", "--migrations-dir", dir])
.status()
.context("Failed to execute confiture")?;
if status.success() {
formatter.progress("Preflight checks passed.");
Ok(())
} else {
anyhow::bail!("Preflight checks failed. Check the output above for details.")
}
}