rok-cli 0.3.2

Developer CLI for rok-based Axum applications
use super::{write_file, Scaffold, ScaffoldArgs, ScaffoldResult};
use anyhow::Result;
use heck::{ToSnakeCase, ToUpperCamelCase};

pub struct CrudScaffold;

impl Scaffold for CrudScaffold {
    fn name(&self) -> &'static str {
        "crud"
    }
    fn description(&self) -> &'static str {
        "Full CRUD: model + migration + controller + validation + routes + tests"
    }

    fn generate(&self, args: &ScaffoldArgs) -> Result<ScaffoldResult> {
        let mut r = ScaffoldResult::default();
        let model = args.model_or("Resource");
        let pascal = model.to_upper_camel_case();
        let snake = model.to_snake_case();
        let d = args.dry_run;

        write_file(
            &mut r,
            &format!("src/app/models/{snake}.rs"),
            &model_template(&pascal, &snake),
            d,
        )?;
        write_file(
            &mut r,
            &format!("src/app/controllers/{snake}_controller.rs"),
            &controller_template(&pascal, &snake),
            d,
        )?;
        write_file(
            &mut r,
            &format!("src/app/requests/{snake}_request.rs"),
            &request_template(&pascal, &snake),
            d,
        )?;
        write_file(
            &mut r,
            &format!("migrations/create_{snake}s_table.sql"),
            &migration_template(&snake),
            d,
        )?;
        write_file(
            &mut r,
            &format!("tests/{snake}_controller_test.rs"),
            &test_template(&pascal, &snake),
            d,
        )?;

        r.warnings
            .push(format!("Register /{snake}s routes in src/app/routes.rs"));
        Ok(r)
    }
}

fn model_template(pascal: &str, snake: &str) -> String {
    format!(
        r#"use rok_orm::prelude::*;
use serde::{{Deserialize, Serialize}};

#[derive(Debug, Clone, Serialize, Deserialize, Model)]
#[rok_orm(table = "{snake}s")]
pub struct {pascal} {{
    pub id: i64,
    pub name: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}}
"#
    )
}

fn controller_template(pascal: &str, snake: &str) -> String {
    format!(
        r#"use axum::{{extract::{{Path, State}}, response::IntoResponse, Json}};
use rok_auth::axum::{{Ctx, Response}};
use rok_validate::Valid;
use crate::app::{{models::{snake}::{pascal}, requests::{snake}_request::{{Create{pascal}Request, Update{pascal}Request}}}};

pub async fn index(State(pool): State<sqlx::PgPool>) -> impl IntoResponse {{
    let items = {pascal}::all(&pool).await.unwrap_or_default();
    Response::json(serde_json::json!({{ "data": items }}))
}}

pub async fn show(State(pool): State<sqlx::PgPool>, Path(id): Path<i64>) -> impl IntoResponse {{
    match {pascal}::find(id, &pool).await {{
        Ok(item) => Response::json(serde_json::json!({{ "data": item }})),
        Err(_)   => Response::not_found("{pascal} not found"),
    }}
}}

pub async fn store(State(pool): State<sqlx::PgPool>, Valid(Json(req)): Valid<Json<Create{pascal}Request>>) -> impl IntoResponse {{
    // TODO: insert into DB
    Response::created(serde_json::json!({{ "message": "created" }}))
}}

pub async fn update(State(pool): State<sqlx::PgPool>, Path(id): Path<i64>, Valid(Json(req)): Valid<Json<Update{pascal}Request>>) -> impl IntoResponse {{
    // TODO: update in DB
    Response::json(serde_json::json!({{ "message": "updated" }}))
}}

pub async fn destroy(State(pool): State<sqlx::PgPool>, Path(id): Path<i64>) -> impl IntoResponse {{
    // TODO: delete from DB
    Response::no_content()
}}
"#
    )
}

fn request_template(pascal: &str, _snake: &str) -> String {
    format!(
        r#"use rok_validate::Validate;
use serde::Deserialize;

#[derive(Debug, Deserialize, Validate)]
pub struct Create{pascal}Request {{
    #[validate(required, max_length = 255)]
    pub name: Option<String>,
}}

#[derive(Debug, Deserialize, Validate)]
pub struct Update{pascal}Request {{
    #[validate(max_length = 255)]
    pub name: Option<String>,
}}
"#
    )
}

fn migration_template(snake: &str) -> String {
    format!(
        r#"CREATE TABLE {snake}s (
    id         BIGSERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
"#
    )
}

fn test_template(_pascal: &str, snake: &str) -> String {
    format!(
        r#"#[cfg(test)]
mod tests {{
    #[tokio::test]
    async fn test_{snake}_index_returns_list() {{
        // TODO: GET /{snake}s and verify 200 + data array
    }}

    #[tokio::test]
    async fn test_{snake}_store_creates_record() {{
        // TODO: POST /{snake}s and verify 201 + record in DB
    }}

    #[tokio::test]
    async fn test_{snake}_show_returns_record() {{
        // TODO: GET /{snake}s/1 and verify 200 + correct data
    }}

    #[tokio::test]
    async fn test_{snake}_destroy_deletes_record() {{
        // TODO: DELETE /{snake}s/1 and verify 204 + record gone
    }}
}}
"#
    )
}