slave 0.2.0

Laravel-inspired CLI for Rust web apps: scaffold Axum projects with built-in auth, users, roles and Turso migrations
//! `slave make:migration <name>`: create a timestamped migration and register it.
//!
//! Timestamped file names are not valid Rust module names, so each file is
//! attached in `database/migrations/mod.rs` with `#[path = "<file>"] mod m<file stem>;`
//! and listed in the `migrations![...]` call there.

use std::fs;
use std::path::Path;

use anyhow::{Context, Result, bail};

use crate::{generator, naming, project, templates, timestamp};

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

pub fn run(name: &str) -> Result<()> {
    naming::validate_migration_name(name)?;
    let root = project::find_root()?;
    generate(&root.join(MIGRATIONS_DIR), name)
}

fn generate(dir: &Path, name: &str) -> Result<()> {
    let registry = dir.join("mod.rs");
    if !registry.is_file() {
        bail!(
            "{MIGRATIONS_DIR}/mod.rs not found; migrations need the layout created by `slave new`"
        );
    }
    let existing = existing_migrations(dir)?;
    if let Some((timestamp, _)) = existing.iter().find(|(_, other)| other == name) {
        bail!("a migration named `{name}` already exists: {MIGRATIONS_DIR}/{timestamp}_{name}.rs");
    }
    let latest = existing
        .iter()
        .map(|(timestamp, _)| timestamp.as_str())
        .max();
    let timestamp = timestamp::utc_now_after(latest)?;

    let stem = format!("{timestamp}_{name}");
    let file_name = format!("{stem}.rs");
    let struct_name = naming::to_pascal_case(name);
    let (template, table) = match create_table_target(name) {
        Some(table) => (templates::MIGRATION_CREATE_TABLE, table),
        None => (templates::MIGRATION, ""),
    };
    let contents = templates::render(template, &[("struct", &struct_name), ("table", table)]);
    generator::write_file(&dir.join(&file_name), &contents, false)?;

    let module = format!("m{stem}");
    let attach = [
        format!("#[path = \"{file_name}\"]"),
        format!("mod {module};"),
    ];
    let mut registered = true;
    for line in attach {
        registered &=
            generator::insert_before_marker(&registry, templates::MIGRATION_MODULES_MARKER, &line)?;
    }
    let entry = format!("        {module},");
    registered &= generator::insert_before_marker(&registry, templates::MIGRATIONS_MARKER, &entry)?;

    println!("Migration [{MIGRATIONS_DIR}/{file_name}] created successfully.");
    if table.is_empty() {
        println!("  note: fill in up(); down() returns None (irreversible) until you implement it");
    }
    if !registered {
        println!(
            "  note: markers not found in {MIGRATIONS_DIR}/mod.rs; add `#[path = \"{file_name}\"] mod {module};` \
             and list `{module}` in `migrations![...]` yourself"
        );
    }
    Ok(())
}

/// The table name for `create_<table>_table` migrations.
fn create_table_target(name: &str) -> Option<&str> {
    name.strip_prefix("create_")?
        .strip_suffix("_table")
        .filter(|table| !table.is_empty())
}

/// Lists `(timestamp, name)` for every `<14-digit timestamp>_<name>.rs` file in `dir`.
fn existing_migrations(dir: &Path) -> Result<Vec<(String, String)>> {
    let entries = fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))?;
    let mut migrations = Vec::new();
    for entry in entries {
        let file_name = entry?.file_name().to_string_lossy().into_owned();
        let Some((timestamp, name)) = file_name
            .strip_suffix(".rs")
            .and_then(|stem| stem.split_once('_'))
        else {
            continue;
        };
        if timestamp.len() == 14 && timestamp.bytes().all(|b| b.is_ascii_digit()) {
            migrations.push((timestamp.to_owned(), name.to_owned()));
        }
    }
    Ok(migrations)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detects_create_table_migrations() {
        assert_eq!(create_table_target("create_users_table"), Some("users"));
        assert_eq!(
            create_table_target("create_blog_posts_table"),
            Some("blog_posts")
        );
        assert_eq!(create_table_target("create__table"), None);
        assert_eq!(create_table_target("add_email_to_users"), None);
        assert_eq!(create_table_target("create_users"), None);
    }
}