use std::path::{Path, PathBuf};
use super::error::MigrateError;
#[derive(Debug, Clone)]
pub struct StartAppOptions {
pub app_name: String,
pub manage_bin: Option<&'static str>,
}
#[derive(Debug, Default)]
pub struct StartAppReport {
pub written: Vec<String>,
pub skipped: Vec<String>,
}
pub fn startapp(
project_root: &Path,
opts: &StartAppOptions,
) -> Result<StartAppReport, MigrateError> {
validate_app_name(&opts.app_name)?;
let mut report = StartAppReport::default();
let app_dir = project_root.join("src").join(&opts.app_name);
if !app_dir.exists() {
std::fs::create_dir_all(&app_dir)?;
}
let mod_body = render_mod_template(&opts.app_name);
let entries: [(&str, String); 4] = [
("mod.rs", mod_body),
("models.rs", MODELS_TEMPLATE.into()),
("views.rs", VIEWS_TEMPLATE.into()),
("urls.rs", URLS_TEMPLATE.into()),
];
for (filename, body) in entries {
let path = app_dir.join(filename);
let rel = format!("src/{}/{}", opts.app_name, filename);
write_or_skip(&path, &rel, &body, &mut report)?;
}
if let Some(template) = opts.manage_bin {
let bin_dir = project_root.join("src").join("bin");
if !bin_dir.exists() {
std::fs::create_dir_all(&bin_dir)?;
}
let path = bin_dir.join("manage.rs");
write_or_skip(&path, "src/bin/manage.rs", template, &mut report)?;
}
Ok(report)
}
fn write_or_skip(
path: &PathBuf,
rel: &str,
body: &str,
report: &mut StartAppReport,
) -> Result<(), MigrateError> {
if path.exists() {
report.skipped.push(rel.to_owned());
return Ok(());
}
std::fs::write(path, body)?;
report.written.push(rel.to_owned());
Ok(())
}
fn validate_app_name(name: &str) -> Result<(), MigrateError> {
let bytes = name.as_bytes();
let valid = !bytes.is_empty()
&& (bytes[0].is_ascii_alphabetic() || bytes[0] == b'_')
&& bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'_');
if !valid {
return Err(MigrateError::Validation(format!(
"app name `{name}` is not a valid Rust identifier — \
must match [A-Za-z_][A-Za-z0-9_]*"
)));
}
Ok(())
}
fn render_mod_template(app_name: &str) -> String {
format!(
"//! `{app_name}` — Django-shape app module.\n\
//!\n\
//! Add `mod {app_name};` (or `pub mod {app_name};`) to your\n\
//! `src/main.rs` / `src/lib.rs` so these submodules are\n\
//! pulled into the binary's `inventory` registry.\n\
\n\
pub mod models;\n\
pub mod urls;\n\
pub mod views;\n",
)
}
const MODELS_TEMPLATE: &str = "//! App 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. No per-model registration
//! step.
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,
}
";
const VIEWS_TEMPLATE: &str = "//! App views — request handlers (Django-style \"views\").
//!
//! Each handler is a stateless async fn; `urls.rs` mounts them
//! under their HTTP paths. For pure-CRUD admin needs you don't
//! need any custom views — `rustango::admin::router(pool)` covers
//! that. The handlers below are stubs you can replace.
use axum::response::Html;
/// `GET /` — landing page with a link into the auto-admin.
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 is at <a href=\\\"/admin\\\">/admin</a>.</p>\",
)
}
/// `GET /healthz` — liveness probe.
pub async fn healthz() -> &'static str {
\"ok\"
}
";
const URLS_TEMPLATE: &str = "//! App URL routing.
//!
//! Single function `router(pool) -> Router` that wires every HTTP
//! path the app exposes. The auto-admin mounts under `/admin`;
//! custom views from `views.rs` mount alongside.
use axum::routing::get;
use axum::Router;
use rustango::admin;
use rustango::sql::sqlx::PgPool;
use super::views;
pub fn router(pool: PgPool) -> Router {
let admin = admin::Builder::new(pool.clone()).build();
Router::new()
.route(\"/\", get(views::index))
.route(\"/healthz\", get(views::healthz))
.with_state(pool)
.nest(\"/admin\", admin)
}
";
pub const SINGLE_TENANT_MANAGE_BIN: &str =
"//! Generated by `manage startapp --with-manage-bin`. Edit freely.
//!
//! UX: `cargo run --bin manage -- migrate`,
//! `cargo run --bin manage -- makemigrations`, etc. The dispatcher is
//! defined in `rustango::migrate::manage`; this binary just hands it
//! the pool and argv.
use rustango::sql::sqlx::PgPool;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Pull your models into this binary so `inventory` registers
// them — replace the placeholder line below with whatever fits
// your project layout. For the `manage startapp <name>` shape:
// #[allow(unused_imports)]
// use super::<name>::models::*;
// For a top-level src/models.rs:
// #[allow(unused_imports)]
// use super::models::*;
let pool = PgPool::connect(&std::env::var(\"DATABASE_URL\")?).await?;
let dir: &std::path::Path = \"./migrations\".as_ref();
rustango::migrate::manage::run(&pool, dir, std::env::args().skip(1)).await?;
Ok(())
}
";
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn fresh_root(label: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
let mut p = std::env::temp_dir();
p.push(format!("rustango_scaffold_{label}_{pid}_{n}"));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn writes_app_files_into_src_subdir() {
let root = fresh_root("writes_app");
let report = startapp(
&root,
&StartAppOptions {
app_name: "blog".into(),
manage_bin: None,
},
)
.unwrap();
assert_eq!(report.skipped, Vec::<String>::new());
assert_eq!(
report.written,
vec![
"src/blog/mod.rs",
"src/blog/models.rs",
"src/blog/views.rs",
"src/blog/urls.rs",
]
);
for f in ["mod.rs", "models.rs", "views.rs", "urls.rs"] {
let p = root.join("src").join("blog").join(f);
assert!(p.exists(), "{}", p.display());
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn second_run_skips_existing_files() {
let root = fresh_root("idempotent");
let _ = startapp(
&root,
&StartAppOptions {
app_name: "blog".into(),
manage_bin: None,
},
)
.unwrap();
let second = startapp(
&root,
&StartAppOptions {
app_name: "blog".into(),
manage_bin: None,
},
)
.unwrap();
assert!(second.written.is_empty());
assert_eq!(second.skipped.len(), 4);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn manage_bin_template_writes_src_bin_manage_rs() {
let root = fresh_root("manage_bin");
let report = startapp(
&root,
&StartAppOptions {
app_name: "blog".into(),
manage_bin: Some(SINGLE_TENANT_MANAGE_BIN),
},
)
.unwrap();
assert!(report.written.contains(&"src/bin/manage.rs".to_owned()));
let body = std::fs::read_to_string(root.join("src/bin/manage.rs")).unwrap();
assert!(body.contains("rustango::migrate::manage::run"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn invalid_app_name_is_rejected() {
let root = fresh_root("invalid");
let err = startapp(
&root,
&StartAppOptions {
app_name: "1bad-name".into(),
manage_bin: None,
},
)
.unwrap_err();
assert!(matches!(err, MigrateError::Validation(_)));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rendered_mod_template_pulls_in_three_submodules() {
let body = render_mod_template("shop");
assert!(body.contains("`shop`"));
assert!(body.contains("pub mod models;"));
assert!(body.contains("pub mod urls;"));
assert!(body.contains("pub mod views;"));
}
}