use std::{fs, path::Path};
use heck::{ToSnakeCase, ToUpperCamelCase};
pub fn controller(name: &str, resource: bool) -> anyhow::Result<()> {
let camel = name.to_upper_camel_case();
let base = camel.strip_suffix("Controller").unwrap_or(&camel);
let pascal = base.to_upper_camel_case();
let snake = base.to_snake_case();
let path = format!("src/app/controllers/{snake}_controller.rs");
if resource {
write_file(&path, &resource_controller_template(&pascal, &snake))?;
} else {
write_file(&path, &controller_template(&pascal, &snake))?;
}
println!("Created {path}");
println!("Register it in src/app/controllers/mod.rs:");
println!(" pub mod {snake}_controller;");
Ok(())
}
fn controller_template(pascal: &str, snake: &str) -> String {
format!(
r#"use axum::{{extract::Path, response::IntoResponse}};
use rok_auth::axum::{{Ctx, Response}};
use rok_auth_macros::controller;
#[controller]
pub struct {pascal}Controller;
impl {pascal}Controller {{
/// `GET /{snake}s`
pub async fn index(ctx: Ctx) -> impl IntoResponse {{
Response::json(serde_json::json!({{ "data": [] }}))
}}
/// `POST /{snake}s`
pub async fn store(ctx: Ctx) -> impl IntoResponse {{
Response::json(serde_json::json!({{ "message": "created" }}))
}}
/// `GET /{snake}s/:id`
pub async fn show(ctx: Ctx, Path(id): Path<i64>) -> impl IntoResponse {{
Response::json(serde_json::json!({{ "id": id }}))
}}
/// `PUT /{snake}s/:id`
pub async fn update(ctx: Ctx, Path(id): Path<i64>) -> impl IntoResponse {{
Response::json(serde_json::json!({{ "id": id, "message": "updated" }}))
}}
/// `DELETE /{snake}s/:id`
pub async fn destroy(ctx: Ctx, Path(id): Path<i64>) -> impl IntoResponse {{
Response::no_content()
}}
}}
"#
)
}
fn resource_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_auth_macros::controller;
use crate::app::models::{pascal};
use crate::app::resources::{snake}_resource::{pascal}Resource;
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 {{
let items = {pascal}::all(&state.pool).await?;
let data: Vec<_> = items.into_iter().map({pascal}Resource::from).collect();
Response::json(serde_json::json!({{ "data": data }}))
}}
pub async fn store(
ctx: Ctx,
State(state): State<AppState>,
Json(body): Json<Create{pascal}Request>,
) -> impl IntoResponse {{
let item = {pascal}::create(&state.pool, body).await?;
Response::json({pascal}Resource::from(item))
}}
pub async fn show(
ctx: Ctx,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> impl IntoResponse {{
let item = {pascal}::find_or_fail(&state.pool, id).await?;
Response::json({pascal}Resource::from(item))
}}
pub async fn update(
ctx: Ctx,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(body): Json<Update{pascal}Request>,
) -> impl IntoResponse {{
let item = {pascal}::update(&state.pool, id, body).await?;
Response::json({pascal}Resource::from(item))
}}
pub async fn destroy(
ctx: Ctx,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> impl IntoResponse {{
{pascal}::destroy(&state.pool, id).await?;
Response::no_content()
}}
}}
"#
)
}
#[allow(dead_code)]
pub fn model(name: &str, id_type: Option<&str>) -> anyhow::Result<()> {
model_full(name, id_type, false, false, false, false)
}
pub fn model_full(
name: &str,
id_type: Option<&str>,
with_migration: bool,
with_controller: bool,
with_resource: bool,
with_factory: bool,
) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/models/{snake}.rs");
write_file(&path, &model_template(&pascal, &snake, id_type))?;
println!("Created {path}");
println!(" Register in src/app/models/mod.rs: pub mod {snake};");
if with_migration {
migration(&format!("create_{snake}s_table"))?;
}
if with_controller {
controller(name, false)?;
}
if with_resource {
resource(name, false)?;
}
if with_factory {
make_factory(name)?;
}
Ok(())
}
pub fn make_factory(name: &str) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/database/factories/{snake}_factory.rs");
write_file(&path, &factory_template(&pascal))?;
println!("Created {path}");
Ok(())
}
fn factory_template(pascal: &str) -> String {
format!(
r#"use rok_orm_factory::Factory;
pub struct {pascal}Factory;
impl Factory for {pascal}Factory {{
type Model = super::{pascal};
fn definition() -> Vec<(&'static str, rok_orm::SqlValue)> {{
vec![
// ("name", "Default Name".into()),
]
}}
}}
"#
)
}
fn model_template(pascal: &str, snake: &str, id_type: Option<&str>) -> String {
let (use_line, id_rust_type, id_attr) = match id_type {
Some("ulid") => (
"use rok_ids::Ulid;\n",
"Ulid",
"#[rok_orm(id = \"ulid\")]\n",
),
Some("cuid2") => (
"use rok_ids::Cuid2;\n",
"Cuid2",
"#[rok_orm(id = \"cuid2\")]\n",
),
Some("uuid_v7") => (
"use rok_ids::UuidV7;\n",
"UuidV7",
"#[rok_orm(id = \"uuid_v7\")]\n",
),
Some("snowflake") => (
"use rok_ids::Snowflake;\n",
"Snowflake",
"#[rok_orm(id = \"snowflake\")]\n",
),
Some("nanoid") => (
"use rok_ids::NanoId;\n",
"NanoId",
"#[rok_orm(id = \"nanoid\")]\n",
),
_ => ("", "i64", ""),
};
let migration_hint = match id_type {
Some("ulid") => "// Migration: t.ulid_pk();".to_string(),
Some("cuid2") => "// Migration: t.cuid2_pk();".to_string(),
Some("uuid_v7") => "// Migration: t.uuid_v7_pk();".to_string(),
Some("snowflake") => "// Migration: t.snowflake_pk();".to_string(),
Some("nanoid") => "// Migration: t.nanoid_pk();".to_string(),
_ => "// Migration: t.id();".to_string(),
};
let _ = snake; format!(
r#"use rok_orm::Model;
use serde::{{Deserialize, Serialize}};
{use_line}
#[derive(Debug, Clone, Model, Serialize, Deserialize, sqlx::FromRow)]
{id_attr}pub struct {pascal} {{
pub id: {id_rust_type},
{migration_hint}
// TODO: add fields
}}
"#
)
}
fn migration_dir() -> &'static str {
if Path::new("database/migrations").exists() {
"database/migrations"
} else {
"migrations"
}
}
pub fn migration(name: &str) -> anyhow::Result<()> {
use chrono::Local;
let snake = name.to_snake_case();
let pascal = snake.to_upper_camel_case();
let ts = Local::now().format("%Y_%m_%d_%H%M%S").to_string();
let dir = migration_dir();
fs::create_dir_all(dir)?;
let filename = format!("{dir}/{ts}_{snake}.rs");
write_file(&filename, &migration_template(&pascal, &snake, &ts))?;
println!("Created {filename}");
println!("Register in your migration runner:");
println!(" .migration({pascal})");
Ok(())
}
fn migration_template(pascal: &str, snake: &str, timestamp: &str) -> String {
format!(
r#"use rok_orm_migrate::{{Migration, SchemaExecutor}};
use async_trait::async_trait;
pub struct {pascal};
#[async_trait]
impl Migration for {pascal} {{
fn name(&self) -> &str {{
"{timestamp}_{snake}"
}}
async fn up(&self, schema: &SchemaExecutor) -> anyhow::Result<()> {{
schema.create("{snake}", |t| {{
t.id();
// TODO: add columns
// t.string("name").not_null();
t.timestamps();
}}).await
}}
async fn down(&self, schema: &SchemaExecutor) -> anyhow::Result<()> {{
schema.drop_table_if_exists("{snake}").await
}}
}}
"#
)
}
pub fn seeder(name: &str) -> anyhow::Result<()> {
let camel = name.to_upper_camel_case();
let base = camel.strip_suffix("Seeder").unwrap_or(&camel);
let pascal = base.to_upper_camel_case();
let snake = base.to_snake_case();
let path = format!("src/database/seeders/{snake}_seeder.rs");
fs::create_dir_all("src/database/seeders")?;
write_file(&path, &seeder_template(&pascal))?;
println!("Created {path}");
Ok(())
}
fn seeder_template(pascal: &str) -> String {
format!(
r#"use sqlx::PgPool;
pub struct {pascal}Seeder;
impl {pascal}Seeder {{
pub async fn run(pool: &PgPool) -> Result<(), sqlx::Error> {{
// TODO: insert seed data
Ok(())
}}
}}
"#
)
}
pub fn validator(name: &str) -> anyhow::Result<()> {
let camel = name.to_upper_camel_case();
let base = camel.strip_suffix("Request").unwrap_or(&camel);
let pascal = base.to_upper_camel_case();
let snake = base.to_snake_case();
let path = format!("src/app/validators/{snake}_request.rs");
write_file(&path, &validator_template(&pascal))?;
println!("Created {path}");
println!("Register it in src/app/validators/mod.rs:");
println!(" pub mod {snake}_request;");
Ok(())
}
fn validator_template(pascal: &str) -> String {
format!(
r#"use rok_validate::Validate;
use serde::Deserialize;
#[derive(Deserialize, Validate)]
pub struct {pascal}Request {{
// TODO: add validated fields
// #[validate(required, min = 1, max = 255)]
// pub name: String,
}}
"#
)
}
pub fn locale(lang: &str, force: bool) -> anyhow::Result<()> {
if lang == "en" {
anyhow::bail!("Cannot generate a template from itself. Use a language other than 'en'.");
}
let lang = lang.to_lowercase();
let out_path = format!("locales/{lang}.json");
if !force && std::path::Path::new(&out_path).exists() {
anyhow::bail!("{out_path} already exists. Pass --force to overwrite.");
}
let template_map: serde_json::Map<String, serde_json::Value> =
if let Ok(content) = std::fs::read_to_string("locales/en.json") {
serde_json::from_str(&content).unwrap_or_else(|_| default_locale_template())
} else {
default_locale_template()
};
let blank = blank_locale_map(&template_map);
let json = serde_json::to_string_pretty(&blank)?;
fs::create_dir_all("locales")?;
fs::write(&out_path, json + "\n")?;
println!("Created {out_path}");
println!("Translate the values in {out_path} then add I18nLayer::with_dir(\"locales\", \"{lang}\") to your router.");
Ok(())
}
fn blank_locale_map(
template: &serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
use serde_json::Value;
let mut out = serde_json::Map::new();
for (k, v) in template {
let blank_val = match v {
Value::String(_) => Value::String(String::new()),
Value::Object(inner) => {
let mut obj = serde_json::Map::new();
for (ik, iv) in inner {
obj.insert(
ik.clone(),
if iv.is_string() {
Value::String(String::new())
} else {
iv.clone()
},
);
}
Value::Object(obj)
}
other => other.clone(),
};
out.insert(k.clone(), blank_val);
}
out
}
fn default_locale_template() -> serde_json::Map<String, serde_json::Value> {
let v: serde_json::Value = serde_json::json!({
"validation.required": "The {field} field is required.",
"validation.email": "The {field} field must be a valid email address.",
"validation.url": "The {field} field must be a valid URL.",
"validation.numeric": "The {field} field must be a number.",
"validation.min_length": "The {field} field must be at least {min} character(s).",
"validation.max_length": "The {field} field may not be greater than {max} character(s).",
"validation.min": "The {field} field must be at least {min}.",
"validation.max": "The {field} field may not be greater than {max}.",
"validation.same": "The {field} field confirmation does not match.",
"validation.in": "The {field} field must be one of: {options}."
});
v.as_object().cloned().unwrap_or_default()
}
pub fn observer(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/observers/{snake}.rs");
write_file_guarded(&path, &observer_template(&pascal), force)?;
println!("Created {path}");
println!("Register with: rok_orm::observe::<Model, {pascal}>();");
Ok(())
}
fn observer_template(pascal: &str) -> String {
format!(
r#"use rok_orm::{{Observer, OrmResult}};
pub struct {pascal};
impl<M: rok_orm::Model> Observer<M> for {pascal} {{
fn creating(&self, _model: &M) -> OrmResult<()> {{ Ok(()) }}
fn created(&self, _model: &M) {{}}
fn updating(&self, _model: &M) -> OrmResult<()> {{ Ok(()) }}
fn updated(&self, _model: &M) {{}}
fn deleting(&self, _model: &M) -> OrmResult<()> {{ Ok(()) }}
fn deleted(&self, _model: &M) {{}}
}}
"#
)
}
pub fn resource(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/resources/{snake}_resource.rs");
write_file_guarded(&path, &resource_template(&pascal), force)?;
println!("Created {path}");
println!("Register in src/app/resources/mod.rs:");
println!(" pub mod {snake}_resource;");
Ok(())
}
fn resource_template(pascal: &str) -> String {
format!(
r#"use serde_json::json;
pub struct {pascal}Resource;
impl {pascal}Resource {{
pub fn transform(model: &impl serde::Serialize) -> serde_json::Value {{
// TODO: map model fields to the response shape
json!({{ "id": 0 }})
}}
}}
"#
)
}
pub fn scope(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/scopes/{snake}.rs");
write_file_guarded(&path, &scope_template(&pascal), force)?;
println!("Created {path}");
Ok(())
}
fn scope_template(pascal: &str) -> String {
format!(
r#"use rok_orm::{{Model, QueryBuilder}};
pub struct {pascal};
impl {pascal} {{
pub fn apply<M: Model>(builder: QueryBuilder<M>) -> QueryBuilder<M> {{
// TODO: add scope conditions
// builder.where_eq("active", true)
builder
}}
}}
"#
)
}
pub fn policy(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/policies/{snake}.rs");
write_file_guarded(&path, &policy_template(&pascal), force)?;
println!("Created {path}");
println!("Register in src/app/policies/mod.rs:");
println!(" pub mod {snake};");
Ok(())
}
fn policy_template(pascal: &str) -> String {
format!(
r#"pub struct {pascal};
impl {pascal} {{
pub fn view(&self, _user_id: i64) -> bool {{ true }}
pub fn create(&self, _user_id: i64) -> bool {{ true }}
pub fn update(&self, _user_id: i64) -> bool {{ true }}
pub fn delete(&self, _user_id: i64) -> bool {{ true }}
}}
"#
)
}
pub fn job(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/jobs/{snake}.rs");
write_file_guarded(&path, &job_template(&pascal), force)?;
println!("Created {path}");
println!("Dispatch with: rok_queue::dispatch({pascal} {{ ... }}).await?;");
Ok(())
}
fn job_template(pascal: &str) -> String {
format!(
r#"use rok_queue::{{Job, JobContext, JobError, Runnable}};
use serde::{{Deserialize, Serialize}};
#[derive(Serialize, Deserialize, Job)]
#[job(queue = "default", max_attempts = 3)]
pub struct {pascal} {{
// TODO: add job payload fields
pub id: i64,
}}
impl Runnable for {pascal} {{
async fn handle(&self, _ctx: &JobContext) -> Result<(), JobError> {{
// TODO: implement job logic
Ok(())
}}
}}
"#
)
}
pub fn event(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/events/{snake}.rs");
write_file_guarded(&path, &event_template(&pascal), force)?;
println!("Created {path}");
println!("Emit with: rok_events::emit({pascal} {{ ... }});");
Ok(())
}
fn event_template(pascal: &str) -> String {
format!(
r#"use rok_events::Event;
use serde::{{Deserialize, Serialize}};
#[derive(Serialize, Deserialize, Event)]
pub struct {pascal} {{
// TODO: add event fields
}}
"#
)
}
pub fn listener(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/listeners/{snake}.rs");
write_file_guarded(&path, &listener_template(&pascal), force)?;
println!("Created {path}");
println!("Register with: rok_events::listen::<EventType, {pascal}>();");
Ok(())
}
fn listener_template(pascal: &str) -> String {
format!(
r#"pub struct {pascal};
impl {pascal} {{
pub async fn handle<E: rok_events::Event>(&self, _event: &E) {{
// TODO: implement listener logic
}}
}}
"#
)
}
pub fn notification(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/notifications/{snake}.rs");
write_file_guarded(&path, ¬ification_template(&pascal), force)?;
println!("Created {path}");
Ok(())
}
fn notification_template(pascal: &str) -> String {
format!(
r#"use serde::{{Deserialize, Serialize}};
#[derive(Serialize, Deserialize)]
pub struct {pascal} {{
// TODO: add notification fields
}}
impl {pascal} {{
pub fn channels(&self) -> Vec<&'static str> {{
vec!["mail", "database"]
}}
}}
"#
)
}
pub fn test(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let crud_path = format!("tests/{snake}_test.rs");
if !force && Path::new(&crud_path).exists() {
println!("skip {crud_path} already exists — use --force to create a second file");
return Ok(());
}
let path = format!("tests/{snake}.rs");
write_file_guarded(&path, &test_template(&pascal, &snake), force)?;
println!("Created {path}");
Ok(())
}
fn test_template(pascal: &str, snake: &str) -> String {
let _ = pascal;
format!(
r#"#[cfg(test)]
mod tests {{
#[tokio::test]
async fn {snake}_test() {{
// TODO: implement test
}}
}}
"#
)
}
fn write_file(path: &str, content: &str) -> anyhow::Result<()> {
write_file_guarded(path, content, false)
}
fn write_file_guarded(path: &str, content: &str, force: bool) -> anyhow::Result<()> {
let p = Path::new(path);
if !force && p.exists() {
anyhow::bail!("{path} already exists. Pass --force to overwrite.");
}
if let Some(parent) = p.parent() {
fs::create_dir_all(parent)?;
}
fs::write(p, content)?;
Ok(())
}