use std::io::{self, IsTerminal, Write};
use crate::{Backend, NewArgs, Template, OPTIONAL_FEATURES};
const TEMPLATES: &[(&str, Template, &str)] = &[
(
"fullstack",
Template::Fullstack,
"ORM + auto-admin + forms — the usual starting point",
),
(
"api",
Template::Api,
"bare ORM + axum, no admin UI — for JSON-only services",
),
(
"tenant",
Template::Tenant,
"multi-tenancy: tenant registry + operator console",
),
];
const BACKENDS: &[(&str, Backend, &str)] = &[
(
"postgres",
Backend::Postgres,
"every feature, including schema-mode tenancy",
),
(
"sqlite",
Backend::Sqlite,
"a file beside the project — no server to run",
),
("mysql", Backend::Mysql, "MySQL 8.0+ / MariaDB"),
];
pub fn run(given: NewArgs, name_was_given: bool) -> Result<NewArgs, String> {
if !io::stdin().is_terminal() {
return Err(
"--interactive needs a terminal — pass --template / --backend / --features instead"
.to_owned(),
);
}
println!();
println!(" rustango — new project");
println!(" (enter accepts the default; ctrl-c aborts)");
let name = if name_was_given {
given.name
} else {
ask_name()?
};
let template = choose("Template", TEMPLATES.iter().map(|(n, _, a)| (*n, *a)), 0)
.map(|ix| TEMPLATES[ix].1)?;
let backend = choose("Database", BACKENDS.iter().map(|(n, _, a)| (*n, *a)), 0)
.map(|ix| BACKENDS[ix].1)?;
let features = ask_features(template, &given.features)?;
let args = NewArgs {
name,
template,
backend,
features,
rustango_path: given.rustango_path,
interactive: false,
};
println!();
println!(" Same thing without the wizard:");
println!(" {}", equivalent_command(&args));
println!();
if !confirm("Create it?")? {
return Err("cancelled".to_owned());
}
Ok(args)
}
fn equivalent_command(args: &NewArgs) -> String {
let mut cmd = format!(
"cargo rustango new {} --template {} --backend {}",
args.name,
args.template.name(),
args.backend.feature()
);
if !args.features.is_empty() {
cmd.push_str(&format!(" --features {}", args.features.join(",")));
}
cmd
}
fn ask_name() -> Result<String, String> {
loop {
let raw = prompt("\n Project name: ")?;
let name = raw.trim();
if name.is_empty() {
println!(" a name is required");
continue;
}
match crate::validate_name(name) {
Ok(()) => return Ok(name.to_owned()),
Err(e) => println!(" {e}"),
}
}
}
fn choose<'a>(
title: &str,
options: impl Iterator<Item = (&'a str, &'a str)>,
default_ix: usize,
) -> Result<usize, String> {
let options: Vec<(&str, &str)> = options.collect();
let width = options.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
let iw = options.len().to_string().len();
println!();
println!(" {title}");
for (i, (name, about)) in options.iter().enumerate() {
let marker = if i == default_ix { " (default)" } else { "" };
println!(" {:>iw$}) {name:<width$} {about}{marker}", i + 1);
}
loop {
let raw = prompt(" > ")?;
let raw = raw.trim();
if raw.is_empty() {
return Ok(default_ix);
}
match raw.parse::<usize>() {
Ok(n) if n >= 1 && n <= options.len() => return Ok(n - 1),
_ => match options.iter().position(|(n, _)| *n == raw) {
Some(ix) => return Ok(ix),
None => println!(" pick 1-{}", options.len()),
},
}
}
}
fn ask_features(template: Template, preselected: &[String]) -> Result<Vec<String>, String> {
let base = template.base_features();
let menu: Vec<(&str, &str)> = OPTIONAL_FEATURES
.iter()
.filter(|(n, _)| !base.contains(n))
.map(|(n, a)| (*n, *a))
.collect();
if menu.is_empty() {
return Ok(Vec::new());
}
let width = menu.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
let iw = menu.len().to_string().len();
println!();
println!(" Extra features (numbers, e.g. `1 3 4` — enter for none)");
for (i, (name, about)) in menu.iter().enumerate() {
let on = if preselected.iter().any(|p| p == name) {
"*"
} else {
" "
};
println!(" {on} {:>iw$}) {name:<width$} {about}", i + 1);
}
if !preselected.is_empty() {
println!(" (* already set by --features; enter keeps them)");
}
loop {
let raw = prompt(" > ")?;
let raw = raw.trim();
if raw.is_empty() {
return Ok(preselected.to_vec());
}
let mut picked = Vec::new();
let mut bad = None;
for tok in raw.split([',', ' ']).filter(|t| !t.is_empty()) {
match tok.parse::<usize>() {
Ok(n) if n >= 1 && n <= menu.len() => {
let name = menu[n - 1].0.to_owned();
if !picked.contains(&name) {
picked.push(name);
}
}
_ => {
bad = Some(tok.to_owned());
break;
}
}
}
match bad {
Some(t) => println!(" `{t}` is not one of 1-{}", menu.len()),
None => return Ok(picked),
}
}
}
fn confirm(question: &str) -> Result<bool, String> {
loop {
let raw = prompt(&format!(" {question} [Y/n] "))?;
return Ok(match raw.trim().to_ascii_lowercase().as_str() {
"" | "y" | "yes" => true,
"n" | "no" => false,
_ => continue,
});
}
}
fn prompt(label: &str) -> Result<String, String> {
print!("{label}");
io::stdout().flush().map_err(|e| e.to_string())?;
let mut buf = String::new();
match io::stdin().read_line(&mut buf) {
Ok(0) => Err("cancelled".to_owned()),
Ok(_) => Ok(buf),
Err(e) => Err(e.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_echoed_command_round_trips() {
let cases = [
(Template::Tenant, Backend::Sqlite, vec!["sso", "cache-page"]),
(Template::Api, Backend::Mysql, vec![]),
(Template::Fullstack, Backend::Postgres, vec!["tenancy"]),
];
for (template, backend, features) in cases {
let features: Vec<String> = features.iter().map(|s| (*s).to_owned()).collect();
let args = NewArgs {
name: "probe".to_owned(),
template,
backend,
features: features.clone(),
rustango_path: None,
interactive: false,
};
let cmd = equivalent_command(&args);
let argv: Vec<String> = cmd
.split_whitespace()
.skip(3) .map(str::to_owned)
.collect();
let back = crate::parse_new_args(&argv, false)
.unwrap_or_else(|e| panic!("`{cmd}` does not parse: {e}"));
assert_eq!(back.name, "probe", "{cmd}");
assert_eq!(back.template.name(), template.name(), "{cmd}");
assert_eq!(back.backend, backend, "{cmd}");
assert_eq!(back.features, features, "{cmd}");
}
}
#[test]
fn every_menu_label_is_a_valid_flag_value() {
for (label, template, _) in TEMPLATES {
assert_eq!(Template::parse(label).expect(label).name(), template.name());
}
for (label, backend, _) in BACKENDS {
assert_eq!(&Backend::parse(label).expect(label), backend);
}
}
}