use super::{Backend, Template};
const DEPENDENCIES: &[(&str, &str)] = &[
(
"tokio",
r#"{ version = "1", features = ["macros", "rt-multi-thread", "sync", "signal", "net"] }"#,
),
(
"axum",
r#"{ version = "0.8", default-features = false, features = ["tokio", "http1", "json", "form", "query"] }"#,
),
("tower", r#"{ version = "0.5", features = ["util"] }"#),
("serde", r#"{ version = "1", features = ["derive"] }"#),
("serde_json", r#""1""#),
(
"chrono",
r#"{ version = "0.4", default-features = false, features = ["serde", "clock"] }"#,
),
("tracing", r#""0.1""#),
(
"tracing-subscriber",
r#"{ version = "0.3", features = ["env-filter"] }"#,
),
("dotenvy", r#""0.15""#),
];
const ASYNC_TRAIT: &str = "async-trait";
pub fn dependency_names() -> impl Iterator<Item = &'static str> {
["rustango", ASYNC_TRAIT]
.into_iter()
.chain(DEPENDENCIES.iter().map(|(n, _)| *n))
}
pub fn toml_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
c if c.is_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
pub fn cargo_toml(
name: &str,
template: Template,
backend: Backend,
features: &[String],
rustango_path: Option<&str>,
) -> String {
let rustango_dep = template.rustango_dep(rustango_path, features);
let default_backend = backend.feature();
let wants_jobs = template.base_features().iter().any(|f| *f == "batteries")
|| features
.iter()
.any(|f| f == "jobs" || f == "jobs-postgres" || f == "batteries");
let async_trait_dep = if wants_jobs {
format!(
"\n# `rustango::jobs::Job` is an async trait — implementing one needs this.\n\
{ASYNC_TRAIT} = \"0.1\""
)
} else {
String::new()
};
let deps = DEPENDENCIES
.iter()
.map(|(n, v)| format!("{n} = {v}"))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
# Empty `[workspace]` table makes this project standalone: if a parent
# directory has its own workspace `Cargo.toml`, cargo would otherwise
# refuse to build (see "current package believes it's in a workspace
# when it's not"). This declaration severs that link without taking on
# any workspace members. Delete it if you intentionally want the
# project to be a member of a parent workspace.
[workspace]
[dependencies]
# `rustango` re-exports every proc-macro (Model / Form / ViewSet /
# Serializer / embed_migrations / main), so you do NOT need to depend
# on `rustango-macros` directly. Use `rustango::Model` etc.
rustango = {rustango_dep}
{deps}{async_trait_dep}
[dev-dependencies]
tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
# The `#[derive(Model)]` FromRow / LoadRelated impls are
# `#[cfg(feature = "...")]`-gated on THIS crate's features, so a backend
# feature must be enabled here for them to compile. `default` picks the
# one `cargo run` uses; switch with e.g.
# `cargo run --no-default-features --features sqlite`.
[features]
default = ["{default_backend}"]
postgres = ["rustango/postgres"]
sqlite = ["rustango/sqlite"]
mysql = ["rustango/mysql"]
"#
)
}
pub fn env_example(name: &str, backend: Backend) -> String {
let url = backend.compose_url(name);
let host_note = match backend.service() {
Some(svc) => format!(
"# Defaults suit `docker compose up -d`; the database name matches
# docker-compose.yml. Running cargo on the host instead of in the rust
# container? Change `{svc}` -> `localhost` below."
),
None => "# SQLite needs no database server — the file lives beside the
# project and is created on first `cargo run -- migrate`."
.to_owned(),
};
format!(
"# Copy to .env and edit for your environment; src/main.rs loads it at startup.
{host_note}
DATABASE_URL={url}
# Bind address. Unset, the tier's `[server] bind` applies (127.0.0.1 in
# dev); docker-compose.yml sets 0.0.0.0 for the container only.
# RUSTANGO_BIND=127.0.0.1:8080
# Apex domain the operator console is served on.
RUSTANGO_APEX_DOMAIN=localhost
# Session signing key — 32 bytes of base64. Anything else is discarded
# silently and a key is generated into ./var/ instead, so a project that
# looks configured is not. Leave it commented in development; set a real
# one in production and keep it out of source control:
# RUSTANGO_SESSION_SECRET=$(openssl rand -base64 32)
# RUSTANGO_SESSION_SECRET=
# Encryption key for sealed secrets: saved database server passwords,
# tenant database URLs, EncryptedString fields. Use 32 or more random
# bytes and back it up: without it sealed values cannot be read.
# RUSTANGO_SECRET_KEY=$(openssl rand -base64 48)
# RUSTANGO_SECRET_KEY=
# Rotation: old key here, new one above, then in a tenancy project
# `cargo run -- reseal-tenant-secrets --apply`.
# RUSTANGO_SECRET_KEY_PREVIOUS=
# Log filter, standard RUST_LOG syntax. Default is `info,sqlx=warn`.
# Production sets this in the orchestrator rather than here.
# RUST_LOG=info,my_app=debug
"
)
}
pub const GITIGNORE: &str = "/target
/.env
/.env.*
!/.env.example
# Runtime state. The server writes generated session signing keys here
# when RUSTANGO_SESSION_SECRET is unset — never commit them.
/var/
# Local databases (sqlite backend, test fixtures).
*.db
*.db-wal
*.db-shm
*.db-journal
*-migrate.lock*
*.log
";
pub const RUST_TOOLCHAIN: &str = "[toolchain]
channel = \"stable\"
components = [\"rustfmt\", \"clippy\", \"rust-analyzer\"]
";
pub fn docker_compose(name: &str, backend: Backend) -> String {
let db_service = match backend {
Backend::Postgres => format!(
r#" postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: rustango
POSTGRES_PASSWORD: rustango
POSTGRES_DB: {name}_dev
# Without this the data lives in the container's writable layer, so
# `docker compose down` deletes your development database and `up`
# hands back an empty one, with nothing said either way (#1309).
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
# Loopback only: the password above is public.
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rustango -d {name}_dev"]
interval: 2s
timeout: 2s
retries: 20
"#
),
Backend::Mysql => format!(
r#" mysql:
image: mysql:8
# Case-sensitive like PostgreSQL/SQLite; the stock `_ai_ci` ignores case.
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_as_cs
environment:
MYSQL_ROOT_PASSWORD: rustango
MYSQL_DATABASE: {name}_dev
MYSQL_USER: rustango
MYSQL_PASSWORD: rustango
# Without this the data lives in the container's writable layer, so
# `docker compose down` deletes your development database and `up`
# hands back an empty one, with nothing said either way (#1309).
volumes:
- mysql-data:/var/lib/mysql
ports:
# Loopback only: the password above is public.
- "127.0.0.1:3306:3306"
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u rustango -prustango"]
interval: 2s
timeout: 2s
retries: 30
"#
),
Backend::Sqlite => String::new(),
};
let db_volume = match backend {
Backend::Postgres => " postgres-data:\n",
Backend::Mysql => " mysql-data:\n",
Backend::Sqlite => "",
};
let (skip_hint, depends_on) = match backend.service() {
Some(svc) => (
format!(
" # Skip it and run cargo on the host with\n \
# `docker compose up -d --no-deps {svc}`."
),
format!(" depends_on:\n {svc}:\n condition: service_healthy\n"),
),
None => (
" # Skip it by running `cargo run` on the host — with SQLite\n \
# there is nothing else compose needs to start."
.to_owned(),
String::new(),
),
};
format!(
r#"services:
{db_service} # Hot-reload Rust dev container. `cargo watch -x run` rebuilds and
# restarts the binary on every source edit.
{skip_hint}
rust:
# Dockerfile.dev, not Dockerfile: the plain one is the deployable
# release image, which copies the source in and would defeat the
# bind mount below.
build:
context: .
dockerfile: Dockerfile.dev
{depends_on} env_file:
- .env
volumes:
- ./:/app
- cargo-target:/app/target
- cargo-registry:/usr/local/cargo/registry
- cargo-git:/usr/local/cargo/git
environment:
CARGO_TARGET_DIR: /app/target
CARGO_INCREMENTAL: "1"
# All interfaces inside the container, so the port mapping reaches it.
RUSTANGO_BIND: 0.0.0.0:8080
command: bash -c "cargo watch -x run"
ports:
# Loopback only: a dev server is not for the network.
- "127.0.0.1:8080:8080"
volumes:
{db_volume} cargo-target:
cargo-registry:
cargo-git:
"#
)
}
pub fn dockerfile_dev() -> &'static str {
"FROM rust:1\n\
\n\
WORKDIR /app\n\
\n\
RUN cargo install cargo-watch\n"
}
pub fn dockerfile_prod(name: &str) -> String {
format!(
r#"# The deployable image. `Dockerfile.dev` is the hot-reload one
# docker-compose.yml uses; this one is for shipping.
#
# docker build -t {name}:latest .
# docker run --rm -p 8080:8080 -e DATABASE_URL=... {name}:latest
#
# Pick a non-default backend at build time:
# docker build --build-arg FEATURES=sqlite --build-arg NO_DEFAULT=1 .
FROM rust:1-bookworm AS builder
ARG FEATURES=""
ARG NO_DEFAULT=""
WORKDIR /src
COPY . .
# Incremental compilation only grows the cache in a one-shot image build.
ENV CARGO_INCREMENTAL=0
RUN set -eux; \
flags="--release --locked"; \
if [ -n "$NO_DEFAULT" ]; then flags="$flags --no-default-features"; fi; \
if [ -n "$FEATURES" ]; then flags="$flags --features $FEATURES"; fi; \
cargo build $flags; \
mkdir -p /out; \
cp target/release/{name} /out/
# Same Debian release as the builder: the binary links the builder's glibc.
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /out/{name} /usr/local/bin/{name}
# Migrations and settings are read at run time, so they travel with the
# binary rather than being baked into it. `system/` holds the framework's
# own migrations: without it `migrate` skips framework schema changes.
COPY migrations /app/migrations
COPY system /app/system
COPY config /app/config
# Never root. The numeric id keeps the ownership stable if the image is
# rebuilt on a host whose useradd picks a different one.
RUN useradd --uid 10001 --create-home app && chown -R 10001 /app
USER 10001
WORKDIR /app
# A release image runs the prod tier. Unset, the dev tier would load and
# bind 127.0.0.1, which the port mapping cannot reach.
ENV RUSTANGO_ENV=prod
EXPOSE 8080
# `.with_health()` mounts /health; drop this line if you removed it.
HEALTHCHECK --interval=5s --timeout=3s --start-period=20s --retries=12 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
CMD ["{name}"]
"#
)
}
pub const DOCKERIGNORE: &str = "\
# Build output — the image builds its own, and this is the single
# biggest thing that would otherwise be uploaded to the daemon.
target/
**/target/
# Secrets. `.env.example` is the committed template and is safe.
.env
.env.*
!.env.example
# Local databases — a SQLite file here would be baked into the image.
*.db
*.db-wal
*.db-shm
*.db-journal
*-migrate.lock*
# Nothing in the image needs these.
.git/
.gitignore
node_modules/
**/node_modules/
";
pub fn readme(name: &str, template: Template, backend: Backend) -> String {
let template_label = match template {
Template::Api => "api (bare ORM + axum, no admin)",
Template::Fullstack => "fullstack (ORM + auto-admin)",
Template::Tenant => "tenant (multi-tenancy + operator console)",
};
let backend_label = backend.feature();
let (up_line, host_path) = match backend.service() {
Some(svc) => (
format!("docker compose up -d # boots {svc} + rust + cargo-watch"),
format!(
r#"### B. Cargo on the host (Docker just for {svc})
```sh
cp .env.example .env # then change `{svc}` -> `localhost` in DATABASE_URL
docker compose up -d {svc} # only the DB
cargo run -- makemigrations # generate the framework's (and your) tables
cargo run -- migrate # apply pending migrations
cargo run # boot the HTTP server
cargo run -- --help # full verb list (makemigrations, startapp, etc.)
```"#
),
),
None => (
"docker compose up -d # boots rust + cargo-watch".to_owned(),
r#"### B. Cargo on the host (no Docker at all)
SQLite is a file next to the project, so nothing else has to be running.
```sh
cp .env.example .env
cargo run -- makemigrations # generate the framework's (and your) tables
cargo run -- migrate # creates the database file and applies migrations
cargo run # boot the HTTP server
cargo run -- --help # full verb list (makemigrations, startapp, etc.)
```"#
.to_owned(),
),
};
let tenant_note = match template {
Template::Tenant => tenant_dev_note(backend),
_ => String::new(),
};
format!(
r#"# {name}
Generated with `cargo rustango new {name}` — template `{template_label}`,
backend `{backend_label}`.
## Run locally — two paths
### A. All-in-Docker (default; hot-reload via cargo-watch)
```sh
cp .env.example .env
{up_line}
docker compose run --rm rust cargo run -- makemigrations
docker compose run --rm rust cargo run -- migrate
# server lives at http://localhost:8080 — edits to src/ trigger rebuild
```
The `rust` service runs `cargo watch -x run` against the bind-mounted
source tree. Three named volumes preserve incremental build state
across container restarts so a fresh `up` doesn't recompile from
scratch.
{host_path}
Either way: `cargo run` (no args) is `runserver`. Every other
management verb (`makemigrations`, `migrate`, `startapp`, `check`, …)
flows through the same binary via `rustango::manage::Cli` — see
`src/main.rs`.
{tenant_note}
## Project layout
```text
src/
main.rs — Cli::new().api(urls::api()).run() boots both server + verbs
models.rs — every #[derive(Model)] lives here
views.rs — request handlers
urls.rs — pub fn api() -> Router aggregator
migrations/ — JSON migration files (committed to git)
system/migrations/ — the framework's own migrations (commit them too; the image ships them)
```
Adding a new model is one struct in `models.rs`; the auto-admin sees
it immediately. See <https://github.com/ujeenet/rustango> for the full
feature list.
"#
)
}
fn tenant_dev_note(backend: Backend) -> String {
let server = match backend {
Backend::Postgres => {
"The compose Postgres has no TLS: save it with host `postgres`
(`localhost` from the host), user `rustango` and TLS `disable`."
}
Backend::Mysql => {
"The compose `rustango` user cannot create users or grant rights:
save the server as `root` (password `rustango`), host `mysql` (`localhost`
from the host)."
}
Backend::Sqlite => "Save a server with a directory for tenant files, e.g. `var/tenants`.",
};
format!(
"
## Tenants and database servers
The operator console has **New tenant** and **Database servers** pages (see
`src/main.rs`). Saving a server password needs `RUSTANGO_SECRET_KEY` in `.env`
(see `.env.example`). {server}
"
)
}
pub fn main_rs(template: Template, name: &str) -> String {
let body = match template {
Template::Api => MAIN_RS_API,
Template::Fullstack => MAIN_RS_FULLSTACK,
Template::Tenant => MAIN_RS_TENANT,
};
body.replace("{crate_name}", &crate_name(name))
}
pub fn main_rs_for(template: Template, name: &str, features: &[String]) -> String {
let main = main_rs(template, name);
if !features.iter().any(|f| f == "csrf") {
return main;
}
main.replacen(
API_LINE,
&format!(
"{API_LINE} .with_csrf() // `--features csrf`: form POSTs must carry `_csrf`\n"
),
1,
)
}
const API_LINE: &str = " .api(urls::api())\n";
pub fn crate_name(package_name: &str) -> String {
package_name.replace('-', "_")
}
pub fn lib_rs(name: &str) -> String {
let krate = crate_name(name);
format!(
"//! `{krate}` — the application, as a library.
//!
//! Everything lives here rather than in `src/main.rs` so that binaries
//! under `src/bin/` can reach it. A file in `src/bin/` is its own
//! crate: `crate::` there is that binary, not this project, so a
//! worker generated by `manage make:worker` could not otherwise see
//! your models or job types.
//!
//! From `src/main.rs` or any `src/bin/*.rs`:
//!
//! ```ignore
//! use {krate}::urls;
//! use {krate}::models::SomeModel;
//! ```
//!
//! `manage startapp <name>` adds its `pub mod <name>;` line here.
pub mod models;
pub mod urls;
pub mod views;
"
)
}
const MAIN_RS_API: &str = "//! Project entrypoint — `Cli::run()` is the unified dispatcher
//! that handles `cargo run` (runserver) AND `cargo run -- migrate` /
//! `makemigrations` / `startapp` / etc. from one binary. No
//! `src/bin/manage.rs` needed.
//!
//! Logging is auto-configured by `#[rustango::main]` —
//! `tracing_subscriber::fmt` with env-filter, default
//! `info,sqlx=warn`. Override with `RUST_LOG` (env, or `./.env`: the macro reads that key)
//! or replace the macro with a hand-rolled subscriber in front of
//! `Cli::new()` for JSON / file-rotation / OTel export.
// The app's modules live in the library target (src/lib.rs) so that
// binaries under src/bin/ — a worker from `manage make:worker`, say —
// can reach the same models and jobs this one does. A `src/bin/*.rs`
// file is its own crate, so `crate::` there is NOT this project.
use {crate_name}::urls;
// Errors print with Display, so a multi-line CLI error reads as lines.
#[rustango::main]
async fn main() -> std::process::ExitCode {
if let Err(e) = run().await {
eprintln!(\"Error: {e}\");
return std::process::ExitCode::FAILURE;
}
std::process::ExitCode::SUCCESS
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let _ = dotenvy::dotenv();
rustango::manage::Cli::new()
.api(urls::api())
.with_health() // /health + /ready; the Dockerfile HEALTHCHECK probes /health
// Behind a load balancer, name its addresses so per-IP limits see clients:
// .with_trusted_proxies([\"10.0.0.0/8\"])?
// NOTE: no .with_settings_from_env() here. It is gated on the
// config feature, and this template is deliberately bare: ORM +
// axum + the manage dispatcher. Adding config alone does not
// help either — the settings layers it switches on also need
// session, signals, access_log, allowed_hosts and body_limit,
// which together are batteries, which is the fullstack template.
//
// So the config/*.toml this scaffolder writes are inert in an api
// project. Pre-existing, and worth fixing properly — either stop
// writing them here, or give the template the features to read
// them — but not by making a minimal template heavy.
.run()
.await
}
";
const MAIN_RS_FULLSTACK: &str = "//! Project entrypoint — `Cli::run()` is the unified dispatcher
//! that handles `cargo run` (runserver) AND `cargo run -- migrate` /
//! `makemigrations` / `startapp` / etc. from one binary. No
//! `src/bin/manage.rs` needed. The auto-admin is at `/admin` (or
//! `[admin] url_prefix`), behind a login: `cargo run -- create-admin
//! <username>` makes the account.
//!
//! Logging is auto-configured by `#[rustango::main]` —
//! `tracing_subscriber::fmt` with env-filter, default
//! `info,sqlx=warn`. Override with `RUST_LOG` (env, or `./.env`: the macro reads that key)
//! or replace the macro with a hand-rolled subscriber in front of
//! `Cli::new()` for JSON / file-rotation / OTel export.
// See the note in the api template: the app's modules live in the
// library target so `src/bin/*.rs` binaries can reach them.
use {crate_name}::urls;
// Errors print with Display, so a multi-line CLI error reads as lines.
#[rustango::main]
async fn main() -> std::process::ExitCode {
if let Err(e) = run().await {
eprintln!(\"Error: {e}\");
return std::process::ExitCode::FAILURE;
}
std::process::ExitCode::SUCCESS
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let _ = dotenvy::dotenv();
// config/*.toml for the RUSTANGO_ENV tier (default `dev`), then
// RUSTANGO__* env overrides; loaded once for the server and the admin.
// No config/ dir: a warning, and both run on defaults.
let loaded = rustango::config::Settings::load_from_env();
let settings = loaded.as_ref().cloned().unwrap_or_default();
let admin_settings = settings.clone();
rustango::manage::Cli::new()
.api(urls::api())
// Built from the server's own pool, so other verbs need no database.
.nest_with(urls::admin_prefix(&settings), move |pool| {
urls::admin_router(pool, &admin_settings)
})
.with_health() // /health + /ready endpoints for load balancers
// Behind a load balancer, name its addresses so per-IP limits see clients:
// .with_trusted_proxies([\"10.0.0.0/8\"])?
.with_loaded_settings(loaded)
.run()
.await
}
";
const MAIN_RS_TENANT: &str = r##"//! Tenant project entrypoint — HTTP server serving both the operator
//! console and per-tenant apps via subdomain routing.
//!
//! Logging is auto-configured by `#[rustango::main]` —
//! `tracing_subscriber::fmt` with env-filter, default
//! `info,sqlx=warn`. Override with `RUST_LOG` (env, or `./.env`: the macro reads that key)
//! or replace the macro with a hand-rolled subscriber in front of
//! `Cli::new()` for JSON / file-rotation / OTel export.
// See the note in the api template: the app's modules live in the
// library target so `src/bin/*.rs` binaries can reach them.
use {crate_name}::urls;
// Errors print with Display, so a multi-line CLI error reads as lines.
#[rustango::main]
async fn main() -> std::process::ExitCode {
if let Err(e) = run().await {
eprintln!("Error: {e}");
return std::process::ExitCode::FAILURE;
}
std::process::ExitCode::SUCCESS
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let _ = dotenvy::dotenv();
rustango::manage::Cli::new()
.tenancy()
.api(urls::api())
.with_health() // /health + /ready hit the registry pool
// Console "New tenant" and "Database servers" pages; every operator
// can use them. Delete this line to turn them off.
.with_tenant_provisioning("migrations")
// Behind a load balancer, name its addresses so per-IP limits see clients:
// .with_trusted_proxies(["10.0.0.0/8"])?
// Loads config/*.toml for the RUSTANGO_ENV tier (default `dev`),
// then RUSTANGO__* env overrides. Without it the files the
// scaffolder writes are inert, and the login, admin and operator
// console send no security headers.
.with_settings_from_env()
.run()
.await
}
"##;
pub fn models_rs(template: Template) -> String {
let header = "//! Project models — every #[derive(Model)] lives here.
//!
//! Adding a struct here makes it admin-visible automatically: the
//! macro populates the `inventory` registry that
//! `rustango::admin::router(pool)` walks.
use rustango::sql::Auto;
use rustango::Model;
// `#[derive(Model)]` registers this struct at *runtime* through `inventory`,
// which rustc cannot see — so fields only the framework reads look dead to it.
// Delete this line once your own code reads them.
#[allow(dead_code)]
#[derive(Model, Debug, Clone)]
#[rustango(table = \"item\", display = \"name\")]
pub struct Item {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 64)]
pub name: String,
pub active: bool,
}
";
if matches!(template, Template::Tenant) {
format!(
"{header}
// Tenancy registry models (Org, Operator, User) come along automatically
// with the `tenancy` feature — you don't need to redefine them here.
"
)
} else {
header.to_owned()
}
}
pub const VIEWS_RS: &str = "//! Project views — the request handlers this project mounts.
use axum::response::Html;
pub async fn index() -> Html<&'static str> {
Html(
\"<!doctype html>\\n\\
<title>rustango app</title>\\n\\
<h1>Hello from Rustango!</h1>\\n\\
<p>The auto-admin (if enabled) is at <a href=\\\"/admin\\\">/admin</a>.</p>\",
)
}
pub async fn healthz() -> &'static str {
\"ok\"
}
";
pub fn urls_rs(template: Template) -> String {
match template {
Template::Api => {
"//! Project URL routing (template: api — no admin).
//!
//! `Router::new()` is the auto-mount anchor — `manage startapp`
//! inserts `.merge(crate::<name>::urls::api())` lines here.
use axum::routing::get;
use axum::Router;
use crate::views;
pub fn api() -> Router<()> {
Router::new()
.route(\"/\", get(views::index))
.route(\"/healthz\", get(views::healthz))
}
"
.to_owned()
}
Template::Fullstack => {
"//! Project URL routing (template: fullstack — ORM + admin).
//!
//! `Router::new()` in `api()` is the auto-mount anchor —
//! `manage startapp` inserts `.merge(crate::<name>::urls::api())`
//! lines here.
use axum::routing::get;
use axum::Router;
use rustango::admin;
use rustango::config::Settings;
use rustango::session::{load_session_secret_for_tier, tier_from_env};
use rustango::sql::Pool;
use crate::views;
pub fn api() -> Router<()> {
Router::new()
.route(\"/\", get(views::index))
.route(\"/healthz\", get(views::healthz))
}
/// Where the admin mounts: `[admin] url_prefix` (or `[routes] admin_url`), else `/admin`.
pub fn admin_prefix(settings: &Settings) -> String {
admin::Builder::settings_prefix(settings)
.unwrap_or(\"/admin\")
.to_owned()
}
/// The auto-admin, behind a login. Create the first account with
/// `cargo run -- create-admin <username>`.
///
/// The signing key comes from `RUSTANGO_SESSION_SECRET`, else (off the
/// prod tier) from `./var/`, so a restart keeps you signed in.
pub fn admin_router(pool: Pool, settings: &Settings) -> Router {
let secret = load_session_secret_for_tier(
&tier_from_env(),
std::path::Path::new(\"./var/.rustango_session.key\"),
);
admin::Builder::from_settings(pool, settings)
.admin_prefix(admin_prefix(settings)) // the path main.rs nests it at
.with_session_auth(secret)
.build()
}
"
.to_owned()
}
Template::Tenant => {
"//! Project URL routing (template: tenant).
//!
//! `Builder::api(...)` mounts these routes on every tenant
//! subdomain alongside the auto-admin. Handlers can take
//! `rustango::extractors::Tenant` to resolve the current tenant +
//! get a tenant-scoped `&mut PgConnection`. Example:
//!
//! ```ignore
//! pub async fn list_items(mut t: rustango::extractors::Tenant)
//! -> Result<axum::Json<Vec<crate::models::Item>>, axum::http::StatusCode> {
//! let rows = crate::models::Item::objects()
//! .fetch_on(t.conn()).await
//! .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
//! Ok(axum::Json(rows))
//! }
//! ```
use axum::routing::get;
use axum::Router;
use crate::views;
pub fn api() -> Router<()> {
Router::new()
.route(\"/\", get(views::index))
.route(\"/healthz\", get(views::healthz))
}
"
.to_owned()
}
}
}
pub fn config_default_toml(name: &str, backend: Backend) -> String {
let example_url = backend.host_url(name);
format!(
r##"# {name} — shared defaults across every tier
# (`config/default.toml` is loaded first; `config/<RUSTANGO_ENV>_settings.toml`
# overrides on top.) Every section is optional — uncomment + edit
# what you need.
# [database]
# url = "{example_url}"
# pool_min_size = 2
# pool_max_size = 20
# [admin]
# allowed_tables = [] # empty = every registered model
# read_only_tables = []
# [server]
# bind = "127.0.0.1:8080"
# request_timeout_secs = 30
# max_body_bytes = 2097152 # 2 MiB
# shutdown_timeout_secs = 20 # drain after SIGTERM
# [auth]
# argon2_memory_kib = 19456 # OWASP 2024 floor
# argon2_iterations = 2
# lockout_threshold = 5
# lockout_duration_secs = 900
# login_ip_limit = 20 # failed logins per IP per window
# login_ip_window_secs = 60
# login_global_limit = 600 # failed logins across all clients, per login, per window
# login_global_window_secs = 60
# hash_wait_ms = 5000 # wait for a hashing slot before 503
# [auth.jwt]
# access_ttl_secs = 900 # 15 min
# refresh_ttl_secs = 604800 # 7 days
# issuer = "{name}"
# [brand]
# name = "{name}"
# tagline = ""
# logo_url = "https://cdn.example.com/logo.png" # full URL; beats logo_path
# logo_path = "assets/logo.png" # local file the operator console serves
# primary_color = "#2c6fb0"
# theme_mode = "auto" # auto | light | dark
# [security]
# headers_preset = "strict"
# hsts_max_age_secs = 31536000 # 1 year
# cors_allowed_origins = []
[errors]
# Styled 4xx/5xx pages for browsers, htmx fragments, plain text for other
# clients; JSON responses are never touched. Uses [brand] for the look.
# Override any page with templates/errors/404.html, errors/5xx.html or
# errors/default.html.
enabled = true
# templates = "templates" # template root holding errors/*.html
# htmx_target = "#flash" # where htmx error fragments are swapped in
# [routes]
# legacy_preset = false
# # Per-field overrides: login_url / admin_url / audit_url / static_url /
# # brand_url / change_password_url / impersonation_handoff_url
# [audit]
# retention_days = 90
# [logging] # needs `Cli::with_logging()` AND
# # `#[rustango::main(logging = false)]` —
# # see config/prod_settings.toml for why
# level = "info,sqlx=warn" # RUST_LOG syntax; RUST_LOG still wins
# format = "pretty" # pretty | json | compact
# with_line_numbers = false
# file_dir = "/var/log/{name}" # set to also write a rolling file
# file_rotation = "daily" # daily | hourly | minutely | never
# [mcp] # Model Context Protocol server (feature = "mcp")
# prefix = "/mcp" # URL prefix the MCP router mounts under
# token_ttl_secs = 900 # agent access-token lifetime (15 min)
# enable_sse = true # serve the GET {{prefix}} SSE stream
# allowed_origins = [] # CORS allow-list (empty = same-origin only)
# rate_limit_per_minute = 0 # per-IP cap (0/unset = unlimited)
# max_tools_listed = 0 # tools/list page size (0/unset = unlimited)
"##
)
}
pub fn config_dev_settings_toml(name: &str, backend: Backend) -> String {
let url = backend.host_url(name);
format!(
r##"# {name} — local development tier
# Loaded when RUSTANGO_ENV=dev (the default when unset).
[database]
# Matches docker-compose.yml and .env.example. Host is `localhost`, not
# the compose service name: this tier runs the app on the host against
# the container.
url = "{url}"
[server]
bind = "127.0.0.1:8080"
[security]
# Drop strict headers in dev so http<->https rebinds don't lock the
# browser into HSTS.
headers_preset = "dev"
hsts_max_age_secs = 0
# Plain-HTTP dev server: a `Secure` login cookie would be dropped.
secure_cookies = false
[brand]
# Make the dev tier visually distinguishable from prod (shown as a badge).
tagline = "dev"
"##
)
}
pub fn config_staging_settings_toml(name: &str, backend: Backend) -> String {
let staging_url = match backend {
Backend::Postgres => format!("postgres://staging-host/{name}_staging"),
Backend::Mysql => format!("mysql://staging-host/{name}_staging"),
Backend::Sqlite => format!("sqlite://./{name}_staging.db?mode=rwc"),
};
format!(
r##"# {name} — staging tier
# Loaded when RUSTANGO_ENV=staging. Production-shape security
# headers, but pointed at a separate database with shorter retention
# so QA volume doesn't bleed into prod analytics.
# [database]
# url = "{staging_url}"
[server]
bind = "0.0.0.0:8080"
[security]
headers_preset = "strict"
hsts_max_age_secs = 31536000
[brand]
tagline = "staging"
[audit]
retention_days = 30
"##
)
}
pub fn config_prod_settings_toml(name: &str) -> String {
format!(
r##"# {name} — production tier
# Loaded when RUSTANGO_ENV=prod. Strict-by-default; sensitive values
# (database url, secret key) come from the environment or your secrets
# manager — leaving them out of source control.
# The URL comes from the `DATABASE_URL` environment variable, which is
# what every pool reads. Set it from your secrets manager.
#
# Pool sizes can be pinned here. If you do, uncomment the header *with*
# the keys: a key left uncommented under a commented-out `[database]`
# lands at the TOML root, where it is silently ignored.
# [database]
# pool_min_size = 5
# pool_max_size = 50
[server]
bind = "0.0.0.0:8080"
request_timeout_secs = 30
shutdown_timeout_secs = 20
[security]
headers_preset = "strict"
hsts_max_age_secs = 31536000
[audit]
retention_days = 365
# Read by `Cli::with_logging()`, which this project does NOT call. To make
# this section live: add `.with_logging()`, and change `src/main.rs` to
# `#[rustango::main(logging = false)]` so the macro's own subscriber steps
# aside — `tracing` keeps the first one installed, so without that the
# settings here are read and discarded (#1465).
# `RUST_LOG` overrides `level` either way, and works today.
# [logging]
# level = "info"
# format = "json" # one object per event, for a log aggregator
"##
)
}