cargo-rustango 0.43.0

`cargo rustango new <name>` — Django-style project scaffolder for the rustango framework.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
//! File body templates for `cargo rustango new`.
//!
//! Templates are plain `const &str` (or builder fns when they need
//! interpolation). Keeping them in-source means the binary has zero
//! runtime filesystem dependency and `cargo install cargo-rustango`
//! ships everything in one artifact. CI snapshot-tests the generated
//! output by running `cargo check` on each template.

use super::Template;

// ---------------- Cargo.toml ----------------

pub fn cargo_toml(name: &str, template: Template) -> String {
    let rustango_dep = template.rustango_features();
    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}
tokio = {{ version = "1", features = ["macros", "rt-multi-thread", "sync", "signal", "net"] }}
axum = {{ version = "0.8", default-features = false, features = ["tokio", "http1", "json", "form", "query"] }}
tower = {{ version = "0.5", features = ["util"] }}
serde = {{ version = "1", features = ["derive"] }}
serde_json = "1"
chrono = {{ version = "0.4", default-features = false, features = ["serde", "clock"] }}
tracing = "0.1"
tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
dotenvy = "0.15"

[dev-dependencies]
tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
"#
    )
}

// ---------------- .env.example ----------------

pub fn env_example(name: &str) -> String {
    format!(
        "# Copy this file to .env and edit the values for your environment.
# `dotenvy::dotenv()` in src/main.rs picks it up at startup.
#
# Defaults are Docker-friendly (`postgres` host, `0.0.0.0` bind) so
# `docker compose up -d` boots a working stack without any edits.
# If you run cargo on the host instead of in the rust container,
# change `postgres` -> `localhost` in DATABASE_URL.
#
# Database name matches docker-compose.yml's POSTGRES_DB ({name}_dev).
DATABASE_URL=postgres://rustango:rustango@postgres:5432/{name}_dev
RUSTANGO_BIND=0.0.0.0:8080

# Tenancy template only — apex domain + signing secret.
# Generate a real secret with: openssl rand -base64 32
RUSTANGO_APEX_DOMAIN=localhost
RUSTANGO_SESSION_SECRET=change-me-base64-encoded-32-bytes-or-more
"
    )
}

// ---------------- .gitignore ----------------

pub const GITIGNORE: &str = "/target
/.env
*.log
";

// ---------------- rust-toolchain.toml ----------------

/// Pin rustup to 1.88 in the new project so users on macOS who have
/// Homebrew's older `rust` binary on PATH (currently 1.86) don't get
/// the "rustc 1.86.0 is not supported by the following packages"
/// error when they `cd` into the project. rustup reads this file and
/// silently uses 1.88 inside the project regardless of which cargo
/// they invoked. v0.8 rustango requires 1.88 (workspace.package.rust-version).
pub const RUST_TOOLCHAIN: &str = "[toolchain]
channel = \"1.88\"
";

// ---------------- docker-compose.yml ----------------

/// Bundle a working Postgres + Rust hot-reload dev stack out of the
/// box (#86). The `rust` service runs `cargo watch -x run` against
/// the bind-mounted source tree; the three named volumes
/// (`cargo-target`, `cargo-registry`, `cargo-git`) preserve
/// incremental build state across container restarts so a fresh
/// `docker compose up` doesn't trigger a full from-scratch rebuild.
///
/// Users who prefer running cargo on the host can simply ignore the
/// `rust` service and run the postgres service standalone with
/// `docker compose up -d postgres`.
pub fn docker_compose(name: &str) -> String {
    format!(
        r#"services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: rustango
      POSTGRES_PASSWORD: rustango
      POSTGRES_DB: {name}_dev
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U rustango -d {name}_dev"]
      interval: 2s
      timeout: 2s
      retries: 20

  # Hot-reload Rust dev container. `cargo watch -x run` rebuilds and
  # restarts the binary on every source edit. Skip this service (run
  # cargo on the host) by passing `--no-deps postgres` to
  # `docker compose up`, or remove it entirely if you don't want the
  # Docker-based dev loop.
  rust:
    build: .
    depends_on:
      postgres:
        condition: service_healthy
    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"
    command: bash -c "cargo watch -x run"
    ports:
      - "8080:8080"

volumes:
  cargo-target:
  cargo-registry:
  cargo-git:
"#
    )
}

// ---------------- Dockerfile ----------------

/// Companion to [`docker_compose`] — a thin Rust dev image with
/// `cargo-watch` preinstalled. The image stays small because all
/// project sources land via the bind mount; this image only needs
/// the toolchain + cargo-watch installed.
///
/// The rust version is pinned to the same value as
/// `rust-toolchain.toml`'s `channel`. Override by editing both
/// files together if you bump the toolchain.
pub fn dockerfile() -> &'static str {
    "FROM rust:1.88\n\
     \n\
     WORKDIR /app\n\
     \n\
     RUN cargo install cargo-watch\n"
}

// ---------------- README.md ----------------

pub fn readme(name: &str, template: Template) -> 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)",
    };
    format!(
        r#"# {name}

Generated with `cargo rustango new {name}` — template `{template_label}`.

## Run locally — two paths

### A. All-in-Docker (default; hot-reload via cargo-watch)

```sh
cp .env.example .env
docker compose up -d                 # boots postgres + rust + cargo-watch
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.

### B. Cargo on the host (Docker just for postgres)

```sh
cp .env.example .env                 # then change `postgres` -> `localhost` in DATABASE_URL
docker compose up -d postgres        # only the DB
cargo run -- migrate                 # apply pending migrations
cargo run                            # boot the HTTP server
cargo run -- --help                  # full verb list (makemigrations, startapp, etc.)
```

Either way: `cargo run` (no args) is `runserver`. Every other
Django-style verb flows through the same binary via
`rustango::manage::Cli` — see `src/main.rs`.

## 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 (Django-style "views")
  urls.rs         — pub fn api() -> Router aggregator

migrations/       — JSON migration files (committed to git)
```

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.
"#
    )
}

// ---------------- src/main.rs ----------------

/// Per-template `main.rs` — api/fullstack get the simple sqlx pool +
/// `urls::router(pool)` shape; tenant gets `rustango::server::Builder`
/// which auto-mounts the operator console at the apex and the tenant
/// admin at every subdomain via host-based dispatch. Without Builder,
/// the tenant template would scaffold a server that 404s on `/admin`
/// because nothing wires the auto-admin or operator console in — the
/// v0.8.1 Builder does that work.
pub fn main_rs(template: Template) -> &'static str {
    match template {
        Template::Api => MAIN_RS_API,
        Template::Fullstack => MAIN_RS_FULLSTACK,
        Template::Tenant => MAIN_RS_TENANT,
    }
}

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.

mod models;
mod urls;
mod views;

#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _ = dotenvy::dotenv();
    rustango::manage::Cli::new()
        .api(urls::api())
        .with_welcome() // friendly `/` on first run; drop once you have a root handler
        .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. Auto-admin is mounted via
//! `urls::api()` which nests `admin_router(pool)` itself.

mod models;
mod urls;
mod views;

#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _ = dotenvy::dotenv();
    rustango::manage::Cli::new()
        .api(urls::api())
        .with_welcome() // friendly `/` on first run; drop once you have a root handler
        .with_health() // /health + /ready endpoints for load balancers
        .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.

mod models;
mod urls;
mod views;

#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _ = dotenvy::dotenv();
    rustango::manage::Cli::new()
        .tenancy()
        .api(urls::api())
        .with_welcome() // friendly `/` on first run; drop once you have a root handler
        .with_health() // /health + /ready hit the registry pool
        .run()
        .await
}
"##;

// MANAGE_RS_TENANT removed in v0.16 — tenant projects now use the
// same single-binary `Cli::new().tenancy().run()` shape as every
// other template; src/main.rs is the one entrypoint.

// ---------------- src/models.rs ----------------

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, 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 via the `rustango::tenancy::*` import in
// src/bin/manage.rs — you don't need to redefine them here.
"
        )
    } else {
        header.to_owned()
    }
}

// ---------------- src/views.rs ----------------

pub const VIEWS_RS: &str = "//! Project views — request handlers (Django-style \"views\").

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\"
}
";

// ---------------- src/urls.rs ----------------

pub fn urls_rs(template: Template) -> String {
    match template {
        Template::Api => {
            // Stateless aggregator. `manage startapp <name>` auto-
            // patches a `.merge(crate::<name>::urls::api())` line
            // after `Router::new()` so additional apps compose
            // cleanly. Handlers that need the pool can read it from
            // request extensions (`Extension<PgPool>`) — main.rs
            // attaches it via `.layer(Extension(pool))`.
            "//! 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 => {
            // Stateless aggregator + a separate `admin_router(pool)`
            // helper that main.rs nests under `/admin`. Pool flows
            // through `Extension<PgPool>` (attached by main.rs) so
            // all apps' handlers can grab it without each one
            // declaring the pool as a state type.
            "//! Project URL routing (template: fullstack — ORM + auto-admin).
//!
//! `Router::new()` in `api()` is the auto-mount anchor —
//! `manage startapp` inserts `.merge(crate::<name>::urls::api())`
//! lines here. The auto-admin is built separately via
//! `admin_router(pool)` and nested at `/admin` from `main.rs`.

use axum::routing::get;
use axum::Router;
use rustango::admin;
use rustango::sql::sqlx::PgPool;

use crate::views;

pub fn api() -> Router<()> {
    Router::new()
        .route(\"/\", get(views::index))
        .route(\"/healthz\", get(views::healthz))
}

pub fn admin_router(pool: PgPool) -> Router {
    admin::Builder::new(pool).build()
}
"
            .to_owned()
        }
        Template::Tenant => {
            // Multi-tenant: main.rs uses `rustango::server::Builder`,
            // which expects a stateless `Router<()>` and injects the
            // `TenantContext` extension itself so handlers can use
            // `rustango::extractors::Tenant`. The user's routes mount
            // on every tenant subdomain alongside the auto-admin.
            "//! 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()
        }
    }
}
// ---------------- Bootstrap migrations (tenant template) ----------------

/// Registry-scoped bootstrap migration shipped by `rustango::tenancy`.
/// Embedded as a static string so `cargo rustango new --template tenant`
/// drops a working `migrations/` dir into the new project — the very
/// first `cargo run -- migrate` creates `rustango_orgs` /
/// `rustango_operators` without a separate `manage init-tenancy` step.
///
/// Regenerate by running `cargo test -p rustango --test dump_bootstrap
/// --features tenancy` and copying the output into
/// `crates/cargo-rustango/templates/`.
pub const BOOTSTRAP_REGISTRY_MIGRATION: &str =
    include_str!("../templates/0001_rustango_registry_initial.json");

/// Tenant-scoped bootstrap migration — same provenance as
/// [`BOOTSTRAP_REGISTRY_MIGRATION`]. Creates `rustango_users` inside
/// each tenant's schema/database when `manage migrate-tenants` runs.
pub const BOOTSTRAP_TENANT_MIGRATION: &str =
    include_str!("../templates/0001_rustango_tenant_initial.json");

// ---------------- Tiered settings files (#87) ----------------
//
// Every fresh project ships with four config files: `default.toml`
// for shared knobs + one `<env>_settings.toml` per tier (dev /
// staging / prod). The runtime picks the tier from `RUSTANGO_ENV`
// (default `dev`). Sensible-by-default values mean a freshly-
// scaffolded `cargo run` works without any TOML edits; production
// deploys override the prod tier with their own values.

/// `config/default.toml` — shared values that don't depend on tier.
/// Intentionally sparse — every section is optional and defaults to
/// the section's `Default` impl. Tier files override here.
pub fn config_default_toml(name: &str) -> String {
    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           = "postgres://localhost/{name}"
# 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

# [auth]
# argon2_memory_kib  = 19456    # OWASP 2024 floor
# argon2_iterations  = 2
# lockout_threshold  = 5
# lockout_duration_secs = 900

# [auth.jwt]
# access_ttl_secs   = 900       # 15 min
# refresh_ttl_secs  = 604800    # 7 days
# issuer            = "{name}"

# [brand]
# name           = "{name}"
# tagline        = ""
# primary_color  = "#2c6fb0"
# theme_mode     = "auto"       # auto | light | dark

# [security]
# headers_preset       = "strict"
# hsts_max_age_secs    = 31536000     # 1 year
# cors_allowed_origins = []

# [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
"##
    )
}

/// `config/dev_settings.toml` — local development. Loose defaults:
/// short JWT TTLs (so token-rotation bugs surface early), no HSTS
/// (so http→https rebinds don't lock the browser), debug-friendly
/// settings.
pub fn config_dev_settings_toml(name: &str) -> String {
    format!(
        r##"# {name} — local development tier
# Loaded when RUSTANGO_ENV=dev (the default when unset).

[database]
url = "postgres://postgres:postgres@localhost:5432/{name}_dev"

[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

[brand]
# Make the dev tier visually distinguishable from prod.
tagline = "(dev)"
"##
    )
}

/// `config/staging_settings.toml` — production-like but pointed at
/// a separate database, with shorter retention.
pub fn config_staging_settings_toml(name: &str) -> String {
    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 = "postgres://staging-host/{name}_staging"

[server]
bind = "0.0.0.0:8080"

[security]
headers_preset    = "strict"
hsts_max_age_secs = 31536000

[brand]
tagline = "(staging)"

[audit]
retention_days = 30
"##
    )
}

/// `config/prod_settings.toml` — production. Strict defaults; expects
/// real values (DATABASE_URL, secret_key, etc.) supplied via env
/// vars or out-of-band secret management. The TOML purposefully
/// leaves the database url commented — operators set it via
/// `RUSTANGO__DATABASE__URL` or a secrets manager.
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 RUSTANGO__* env vars or your
# secrets manager — leaving them out of source control.

# [database]
# url = "set via RUSTANGO__DATABASE__URL or your secrets manager"
pool_min_size = 5
pool_max_size = 50

[server]
bind                 = "0.0.0.0:8080"
request_timeout_secs = 30

[security]
headers_preset    = "strict"
hsts_max_age_secs = 31536000

[audit]
retention_days = 365
"##
    )
}