rustio-admin-cli 0.27.6

Command-line tools for rustio-admin: project scaffolding, migrations, user management.
//! `rustio-admin migrate` -- drive the framework's `migrations::apply` /
//! `status` against `DATABASE_URL`. The directory defaults to
//! `migrations/` under the current working directory, mirroring the
//! convention every example/template uses.

use std::path::PathBuf;

use clap::Subcommand;

use rustio_admin::migrations;

#[derive(Subcommand)]
pub enum Action {
    /// Apply every pending migration in `--dir` (default `migrations/`).
    Apply {
        #[arg(long, default_value = "migrations")]
        dir: PathBuf,
    },
    /// Show every migration file with a ✓ or · for applied / pending.
    Status {
        #[arg(long, default_value = "migrations")]
        dir: PathBuf,
    },
}

pub async fn run(action: Action) -> Result<(), String> {
    let db = crate::db().await?;
    match action {
        Action::Apply { dir } => apply(db, dir).await,
        Action::Status { dir } => status(db, dir).await,
    }
}

async fn apply(db: rustio_admin::Db, dir: PathBuf) -> Result<(), String> {
    let opts = migrations::ApplyOptions { verbose: true };
    // Spinner while the library runs the per-file migration loop.
    // The library does not surface per-migration progress hooks, so
    // one outer spinner is the minimum-scope feedback that satisfies
    // PR 1.4 / DESIGN_ONBOARDING.md §9 without changing the migration
    // engine. The existing per-file `✓ <name>` summary below the
    // spinner remains untouched.
    let step = crate::progress::Step::start("Applying migrations");
    let applied = match migrations::apply_with(&db, &dir, opts).await {
        Ok(a) => {
            step.clear();
            a
        }
        Err(e) => {
            step.clear();
            return Err(crate::ui::classify_migration_error(&format!("apply: {e}")).format());
        }
    };
    if applied.is_empty() {
        println!("Nothing to apply -- every migration is up to date.");
    } else {
        println!("Applied {} migration(s):", applied.len());
        for name in applied {
            println!("{name}");
        }
    }
    suggest_after_migrate(&db).await;
    Ok(())
}

/// After `migrate apply`, point the developer at the single next step
/// for where they now stand: create the first admin login, or — if one
/// already exists — launch the app. Interactive terminals only; scripts
/// and CI stay quiet.
async fn suggest_after_migrate(db: &rustio_admin::Db) {
    if !crate::style::is_interactive() {
        return;
    }
    if active_admin_exists(db).await {
        crate::style::next_step(
            "launch your app",
            &[(
                "cargo run".to_string(),
                format!(
                    "{} {}",
                    crate::style::hint(""),
                    crate::style::url("http://127.0.0.1:8000/admin")
                ),
            )],
        );
    } else {
        let proj = current_project_name();
        crate::style::next_step(
            "create your admin login",
            &[(
                format!("rustio-admin user create --email admin@{proj}.local --role administrator"),
                String::new(),
            )],
        );
    }
}

/// True when the auth tables exist and hold at least one active
/// administrator/developer. Any query failure is treated as "no admin
/// yet" — this pointer must never crash the command it follows.
async fn active_admin_exists(db: &rustio_admin::Db) -> bool {
    let exists: bool = sqlx::query_scalar(
        "SELECT EXISTS (
            SELECT 1 FROM information_schema.tables WHERE table_name = 'rustio_users'
        )",
    )
    .fetch_one(db.pool())
    .await
    .unwrap_or(false);
    if !exists {
        return false;
    }
    let n: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM rustio_users \
         WHERE role IN ('administrator', 'developer') AND is_active = TRUE",
    )
    .fetch_one(db.pool())
    .await
    .unwrap_or(0);
    n > 0
}

/// The current directory's name, used to suggest a sensible admin email
/// (`admin@<project>.local`) — mirroring what `new` printed. Falls back
/// to `app` if the directory name can't be read.
fn current_project_name() -> String {
    std::env::current_dir()
        .ok()
        .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "app".to_string())
}

async fn status(db: rustio_admin::Db, dir: PathBuf) -> Result<(), String> {
    let entries = migrations::status(&db, &dir)
        .await
        .map_err(|e| format!("status: {e}"))?;
    if entries.is_empty() {
        println!("No migration files found in {}.", dir.display());
        return Ok(());
    }
    let applied = entries.iter().filter(|(_, a)| *a).count();
    let pending = entries.len() - applied;
    for (name, ok) in &entries {
        let mark = if *ok { "" } else { "·" };
        println!(" {mark} {name}");
    }
    println!();
    println!("{applied} applied, {pending} pending.");
    Ok(())
}