Rustango docs
← Cookbook

Chapter 1 — Project shape & manage commands

Everything a project needs — scaffolding, migrations, users, config — runs through one CLI: cargo run -- <verb>. Here's the verb list (cargo run -- --help):

$ cargo run -- --help
rustango manage CLI — tenancy-aware dispatcher

USAGE:
  cargo run -- <verb> [args]

TENANT MANAGEMENT:
  create-tenant <slug> [--display-name <s>] [--mode schema|database] …
  drop-tenant   <slug> [--confirm <slug>]        Soft-delete (data preserved).
  purge-tenant  <slug> [--confirm <slug>]        Hard-delete: drops schema/DB.
  list-tenants                                   Print every tenant.

USER / OPERATOR MANAGEMENT:
  create-operator <username> [--password <p> | --generate]
  create-user <slug> <username> [--password <p>] [--superuser]
  reset-password <slug> <username> [--password <p> | --generate]
  set-superuser <slug> <username> [--on|--off]
  …

QUICK START:
  wizard | init        Interactive setup — fresh project → working tenant +
                       operator + superuser, one opt-in prompt at a time.

The rest of this chapter walks the verbs you'll reach for most.

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 project matches the layout cargo rustango new --template tenant produces. A single Cli::new() dispatcher means there's no separate manage binary — cargo run starts the server, and cargo run -- <verb> runs everything else.

Generated starter app: manage startapp <name> ships:

  • A singularized starter modelstartapp posts produces pub struct Post on table "post". Conservative trailing-s strip 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_inventory smoke test in tests.rs asserting the model lands in inventory::iter::<ModelEntry> (the canonical signal that the auto-admin will pick it up).
  • Doc comments calling out that permissions = true is the default and the four CRUD codenames ({table}.add, .change, .delete, .view) are auto-seeded by auto_create_permissions during the next migrate.
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/0002_cookbook_tables.json   // your app's migrations
├── system/migrations/0001_*.json          // framework tables, generated by makemigrations
├── 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 -- migrate applies pending; cargo run -- migrate <target> walks forward or back to a named target (zero unapplies 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 — this one entry point replaces hand-rolled axum wiring.

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: when create-user <slug> <name> runs and a tenant has no users yet, the new row is forced to is_superuser = true regardless of --superuser. This avoids the cold-start trap where the first user lands on an admin with an empty sidebar (no permissions or roles assigned yet). A note: auto-promoted because first user of tenant line is printed so onboarding scripts can detect it.

Verified by: tests/cookbook_chapter01_manage.rs::cli_dispatcher_recognises_create_operator_verb


1.6b Recovery + setup CLI verbs

What: Six verbs for password / superuser / pool maintenance.

VerbPurpose
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-poolsIterate 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

What: Four verbs that smooth the local dev loop. None of them touch already-applied migrations; each is safe to run unattended and is either idempotent or refuses on conflict.

VerbPurpose
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 --squashDelete 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. Three layers, last writer wins:

  1. config/default.toml — required. Shared knobs across every environment.
  2. config/<RUSTANGO_ENV>_settings.toml — tier overlay (dev_settings.toml, staging_settings.toml, prod_settings.toml). A plain <env>.toml (without the _settings suffix) also loads when no _settings variant exists; the _settings form wins when both are present.
  3. RUSTANGO__SECTION__KEY=value env vars — final override. Double underscore is the path separator (RUSTANGO__DATABASE__URL overrides [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:

// 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_BIND env → Settings.server.bind → hardcoded 0.0.0.0:8080.
  • Settings.routes (tenancy projects) — pick the preset (legacy_preset = trueRouteConfig::legacy(), otherwise the friendly default()), 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 runserver time 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