ironic 1.0.9

A batteries-included, type-safe application framework for Rust
Documentation
use std::{
    fs,
    io::Write,
    path::Path,
    time::{SystemTime, UNIX_EPOCH},
};

use crate::{CliError, cli::MigrateAction};

const MIGRATIONS_DIR: &str = "./migrations";

#[cfg(not(feature = "sqlx-postgres"))]
const NO_SQLX_MSG: &str = "The `migrate up|down|status` commands require the `sqlx` feature.\n\
     Install with: cargo install ironic --features sqlx-postgres";

pub(crate) fn execute(action: MigrateAction, output: &mut impl Write) -> Result<(), CliError> {
    match action {
        MigrateAction::Create { name } => create_migration(&name, output),
        #[cfg(feature = "sqlx-postgres")]
        MigrateAction::Up => run_migrations(output),
        #[cfg(not(feature = "sqlx-postgres"))]
        MigrateAction::Up => {
            writeln!(output, "{NO_SQLX_MSG}")?;
            Ok(())
        }
        #[cfg(feature = "sqlx-postgres")]
        MigrateAction::Down { steps } => revert_migrations(steps, output),
        #[cfg(not(feature = "sqlx-postgres"))]
        MigrateAction::Down { .. } => {
            writeln!(output, "{NO_SQLX_MSG}")?;
            Ok(())
        }
        #[cfg(feature = "sqlx-postgres")]
        MigrateAction::Status => show_status(output),
        #[cfg(not(feature = "sqlx-postgres"))]
        MigrateAction::Status => {
            writeln!(output, "{NO_SQLX_MSG}")?;
            Ok(())
        }
    }
}

fn create_migration(name: &str, output: &mut impl Write) -> Result<(), CliError> {
    let dir = Path::new(MIGRATIONS_DIR);
    fs::create_dir_all(dir)?;

    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    let filename = format!("{timestamp}_{name}.sql");
    let path = dir.join(&filename);

    if path.exists() {
        return Err(CliError::FileConflict { path });
    }

    let mut file = fs::File::create(&path)?;
    writeln!(file, "-- Migration: {name}")?;
    writeln!(file, "-- Created: {timestamp}")?;
    writeln!(file)?;
    writeln!(file, "-- Write your up SQL here")?;
    writeln!(file)?;

    writeln!(output, "  ✓ Created {filename}")?;
    Ok(())
}

#[cfg(feature = "sqlx-postgres")]
fn load_database_url() -> Result<String, CliError> {
    if let Ok(url) = std::env::var("DATABASE_URL") {
        return Ok(url);
    }
    if let Ok(content) = fs::read_to_string("./.env") {
        for line in content.lines() {
            let line = line.trim();
            if line.starts_with('#') || line.is_empty() {
                continue;
            }
            if let Some(value) = line.strip_prefix("DATABASE_URL=") {
                return Ok(value.trim_matches('"').to_owned());
            }
        }
    }
    Err(CliError::CommandFailed {
        program: "ironic migrate".into(),
        status: "DATABASE_URL is not set — check your .env file or set the DATABASE_URL environment variable".into(),
    })
}

#[cfg(feature = "sqlx-postgres")]
type DbPool = sqlx::PgPool;

#[cfg(feature = "sqlx-postgres")]
async fn connect() -> Result<DbPool, CliError> {
    let url = load_database_url()?;
    DbPool::connect(&url)
        .await
        .map_err(|e| CliError::CommandFailed {
            program: "ironic migrate".into(),
            status: format!("failed to connect to database: {e}"),
        })
}

#[cfg(feature = "sqlx-postgres")]
fn run_migrations(output: &mut impl Write) -> Result<(), CliError> {
    let rt = tokio::runtime::Runtime::new().map_err(|e| CliError::CommandFailed {
        program: "ironic migrate".into(),
        status: format!("failed to start runtime: {e}"),
    })?;
    rt.block_on(async {
        let pool = connect().await?;
        let migrator = sqlx::migrate::Migrator::new(Path::new(MIGRATIONS_DIR))
            .await
            .map_err(|e| CliError::CommandFailed {
                program: "ironic migrate".into(),
                status: format!("failed to load migrations: {e}"),
            })?;

        migrator
            .run(&pool)
            .await
            .map_err(|e| CliError::CommandFailed {
                program: "ironic migrate".into(),
                status: format!("migration run failed: {e}"),
            })?;

        writeln!(output, "  ✓ Migrations applied successfully")?;
        Ok(())
    })
}

#[cfg(feature = "sqlx-postgres")]
fn revert_migrations(steps: i64, output: &mut impl Write) -> Result<(), CliError> {
    let rt = tokio::runtime::Runtime::new().map_err(|e| CliError::CommandFailed {
        program: "ironic migrate".into(),
        status: format!("failed to start runtime: {e}"),
    })?;
    rt.block_on(async {
        let pool = connect().await?;
        let migrator = sqlx::migrate::Migrator::new(Path::new(MIGRATIONS_DIR))
            .await
            .map_err(|e| CliError::CommandFailed {
                program: "ironic migrate".into(),
                status: format!("failed to load migrations: {e}"),
            })?;

        migrator
            .undo(&pool, steps)
            .await
            .map_err(|e| CliError::CommandFailed {
                program: "ironic migrate".into(),
                status: format!("migration revert failed: {e}"),
            })?;

        writeln!(output, "  ✓ Reverted {steps} migration(s)")?;
        Ok(())
    })
}

#[cfg(feature = "sqlx-postgres")]
fn show_status(output: &mut impl Write) -> Result<(), CliError> {
    let rt = tokio::runtime::Runtime::new().map_err(|e| CliError::CommandFailed {
        program: "ironic migrate".into(),
        status: format!("failed to start runtime: {e}"),
    })?;
    rt.block_on(async {
        let pool = connect().await?;
        let migrator = sqlx::migrate::Migrator::new(Path::new(MIGRATIONS_DIR))
            .await
            .map_err(|e| CliError::CommandFailed {
                program: "ironic migrate".into(),
                status: format!("failed to load migrations: {e}"),
            })?;

        let applied: Vec<(String,)> =
            sqlx::query_as("SELECT version::text FROM _sqlx_migrations ORDER BY version")
                .fetch_all(&pool)
                .await
                .unwrap_or_default();

        writeln!(output, "  Migration status:")?;
        writeln!(output)?;

        for migration in migrator.migrations.iter() {
            let version_str = migration.version.to_string();
            let status = if applied.iter().any(|(v,)| *v == version_str) {
                "  ✓ Applied  "
            } else {
                "  ⏳ Pending "
            };
            writeln!(
                output,
                "  {status}  {version_str:>8}  {}",
                migration.description
            )?;
        }

        writeln!(output)?;
        writeln!(
            output,
            "  Total: {}, applied: {}",
            migrator.migrations.len(),
            applied.len(),
        )?;
        Ok(())
    })
}