use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result};
use handlebars::Handlebars;
use serde_json::json;
use super::project_root;
pub fn model(name: &str, with_migration: bool, fields: &[String]) -> Result<()> {
let path = project_root().join(format!("app/Models/{name}.rs"));
let fields_parsed = parse_fields(fields);
write_template(
&path,
MODEL_TEMPLATE,
json!({
"name": name,
"table": pluralize_snake(&snake_case(name)),
"fields": fields_parsed,
}),
)?;
println!("created {}", path.display());
if with_migration {
migration(&format!(
"create_{}_table",
pluralize_snake(&snake_case(name))
))?;
}
Ok(())
}
pub fn migration(name: &str) -> Result<()> {
let ts = chrono::Utc::now().format("%Y_%m_%d_%H%M%S");
let file_name = format!("{ts}_{name}.rs");
let stem = format!("{ts}_{name}");
let path = project_root().join("database/migrations").join(&file_name);
let table = name
.strip_prefix("create_")
.and_then(|s| s.strip_suffix("_table"))
.map(|s| s.to_string());
let struct_name = pascal_case(name);
write_template(
&path,
MIGRATION_TEMPLATE,
json!({
"struct_name": struct_name,
"name": stem,
"table": table,
}),
)?;
println!("created {}", path.display());
let mod_rs = project_root().join("database/migrations/mod.rs");
let snake = snake_case(name);
let mut mod_name = snake.clone();
let mut existing = if mod_rs.exists() {
std::fs::read_to_string(&mod_rs).unwrap_or_default()
} else {
String::new()
};
let collision_pat = format!("pub mod {snake};");
if existing.contains(&collision_pat) {
mod_name = format!("{snake}_{}", ts.to_string().replace('_', ""));
}
let mod_line = format!("\n#[path = \"{file_name}\"]\npub mod {mod_name};\n");
if mod_rs.exists() {
if !existing.contains(&file_name) {
if !existing.ends_with('\n') {
existing.push('\n');
}
existing.push_str(&mod_line);
std::fs::write(&mod_rs, existing).context("append migration to mod.rs")?;
println!("appended to database/migrations/mod.rs");
}
} else {
std::fs::create_dir_all(mod_rs.parent().unwrap()).ok();
std::fs::write(
&mod_rs,
format!(
"//! Database migrations. Each file is `mod`-included here. \n//! `MigrationRunner::new(pool)` auto-discovers via inventory.\n{mod_line}"
),
)
.context("write database/migrations/mod.rs")?;
}
Ok(())
}
pub fn controller(name: &str, resource: bool) -> Result<()> {
let dir = "app/Http/Controllers";
let path = project_root().join(format!("{dir}/{name}.rs"));
let tpl = if resource {
RESOURCE_CONTROLLER_TEMPLATE
} else {
CONTROLLER_TEMPLATE
};
let resource_lower = snake_case(name.trim_end_matches("Controller"));
write_template(
&path,
tpl,
json!({
"name": name,
"resource": resource_lower,
"resource_plural": pluralize_snake(&resource_lower),
}),
)?;
println!("created {}", path.display());
append_to_mod_rs(dir, name)?;
Ok(())
}
pub fn request(name: &str) -> Result<()> {
let dir = "app/Http/Requests";
let path = project_root().join(format!("{dir}/{name}.rs"));
write_template(&path, REQUEST_TEMPLATE, json!({ "name": name }))?;
println!("created {}", path.display());
append_to_mod_rs(dir, name)?;
Ok(())
}
pub fn job(name: &str) -> Result<()> {
let dir = "app/Jobs";
let path = project_root().join(format!("{dir}/{name}.rs"));
write_template(&path, JOB_TEMPLATE, json!({ "name": name }))?;
println!("created {}", path.display());
append_to_mod_rs(dir, name)?;
Ok(())
}
pub fn event(name: &str) -> Result<()> {
let dir = "app/Events";
let path = project_root().join(format!("{dir}/{name}.rs"));
write_template(&path, EVENT_TEMPLATE, json!({ "name": name }))?;
println!("created {}", path.display());
append_to_mod_rs(dir, name)?;
Ok(())
}
pub fn listener(name: &str, event: Option<&str>) -> Result<()> {
let dir = "app/Listeners";
let path = project_root().join(format!("{dir}/{name}.rs"));
write_template(
&path,
LISTENER_TEMPLATE,
json!({
"name": name,
"event": event.unwrap_or("SomeEvent"),
}),
)?;
println!("created {}", path.display());
append_to_mod_rs(dir, name)?;
Ok(())
}
pub fn test(name: &str) -> Result<()> {
let path = project_root().join(format!("tests/{}.rs", snake_case(name)));
write_template(&path, TEST_TEMPLATE, json!({ "name": name }))?;
println!("created {}", path.display());
Ok(())
}
pub fn seeder(name: &str) -> Result<()> {
let path = project_root().join(format!("database/seeders/{name}.rs"));
write_template(&path, SEEDER_TEMPLATE, json!({ "name": name }))?;
println!("created {}", path.display());
let mod_rs = project_root().join("database/seeders/mod.rs");
let mod_name = snake_case(name);
let line =
format!("\n#[path = \"{name}.rs\"]\npub mod {mod_name};\npub use {mod_name}::{name};\n");
if mod_rs.exists() {
let mut current = std::fs::read_to_string(&mod_rs).unwrap_or_default();
if !current.contains(&format!("\"{name}.rs\"")) {
if !current.ends_with('\n') {
current.push('\n');
}
current.push_str(&line);
std::fs::write(&mod_rs, current).context("append seeder to mod.rs")?;
println!("appended to database/seeders/mod.rs");
}
}
println!();
println!(" smith db:seed --class={name}");
println!();
Ok(())
}
pub fn component(name: &str) -> Result<()> {
let snake = snake_case(name);
let rust_path = project_root().join(format!("app/Spark/{name}.rs"));
let view_path = project_root().join(format!("resources/views/spark/{snake}.forge.html"));
write_template(
&rust_path,
COMPONENT_RUST_TEMPLATE,
json!({ "name": name, "snake": snake.clone() }),
)?;
println!("created {}", rust_path.display());
write_template(
&view_path,
COMPONENT_VIEW_TEMPLATE,
json!({ "name": name, "snake": snake.clone() }),
)?;
println!("created {}", view_path.display());
let mod_rs = project_root().join("app/Spark/mod.rs");
let mod_name = snake.clone();
let line =
format!("\n#[path = \"{name}.rs\"]\npub mod {mod_name};\npub use {mod_name}::{name};\n");
if mod_rs.exists() {
let mut current = std::fs::read_to_string(&mod_rs).unwrap_or_default();
if !current.contains(&format!("\"{name}.rs\"")) {
if !current.ends_with('\n') {
current.push('\n');
}
current.push_str(&line);
std::fs::write(&mod_rs, current).context("append component to mod.rs")?;
println!("appended to app/Spark/mod.rs");
}
} else {
std::fs::create_dir_all(mod_rs.parent().unwrap()).ok();
std::fs::write(
&mod_rs,
format!(
"//! Spark components. Each module is `mod`-included here.\n//! Components register themselves via `inventory` from `#[spark_component]`.\n{line}"
),
)
.context("write app/Spark/mod.rs")?;
}
println!();
println!(" Mount it in a template:");
println!(" @spark(\"{snake}\")");
println!();
Ok(())
}
pub fn mail(name: &str) -> Result<()> {
let dir = "app/Mail";
let path = project_root().join(format!("{dir}/{name}.rs"));
write_template(&path, MAIL_TEMPLATE, json!({ "name": name }))?;
println!("created {}", path.display());
append_to_mod_rs(dir, name)?;
Ok(())
}
pub fn notification(name: &str) -> Result<()> {
let dir = "app/Notifications";
let path = project_root().join(format!("{dir}/{name}.rs"));
write_template(&path, NOTIFICATION_TEMPLATE, json!({ "name": name }))?;
println!("created {}", path.display());
append_to_mod_rs(dir, name)?;
Ok(())
}
pub fn policy(name: &str, model: Option<&str>) -> Result<()> {
let model_name = model.unwrap_or_else(|| name.strip_suffix("Policy").unwrap_or(name));
let dir = "app/Policies";
let path = project_root().join(format!("{dir}/{name}.rs"));
write_template(
&path,
POLICY_TEMPLATE,
json!({ "name": name, "model": model_name }),
)?;
println!("created {}", path.display());
append_to_mod_rs(dir, name)?;
Ok(())
}
pub fn rule(name: &str) -> Result<()> {
let dir = "app/Rules";
let path = project_root().join(format!("{dir}/{name}.rs"));
write_template(&path, RULE_TEMPLATE, json!({ "name": name }))?;
println!("created {}", path.display());
append_to_mod_rs(dir, name)?;
Ok(())
}
pub fn resource_serializer(name: &str) -> Result<()> {
let model_name = name.strip_suffix("Resource").unwrap_or(name);
let dir = "app/Http/Resources";
let path = project_root().join(format!("{dir}/{name}.rs"));
write_template(
&path,
RESOURCE_TEMPLATE,
json!({ "name": name, "model": model_name }),
)?;
println!("created {}", path.display());
append_to_mod_rs(dir, name)?;
Ok(())
}
pub fn factory(name: &str, model: Option<&str>) -> Result<()> {
let model_name = model.unwrap_or_else(|| name.strip_suffix("Factory").unwrap_or(name));
let dir = "database/factories";
let path = project_root().join(format!("{dir}/{name}.rs"));
write_template(
&path,
FACTORY_TEMPLATE,
json!({ "name": name, "model": model_name }),
)?;
println!("created {}", path.display());
append_to_mod_rs(dir, name)?;
Ok(())
}
fn append_to_mod_rs(dir: &str, name: &str) -> Result<()> {
let mod_rs = project_root().join(dir).join("mod.rs");
let snake = snake_case(name);
let line = format!("\n#[path = \"{name}.rs\"]\nmod {snake};\npub use {snake}::{name};\n");
let file_marker = format!("\"{name}.rs\"");
let mut current = if mod_rs.exists() {
std::fs::read_to_string(&mod_rs).unwrap_or_default()
} else {
if let Some(parent) = mod_rs.parent() {
std::fs::create_dir_all(parent).ok();
}
String::new()
};
if current.contains(&file_marker) {
return Ok(());
}
if !current.is_empty() && !current.ends_with('\n') {
current.push('\n');
}
current.push_str(&line);
std::fs::write(&mod_rs, current).with_context(|| format!("append {name} to {}/mod.rs", dir))?;
println!("appended to {}/mod.rs", dir);
Ok(())
}
fn parse_fields(fields: &[String]) -> serde_json::Value {
let mut parsed = Vec::new();
for spec in fields {
let parts: Vec<&str> = spec.split(':').collect();
let name = parts.first().copied().unwrap_or("").to_string();
let ty = parts.get(1).copied().unwrap_or("string").to_string();
let modifier = parts.get(2).copied().unwrap_or("").to_string();
parsed.push(json!({
"name": name,
"type": ty,
"rust_type": rust_type_for(&ty),
"modifier": modifier,
}));
}
serde_json::Value::Array(parsed)
}
fn rust_type_for(ty: &str) -> &'static str {
match ty {
"string" | "text" => "String",
"int" | "integer" => "i32",
"bigint" | "big_integer" => "i64",
"bool" | "boolean" => "bool",
"uuid" => "uuid::Uuid",
"json" => "serde_json::Value",
"timestamp" | "datetime" => "chrono::DateTime<chrono::Utc>",
_ => "String",
}
}
fn write_template(path: &PathBuf, template: &str, data: serde_json::Value) -> Result<()> {
let mut hb = Handlebars::new();
hb.register_escape_fn(handlebars::no_escape);
let rendered = hb
.render_template(template, &data)
.context("template render failed")?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ok();
}
if path.exists() {
anyhow::bail!("file already exists: {}", path.display());
}
fs::write(path, rendered).context("write file failed")?;
Ok(())
}
fn snake_case(s: &str) -> String {
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() && i > 0 {
out.push('_');
}
out.push(c.to_ascii_lowercase());
}
out
}
fn pascal_case(s: &str) -> String {
s.split('_')
.map(|w| {
let mut chars = w.chars();
match chars.next() {
Some(c) => c.to_uppercase().chain(chars).collect::<String>(),
None => String::new(),
}
})
.collect()
}
fn pluralize_snake(s: &str) -> String {
if s.ends_with('s') {
s.to_string()
} else if s.ends_with('y') {
let mut s = s.to_string();
s.pop();
s.push_str("ies");
s
} else {
format!("{s}s")
}
}
const MODEL_TEMPLATE: &str = r#"use anvilforge::cast::Model;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Model)]
#[table("{{table}}")]
pub struct {{name}} {
pub id: i64,
{{#each fields}} pub {{this.name}}: {{this.rust_type}},
{{/each}}
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
"#;
const MIGRATION_TEMPLATE: &str = r#"use anvilforge::prelude::*;
use anvilforge::cast::Schema;
#[derive(Migration)]
pub struct {{struct_name}};
impl CastMigration for {{struct_name}} {
fn name(&self) -> &'static str {
"{{name}}"
}
fn up(&self, s: &mut Schema) {
{{#if table}} s.create("{{table}}", |t| {
t.id();
t.timestamps();
});
{{else}} // TODO: define migration up
{{/if}} }
fn down(&self, s: &mut Schema) {
{{#if table}} s.drop_if_exists("{{table}}");
{{else}} // TODO: define migration down
{{/if}} }
}
"#;
const CONTROLLER_TEMPLATE: &str = r#"use anvilforge::prelude::*;
pub struct {{name}};
impl {{name}} {
pub async fn index(State(_container): State<Container>) -> Result<ViewResponse> {
// TODO: implement
Ok(ViewResponse::new("<h1>{{name}}</h1>"))
}
}
"#;
const RESOURCE_CONTROLLER_TEMPLATE: &str = r#"use anvilforge::prelude::*;
pub struct {{name}};
impl {{name}} {
pub async fn index(State(_c): State<Container>) -> Result<ViewResponse> {
Ok(ViewResponse::new("<h1>{{resource_plural}}#index</h1>"))
}
pub async fn show(Path(id): Path<i64>) -> Result<ViewResponse> {
Ok(ViewResponse::new(format!("<h1>{{resource}}#show {{{{id}}}}</h1>")))
}
pub async fn create() -> Result<ViewResponse> {
Ok(ViewResponse::new("<h1>{{resource}}#create</h1>"))
}
pub async fn store() -> Result<Redirect> {
Ok(Redirect::to("/{{resource_plural}}"))
}
pub async fn edit(Path(_id): Path<i64>) -> Result<ViewResponse> {
Ok(ViewResponse::new("<h1>{{resource}}#edit</h1>"))
}
pub async fn update(Path(_id): Path<i64>) -> Result<Redirect> {
Ok(Redirect::to("/{{resource_plural}}"))
}
pub async fn destroy(Path(_id): Path<i64>) -> Result<Redirect> {
Ok(Redirect::to("/{{resource_plural}}"))
}
}
"#;
const REQUEST_TEMPLATE: &str = r#"use anvilforge::prelude::*;
use garde::Validate;
use serde::Deserialize;
#[derive(Debug, Deserialize, Validate, FormRequest)]
pub struct {{name}} {
#[garde(length(min = 1))]
pub title: String,
}
"#;
const JOB_TEMPLATE: &str = r#"use anvilforge::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Job)]
pub struct {{name}} {
// TODO: job payload fields
}
impl {{name}} {
pub async fn handle(&self, _container: &Container) -> Result<()> {
// TODO: implement
Ok(())
}
}
"#;
const EVENT_TEMPLATE: &str = r#"use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct {{name}} {
// TODO: event payload fields
}
"#;
const LISTENER_TEMPLATE: &str = r#"use anvilforge::prelude::*;
use crate::app::events::{{event}};
pub struct {{name}};
impl {{name}} {
pub async fn handle(_event: {{event}}) -> Result<()> {
// TODO: implement
Ok(())
}
}
"#;
const TEST_TEMPLATE: &str = r#"use anvilforge::assay::*;
#[tokio::test]
async fn {{name}}_works() {
let app = crate::bootstrap::app::build(/* container */).await
.expect("build app");
let client = TestClient::new(app).await;
client.get("/").await
.assert_ok()
.assert_see("welcome");
// Fluent expectations á la Pest:
expect(2 + 2).to_be(4);
expect(vec!["a", "b", "c"]).to_have_length(3);
}
"#;
const SEEDER_TEMPLATE: &str = r#"//! {{name}}. Auto-registered via `#[derive(Seeder)]`.
use anvilforge::prelude::*;
use anvilforge::seeder::Seeder;
use anvilforge::async_trait::async_trait;
#[derive(Seeder)]
pub struct {{name}};
#[async_trait]
impl Seeder for {{name}} {
fn name(&self) -> &'static str { "{{name}}" }
async fn run(&self, _c: &Container) -> Result<()> {
// TODO: write seed data, e.g.:
// sqlx::query("INSERT INTO ... ON CONFLICT DO NOTHING ...")
// .execute(_c.pool()).await.map_err(Error::Database)?;
Ok(())
}
}
"#;
const COMPONENT_RUST_TEMPLATE: &str = r#"//! {{name}} — Spark reactive component.
use anvilforge::prelude::*;
use spark::prelude::*;
#[spark_component(template = "spark/{{snake}}")]
pub struct {{name}} {
pub count: i32,
}
#[spark_actions]
impl {{name}} {
#[spark_mount]
fn mount(_props: MountProps) -> Self {
Self::default()
}
async fn increment(&mut self) -> Result<()> {
self.count += 1;
Ok(())
}
}
"#;
const COMPONENT_VIEW_TEMPLATE: &str = r#"<div>
<h2>{{ '{{ count }}' }}</h2>
<button spark:click="increment">+1</button>
</div>
"#;
const FACTORY_TEMPLATE: &str = r#"//! {{name}} — generates random {{model}}s for tests/dev.
use anvilforge::prelude::*;
use anvilforge::seeder::{Factory, PersistentFactory};
use anvilforge::async_trait::async_trait;
use crate::app::Models::{{model}};
pub struct {{name}};
impl Factory<{{model}}> for {{name}} {
fn definition() -> {{model}} {
use fake::{Fake, faker::{name::en::Name, internet::en::SafeEmail}};
// TODO: adjust field assignments to match {{model}}'s fields.
{{model}} {
id: 0,
name: Name().fake(),
email: SafeEmail().fake(),
..Default::default()
}
}
}
// Implement PersistentFactory to enable `{{name}}::create(&c).await?`.
#[async_trait]
impl PersistentFactory<{{model}}> for {{name}} {
async fn save(_c: &Container, _model: {{model}}) -> Result<{{model}}> {
// TODO: insert + return the row with the assigned id.
// Example for a User-shaped model:
// let row: (i64,) = sqlx::query_as(
// "INSERT INTO {{model | lower}}s (name, email) VALUES ($1, $2) RETURNING id",
// )
// .bind(&_model.name).bind(&_model.email)
// .fetch_one(_c.pool()).await.map_err(Error::Database)?;
// Ok({{model}} { id: row.0, .._model })
Ok(_model)
}
}
"#;
const MAIL_TEMPLATE: &str = r#"//! {{name}} — a Mailable. Laravel parity: `Mail::to($user)->send(new {{name}}($args))`.
use anvilforge::prelude::*;
use anvilforge::mail::{Mailable, OutgoingMessage};
pub struct {{name}} {
// TODO: typed fields the template / subject need.
// pub order: Order,
}
impl Mailable for {{name}} {
fn build(&self, message: &mut OutgoingMessage) {
message
.subject("TODO: subject")
.view("mail.{{name}}", serde_json::json!({
// "order": self.order,
}));
}
}
// To send:
// c.mailer().to(&user).send({{name}} { /* ... */ }).await?;
"#;
const NOTIFICATION_TEMPLATE: &str = r#"//! {{name}} — a multi-channel Notification (mail + database + broadcast).
use anvilforge::prelude::*;
use anvilforge::notification::{Notification, NotificationChannel, NotifiablePayload};
pub struct {{name}} {
// TODO: typed fields. The same struct serves every channel.
}
impl Notification for {{name}} {
fn via(&self) -> Vec<NotificationChannel> {
// Pick the channels this notification is sent over.
vec![NotificationChannel::Mail]
}
fn to_mail(&self) -> NotifiablePayload {
NotifiablePayload::mail("TODO: subject", "mail.{{name}}", serde_json::json!({}))
}
fn to_database(&self) -> NotifiablePayload {
NotifiablePayload::database(serde_json::json!({}))
}
}
// To send:
// c.notify(&users, {{name}} { /* ... */ }).await?;
"#;
const POLICY_TEMPLATE: &str = r#"//! {{name}} — authorization policy for `{{model}}`.
//! Laravel parity: `class {{name}} { public function view(User $u, {{model}} $m) { ... } }`.
use anvilforge::prelude::*;
use crate::app::Models::{User, {{model}}};
pub struct {{name}};
impl {{name}} {
/// Anyone can list — return true. Restrict by querying inside the controller.
pub fn view_any(_user: &User) -> bool {
true
}
pub fn view(_user: &User, _resource: &{{model}}) -> bool {
// TODO: per-resource visibility rule.
true
}
pub fn create(_user: &User) -> bool {
// TODO: role check.
true
}
pub fn update(_user: &User, _resource: &{{model}}) -> bool {
// TODO: ownership check. e.g. _user.id == _resource.author_id
false
}
pub fn delete(_user: &User, _resource: &{{model}}) -> bool {
false
}
}
// To check inside a controller:
// if !{{name}}::update(&user, &resource) {
// return Err(Error::forbidden("not yours"));
// }
"#;
const RULE_TEMPLATE: &str = r#"//! {{name}} — custom validation rule.
//! Laravel parity: `class {{name}} implements Rule { public function passes($attr, $value) { ... } }`.
//!
//! Use it on a FormRequest field with `#[garde(custom = "{{name}}::check"))]`,
//! or call `{{name}}::check(&value)` directly.
use garde::Path;
pub struct {{name}};
impl {{name}} {
pub fn check(value: &str, _ctx: &()) -> Result<(), garde::Error> {
// TODO: return Err(garde::Error::new("message")) on failure.
if value.is_empty() {
return Err(garde::Error::new("must not be empty"));
}
Ok(())
}
}
#[allow(dead_code)]
fn _example(_p: Path) {}
"#;
const RESOURCE_TEMPLATE: &str = r#"//! {{name}} — API Resource serializer for `{{model}}`.
//! Laravel parity: `class {{name}} extends JsonResource { public function toArray($req) { ... } }`.
//!
//! Wrap a model instance in this to control the JSON shape sent to clients,
//! independent of the database column layout.
use anvilforge::prelude::*;
use serde::Serialize;
use crate::app::Models::{{model}};
#[derive(Serialize)]
pub struct {{name}} {
pub id: i64,
// TODO: the fields you want to expose.
// Hide secrets (password, internal flags) by simply not listing them.
}
impl From<{{model}}> for {{name}} {
fn from(m: {{model}}) -> Self {
Self {
id: m.id,
// TODO: map each field from the model.
}
}
}
impl {{name}} {
/// Convenience for collection endpoints: `Json({{name}}::collection(rows))`.
pub fn collection(rows: Vec<{{model}}>) -> Vec<{{name}}> {
rows.into_iter().map({{name}}::from).collect()
}
}
"#;