rok-cli 0.3.2

Developer CLI for rok-based Axum applications
//! `rok make:feature` — generate complete features from a JSON spec.

use crate::json_io;
use anyhow::Result;
use heck::{ToSnakeCase, ToUpperCamelCase};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct FeatureSpec {
    pub name: String,
    pub models: Option<Vec<ModelSpec>>,
    pub endpoints: Option<Vec<EndpointSpec>>,
    pub flags: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
pub struct ModelSpec {
    pub name: String,
    pub fields: Option<Vec<FieldSpec>>,
    pub soft_delete: Option<bool>,
    pub timestamps: Option<bool>,
}

#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct FieldSpec {
    pub name: String,
    pub r#type: String,
    pub validations: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct EndpointSpec {
    pub route: String,
    pub method: String,
    pub controller: String,
    pub middleware: Option<Vec<String>>,
}

pub fn run(
    spec_json: Option<&str>,
    json_file: Option<&str>,
    dry_run: bool,
    json: bool,
) -> Result<()> {
    let spec: FeatureSpec = if let Some(file) = json_file {
        let v = json_io::load_file(file)?;
        serde_json::from_value(v)?
    } else if let Some(inline) = spec_json {
        serde_json::from_str(inline)?
    } else {
        anyhow::bail!("Provide --spec or --json-file with a FeatureSpec JSON");
    };

    validate_spec(&spec)?;

    let mut files_created = Vec::new();
    let mut warnings = Vec::new();

    // Generate models
    if let Some(models) = &spec.models {
        for model in models {
            let pascal = model.name.to_upper_camel_case();
            let snake = model.name.to_snake_case();
            let timestamps = model.timestamps.unwrap_or(true);
            let soft_delete = model.soft_delete.unwrap_or(false);

            let model_path = format!("src/app/models/{snake}.rs");
            let migration_path = format!("migrations/create_{snake}s_table.sql");

            if !dry_run {
                std::fs::create_dir_all("src/app/models")?;
                std::fs::write(
                    &model_path,
                    model_template(&pascal, &snake, model, timestamps, soft_delete),
                )?;
                std::fs::write(
                    &migration_path,
                    migration_template(&snake, model, timestamps, soft_delete),
                )?;
            }
            files_created.push(model_path);
            files_created.push(migration_path);

            warnings.push(format!("Register {pascal} model in src/app/models/mod.rs"));
        }
    }

    // Generate controllers from endpoints
    if let Some(endpoints) = &spec.endpoints {
        let mut controllers: std::collections::HashSet<String> = std::collections::HashSet::new();
        for ep in endpoints {
            controllers.insert(ep.controller.clone());
        }
        for ctrl in controllers {
            let snake = ctrl.to_snake_case();
            let pascal = ctrl.to_upper_camel_case();
            let path = format!("src/app/controllers/{snake}.rs");

            if !dry_run {
                std::fs::create_dir_all("src/app/controllers")?;
                std::fs::write(&path, controller_stub(&pascal))?;
            }
            files_created.push(path);
        }

        // Generate routes registration stub
        let routes_path = "src/app/routes/feature_routes.rs".to_string();
        if !dry_run {
            std::fs::create_dir_all("src/app/routes")?;
            std::fs::write(&routes_path, routes_stub(&spec.name, endpoints))?;
        }
        files_created.push(routes_path);
    }

    if json {
        let obj = serde_json::json!({
            "status": "ok",
            "feature": spec.name,
            "dry_run": dry_run,
            "files": files_created,
            "warnings": warnings,
        });
        println!("{}", serde_json::to_string_pretty(&obj)?);
    } else {
        println!("Feature: {}", spec.name);
        for f in &files_created {
            println!("{} {f}", if dry_run { "[dry-run]" } else { "created" });
        }
        for w in &warnings {
            eprintln!("warning: {w}");
        }
    }

    Ok(())
}

fn validate_spec(spec: &FeatureSpec) -> Result<()> {
    if spec.name.is_empty() {
        anyhow::bail!("FeatureSpec 'name' field is required and must not be empty");
    }
    if spec.models.is_none() && spec.endpoints.is_none() {
        anyhow::bail!("FeatureSpec must have at least 'models' or 'endpoints'");
    }
    Ok(())
}

fn model_template(
    pascal: &str,
    snake: &str,
    spec: &ModelSpec,
    timestamps: bool,
    soft_delete: bool,
) -> String {
    let fields: String = spec
        .fields
        .as_deref()
        .unwrap_or(&[])
        .iter()
        .map(|f| {
            let rust_type = map_type(&f.r#type);
            format!("    pub {}: {},\n", f.name, rust_type)
        })
        .collect();

    let ts = if timestamps {
        "    pub created_at: chrono::DateTime<chrono::Utc>,\n    pub updated_at: chrono::DateTime<chrono::Utc>,\n"
    } else {
        ""
    };
    let sd = if soft_delete {
        "    pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,\n"
    } else {
        ""
    };

    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,
{fields}{ts}{sd}}}
"#
    )
}

fn migration_template(
    snake: &str,
    spec: &ModelSpec,
    timestamps: bool,
    soft_delete: bool,
) -> String {
    let cols: String = spec
        .fields
        .as_deref()
        .unwrap_or(&[])
        .iter()
        .map(|f| {
            let sql_type = sql_type(&f.r#type);
            format!("    {:<20} {sql_type},\n", f.name)
        })
        .collect();

    let ts = if timestamps {
        "    created_at           TIMESTAMPTZ NOT NULL DEFAULT now(),\n    updated_at           TIMESTAMPTZ NOT NULL DEFAULT now(),\n"
    } else {
        ""
    };
    let sd = if soft_delete {
        "    deleted_at           TIMESTAMPTZ,\n"
    } else {
        ""
    };

    format!("CREATE TABLE {snake}s (\n    id                   BIGSERIAL PRIMARY KEY,\n{cols}{ts}{sd});\n")
}

fn controller_stub(_pascal: &str) -> String {
    r#"use axum::response::IntoResponse;
use rok_auth::axum::{Ctx, Response};

// Generated by rok make:feature
pub async fn index(ctx: Ctx) -> impl IntoResponse {
    Response::json(serde_json::json!({ "data": [] }))
}
"#
    .to_string()
}

fn routes_stub(feature: &str, endpoints: &[EndpointSpec]) -> String {
    let mut out = format!("// Routes for feature: {feature}\nuse axum::Router;\n\npub fn routes() -> Router {{\n    Router::new()\n");
    for ep in endpoints {
        let method = ep.method.to_lowercase();
        out.push_str(&format!(
            "        .route(\"{}\", axum::routing::{}({}))\n",
            ep.route, method, ep.controller
        ));
    }
    out.push_str("}\n");
    out
}

fn map_type(t: &str) -> &'static str {
    match t.to_lowercase().as_str() {
        "string" | "text" | "varchar" => "String",
        "int" | "integer" | "i32" => "i32",
        "bigint" | "i64" => "i64",
        "float" | "f64" | "double" => "f64",
        "bool" | "boolean" => "bool",
        "json" | "jsonb" => "serde_json::Value",
        _ => "String",
    }
}

fn sql_type(t: &str) -> &'static str {
    match t.to_lowercase().as_str() {
        "string" | "varchar" => "TEXT NOT NULL",
        "text" => "TEXT",
        "int" | "integer" | "i32" => "INTEGER NOT NULL",
        "bigint" | "i64" => "BIGINT NOT NULL",
        "float" | "f64" | "double" => "DOUBLE PRECISION NOT NULL",
        "bool" | "boolean" => "BOOLEAN NOT NULL DEFAULT false",
        "json" | "jsonb" => "JSONB",
        _ => "TEXT",
    }
}