rok-cli 0.3.2

Developer CLI for rok-based Axum applications
//! `--template saas` — Multi-tenant SaaS with auth-v4, billing hooks, org model.

use std::{fs, path::Path};

pub fn generate(name: &str, root: &Path) -> 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()
);
"#;