mod controller;
mod factory;
mod migration;
mod model;
mod policy;
mod resource;
mod routes;
mod test;
mod validator;
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()
}
}
pub(super) struct TypeMap {
pub rust_type: &'static str,
pub sql_type: &'static str,
pub validate_attr: &'static str,
pub fake_val: &'static str,
pub resource_type: &'static str,
}
pub(super) 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",
},
}
}
pub(super) 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",
}
}
pub(super) 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",
_ => "",
}
}
pub(super) 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",
}
}
pub(super) 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 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,
&migration::gen(&snake, &fields, id_type, soft_delete, timestamps),
force,
)?;
let model_path = format!("src/app/models/{snake}.rs");
write_new(
&model_path,
&model::gen(&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, &validator::gen_create(&pascal, &fields), force)?;
let uv_path = format!("src/app/validators/update_{snake}_request.rs");
write_new(&uv_path, &validator::gen_update(&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,
&resource::gen(&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, &policy::gen(&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,
&controller::gen(&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,
&factory::gen(&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, &routes::gen(&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, &test::gen(&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(())
}