use std::{fs, path::Path};
use super::WizardConfig;
pub fn generate(name: &str, root: &Path, _config: &WizardConfig) -> anyhow::Result<()> {
for dir in &[
"src/app/controllers",
"src/app/models",
"src/app/validators",
"src/app/middleware",
"src/config",
"src/routes",
"database/migrations",
"tests/common",
] {
fs::create_dir_all(root.join(dir))?;
}
let files: Vec<(&str, String)> = vec![
("Cargo.toml", cargo_toml(name)),
("src/main.rs", main_rs(name)),
(".env.example", ENV_EXAMPLE.into()),
(".gitignore", GITIGNORE.into()),
("src/lib.rs", LIB_RS.into()),
("src/state.rs", STATE_RS.into()),
("src/routes/mod.rs", ROUTES_MOD_RS.into()),
("src/config/mod.rs", CONFIG_MOD_RS.into()),
("src/app/mod.rs", APP_MOD_RS.into()),
("src/app/controllers/mod.rs", CONTROLLERS_MOD_RS.into()),
(
"src/app/controllers/auth_controller.rs",
AUTH_CONTROLLER_RS.into(),
),
(
"src/app/controllers/org_controller.rs",
ORG_CONTROLLER_RS.into(),
),
(
"src/app/controllers/billing_controller.rs",
BILLING_CONTROLLER_RS.into(),
),
("src/app/models/mod.rs", MODELS_MOD_RS.into()),
("src/app/models/user.rs", USER_MODEL_RS.into()),
("src/app/models/organisation.rs", ORG_MODEL_RS.into()),
(
"src/app/models/subscription.rs",
SUBSCRIPTION_MODEL_RS.into(),
),
("src/app/middleware/mod.rs", MIDDLEWARE_MOD_RS.into()),
(
"src/app/middleware/tenant_resolver.rs",
TENANT_RESOLVER_RS.into(),
),
("src/app/validators/mod.rs", VALIDATORS_MOD_RS.into()),
("database/migrations/001_users.sql", MIGRATION_USERS.into()),
(
"database/migrations/002_organisations.sql",
MIGRATION_ORGS.into(),
),
(
"database/migrations/003_subscriptions.sql",
MIGRATION_SUBS.into(),
),
];
for (rel, content) in &files {
fs::write(root.join(rel), content)?;
println!(" create {rel}");
}
Ok(())
}
fn cargo_toml(name: &str) -> String {
format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "{name}"
path = "src/main.rs"
[dependencies]
axum = "0.8"
tokio = {{ version = "1", features = ["full"] }}
tower-http = {{ version = "0.6", features = ["trace", "cors"] }}
serde = {{ version = "1", features = ["derive"] }}
serde_json = "1"
sqlx = {{ version = "0.8", default-features = false, features = [
"macros", "runtime-tokio-native-tls", "chrono", "postgres",
] }}
chrono = {{ version = "0.4", features = ["serde"] }}
dotenvy = "0.15"
anyhow = "1"
rok-auth = {{ version = "0.1", features = ["axum", "magic-link"] }}
rok-orm = {{ version = "0.1", features = ["postgres", "axum"] }}
rok-validate = {{ version = "0.1", features = ["axum"] }}
rok-problem = {{ version = "0.1", features = ["axum"] }}
rok-config = {{ version = "0.1" }}
"#
)
}
fn main_rs(name: &str) -> String {
format!(
r#"mod app;
mod config;
mod routes;
mod state;
use axum::Router;
use sqlx::postgres::PgPoolOptions;
use config::{{AppConfig, DatabaseConfig}};
use state::AppState;
#[tokio::main]
async fn main() {{
dotenvy::dotenv().ok();
let app_cfg = AppConfig::load();
let db_cfg = DatabaseConfig::load();
let pool = PgPoolOptions::new()
.max_connections(db_cfg.max_connections)
.connect(&db_cfg.url)
.await
.expect("failed to connect to database");
let state = AppState::new(pool);
let app = Router::new()
.merge(routes::router())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&app_cfg.listen_addr)
.await
.expect("bind");
println!("{name} listening on {{}}", app_cfg.listen_addr);
axum::serve(listener, app).await.expect("server");
}}
"#
)
}
const ENV_EXAMPLE: &str = r#"APP_NAME=my-saas
LISTEN_ADDR=0.0.0.0:3000
DATABASE_URL=postgres://postgres:postgres@localhost/saas_app
JWT_SECRET=change-me-in-production
MAGIC_LINK_SECRET=change-me-in-production
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
MAIL_DRIVER=log
"#;
const GITIGNORE: &str = "/target\n.env\n";
const LIB_RS: &str = r#"pub mod app;
pub mod config;
pub mod routes;
pub mod state;
"#;
const STATE_RS: &str = r#"use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
}
impl AppState {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
"#;
const ROUTES_MOD_RS: &str = r#"use axum::{routing::{get, post}, Router};
use crate::app::controllers::{auth_controller::AuthController, org_controller::OrgController};
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/auth/magic-link", post(AuthController::send_magic_link))
.route("/auth/magic-link/verify", get(AuthController::verify_magic_link))
.route("/orgs", post(OrgController::create))
.route("/orgs/:id", get(OrgController::show))
}
"#;
const CONFIG_MOD_RS: &str = r#"use rok_config::Config;
#[derive(Config, Debug)]
pub struct AppConfig {
#[env("APP_NAME", default = "my-saas")]
pub name: String,
#[env("LISTEN_ADDR", default = "0.0.0.0:3000")]
pub listen_addr: String,
}
#[derive(Config, Debug)]
pub struct DatabaseConfig {
#[env("DATABASE_URL")]
pub url: String,
#[env("DB_MAX_CONNECTIONS", default = 10)]
pub max_connections: u32,
}
"#;
const APP_MOD_RS: &str = r#"pub mod controllers;
pub mod middleware;
pub mod models;
pub mod validators;
"#;
const CONTROLLERS_MOD_RS: &str = r#"pub mod auth_controller;
pub mod billing_controller;
pub mod org_controller;
"#;
const AUTH_CONTROLLER_RS: &str = r#"use axum::{response::IntoResponse, Json};
use serde_json::json;
pub struct AuthController;
impl AuthController {
pub async fn send_magic_link() -> impl IntoResponse {
Json(json!({ "message": "magic link sent — implement me" }))
}
pub async fn verify_magic_link() -> impl IntoResponse {
Json(json!({ "message": "verify magic link — implement me" }))
}
}
"#;
const ORG_CONTROLLER_RS: &str = r#"use axum::{extract::Path, response::IntoResponse, Json};
use serde_json::json;
pub struct OrgController;
impl OrgController {
pub async fn create() -> impl IntoResponse {
Json(json!({ "message": "create org — implement me" }))
}
pub async fn show(Path(id): Path<i64>) -> impl IntoResponse {
Json(json!({ "data": { "id": id } }))
}
}
"#;
const BILLING_CONTROLLER_RS: &str = r#"use axum::{response::IntoResponse, Json};
use serde_json::json;
pub struct BillingController;
impl BillingController {
pub async fn webhook() -> impl IntoResponse {
// Verify Stripe-Signature header and process events
Json(json!({ "received": true }))
}
}
"#;
const MODELS_MOD_RS: &str = r#"pub mod organisation;
pub mod subscription;
pub mod user;
pub use user::User;
pub use organisation::Organisation;
"#;
const USER_MODEL_RS: &str = r#"use rok_orm::Model;
use serde::Serialize;
#[derive(Debug, Clone, Model, Serialize, sqlx::FromRow)]
pub struct User {
pub id: i64,
pub email: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
"#;
const ORG_MODEL_RS: &str = r#"use rok_orm::Model;
use serde::Serialize;
#[derive(Debug, Clone, Model, Serialize, sqlx::FromRow)]
pub struct Organisation {
pub id: i64,
pub name: String,
pub slug: String,
pub owner_id: i64,
pub created_at: chrono::DateTime<chrono::Utc>,
}
"#;
const SUBSCRIPTION_MODEL_RS: &str = r#"use rok_orm::Model;
use serde::Serialize;
#[derive(Debug, Clone, Model, Serialize, sqlx::FromRow)]
pub struct Subscription {
pub id: i64,
pub org_id: i64,
pub stripe_customer: Option<String>,
pub plan: String,
pub status: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
"#;
const MIDDLEWARE_MOD_RS: &str = r#"pub mod tenant_resolver;
"#;
const TENANT_RESOLVER_RS: &str = r#"// Extract organisation from subdomain or X-Org-Slug header.
// Implement as an Axum extractor or middleware layer.
"#;
const VALIDATORS_MOD_RS: &str = r#"// pub mod auth_requests;
"#;
const MIGRATION_USERS: &str = r#"CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"#;
const MIGRATION_ORGS: &str = r#"CREATE TABLE IF NOT EXISTS organisations (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
owner_id BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS org_members (
org_id BIGINT NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
PRIMARY KEY (org_id, user_id)
);
"#;
const MIGRATION_SUBS: &str = r#"CREATE TABLE IF NOT EXISTS subscriptions (
id BIGSERIAL PRIMARY KEY,
org_id BIGINT NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
stripe_customer TEXT,
plan TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"#;