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();
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"));
}
}
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);
}
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",
}
}