use config::{Config, ConfigError, File};
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::{fs::OpenOptions, path::Path};
use crate::error::{AppError, AppResult};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LaunchConfig {
pub store: String,
pub runtime: String,
pub extra_args: Vec<String>,
}
impl Default for LaunchConfig {
fn default() -> Self {
Self {
store: "steam".into(),
runtime: "native".into(),
extra_args: Vec::new(),
}
}
}
#[derive(Serialize, Deserialize)]
pub struct AppConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mod_list: Vec<String>,
pub log_level: String,
#[serde(default)]
pub game_dir: Option<String>,
#[serde(default)]
pub install_dir: Option<String>,
#[serde(default)]
pub data_dir: Option<String>,
#[serde(default)]
pub launch: LaunchConfig,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
mod_list: vec![],
log_level: "error".into(),
game_dir: None,
install_dir: None,
data_dir: None,
launch: LaunchConfig::default(),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum GameDirStatus<'a> {
Set(&'a str),
NeedsMigration,
Unset,
}
impl AppConfig {
pub fn game_dir_status(&self) -> GameDirStatus<'_> {
if let Some(dir) = self.game_dir.as_deref() {
GameDirStatus::Set(dir)
} else if self.install_dir.is_some() {
GameDirStatus::NeedsMigration
} else {
GameDirStatus::Unset
}
}
pub fn base_dir(&self) -> std::path::PathBuf {
match self.data_dir.as_deref() {
Some(dir) => expand_path(dir),
None => std::path::PathBuf::from(&*APP_CACHE_DIR),
}
}
}
pub fn expand_path(path: &str) -> std::path::PathBuf {
match shellexpand::full(path) {
Ok(expanded) => std::path::PathBuf::from(expanded.as_ref()),
Err(_) => std::path::PathBuf::from(path),
}
}
pub const EXAMPLE_GAME_DIR_UNIX: &str = "$HOME/.steam/steam/steamapps/common/Valheim";
pub const EXAMPLE_GAME_DIR_WINDOWS: &str = "C:/Program Files (x86)/Steam/steamapps/common/Valheim";
pub fn example_game_dir() -> &'static str {
if cfg!(windows) {
EXAMPLE_GAME_DIR_WINDOWS
} else {
EXAMPLE_GAME_DIR_UNIX
}
}
pub fn suggest_game_dir(install_dir: &str) -> Option<String> {
let normalized = install_dir.replace('\\', "/");
let mut segments: Vec<&str> = normalized.split('/').collect();
let idx = segments
.iter()
.rposition(|segment| segment.eq_ignore_ascii_case("bepinex"))?;
segments.truncate(idx);
let joined = segments.join("/");
let candidate = joined.trim_end_matches('/');
if candidate.is_empty() {
None
} else {
Some(candidate.to_string())
}
}
pub fn config_home() -> Option<std::path::PathBuf> {
dirs::config_dir().map(|dir| dir.join("vmm"))
}
pub fn place_config_file(config_home: &Path, name: &str) -> std::io::Result<std::path::PathBuf> {
std::fs::create_dir_all(config_home)?;
Ok(config_home.join(name))
}
#[cfg(not(tarpaulin_include))]
pub static APP_CACHE_DIR: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
config_home()
.expect("Failed to resolve the user config directory")
.to_string_lossy()
.into_owned()
});
pub fn get_config(config_override: Option<&Path>) -> Result<AppConfig, ConfigError> {
let home = config_home();
get_config_from(
config_override,
Path::new("vmm_config.toml"),
home.as_deref(),
)
}
fn get_config_from(
config_override: Option<&Path>,
local_path: &Path,
home: Option<&Path>,
) -> Result<AppConfig, ConfigError> {
let default_config_data = AppConfig::default();
let mut builder = Config::builder()
.set_default("mod_list", default_config_data.mod_list.clone())?
.set_default("log_level", default_config_data.log_level.clone())?
.set_default("game_dir", default_config_data.game_dir.clone())?
.set_default("install_dir", default_config_data.install_dir.clone())?
.set_default("data_dir", default_config_data.data_dir.clone())?
.set_default("launch.store", default_config_data.launch.store.clone())?
.set_default("launch.runtime", default_config_data.launch.runtime.clone())?
.set_default(
"launch.extra_args",
default_config_data.launch.extra_args.clone(),
)?;
if let Some(path) = config_override {
builder = builder.add_source(File::with_name(path.to_str().unwrap_or_default()));
} else {
let global_path = home.map(|dir| dir.join("vmm_config.toml"));
let global_exists = global_path.as_deref().is_some_and(Path::exists);
if !local_path.exists()
&& !global_exists
&& let Some(dir) = home
&& let Ok(path) = place_config_file(dir, "vmm_config.toml")
{
let _ = create_missing_config_file(&path, &default_config_data);
}
if let Some(path) = global_path.filter(|path| path.exists()) {
builder = builder.add_source(File::with_name(path.to_str().unwrap_or_default()));
}
if local_path.exists() {
builder = builder.add_source(File::with_name(local_path.to_str().unwrap_or_default()));
}
}
builder.build()?.try_deserialize()
}
fn create_missing_config_file(
config_path: &Path,
default_config_data: &AppConfig,
) -> AppResult<()> {
let serialized_config_data = toml::to_string(&default_config_data)
.map_err(|e| AppError::ConfigSerialization(format!("{}", e)))?;
let mut config_file = OpenOptions::new()
.write(true)
.create_new(true)
.open(config_path)?;
write!(config_file, "{}", serialized_config_data)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;
#[test]
fn test_default_config() {
let default_config = AppConfig::default();
assert!(default_config.mod_list.is_empty());
assert_eq!(default_config.log_level, "error");
}
#[test]
fn test_create_missing_config_file() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("test_config.toml");
let default_config = AppConfig::default();
let result = create_missing_config_file(&config_path, &default_config);
assert!(result.is_ok());
assert!(config_path.exists());
let content = fs::read_to_string(&config_path).unwrap();
assert!(
!content.contains("mod_list"),
"a new config must not offer a deprecated key; got:\n{content}"
);
assert!(content.contains("log_level"));
assert!(content.contains("error"));
let parsed: AppConfig = toml::from_str(&content).unwrap();
assert_eq!(parsed.log_level, default_config.log_level);
assert!(parsed.mod_list.is_empty());
}
#[test]
fn test_custom_config_values() {
let custom_config = AppConfig {
mod_list: vec!["Owner1-ModA".to_string(), "Owner2-ModB".to_string()],
log_level: "debug".to_string(),
game_dir: Some("/path/to/game".to_string()),
..Default::default()
};
let dir = tempdir().unwrap();
let config_path = dir.path().join("custom_config.toml");
let result = create_missing_config_file(&config_path, &custom_config);
assert!(result.is_ok());
let content = fs::read_to_string(&config_path).unwrap();
assert!(content.contains("Owner1-ModA"));
assert!(content.contains("Owner2-ModB"));
assert!(content.contains("debug"));
let parsed: AppConfig = toml::from_str(&content).unwrap();
assert_eq!(parsed.log_level, "debug");
assert_eq!(parsed.mod_list.len(), 2);
assert_eq!(parsed.mod_list[0], "Owner1-ModA");
assert_eq!(parsed.mod_list[1], "Owner2-ModB");
}
#[test]
fn test_get_config_with_override() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("override_config.toml");
let custom_config = AppConfig {
mod_list: vec!["Owner1-ModA".to_string()],
log_level: "debug".to_string(),
..Default::default()
};
create_missing_config_file(&config_path, &custom_config).unwrap();
let loaded = get_config(Some(&config_path)).unwrap();
assert_eq!(loaded.log_level, "debug");
assert_eq!(loaded.mod_list, vec!["Owner1-ModA"]);
}
#[test]
fn a_local_config_layers_over_the_global_one() {
let dir = tempdir().unwrap();
let home = dir.path().join("home");
let local = dir.path().join("project/vmm_config.toml");
fs::create_dir_all(&home).unwrap();
fs::create_dir_all(local.parent().unwrap()).unwrap();
fs::write(
home.join("vmm_config.toml"),
"log_level = \"warn\"\ngame_dir = \"/from/global\"\n",
)
.unwrap();
fs::write(&local, "log_level = \"debug\"\n").unwrap();
let loaded = get_config_from(None, &local, Some(&home)).unwrap();
assert_eq!(loaded.log_level, "debug");
assert_eq!(loaded.game_dir.as_deref(), Some("/from/global"));
}
#[test]
fn the_global_config_is_read_when_there_is_no_local_one() {
let dir = tempdir().unwrap();
let home = dir.path().join("home");
let local = dir.path().join("project/vmm_config.toml");
fs::create_dir_all(&home).unwrap();
fs::write(home.join("vmm_config.toml"), "log_level = \"info\"\n").unwrap();
let loaded = get_config_from(None, &local, Some(&home)).unwrap();
assert_eq!(loaded.log_level, "info");
assert!(!local.exists(), "discovery must not create a local config");
}
#[test]
fn a_first_run_seeds_a_default_config_in_the_config_home() {
let dir = tempdir().unwrap();
let home = dir.path().join("home");
let local = dir.path().join("project/vmm_config.toml");
let loaded = get_config_from(None, &local, Some(&home)).unwrap();
assert!(
home.join("vmm_config.toml").is_file(),
"first run must leave a config the user can edit"
);
assert_eq!(loaded.log_level, AppConfig::default().log_level);
assert!(!local.exists());
}
#[test]
fn discovery_survives_a_machine_with_no_resolvable_home() {
let dir = tempdir().unwrap();
let local = dir.path().join("vmm_config.toml");
fs::write(&local, "log_level = \"trace\"\n").unwrap();
let loaded = get_config_from(None, &local, None).unwrap();
assert_eq!(loaded.log_level, "trace");
}
#[test]
fn test_game_dir_status() {
let with_game_dir = AppConfig {
game_dir: Some("/games/valheim".to_string()),
..Default::default()
};
assert_eq!(
with_game_dir.game_dir_status(),
GameDirStatus::Set("/games/valheim")
);
let legacy = AppConfig {
install_dir: Some("/old/plugins".to_string()),
..Default::default()
};
assert_eq!(legacy.game_dir_status(), GameDirStatus::NeedsMigration);
let both = AppConfig {
game_dir: Some("/games/valheim".to_string()),
install_dir: Some("/old/plugins".to_string()),
..Default::default()
};
assert_eq!(both.game_dir_status(), GameDirStatus::Set("/games/valheim"));
assert_eq!(AppConfig::default().game_dir_status(), GameDirStatus::Unset);
}
#[test]
fn launch_config_defaults_to_native_steam() {
let launch = LaunchConfig::default();
assert_eq!(launch.store, "steam");
assert_eq!(launch.runtime, "native");
assert!(launch.extra_args.is_empty());
}
#[test]
fn base_dir_prefers_data_dir_and_expands_tilde() {
let explicit = AppConfig {
data_dir: Some("/opt/vmm".to_string()),
..Default::default()
};
assert_eq!(explicit.base_dir(), std::path::PathBuf::from("/opt/vmm"));
let expanded = AppConfig {
data_dir: Some("~/vmm-data".to_string()),
..Default::default()
};
assert!(!expanded.base_dir().to_string_lossy().starts_with('~'));
}
#[test]
fn base_dir_falls_back_to_the_config_home_when_data_dir_is_unset() {
let fallback = AppConfig::default().base_dir();
assert_eq!(fallback, std::path::PathBuf::from(&*APP_CACHE_DIR));
assert_eq!(fallback, config_home().unwrap());
}
#[test]
fn launch_settings_load_from_a_config_file() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("launch_config.toml");
fs::write(
&config_path,
"log_level = \"info\"\n\
[launch]\n\
store = \"other\"\n\
runtime = \"proton\"\n\
extra_args = [\"-nolog\"]\n",
)
.unwrap();
let loaded = get_config(Some(&config_path)).unwrap();
assert_eq!(loaded.launch.store, "other");
assert_eq!(loaded.launch.runtime, "proton");
assert_eq!(loaded.launch.extra_args, vec!["-nolog".to_string()]);
}
#[test]
fn launch_settings_fall_back_to_defaults_when_absent() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("bare_config.toml");
fs::write(&config_path, "log_level = \"info\"\n").unwrap();
let loaded = get_config(Some(&config_path)).unwrap();
assert_eq!(loaded.launch.store, "steam");
assert_eq!(loaded.launch.runtime, "native");
assert!(loaded.mod_list.is_empty());
}
#[test]
fn expand_path_expands_a_tilde() {
let home = dirs::home_dir().expect("a home directory");
assert_eq!(
expand_path("~/games/Valheim"),
home.join("games").join("Valheim")
);
}
#[test]
fn expand_path_passes_through_what_it_cannot_expand() {
assert_eq!(
expand_path("/games/Valheim"),
PathBuf::from("/games/Valheim")
);
assert_eq!(
expand_path("$VMM_UNSET_FOR_TESTS/games"),
PathBuf::from("$VMM_UNSET_FOR_TESTS/games")
);
}
#[cfg(unix)]
#[test]
fn the_unix_example_game_dir_expands_to_an_absolute_path() {
let expanded = expand_path(EXAMPLE_GAME_DIR_UNIX);
assert!(
expanded.is_absolute(),
"an example a user pastes into their config must resolve absolutely; got: {}",
expanded.display()
);
}
#[test]
fn the_windows_example_game_dir_is_drive_rooted_and_needs_no_expansion() {
assert!(
!EXAMPLE_GAME_DIR_WINDOWS.contains('$'),
"a Windows example must not depend on variable expansion; got: {EXAMPLE_GAME_DIR_WINDOWS}"
);
assert!(
EXAMPLE_GAME_DIR_WINDOWS
.strip_prefix(char::is_alphabetic)
.is_some_and(|rest| rest.starts_with(":/")),
"a Windows example must be drive-rooted; got: {EXAMPLE_GAME_DIR_WINDOWS}"
);
}
#[cfg(unix)]
#[test]
fn expand_path_expands_environment_variables() {
let home = std::env::var("HOME").unwrap();
assert_eq!(
expand_path("$HOME/games/Valheim"),
PathBuf::from(&home).join("games/Valheim")
);
assert_eq!(
expand_path("${HOME}/games/Valheim"),
PathBuf::from(&home).join("games/Valheim")
);
}
#[cfg(unix)]
#[test]
fn config_home_keeps_the_location_the_xdg_crate_produced() {
let expected = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap()).join(".config"))
.join("vmm");
assert_eq!(config_home().unwrap(), expected);
}
#[test]
fn place_config_file_creates_the_config_directory_when_it_is_missing() {
let dir = tempdir().unwrap();
let home = dir.path().join("vmm");
assert!(!home.exists());
let path = place_config_file(&home, "vmm_config.toml").unwrap();
assert_eq!(path, home.join("vmm_config.toml"));
assert!(home.is_dir());
}
#[test]
fn a_fresh_config_is_written_into_a_config_home_that_did_not_exist() {
let dir = tempdir().unwrap();
let home = dir.path().join("vmm");
let path = place_config_file(&home, "vmm_config.toml").unwrap();
create_missing_config_file(&path, &AppConfig::default()).unwrap();
assert!(path.is_file());
}
#[test]
fn test_suggest_game_dir() {
assert_eq!(
suggest_game_dir("/games/Valheim/BepInEx/plugins").as_deref(),
Some("/games/Valheim")
);
assert_eq!(
suggest_game_dir("~/.steam/steam/steamapps/common/Valheim/BepInEx").as_deref(),
Some("~/.steam/steam/steamapps/common/Valheim")
);
assert_eq!(
suggest_game_dir("/games/Valheim/bepinex/plugins").as_deref(),
Some("/games/Valheim")
);
assert_eq!(suggest_game_dir("/some/other/path"), None);
}
#[test]
fn suggest_game_dir_handles_windows_paths_and_repeated_segments() {
assert_eq!(
suggest_game_dir("C:\\Games\\Valheim\\BepInEx\\plugins").as_deref(),
Some("C:/Games/Valheim")
);
assert_eq!(
suggest_game_dir("/games/bepinex-mods/Valheim/BepInEx/plugins").as_deref(),
Some("/games/bepinex-mods/Valheim")
);
assert_eq!(suggest_game_dir("/games/bepinex-mods/plugins"), None);
assert_eq!(suggest_game_dir("/BepInEx/plugins"), None);
}
}