use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
mod templates;
mod wizard;
fn main() -> ExitCode {
let raw: Vec<String> = std::env::args().collect();
let args: Vec<String> = match raw.iter().position(|s| s == "rustango") {
Some(i) if i + 1 < raw.len() => raw[i + 1..].to_vec(),
_ => raw[1..].to_vec(),
};
match args.first().map(String::as_str) {
Some("new") => match cmd_new(&args[1..]) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(1)
}
},
Some("--help") | Some("-h") | None => {
print_help();
ExitCode::SUCCESS
}
Some("--version") | Some("-V") => {
println!("cargo-rustango {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
Some(other) => {
eprintln!("error: unknown subcommand `{other}` (run with --help)");
ExitCode::from(2)
}
}
}
fn print_help() {
println!("cargo-rustango — project scaffolder for the rustango web framework");
println!();
println!("USAGE:");
println!(" cargo rustango new <name> [--template api|fullstack|tenant]");
println!(" [--rustango-path <dir>]");
println!();
println!("TEMPLATES:");
println!(" api bare ORM + axum, no admin (JSON-only services)");
println!(" fullstack ORM + auto-admin (default)");
println!(" tenant multi-tenancy + operator console + tenancy_manage CLI");
println!();
println!("OPTIONS:");
println!(" -t, --template <name> project template (default: fullstack)");
println!(" -b, --backend <name> postgres | sqlite | mysql (default: postgres)");
println!(" -F, --features <list> extra rustango features, comma-separated");
println!(" -i, --interactive pick from numbered menus instead");
println!(" --rustango-path <d> depend on a local rustango checkout");
println!(" instead of the published crate");
println!();
println!("INTERACTIVE:");
println!(" `cargo rustango new` with no arguments opens a wizard that asks");
println!(" for each of the above, then prints the equivalent command line.");
println!();
println!("BACKEND:");
println!(" --backend picks what `cargo run` uses and shapes .env.example,");
println!(" docker-compose.yml, and the settings tiers to match. The other");
println!(" two stay one flag away:");
println!(" cargo run --no-default-features --features sqlite");
println!();
println!("FEATURES:");
let width = OPTIONAL_FEATURES
.iter()
.map(|(n, _)| n.len())
.max()
.unwrap_or(0);
for (name, about) in OPTIONAL_FEATURES {
println!(" {name:<width$} {about}");
}
println!();
println!("EXAMPLES:");
println!(" cargo rustango new myblog");
println!(" cargo rustango new api_demo --template api");
println!(" cargo rustango new api_demo --template=api");
println!(" cargo rustango new shop --template tenant");
println!(" cargo rustango new edge --backend sqlite");
println!(" cargo rustango new saas --template tenant --features csrf,sso,cache-redis");
println!(" cargo rustango new ex1 --rustango-path ../rustango/crates/rustango");
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
Postgres,
Sqlite,
Mysql,
}
impl Backend {
fn parse(s: &str) -> Result<Self, String> {
match s {
"postgres" | "postgresql" | "pg" => Ok(Self::Postgres),
"sqlite" | "sqlite3" => Ok(Self::Sqlite),
"mysql" | "mariadb" => Ok(Self::Mysql),
other => Err(format!(
"unknown backend `{other}` — must be `postgres`, `sqlite`, or `mysql`"
)),
}
}
pub fn feature(self) -> &'static str {
match self {
Self::Postgres => "postgres",
Self::Sqlite => "sqlite",
Self::Mysql => "mysql",
}
}
pub fn host_url(self, name: &str) -> String {
match self {
Self::Postgres => format!("postgres://rustango:rustango@localhost:5432/{name}_dev"),
Self::Mysql => format!("mysql://rustango:rustango@localhost:3306/{name}_dev"),
Self::Sqlite => self.compose_url(name),
}
}
pub fn compose_url(self, name: &str) -> String {
match self {
Self::Postgres => format!("postgres://rustango:rustango@postgres:5432/{name}_dev"),
Self::Mysql => format!("mysql://rustango:rustango@mysql:3306/{name}_dev"),
Self::Sqlite => format!("sqlite://./{name}_dev.db?mode=rwc"),
}
}
pub fn service(self) -> Option<&'static str> {
match self {
Self::Postgres => Some("postgres"),
Self::Mysql => Some("mysql"),
Self::Sqlite => None,
}
}
}
pub const OPTIONAL_FEATURES: &[(&str, &str)] = &[
(
"tenancy",
"multi-tenancy: tenant registry, per-tenant databases, operator console",
),
("csrf", "CSRF protection middleware for form POSTs"),
("sso", "OIDC single sign-on for application users"),
("admin-sso", "OIDC single sign-on for the admin site"),
("passkey", "WebAuthn / passkey authentication"),
("cache-redis", "Redis cache backend"),
("cache-page", "whole-page response caching"),
("jobs", "background job queue (in-process worker pool)"),
(
"jobs-postgres",
"database-backed job queue, surviving restarts",
),
("scheduler", "fixed-interval background tasks"),
("email-smtp", "SMTP transport for the email framework"),
("mcp", "Model Context Protocol server for AI agents"),
("testkit", "test-only schema builders and model factories"),
("test_utils", "test-only constructors for downstream crates"),
];
#[derive(Debug, Clone, Copy)]
enum Template {
Api,
Fullstack,
Tenant,
}
impl Template {
fn parse(s: &str) -> Result<Self, String> {
match s {
"api" => Ok(Self::Api),
"fullstack" => Ok(Self::Fullstack),
"tenant" => Ok(Self::Tenant),
other => Err(format!(
"unknown template `{other}` — must be `api`, `fullstack`, or `tenant`"
)),
}
}
pub fn name(self) -> &'static str {
match self {
Self::Api => "api",
Self::Fullstack => "fullstack",
Self::Tenant => "tenant",
}
}
pub fn base_features(self) -> &'static [&'static str] {
match self {
Self::Api => &["manage"],
Self::Fullstack => &["batteries"],
Self::Tenant => &["batteries", "tenancy"],
}
}
fn rustango_dep(self, path: Option<&str>, extras: &[String]) -> String {
let feats = feature_list(self.base_features(), extras);
match path {
Some(p) => {
format!(r#"{{ path = "{p}", default-features = false, features = [{feats}] }}"#)
}
None => format!(
r#"{{ version = "{}", default-features = false, features = [{feats}] }}"#,
mm_version()
),
}
}
}
fn feature_list(base: &[&str], extras: &[String]) -> String {
let mut all: Vec<&str> = base.to_vec();
for e in extras {
if !all.contains(&e.as_str()) {
all.push(e);
}
}
all.iter()
.map(|f| format!("\"{f}\""))
.collect::<Vec<_>>()
.join(", ")
}
fn validate_features(extras: &[String], template: Template) -> Result<(), String> {
for f in extras {
if ["postgres", "sqlite", "mysql"].contains(&f.as_str()) {
return Err(format!(
"`{f}` is a database backend — pass `--backend {f}` instead. \
The project forwards its backend through its own feature, so \
naming it here would break `#[derive(Model)]` (#1211)."
));
}
if template.base_features().contains(&f.as_str()) {
continue;
}
if !OPTIONAL_FEATURES.iter().any(|(n, _)| n == f) {
let known: Vec<&str> = OPTIONAL_FEATURES.iter().map(|(n, _)| *n).collect();
return Err(format!(
"unknown feature `{f}` — available: {}.\n\
Anything else can be added to the generated Cargo.toml by hand.",
known.join(", ")
));
}
}
Ok(())
}
fn mm_version() -> String {
let full = env!("CARGO_PKG_VERSION");
full.rsplit_once('.')
.map(|(mm, _patch)| mm.to_owned())
.unwrap_or_else(|| full.to_owned())
}
#[derive(Debug)]
struct NewArgs {
name: String,
template: Template,
backend: Backend,
features: Vec<String>,
rustango_path: Option<String>,
interactive: bool,
}
fn cmd_new(args: &[String]) -> Result<(), String> {
let asked = args.iter().any(|a| a == "-i" || a == "--interactive");
let bare = args.is_empty() && std::io::IsTerminal::is_terminal(&std::io::stdin());
let mut parsed = parse_new_args(args, asked || bare)?;
if parsed.interactive {
let named = !parsed.name.is_empty();
parsed = wizard::run(parsed, named)?;
}
validate_name(&parsed.name)?;
let root = PathBuf::from(&parsed.name);
if root.exists() {
return Err(format!(
"destination directory `{}` already exists — pick a fresh name or remove it first",
root.display()
));
}
println!(
"scaffolding `{}` (template: {}, backend: {}) at {}",
parsed.name,
parsed.template.name(),
parsed.backend.feature(),
root.display()
);
fs::create_dir_all(&root).map_err(|e| format!("create_dir_all({}): {e}", root.display()))?;
write_project(&root, &parsed)?;
println!();
println!("done. next:");
println!(" cd {}", parsed.name);
println!(" cp .env.example .env");
if parsed.backend.service().is_some() {
println!(" docker compose up -d");
}
println!(
" cargo run -- makemigrations # generate schema migrations (incl. system/ framework tables)"
);
println!(" cargo run -- migrate # apply them");
println!(" cargo run # boot the HTTP server");
println!(" cargo run -- --help # full verb list");
Ok(())
}
fn parse_new_args(args: &[String], interactive: bool) -> Result<NewArgs, String> {
let mut name: Option<String> = None;
let mut template = Template::Fullstack;
let mut backend = Backend::Postgres;
let mut features: Vec<String> = Vec::new();
let mut rustango_path: Option<String> = None;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--backend" | "-b" => {
let v = iter
.next()
.ok_or_else(|| "--backend requires a value".to_owned())?;
backend = Backend::parse(v)?;
}
_ if arg.starts_with("--backend=") => {
backend = Backend::parse(&arg["--backend=".len()..])?;
}
_ if arg.starts_with("-b=") => {
backend = Backend::parse(&arg["-b=".len()..])?;
}
"--features" | "-F" => {
let v = iter
.next()
.ok_or_else(|| "--features requires a value".to_owned())?;
features.extend(split_features(v));
}
_ if arg.starts_with("--features=") => {
features.extend(split_features(&arg["--features=".len()..]));
}
_ if arg.starts_with("-F=") => {
features.extend(split_features(&arg["-F=".len()..]));
}
"--template" | "-t" => {
let v = iter
.next()
.ok_or_else(|| "--template requires a value".to_owned())?;
template = Template::parse(v)?;
}
_ if arg.starts_with("--template=") => {
template = Template::parse(&arg["--template=".len()..])?;
}
_ if arg.starts_with("-t=") => {
template = Template::parse(&arg["-t=".len()..])?;
}
"--rustango-path" => {
let v = iter
.next()
.ok_or_else(|| "--rustango-path requires a value".to_owned())?;
rustango_path = Some(v.clone());
}
_ if arg.starts_with("--rustango-path=") => {
rustango_path = Some(arg["--rustango-path=".len()..].to_owned());
}
"--interactive" | "-i" => {}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other if other.starts_with('-') => {
return Err(format!("unknown flag `{other}` (run --help)"));
}
other => {
if name.is_some() {
return Err(format!("unexpected positional argument `{other}`"));
}
name = Some(other.to_owned());
}
}
}
let name = match name {
Some(n) => n,
None if interactive => String::new(),
None => {
return Err(
"missing project name (e.g. `cargo rustango new myapp`, or `new` alone \
for the interactive wizard)"
.to_owned(),
)
}
};
let mut seen = Vec::new();
features.retain(|f| {
let fresh = !seen.contains(f);
seen.push(f.clone());
fresh
});
validate_features(&features, template)?;
Ok(NewArgs {
name,
template,
backend,
features,
rustango_path,
interactive,
})
}
fn split_features(raw: &str) -> Vec<String> {
raw.split([',', ' '])
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
}
const RESERVED_TARGET_NAMES: &[&str] = &["build", "deps", "examples", "incremental"];
fn validate_name(name: &str) -> Result<(), String> {
if RESERVED_TARGET_NAMES.contains(&name) {
return Err(format!(
"`{name}` cannot be a project name — cargo forbids it as a binary target \
because it conflicts with its own build directory names"
));
}
let valid = !name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if !valid {
return Err(format!(
"`{name}` is not a valid Cargo crate name — use [A-Za-z_][A-Za-z0-9_-]*"
));
}
Ok(())
}
fn write_project(root: &Path, args: &NewArgs) -> Result<(), String> {
let name = &args.name;
let template = args.template;
let backend = args.backend;
write(
root,
"Cargo.toml",
&templates::cargo_toml(
name,
template,
backend,
&args.features,
args.rustango_path.as_deref(),
),
)?;
write(root, ".env.example", &templates::env_example(name, backend))?;
write(root, ".gitignore", templates::GITIGNORE)?;
write(root, "rust-toolchain.toml", templates::RUST_TOOLCHAIN)?;
write(
root,
"docker-compose.yml",
&templates::docker_compose(name, backend),
)?;
write(root, "Dockerfile", &templates::dockerfile_prod(name))?;
write(root, "Dockerfile.dev", templates::dockerfile_dev())?;
write(root, ".dockerignore", templates::DOCKERIGNORE)?;
write(
root,
"README.md",
&templates::readme(name, template, backend),
)?;
write(
root,
"config/default.toml",
&templates::config_default_toml(name, backend),
)?;
write(
root,
"config/dev_settings.toml",
&templates::config_dev_settings_toml(name, backend),
)?;
write(
root,
"config/staging_settings.toml",
&templates::config_staging_settings_toml(name, backend),
)?;
write(
root,
"config/prod_settings.toml",
&templates::config_prod_settings_toml(name),
)?;
fs::create_dir_all(root.join("migrations")).map_err(|e| format!("create migrations/: {e}"))?;
write(root, "src/lib.rs", &templates::lib_rs(name))?;
write(root, "src/main.rs", &templates::main_rs(template, name))?;
write(root, "src/models.rs", &templates::models_rs(template))?;
write(root, "src/views.rs", templates::VIEWS_RS)?;
write(root, "src/urls.rs", &templates::urls_rs(template))?;
if matches!(template, Template::Tenant) {
write(root, "system/migrations/.gitkeep", "")?;
}
Ok(())
}
fn write(root: &Path, rel: &str, body: &str) -> Result<(), String> {
let path = root.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create_dir_all({}): {e}", parent.display()))?;
}
fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
println!(" + {rel}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mm_version_strips_patch() {
let v = mm_version();
assert!(
v.matches('.').count() == 1 || (v.matches('.').count() == 0 && !v.is_empty()),
"expected `major.minor` shape, got `{v}`"
);
let full = env!("CARGO_PKG_VERSION");
assert!(
full.starts_with(&v),
"expected `{full}` to start with `{v}`"
);
}
#[test]
fn every_template_pins_current_version() {
let mm = mm_version();
let needle = format!("\"{mm}\"");
for template in [Template::Api, Template::Fullstack, Template::Tenant] {
let dep = template.rustango_dep(None, &[]);
assert!(
dep.contains(&needle),
"template {template:?} dep `{dep}` does not pin v{mm}"
);
}
}
#[test]
fn config_templates_emit_default_plus_three_tiers() {
for name in ["acme", "demo_app"] {
let default_body = templates::config_default_toml(name, Backend::Postgres);
assert!(
default_body.contains(name),
"config_default_toml({name}) must mention the project name; got: {default_body}"
);
let dev = templates::config_dev_settings_toml(name, Backend::Postgres);
assert!(
dev.contains("(dev)") || dev.contains("dev_settings"),
"dev tier should be visually distinguishable; got: {dev}"
);
let staging = templates::config_staging_settings_toml(name, Backend::Postgres);
assert!(
staging.contains("staging") && staging.contains("retention_days"),
"staging tier missing retention_days; got: {staging}"
);
let prod = templates::config_prod_settings_toml(name);
assert!(
prod.contains("strict") && prod.contains("hsts_max_age_secs"),
"prod tier should default to strict security headers; got: {prod}"
);
}
}
#[test]
fn no_template_pins_yanked_version() {
const YANKED: &[&str] = &["0.23"];
for template in [Template::Api, Template::Fullstack, Template::Tenant] {
let dep = template.rustango_dep(None, &[]);
for ver in YANKED {
let needle = format!("\"{ver}\"");
assert!(
!dep.contains(&needle),
"template {template:?} pins yanked rustango v{ver}: `{dep}`"
);
}
}
}
#[test]
fn dockerfile_dev_emits_rust_toolchain_with_cargo_watch() {
let body = templates::dockerfile_dev();
assert!(
body.contains("FROM rust:"),
"Dockerfile.dev must base on a rust image, got `{body}`"
);
assert!(
body.contains("cargo install cargo-watch"),
"Dockerfile.dev must preinstall cargo-watch (powers the docker-compose.yml \
hot-reload command), got `{body}`"
);
assert!(
body.contains("WORKDIR /app"),
"Dockerfile.dev must set WORKDIR /app to match docker-compose.yml's bind \
mount target, got `{body}`"
);
}
#[test]
fn dockerfile_builds_a_release_binary_and_drops_root() {
let body = templates::dockerfile_prod("myapp");
assert!(
body.contains("AS builder") && body.matches("FROM ").count() >= 2,
"the deploy image must be multi-stage, or the toolchain ships with it: {body}"
);
assert!(
body.contains("--release"),
"a deploy image built at the debug profile is not a deploy image: {body}"
);
assert!(
body.contains("--locked"),
"a deploy image is exactly where an unnoticed dependency bump should fail \
the build rather than ship: {body}"
);
assert!(
body.contains("USER 10001"),
"the runtime stage must drop root: {body}"
);
assert!(
body.contains(r#"CMD ["myapp"]"#),
"CMD must name the generated binary, got: {body}"
);
assert!(
body.contains("rust:1-bookworm") && body.contains("debian:bookworm-slim"),
"builder and runtime must share a Debian release: {body}"
);
}
#[test]
fn dockerignore_excludes_target_and_secrets() {
let body = templates::DOCKERIGNORE;
for needle in ["target/", ".env", "*.db", ".git/"] {
assert!(
body.contains(needle),
"`.dockerignore` must exclude `{needle}`, got: {body}"
);
}
assert!(
body.contains("!.env.example"),
"`.env.example` is the committed template and must survive the `.env` \
exclusion, got: {body}"
);
}
#[test]
fn docker_compose_bundles_rust_service_with_cargo_watch() {
let body = templates::docker_compose("myapp", Backend::Postgres);
assert!(body.contains("image: postgres:"), "{body}");
assert!(body.contains("POSTGRES_DB: myapp_dev"), "{body}");
assert!(body.contains("rust:"), "rust service block missing: {body}");
assert!(
body.contains("cargo watch -x run"),
"rust service must run cargo-watch, got: {body}"
);
assert!(
body.contains("dockerfile: Dockerfile.dev"),
"rust service must build from Dockerfile.dev, got: {body}"
);
for vol in ["cargo-target", "cargo-registry", "cargo-git"] {
assert!(
body.contains(vol),
"expected cargo cache volume `{vol}` in compose, got: {body}"
);
}
assert!(
body.contains("depends_on:"),
"rust service must depend on postgres being healthy, got: {body}"
);
}
#[test]
fn every_main_template_mounts_with_welcome() {
for template in [Template::Api, Template::Fullstack, Template::Tenant] {
let body = templates::main_rs(template, "my_app");
assert!(
body.contains(".with_welcome()"),
"template {template:?} src/main.rs should chain `.with_welcome()` \
— the scaffolded entrypoint, got:\n{body}"
);
}
}
fn parse(argv: &[&str]) -> Result<NewArgs, String> {
let owned: Vec<String> = argv.iter().map(|s| (*s).to_owned()).collect();
parse_new_args(&owned, false)
}
#[test]
fn backend_picks_the_default_feature_only() {
for (flag, feature) in [
("postgres", "postgres"),
("sqlite", "sqlite"),
("mysql", "mysql"),
("pg", "postgres"),
("mariadb", "mysql"),
] {
let args = parse(&["app", "--backend", flag]).expect(flag);
let toml = templates::cargo_toml("app", args.template, args.backend, &[], None);
assert!(
toml.contains(&format!(r#"default = ["{feature}"]"#)),
"--backend {flag} should default to {feature}:\n{toml}"
);
for other in ["postgres", "sqlite", "mysql"] {
assert!(
toml.contains(&format!(r#"{other} = ["rustango/{other}"]"#)),
"the `{other}` forward must survive --backend {flag}:\n{toml}"
);
}
}
}
#[test]
fn backend_accepts_both_flag_forms() {
for argv in [
vec!["app", "--backend", "sqlite"],
vec!["app", "--backend=sqlite"],
vec!["app", "-b", "sqlite"],
vec!["app", "-b=sqlite"],
] {
let args = parse(&argv).unwrap_or_else(|e| panic!("{argv:?}: {e}"));
assert_eq!(args.backend, Backend::Sqlite, "{argv:?}");
}
assert!(parse(&["app", "--backend", "oracle"]).is_err());
}
#[test]
fn features_accumulate_without_duplicates() {
for argv in [
vec!["app", "--features", "csrf,sso"],
vec!["app", "--features=csrf,sso"],
vec!["app", "--features", "csrf sso"],
vec!["app", "-F", "csrf", "-F", "sso"],
vec!["app", "--features", "csrf,sso,csrf"],
] {
let args = parse(&argv).unwrap_or_else(|e| panic!("{argv:?}: {e}"));
assert_eq!(args.features, vec!["csrf", "sso"], "{argv:?}");
}
}
#[test]
fn features_extend_the_template_list() {
let args = parse(&["app", "--template", "tenant", "--features", "csrf"]).expect("parse");
let dep = args.template.rustango_dep(None, &args.features);
assert!(
dep.contains(r#"features = ["batteries", "tenancy", "csrf"]"#),
"extras must append to the template's own: {dep}"
);
}
#[test]
fn features_rejects_backends_and_points_at_the_flag() {
for backend in ["postgres", "sqlite", "mysql"] {
let err = parse(&["app", "--features", backend]).expect_err(backend);
assert!(
err.contains(&format!("--backend {backend}")),
"should redirect to --backend: {err}"
);
}
}
#[test]
fn features_rejects_unknown_names() {
let err = parse(&["app", "--features", "cache-redis,nosuchthing"]).expect_err("unknown");
assert!(err.contains("nosuchthing"), "{err}");
assert!(
err.contains("cache-redis"),
"should list what is valid: {err}"
);
assert!(parse(&["app", "--features", "batteries"]).is_ok());
}
#[test]
fn names_cargo_forbids_as_a_binary_target_are_refused() {
for n in ["build", "deps", "examples", "incremental"] {
let err = validate_name(n).expect_err(n);
assert!(err.contains(n), "should name it: {err}");
assert!(
err.contains("binary target"),
"should say why cargo objects: {err}"
);
}
for n in ["myblog", "test", "std", "core", "crate_thing"] {
validate_name(n).unwrap_or_else(|e| panic!("{n} should be allowed: {e}"));
}
}
#[test]
fn the_env_template_ships_no_live_session_secret() {
let env = templates::env_example("app", Backend::Postgres);
for line in env.lines() {
let t = line.trim();
assert!(
!(t.starts_with("RUSTANGO_SESSION_SECRET=")
&& t.len() > "RUSTANGO_SESSION_SECRET=".len()),
"an uncommented secret with a value would be silently discarded: {t}"
);
}
assert!(
env.contains("openssl rand -base64 32"),
"it should still say how to make a real one:\n{env}"
);
}
#[test]
fn backend_reaches_env_compose_and_settings() {
let name = "app";
for (backend, needle) in [
(Backend::Postgres, "postgres://"),
(Backend::Mysql, "mysql://"),
(Backend::Sqlite, "sqlite://"),
] {
let env = templates::env_example(name, backend);
assert!(env.contains(needle), "{backend:?} .env.example: {env}");
let dev = templates::config_dev_settings_toml(name, backend);
assert!(dev.contains(needle), "{backend:?} dev tier: {dev}");
}
let lite = templates::docker_compose(name, Backend::Sqlite);
assert!(!lite.contains("image: "), "no db image for sqlite:\n{lite}");
assert!(
!lite.contains("depends_on:"),
"nothing to depend on:\n{lite}"
);
assert!(lite.contains("rust:"), "the dev container stays:\n{lite}");
let my = templates::docker_compose(name, Backend::Mysql);
assert!(my.contains("image: mysql:8"), "{my}");
assert!(my.contains("MYSQL_DATABASE: app_dev"), "{my}");
assert!(my.contains("depends_on:"), "{my}");
}
#[test]
fn env_example_defaults_to_docker_friendly_values() {
let body = templates::env_example("myapp", Backend::Postgres);
assert!(
body.contains("@postgres:5432/"),
"DATABASE_URL host must default to `postgres` (compose service name), \
got: {body}"
);
assert!(
body.contains("RUSTANGO_BIND=0.0.0.0:8080"),
"bind must default to 0.0.0.0 so the container's exposed port is \
reachable from the host, got: {body}"
);
}
}