use std::io::{BufRead, Write};
use std::path::Path;
use crate::tenancy::TenantPools;
use super::tenants::create_tenant;
use super::users::{create_operator_cmd, create_superuser_cmd};
use super::InitTenancyFn;
pub(super) async fn wizard_cmd<R: BufRead, W: Write + Send>(
pools: &TenantPools,
registry_url: &str,
dir: &Path,
init_fn: InitTenancyFn,
reader: &mut R,
writer: &mut W,
) -> Result<(), super::TenancyError> {
write_intro(writer)?;
if prompt_yes_no(reader, writer, "Scaffold a new app?", true)? {
let name = prompt_value(reader, writer, " App name", Some("blog"))?;
if !name.is_empty() {
let opts = crate::migrate::scaffold::StartAppOptions {
app_name: name.clone(),
manage_bin: None,
base_dir: None,
};
let report = crate::migrate::scaffold::startapp(Path::new("."), &opts)
.map_err(|e| super::TenancyError::Validation(format!("startapp `{name}`: {e}")))?;
for path in &report.written {
writeln!(writer, " wrote {path}")?;
}
for path in &report.skipped {
writeln!(writer, " skipped (exists) {path}")?;
}
for hint in &report.manual_steps {
writeln!(writer, " manual step: {hint}")?;
}
}
}
if prompt_yes_no(reader, writer, "Initialize tenancy?", true)? {
super::migrations::init_tenancy_cmd_with(dir, writer, init_fn)?;
}
if prompt_yes_no(reader, writer, "Apply registry migrations now?", true)? {
super::migrations::migrate_registry_cmd(pools, dir, writer).await?;
}
if prompt_yes_no(reader, writer, "Create an operator account?", true)? {
let username = prompt_value(reader, writer, " Operator username", Some("admin"))?;
let password = prompt_value(reader, writer, " Operator password", None)?;
if username.is_empty() || password.is_empty() {
writeln!(writer, " (skipped — username and password required)")?;
} else {
create_operator_cmd(
pools,
&[username.clone(), "--password".into(), password.clone()],
writer,
)
.await?;
}
}
if prompt_yes_no(reader, writer, "Create a tenant?", true)? {
let slug = prompt_value(reader, writer, " Tenant slug", Some("acme"))?;
let display = prompt_value(reader, writer, " Display name", Some(&slug))?;
if slug.is_empty() {
writeln!(writer, " (skipped — slug required)")?;
} else {
create_tenant(
pools,
registry_url,
dir,
&[slug.clone(), "--display-name".into(), display.clone()],
writer,
)
.await?;
if prompt_yes_no(
reader,
writer,
" Create a superuser for this tenant?",
true,
)? {
let username =
prompt_value(reader, writer, " Superuser username", Some("admin"))?;
let password = prompt_value(reader, writer, " Superuser password", None)?;
if username.is_empty() || password.is_empty() {
writeln!(writer, " (skipped — username and password required)")?;
} else {
create_superuser_cmd(
pools,
registry_url,
&[
slug.clone(),
username.clone(),
"--password".into(),
password.clone(),
],
writer,
)
.await?;
}
}
}
}
write_outro(writer)?;
Ok(())
}
fn write_intro<W: Write>(w: &mut W) -> std::io::Result<()> {
writeln!(
w,
"rustango wizard — interactive setup\n\
===================================\n\
Press Enter to accept the default, or type your own value. Each\n\
step asks before running; type `n` to skip.\n"
)
}
fn write_outro<W: Write>(w: &mut W) -> std::io::Result<()> {
writeln!(
w,
"\nWizard complete. Next:\n • cargo run (boot the server)\n \
• visit /__login (operator console)\n \
• visit <slug>.localhost (tenant admin)"
)
}
pub(super) fn prompt_yes_no<R: BufRead, W: Write>(
reader: &mut R,
writer: &mut W,
question: &str,
default_yes: bool,
) -> std::io::Result<bool> {
let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
write!(writer, "{question} {hint} ")?;
writer.flush()?;
let mut buf = String::new();
reader.read_line(&mut buf)?;
let trimmed = buf.trim();
if trimmed.is_empty() {
return Ok(default_yes);
}
Ok(matches!(
trimmed.to_ascii_lowercase().as_str(),
"y" | "yes" | "1" | "true"
))
}
pub(super) fn prompt_value<R: BufRead, W: Write>(
reader: &mut R,
writer: &mut W,
label: &str,
default: Option<&str>,
) -> std::io::Result<String> {
match default {
Some(d) => write!(writer, "{label} (default: {d}): ")?,
None => write!(writer, "{label}: ")?,
}
writer.flush()?;
let mut buf = String::new();
reader.read_line(&mut buf)?;
let trimmed = buf.trim();
if trimmed.is_empty() {
return Ok(default.unwrap_or("").to_owned());
}
Ok(trimmed.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn prompt_yes_no_accepts_truthy_strings_and_defaults_on_empty() {
let cases = [
("y\n", false, true),
("Y\n", false, true),
("yes\n", false, true),
("YES\n", false, true),
("1\n", false, true),
("true\n", false, true),
("n\n", true, false),
("N\n", true, false),
("no\n", true, false),
("anything-else\n", true, false),
("\n", true, true),
("\n", false, false),
];
for (input, default_yes, expected) in cases {
let mut r = Cursor::new(input.as_bytes().to_vec());
let mut w: Vec<u8> = Vec::new();
let got = prompt_yes_no(&mut r, &mut w, "?", default_yes).unwrap();
assert_eq!(got, expected, "input={input:?} default_yes={default_yes}");
}
}
#[test]
fn prompt_yes_no_writes_correct_hint_letter_capitalized() {
let mut r = Cursor::new(b"y\n".to_vec());
let mut w: Vec<u8> = Vec::new();
prompt_yes_no(&mut r, &mut w, "Apply registry migrations?", true).unwrap();
let out = String::from_utf8(w).unwrap();
assert!(out.contains("[Y/n]"), "got: {out}");
assert!(!out.contains("[y/N]"));
let mut r = Cursor::new(b"y\n".to_vec());
let mut w: Vec<u8> = Vec::new();
prompt_yes_no(&mut r, &mut w, "Wipe everything?", false).unwrap();
let out = String::from_utf8(w).unwrap();
assert!(out.contains("[y/N]"), "got: {out}");
assert!(!out.contains("[Y/n]"));
}
#[test]
fn prompt_value_trims_input_and_falls_back_to_default() {
let mut r = Cursor::new(b" blog \n".to_vec());
let mut w: Vec<u8> = Vec::new();
assert_eq!(
prompt_value(&mut r, &mut w, "App name", Some("default_app")).unwrap(),
"blog"
);
let mut r = Cursor::new(b"\n".to_vec());
let mut w: Vec<u8> = Vec::new();
assert_eq!(
prompt_value(&mut r, &mut w, "App name", Some("default_app")).unwrap(),
"default_app"
);
let mut r = Cursor::new(b"\n".to_vec());
let mut w: Vec<u8> = Vec::new();
assert_eq!(prompt_value(&mut r, &mut w, "Password", None).unwrap(), "");
}
#[test]
fn prompt_value_shows_default_in_prompt() {
let mut r = Cursor::new(b"\n".to_vec());
let mut w: Vec<u8> = Vec::new();
prompt_value(&mut r, &mut w, "App name", Some("blog")).unwrap();
let out = String::from_utf8(w).unwrap();
assert!(out.contains("default: blog"), "got: {out}");
let mut r = Cursor::new(b"\n".to_vec());
let mut w: Vec<u8> = Vec::new();
prompt_value(&mut r, &mut w, "Password", None).unwrap();
let out = String::from_utf8(w).unwrap();
assert!(!out.contains("default:"), "got: {out}");
}
}