use anyhow::Result;
use colored::*;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command as TokioCommand;
use tokio::select;
use tokio::time::sleep;
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
#[error("Failed to copy pack: {0}")]
PackCopyFailed(String),
#[error("Invalid pack path: {0}")]
InvalidPackPath(String),
#[error("Invalid server path: {0}")]
InvalidServerPath(String),
#[error("Failed to start server: {0}")]
ServerStartFailed(String),
#[error("Validation failed: {0}")]
ValidationFailed(String),
}
#[derive(Debug)]
pub struct ValidationResult {
pub errors: Vec<String>,
pub warnings: Vec<String>,
pub info: Vec<String>,
}
const TESTING_BP_NAME: &str = "TESTING_PACK_BP";
const TESTING_RP_NAME: &str = "TESTING_PACK_RP";
#[derive(Debug, Deserialize)]
struct Manifest {
header: Header,
}
#[derive(Debug, Deserialize)]
struct Header {
uuid: String,
version: Vec<u32>,
}
#[derive(Debug, Serialize)]
struct WorldPack {
pack_id: String,
version: Vec<u32>,
}
fn read_pack_manifest(pack_path: &Path) -> Result<Header, ValidationError> {
let manifest_path = pack_path.join("manifest.json");
if !manifest_path.exists() {
return Err(ValidationError::InvalidPackPath(
"manifest.json not found".to_string(),
));
}
let manifest_content = fs::read_to_string(manifest_path).map_err(|e| {
ValidationError::PackCopyFailed(format!("Failed to read manifest.json: {}", e))
})?;
let manifest: Manifest = serde_json::from_str(&manifest_content).map_err(|e| {
ValidationError::PackCopyFailed(format!("Failed to parse manifest.json: {}", e))
})?;
Ok(manifest.header)
}
fn create_world_pack_configs(
server_path: &Path,
bp_header: Header,
rp_header: Header,
) -> Result<(), ValidationError> {
let world_path = server_path.join("worlds/Bedrock level");
fs::create_dir_all(&world_path).map_err(|e| {
ValidationError::PackCopyFailed(format!("Failed to create world directory: {}", e))
})?;
let bp_config = vec![WorldPack {
pack_id: bp_header.uuid,
version: bp_header.version,
}];
let bp_config_path = world_path.join("world_behavior_packs.json");
fs::write(
&bp_config_path,
serde_json::to_string_pretty(&bp_config).map_err(|e| {
ValidationError::PackCopyFailed(format!("Failed to serialize BP config: {}", e))
})?,
)
.map_err(|e| ValidationError::PackCopyFailed(format!("Failed to write BP config: {}", e)))?;
let rp_config = vec![WorldPack {
pack_id: rp_header.uuid,
version: rp_header.version,
}];
let rp_config_path = world_path.join("world_resource_packs.json");
fs::write(
&rp_config_path,
serde_json::to_string_pretty(&rp_config).map_err(|e| {
ValidationError::PackCopyFailed(format!("Failed to serialize RP config: {}", e))
})?,
)
.map_err(|e| ValidationError::PackCopyFailed(format!("Failed to write RP config: {}", e)))?;
Ok(())
}
pub fn symlink_test_packs(
server_path: &Path,
bp_path: &Path,
rp_path: &Path,
) -> Result<(), ValidationError> {
if !server_path.exists() || !server_path.is_dir() {
return Err(ValidationError::InvalidServerPath(
"Server path does not exist or is not a directory".to_string(),
));
}
if !bp_path.exists() || !bp_path.is_dir() {
return Err(ValidationError::InvalidPackPath(
"Behavior pack path does not exist or is not a directory".to_string(),
));
}
if !rp_path.exists() || !rp_path.is_dir() {
return Err(ValidationError::InvalidPackPath(
"Resource pack path does not exist or is not a directory".to_string(),
));
}
let bp_header = read_pack_manifest(bp_path)?;
let rp_header = read_pack_manifest(rp_path)?;
let bp_dir = server_path.join("behavior_packs").join(TESTING_BP_NAME);
let rp_dir = server_path.join("resource_packs").join(TESTING_RP_NAME);
let cleanup_path = |path: &Path| -> Result<(), ValidationError> {
match fs::metadata(path) {
Ok(_) => {
if let Err(_) = fs::remove_file(path) {
fs::remove_dir_all(path).map_err(|e| {
ValidationError::PackCopyFailed(format!("Failed to remove existing path: {}", e))
})?;
}
}
Err(_) => {}
}
Ok(())
};
cleanup_path(&bp_dir)?;
cleanup_path(&rp_dir)?;
fs::create_dir_all(bp_dir.parent().unwrap()).map_err(|e| {
ValidationError::PackCopyFailed(format!("Failed to create BP directory: {}", e))
})?;
fs::create_dir_all(rp_dir.parent().unwrap()).map_err(|e| {
ValidationError::PackCopyFailed(format!("Failed to create RP directory: {}", e))
})?;
let bp_abs = fs::canonicalize(bp_path).map_err(|e| {
ValidationError::PackCopyFailed(format!("Failed to get absolute BP path: {}", e))
})?;
let rp_abs = fs::canonicalize(rp_path).map_err(|e| {
ValidationError::PackCopyFailed(format!("Failed to get absolute RP path: {}", e))
})?;
std::os::unix::fs::symlink(&bp_abs, &bp_dir).map_err(|e| {
ValidationError::PackCopyFailed(format!("Failed to create BP symlink: {}", e))
})?;
std::os::unix::fs::symlink(&rp_abs, &rp_dir).map_err(|e| {
ValidationError::PackCopyFailed(format!("Failed to create RP symlink: {}", e))
})?;
create_world_pack_configs(server_path, bp_header, rp_header)?;
Ok(())
}
pub async fn start_server(server_path: &Path, last_log_timeout: Option<u64>, verbose: bool) -> Result<ValidationResult, ValidationError> {
if !server_path.exists() || !server_path.is_dir() {
return Err(ValidationError::InvalidServerPath(
"Server path does not exist or is not a directory".to_string(),
));
}
let server_exe = server_path.join("bedrock_server");
if !server_exe.exists() {
return Err(ValidationError::InvalidServerPath(
"bedrock_server executable not found".to_string(),
));
}
println!("{}", "Making server executable...".cyan());
Command::new("chmod")
.arg("+x")
.arg(&server_exe)
.output()
.map_err(|e| {
ValidationError::ServerStartFailed(format!("Failed to chmod server: {}", e))
})?;
println!("{}", "Starting server process...".cyan());
let mut child = TokioCommand::new(&server_exe)
.current_dir(server_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| {
ValidationError::ServerStartFailed(format!("Failed to start server: {}", e))
})?;
let stdout = child.stdout.take().ok_or_else(|| {
ValidationError::ServerStartFailed("Failed to capture server stdout".to_string())
})?;
let stderr = child.stderr.take().ok_or_else(|| {
ValidationError::ServerStartFailed("Failed to capture server stderr".to_string())
})?;
let mut validation_result = ValidationResult {
errors: Vec::new(),
warnings: Vec::new(),
info: Vec::new(),
};
println!("{}", "Monitoring server output...".cyan());
let mut stdout_reader = BufReader::new(stdout).lines();
let mut stderr_reader = BufReader::new(stderr).lines();
let mut last_log_time = Instant::now();
let mut telemetry_seen = false;
let mut server_started = false;
let mut telemetry_complete = false;
loop {
let timeout_future = if telemetry_complete {
Box::pin(sleep(Duration::from_secs(last_log_timeout.unwrap_or(2))))
} else {
Box::pin(sleep(Duration::from_secs(0)))
};
select! {
Ok(Some(line)) = stdout_reader.next_line() => {
let line = line.trim();
if !line.is_empty() {
process_line(line, &mut validation_result, &mut last_log_time, &mut telemetry_seen, &mut server_started, &mut telemetry_complete, verbose)?;
}
}
Ok(Some(line)) = stderr_reader.next_line() => {
let line = line.trim();
if !line.is_empty() {
process_line(line, &mut validation_result, &mut last_log_time, &mut telemetry_seen, &mut server_started, &mut telemetry_complete, verbose)?;
}
}
_ = timeout_future => {
if telemetry_complete {
println!("{}", format!("\nNo new logs for {} seconds, validation complete.", last_log_timeout.unwrap_or(2)).yellow());
break;
}
}
else => break
}
}
println!("{}", "Stopping server...".cyan());
child
.kill()
.await
.map_err(|e| ValidationError::ServerStartFailed(format!("Failed to stop server: {}", e)))?;
child.wait().await.map_err(|e| {
ValidationError::ServerStartFailed(format!("Failed to wait for server to stop: {}", e))
})?;
println!("{}", "Server stopped.".green());
Ok(validation_result)
}
fn process_line(
line: &str,
validation_result: &mut ValidationResult,
last_log_time: &mut Instant,
telemetry_seen: &mut bool,
server_started: &mut bool,
telemetry_complete: &mut bool,
verbose: bool,
) -> Result<(), ValidationError> {
if verbose {
println!("{}", format!("{}", line).dimmed());
}
if line.contains("Server started.") {
*server_started = true;
println!("{}", "Server has started successfully".green());
return Ok(());
}
if line.contains("TELEMETRY MESSAGE") {
*telemetry_seen = true;
println!("{}", "Starting validation...".cyan());
*last_log_time = Instant::now();
return Ok(());
}
if *telemetry_seen && line.contains("======================================================") {
*telemetry_seen = false;
*telemetry_complete = true;
*last_log_time = Instant::now();
return Ok(());
}
if !*server_started || *telemetry_seen {
return Ok(());
}
if line.contains("ERROR") || line.contains("WARN") || line.contains("INFO") {
*last_log_time = Instant::now();
}
if line.contains("ERROR") {
validation_result.errors.push(line.to_string());
} else if line.contains("WARN") {
validation_result.warnings.push(line.to_string());
} else if line.contains("INFO") {
validation_result.info.push(line.to_string());
if !verbose {
println!("{}", format!("{}", line).blue());
}
}
Ok(())
}