slave-framework 0.1.0

Runtime for slave apps: Axum server lifecycle, config, logging, Turso database, migrations and password/token auth
//! Command-line front end for the migration engine.
//!
//! Generated applications call [`run`] from `src/bin/migrate.rs`; the `slave`
//! CLI invokes that binary for `migrate`, `migrate:status` and `migrate:rollback`.

use std::fmt::Write as _;
use std::process::ExitCode;
use std::time::Duration;

use super::{Migrator, Registered, RollbackReport, RunReport, State, Status};
use crate::{Result, database, env};

/// Parses the first argument (`run`, `status` or `rollback`; default `run`),
/// connects to the database from `DATABASE_PATH`, and prints the outcome.
pub async fn run(migrations: Vec<Registered>) -> ExitCode {
    let command = std::env::args().nth(1);
    match execute(command.as_deref(), migrations).await {
        Ok(code) => code,
        Err(err) => {
            eprintln!("error: {err}");
            ExitCode::FAILURE
        }
    }
}

async fn execute(command: Option<&str>, migrations: Vec<Registered>) -> Result<ExitCode> {
    if !matches!(command, None | Some("run" | "status" | "rollback")) {
        eprintln!("usage: migrate [run|status|rollback]");
        return Ok(ExitCode::from(2));
    }

    env::load_dotenv()?;
    let path = database::path_from_env();
    let mut migrator = Migrator::new(database::connect(&path).await?, migrations)?;
    println!("Database: {path}\n");

    let (output, ok) = match command {
        Some("status") => (format_status(&migrator.status().await?), true),
        Some("rollback") => {
            let report = migrator.rollback().await?;
            (format_rollback(&report), report.failed.is_none())
        }
        _ => {
            let report = migrator.run().await?;
            (format_run(&report), report.failed.is_none())
        }
    };
    print!("{output}");
    Ok(if ok {
        ExitCode::SUCCESS
    } else {
        ExitCode::FAILURE
    })
}

pub fn format_run(report: &RunReport) -> String {
    let mut out = String::new();
    for outcome in &report.applied {
        let _ = writeln!(
            out,
            "  DONE    {}  {}",
            outcome.name,
            millis(outcome.duration)
        );
    }
    if let Some(failure) = &report.failed {
        let _ = writeln!(
            out,
            "  FAILED  {}\n          {}",
            failure.name, failure.error
        );
    }

    let skipped = report.skipped.len();
    if report.applied.is_empty() && report.failed.is_none() {
        let _ = writeln!(out, "Nothing to migrate ({skipped} already applied).");
        return out;
    }
    let _ = writeln!(
        out,
        "\nBatch {}: {} applied, {skipped} skipped (already applied).",
        report.batch,
        report.applied.len()
    );
    if let Some(failure) = &report.failed {
        let _ = writeln!(out, "{}", failure_note(&failure.name, failure.reverted));
    }
    out
}

pub fn format_rollback(report: &RollbackReport) -> String {
    let mut out = String::new();
    let Some(batch) = report.batch else {
        out.push_str("Nothing to roll back.\n");
        return out;
    };
    let _ = writeln!(out, "Rolling back batch {batch}:");
    for outcome in &report.rolled_back {
        let _ = writeln!(
            out,
            "  REVERTED  {}  {}",
            outcome.name,
            millis(outcome.duration)
        );
    }
    if let Some(failure) = &report.failed {
        let _ = writeln!(
            out,
            "  FAILED    {}\n            {}",
            failure.name, failure.error
        );
        let _ = writeln!(
            out,
            "\n{} Migrations reverted above stay reverted.",
            failure_note(&failure.name, failure.reverted)
        );
    }
    out
}

pub fn format_status(statuses: &[Status]) -> String {
    if statuses.is_empty() {
        return "No migrations found.\n".to_owned();
    }
    let mut out = format!(
        "{:<8} {:<6} {:<20} Migration\n",
        "Status", "Batch", "Executed at"
    );
    let mut any_missing = false;
    for status in statuses {
        let (label, batch, at) = match &status.state {
            State::Applied { batch, executed_at } => {
                ("Ran", batch.to_string(), executed_at.as_str())
            }
            State::Pending => ("Pending", String::new(), ""),
            State::Missing { batch, executed_at } => {
                any_missing = true;
                ("Missing", batch.to_string(), executed_at.as_str())
            }
        };
        let _ = writeln!(out, "{label:<8} {batch:<6} {at:<20} {}", status.name);
    }
    if any_missing {
        out.push_str(
            "\nMissing: recorded as applied, but not registered in database/migrations/mod.rs.\n",
        );
    }
    out
}

fn failure_note(name: &str, reverted: bool) -> String {
    if reverted {
        format!("`{name}` was rolled back and not recorded; fix it and run the command again.")
    } else {
        format!(
            "`{name}` is non-transactional: statements before the failure may have been applied. \
             Inspect the database before retrying."
        )
    }
}

fn millis(duration: Duration) -> String {
    format!("({} ms)", duration.as_millis())
}