use std::{path::Path, process::Command};
use anyhow::{Context, Result};
use fraiseql_db::postgres::PostgresTlsConfig;
use tracing::info;
use crate::{config::DatabaseRuntimeConfig, 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<()> {
require_confiture(formatter)?;
let (command, done, failed) = match action {
MigrateAction::Up { database_url, dir } => {
info!("Running migrations up from {dir}");
formatter.progress(&format!("Applying migrations from {dir}..."));
(
ConfitureCommand::new(ConfitureVerb::Up, dir, formatter).database_url(database_url),
"Migrations applied successfully.".to_string(),
"Migration failed. Check the output above for details.",
)
},
MigrateAction::Down {
database_url,
dir,
steps,
} => {
info!("Rolling back {steps} migration(s) from {dir}");
formatter.progress(&format!("Rolling back {steps} migration(s)..."));
(
ConfitureCommand::new(ConfitureVerb::Down, dir, formatter)
.database_url(database_url)
.steps(*steps),
"Rollback completed successfully.".to_string(),
"Rollback failed. Check the output above for details.",
)
},
MigrateAction::Status { database_url, dir } => {
info!("Checking migration status for {dir}");
(
ConfitureCommand::new(ConfitureVerb::Status, dir, formatter)
.database_url(database_url),
String::new(),
"Failed to get migration status.",
)
},
MigrateAction::Create { name, dir } => {
info!("Creating migration: {name} in {dir}");
ensure_migrations_dir(dir)?;
(
ConfitureCommand::new(ConfitureVerb::Generate, dir, formatter).name(name),
format!("Migration created in {dir}/"),
"Failed to create migration.",
)
},
MigrateAction::Generate { name, dir } => {
info!("Generating migration: {name} in {dir}");
ensure_migrations_dir(dir)?;
formatter.progress(&format!("Generating migration '{name}' in {dir}..."));
(
ConfitureCommand::new(ConfitureVerb::Generate, dir, formatter).name(name),
format!("Migration generated in {dir}/"),
"Failed to generate migration.",
)
},
MigrateAction::Validate { dir } => {
info!("Validating migrations in {dir}");
(
ConfitureCommand::new(ConfitureVerb::Validate, dir, formatter),
String::new(),
"Migration validation failed. Check the output above for details.",
)
},
MigrateAction::Preflight { dir } => {
info!("Running preflight checks for {dir}");
formatter.progress(&format!("Running preflight checks on {dir}..."));
(
ConfitureCommand::new(ConfitureVerb::Preflight, dir, formatter),
"Preflight checks passed.".to_string(),
"Preflight checks failed. Check the output above for details.",
)
},
};
if command.succeeds()? {
if !done.is_empty() {
formatter.progress(&done);
}
Ok(())
} else {
anyhow::bail!("{failed}")
}
}
pub fn require_confiture(formatter: &OutputFormatter) -> Result<()> {
if is_confiture_installed() {
return Ok(());
}
print_install_instructions(formatter);
anyhow::bail!("confiture is not installed. See instructions above.")
}
pub async fn refuse_unsupported_server(database: &ResolvedDatabase) -> Result<()> {
crate::connection::postgres_adapter(&database.url, "migration", &database.tls)
.await
.map(drop)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConfitureVerb {
Up,
Down,
Status,
Generate,
Validate,
Preflight,
}
impl ConfitureVerb {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Up => "up",
Self::Down => "down",
Self::Status => "status",
Self::Generate => "generate",
Self::Validate => "validate",
Self::Preflight => "preflight",
}
}
pub(crate) const fn accepts_no_config(self) -> bool {
matches!(self, Self::Up | Self::Down | Self::Status | Self::Preflight)
}
}
#[derive(Clone)]
pub(crate) struct ConfitureCommand {
verb: ConfitureVerb,
migrations_dir: String,
name: Option<String>,
steps: Option<u32>,
json: bool,
database_url: Option<String>,
}
impl ConfitureCommand {
pub(crate) fn new(
verb: ConfitureVerb,
migrations_dir: &str,
formatter: &OutputFormatter,
) -> Self {
Self {
verb,
migrations_dir: migrations_dir.to_string(),
name: None,
steps: None,
json: formatter.is_json(),
database_url: None,
}
}
pub(crate) fn name(mut self, name: &str) -> Self {
self.name = Some(name.to_string());
self
}
pub(crate) const fn steps(mut self, steps: u32) -> Self {
self.steps = Some(steps);
self
}
pub(crate) fn database_url(mut self, url: &str) -> Self {
debug_assert!(
self.verb.accepts_no_config(),
"confiture migrate {} has no --no-config, so it cannot take a DSN this way",
self.verb.as_str()
);
self.database_url = Some(url.to_string());
self
}
pub(crate) fn argv(&self) -> Vec<String> {
let mut argv = vec!["migrate".to_string(), self.verb.as_str().to_string()];
if let Some(name) = &self.name {
argv.push(name.clone());
}
argv.push("--migrations-dir".to_string());
argv.push(self.migrations_dir.clone());
if let Some(steps) = self.steps {
argv.push("--steps".to_string());
argv.push(steps.to_string());
}
if self.database_url.is_some() {
argv.push("--no-config".to_string());
}
if self.json {
argv.push("--format".to_string());
argv.push("json".to_string());
}
argv
}
pub(crate) fn command(&self) -> Command {
let mut command = Command::new("confiture");
command.args(self.argv());
if let Some(url) = &self.database_url {
command.env("CONFITURE_DATABASE_URL", url);
}
command
}
fn succeeds(&self) -> Result<bool> {
let status = self.command().status().context("Failed to execute confiture")?;
Ok(status.success())
}
}
impl std::fmt::Debug for ConfitureCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConfitureCommand")
.field("verb", &self.verb)
.field("migrations_dir", &self.migrations_dir)
.field("name", &self.name)
.field("steps", &self.steps)
.field("json", &self.json)
.field("database_url", &self.database_url.as_ref().map(|_| "<redacted>"))
.finish()
}
}
fn ensure_migrations_dir(dir: &str) -> Result<()> {
std::fs::create_dir_all(dir).context(format!("Failed to create migration directory: {dir}"))
}
pub(crate) const MIGRATE_LONG_ABOUT: &str = "Run database migrations\n\n\
Wraps confiture: every verb is one `confiture migrate <verb>` call. The database URL is \
resolved in this order: the --database flag, then [database].url in fraiseql.toml, then \
the DATABASE_URL environment variable. The result reaches confiture as \
CONFITURE_DATABASE_URL with --no-config, so confiture's own config files never override \
it.";
pub fn resolve_database_url(explicit: Option<&str>) -> Result<String> {
resolve_database(explicit).map(|db| db.url)
}
#[derive(Debug, Clone)]
pub struct ResolvedDatabase {
pub url: String,
pub tls: PostgresTlsConfig,
}
pub fn resolve_database(explicit: Option<&str>) -> Result<ResolvedDatabase> {
resolve_configured_database(explicit)?.ok_or_else(|| {
anyhow::anyhow!(
"No database URL provided. Use --database, set [database].url in fraiseql.toml, \
or set DATABASE_URL environment variable."
)
})
}
pub fn resolve_configured_database(explicit: Option<&str>) -> Result<Option<ResolvedDatabase>> {
if let Some(url) = explicit {
return Ok(Some(ResolvedDatabase {
url: url.to_string(),
tls: PostgresTlsConfig::default(),
}));
}
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")?;
let database = DatabaseRuntimeConfig::from_document(&parsed)
.context("Failed to parse [database] in fraiseql.toml")?;
if let Some(url) = &database.url {
info!("Using database URL from fraiseql.toml");
return Ok(Some(ResolvedDatabase {
url: url.clone(),
tls: database.postgres_tls()?,
}));
}
}
if let Ok(url) = std::env::var("DATABASE_URL") {
info!("Using DATABASE_URL environment variable");
return Ok(Some(ResolvedDatabase {
url,
tls: PostgresTlsConfig::default(),
}));
}
Ok(None)
}
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");
}