use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
mod templates;
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 — Django-style project scaffolder for rustango");
println!();
println!("USAGE:");
println!(" cargo rustango new <name> [--template api|fullstack|tenant]");
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!("EXAMPLES:");
println!(" cargo rustango new myblog");
println!(" cargo rustango new api_demo --template api");
println!(" cargo rustango new shop --template tenant");
}
#[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`"
)),
}
}
fn rustango_features(self) -> String {
let v = mm_version();
match self {
Self::Api => format!(
r#"{{ version = "{v}", default-features = false, features = ["postgres", "manage"] }}"#
),
Self::Fullstack => format!(r#""{v}""#),
Self::Tenant => format!(r#"{{ version = "{v}", features = ["tenancy"] }}"#),
}
}
}
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())
}
struct NewArgs {
name: String,
template: Template,
}
fn cmd_new(args: &[String]) -> Result<(), String> {
let parsed = parse_new_args(args)?;
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: {:?}) at {}",
parsed.name,
parsed.template,
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");
println!(" docker compose up -d");
println!(" cargo run -- migrate # apply pending migrations");
println!(" cargo run # boot the HTTP server");
println!(" cargo run -- --help # full verb list");
Ok(())
}
fn parse_new_args(args: &[String]) -> Result<NewArgs, String> {
let mut name: Option<String> = None;
let mut template = Template::Fullstack;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--template" | "-t" => {
let v = iter
.next()
.ok_or_else(|| "--template requires a value".to_owned())?;
template = Template::parse(v)?;
}
"--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 =
name.ok_or_else(|| "missing project name (e.g. `cargo rustango new myapp`)".to_owned())?;
Ok(NewArgs { name, template })
}
fn validate_name(name: &str) -> Result<(), String> {
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;
write(root, "Cargo.toml", &templates::cargo_toml(name, template))?;
write(root, ".env.example", &templates::env_example(name))?;
write(root, ".gitignore", templates::GITIGNORE)?;
write(root, "rust-toolchain.toml", templates::RUST_TOOLCHAIN)?;
write(root, "docker-compose.yml", &templates::docker_compose(name))?;
write(root, "Dockerfile", templates::dockerfile())?;
write(root, "README.md", &templates::readme(name, template))?;
write(
root,
"config/default.toml",
&templates::config_default_toml(name),
)?;
write(
root,
"config/dev_settings.toml",
&templates::config_dev_settings_toml(name),
)?;
write(
root,
"config/staging_settings.toml",
&templates::config_staging_settings_toml(name),
)?;
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/main.rs", templates::main_rs(template))?;
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,
"migrations/0001_rustango_registry_initial.json",
templates::BOOTSTRAP_REGISTRY_MIGRATION,
)?;
write(
root,
"migrations/0001_rustango_tenant_initial.json",
templates::BOOTSTRAP_TENANT_MIGRATION,
)?;
}
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_features();
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);
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);
assert!(
dev.contains("(dev)") || dev.contains("dev_settings"),
"dev tier should be visually distinguishable; got: {dev}"
);
let staging = templates::config_staging_settings_toml(name);
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_features();
for ver in YANKED {
let needle = format!("\"{ver}\"");
assert!(
!dep.contains(&needle),
"template {template:?} pins yanked rustango v{ver}: `{dep}`"
);
}
}
}
#[test]
fn dockerfile_emits_rust_toolchain_with_cargo_watch() {
let body = templates::dockerfile();
assert!(
body.contains("FROM rust:"),
"Dockerfile must base on a rust image, got `{body}`"
);
assert!(
body.contains("cargo install cargo-watch"),
"Dockerfile must preinstall cargo-watch (powers the docker-compose.yml \
hot-reload command), got `{body}`"
);
assert!(
body.contains("WORKDIR /app"),
"Dockerfile must set WORKDIR /app to match docker-compose.yml's bind \
mount target, got `{body}`"
);
}
#[test]
fn docker_compose_bundles_rust_service_with_cargo_watch() {
let body = templates::docker_compose("myapp");
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("build: ."),
"rust service must build from the project Dockerfile, 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);
assert!(
body.contains(".with_welcome()"),
"template {template:?} src/main.rs should chain `.with_welcome()` \
— the scaffolded entrypoint, got:\n{body}"
);
}
}
#[test]
fn env_example_defaults_to_docker_friendly_values() {
let body = templates::env_example("myapp");
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}"
);
}
}