use std::io::{self, IsTerminal, Write};
pub(crate) const PROJECT_TYPES: &[&str] = &["custom", "clinic", "school", "inventory", "blog"];
const POSTGRES_GUIDANCE: &str = "\
RustIO is built on PostgreSQL — one database, by design (no SQLite fallback).
If you don't have PostgreSQL 16 yet, install and start it:
macOS brew install postgresql@16 && brew services start postgresql@16
Ubuntu sudo apt install postgresql-16 && sudo systemctl start postgresql
Windows https://www.postgresql.org/download/windows/
The wizard does not install PostgreSQL for you -- that is deliberate. You
stay in control of your machine.";
pub(crate) struct WizardInput {
pub project_name: String,
pub project_type: String,
pub db_name: String,
}
pub(crate) fn should_run(no_interactive: bool) -> bool {
if no_interactive {
return false;
}
if std::env::var_os("CI").is_some() {
return false;
}
io::stdin().is_terminal() && io::stdout().is_terminal()
}
pub(crate) fn run(suggested_name: Option<&str>) -> Result<WizardInput, String> {
println!();
println!("Create a new RustIO project");
println!("────────────────────────────────────────────────────────────");
println!("Three short questions, then a ready-to-run admin application you");
println!("can migrate and launch in minutes — with authentication, an admin");
println!("panel, and an audit trail already wired in.");
println!();
println!("Press Enter to accept the default shown in [brackets]. Nothing is");
println!("written to disk until you've answered all three.");
println!();
let project_name = ask_project_name(suggested_name)?;
println!("\n ✓ project {project_name}");
let project_type = ask_project_type()?;
println!("\n ✓ type {project_type}");
println!();
println!("{POSTGRES_GUIDANCE}");
println!();
let db_name = ask_db_name(&project_name)?;
println!("\n ✓ database {db_name}");
println!();
println!("That's everything. Creating your project now…");
println!();
Ok(WizardInput {
project_name,
project_type,
db_name,
})
}
fn ask_project_name(suggested: Option<&str>) -> Result<String, String> {
println!("Project name — names the new folder and the Cargo crate.");
println!("Letters, digits, '-' and '_'; must start with a letter.");
loop {
let prompt = match suggested {
Some(s) => format!("Project name [{s}]: "),
None => "Project name: ".into(),
};
let input = prompt_line(&prompt)?;
let chosen = if input.is_empty() {
suggested.unwrap_or("").to_string()
} else {
input
};
if chosen.is_empty() {
println!(" Project name is required.");
continue;
}
match validate_project_name(&chosen) {
Ok(()) => return Ok(chosen),
Err(msg) => println!(" {msg}"),
}
}
}
fn ask_project_type() -> Result<String, String> {
println!();
println!("Project type — what you're building. `custom` is a clean slate;");
println!("the others name a familiar starting point. You can change");
println!("direction at any time; this choice locks nothing in.");
println!("Project type:");
for (i, t) in PROJECT_TYPES.iter().enumerate() {
println!(" {}) {t}", i + 1);
}
loop {
let input = prompt_line("Type [custom]: ")?;
if input.is_empty() {
return Ok("custom".into());
}
if let Ok(n) = input.parse::<usize>() {
if (1..=PROJECT_TYPES.len()).contains(&n) {
return Ok(PROJECT_TYPES[n - 1].into());
}
}
if let Some(t) = PROJECT_TYPES
.iter()
.find(|t| t.eq_ignore_ascii_case(&input))
{
return Ok((*t).into());
}
println!(
" Choose 1–{}, or one of: {}",
PROJECT_TYPES.len(),
PROJECT_TYPES.join(", ")
);
}
}
fn ask_db_name(project: &str) -> Result<String, String> {
println!("Database name — your local PostgreSQL database for development.");
println!("RustIO writes it into `.env` as DATABASE_URL; you create it with");
println!("`createdb` in the next steps.");
let default = format!("{project}_dev");
loop {
let input = prompt_line(&format!("Database name [{default}]: "))?;
let chosen = if input.is_empty() {
default.clone()
} else {
input
};
match validate_db_name(&chosen) {
Ok(()) => return Ok(chosen),
Err(msg) => println!(" {msg}"),
}
}
}
fn prompt_line(prompt: &str) -> Result<String, String> {
print!("{prompt}");
io::stdout()
.flush()
.map_err(|e| format!("flush stdout: {e}"))?;
let mut buf = String::new();
let n = io::stdin()
.read_line(&mut buf)
.map_err(|e| format!("read stdin: {e}"))?;
if n == 0 {
return Err(
"stdin closed unexpectedly; re-run with `--no-interactive` for scripted use".into(),
);
}
Ok(buf.trim().to_string())
}
pub(crate) fn validate_project_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(())
}
pub(crate) fn validate_db_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("database name is required".into());
}
if name.starts_with(|c: char| c.is_ascii_digit()) {
return Err("database name may not start with a digit".into());
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err("database name may only contain ASCII letters, digits, and '_'".into());
}
if name.len() > 63 {
return Err("database name must be 63 characters or fewer (PostgreSQL limit)".into());
}
let reserved = ["postgres", "template0", "template1"];
if reserved.iter().any(|r| r.eq_ignore_ascii_case(name)) {
return Err(format!(
"'{name}' is reserved by PostgreSQL -- pick a different name"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_run_respects_no_interactive_flag() {
assert!(!should_run(true), "--no-interactive must always bypass");
}
#[test]
fn validate_db_name_accepts_typical_names() {
for n in &["clinic_dev", "school_2026", "inventory", "a_b_c", "x1"] {
assert!(validate_db_name(n).is_ok(), "should accept {n}");
}
}
#[test]
fn validate_db_name_rejects_bad_names() {
for n in &[
"",
"1clinic",
"clinic-dev",
"clinic.dev",
"clinic dev",
"postgres",
"TEMPLATE0",
"Template1",
] {
assert!(validate_db_name(n).is_err(), "should reject {n:?}");
}
}
#[test]
fn validate_db_name_enforces_63_byte_limit() {
let ok = "a".repeat(63);
let too_long = "a".repeat(64);
assert!(validate_db_name(&ok).is_ok(), "63 chars must be accepted");
assert!(
validate_db_name(&too_long).is_err(),
"64 chars must be rejected"
);
}
#[test]
fn validate_project_name_mirrors_scaffold_semantics() {
for ok in &["my-app", "my_app", "MyApp", "app1"] {
assert!(validate_project_name(ok).is_ok(), "should accept {ok}");
}
for bad in &["", "1app", "my app", "my/app"] {
assert!(validate_project_name(bad).is_err(), "should reject {bad:?}");
}
}
#[test]
fn project_types_list_starts_with_custom() {
assert_eq!(PROJECT_TYPES.first(), Some(&"custom"));
}
}