use std::{fs, io::Write as _, path::Path};
use console::style;
use heck::{ToSnakeCase, ToUpperCamelCase};
#[derive(Debug, Clone)]
pub struct FieldDef {
pub name: String,
pub field_type: String,
pub optional: bool,
}
impl FieldDef {
pub fn parse_list(input: &str) -> Vec<Self> {
input
.split(',')
.filter_map(|s| {
let s = s.trim();
let mut parts = s.splitn(2, ':');
let name = parts.next()?.trim().to_snake_case();
let raw_ft = parts.next()?.trim().to_lowercase();
if name.is_empty() || raw_ft.is_empty() {
return None;
}
let optional = raw_ft.ends_with('?');
let field_type = raw_ft.trim_end_matches('?').to_string();
Some(FieldDef {
name,
field_type,
optional,
})
})
.collect()
}
}
struct TypeMap {
rust_type: &'static str,
sql_type: &'static str,
validate_attr: &'static str,
fake_val: &'static str,
resource_type: &'static str,
}
fn type_map(ft: &str) -> TypeMap {
match ft {
"string" => TypeMap {
rust_type: "String",
sql_type: "VARCHAR(255)",
validate_attr: "#[validate(required, max = 255)]",
fake_val: "\"sample text\".to_string()",
resource_type: "String",
},
"text" => TypeMap {
rust_type: "String",
sql_type: "TEXT",
validate_attr: "#[validate(required)]",
fake_val: "\"long form content here.\".to_string()",
resource_type: "String",
},
"bool" => TypeMap {
rust_type: "bool",
sql_type: "BOOLEAN",
validate_attr: "",
fake_val: "false",
resource_type: "bool",
},
"int" | "bigint" => TypeMap {
rust_type: "i64",
sql_type: "BIGINT",
validate_attr: "#[validate(min = 0)]",
fake_val: "0_i64",
resource_type: "i64",
},
"float" => TypeMap {
rust_type: "f64",
sql_type: "DOUBLE PRECISION",
validate_attr: "",
fake_val: "0.0_f64",
resource_type: "f64",
},
"decimal" => TypeMap {
rust_type: "f64",
sql_type: "DECIMAL(10, 2)",
validate_attr: "",
fake_val: "0.0_f64",
resource_type: "f64",
},
"uuid" => TypeMap {
rust_type: "String",
sql_type: "UUID",
validate_attr: "",
fake_val: "\"00000000-0000-0000-0000-000000000000\".to_string()",
resource_type: "String",
},
"date" => TypeMap {
rust_type: "chrono::NaiveDate",
sql_type: "DATE",
validate_attr: "",
fake_val: "chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()",
resource_type: "String",
},
"datetime" => TypeMap {
rust_type: "chrono::DateTime<chrono::Utc>",
sql_type: "TIMESTAMPTZ",
validate_attr: "",
fake_val: "chrono::Utc::now()",
resource_type: "String",
},
"json" => TypeMap {
rust_type: "serde_json::Value",
sql_type: "JSONB",
validate_attr: "",
fake_val: "serde_json::Value::Null",
resource_type: "serde_json::Value",
},
_ => TypeMap {
rust_type: "String",
sql_type: "TEXT",
validate_attr: "",
fake_val: "String::new()",
resource_type: "String",
},
}
}
fn pk_rust_type(id: Option<&str>) -> &'static str {
match id {
Some("ulid") => "Ulid",
Some("cuid2") => "Cuid2",
Some("uuid_v7") => "UuidV7",
Some("snowflake") => "Snowflake",
Some("nanoid") => "NanoId",
_ => "i64",
}
}
fn pk_use_import(id: Option<&str>) -> &'static str {
match id {
Some("ulid") => "use rok_ids::Ulid;\n",
Some("cuid2") => "use rok_ids::Cuid2;\n",
Some("uuid_v7") => "use rok_ids::UuidV7;\n",
Some("snowflake") => "use rok_ids::Snowflake;\n",
Some("nanoid") => "use rok_ids::NanoId;\n",
_ => "",
}
}
fn pk_ddl(id: Option<&str>) -> &'static str {
match id {
Some("ulid") => " \"id\" TEXT PRIMARY KEY NOT NULL",
Some("cuid2") => " \"id\" TEXT PRIMARY KEY NOT NULL",
Some("uuid_v7") => " \"id\" VARCHAR(36) PRIMARY KEY NOT NULL",
Some("snowflake") => " \"id\" BIGINT PRIMARY KEY NOT NULL",
Some("nanoid") => " \"id\" TEXT PRIMARY KEY NOT NULL",
_ => " \"id\" BIGSERIAL PRIMARY KEY",
}
}
fn pk_id_attr(id: Option<&str>) -> &'static str {
match id {
Some("ulid") => "#[rok_orm(id = \"ulid\")]\n",
Some("cuid2") => "#[rok_orm(id = \"cuid2\")]\n",
Some("uuid_v7") => "#[rok_orm(id = \"uuid_v7\")]\n",
Some("snowflake") => "#[rok_orm(id = \"snowflake\")]\n",
Some("nanoid") => "#[rok_orm(id = \"nanoid\")]\n",
_ => "",
}
}
fn pk_to_string(id: Option<&str>) -> &'static str {
match id {
None => "m.id", _ => "m.id.to_string()", }
}
fn migration_dir() -> &'static str {
if Path::new("database/migrations").exists() {
"database/migrations"
} else {
"migrations"
}
}
fn next_migration_index(dir: &str) -> u32 {
fs::read_dir(dir)
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("sql"))
.count() as u32
+ 1
})
.unwrap_or(1)
}
fn gen_migration(
snake: &str,
fields: &[FieldDef],
id: Option<&str>,
soft_delete: bool,
timestamps: bool,
) -> String {
let pk = pk_ddl(id);
let mut cols = vec![pk.to_string()];
for f in fields {
let tm = type_map(&f.field_type);
let constraint = if f.optional { "" } else { " NOT NULL" };
cols.push(format!(" \"{}\" {}{}", f.name, tm.sql_type, constraint));
}
if timestamps {
cols.push(" \"created_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW()".to_string());
cols.push(" \"updated_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW()".to_string());
}
if soft_delete {
cols.push(" \"deleted_at\" TIMESTAMPTZ".to_string());
}
let cols_sql = cols.join(",\n");
let table = format!("{snake}s");
format!(
"-- up\nCREATE TABLE \"{table}\" (\n{cols_sql}\n);\n\n-- down\nDROP TABLE IF EXISTS \"{table}\";\n"
)
}
fn gen_model(
pascal: &str,
snake: &str,
fields: &[FieldDef],
id: Option<&str>,
soft_delete: bool,
timestamps: bool,
) -> String {
let pk_use = pk_use_import(id);
let pk_type = pk_rust_type(id);
let _id_attr = pk_id_attr(id);
let mut needs_chrono = timestamps || soft_delete;
let mut needs_json = false;
for f in fields {
match f.field_type.as_str() {
"datetime" | "date" => needs_chrono = true,
"json" => needs_json = true,
_ => {}
}
}
let mut use_lines = String::new();
use_lines.push_str(pk_use);
use_lines.push_str("use rok_orm::Model;\n");
use_lines.push_str("use serde::{Deserialize, Serialize};\n");
if needs_chrono {
use_lines.push_str("use chrono::{DateTime, Utc};\n");
}
if needs_json {
use_lines.push_str("use serde_json::Value;\n");
}
let mut orm_attrs = Vec::new();
let table = format!("{snake}s");
orm_attrs.push(format!("table = \"{table}\""));
if let Some(it) = id {
orm_attrs.push(format!("id = \"{it}\""));
}
if timestamps {
orm_attrs.push("timestamps".to_string());
}
if soft_delete {
orm_attrs.push("soft_delete".to_string());
}
let orm_attr_line = format!("#[rok_orm({})]\n", orm_attrs.join(", "));
let mut field_lines = format!(" pub id: {},\n", pk_type);
for f in fields {
let tm = type_map(&f.field_type);
let base_t = match f.field_type.as_str() {
"datetime" => "DateTime<Utc>",
"json" => "Value",
_ => tm.rust_type,
};
let rust_t = if f.optional {
format!("Option<{base_t}>")
} else {
base_t.to_string()
};
field_lines.push_str(&format!(" pub {}: {},\n", f.name, rust_t));
}
if timestamps {
field_lines.push_str(" pub created_at: DateTime<Utc>,\n");
field_lines.push_str(" pub updated_at: DateTime<Utc>,\n");
}
if soft_delete {
field_lines.push_str(" pub deleted_at: Option<DateTime<Utc>>,\n");
}
format!(
"{use_lines}\n#[derive(Debug, Clone, Model, Serialize, Deserialize, sqlx::FromRow)]\n{orm_attr_line}pub struct {pascal} {{\n{field_lines}}}\n"
)
}
fn gen_create_validator(pascal: &str, fields: &[FieldDef]) -> String {
let mut field_lines = String::new();
for f in fields {
let tm = type_map(&f.field_type);
let base_t = match f.field_type.as_str() {
"datetime" | "date" => "String",
"json" => "serde_json::Value",
_ => tm.rust_type,
};
if f.optional {
let opt_attr = tm
.validate_attr
.trim_start_matches("#[validate(")
.trim_end_matches(")]");
let attr = if opt_attr.is_empty() {
String::new()
} else {
format!(" #[validate(optional, {opt_attr})]\n")
};
field_lines.push_str(&attr);
field_lines.push_str(&format!(" pub {}: Option<{}>,\n", f.name, base_t));
} else {
if !tm.validate_attr.is_empty() {
field_lines.push_str(&format!(" {}\n", tm.validate_attr));
}
field_lines.push_str(&format!(" pub {}: {},\n", f.name, base_t));
}
}
format!(
"use rok_validate::Validate;\nuse serde::Deserialize;\n\n#[derive(Deserialize, Validate)]\npub struct Create{pascal}Request {{\n{field_lines}}}\n"
)
}
fn gen_update_validator(pascal: &str, fields: &[FieldDef]) -> String {
let mut field_lines = String::new();
for f in fields {
let tm = type_map(&f.field_type);
let rust_t = match f.field_type.as_str() {
"datetime" => "String",
"date" => "String",
"json" => "serde_json::Value",
_ => tm.rust_type,
};
field_lines.push_str(&format!(" pub {}: Option<{}>,\n", f.name, rust_t));
}
format!(
"use rok_validate::Validate;\nuse serde::Deserialize;\n\n#[derive(Deserialize, Validate)]\npub struct Update{pascal}Request {{\n{field_lines}}}\n"
)
}
fn gen_resource(
pascal: &str,
snake: &str,
fields: &[FieldDef],
id: Option<&str>,
timestamps: bool,
) -> String {
let pk_type = pk_rust_type(id);
let id_resource_type = match id {
Some(_) => "String",
None => "i64",
};
let id_from = pk_to_string(id);
let mut struct_fields = format!(" pub id: {},\n", id_resource_type);
let mut from_fields = format!(" id: {},\n", id_from);
for f in fields {
let tm = type_map(&f.field_type);
let base_res_t = match f.field_type.as_str() {
"datetime" | "date" => "String",
"json" => "serde_json::Value",
_ => tm.resource_type,
};
let (res_t, from_expr) = if f.optional {
let opt_t = format!("Option<{base_res_t}>");
let expr = match f.field_type.as_str() {
"datetime" => format!("m.{}.map(|v| v.to_rfc3339())", f.name),
"date" => format!("m.{}.map(|v| v.to_string())", f.name),
_ => format!("m.{}", f.name),
};
(opt_t, expr)
} else {
let expr = match f.field_type.as_str() {
"datetime" => format!("m.{}.to_rfc3339()", f.name),
"date" => format!("m.{}.to_string()", f.name),
_ => format!("m.{}", f.name),
};
(base_res_t.to_string(), expr)
};
struct_fields.push_str(&format!(" pub {}: {},\n", f.name, res_t));
from_fields.push_str(&format!(" {}: {},\n", f.name, from_expr));
}
if timestamps {
struct_fields.push_str(" pub created_at: String,\n");
struct_fields.push_str(" pub updated_at: String,\n");
from_fields.push_str(" created_at: m.created_at.to_rfc3339(),\n");
from_fields.push_str(" updated_at: m.updated_at.to_rfc3339(),\n");
}
let mut uses = String::new();
uses.push_str("use serde::Serialize;\n");
let pk_use = pk_use_import(id);
if !pk_use.is_empty() {
uses.push_str(pk_use);
}
let _ = snake;
let _ = pk_type;
format!(
"{uses}\n#[derive(Debug, Serialize)]\npub struct {pascal}Resource {{\n{struct_fields}}}\n\nimpl {pascal}Resource {{\n pub fn from(m: super::super::models::{pascal}) -> Self {{\n Self {{\n{from_fields} }}\n }}\n}}\n"
)
}
fn gen_policy(pascal: &str, snake: &str) -> String {
let _ = snake;
format!(
"pub struct {pascal}Policy;\n\nimpl {pascal}Policy {{\n pub fn view() -> bool {{ true }}\n pub fn create() -> bool {{ true }}\n pub fn update() -> bool {{ true }}\n pub fn delete() -> bool {{ true }}\n}}\n"
)
}
fn gen_controller(
pascal: &str,
snake: &str,
id: Option<&str>,
auth: bool,
_paginate: bool,
) -> String {
let id_type = pk_rust_type(id);
let id_param_type = match id {
Some("snowflake") => "i64",
Some(_) => "String",
None => "i64",
};
let auth_guard = if auth {
" ctx.require_auth()?;\n "
} else {
""
};
let _ = snake;
let _ = id_type;
format!(
r#"use axum::{{
extract::{{Path, State}},
response::IntoResponse,
Json,
}};
use rok_auth::axum::{{Ctx, Response}};
use rok_auth_macros::controller;
use crate::app::validators::{{create_{snake}_request::Create{pascal}Request, update_{snake}_request::Update{pascal}Request}};
use crate::state::AppState;
#[controller]
pub struct {pascal}Controller;
impl {pascal}Controller {{
pub async fn index(ctx: Ctx, State(_state): State<AppState>) -> impl IntoResponse {{
{auth_guard}Response::json(serde_json::json!({{ "data": [] }}))
}}
pub async fn store(
ctx: Ctx,
State(_state): State<AppState>,
Json(_body): Json<Create{pascal}Request>,
) -> impl IntoResponse {{
{auth_guard}Response::json(serde_json::json!({{ "message": "created" }}))
}}
pub async fn show(
ctx: Ctx,
State(_state): State<AppState>,
Path(id): Path<{id_param_type}>,
) -> impl IntoResponse {{
{auth_guard}Response::json(serde_json::json!({{ "id": id.to_string() }}))
}}
pub async fn update(
ctx: Ctx,
State(_state): State<AppState>,
Path(id): Path<{id_param_type}>,
Json(_body): Json<Update{pascal}Request>,
) -> impl IntoResponse {{
{auth_guard}Response::json(serde_json::json!({{ "id": id.to_string(), "message": "updated" }}))
}}
pub async fn destroy(
ctx: Ctx,
State(_state): State<AppState>,
Path(id): Path<{id_param_type}>,
) -> impl IntoResponse {{
{auth_guard}let _ = id;
Response::no_content()
}}
}}
"#
)
}
fn gen_factory(
pascal: &str,
snake: &str,
fields: &[FieldDef],
id: Option<&str>,
timestamps: bool,
soft_delete: bool,
) -> String {
let pk_type = pk_rust_type(id);
let pk_use = pk_use_import(id);
let id_fake = match id {
None => "0_i64".to_string(),
_ => format!("{}::default()", pk_type),
};
let mut field_defaults = format!(" id: {id_fake},\n");
for f in fields {
let val = if f.optional {
"None".to_string()
} else {
let tm = type_map(&f.field_type);
tm.fake_val.to_string()
};
field_defaults.push_str(&format!(" {}: {},\n", f.name, val));
}
if timestamps {
field_defaults.push_str(" created_at: chrono::Utc::now(),\n");
field_defaults.push_str(" updated_at: chrono::Utc::now(),\n");
}
if soft_delete {
field_defaults.push_str(" deleted_at: None,\n");
}
let _ = snake;
let _ = pk_type;
let mut uses = String::new();
uses.push_str(pk_use);
format!(
"{uses}use crate::app::models::{pascal};\n\npub struct {pascal}Factory;\n\nimpl {pascal}Factory {{\n pub fn build() -> {pascal} {{\n {pascal} {{\n{field_defaults} }}\n }}\n}}\n"
)
}
fn gen_routes(pascal: &str, snake: &str, id: Option<&str>) -> String {
let id_param = match id {
Some("snowflake") => ":id",
_ => ":id",
};
format!(
r#"use axum::{{routing::get, Router}};
use crate::app::controllers::{snake}_controller::{pascal}Controller;
use crate::state::AppState;
pub fn {snake}_routes() -> Router<AppState> {{
Router::new()
.route("/{snake}s", get({pascal}Controller::index).post({pascal}Controller::store))
.route("/{snake}s/{id_param}", get({pascal}Controller::show).put({pascal}Controller::update).delete({pascal}Controller::destroy))
}}
"#
)
}
fn gen_test(_pascal: &str, snake: &str) -> String {
format!(
r#"mod common;
#[tokio::test]
#[ignore = "requires running database"]
async fn test_{snake}_index() {{
let app = common::TestApp::boot().await;
let res = app.client.get("/api/v1/{snake}s").await;
res.assert_status(200);
}}
#[tokio::test]
#[ignore = "requires running database"]
async fn test_{snake}_show() {{
let app = common::TestApp::boot().await;
let res = app.client.get("/api/v1/{snake}s/1").await;
res.assert_status(200);
}}
#[tokio::test]
#[ignore = "requires running database"]
async fn test_{snake}_store() {{
let app = common::TestApp::boot().await;
let res = app.client.post("/api/v1/{snake}s", serde_json::json!({{}})).await;
res.assert_status(201);
}}
#[tokio::test]
#[ignore = "requires running database"]
async fn test_{snake}_update() {{
let app = common::TestApp::boot().await;
let res = app.client.put("/api/v1/{snake}s/1", serde_json::json!({{}})).await;
res.assert_status(200);
}}
#[tokio::test]
#[ignore = "requires running database"]
async fn test_{snake}_destroy() {{
let app = common::TestApp::boot().await;
let res = app.client.delete("/api/v1/{snake}s/1").await;
res.assert_status(204);
}}
"#
)
}
fn write_new(path: &str, content: &str, force: bool) -> anyhow::Result<bool> {
let p = Path::new(path);
if p.exists() && !force {
println!(
" {} {path} (already exists, use --force to overwrite)",
style("skip").yellow()
);
return Ok(false);
}
if let Some(parent) = p.parent() {
fs::create_dir_all(parent)?;
}
fs::write(p, content)?;
println!(" {} {path}", style("create").green().bold());
Ok(true)
}
fn append_line(path: &str, line: &str) -> anyhow::Result<()> {
if !Path::new(path).exists() {
fs::create_dir_all(Path::new(path).parent().unwrap_or(Path::new(".")))?;
fs::write(path, format!("{line}\n"))?;
println!(" {} {path}", style("create").green().bold());
return Ok(());
}
let existing = fs::read_to_string(path)?;
if existing.contains(line.trim()) {
return Ok(());
}
let mut f = fs::OpenOptions::new().append(true).open(path)?;
writeln!(f, "{line}")?;
println!(" {} {path} ← {line}", style("update").cyan().bold());
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn crud(
name: &str,
fields_str: Option<&str>,
id_type: Option<&str>,
auth: bool,
soft_delete: bool,
paginate: bool,
timestamps: bool,
force: bool,
) -> anyhow::Result<()> {
let fields_input: String = match fields_str {
Some(s) => s.to_owned(),
None => {
use dialoguer::Input;
println!("{}", style("rok make:crud — interactive wizard").bold());
Input::<String>::new()
.with_prompt("Fields (e.g. title:string,body:text,published:bool)")
.interact_text()?
}
};
let fields = FieldDef::parse_list(&fields_input);
if fields.is_empty() {
anyhow::bail!("No valid fields parsed. Use format: name:type,name:type");
}
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
println!(
"\n{} Scaffolding {pascal} CRUD...\n",
style("rok").green().bold()
);
let mig_dir = migration_dir();
let mig_idx = next_migration_index(mig_dir);
let mig_path = format!("{mig_dir}/{mig_idx:03}_create_{snake}s_table.sql");
write_new(
&mig_path,
&gen_migration(&snake, &fields, id_type, soft_delete, timestamps),
force,
)?;
let model_path = format!("src/app/models/{snake}.rs");
write_new(
&model_path,
&gen_model(&pascal, &snake, &fields, id_type, soft_delete, timestamps),
force,
)?;
append_line("src/app/models/mod.rs", &format!("pub mod {snake};"))?;
append_line(
"src/app/models/mod.rs",
&format!("pub use {snake}::{pascal};"),
)?;
let cv_path = format!("src/app/validators/create_{snake}_request.rs");
write_new(&cv_path, &gen_create_validator(&pascal, &fields), force)?;
let uv_path = format!("src/app/validators/update_{snake}_request.rs");
write_new(&uv_path, &gen_update_validator(&pascal, &fields), force)?;
append_line(
"src/app/validators/mod.rs",
&format!("pub mod create_{snake}_request;"),
)?;
append_line(
"src/app/validators/mod.rs",
&format!("pub mod update_{snake}_request;"),
)?;
let res_path = format!("src/app/resources/{snake}_resource.rs");
write_new(
&res_path,
&gen_resource(&pascal, &snake, &fields, id_type, timestamps),
force,
)?;
append_line(
"src/app/resources/mod.rs",
&format!("pub mod {snake}_resource;"),
)?;
append_line(
"src/app/resources/mod.rs",
&format!("pub use {snake}_resource::{pascal}Resource;"),
)?;
let pol_path = format!("src/app/policies/{snake}_policy.rs");
write_new(&pol_path, &gen_policy(&pascal, &snake), force)?;
append_line(
"src/app/policies/mod.rs",
&format!("pub mod {snake}_policy;"),
)?;
let ctrl_path = format!("src/app/controllers/{snake}_controller.rs");
write_new(
&ctrl_path,
&gen_controller(&pascal, &snake, id_type, auth, paginate),
force,
)?;
append_line(
"src/app/controllers/mod.rs",
&format!("pub mod {snake}_controller;"),
)?;
let fac_path = format!("src/database/factories/{snake}_factory.rs");
write_new(
&fac_path,
&gen_factory(&pascal, &snake, &fields, id_type, timestamps, soft_delete),
force,
)?;
append_line(
"src/database/factories/mod.rs",
&format!("pub mod {snake}_factory;"),
)?;
let route_path = format!("src/routes/{snake}s.rs");
write_new(&route_path, &gen_routes(&pascal, &snake, id_type), force)?;
append_line("src/routes/mod.rs", &format!("pub mod {snake}s;"))?;
let test_path = format!("tests/{snake}_test.rs");
write_new(&test_path, &gen_test(&pascal, &snake), force)?;
println!(
"\n{} Done! Add to api_router() in src/routes/api.rs:",
style("→").bold()
);
println!(" .merge({snake}s::{snake}_routes())\n");
Ok(())
}