anvilforge-cli 0.1.0

Smith — Anvilforge's CLI (Artisan equivalent). Scaffolding, migrations, serve, queue:work, schedule:run, test.
//! `smith make:auth` — scaffold login/register/logout (Laravel Breeze equivalent).

use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result};

use super::project_root;

pub fn scaffold() -> Result<()> {
    let root = project_root();

    let files = [
        ("src/app/controllers_auth.rs", AUTH_CONTROLLER),
        ("src/app/requests_auth.rs", AUTH_REQUESTS),
        ("src/routes/auth.rs", AUTH_ROUTES),
        ("resources/views/auth/login.forge.html", LOGIN_VIEW),
        ("resources/views/auth/register.forge.html", REGISTER_VIEW),
        (
            "database/migrations/2026_01_01_000099_add_auth_columns_to_users.rs",
            AUTH_MIGRATION,
        ),
    ];

    let mut written = Vec::new();
    let mut skipped = Vec::new();
    for (rel, contents) in files {
        let path = root.join(rel);
        if path.exists() {
            skipped.push(rel);
            continue;
        }
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).ok();
        }
        fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?;
        written.push(rel);
    }

    println!();
    println!("  ✓ scaffolded auth");
    println!();
    for w in &written {
        println!("    + {w}");
    }
    if !skipped.is_empty() {
        println!();
        println!("  skipped (already exist):");
        for s in &skipped {
            println!("    - {s}");
        }
    }
    println!();
    println!("  manual steps:");
    println!("    1. Add `mod controllers_auth;` and `mod requests_auth;` to src/app/mod.rs");
    println!("    2. Add `pub mod auth;` to src/routes/mod.rs");
    println!("    3. Wire `routes::auth::register` into bootstrap/app.rs");
    println!("    4. Run `smith migrate` to add auth columns to your users table");
    println!();
    Ok(())
}

const AUTH_CONTROLLER: &str = r##"//! Authentication controllers — login, register, logout.

use anvilforge::prelude::*;
use anvilforge::auth;
use anvilforge::session::Session;

use crate::app::models::User;
use crate::app::requests_auth::{LoginRequest, RegisterRequest};

pub struct AuthController;

impl AuthController {
    /// GET /login — show the login form.
    pub async fn show_login() -> Result<ViewResponse> {
        Ok(ViewResponse::new(include_str!("../../resources/views/auth/login.forge.html").to_string()))
    }

    /// POST /login — verify credentials, persist user in session.
    pub async fn login(
        State(c): State<Container>,
        session: Session,
        payload: LoginRequest,
    ) -> Result<Redirect> {
        let user = auth::attempt::<User>(&c, &payload.email, &payload.password)
            .await?
            .ok_or_else(|| Error::Unauthenticated)?;
        auth::login(&session, &user).await?;
        Ok(Redirect::to("/"))
    }

    /// GET /register — show the registration form.
    pub async fn show_register() -> Result<ViewResponse> {
        Ok(ViewResponse::new(include_str!("../../resources/views/auth/register.forge.html").to_string()))
    }

    /// POST /register — create a user and log them in.
    pub async fn register(
        State(c): State<Container>,
        session: Session,
        payload: RegisterRequest,
    ) -> Result<Redirect> {
        let password_hash = auth::hash_password(&payload.password)?;
        let row: (i64,) = sqlx::query_as(
            "INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3) RETURNING id",
        )
        .bind(&payload.name)
        .bind(&payload.email)
        .bind(&password_hash)
        .fetch_one(c.pool())
        .await
        .map_err(Error::Database)?;
        let user = User::find(c.pool(), row.0)
            .await?
            .ok_or(Error::NotFound)?;
        auth::login(&session, &user).await?;
        Ok(Redirect::to("/"))
    }

    /// POST /logout — clear the session.
    pub async fn logout(session: Session) -> Result<Redirect> {
        auth::logout(&session).await?;
        Ok(Redirect::to("/"))
    }
}
"##;

const AUTH_REQUESTS: &str = r#"//! Auth-related form requests.

use anvilforge::prelude::*;
use garde::Validate;

#[derive(Debug, Deserialize, Validate, FormRequest)]
pub struct LoginRequest {
    #[garde(email)]
    pub email: String,

    #[garde(length(min = 1))]
    pub password: String,
}

#[derive(Debug, Deserialize, Validate, FormRequest)]
pub struct RegisterRequest {
    #[garde(length(min = 1, max = 80))]
    pub name: String,

    #[garde(email)]
    pub email: String,

    #[garde(length(min = 8))]
    pub password: String,
}
"#;

const AUTH_ROUTES: &str = r#"//! Auth routes.

use anvilforge::prelude::*;

use crate::app::controllers_auth::AuthController;

pub fn register(r: Router) -> Router {
    r.get("/login", AuthController::show_login)
        .post("/login", AuthController::login)
        .get("/register", AuthController::show_register)
        .post("/register", AuthController::register)
        .post("/logout", AuthController::logout)
}
"#;

const LOGIN_VIEW: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Log in</title>
    <style>
        body { font-family: system-ui, sans-serif; max-width: 24rem; margin: 4rem auto; padding: 0 1rem; }
        h1 { color: #c2410c; }
        form { display: grid; gap: 0.75rem; }
        label { display: grid; gap: 0.25rem; }
        input { padding: 0.5rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 0.25rem; }
        button { padding: 0.5rem; font-size: 1rem; background: #c2410c; color: white; border: 0; border-radius: 0.25rem; cursor: pointer; }
    </style>
</head>
<body>
    <h1>Log in</h1>
    <form method="POST" action="/login">
        @csrf
        <label>Email <input type="email" name="email" required></label>
        <label>Password <input type="password" name="password" required></label>
        <button type="submit">Log in</button>
    </form>
    <p>No account? <a href="/register">Register</a></p>
</body>
</html>
"#;

const REGISTER_VIEW: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Register</title>
    <style>
        body { font-family: system-ui, sans-serif; max-width: 24rem; margin: 4rem auto; padding: 0 1rem; }
        h1 { color: #c2410c; }
        form { display: grid; gap: 0.75rem; }
        label { display: grid; gap: 0.25rem; }
        input { padding: 0.5rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 0.25rem; }
        button { padding: 0.5rem; font-size: 1rem; background: #c2410c; color: white; border: 0; border-radius: 0.25rem; cursor: pointer; }
    </style>
</head>
<body>
    <h1>Register</h1>
    <form method="POST" action="/register">
        @csrf
        <label>Name <input type="text" name="name" required></label>
        <label>Email <input type="email" name="email" required></label>
        <label>Password <input type="password" name="password" required minlength="8"></label>
        <button type="submit">Register</button>
    </form>
    <p>Already have an account? <a href="/login">Log in</a></p>
</body>
</html>
"#;

const AUTH_MIGRATION: &str = r#"//! Add auth columns to the users table.
//! Adjust as needed — if your `users` table already has `password_hash`, this migration is a no-op.

use anvilforge::prelude::*;
use anvilforge::cast::Schema;

pub struct AddAuthColumnsToUsersTable;

impl CastMigration for AddAuthColumnsToUsersTable {
    fn name(&self) -> &'static str {
        "2026_01_01_000099_add_auth_columns_to_users"
    }

    fn up(&self, s: &mut Schema) {
        // sea-query's alter API is limited; emit raw SQL for compatibility.
        s.raw("ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255) NOT NULL DEFAULT ''");
        s.raw("ALTER TABLE users ADD COLUMN IF NOT EXISTS remember_token VARCHAR(100)");
    }

    fn down(&self, s: &mut Schema) {
        s.raw("ALTER TABLE users DROP COLUMN IF EXISTS password_hash");
        s.raw("ALTER TABLE users DROP COLUMN IF EXISTS remember_token");
    }
}
"#;

// Silence unused warnings on Rust < 1.80 where include_str! syntax check happens early.
#[allow(dead_code)]
fn _link(_: PathBuf) {}