use std::io::{self, IsTerminal, Write};
use crate::{Backend, NewArgs, NewFlags, 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: NewFlags, 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)");
ask(given, name_was_given, &mut prompt)
}
fn ask(given: NewFlags, name_was_given: bool, prompt: &mut Prompt<'_>) -> Result<NewArgs, String> {
let name = if name_was_given {
given.name
} else {
ask_name(prompt)?
};
let template = match given.template {
Some(t) => t,
None => {
let default = TEMPLATES
.iter()
.position(|(_, t, _)| *t == Template::DEFAULT)
.unwrap_or(0);
let menu = TEMPLATES.iter().map(|(n, _, a)| (*n, *a));
TEMPLATES[choose("Template", menu, default, prompt)?].1
}
};
let backend = match given.backend {
Some(b) => b,
None => {
let default = BACKENDS
.iter()
.position(|(_, b, _)| *b == Backend::DEFAULT)
.unwrap_or(0);
let menu = BACKENDS.iter().map(|(n, _, a)| (*n, *a));
BACKENDS[choose("Database", menu, default, prompt)?].1
}
};
let features = ask_features(template, &given.features, prompt)?;
let args = NewArgs {
name,
template,
backend,
features,
rustango_path: given.rustango_path,
};
println!();
println!(" Same thing without the wizard:");
println!(" {}", equivalent_command(&args));
println!();
if !confirm("Create it?", prompt)? {
return Err("cancelled".to_owned());
}
Ok(args)
}
type Prompt<'a> = dyn FnMut(&str) -> Result<String, String> + 'a;
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(",")));
}
if let Some(path) = &args.rustango_path {
cmd.push_str(&format!(" --rustango-path {}", shell_quote(path)));
}
cmd
}
fn shell_quote(s: &str) -> String {
let plain = !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./:=@,+".contains(c));
if plain {
s.to_owned()
} else {
format!("'{}'", s.replace('\'', r"'\''"))
}
}
fn ask_name(prompt: &mut Prompt<'_>) -> 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,
prompt: &mut Prompt<'_>,
) -> 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],
prompt: &mut Prompt<'_>,
) -> 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(preselected.to_vec());
}
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; numbers add to them)");
}
loop {
let raw = prompt(" > ")?;
let raw = raw.trim();
if raw.is_empty() {
return Ok(preselected.to_vec());
}
let mut picked = preselected.to_vec();
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, prompt: &mut Prompt<'_>) -> 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,
};
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, template, "{cmd}");
assert_eq!(back.backend, backend, "{cmd}");
assert_eq!(back.features, features, "{cmd}");
}
}
#[test]
fn given_template_and_backend_are_not_asked() {
let argv: Vec<String> = ["shop", "-i", "--template", "tenant", "--backend", "sqlite"]
.iter()
.map(|s| (*s).to_owned())
.collect();
let flags = crate::parse_new_flags(&argv, true).expect("parse");
let mut asked = Vec::new();
let mut enter = |label: &str| {
asked.push(label.to_owned());
Ok("\n".to_owned())
};
let args = ask(flags, true, &mut enter).expect("wizard");
assert_eq!(args.template, Template::Tenant);
assert_eq!(args.backend, Backend::Sqlite);
assert_eq!(asked.len(), 2, "{asked:?}");
}
#[test]
fn missing_template_and_backend_are_asked_with_defaults() {
let flags = crate::parse_new_flags(&["shop".to_owned()], true).expect("parse");
let mut asked = 0;
let mut enter = |_: &str| {
asked += 1;
Ok("\n".to_owned())
};
let args = ask(flags, true, &mut enter).expect("wizard");
assert_eq!(args.template, Template::DEFAULT);
assert_eq!(args.backend, Backend::DEFAULT);
assert_eq!(asked, 4);
}
#[test]
fn menu_picks_add_to_given_features() {
let argv: Vec<String> = [
"shop",
"-i",
"-t",
"api",
"-b",
"sqlite",
"--features",
"csrf",
]
.iter()
.map(|s| (*s).to_owned())
.collect();
let flags = crate::parse_new_flags(&argv, true).expect("parse");
let sso = OPTIONAL_FEATURES
.iter()
.filter(|(n, _)| !Template::Api.base_features().contains(n))
.position(|(n, _)| *n == "sso")
.expect("sso on the api menu")
+ 1;
let mut answers = vec![format!("{sso}\n"), "\n".to_owned()].into_iter();
let mut next = |_: &str| Ok(answers.next().expect("asked too often"));
let args = ask(flags, true, &mut next).expect("wizard");
assert_eq!(args.features, vec!["csrf", "sso"]);
}
#[test]
fn the_echoed_command_keeps_rustango_path() {
let mut args = NewArgs {
name: "probe".to_owned(),
template: Template::Api,
backend: Backend::Sqlite,
features: vec![],
rustango_path: Some("../rustango/crates/rustango".to_owned()),
};
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).expect("parses");
assert_eq!(back.rustango_path, args.rustango_path, "{cmd}");
args.rustango_path = Some("my dir/it's".to_owned());
assert!(
equivalent_command(&args).ends_with(r"--rustango-path 'my dir/it'\''s'"),
"{}",
equivalent_command(&args)
);
}
#[test]
fn every_menu_label_is_a_valid_flag_value() {
for (label, template, _) in TEMPLATES {
assert_eq!(&Template::parse(label).expect(label), template);
}
for (label, backend, _) in BACKENDS {
assert_eq!(&Backend::parse(label).expect(label), backend);
}
}
}