use crate::error::{Error, Result};
use std::env;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct AppConfig {
pub server_name: String,
pub world_name: String,
pub server_pass: String,
pub server_public: bool,
pub tz: String,
pub update_cron: String,
pub restart_cron: String,
pub backups_cron: String,
pub crossplay: bool,
pub supervisor_http: bool,
pub supervisor_http_pass: String,
pub bepinex: bool,
pub apply_dll_patch: bool,
pub win_user: String,
pub win_host: String,
pub win_ssh_user: String,
pub win_ssh_port: u16,
pub win_ssh_key: PathBuf,
pub script_dir: PathBuf,
}
impl AppConfig {
pub fn from_env(script_dir: &Path) -> Result<Self> {
let current_user = env::var("USER")
.or_else(|_| env::var("LOGNAME"))
.unwrap_or_else(|_| "root".into());
let win_user = env::var("WIN_USER").unwrap_or_else(|_| current_user.clone());
let win_ssh_user = env::var("WIN_SSH_USER").unwrap_or_else(|_| win_user.clone());
let win_ssh_port: u16 = env::var("WIN_SSH_PORT")
.unwrap_or_else(|_| "22".into())
.parse()
.map_err(|_| Error::config("WIN_SSH_PORT must be a valid port number"))?;
Ok(Self {
server_name: env::var("SERVER_NAME").unwrap_or_else(|_| "My Server".into()),
world_name: env::var("WORLD_NAME").unwrap_or_else(|_| "Dedicated".into()),
server_pass: env::var("SERVER_PASS").unwrap_or_default(),
server_public: parse_bool(&env::var("SERVER_PUBLIC").unwrap_or_default()),
tz: env::var("TZ").unwrap_or_else(|_| "Etc/UTC".into()),
update_cron: env::var("UPDATE_CRON").unwrap_or_default(),
restart_cron: env::var("RESTART_CRON").unwrap_or_default(),
backups_cron: env::var("BACKUPS_CRON").unwrap_or_default(),
crossplay: parse_bool(&env::var("CROSSPLAY").unwrap_or_default()),
supervisor_http: parse_bool(&env::var("SUPERVISOR_HTTP").unwrap_or_default()),
supervisor_http_pass: env::var("SUPERVISOR_HTTP_PASS").unwrap_or_default(),
bepinex: parse_bool(&env::var("BEPINEX").unwrap_or_default()),
apply_dll_patch: parse_bool(&env::var("APPLY_DLL_PATCH").unwrap_or_default()),
win_user,
win_host: env::var("WIN_HOST").unwrap_or_default(),
win_ssh_user,
win_ssh_port,
win_ssh_key: PathBuf::from(env::var("WIN_SSH_KEY").unwrap_or_default()),
script_dir: script_dir.to_path_buf(),
})
}
pub fn env_file(&self) -> PathBuf {
self.script_dir.join("valheim.env")
}
pub fn mods_list_file(&self) -> PathBuf {
self.script_dir.join("mods_list.txt")
}
pub fn mods_cache_dir(&self) -> PathBuf {
self.script_dir.join("mods_cache")
}
pub fn plugins_dir(&self) -> PathBuf {
self.script_dir.join("config/bepinex/plugins")
}
pub fn worlds_local_dir(&self) -> PathBuf {
self.script_dir.join("config/worlds_local")
}
pub fn backups_dir(&self) -> PathBuf {
self.script_dir.join("config/backups")
}
pub fn data_dir(&self) -> PathBuf {
self.script_dir.join("data")
}
pub fn patches_dir(&self) -> PathBuf {
self.script_dir.join("patches")
}
pub fn patch_dll_src(&self) -> PathBuf {
self.patches_dir().join("assembly_valheim.dll")
}
pub fn worlds_src_remote(&self) -> String {
format!(
"C:/Users/{}/AppData/LocalLow/IronGate/Valheim/worlds_local",
self.win_user
)
}
pub fn filtered_list_file(&self) -> PathBuf {
self.script_dir.join("mods_list.filtered.txt")
}
}
pub fn parse_bool(s: &str) -> bool {
matches!(s.to_lowercase().trim(), "true" | "1" | "yes" | "on")
}
pub fn bool_label(b: bool) -> &'static str {
if b {
"Enabled"
} else {
"Disabled"
}
}
pub fn bool_onoff(b: bool) -> &'static str {
if b {
"On"
} else {
"Off"
}
}
pub fn cron_human(cron: &str) -> String {
let parts: Vec<&str> = cron.split_whitespace().collect();
if parts.len() != 5 {
return cron.to_string();
}
let (min, hr, dom, mon, dow) = (parts[0], parts[1], parts[2], parts[3], parts[4]);
if let Some(n) = min.strip_prefix("*/") {
if hr == "*" && dom == "*" && mon == "*" && dow == "*" {
return format!("Every {} minutes", n);
}
}
if min == "*" && hr == "*" && dom == "*" && mon == "*" && dow == "*" {
return "Every minute".into();
}
if hr == "*" && dom == "*" && mon == "*" && dow == "*" {
if let Ok(m) = min.parse::<u32>() {
return format!("Every hour at minute {}", m);
}
}
if let Some(h_str) = hr.strip_prefix("*/") {
if dom == "*" && mon == "*" && dow == "*" {
if let (Ok(m), Ok(h)) = (min.parse::<u32>(), h_str.parse::<u32>()) {
return format!("Every {}h at minute {}", h, m);
}
}
}
if dom == "*" && mon == "*" && dow == "*" {
if let (Ok(m), Ok(h)) = (min.parse::<u32>(), hr.parse::<u32>()) {
return format!("Daily at {:02}:{:02}", h, m);
}
}
if dom == "*" && mon == "*" {
if let (Ok(m), Ok(h), Ok(d)) = (min.parse::<u32>(), hr.parse::<u32>(), dow.parse::<usize>())
{
let days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
let day_name = days.get(d).copied().unwrap_or("day");
return format!("Every {} at {:02}:{:02}", day_name, h, m);
}
}
if dow == "*" {
if let (Ok(m), Ok(h), Ok(d)) = (min.parse::<u32>(), hr.parse::<u32>(), dom.parse::<u32>()) {
return format!("Monthly on day {} at {:02}:{:02}", d, h, m);
}
}
cron.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_bool_variants() {
assert!(parse_bool("true"));
assert!(parse_bool("1"));
assert!(parse_bool("yes"));
assert!(parse_bool("on"));
assert!(!parse_bool("false"));
assert!(!parse_bool("0"));
assert!(!parse_bool(""));
}
#[test]
fn cron_human_cases() {
assert_eq!(cron_human("30 * * * *"), "Every hour at minute 30");
assert_eq!(cron_human("30 4 * * *"), "Daily at 04:30");
assert_eq!(cron_human("5 * * * *"), "Every hour at minute 5");
assert_eq!(cron_human("*/15 * * * *"), "Every 15 minutes");
}
#[test]
fn env_file_with_quoted_cron_values() {
use std::fs;
use std::path::PathBuf;
let test_dir = PathBuf::from("/tmp/odin_test_env");
let _ = fs::create_dir_all(&test_dir);
let env_content = "SERVER_NAME=\"Test Server\"\nUPDATE_CRON=\"30 * * * *\"\nRESTART_CRON=\"30 4 * * *\"\nBACKUPS_CRON=\"5 * * * *\"\nAPPLY_DLL_PATCH=true\n";
let env_file = test_dir.join("valheim.env");
fs::write(&env_file, env_content).expect("Failed to write test env file");
let result = dotenvy::from_path_override(&env_file);
assert!(
result.is_ok(),
"Failed to parse env file with quoted cron values: {:?}",
result
);
let apply_dll = std::env::var("APPLY_DLL_PATCH").unwrap_or_default();
assert_eq!(
apply_dll, "true",
"APPLY_DLL_PATCH should be loaded as 'true'"
);
let _ = fs::remove_file(&env_file);
let _ = fs::remove_dir(&test_dir);
}
}