use std::path::{Path, PathBuf};
use super::error::MigrateError;
#[derive(Debug, Clone, Default)]
pub struct StartAppOptions {
pub app_name: String,
pub manage_bin: Option<&'static str>,
pub base_dir: Option<PathBuf>,
}
#[derive(Debug, Default)]
pub struct StartAppReport {
pub written: Vec<String>,
pub skipped: Vec<String>,
pub patched: Vec<String>,
pub manual_steps: 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 base_dir = opts
.base_dir
.clone()
.unwrap_or_else(|| PathBuf::from("src"));
let base_label = base_dir.display().to_string();
let app_dir = project_root.join(&base_dir).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); 5] = [
("mod.rs", mod_body),
("models.rs", MODELS_TEMPLATE.into()),
("views.rs", VIEWS_TEMPLATE.into()),
("urls.rs", URLS_TEMPLATE.into()),
("tests.rs", TESTS_TEMPLATE.into()),
];
for (filename, body) in entries {
let path = app_dir.join(filename);
let rel = format!("{base_label}/{}/{}", opts.app_name, filename);
write_or_skip(&path, &rel, &body, &mut report)?;
}
let main_path = project_root.join(&base_dir).join("main.rs");
let lib_path = project_root.join(&base_dir).join("lib.rs");
let entry_path = if main_path.exists() {
Some(main_path)
} else if lib_path.exists() {
Some(lib_path)
} else {
None
};
if let Some(path) = entry_path {
let rel = path
.strip_prefix(project_root)
.unwrap_or(&path)
.display()
.to_string();
match try_register_app_in_entry(&path, &opts.app_name)? {
EntryEditOutcome::Patched => report.patched.push(rel),
EntryEditOutcome::AlreadyRegistered => {} EntryEditOutcome::CouldNotFindAnchor => {
report.manual_steps.push(format!(
"{rel}: add `mod {};` near the other `mod` declarations",
opts.app_name
));
}
}
}
let urls_path = project_root.join(&base_dir).join("urls.rs");
if urls_path.exists() {
let rel = urls_path
.strip_prefix(project_root)
.unwrap_or(&urls_path)
.display()
.to_string();
match try_merge_app_into_urls(&urls_path, &opts.app_name)? {
EntryEditOutcome::Patched => report.patched.push(rel),
EntryEditOutcome::AlreadyRegistered => {}
EntryEditOutcome::CouldNotFindAnchor => {
report.manual_steps.push(format!(
"{rel}: add `.merge(crate::{}::urls::api())` to your aggregator router",
opts.app_name
));
}
}
}
if let Some(template) = opts.manage_bin {
let bin_dir = project_root.join(&base_dir).join("bin");
if !bin_dir.exists() {
std::fs::create_dir_all(&bin_dir)?;
}
let path = bin_dir.join("manage.rs");
let rel = format!("{base_label}/bin/manage.rs");
write_or_skip(&path, &rel, 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(())
}
enum EntryEditOutcome {
Patched,
AlreadyRegistered,
CouldNotFindAnchor,
}
fn try_register_app_in_entry(
path: &Path,
app_name: &str,
) -> Result<EntryEditOutcome, MigrateError> {
let body = std::fs::read_to_string(path)?;
let needle = format!("mod {app_name};");
let needle_pub = format!("pub mod {app_name};");
if body.contains(&needle) || body.contains(&needle_pub) {
return Ok(EntryEditOutcome::AlreadyRegistered);
}
let lines: Vec<&str> = body.lines().collect();
let mod_anchor = lines
.iter()
.rposition(|l| l.trim_start().starts_with("mod ") && l.trim_end().ends_with(';'));
let insert_at = if let Some(idx) = mod_anchor {
idx + 1
} else {
let mut i = 0;
while i < lines.len() && lines[i].trim_start().starts_with("//!") {
i += 1;
}
if i == 0 {
return Ok(EntryEditOutcome::CouldNotFindAnchor);
}
if lines.get(i).is_some_and(|l| l.trim().is_empty()) {
i + 1
} else {
i
}
};
let mut out = String::with_capacity(body.len() + needle.len() + 1);
for (i, line) in lines.iter().enumerate() {
if i == insert_at {
out.push_str(&needle);
out.push('\n');
}
out.push_str(line);
out.push('\n');
}
if insert_at >= lines.len() {
out.push_str(&needle);
out.push('\n');
}
std::fs::write(path, out)?;
Ok(EntryEditOutcome::Patched)
}
fn try_merge_app_into_urls(
path: &Path,
app_name: &str,
) -> Result<EntryEditOutcome, MigrateError> {
let body = std::fs::read_to_string(path)?;
let merge_call = format!(".merge(crate::{app_name}::urls::api())");
if body.contains(&merge_call) {
return Ok(EntryEditOutcome::AlreadyRegistered);
}
let lines: Vec<&str> = body.lines().collect();
let anchor = lines.iter().rposition(|l| l.contains("Router::new()"));
let Some(idx) = anchor else {
return Ok(EntryEditOutcome::CouldNotFindAnchor);
};
let indent: String = lines[idx]
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let inserted_indent = format!("{indent} ");
let inserted = format!("{inserted_indent}{merge_call}");
let mut out = String::with_capacity(body.len() + inserted.len() + 1);
for (i, line) in lines.iter().enumerate() {
out.push_str(line);
out.push('\n');
if i == idx {
out.push_str(&inserted);
out.push('\n');
}
}
std::fs::write(path, out)?;
Ok(EntryEditOutcome::Patched)
}
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\
\n\
#[cfg(test)]\n\
mod tests;\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.
//!
//! `pub fn api() -> Router<()>` — every route this app exposes.
//! The project-root `src/urls.rs` aggregator calls
//! `.merge(crate::<this_app>::urls::api())` so these routes show up
//! at the project's root. Handlers can take
//! `rustango::extractors::Tenant` (in tenancy projects) or extract
//! state via axum's normal `State<...>` mechanism.
use axum::routing::get;
use axum::Router;
use super::views;
pub fn api() -> Router<()> {
Router::new()
.route(\"/\", get(views::index))
.route(\"/healthz\", get(views::healthz))
}
";
const TESTS_TEMPLATE: &str = "//! App-level integration tests.
//!
//! Run with `cargo test`. Uses `rustango::test_client::TestClient` to
//! exercise the app's router in-process — no network, no real socket.
#[cfg(test)]
mod tests {
use rustango::test_client::TestClient;
use super::urls::api;
#[tokio::test]
async fn index_returns_200() {
let client = TestClient::new(api());
let response = client.get(\"/\").send().await;
assert_eq!(response.status, 200);
}
#[tokio::test]
async fn healthz_returns_ok() {
let client = TestClient::new(api());
let response = client.get(\"/healthz\").send().await;
assert_eq!(response.status, 200);
assert_eq!(response.text(), \"ok\");
}
}
";
pub const SINGLE_TENANT_MANAGE_BIN: &str =
"//! Generated by `manage startapp --with-manage-bin`. Edit freely.
//!
//! UX: `cargo run -- migrate`,
//! `cargo run -- 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(),
..Default::default()
},
)
.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",
"src/blog/tests.rs",
]
);
for f in ["mod.rs", "models.rs", "views.rs", "urls.rs", "tests.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(),
..Default::default()
},
)
.unwrap();
let second = startapp(
&root,
&StartAppOptions {
app_name: "blog".into(),
..Default::default()
},
)
.unwrap();
assert!(second.written.is_empty());
assert_eq!(second.skipped.len(), 5);
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),
..Default::default()
},
)
.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(),
..Default::default()
},
)
.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;"));
assert!(body.contains("#[cfg(test)]"));
assert!(body.contains("mod tests;"));
}
#[test]
fn auto_mount_inserts_mod_after_existing_mods() {
let root = fresh_root("automount_main");
let src = root.join("src");
std::fs::create_dir_all(&src).unwrap();
let main = src.join("main.rs");
std::fs::write(
&main,
"//! example main.rs\n\
\n\
mod blog;\n\
mod views;\n\
\n\
fn main() {}\n",
)
.unwrap();
let report = startapp(
&root,
&StartAppOptions {
app_name: "shop".into(),
..Default::default()
},
)
.unwrap();
let body = std::fs::read_to_string(&main).unwrap();
assert!(body.contains("mod shop;"), "body was {body}");
assert!(report.patched.iter().any(|p| p.contains("main.rs")));
let report2 = startapp(
&root,
&StartAppOptions {
app_name: "shop".into(),
..Default::default()
},
)
.unwrap();
assert_eq!(report2.patched.len(), 0, "second run should not patch");
let body2 = std::fs::read_to_string(&main).unwrap();
assert_eq!(body2.matches("mod shop;").count(), 1);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn auto_mount_appends_merge_call_to_urls_router() {
let root = fresh_root("automount_urls");
let src = root.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("main.rs"), "fn main() {}\n").unwrap();
std::fs::write(
src.join("urls.rs"),
"use axum::Router;\n\
pub fn api() -> Router<()> {\n \
Router::new()\n\
}\n",
)
.unwrap();
let report = startapp(
&root,
&StartAppOptions {
app_name: "shop".into(),
..Default::default()
},
)
.unwrap();
let body = std::fs::read_to_string(src.join("urls.rs")).unwrap();
assert!(
body.contains(".merge(crate::shop::urls::api())"),
"urls.rs was: {body}"
);
assert!(report.patched.iter().any(|p| p.contains("urls.rs")));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn auto_mount_emits_manual_step_when_no_anchor() {
let root = fresh_root("automount_bail");
let src = root.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("urls.rs"),
"// hand-rolled aggregator with no recognisable anchor\npub fn api() {}\n",
)
.unwrap();
std::fs::write(src.join("main.rs"), "fn main() {}\n").unwrap();
let report = startapp(
&root,
&StartAppOptions {
app_name: "shop".into(),
..Default::default()
},
)
.unwrap();
assert!(
report.manual_steps.iter().any(|h| h.contains("urls.rs")),
"expected a manual-step hint for urls.rs, got: {:?}",
report.manual_steps
);
let _ = std::fs::remove_dir_all(&root);
}
}