use crate::config::TomlTarget;
use crate::display::EnderDisplay;
use crate::protocols::PROTOCOLS;
use clap::{Parser, Subcommand};
use inquire::validator::Validation;
use owo_colors::OwoColorize;
use serde::Serialize;
use std::collections::HashMap;
use std::error::Error;
use std::fmt::Display;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(
name = "enderpearl",
version,
about = "Async proxy for Minecraft and HTTP traffic",
args_conflicts_with_subcommands = false
)]
pub struct Cli {
#[arg(short, long, default_value = "enderpearl.toml", global = true)]
pub config: PathBuf,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
Run,
Init,
}
use inquire::{Confirm, CustomType, MultiSelect, Text};
pub fn handle_init(config_path: &Path) -> anyhow::Result<()> {
set_prompt_theme();
println!();
EnderDisplay::print_banner();
println!();
section_header("Enderpearl Configuration");
let bind = Text::new("Bind address:")
.with_default("0.0.0.0")
.with_help_message("IP address for the proxy to listen on (0.0.0.0 = all interfaces)")
.prompt()?;
let port: u16 = CustomType::new("Port:")
.with_default(25565)
.with_help_message("Port for the proxy to listen on (default 25565 for Minecraft)")
.with_error_message("Enter a valid port number (1–65535)")
.prompt()?;
section_header("Protocols");
let selections = MultiSelect::new(
"Select protocols to configure:",
PROTOCOLS.iter().map(|p| p.display_name).collect(),
)
.with_help_message("space to toggle, enter to confirm")
.prompt()?;
if selections.is_empty() {
println!(" No protocols selected");
println!();
return Ok(());
}
let upstreams = collect_upstream_configs(&selections)?;
if config_path.exists() {
let overwrite = Confirm::new("File already exists. Overwrite?")
.with_default(false)
.prompt()?;
if !overwrite {
return Ok(());
}
}
let config = InitConfig {
server: ServerEntry { bind, port },
upstream: upstreams,
};
let toml_str = toml::to_string_pretty(&config)?;
fs::write(config_path, toml_str)?;
section_header(format!("Written to {}", config_path.display()));
Ok(())
}
#[derive(Serialize)]
struct InitConfig {
server: ServerEntry,
upstream: HashMap<String, UpstreamConfig>,
}
#[derive(Serialize)]
struct ServerEntry {
bind: String,
port: u16,
}
#[derive(Serialize)]
struct UpstreamConfig {
forward_to: TomlTarget,
#[serde(skip_serializing_if = "Option::is_none")]
wake_command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
fake_motd: Option<String>,
}
fn collect_upstream_configs(
selections: &[&str],
) -> anyhow::Result<HashMap<String, UpstreamConfig>> {
let mut upstreams = HashMap::new();
for proto in selections {
section_header(proto);
let meta = PROTOCOLS
.iter()
.find(|m| m.display_name == *proto)
.ok_or_else(|| anyhow::anyhow!("unknown protocol '{proto}'"))?;
let target = Text::new("Forward to:")
.with_default(meta.default_port)
.with_help_message("Server address — separate multiple with commas for round-robin")
.with_validator(validate_address)
.prompt()?;
let wake = Text::new("Wake command (optional):")
.with_help_message(
"Shell command to start the server on traffic — e.g. docker start mc",
)
.prompt()?;
let wake = if wake.trim().is_empty() {
None
} else {
Some(wake.trim().to_string())
};
let fake_motd = Text::new("Fake MOTD JSON (optional):")
.with_help_message(
"Leave empty to disable. JSON with version, players, description fields",
)
.prompt()?;
let fake_motd = if fake_motd.trim().is_empty() {
None
} else {
Some(fake_motd.trim().to_string())
};
let forward_to = if target.contains(',') {
TomlTarget::Pool(target.split(',').map(|s| s.trim().to_string()).collect())
} else {
TomlTarget::Address(target.trim().to_string())
};
upstreams.insert(
meta.config_key.to_string(),
UpstreamConfig {
forward_to,
wake_command: wake,
fake_motd,
},
);
}
Ok(upstreams)
}
#[allow(clippy::unnecessary_wraps)]
fn validate_address(s: &str) -> Result<Validation, Box<dyn Error + Send + Sync>> {
let s = s.trim();
if s.is_empty() {
return Ok(Validation::Invalid(
"Address is required — e.g. 127.0.0.1:25566".into(),
));
}
for addr in s.split(',') {
let addr = addr.trim();
if addr.is_empty() {
return Ok(Validation::Invalid("Pool entry cannot be empty".into()));
}
if !addr.contains(':') {
return Ok(Validation::Invalid(
format!("'{addr}' needs a port — use format host:port").into(),
));
}
if let Some(port_str) = addr.rsplit(':').next()
&& port_str.parse::<u16>().is_err()
{
return Ok(Validation::Invalid(
format!("'{port_str}' is not a valid port number (1–65535)").into(),
));
}
}
Ok(Validation::Valid)
}
fn set_prompt_theme() {
use inquire::ui::{Attributes, Color, Styled};
let prefix = Styled::new("▪ ")
.with_fg(Color::LightMagenta)
.with_attr(Attributes::BOLD);
let check = Styled::new("✓ ").with_fg(Color::LightGreen);
let cross = Styled::new("✗ ").with_fg(Color::DarkGrey);
inquire::set_global_render_config(
inquire::ui::RenderConfig::default_colored()
.with_prompt_prefix(prefix)
.with_answered_prompt_prefix(prefix)
.with_selected_checkbox(check)
.with_unselected_checkbox(cross),
);
}
fn section_header(msg: impl Display) {
println!();
println!(
" {} {}",
"■".bright_magenta(),
msg.to_string().bright_magenta().bold()
);
println!(" {}", "─".repeat(45).magenta());
}