use std::{path::Path, process::Command};
use anyhow::{Context, Result};
use tracing::info;
#[derive(Debug, Clone)]
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,
},
}
pub fn run(action: &MigrateAction) -> Result<()> {
if !is_confiture_installed() {
print_install_instructions();
anyhow::bail!("confiture is not installed. See instructions above.");
}
match action {
MigrateAction::Up { database_url, dir } => run_up(database_url, dir),
MigrateAction::Down {
database_url,
dir,
steps,
} => run_down(database_url, dir, *steps),
MigrateAction::Status { database_url, dir } => run_status(database_url, dir),
MigrateAction::Create { name, dir } => run_create(name, dir),
}
}
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() {
eprintln!("confiture is not installed.");
eprintln!();
eprintln!("Install it with one of:");
eprintln!(" cargo install confiture # From crates.io");
eprintln!(" brew install confiture # macOS (if available)");
eprintln!();
eprintln!("Learn more: https://github.com/fraiseql/confiture");
}
fn run_up(database_url: &str, dir: &str) -> Result<()> {
info!("Running migrations up from {dir}");
println!("Applying migrations from {dir}...");
let status = Command::new("confiture")
.args(["up", "--source", dir, "--database-url", database_url])
.status()
.context("Failed to execute confiture")?;
if status.success() {
println!("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) -> Result<()> {
info!("Rolling back {steps} migration(s) from {dir}");
println!("Rolling back {steps} migration(s)...");
let steps_str = steps.to_string();
let status = Command::new("confiture")
.args([
"down",
"--source",
dir,
"--database-url",
database_url,
"--steps",
&steps_str,
])
.status()
.context("Failed to execute confiture")?;
if status.success() {
println!("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, "--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) -> 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() {
println!("Migration created in {dir}/");
Ok(())
} else {
anyhow::bail!("Failed to create migration.")
}
}
#[cfg(test)]
mod tests {
use super::*;
static GLOBAL_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn test_resolve_migration_dir_explicit() {
assert_eq!(resolve_migration_dir(Some("custom/dir")), "custom/dir");
}
#[test]
fn test_resolve_migration_dir_default() {
let dir = resolve_migration_dir(None);
assert!(!dir.is_empty());
}
#[test]
fn test_resolve_database_url_explicit() {
let url = resolve_database_url(Some("postgres://localhost/test")).unwrap();
assert_eq!(url, "postgres://localhost/test");
}
#[test]
fn test_resolve_database_url_no_source() {
let _guard = GLOBAL_STATE_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
temp_env::with_vars([("DATABASE_URL", None::<&str>)], || {
let result = resolve_database_url(None);
assert!(result.is_err());
});
std::env::set_current_dir(original).unwrap();
}
#[test]
fn test_resolve_database_url_from_env() {
let _guard = GLOBAL_STATE_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
temp_env::with_vars([("DATABASE_URL", Some("postgres://env/test"))], || {
let url = resolve_database_url(None).unwrap();
assert_eq!(url, "postgres://env/test");
});
std::env::set_current_dir(original).unwrap();
}
}