slave 0.2.0

Laravel-inspired CLI for Rust web apps: scaffold Axum projects with built-in auth, users, roles and Turso migrations
//! `slave new <name>`: scaffold a new domain-based application.

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

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

use crate::cli::NewArgs;
use crate::{generator, naming, templates, timestamp};

pub fn run(args: &NewArgs) -> Result<()> {
    let name = args.name.as_str();
    naming::validate_project_name(name)?;
    let framework_dependency = framework_dependency(args.framework_path.as_deref())?;

    let target = std::env::current_dir()
        .context("failed to read the current directory")?
        .join(name);
    if target.exists() {
        bail!("destination `{}` already exists", target.display());
    }

    fs::create_dir(&target)
        .with_context(|| format!("failed to create directory {}", target.display()))?;

    if let Err(err) = scaffold(&target, name, &framework_dependency) {
        // Only remove what we just created; the directory did not exist before.
        let _ = fs::remove_dir_all(&target);
        return Err(err);
    }

    println!("\nApplication `{name}` created successfully.\n");
    println!("Next steps:");
    println!("  cd {name}");
    println!("  slave migrate      # create the users, roles and auth_tokens tables");
    println!("  slave serve        # then POST /auth/register; the first account is admin");
    Ok(())
}

fn scaffold(target: &Path, name: &str, framework_dependency: &str) -> Result<()> {
    // One timestamp per initial migration, strictly increasing so they run in order.
    let [ts_users, ts_roles, ts_tokens]: [String; 3] =
        timestamp::utc_now_sequence(3)?
            .try_into()
            .map_err(|_| anyhow::anyhow!("failed to generate migration timestamps"))?;
    let vars = [
        ("name", name),
        ("slave_version", env!("CARGO_PKG_VERSION")),
        ("framework_dependency", framework_dependency),
        ("ts_users", ts_users.as_str()),
        ("ts_roles", ts_roles.as_str()),
        ("ts_tokens", ts_tokens.as_str()),
    ];
    for file in templates::PROJECT {
        let path = templates::render(file.path, &vars);
        let contents = templates::render(file.contents, &vars);
        generator::write_file(&target.join(&path), &contents, false)?;
        println!("  created {name}/{path}");
    }

    // Like Laravel, start with a working .env copied from the example.
    let env_example = templates::PROJECT
        .iter()
        .find(|file| file.path == ".env.example")
        .map(|file| templates::render(file.contents, &vars))
        .unwrap_or_default();
    generator::write_file(&target.join(".env"), &env_example, false)?;
    println!("  created {name}/.env");
    Ok(())
}

/// Returns the `slave-framework` line for the generated `Cargo.toml`.
fn framework_dependency(local_path: Option<&Path>) -> Result<String> {
    let Some(path) = local_path else {
        return Ok(format!(
            "slave-framework = \"{}\"",
            templates::FRAMEWORK_VERSION
        ));
    };

    let path = path
        .canonicalize()
        .with_context(|| format!("framework path {} does not exist", path.display()))?;
    let manifest = fs::read_to_string(path.join("Cargo.toml")).unwrap_or_default();
    if !manifest.contains("name = \"slave-framework\"") {
        bail!(
            "{} does not contain the slave-framework crate",
            path.display()
        );
    }
    let path = path
        .to_string_lossy()
        .replace('\\', "\\\\")
        .replace('"', "\\\"");
    Ok(format!("slave-framework = {{ path = \"{path}\" }}"))
}