init2
This commit is contained in:
203
src/convert.rs
Normal file
203
src/convert.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use crate::copy;
|
||||
use crate::input::ResolvedInput;
|
||||
use crate::mods;
|
||||
use crate::password;
|
||||
use crate::players_db;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct ConvertOptions<'a> {
|
||||
pub input: &'a Path,
|
||||
pub output: &'a Path,
|
||||
pub server_name: &'a str,
|
||||
pub server_config: Option<&'a Path>,
|
||||
pub workshop_dir: Option<&'a Path>,
|
||||
pub password: Option<&'a str>,
|
||||
pub env_file: Option<&'a Path>,
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
pub fn convert(opts: &ConvertOptions<'_>) -> Result<()> {
|
||||
let resolved = ResolvedInput::resolve(opts.input)?;
|
||||
|
||||
let dest_world = opts
|
||||
.output
|
||||
.join("Saves")
|
||||
.join("Multiplayer")
|
||||
.join(opts.server_name);
|
||||
|
||||
if dest_world.exists() && !opts.force {
|
||||
bail!(
|
||||
"destination world already exists: {} (use --force to replace)",
|
||||
dest_world.display()
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"Converting {} -> {}",
|
||||
resolved.world_root.display(),
|
||||
dest_world.display()
|
||||
);
|
||||
|
||||
let copied = copy::copy_world_filtered(&resolved.world_root, &dest_world)?;
|
||||
eprintln!("Copied {copied} files");
|
||||
|
||||
let players_db_path = dest_world.join("players.db");
|
||||
if players_db_path.is_file() {
|
||||
let updated = players_db::patch_world_column(&players_db_path, opts.server_name)?;
|
||||
eprintln!(
|
||||
"Updated {updated} networkPlayers rows (world -> {})",
|
||||
opts.server_name
|
||||
);
|
||||
} else {
|
||||
eprintln!("Warning: no players.db found in destination world");
|
||||
}
|
||||
|
||||
if let Some(config_dir) = opts.server_config {
|
||||
copy_server_config(config_dir, opts.output, &resolved.world_name, opts.server_name)?;
|
||||
}
|
||||
|
||||
recover_mods_into_server_ini(opts, &resolved.world_root)?;
|
||||
apply_server_password(opts)?;
|
||||
|
||||
eprintln!("Done.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_server_password(opts: &ConvertOptions<'_>) -> Result<()> {
|
||||
let Some(pw) = password::resolve_password(opts.password, opts.env_file, opts.output)? else {
|
||||
eprintln!(
|
||||
"No server password set (pass --password or SERVER_PASSWORD/PASSWORD in .env); leaving Password= unchanged"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let ini = opts
|
||||
.output
|
||||
.join("Server")
|
||||
.join(format!("{}.ini", opts.server_name));
|
||||
if !ini.is_file() {
|
||||
eprintln!(
|
||||
"Warning: {} missing — cannot apply Password=",
|
||||
ini.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
password::patch_ini_key(&ini, "Password", &pw)?;
|
||||
eprintln!("Patched Password= in {}", ini.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recover_mods_into_server_ini(opts: &ConvertOptions<'_>, world_root: &Path) -> Result<()> {
|
||||
let mod_ids = mods::extract_mod_ids(world_root)?;
|
||||
if mod_ids.is_empty() {
|
||||
eprintln!("No mod IDs found in WorldDictionaryReadable.lua");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!("Recovered {} mod ID(s) from WorldDictionary", mod_ids.len());
|
||||
|
||||
let Some(workshop_dir) = opts.workshop_dir else {
|
||||
eprintln!(
|
||||
"Warning: --workshop-dir not set; wrote Mods= only. Pass a Steam workshop content/108600 path to also fill WorkshopItems="
|
||||
);
|
||||
let mods_line = mod_ids.join(";");
|
||||
let ini = opts.output.join("Server").join(format!("{}.ini", opts.server_name));
|
||||
if ini.is_file() {
|
||||
// Keep existing WorkshopItems if any
|
||||
let text = fs::read_to_string(&ini)?;
|
||||
let workshop = text
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("WorkshopItems="))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
mods::patch_server_ini_mods(&ini, &mods_line, &workshop)?;
|
||||
eprintln!("Patched Mods= in {}", ini.display());
|
||||
} else {
|
||||
eprintln!(
|
||||
"Warning: {} missing — create/start the server once, then re-run with --workshop-dir",
|
||||
ini.display()
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let (workshop_ids, _) = mods::resolve_workshop_ids(&mod_ids, workshop_dir)?;
|
||||
let mods_line = mod_ids.join(";");
|
||||
let workshop_line = workshop_ids.join(";");
|
||||
|
||||
let server_dir = opts.output.join("Server");
|
||||
fs::create_dir_all(&server_dir)?;
|
||||
let ini = server_dir.join(format!("{}.ini", opts.server_name));
|
||||
|
||||
if !ini.is_file() {
|
||||
bail!(
|
||||
"server ini not found at {} — start the dedicated server once to generate it, then re-run",
|
||||
ini.display()
|
||||
);
|
||||
}
|
||||
|
||||
mods::patch_server_ini_mods(&ini, &mods_line, &workshop_line)?;
|
||||
eprintln!(
|
||||
"Patched {} ({} mods, {} workshop items)",
|
||||
ini.display(),
|
||||
mod_ids.len(),
|
||||
workshop_ids.len()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_server_config(
|
||||
config_dir: &Path,
|
||||
output: &Path,
|
||||
old_name: &str,
|
||||
server_name: &str,
|
||||
) -> Result<()> {
|
||||
let dest_server = output.join("Server");
|
||||
fs::create_dir_all(&dest_server)?;
|
||||
|
||||
let mappings = [
|
||||
(format!("{old_name}.ini"), format!("{server_name}.ini")),
|
||||
(
|
||||
format!("{old_name}_SandboxVars.lua"),
|
||||
format!("{server_name}_SandboxVars.lua"),
|
||||
),
|
||||
(
|
||||
format!("{old_name}_spawnpoints.lua"),
|
||||
format!("{server_name}_spawnpoints.lua"),
|
||||
),
|
||||
(
|
||||
format!("{old_name}_spawnregions.lua"),
|
||||
format!("{server_name}_spawnregions.lua"),
|
||||
),
|
||||
];
|
||||
|
||||
let mut copied_any = false;
|
||||
for (src_name, dest_name) in mappings {
|
||||
let src = config_dir.join(&src_name);
|
||||
if src.is_file() {
|
||||
let dest = dest_server.join(&dest_name);
|
||||
fs::copy(&src, &dest).with_context(|| {
|
||||
format!(
|
||||
"failed to copy server config {} -> {}",
|
||||
src.display(),
|
||||
dest.display()
|
||||
)
|
||||
})?;
|
||||
eprintln!("Copied server config: {src_name} -> {dest_name}");
|
||||
copied_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !copied_any {
|
||||
eprintln!(
|
||||
"Warning: no server config files found in {} for world name `{old_name}`",
|
||||
config_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
81
src/copy.rs
Normal file
81
src/copy.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
const SKIP_FILENAMES: &[&str] = &["thumb.png", "InGameMap.ini", "serverid.dat"];
|
||||
|
||||
pub fn copy_world_filtered(source: &Path, dest: &Path) -> Result<u64> {
|
||||
if dest.exists() {
|
||||
fs::remove_dir_all(dest)
|
||||
.with_context(|| format!("failed to remove existing destination: {}", dest.display()))?;
|
||||
}
|
||||
fs::create_dir_all(dest)?;
|
||||
|
||||
let mut copied = 0u64;
|
||||
|
||||
for entry in WalkDir::new(source).follow_links(false) {
|
||||
let entry = entry?;
|
||||
let rel = entry
|
||||
.path()
|
||||
.strip_prefix(source)
|
||||
.context("walkdir path outside source root")?;
|
||||
|
||||
if rel.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if should_skip(rel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let target = dest.join(rel);
|
||||
if entry.file_type().is_dir() {
|
||||
fs::create_dir_all(&target)?;
|
||||
} else {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(entry.path(), &target)?;
|
||||
copied += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(copied)
|
||||
}
|
||||
|
||||
fn should_skip(rel: &Path) -> bool {
|
||||
let file_name = match rel.file_name().and_then(|n| n.to_str()) {
|
||||
Some(name) => name,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
if SKIP_FILENAMES.contains(&file_name) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if file_name.ends_with(".db-journal") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Syncthing-style atomic-write conflict copies (e.g. foo.bin-1234567890.123-ZwS1g3qi.bin)
|
||||
if file_name.contains("-ZwS1g3qi") {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skips_conflict_and_journal_files() {
|
||||
assert!(should_skip(Path::new("erosion.ini-1785960551.276219-ZwS1g3qi.ini")));
|
||||
assert!(should_skip(Path::new("players.db-journal")));
|
||||
assert!(should_skip(Path::new("thumb.png")));
|
||||
assert!(!should_skip(Path::new("players.db")));
|
||||
assert!(!should_skip(Path::new("map/1000/1056.bin")));
|
||||
}
|
||||
}
|
||||
119
src/input.rs
Normal file
119
src/input.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::fs;
|
||||
use std::io::copy;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::TempDir;
|
||||
use zip::ZipArchive;
|
||||
|
||||
/// Resolved world input: a directory path and optional temp dir holding zip extraction.
|
||||
pub struct ResolvedInput {
|
||||
pub world_root: PathBuf,
|
||||
pub world_name: String,
|
||||
_temp_dir: Option<TempDir>,
|
||||
}
|
||||
|
||||
impl ResolvedInput {
|
||||
pub fn resolve(input: &Path) -> Result<Self> {
|
||||
if is_player_folder(input) {
|
||||
bail!(
|
||||
"input looks like a client player cache folder (ends with `_player`): {}",
|
||||
input.display()
|
||||
);
|
||||
}
|
||||
|
||||
if input.is_dir() {
|
||||
let world_name = input
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.context("input directory has no name")?
|
||||
.to_string();
|
||||
return Ok(Self {
|
||||
world_root: input.to_path_buf(),
|
||||
world_name,
|
||||
_temp_dir: None,
|
||||
});
|
||||
}
|
||||
|
||||
if input.is_file() && input.extension().is_some_and(|e| e == "zip") {
|
||||
return resolve_zip(input);
|
||||
}
|
||||
|
||||
bail!(
|
||||
"input must be a world directory or .zip file: {}",
|
||||
input.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_player_folder(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|name| name.ends_with("_player"))
|
||||
}
|
||||
|
||||
fn resolve_zip(zip_path: &Path) -> Result<ResolvedInput> {
|
||||
let file = fs::File::open(zip_path)
|
||||
.with_context(|| format!("failed to open zip: {}", zip_path.display()))?;
|
||||
let mut archive = ZipArchive::new(file)
|
||||
.with_context(|| format!("failed to read zip: {}", zip_path.display()))?;
|
||||
|
||||
let temp_dir = tempfile::tempdir().context("failed to create temp directory")?;
|
||||
let extract_root = temp_dir.path();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive.by_index(i)?;
|
||||
let entry_path = entry
|
||||
.enclosed_name()
|
||||
.with_context(|| format!("zip entry {} has an invalid path", entry.name()))?;
|
||||
|
||||
let out_path = extract_root.join(entry_path);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&out_path)?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut out_file = fs::File::create(&out_path)?;
|
||||
copy(&mut entry, &mut out_file)?;
|
||||
}
|
||||
}
|
||||
|
||||
let top_level: Vec<_> = fs::read_dir(extract_root)?
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
|
||||
if top_level.len() != 1 {
|
||||
bail!(
|
||||
"expected exactly one top-level folder in zip {}, found {}",
|
||||
zip_path.display(),
|
||||
top_level.len()
|
||||
);
|
||||
}
|
||||
|
||||
let world_root = top_level[0].path();
|
||||
if !world_root.is_dir() {
|
||||
bail!(
|
||||
"expected top-level entry in zip to be a directory: {}",
|
||||
zip_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
if is_player_folder(&world_root) {
|
||||
bail!(
|
||||
"zip contains a client player cache folder (ends with `_player`): {}",
|
||||
world_root.display()
|
||||
);
|
||||
}
|
||||
|
||||
let world_name = world_root
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.context("extracted world directory has no name")?
|
||||
.to_string();
|
||||
|
||||
Ok(ResolvedInput {
|
||||
world_root,
|
||||
world_name,
|
||||
_temp_dir: Some(temp_dir),
|
||||
})
|
||||
}
|
||||
64
src/main.rs
Normal file
64
src/main.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
mod convert;
|
||||
mod copy;
|
||||
mod input;
|
||||
mod mods;
|
||||
mod password;
|
||||
mod players_db;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "zomboid-converter",
|
||||
about = "Convert a locally hosted Project Zomboid multiplayer world into a dedicated server save layout"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Hosted world directory or .zip archive (not the `_player` folder)
|
||||
#[arg(short, long)]
|
||||
input: PathBuf,
|
||||
|
||||
/// Dedicated server-data root (writes Saves/Multiplayer/<server-name>/)
|
||||
#[arg(short, long)]
|
||||
output: PathBuf,
|
||||
|
||||
/// Target world and config basename (e.g. pzserver)
|
||||
#[arg(short = 'n', long, default_value = "pzserver")]
|
||||
server_name: String,
|
||||
|
||||
/// Optional directory containing hosted Server/ config files to copy and rename
|
||||
#[arg(long)]
|
||||
server_config: Option<PathBuf>,
|
||||
|
||||
/// Steam workshop content dir for app 108600 (resolves mod IDs → WorkshopItems)
|
||||
#[arg(long)]
|
||||
workshop_dir: Option<PathBuf>,
|
||||
|
||||
/// Server join password (Password= in server ini). Overrides .env when set.
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
|
||||
/// Path to .env for SERVER_PASSWORD/PASSWORD (default: <output>/../.env)
|
||||
#[arg(long)]
|
||||
env_file: Option<PathBuf>,
|
||||
|
||||
/// Replace an existing destination world folder
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
convert::convert(&convert::ConvertOptions {
|
||||
input: &cli.input,
|
||||
output: &cli.output,
|
||||
server_name: &cli.server_name,
|
||||
server_config: cli.server_config.as_deref(),
|
||||
workshop_dir: cli.workshop_dir.as_deref(),
|
||||
password: cli.password.as_deref(),
|
||||
env_file: cli.env_file.as_deref(),
|
||||
force: cli.force,
|
||||
})
|
||||
}
|
||||
189
src/mods.rs
Normal file
189
src/mods.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const VANILLA_MOD: &str = "pz-vanilla";
|
||||
|
||||
/// Extract unique mod IDs from WorldDictionaryReadable.lua (excludes pz-vanilla).
|
||||
pub fn extract_mod_ids(world_root: &Path) -> Result<Vec<String>> {
|
||||
let dict_path = world_root.join("WorldDictionaryReadable.lua");
|
||||
let text = fs::read_to_string(&dict_path).with_context(|| {
|
||||
format!(
|
||||
"failed to read WorldDictionaryReadable.lua at {}",
|
||||
dict_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut mods = BTreeSet::new();
|
||||
for line in text.lines() {
|
||||
if let Some(id) = parse_mod_id_line(line) {
|
||||
if id != VANILLA_MOD {
|
||||
mods.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// damnlib is a common dependency — load it first when present
|
||||
let mut ordered: Vec<String> = mods.into_iter().collect();
|
||||
if let Some(pos) = ordered.iter().position(|m| m == "damnlib") {
|
||||
let lib = ordered.remove(pos);
|
||||
ordered.insert(0, lib);
|
||||
}
|
||||
|
||||
Ok(ordered)
|
||||
}
|
||||
|
||||
fn parse_mod_id_line(line: &str) -> Option<String> {
|
||||
let key = "modID = \"";
|
||||
let start = line.find(key)? + key.len();
|
||||
let rest = &line[start..];
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
/// Resolve mod folder IDs to Steam Workshop item IDs by scanning a Steam workshop content dir
|
||||
/// (`.../steamapps/workshop/content/108600`).
|
||||
pub fn resolve_workshop_ids(
|
||||
mod_ids: &[String],
|
||||
workshop_content_dir: &Path,
|
||||
) -> Result<(Vec<String>, Vec<String>)> {
|
||||
let catalog = index_workshop_mods(workshop_content_dir)?;
|
||||
let mut workshop_ids = Vec::new();
|
||||
let mut missing = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
|
||||
for mod_id in mod_ids {
|
||||
match catalog.get(mod_id) {
|
||||
Some(wid) => {
|
||||
if seen.insert(wid.clone()) {
|
||||
workshop_ids.push(wid.clone());
|
||||
}
|
||||
}
|
||||
None => missing.push(mod_id.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
anyhow::bail!(
|
||||
"could not resolve Workshop IDs for {} mod(s): {}",
|
||||
missing.len(),
|
||||
missing.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
Ok((workshop_ids, missing))
|
||||
}
|
||||
|
||||
fn index_workshop_mods(workshop_content_dir: &Path) -> Result<HashMap<String, String>> {
|
||||
let mut catalog = HashMap::new();
|
||||
if !workshop_content_dir.is_dir() {
|
||||
anyhow::bail!(
|
||||
"workshop content directory not found: {}",
|
||||
workshop_content_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(workshop_content_dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let wid = name.to_string_lossy();
|
||||
if !entry.file_type()?.is_dir() || !wid.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
scan_mod_infos(entry.path().as_path(), &wid, &mut catalog)?;
|
||||
}
|
||||
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
fn scan_mod_infos(dir: &Path, workshop_id: &str, catalog: &mut HashMap<String, String>) -> Result<()> {
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(current) = stack.pop() {
|
||||
for entry in fs::read_dir(¤t)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if entry.file_type()?.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if entry.file_name() == "mod.info" {
|
||||
let text = fs::read_to_string(&path)?;
|
||||
if let Some(mod_id) = parse_mod_info_id(&text) {
|
||||
catalog.entry(mod_id).or_insert_with(|| workshop_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_mod_info_id(text: &str) -> Option<String> {
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(rest) = line.strip_prefix("id=") {
|
||||
return Some(rest.trim().to_string());
|
||||
}
|
||||
// rare: "id = foo"
|
||||
if let Some(rest) = line.strip_prefix("id") {
|
||||
let rest = rest.trim_start();
|
||||
if let Some(rest) = rest.strip_prefix('=') {
|
||||
return Some(rest.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Patch Mods= and WorkshopItems= in an existing server .ini (preserves other settings).
|
||||
pub fn patch_server_ini_mods(ini_path: &Path, mods: &str, workshop_items: &str) -> Result<()> {
|
||||
let text = fs::read_to_string(ini_path)
|
||||
.with_context(|| format!("failed to read {}", ini_path.display()))?;
|
||||
|
||||
let mut out = String::with_capacity(text.len() + mods.len() + workshop_items.len());
|
||||
let mut saw_mods = false;
|
||||
let mut saw_workshop = false;
|
||||
|
||||
for line in text.lines() {
|
||||
if let Some(rest) = line.strip_prefix("Mods=") {
|
||||
let _ = rest;
|
||||
out.push_str("Mods=");
|
||||
out.push_str(mods);
|
||||
out.push('\n');
|
||||
saw_mods = true;
|
||||
} else if let Some(rest) = line.strip_prefix("WorkshopItems=") {
|
||||
let _ = rest;
|
||||
out.push_str("WorkshopItems=");
|
||||
out.push_str(workshop_items);
|
||||
out.push('\n');
|
||||
saw_workshop = true;
|
||||
} else {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_mods {
|
||||
anyhow::bail!("no Mods= line found in {}", ini_path.display());
|
||||
}
|
||||
if !saw_workshop {
|
||||
anyhow::bail!("no WorkshopItems= line found in {}", ini_path.display());
|
||||
}
|
||||
|
||||
fs::write(ini_path, out).with_context(|| format!("failed to write {}", ini_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_mod_id_line() {
|
||||
assert_eq!(
|
||||
parse_mod_id_line(r#" modID = "damnlib","#),
|
||||
Some("damnlib".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
131
src/password.rs
Normal file
131
src/password.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Resolve server join password: CLI override wins, else `.env` keys.
|
||||
pub fn resolve_password(
|
||||
cli_password: Option<&str>,
|
||||
env_file: Option<&Path>,
|
||||
output: &Path,
|
||||
) -> Result<Option<String>> {
|
||||
if let Some(pw) = cli_password {
|
||||
if !pw.is_empty() {
|
||||
return Ok(Some(pw.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let env_path = match env_file {
|
||||
Some(p) => Some(p.to_path_buf()),
|
||||
None => default_env_path(output),
|
||||
};
|
||||
|
||||
let Some(env_path) = env_path else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if !env_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let vars = parse_env_file(&env_path)?;
|
||||
for key in ["SERVER_PASSWORD", "PASSWORD"] {
|
||||
if let Some(val) = vars.get(key) {
|
||||
if !val.is_empty() {
|
||||
return Ok(Some(val.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn default_env_path(output: &Path) -> Option<PathBuf> {
|
||||
// output is typically .../server/server-data → sibling .env at .../server/.env
|
||||
let candidate = output.join("..").join(".env");
|
||||
let candidate = candidate.canonicalize().ok().filter(|p| p.is_file());
|
||||
if candidate.is_some() {
|
||||
return candidate;
|
||||
}
|
||||
let candidate = output.join(".env");
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn parse_env_file(path: &Path) -> Result<HashMap<String, String>> {
|
||||
let text = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read env file: {}", path.display()))?;
|
||||
let mut map = HashMap::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
let mut value = value.trim().to_string();
|
||||
if (value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\''))
|
||||
{
|
||||
value = value[1..value.len() - 1].to_string();
|
||||
}
|
||||
map.insert(key.to_string(), value);
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Patch a single KEY=value line in a server .ini (exact key prefix match).
|
||||
pub fn patch_ini_key(ini_path: &Path, key: &str, value: &str) -> Result<()> {
|
||||
let text = fs::read_to_string(ini_path)
|
||||
.with_context(|| format!("failed to read {}", ini_path.display()))?;
|
||||
let prefix = format!("{key}=");
|
||||
let mut out = String::with_capacity(text.len() + value.len());
|
||||
let mut saw = false;
|
||||
|
||||
for line in text.lines() {
|
||||
if line.starts_with(&prefix) {
|
||||
out.push_str(&prefix);
|
||||
out.push_str(value);
|
||||
out.push('\n');
|
||||
saw = true;
|
||||
} else {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if !saw {
|
||||
anyhow::bail!("no {key}= line found in {}", ini_path.display());
|
||||
}
|
||||
|
||||
fs::write(ini_path, out).with_context(|| format!("failed to write {}", ini_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn parses_env_and_prefers_cli() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
writeln!(f, "SERVER_PASSWORD=fromenv").unwrap();
|
||||
let path = f.path();
|
||||
let vars = parse_env_file(path).unwrap();
|
||||
assert_eq!(vars.get("SERVER_PASSWORD").unwrap(), "fromenv");
|
||||
|
||||
let resolved = resolve_password(Some("clipw"), Some(path), Path::new("/tmp")).unwrap();
|
||||
assert_eq!(resolved.as_deref(), Some("clipw"));
|
||||
|
||||
let resolved = resolve_password(None, Some(path), Path::new("/tmp")).unwrap();
|
||||
assert_eq!(resolved.as_deref(), Some("fromenv"));
|
||||
}
|
||||
}
|
||||
14
src/players_db.rs
Normal file
14
src/players_db.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn patch_world_column(players_db: &Path, server_name: &str) -> Result<usize> {
|
||||
let conn = Connection::open(players_db)
|
||||
.with_context(|| format!("failed to open players.db: {}", players_db.display()))?;
|
||||
|
||||
let updated = conn
|
||||
.execute("UPDATE networkPlayers SET world = ?1", [server_name])
|
||||
.with_context(|| "failed to update networkPlayers.world")?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
Reference in New Issue
Block a user