use std::fs;
use std::path::Path;
use crate::style as sty;
const PROJECT_TEMPLATES: &[(&str, &str)] = &[
(
"Cargo.toml",
include_str!("../templates/project/Cargo.toml.tmpl"),
),
(
".env.example",
include_str!("../templates/project/.env.example"),
),
(
".gitignore",
include_str!("../templates/project/.gitignore"),
),
(
"README.md",
include_str!("../templates/project/README.md.tmpl"),
),
(
"src/main.rs",
include_str!("../templates/project/src/main.rs.tmpl"),
),
(
"templates/home.html",
include_str!("../templates/project/templates/home.html"),
),
(
"migrations/.gitkeep",
include_str!("../templates/project/migrations/.gitkeep"),
),
];
const BLOG_OVERRIDES: &[(&str, &str)] = &[(
"src/main.rs",
include_str!("../templates/project_blog/src/main.rs.tmpl"),
)];
const BLOG_EXTRAS: &[(&str, &str)] = &[
(
"src/post.rs",
include_str!("../templates/project_blog/src/post.rs.tmpl"),
),
(
"src/comment.rs",
include_str!("../templates/project_blog/src/comment.rs.tmpl"),
),
(
"migrations/0001_create_posts.sql",
include_str!("../templates/project_blog/migrations/0001_create_posts.sql"),
),
(
"migrations/0002_create_comments.sql",
include_str!("../templates/project_blog/migrations/0002_create_comments.sql"),
),
];
const CLINIC_OVERRIDES: &[(&str, &str)] = &[(
"src/main.rs",
include_str!("../templates/project_clinic/src/main.rs.tmpl"),
)];
const CLINIC_EXTRAS: &[(&str, &str)] = &[
(
"src/patient.rs",
include_str!("../templates/project_clinic/src/patient.rs.tmpl"),
),
(
"src/appointment.rs",
include_str!("../templates/project_clinic/src/appointment.rs.tmpl"),
),
(
"migrations/0001_create_patients.sql",
include_str!("../templates/project_clinic/migrations/0001_create_patients.sql"),
),
(
"migrations/0002_create_appointments.sql",
include_str!("../templates/project_clinic/migrations/0002_create_appointments.sql"),
),
];
type TemplateSet = &'static [(&'static str, &'static str)];
fn preset_layers(preset: &str) -> Option<(TemplateSet, TemplateSet)> {
match preset {
"blog" => Some((BLOG_OVERRIDES, BLOG_EXTRAS)),
"clinic" => Some((CLINIC_OVERRIDES, CLINIC_EXTRAS)),
_ => None,
}
}
fn humanise_name(name: &str) -> String {
name.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(|w| {
let mut cs = w.chars();
match cs.next() {
Some(c) => c.to_uppercase().collect::<String>() + cs.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn type_phrase(project_type: &str) -> &'static str {
match project_type {
"clinic" => "Clinic project initialized.",
"school" => "School management project initialized.",
"inventory" => "Inventory project initialized.",
"blog" => "Blog project initialized.",
_ => "Custom RustIO project initialized.",
}
}
const VALID_PRESETS: &[&str] = &["minimal", "blog", "clinic"];
fn migrate_hint(preset: &str) -> &'static str {
match preset {
"blog" => "creates the posts + comments tables",
"clinic" => "creates the patients + appointments tables (with example rows)",
_ => "no project migrations yet (`rustio-admin startapp <name>` adds the first)",
}
}
pub fn project(name: &str, preset: &str) -> Result<(), String> {
project_in(Path::new("."), name, preset, "custom")
}
pub fn project_with_db(
name: &str,
preset: &str,
db_name: &str,
project_type: &str,
) -> Result<(), String> {
project_with_db_in(Path::new("."), name, preset, db_name, project_type)
}
fn print_created_and_next(
created_line: &str,
heading: &str,
steps: &[(String, String)],
note: Option<&str>,
) {
println!();
println!(" {} {}", sty::check(), created_line);
println!();
println!(" {}", sty::divider());
println!();
println!(" {}", sty::heading(heading));
println!();
for (cmd, annot) in steps {
println!(" {}", sty::command(cmd));
if !annot.is_empty() {
println!(" {annot}");
}
}
if let Some(n) = note {
println!();
println!(" {}", sty::hint(n));
}
println!();
}
fn launch_steps(name: &str, preset: &str, db: Option<&str>) -> Vec<(String, String)> {
let mut steps = vec![(format!("cd {name}"), String::new())];
match db {
Some(db) => steps.push((
format!("createdb {db}"),
sty::hint("create the local database"),
)),
None => steps.push((
"cp .env.example .env".to_string(),
sty::hint("then set DATABASE_URL and run `createdb`"),
)),
}
steps.push((
"rustio-admin migrate apply".to_string(),
sty::hint(migrate_hint(preset)),
));
steps.push((
format!("rustio-admin user create --email admin@{name}.local --role administrator"),
sty::hint("your admin login"),
));
steps.push((
"cargo run".to_string(),
format!(
"{} {}",
sty::hint("→"),
sty::url("http://127.0.0.1:8000/admin")
),
));
steps
}
fn project_in(parent: &Path, name: &str, preset: &str, project_type: &str) -> Result<(), String> {
let written = write_project_files(parent, name, preset, project_type)?;
let created = format!(
"{} {}",
sty::path(&format!("{name}/")),
sty::hint(&format!("· {written} files ({preset} preset)"))
);
print_created_and_next(
&created,
"Next — set up and launch:",
&launch_steps(name, preset, None),
None,
);
Ok(())
}
fn project_with_db_in(
parent: &Path,
name: &str,
preset: &str,
db_name: &str,
project_type: &str,
) -> Result<(), String> {
let written = write_project_files(parent, name, preset, project_type)?;
let project_root = parent.join(name);
write_env_file(&project_root, db_name)?;
let created = format!(
"{} {}",
sty::path(&format!("{name}/")),
sty::hint(&format!(
"· {} files ({preset} preset, incl. .env)",
written + 1
))
);
print_created_and_next(
&created,
"Next — create the database and launch:",
&launch_steps(name, preset, Some(db_name)),
Some("The first `cargo run` takes a few minutes — that's normal for a fresh Rust build."),
);
Ok(())
}
fn write_project_files(
parent: &Path,
name: &str,
preset: &str,
project_type: &str,
) -> Result<usize, String> {
validate_name(name)?;
if !VALID_PRESETS.contains(&preset) {
return Err(format!(
"unknown preset `{preset}`. Valid: {}",
VALID_PRESETS.join(", ")
));
}
let dir = parent.join(name);
let dir = dir.as_path();
if dir.exists() {
return Err(format!(
"`{name}` already exists in the current directory. Pick a fresh name or remove it first."
));
}
let type_phrase = type_phrase(project_type);
let name_title = humanise_name(name);
let mut written = 0usize;
for (rel, body) in PROJECT_TEMPLATES {
let target = dir.join(rel);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let body = body
.replace("{{name}}", name)
.replace("{{name_title}}", &name_title)
.replace("{{type_phrase}}", type_phrase);
fs::write(&target, body).map_err(|e| format!("write {}: {e}", target.display()))?;
written += 1;
}
if let Some((overrides, extras)) = preset_layers(preset) {
for (rel, body) in overrides.iter().chain(extras.iter()) {
let target = dir.join(rel);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let body = body
.replace("{{name}}", name)
.replace("{{name_title}}", &name_title)
.replace("{{type_phrase}}", type_phrase);
fs::write(&target, body).map_err(|e| format!("write {}: {e}", target.display()))?;
if extras.iter().any(|(p, _)| *p == *rel) {
written += 1;
}
}
}
Ok(written)
}
fn write_env_file(project_root: &Path, db_name: &str) -> Result<(), String> {
let path = project_root.join(".env");
let body = format!(
"# Generated by `rustio-admin new` wizard. Local-dev defaults.\n\
# Review and rotate before deploying.\n\
\n\
RUST_LOG=info\n\
\n\
DB_HOST=localhost\n\
DB_PORT=5432\n\
DB_USER=postgres\n\
DB_PASSWORD=postgres\n\
DB_NAME={db_name}\n\
\n\
DATABASE_URL=postgres://${{DB_USER}}:${{DB_PASSWORD}}@${{DB_HOST}}:${{DB_PORT}}/${{DB_NAME}}\n\
\n\
# 32-byte URL-safe-base64 key for R3 MFA + R4 emergency-access.\n\
# Generate with: openssl rand 32 | base64 | tr '+/' '-_' | tr -d '='\n\
RUSTIO_SECRET_KEY=\n"
);
fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))
}
fn validate_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("project name is required".into());
}
if name.starts_with(|c: char| c.is_ascii_digit()) {
return Err("project name may not start with a digit".into());
}
let valid = name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if !valid {
return Err("project name may only contain ASCII letters, digits, '-', and '_'".into());
}
Ok(())
}
const APP_MODEL_TEMPLATE: &str = include_str!("../templates/app/model.rs.tmpl");
const APP_MIGRATION_TEMPLATE: &str = include_str!("../templates/app/migration.sql.tmpl");
const APP_MODEL_WITH_FIELDS_TEMPLATE: &str =
include_str!("../templates/app/model_with_fields.rs.tmpl");
const APP_MIGRATION_WITH_FIELDS_TEMPLATE: &str =
include_str!("../templates/app/migration_with_fields.sql.tmpl");
pub fn app(name: &str, field_args: Vec<String>, no_interactive: bool) -> Result<(), String> {
validate_app_name(name)?;
ensure_in_project_root()?;
let singular = camel_case(name);
let table = crate::app_fields::pluralise_snake(name);
let model_path = Path::new("src").join(format!("{name}.rs"));
if model_path.exists() {
return Err(format!(
"{} already exists; pick a different name or remove the file first.",
model_path.display()
));
}
let next_version = next_migration_version()?;
let migration_path =
Path::new("migrations").join(format!("{next_version:04}_create_{table}.sql"));
fs::create_dir_all("migrations").map_err(|e| format!("mkdir migrations: {e}"))?;
let fields = collect_fields(field_args, no_interactive)?;
let field_count = if fields.is_empty() {
let model_body = APP_MODEL_TEMPLATE
.replace("{{Singular}}", &singular)
.replace("{{name}}", name)
.replace("{{table}}", &table);
let migration_body = APP_MIGRATION_TEMPLATE
.replace("{{name}}", name)
.replace("{{table}}", &table);
fs::write(&model_path, model_body)
.map_err(|e| format!("write {}: {e}", model_path.display()))?;
fs::write(&migration_path, migration_body)
.map_err(|e| format!("write {}: {e}", migration_path.display()))?;
None
} else {
let r = crate::app_fields::render(&fields);
let model_body = APP_MODEL_WITH_FIELDS_TEMPLATE
.replace("{{Singular}}", &singular)
.replace("{{name}}", name)
.replace("{{table}}", &table)
.replace("{{imports}}", &r.imports)
.replace("{{struct_fields}}", &r.struct_fields)
.replace("{{columns_literal}}", &r.columns_literal)
.replace("{{insert_columns_literal}}", &r.insert_columns_literal)
.replace("{{from_row_assignments}}", &r.from_row_assignments)
.replace("{{insert_values_expr}}", &r.insert_values_expr)
.replace("{{list_display_literal}}", &r.list_display_literal)
.replace("{{search_fields_literal}}", &r.search_fields_literal);
let migration_body = APP_MIGRATION_WITH_FIELDS_TEMPLATE
.replace("{{name}}", name)
.replace("{{table}}", &table)
.replace("{{column_decls}}", &r.column_decls_sql);
fs::write(&model_path, model_body)
.map_err(|e| format!("write {}: {e}", model_path.display()))?;
fs::write(&migration_path, migration_body)
.map_err(|e| format!("write {}: {e}", migration_path.display()))?;
Some(fields.len())
};
print_startapp_summary(
&singular,
&model_path,
&migration_path,
&table,
field_count,
name,
);
Ok(())
}
fn print_startapp_summary(
singular: &str,
model_path: &Path,
migration_path: &Path,
table: &str,
field_count: Option<usize>,
name: &str,
) {
use console::style;
let path_style = |p: &str| style(p.to_string()).cyan();
let cmd_style = |c: &str| style(c.to_string()).green();
let url_style = |u: &str| style(u.to_string()).cyan().underlined();
let mark_style = |m: &str| style(m.to_string()).dim();
let check = style("✓".to_string()).green();
println!();
let header = match field_count {
Some(n) => format!(
"MODEL CREATED {} ({} field{})",
style(singular).bold(),
n,
if n == 1 { "" } else { "s" }
),
None => format!("MODEL CREATED {}", style(singular).bold()),
};
println!("{header}");
println!(
" {} {}",
check,
path_style(&model_path.display().to_string())
);
println!(
" {} {}",
check,
path_style(&migration_path.display().to_string())
);
println!();
let main_rs = Path::new("src").join("main.rs");
let main_src = fs::read_to_string(&main_rs).unwrap_or_default();
let mods_ok = main_src.contains("// rustio: modules");
let imports_ok = main_src.contains("// rustio: imports");
let models_ok = main_src.contains("// rustio: models");
if !(mods_ok && imports_ok && models_ok) {
let warn = style("WARNING").yellow().bold();
println!("{warn} `src/main.rs` is missing one or more rustio insertion markers.");
println!(" Add the marker(s) below to your file manually before applying");
println!(" the edits. `rustio-admin startapp` deliberately does NOT edit your");
println!(" `src/main.rs` -- you stay the author.");
println!();
if !mods_ok {
println!(
" add {} (suggested location: after the `use` block at the top)",
mark_style("// rustio: modules")
);
}
if !imports_ok {
println!(
" add {} (suggested location: after the `// rustio: modules` line)",
mark_style("// rustio: imports")
);
}
if !models_ok {
println!(
" add {} (suggested location: inside the `Admin::new()...` builder chain, before `;`)",
mark_style("// rustio: models")
);
}
println!();
}
println!(
"{} edits in {}",
style("3").bold(),
path_style("src/main.rs")
);
println!(
" under {} add {}",
mark_style("// rustio: modules"),
style(format!("mod {name};")).bold()
);
println!(
" under {} add {}",
mark_style("// rustio: imports "),
style(format!("use {name}::{singular};")).bold()
);
println!(
" under {} add {}",
mark_style("// rustio: models "),
style(format!(".model::<{singular}>()")).bold()
);
println!();
println!(" {}", cmd_style("$ rustio-admin migrate apply"));
println!(" {}", cmd_style("$ cargo run"));
println!(
" {} {}",
style("admin").dim(),
url_style(&format!("http://127.0.0.1:8000/admin/{table}"))
);
}
fn collect_fields(
field_args: Vec<String>,
no_interactive: bool,
) -> Result<Vec<crate::app_fields::Field>, String> {
let mut fields: Vec<crate::app_fields::Field> = Vec::with_capacity(field_args.len());
for raw in field_args {
let field = crate::app_fields::parse_field(&raw).map_err(|e| e.format())?;
fields.push(field);
}
crate::app_fields::validate_unique_names(&fields).map_err(|e| e.format())?;
if fields.is_empty() && wizard_eligible(no_interactive) {
fields = prompt_fields_interactively()?;
crate::app_fields::validate_unique_names(&fields).map_err(|e| e.format())?;
}
Ok(fields)
}
fn wizard_eligible(no_interactive: bool) -> bool {
use std::io::IsTerminal;
if no_interactive {
return false;
}
if std::env::var_os("CI").is_some() {
return false;
}
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}
fn prompt_fields_interactively() -> Result<Vec<crate::app_fields::Field>, String> {
use std::io::{self, Write};
println!("--------------------------------------------------");
println!("rustio-admin startapp -- field declarations");
println!("--------------------------------------------------");
println!();
println!("Add fields one at a time. Format: <name>:<type>");
println!("Press ENTER on an empty line to finish.");
println!();
println!("Types: str, text, int, bigint, bool, timestamp, json, fk:<Model>");
println!();
let mut out = Vec::<crate::app_fields::Field>::new();
loop {
print!(" field {}> ", out.len() + 1);
io::stdout().flush().map_err(|e| format!("flush: {e}"))?;
let mut buf = String::new();
let n = io::stdin()
.read_line(&mut buf)
.map_err(|e| format!("read stdin: {e}"))?;
if n == 0 {
break;
}
let input = buf.trim();
if input.is_empty() {
break;
}
match crate::app_fields::parse_field(input) {
Ok(f) => out.push(f),
Err(e) => {
println!();
println!("{}", e.format());
println!();
}
}
}
if !out.is_empty() {
println!();
println!("Summary");
for f in &out {
println!(" {}: {:?}", f.name, f.kind);
}
println!();
}
Ok(out)
}
fn validate_app_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("app name is required".into());
}
if name.starts_with(|c: char| c.is_ascii_digit()) {
return Err("app name may not start with a digit".into());
}
let valid = name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
if !valid {
return Err(
"app name may only contain lowercase ASCII letters, digits, and '_' \
(e.g. `post`, `course`, `book_review`)"
.into(),
);
}
Ok(())
}
fn ensure_in_project_root() -> Result<(), String> {
if !Path::new("Cargo.toml").exists() {
return Err(
"no Cargo.toml in the current directory. Run `rustio-admin startapp` from the \
project root, or scaffold a fresh project with `rustio-admin startproject <name>` \
first."
.into(),
);
}
if !Path::new("src").join("main.rs").exists() {
return Err(
"no src/main.rs in the current directory. The CLI scaffolds models for \
binary projects."
.into(),
);
}
Ok(())
}
fn camel_case(snake: &str) -> String {
let mut out = String::with_capacity(snake.len());
let mut next_upper = true;
for c in snake.chars() {
if c == '_' {
next_upper = true;
} else if next_upper {
out.extend(c.to_uppercase());
next_upper = false;
} else {
out.push(c);
}
}
out
}
fn next_migration_version() -> Result<i64, String> {
let dir = Path::new("migrations");
if !dir.exists() {
return Ok(1);
}
let mut highest: i64 = 0;
for entry in fs::read_dir(dir).map_err(|e| format!("read_dir migrations: {e}"))? {
let entry = entry.map_err(|e| format!("dir entry: {e}"))?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("sql") {
continue;
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s,
None => continue,
};
let prefix = stem.split_once('_').map(|(p, _)| p).unwrap_or(stem);
if let Ok(n) = prefix.parse::<i64>() {
if n > highest {
highest = n;
}
}
}
Ok(highest + 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_names_accepted() {
for name in &["my-app", "my_app", "MyApp", "app1", "a-b_c-1"] {
assert!(validate_name(name).is_ok(), "should accept {name}");
}
}
#[test]
fn invalid_names_rejected() {
for name in &["", "1app", "my app", "my/app", "my.app", "my\u{1F600}app"] {
assert!(validate_name(name).is_err(), "should reject {name:?}");
}
}
#[allow(clippy::const_is_empty)]
#[test]
fn every_template_carries_at_least_one_placeholder_or_fixed_content() {
for (rel, body) in PROJECT_TEMPLATES {
assert!(!body.is_empty(), "template {rel} is empty");
}
assert!(
!APP_MODEL_TEMPLATE.is_empty(),
"app model template is empty"
);
assert!(
!APP_MIGRATION_TEMPLATE.is_empty(),
"app migration template is empty"
);
}
#[test]
fn humanise_name_capitalises_words_and_splits_on_separators() {
assert_eq!(humanise_name("clinic"), "Clinic");
assert_eq!(humanise_name("my-clinic"), "My Clinic");
assert_eq!(humanise_name("school_admin"), "School Admin");
assert_eq!(humanise_name("acme_dental_clinic"), "Acme Dental Clinic");
assert_eq!(humanise_name("_foo__bar_"), "Foo Bar");
assert_eq!(humanise_name(""), "");
}
#[test]
fn camel_case_handles_single_word_and_snake() {
assert_eq!(camel_case("post"), "Post");
assert_eq!(camel_case("book_review"), "BookReview");
assert_eq!(camel_case("a_b_c"), "ABC");
assert_eq!(camel_case(""), "");
}
#[test]
fn validate_app_name_accepts_typical_names() {
for name in &["post", "course", "book_review", "user2", "v1"] {
assert!(validate_app_name(name).is_ok(), "should accept {name}");
}
}
#[test]
fn validate_app_name_rejects_capitals_and_dashes() {
for name in &["Post", "BOOK", "book-review", "1book", "", "book.review"] {
assert!(validate_app_name(name).is_err(), "should reject {name:?}");
}
}
#[allow(clippy::const_is_empty)]
#[test]
fn blog_preset_templates_are_non_empty() {
for (rel, body) in BLOG_OVERRIDES.iter().chain(BLOG_EXTRAS.iter()) {
assert!(!body.is_empty(), "blog template {rel} is empty");
}
}
#[allow(clippy::const_is_empty)]
#[test]
fn clinic_preset_templates_are_non_empty() {
for (rel, body) in CLINIC_OVERRIDES.iter().chain(CLINIC_EXTRAS.iter()) {
assert!(!body.is_empty(), "clinic template {rel} is empty");
}
}
#[test]
fn project_rejects_unknown_preset_with_valid_list_in_message() {
let dir = unique_tempdir();
let err =
project_in(&dir, "proj", "definitely-not-a-preset", "custom").expect_err("must error");
assert!(err.contains("unknown preset"), "got: {err}");
assert!(err.contains("minimal"), "must list minimal: {err}");
assert!(err.contains("blog"), "must list blog: {err}");
assert!(err.contains("clinic"), "must list clinic: {err}");
}
#[test]
fn project_clinic_wires_models_and_seeded_migrations() {
let dir = unique_tempdir();
project_in(&dir, "clinic", "clinic", "clinic").expect("clinic should scaffold");
let root = dir.join("clinic");
for f in [
"src/patient.rs",
"src/appointment.rs",
"migrations/0001_create_patients.sql",
"migrations/0002_create_appointments.sql",
] {
assert!(root.join(f).exists(), "clinic preset must write {f}");
}
let main = fs::read_to_string(root.join("src/main.rs")).unwrap();
assert!(
main.contains("mod patient;"),
"main.rs must declare patient module"
);
assert!(
main.contains("mod appointment;"),
"main.rs must declare appointment module"
);
assert!(
main.contains(".model::<Patient>()"),
"main.rs must register Patient: {main}"
);
assert!(
main.contains(".model::<Appointment>()"),
"main.rs must register Appointment"
);
assert!(
main.contains(".app_name(\"Clinic\")"),
"main.rs app_name must be the humanised project name: {main}"
);
assert!(!main.contains("{{"), "no placeholder may survive: {main}");
let appts =
fs::read_to_string(root.join("migrations/0002_create_appointments.sql")).unwrap();
assert!(
appts.contains("REFERENCES patients(id)"),
"appointments must FK to patients"
);
assert!(
appts.contains("SELECT id FROM patients WHERE full_name ="),
"seed must link appointments to patients by name"
);
}
#[test]
fn project_minimal_is_neutral_with_no_domain_models() {
let dir = unique_tempdir();
project_in(&dir, "proj", "minimal", "custom").expect("minimal should scaffold");
let root = dir.join("proj");
assert!(
!root.join("src/post.rs").exists(),
"post.rs must NOT exist in minimal scaffold (moved to blog preset)"
);
assert!(
!root.join("src/comment.rs").exists(),
"comment.rs must NOT exist in minimal scaffold"
);
assert!(
!root.join("migrations/0001_create_posts.sql").exists(),
"0001_create_posts.sql must NOT exist in minimal scaffold"
);
let main = fs::read_to_string(root.join("src/main.rs")).unwrap();
assert!(
!main.contains("Post"),
"minimal main.rs must not mention Post: {main}"
);
assert!(
!main.contains("Comment"),
"minimal main.rs must not mention Comment"
);
}
#[test]
fn project_minimal_writes_homepage_and_migrations_placeholder() {
let dir = unique_tempdir();
project_in(&dir, "clinic", "minimal", "clinic").expect("scaffold");
let root = dir.join("clinic");
assert!(
root.join("templates/home.html").exists(),
"templates/home.html must exist"
);
assert!(
root.join("migrations/.gitkeep").exists(),
"migrations/.gitkeep must exist (keeps empty dir visible to git)"
);
let main = fs::read_to_string(root.join("src/main.rs")).unwrap();
assert!(
main.contains("include_str!(\"../templates/home.html\")"),
"main.rs must bake home.html: {main}"
);
assert!(main.contains("Response::html("), "main.rs must serve HTML");
}
#[test]
fn homepage_substitutes_type_phrase_per_project_type() {
let cases = [
("custom", "Custom RustIO project initialized."),
("clinic", "Clinic project initialized."),
("school", "School management project initialized."),
("inventory", "Inventory project initialized."),
("blog", "Blog project initialized."),
("unrecognised", "Custom RustIO project initialized."),
];
for (ty, expected) in cases {
let dir = unique_tempdir();
let preset = if ty == "blog" { "blog" } else { "minimal" };
project_in(&dir, "proj", preset, ty).unwrap_or_else(|e| panic!("{ty}: {e}"));
let html =
fs::read_to_string(dir.join("proj").join("templates").join("home.html")).unwrap();
assert!(
html.contains(expected),
"type {ty} should render '{expected}'; got: {html}"
);
assert!(
!html.contains("{{type_phrase}}"),
"placeholder leaked for type {ty}: {html}"
);
assert!(html.contains("proj"), "project name must substitute");
}
}
#[test]
fn project_blog_ships_post_comment_and_both_migrations() {
let dir = unique_tempdir();
project_in(&dir, "blog", "blog", "blog").expect("blog should scaffold");
let root = dir.join("blog");
assert!(root.join("src/post.rs").exists(), "post.rs missing");
assert!(root.join("src/comment.rs").exists(), "comment.rs missing");
assert!(
root.join("migrations/0001_create_posts.sql").exists(),
"0001 migration missing"
);
assert!(
root.join("migrations/0002_create_comments.sql").exists(),
"0002 migration missing"
);
let main = fs::read_to_string(root.join("src/main.rs")).unwrap();
assert!(main.contains(".model::<Post>()"), "Post must be registered");
assert!(
main.contains(".model::<Comment>()"),
"Comment must be registered"
);
assert!(
root.join("templates/home.html").exists(),
"templates/home.html must exist on blog scaffold too"
);
}
#[test]
fn project_with_db_writes_env_with_chosen_db_name() {
let dir = unique_tempdir();
project_with_db_in(&dir, "clinic", "minimal", "clinic_2026", "clinic")
.expect("wizard scaffold should succeed");
let env = fs::read_to_string(dir.join("clinic").join(".env"))
.expect(".env must exist after wizard scaffold");
assert!(
env.contains("DB_NAME=clinic_2026"),
"DB_NAME line missing: {env}"
);
assert!(
env.contains("DATABASE_URL=postgres://"),
"DATABASE_URL line missing: {env}"
);
let url_line = env
.lines()
.find(|l| l.starts_with("DATABASE_URL="))
.unwrap();
assert!(
url_line.contains("${DB_NAME}"),
"URL should compose from components: {url_line}"
);
}
#[test]
fn project_with_db_also_writes_env_example_for_team_sharing() {
let dir = unique_tempdir();
project_with_db_in(&dir, "school", "minimal", "school_dev", "school").unwrap();
let root = dir.join("school");
assert!(
root.join(".env").exists(),
".env (wizard-generated) must exist"
);
assert!(
root.join(".env.example").exists(),
".env.example (team template) must still exist"
);
assert!(
root.join(".gitignore").exists(),
".gitignore must exist (already excludes .env)"
);
let gi = fs::read_to_string(root.join(".gitignore")).unwrap();
assert!(
gi.lines().any(|l| l.trim() == ".env"),
".gitignore must exclude .env: {gi}"
);
let html = fs::read_to_string(root.join("templates/home.html")).unwrap();
assert!(
html.contains("School management project initialized."),
"homepage should reflect school type: {html}"
);
}
#[test]
fn project_and_project_with_db_share_the_same_file_set() {
let a = unique_tempdir();
let b = unique_tempdir();
project_in(&a, "proj", "minimal", "custom").unwrap();
project_with_db_in(&b, "proj", "minimal", "proj_dev", "custom").unwrap();
let mut a_files: Vec<_> = walk_files(&a.join("proj")).collect();
let mut b_files: Vec<_> = walk_files(&b.join("proj")).collect();
a_files.sort();
b_files.sort();
let expected_extra = ".env".to_string();
let extras_in_b: Vec<_> = b_files
.iter()
.filter(|p| !a_files.contains(p))
.cloned()
.collect();
assert_eq!(
extras_in_b,
vec![expected_extra],
"only `.env` should be new"
);
}
fn walk_files(root: &Path) -> impl Iterator<Item = String> + '_ {
fn rec(dir: &Path, base: &Path, out: &mut Vec<String>) {
for entry in fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
rec(&path, base, out);
} else {
out.push(
path.strip_prefix(base)
.unwrap()
.to_string_lossy()
.into_owned(),
);
}
}
}
let mut out = Vec::new();
rec(root, root, &mut out);
out.into_iter()
}
fn unique_tempdir() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let dir = std::env::temp_dir().join(format!("rustio-scaffold-{pid}-{n}"));
fs::create_dir_all(&dir).unwrap();
dir
}
}