Chapter 1 — Project shape & manage commands
1.1 cargo rustango startproject / manage startapp
What: Scaffolder that emits the canonical Django-shape project layout.
When: Brand-new project, or adding a new sub-app to an existing one.
API: cargo-rustango for startproject; manage::startapp for startapp.
Recipe: this very project was scaffolded by hand to match the layout cargo rustango new --template tenant produces. v0.16's unified Cli::new() dispatcher means there is no src/bin/manage.rs and no second binary — cargo run is runserver, cargo run -- <verb> is everything else.
Polished output (v0.28.3, #63): manage startapp <name> ships:
- A singularized starter model —
startapp postsproducespub struct Poston table"post". Conservative trailing-sstrip on names of length ≥ 5 (comments → comment,users → user);news/address/bus/ short names stay untouched. Rename the struct or table literal freely. - An
admin(...)config block (list_display = "name, active, created_at",search_fields = "name",ordering = "-created_at") so the list view is usable out of the box. - A
created_at: DateTime<Utc>field with#[rustango(auto_now_add)]— Django convention. - A
starter_model_registered_in_inventorysmoke test intests.rsasserting the model lands ininventory::iter::<ModelEntry>(the canonical signal that the auto-admin will pick it up). - Doc comments calling out that
permissions = trueis the default and the four CRUD codenames ({table}.add,.change,.delete,.view) are auto-seeded byauto_create_permissionsduring the nextmigrate.
cookbook_blog/
├── Cargo.toml -- one [package], one binary
├── src/
│ ├── main.rs // rustango::main + Cli::new().tenancy().api(...).run()
│ ├── settings.rs // config/{default,test}.toml loader
│ └── apps/
│ ├── tenants/{models.rs, urls.rs, views.rs, admin.rs, mod.rs}
│ ├── auth/...
│ ├── blog/...
│ └── ...
├── migrations/0001_*.json
├── config/{default,test}.toml
└── tests/cookbook_chapter*.rs
Verified by: tests/cookbook_chapter01_manage.rs::layout_matches_django_shape
1.2 cargo run -- migrate / migrate <target> / downgrade [N]
What: Apply / rewind / point-target migrations against a Pool.
When: Boot, deploy, local dev — anywhere schema needs to track code.
API: manage::Cli wraps migrate::runner.
Recipe (src/main.rs):
#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
rustango::manage::Cli::new()
.tenancy()
.api(apps::api())
.run().await
}
cargo run -- migrateapplies pending;cargo run -- migrate <target>walks forward or back to a named target (zerounapplies everything);cargo run -- downgrade [N]rolls back N steps (default 1).
Verified by: tests/cookbook_chapter01_manage.rs::cli_help_works_without_database_url
1.3 cargo run -- makemigrations [name] [--empty] [--dry-run]
What: Diff the current model registry against the latest snapshot and emit a new migration JSON.
When: After a model change.
API: migrate::make
Recipe: forwarded by Cli::run() to migrate::manage::run. cargo run -- makemigrations [<name>]. --dry-run prints the planned ops without writing; --empty <name> emits a stub for hand-written Operation::Data work (e.g. RenameTable).
Verified by: tests/cookbook_chapter01_manage.rs::cli_dispatcher_recognises_makemigrations_verb
1.4 cargo run -- check
What: Static configuration sanity check — model registry + settings + migration ledger.
When: CI gate before running tests.
API: migrate::manage::run check arm.
Recipe: cargo run -- check — runs the bundled checks (system warnings, unapplied migrations).
Verified by: tests/cookbook_chapter01_manage.rs::cli_dispatcher_recognises_check_verb
1.5 cargo run (no args) — runserver via Cli
What: Default verb. Opens the pool, applies migrations, mounts the user's API router, serves on RUSTANGO_BIND (default 0.0.0.0:8080). Tenancy variant defers to server::Builder which wires the apex/subdomain host split + operator console.
When: Always — replaces hand-rolled axum wiring AND the second manage binary projects used to write.
API: manage::Cli::run
Recipe (src/main.rs) — same one-liner as §1.2.
Verified by: tests/cookbook_chapter01_manage.rs::cli_no_args_dispatches_to_runserver
1.6 cargo run -- create-operator / create-user (tenancy)
What: Bootstrap an Operator (registry-side admin) or tenant User. Argon2-hashes the password and inserts into the right table.
When: First boot of a fresh database; new tenant onboarding.
API: tenancy::manage::run create-operator / create-user arms (forwarded by Cli::tenancy().run()).
Recipe: cargo run -- create-operator admin --password letmein then cargo run -- create-user acme alice --password hunter2 --superuser.
First-user auto-superuser (v0.27.6): when create-user <slug> <name> runs and rustango_users for that tenant is empty, the new row is forced is_superuser = true regardless of --superuser. This avoids the cold-start trap where the first onboarded user lands on an admin index with an empty sidebar (no perms granted, no role assignments yet). A note: auto-promoted because first user of tenant line is emitted to stderr so onboarding scripts can detect the promotion.
Verified by: tests/cookbook_chapter01_manage.rs::cli_dispatcher_recognises_create_operator_verb
1.6b Recovery + setup CLI verbs (v0.27.6+)
What: Six verbs the v0.16 unified Cli dispatches into tenancy::manage::run for password / superuser / pool maintenance.
| Verb | Purpose |
|---|---|
create-superuser <slug> <username> --password <pw> | Create user + force is_superuser = true. Sugar for create-user <slug> <username> --password <pw> --superuser. |
set-superuser <slug> <username> [--off] | Flip an existing user's is_superuser flag. --off revokes. No password change. |
reset-password <slug> <username> --password <pw> | Argon2-rehash + write to rustango_users.password_hash. |
reset-operator-password <username> --password <pw> | Same, but for the registry-side rustango_operators table. |
migrate --fake <name> | Insert a row into __rustango_migrations__ without running the SQL. Drift-recovery for environments where the schema is already at that revision. |
prewarm-pools | Iterate every active database-mode tenant in rustango_orgs, build its PgPool once, cache it. Optional warm-up that pays the TCP/TLS/auth cost up-front instead of on the first request. Bounded by [TenantPoolsConfig::max_cached_database_pools]. |
API: tenancy::manage::users::create_superuser_cmd / set_superuser_cmd / reset_password_cmd / reset_operator_password_cmd; tenancy::manage::migrations::fake_apply_to_registry; TenantPools::prewarm_database_tenants.
Recipe:
# Recover a forgotten password without touching the DB:
cargo run -- reset-password acme alice --password rotated-password
# Promote without re-creating:
cargo run -- set-superuser acme alice
# Mark a manually-applied migration as ledgered (drift recovery):
cargo run -- migrate --fake 0007_add_audit_log
# Warm pools at boot (pair with TenantPoolsConfig { prewarm_active_tenants: true }):
cargo run -- prewarm-pools
Verified by: framework unit tests in tenancy::manage::users::tests; pool tests in tests/pools_live.rs.
1.6c Dev-iteration verbs (v0.29 — #82, #84a, #61, #84b)
What: Four verbs that close the dev-loop friction surfaced by the 2026-05 batch. None of them touch applied rows; each is safe to run unattended and idempotent or refuse-on-conflict.
| Verb | Purpose |
|---|---|
make:api_routes <app> [--tenant] | Scaffold src/<app>/api_routes.rs — the per-app composer that .merge(...)-es every viewset's router into a single Router<()>. --tenant emits the no-arg shape (each viewset resolves its own per-request connection); default emits the pool: PgPool shape. Refuses to overwrite existing files. |
forget-pending <name> | Delete a single un-applied migration JSON so the next makemigrations regenerates against current models. Accepts exact name or unique substring; refuses if the named migration is already in the ledger. |
migrate --squash | Delete every pending JSON and re-run makemigrations to produce a single fresh diff. Dev-iteration escape hatch when an evolving model produces a migration the validator rejects (e.g. AddColumn NOT NULL no default). Refuses with zero pending or only one pending (forget-pending is the right verb for the single-file case). |
seed-permissions [--slug <s>] | Re-run auto_create_permissions against one (--slug) or every active tenant. Idempotent — UNIQUE (content_type_id, codename) makes re-running on a populated catalog a no-op. Useful after adding #[rustango(permissions)] to a model without a fresh migrate cycle. |
API:
migrate::manage::make_api_routes_cmd,
migrate::manage::forget_pending_cmd,
migrate::manage::migrate_squash,
tenancy::manage::roles::seed_permissions_cmd.
Recipe:
# Drop a fresh per-app api_routes.rs + start adding viewsets:
cargo run -- startapp regions
cargo run -- make:api_routes regions --tenant
cargo run -- make:viewset CountryViewSet --model Country --tenant
# Then in src/regions/api_routes.rs uncomment / add:
# .merge(super::viewsets::country::viewset().tenant_router("/api/countries"))
# Got an `AddColumn NOT NULL no default` rejection on a fresh table?
cargo run -- migrate --squash
# (deletes pending JSONs, regenerates one fresh diff via makemigrations)
# Or surgical: drop one named pending JSON and re-diff:
cargo run -- forget-pending 0003_auto_20260509
cargo run -- makemigrations
# Add `#[rustango(permissions)]` to an existing model without a
# fresh migrate cycle:
cargo run -- seed-permissions # every active tenant
cargo run -- seed-permissions --slug acme # one tenant only
Verified by: scaffold/template tests in crates/rustango/src/migrate/manage.rs::gen_tests; forget-pending end-to-end via the validator-rejection recovery flow.
1.7 embed_migrations! macro
What: Compile-time embed of the migrations/ JSON files as &'static [Migration] so binaries ship with no filesystem dependency.
When: Distributing a single static binary that owns its schema.
API: rustango_macros::embed_migrations!
Recipe (src/main.rs):
const EMBEDDED: &[rustango::migrate::Migration] =
rustango::embed_migrations!("migrations");
Verified by: tests/cookbook_chapter01_manage.rs::embedded_migrations_are_nonempty
1.8 Settings layering (default.toml → <env>_settings.toml → env vars)
What: Tiered TOML config loader (#87, v0.29). Three layers, last writer wins:
config/default.toml— required. Shared knobs across every environment.config/<RUSTANGO_ENV>_settings.toml— tier overlay (dev_settings.toml,staging_settings.toml,prod_settings.toml). The legacy<env>.tomlshape (pre-v0.29) still loads when no_settingsvariant exists; the_settingsform wins when both are present.RUSTANGO__SECTION__KEY=valueenv vars — final override. Double underscore is the path separator (RUSTANGO__DATABASE__URLoverrides[database] url).
When: Per-environment differences (dev/staging/prod) without code changes, or when secrets need to come from a secrets manager rather than version control.
API: config::Settings::load_from_env,
Settings::load,
Settings::current_env_tier,
Settings::detected_features.
Recipe:
// Reads RUSTANGO_ENV (defaults to "dev"), runs the layered load:
let cfg = rustango::config::Settings::load_from_env()?;
// Or explicit tier:
let cfg = rustango::config::Settings::load("prod")?;
// What tier did we land on?
let tier = rustango::config::Settings::current_env_tier();
// Compile-time feature reflection (telemetry, version pages):
let feats = rustango::config::Settings::detected_features();
// → ["postgres", "tenancy", "admin", "manage", "config", ...]
Wiring into Cli (v0.29):
// One-liner that loads via load_from_env() and applies the entire
// stack — bind address, RouteConfig, plus the security_headers /
// CORS / access_log / body_limit layers — onto your API router at
// runserver time. Falls back to Cli defaults (with a tracing::warn)
// if config files are missing, so projects that don't use the
// layered loader still build cleanly.
rustango::manage::Cli::new()
.api(urls::api())
.with_settings_from_env() // applies bind + routes + layered middleware
.run().await
// Or explicit Settings handle (when you also want to read other sections):
let cfg = rustango::config::Settings::load_from_env()?;
my_setup(&cfg);
rustango::manage::Cli::new()
.api(urls::api())
.with_settings(&cfg)
.run().await
Today with_settings consumes:
Settings.server.bind— bind address. Resolution: explicit.bind(...)after.with_settings(...)→RUSTANGO_BINDenv →Settings.server.bind→ hardcoded0.0.0.0:8080.Settings.routes(tenancy projects) — pick the preset (legacy_preset = true→RouteConfig::legacy(), otherwise the friendlydefault()), then layer per-field overrides (login_url,admin_url, …) on top. An explicit.routes(rc)call BEFORE.with_settings(...)is preserved as the base, so TOML overrides layer on top of any code-side construction.
**The Cli::with_settings path applies the security_headers + CORS
- access_log + body_limit layers automatically** at
runservertime in this innermost-first order:body_limit → access_log → CORS → security_headers → handler. So for the typical case, the one-liner above is all you need — no per-layer wiring required.
For projects that build the server outside Cli, or want to swap
in custom layer construction, each section also exposes a typed
entry point so any subsystem can consume the relevant slice
without depending on the whole struct:
let cfg = rustango::config::Settings::load_from_env()?;
// auth_routes — access_ttl_secs / refresh_ttl_secs
let auth = rustango::tenancy::auth_routes::Config::default()
.with_jwt_settings(&cfg.auth.jwt);
api.merge(rustango::tenancy::auth_routes::jwt_router(auth));
// security_headers — preset + csp + hsts override
let sec = rustango::security_headers::SecurityHeadersLayer::from_settings(&cfg.security);
let app = app.layer(sec.into_layer());
// CORS — empty list = skip, "*" = permissive, otherwise allowlist
if let Some(cors) = rustango::cors::CorsLayer::from_settings(&cfg.security) {
let app = app.layer(cors.into_layer());
}
// access_log — extends the redact list with project additions
let log = rustango::access_log::AccessLogLayer::default()
.with_audit_settings(&cfg.audit); // redact_query_params extras
let app = app.access_log(log);
// body_limit — opt-in (returns None when max_body_bytes is unset)
if let Some(layer) = rustango::body_limit::BodyLimitLayer::from_settings(&cfg.server) {
let app = app.body_limit(layer);
}
// cache backend selection — "memory" / "redis" / "null" / unset
let cache: rustango::cache::BoxedCache = rustango::cache::from_settings(&cfg.cache);
// mailer backend selection — "console" / "memory" / "null" / "smtp"
let mailer: rustango::email::BoxedMailer = rustango::email::from_settings(&cfg.mail);
// jobs queue (memory only — JobQueue isn't object-safe so the trait
// can't be a runtime backend picker; pg backend is wired manually):
let queue = rustango::jobs::inmemory_from_settings(&cfg.jobs);
The operator console automatically picks up [brand] from the
loaded settings at boot — no wiring call needed. Resolution
priority: defaults → Settings.brand.* (TOML) → RUSTANGO_OPERATOR_*
env vars (which still win for deploy-time overrides). Empty strings
in TOML skip (so name = "" falls through to the default rather
than rendering as a blank brand name); invalid hex / theme_mode
values are dropped.
Future fields land here as the wiring catches up — every
Settings field is Option-typed (missing keys fall through,
don't reset).
Sections (every field is Option<T> with sensible defaults):
[database] # url, pool_min_size, pool_max_size
[admin] # allowed_tables, read_only_tables
[server] # bind, request_timeout_secs, max_body_bytes
[auth] # argon2 cost, lockout threshold/duration
[auth.jwt] # access_ttl_secs, refresh_ttl_secs, issuer, audience
[brand] # name, tagline, logo_url, primary_color, theme_mode
[security] # headers_preset, csp, hsts_max_age_secs, cors_allowed_origins
[routes] # legacy_preset + per-field URL prefix overrides
[audit] # retention_days, redact_query_params
[tenancy] # apex_domain
[cache] # backend, redis_url
[jobs] # backend, concurrency
[mail] # backend, smtp_host, from_address
The scaffolder writes all four files (default.toml + the three tier
overlays) when you run cargo rustango new <name>. A fresh
cargo run works without env vars (tier defaults to dev).
Deploy audit: cargo run -- check --deploy flags dev-defaults left
in the prod tier — headers_preset = "dev", hsts_max_age_secs = 0,
argon2_memory_kib < 19456, access_ttl_secs > 3600, loopback bind, etc.
Verified by: tests/cookbook_chapter01_manage.rs::settings_layer_resolves_env_overrides
1.9 rustango::main macro
What: Tokio runtime boot + tracing-subscriber wire-up in one attribute.
When: Every main.
API: rustango::main
Recipe (src/main.rs):
#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { ... }
Verified by: tests/cookbook_chapter01_manage.rs::main_macro_compiles_and_boots
1.10 Welcome page
What: Default GET / landing page when no route is registered.
When: First boot of a brand-new project — confirms the server is alive.
API: welcome
Recipe: handled by Builder::serve automatically when no / route is mounted; replaced by the first user-defined Router::route("/", ...).
Verified by: tests/cookbook_chapter01_manage.rs::welcome_page_renders_on_fresh_router