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 + tenant admin");
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 {
const DEFAULT: Self = Self::Postgres;
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"),
("http2", "serve cleartext HTTP/2 (h2c) next to HTTP/1"),
("testkit", "test-only schema builders and model factories"),
("test_utils", "test-only constructors for downstream crates"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Template {
Api,
Fullstack,
Tenant,
}
impl Template {
const DEFAULT: Self = Self::Fullstack;
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) => {
let p = templates::toml_string(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>,
}
#[derive(Debug)]
struct NewFlags {
name: String,
template: Option<Template>,
backend: Option<Backend>,
features: Vec<String>,
rustango_path: Option<String>,
interactive: bool,
}
impl NewFlags {
fn resolve(self) -> NewArgs {
NewArgs {
name: self.name,
template: self.template.unwrap_or(Template::DEFAULT),
backend: self.backend.unwrap_or(Backend::DEFAULT),
features: self.features,
rustango_path: self.rustango_path,
}
}
}
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 flags = parse_new_flags(args, asked || bare)?;
let parsed = if flags.interactive {
let named = !flags.name.is_empty();
wizard::run(flags, named)?
} else {
flags.resolve()
};
validate_features(&parsed.features, parsed.template)?;
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(())
}
#[cfg(test)]
fn parse_new_args(args: &[String], interactive: bool) -> Result<NewArgs, String> {
let args = parse_new_flags(args, interactive)?.resolve();
validate_features(&args.features, args.template)?;
Ok(args)
}
fn parse_new_flags(args: &[String], interactive: bool) -> Result<NewFlags, String> {
let mut name: Option<String> = None;
let mut template: Option<Template> = None;
let mut backend: Option<Backend> = None;
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 = Some(Backend::parse(v)?);
}
_ if arg.starts_with("--backend=") => {
backend = Some(Backend::parse(&arg["--backend=".len()..])?);
}
_ if arg.starts_with("-b=") => {
backend = Some(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 = Some(Template::parse(v)?);
}
_ if arg.starts_with("--template=") => {
template = Some(Template::parse(&arg["--template=".len()..])?);
}
_ if arg.starts_with("-t=") => {
template = Some(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
});
if template.is_some() || !interactive {
validate_features(&features, template.unwrap_or(Template::DEFAULT))?;
}
Ok(NewFlags {
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_-]*"
));
}
let ident = name.replace('-', "_");
if RESERVED_IDENTS.contains(&ident.as_str()) {
return Err(format!(
"`{name}` cannot be a project name — `{ident}` is a Rust keyword or a \
built-in crate name, so the generated code would not compile"
));
}
if let Some(dep) = templates::dependency_names().find(|d| d.replace('-', "_") == ident) {
return Err(format!(
"`{name}` cannot be a project name — the generated project depends on \
`{dep}`, and a crate cannot share a name with its own dependency"
));
}
Ok(())
}
const RESERVED_IDENTS: &[&str] = &[
"abstract",
"alloc",
"as",
"async",
"await",
"become",
"box",
"break",
"const",
"continue",
"core",
"crate",
"do",
"dyn",
"else",
"enum",
"extern",
"false",
"final",
"fn",
"for",
"gen",
"if",
"impl",
"in",
"let",
"loop",
"macro",
"match",
"mod",
"move",
"mut",
"override",
"priv",
"proc_macro",
"pub",
"ref",
"return",
"self",
"static",
"std",
"struct",
"super",
"trait",
"true",
"try",
"type",
"typeof",
"unsafe",
"unsized",
"use",
"virtual",
"where",
"while",
"yield",
];
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),
)?;
write(root, "migrations/.gitkeep", "")?;
write(root, "src/lib.rs", &templates::lib_rs(name))?;
write(
root,
"src/main.rs",
&templates::main_rs_for(template, name, &args.features),
)?;
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))?;
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 templates_with_config_files_load_them() {
for template in [Template::Fullstack, Template::Tenant] {
let main = templates::main_rs(template, "demo");
assert!(
main.contains(".with_settings_from_env()")
|| main.contains(".with_loaded_settings(loaded)"),
"{template:?} main.rs does not load its settings"
);
}
let dev = templates::config_dev_settings_toml("demo", Backend::Postgres);
assert!(dev.contains("secure_cookies = false"), "{dev}");
let image = templates::dockerfile_prod("demo");
assert!(image.contains("ENV RUSTANGO_ENV=prod"), "{image}");
}
#[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("tagline = \"dev\""),
"dev tier should be visually distinguishable; got: {dev}"
);
assert!(
default_body.contains("# logo_url") && default_body.contains("# logo_path"),
"default.toml [brand] example names both logo keys; got: {default_body}"
);
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}"
);
assert!(
staging.contains("tagline = \"staging\""),
"staging tier tagline; 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 example_configs_match_the_templates() {
let examples =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../rustango/examples");
let mut checked = 0;
let mut drifted = Vec::new();
for entry in std::fs::read_dir(&examples).expect("read examples/") {
let root = entry.expect("examples/ entry").path();
let name = root.file_name().unwrap().to_str().unwrap().to_owned();
if name == "cookbook_blog" || !root.join("config").is_dir() {
continue;
}
checked += 1;
let rendered = |backend| {
[
(".env.example", templates::env_example(&name, backend)),
(
"config/default.toml",
templates::config_default_toml(&name, backend),
),
(
"config/dev_settings.toml",
templates::config_dev_settings_toml(&name, backend),
),
(
"config/staging_settings.toml",
templates::config_staging_settings_toml(&name, backend),
),
]
};
let mismatches = |backend| -> Vec<&str> {
rendered(backend)
.into_iter()
.filter(|(file, want)| {
std::fs::read_to_string(root.join(file)).ok().as_ref() != Some(want)
})
.map(|(file, _)| file)
.collect()
};
let closest = [Backend::Postgres, Backend::Sqlite, Backend::Mysql]
.into_iter()
.map(mismatches)
.min_by_key(Vec::len)
.unwrap();
if !closest.is_empty() {
drifted.push(format!("{name}: {closest:?}"));
}
}
assert!(checked >= 3, "found only {checked} scaffolded examples");
assert!(
drifted.is_empty(),
"drifted from the scaffolder: {drifted:?}"
);
}
#[test]
fn new_projects_turn_error_pages_on() {
for backend in [Backend::Postgres, Backend::Sqlite, Backend::Mysql] {
let body = templates::config_default_toml("acme", backend);
let tree: toml::Table = toml::from_str(&body).expect("default.toml parses");
assert_eq!(
tree.get("errors")
.and_then(|e| e.get("enabled"))
.and_then(toml::Value::as_bool),
Some(true),
"{body}"
);
}
}
#[test]
fn config_templates_have_no_root_keys() {
for backend in [Backend::Postgres, Backend::Sqlite, Backend::Mysql] {
for (file, body) in [
(
"default.toml",
templates::config_default_toml("acme", backend),
),
("dev", templates::config_dev_settings_toml("acme", backend)),
(
"staging",
templates::config_staging_settings_toml("acme", backend),
),
("prod", templates::config_prod_settings_toml("acme")),
] {
let tree: toml::Table = toml::from_str(&body).expect("rendered tier parses");
let loose: Vec<_> = tree
.iter()
.filter(|(_, v)| !v.is_table())
.map(|(k, _)| k)
.collect();
assert!(loose.is_empty(), "{backend:?} {file}: root keys {loose:?}");
}
}
}
#[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 fullstack_mounts_a_gated_driver_neutral_admin() {
let urls = templates::urls_rs(Template::Fullstack);
let main = templates::main_rs(Template::Fullstack, "demo");
assert!(
urls.contains("pub fn admin_router(pool: Pool, settings: &Settings)"),
"{urls}"
);
assert!(urls.contains(".with_session_auth("), "{urls}");
assert!(
urls.contains("admin::Builder::from_settings(pool, settings)"),
"{urls}"
);
assert!(
urls.contains("admin::Builder::settings_prefix(settings)"),
"{urls}"
);
assert!(
main.contains(".nest_with(urls::admin_prefix(&settings)"),
"{main}"
);
assert_eq!(
main.matches("Settings::load_from_env()").count(),
1,
"{main}"
);
assert!(!main.contains("with_settings_from_env"), "{main}");
assert!(!main.contains("load_from_env()?"), "{main}");
assert!(main.contains(".with_loaded_settings(loaded)"), "{main}");
assert!(urls.contains("load_session_secret_for_tier("), "{urls}");
assert!(!urls.contains("from_env_or_random"), "{urls}");
assert!(!main.contains("DATABASE_URL"), "{main}");
for body in [&urls, &main] {
for driver in ["PgPool", "SqlitePool", "MySqlPool"] {
assert!(!body.contains(driver), "names {driver}: {body}");
}
}
}
fn holds_a_file(path: &Path) -> bool {
if path.is_file() {
return true;
}
fs::read_dir(path)
.into_iter()
.flatten()
.filter_map(Result::ok)
.any(|e| holds_a_file(&e.path()))
}
#[test]
fn image_ships_system_migrations_on_every_template() {
for template in ["api", "fullstack", "tenant"] {
let root = std::env::temp_dir().join(format!(
"cargo_rustango_image_{template}_{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
let args = parse(&["demo", "--template", template]).expect("args");
write_project(&root, &args).expect("scaffold");
let image = fs::read_to_string(root.join("Dockerfile")).expect("Dockerfile");
let sources: Vec<&str> = image
.lines()
.filter_map(|l| l.strip_prefix("COPY "))
.filter(|l| !l.starts_with("--from") && !l.starts_with(". "))
.filter_map(|l| l.split_whitespace().next())
.collect();
for src in &sources {
assert!(
holds_a_file(&root.join(src)),
"{template}: COPY {src} is missing from a clone"
);
}
assert!(
sources.contains(&"system"),
"{template}: the image drops system/migrations: {sources:?}"
);
let _ = fs::remove_dir_all(&root);
}
}
#[test]
fn every_template_mounts_the_dockerfile_health_path() {
assert!(templates::dockerfile_prod("myapp").contains("8080/health "));
for template in [Template::Api, Template::Fullstack, Template::Tenant] {
let main = templates::main_rs(template, "myapp");
assert!(main.contains(".with_health()"), "{template:?}: {main}");
}
}
#[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 generated_examples_carry_the_current_ignore_files() {
let examples =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../rustango/examples");
let mut checked = 0;
for entry in std::fs::read_dir(&examples).expect("read examples") {
let dir = entry.expect("entry").path();
let readme = std::fs::read_to_string(dir.join("README.md")).unwrap_or_default();
if !readme.contains("Generated with `cargo rustango new") {
continue;
}
for (file, want) in [
(".gitignore", templates::GITIGNORE),
(".dockerignore", templates::DOCKERIGNORE),
] {
let got = std::fs::read_to_string(dir.join(file)).unwrap_or_default();
assert_eq!(got, want, "{} is stale", dir.join(file).display());
}
checked += 1;
}
assert!(checked > 0, "no generated example found");
}
#[test]
fn gitignore_excludes_env_variants_but_keeps_the_example() {
let lines: Vec<&str> = templates::GITIGNORE.lines().collect();
for needle in ["/.env", "/.env.*", "!/.env.example"] {
assert!(
lines.contains(&needle),
"missing `{needle}`:\n{}",
templates::GITIGNORE
);
}
}
#[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 no_main_template_mounts_welcome_over_its_own_root() {
for template in [Template::Api, Template::Fullstack, Template::Tenant] {
let main = templates::main_rs(template, "my_app");
let routes_root = templates::urls_rs(template).contains(".route(\"/\",");
assert!(
!(routes_root && main.contains(".with_welcome()")),
"template {template:?} routes `/` and also chains `.with_welcome()`"
);
}
}
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_wait_for_the_wizards_template() {
let argv: Vec<String> = ["shop", "-i", "--features", "manage"]
.iter()
.map(|s| (*s).to_owned())
.collect();
let mut flags = parse_new_flags(&argv, true).expect("not checked yet");
flags.template = Some(Template::Api);
let args = flags.resolve();
validate_features(&args.features, args.template).expect("api has manage");
}
#[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", "crate_thing", "my-type"] {
validate_name(n).unwrap_or_else(|e| panic!("{n} should be allowed: {e}"));
}
}
#[test]
fn keywords_and_builtin_crate_names_are_refused() {
for n in [
"type", "match", "self", "super", "crate", "std", "core", "async",
] {
let err = validate_name(n).expect_err(n);
assert!(err.contains("keyword"), "{n}: {err}");
}
}
#[test]
fn rustango_path_round_trips_through_cargo_toml() {
for path in [
r"..\a\b",
r"C:\Users\dev\rustango",
r#"dir "q"/x"#,
"tab\there",
"new\nline",
"del\u{7f}ete",
] {
let toml =
templates::cargo_toml("app", Template::Api, Backend::Sqlite, &[], Some(path));
let doc: toml::Table = toml::from_str(&toml)
.unwrap_or_else(|e| panic!("{path:?} broke Cargo.toml: {e}\n{toml}"));
assert_eq!(doc["dependencies"]["rustango"]["path"].as_str(), Some(path));
}
}
#[test]
fn names_of_template_dependencies_are_refused() {
let jobs = vec!["jobs".to_owned()];
for template in [Template::Api, Template::Fullstack, Template::Tenant] {
let toml = templates::cargo_toml("app", template, Backend::Postgres, &jobs, None);
let doc: toml::Table = toml::from_str(&toml).expect("Cargo.toml parses");
for table in ["dependencies", "dev-dependencies"] {
for dep in doc[table].as_table().expect(table).keys() {
for n in [dep.clone(), dep.replace('-', "_")] {
let err = validate_name(&n).expect_err(&n);
assert!(err.contains("depends on"), "{n}: {err}");
}
}
}
}
}
#[test]
fn compose_binds_the_database_to_loopback() {
for (backend, port) in [(Backend::Postgres, "5432"), (Backend::Mysql, "3306")] {
let compose = templates::docker_compose("app", backend);
let published: Vec<&str> = compose
.lines()
.map(str::trim)
.filter(|l| l.starts_with("- \"") && l.ends_with(&format!(":{port}\"")))
.collect();
assert!(!published.is_empty(), "{backend:?}:\n{compose}");
for line in published {
assert!(line.starts_with("- \"127.0.0.1:"), "{backend:?}: {line}");
}
}
}
#[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("--collation-server=utf8mb4_0900_as_cs"), "{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}"
);
}
#[test]
fn only_the_container_binds_all_interfaces() {
for backend in [Backend::Postgres, Backend::Mysql, Backend::Sqlite] {
let env = templates::env_example("myapp", backend);
assert!(
!env.lines().any(|l| l.starts_with("RUSTANGO_BIND=")),
"{env}"
);
let compose = templates::docker_compose("myapp", backend);
assert!(compose.contains("RUSTANGO_BIND: 0.0.0.0:8080"), "{compose}");
assert!(compose.contains("\"127.0.0.1:8080:8080\""), "{compose}");
assert!(!compose.contains("- \"8080:8080\""), "{compose}");
}
}
#[test]
fn fullstack_admin_reads_settings() {
let urls = templates::urls_rs(Template::Fullstack);
assert!(
urls.contains("admin::Builder::from_settings(pool, settings)"),
"{urls}"
);
let main = templates::main_rs(Template::Fullstack, "demo");
assert!(main.contains(".with_loaded_settings(loaded)"), "{main}");
}
#[test]
fn main_rs_wires_csrf_and_shows_trusted_proxies() {
for template in [Template::Api, Template::Fullstack, Template::Tenant] {
let plain = templates::main_rs_for(template, "myapp", &[]);
assert!(!plain.contains(".with_csrf()"), "{template:?}: {plain}");
assert!(
plain.contains(".with_trusted_proxies("),
"{template:?}: {plain}"
);
let csrf = templates::main_rs_for(template, "myapp", &["csrf".to_owned()]);
assert!(
csrf.contains("\n .with_csrf()"),
"{template:?}: {csrf}"
);
}
}
#[test]
fn readme_runs_makemigrations_before_migrate() {
for backend in [Backend::Postgres, Backend::Sqlite] {
let readme = templates::readme("myapp", Template::Fullstack, backend);
for line in readme
.lines()
.filter(|l| l.contains("cargo run -- migrate"))
{
let at = readme.find(line).unwrap();
assert!(
readme[..at]
.lines()
.last()
.unwrap_or("")
.contains("makemigrations"),
"`{line}` must follow makemigrations:\n{readme}"
);
}
}
}
}