use std::path::{Path, PathBuf};
use crate::vpinball_config::VPinballConfig;
use dialoguer::Select;
use dialoguer::theme::ColorfulTheme;
use figment::{
Figment,
providers::{Format, Toml},
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::Write;
use std::{env, io};
const CONFIGURATION_FILE_NAME: &str = "vpxtool.cfg";
#[derive(Deserialize, Serialize, Debug, PartialEq, Clone, Eq)]
pub struct LaunchTemplate {
pub name: String,
pub executable: PathBuf,
pub arguments: Option<Vec<String>>,
pub env: Option<HashMap<String, String>>,
pub vpinball_config: Option<PathBuf>,
}
#[derive(Deserialize, Serialize)]
pub struct Config {
pub vpx_executable: PathBuf,
pub vpx_config: Option<PathBuf>,
pub tables_folder: Option<PathBuf>,
pub tables_scan_max_depth: Option<usize>,
pub diff: Option<String>,
pub editor: Option<String>,
pub launch_templates: Option<Vec<LaunchTemplate>>,
pub vpxz_excludes: Option<Vec<String>>,
}
#[derive(PartialEq, Debug, Clone)]
pub struct ResolvedConfig {
pub vpx_executable: PathBuf,
pub launch_templates: Vec<LaunchTemplate>,
pub vpx_config: PathBuf,
pub tables_folder: PathBuf,
pub tables_index_path: PathBuf,
pub tables_scan_max_depth: Option<usize>,
pub diff: Option<String>,
pub editor: Option<String>,
pub vpxz_excludes: Vec<String>,
}
pub fn default_vpxz_excludes() -> Vec<String> {
vec![
"Downloads/".to_string(),
"downloads/".to_string(),
"cache/".to_string(),
"**/Thumbs.db".to_string(),
"**/.DS_Store".to_string(),
"**/*.bak".to_string(),
"**/*~".to_string(),
]
}
impl ResolvedConfig {
pub fn global_pinmame_folder(&self) -> PathBuf {
if cfg!(target_os = "windows") {
self.vpx_executable.parent().unwrap().join("VPinMAME")
} else {
dirs::home_dir().unwrap().join(".pinmame")
}
}
pub fn configured_pinmame_folder(&self) -> Option<PathBuf> {
if self.vpx_config.exists() {
let vpinball_config = VPinballConfig::read(&self.vpx_config).unwrap();
if let Some(value) = vpinball_config.get_pinmame_path() {
if value.trim().is_empty() {
return None;
}
let path = PathBuf::from(value);
return Some(path);
}
}
None
}
}
pub fn config_path() -> Option<PathBuf> {
let home_directory_configuration_path = home_config_path();
if home_directory_configuration_path.exists() {
return Some(home_directory_configuration_path);
}
let old_config_path = old_home_config_path();
if old_config_path.exists() {
println!(
"Migrating config file from {old_config_path:?} to {home_directory_configuration_path:?}"
);
std::fs::create_dir_all(home_directory_configuration_path.parent().unwrap()).ok()?;
std::fs::rename(&old_config_path, &home_directory_configuration_path).ok()?;
return Some(home_directory_configuration_path);
}
let local_configuration_path = local_config_path();
if local_configuration_path.exists() {
return Some(local_configuration_path);
}
None
}
pub enum SetupConfigResult {
Configured(PathBuf),
Existing(PathBuf),
}
pub fn setup_config() -> io::Result<SetupConfigResult> {
let existing_config_path = config_path();
match existing_config_path {
Some(path) => Ok(SetupConfigResult::Existing(path)),
None => {
println!("Warning: Failed find a config file.");
let new_config = create_default_config()?;
Ok(SetupConfigResult::Configured(new_config.0))
}
}
}
pub fn load_or_setup_config() -> io::Result<(PathBuf, ResolvedConfig)> {
match load_config()? {
Some(loaded) => Ok(loaded),
None => {
println!("Warning: Failed find a config file.");
create_default_config()
}
}
}
pub fn load_config() -> io::Result<Option<(PathBuf, ResolvedConfig)>> {
match config_path() {
Some(config_path) => {
let config = read_config(&config_path)?;
Ok(Some((config_path, config)))
}
None => Ok(None),
}
}
fn read_config(config_path: &Path) -> io::Result<ResolvedConfig> {
let figment = Figment::new().merge(Toml::file(config_path));
let config: Config = figment.extract().map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to load config file: {e}"),
)
})?;
let tables_folder = config
.tables_folder
.unwrap_or(default_tables_root(&config.vpx_executable));
let vpx_config = config
.vpx_config
.unwrap_or_else(|| default_vpinball_ini_file(&config.vpx_executable));
let launch_templates = config.launch_templates.unwrap_or_else(|| {
generate_default_launch_templates(&config.vpx_executable)
});
let resolved_config = ResolvedConfig {
vpx_executable: config.vpx_executable,
launch_templates,
vpx_config,
tables_folder: tables_folder.clone(),
tables_index_path: tables_index_path(&tables_folder),
tables_scan_max_depth: config.tables_scan_max_depth,
diff: config.diff,
editor: config.editor,
vpxz_excludes: config.vpxz_excludes.unwrap_or_else(default_vpxz_excludes),
};
Ok(resolved_config)
}
fn generate_default_launch_templates(vpx_executable: &Path) -> Vec<LaunchTemplate> {
let default_env = HashMap::from([
("SDL_VIDEODRIVER".to_string(), "".to_string()),
("SDL_RENDER_DRIVER".to_string(), "".to_string()),
]);
vec![LaunchTemplate {
name: "Launch".to_string(),
executable: vpx_executable.to_owned(),
arguments: None,
env: Some(default_env),
vpinball_config: None,
}]
}
pub fn tables_index_path(tables_folder: &Path) -> PathBuf {
tables_folder.join("vpxtool_index.json")
}
pub fn clear_config() -> io::Result<Option<PathBuf>> {
let config_path = config_path();
match config_path {
Some(path) => {
std::fs::remove_file(&path)?;
Ok(Some(path))
}
None => Ok(None),
}
}
fn local_config_path() -> PathBuf {
Path::new(CONFIGURATION_FILE_NAME).to_path_buf()
}
fn old_home_config_path() -> PathBuf {
dirs::config_dir().unwrap().join(CONFIGURATION_FILE_NAME)
}
fn home_config_path() -> PathBuf {
dirs::config_dir()
.unwrap()
.join("vpxtool")
.join(CONFIGURATION_FILE_NAME)
}
fn default_vpinball_ini_file(vpx_executable_path: &Path) -> PathBuf {
let batocera_path = PathBuf::from("/userdata/system/configs/vpinball/VPinballX.ini");
if batocera_path.exists() {
return batocera_path;
}
if let Some(data_dir) = dirs::data_dir()
&& let Some(path) = newest_versioned_vpinball_ini(&data_dir.join("VPinballX"))
{
return path;
}
legacy_vpinball_ini(vpx_executable_path)
}
fn newest_versioned_vpinball_ini(base: &Path) -> Option<PathBuf> {
let mut versions: Vec<((u32, u32), PathBuf)> = fs::read_dir(base)
.ok()?
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|e| {
let name = e.file_name();
let parsed = parse_major_minor(name.to_str()?)?;
Some((parsed, e.path()))
})
.collect();
versions.sort_by_key(|(version, _)| std::cmp::Reverse(*version));
versions.into_iter().find_map(|(_, dir)| {
let ini = dir.join("VPinballX.ini");
ini.exists().then_some(ini)
})
}
fn parse_major_minor(s: &str) -> Option<(u32, u32)> {
let mut parts = s.splitn(2, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
Some((major, minor))
}
pub fn stale_vpx_config_suggestion(saved: &Path, vpx_executable: &Path) -> Option<PathBuf> {
if saved != legacy_vpinball_ini(vpx_executable) {
return None;
}
let resolved = default_vpinball_ini_file(vpx_executable);
(resolved != saved).then_some(resolved)
}
pub fn rewrite_vpx_config(config_file: &Path, vpx_config: &Path) -> io::Result<()> {
let toml_str = std::fs::read_to_string(config_file)?;
let mut doc: toml_edit::DocumentMut = toml_str.parse().map_err(io::Error::other)?;
doc["vpx_config"] = toml_edit::value(vpx_config.to_string_lossy().into_owned());
std::fs::write(config_file, doc.to_string())
}
fn legacy_vpinball_ini(vpx_executable_path: &Path) -> PathBuf {
if cfg!(target_os = "windows") {
vpx_executable_path.parent().unwrap().join("VPinballX.ini")
} else {
dirs::home_dir()
.unwrap()
.join(".vpinball")
.join("VPinballX.ini")
}
}
fn create_default_config() -> io::Result<(PathBuf, ResolvedConfig)> {
let local_configuration_path = local_config_path();
let home_directory_configuration_path = home_config_path();
let choices: Vec<(&str, String)> = vec![
(
"Home",
home_directory_configuration_path
.to_string_lossy()
.to_string(),
),
(
"Local",
local_configuration_path.to_string_lossy().to_string(),
),
];
let selection_opt = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Choose a configuration location:")
.default(0)
.items(
choices
.iter()
.map(|(choice, description)| format!("{choice} \x1b[90m{description}\x1b[0m"))
.collect::<Vec<_>>(),
)
.interact_opt()?;
let config_file = if let Some(index) = selection_opt {
let (_selected_choice, path) = (&choices[index].0, &choices[index].1);
PathBuf::from(path)
} else {
unreachable!("Failed to select a configuration file path.");
};
let mut vpx_executable = default_vpinball_executable();
if !vpx_executable.exists() {
println!("Warning: Failed to detect the vpinball executable.");
print!("vpinball executable path: ");
io::stdout().flush().expect("Failed to flush stdout");
let mut new_executable_path = String::new();
io::stdin()
.read_line(&mut new_executable_path)
.expect("Failed to read line");
vpx_executable = PathBuf::from(new_executable_path.trim().to_string());
if !vpx_executable.exists() {
println!("Error: input file path wasn't found.");
println!("Executable path is not set. ");
std::process::exit(1);
}
}
write_default_config(&config_file, &vpx_executable)?;
let resolved_config = read_config(&config_file)?;
Ok((config_file, resolved_config))
}
fn write_default_config(config_file: &Path, vpx_executable: &Path) -> io::Result<()> {
let launch_templates = generate_default_launch_templates(vpx_executable);
let vpx_config = default_vpinball_ini_file(vpx_executable);
let tables_folder = default_tables_root(vpx_executable);
let config = Config {
vpx_executable: vpx_executable.to_owned(),
launch_templates: Some(launch_templates),
vpx_config: Some(vpx_config.clone()),
tables_folder: Some(tables_folder.clone()),
tables_scan_max_depth: None,
diff: None,
editor: None,
vpxz_excludes: None,
};
write_config(config_file, &config)?;
Ok(())
}
fn write_config(config_file: &Path, config: &Config) -> io::Result<()> {
let toml = toml::to_string(&config).unwrap();
if let Some(parent) = config_file.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = File::create(config_file)?;
file.write_all(toml.as_bytes())
}
pub fn default_tables_root(vpx_executable: &Path) -> PathBuf {
if cfg!(target_os = "macos") {
dirs::home_dir().unwrap().join(".vpinball").join("tables")
} else {
vpx_executable.parent().unwrap().join("tables")
}
}
fn default_vpinball_executable() -> PathBuf {
if cfg!(target_os = "windows") {
let dir = PathBuf::from("c:\\vPinball\\VisualPinball");
let exe = dir.join("VPinballX64.exe");
let local = env::current_dir().unwrap();
if local.join("VPinballX64.exe").exists() {
local.join("VPinballX64.exe")
} else if local.join("VPinballX.exe").exists() {
local.join("VPinballX.exe")
} else if exe.exists() {
exe
} else {
dir.join("VPinballX.exe")
}
} else if cfg!(target_os = "macos") {
let dmg_install =
PathBuf::from("/Applications/VPinballX_GL.app/Contents/MacOS/VPinballX_GL");
if dmg_install.exists() {
dmg_install
} else {
let mut local = env::current_dir().unwrap();
local = local.join("VPinballX_GL");
local
}
} else {
let mut local = env::current_dir().unwrap();
local = local.join("VPinballX_GL");
if local.exists() {
local
} else {
let home = dirs::home_dir().unwrap();
home.join("vpinball").join("vpinball").join("VPinballX_GL")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use testdir::testdir;
#[cfg(target_os = "linux")]
#[test]
fn test_write_default_config_linux() -> io::Result<()> {
use std::io::Read;
let temp_dir = testdir!();
let config_file = temp_dir.join(CONFIGURATION_FILE_NAME);
write_default_config(&config_file, &PathBuf::from("/home/me/vpinball"))?;
let mut file = File::open(&config_file)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
println!("Config file contents: {contents}");
let config = read_config(&config_file)?;
assert_eq!(
config,
ResolvedConfig {
vpx_executable: PathBuf::from("/home/me/vpinball"),
launch_templates: vec!(LaunchTemplate {
name: "Launch".to_string(),
executable: PathBuf::from("/home/me/vpinball"),
arguments: None,
env: Some(HashMap::from([
("SDL_VIDEODRIVER".to_string(), "".to_string()),
("SDL_RENDER_DRIVER".to_string(), "".to_string()),
])),
vpinball_config: None,
},),
vpx_config: default_vpinball_ini_file(&PathBuf::from("/home/me/vpinball")),
tables_folder: PathBuf::from("/home/me/tables"),
tables_index_path: PathBuf::from("/home/me/tables/vpxtool_index.json"),
tables_scan_max_depth: None,
diff: None,
editor: None,
vpxz_excludes: default_vpxz_excludes(),
}
);
Ok(())
}
#[cfg(target_os = "linux")]
#[test]
fn test_read_launch_template_with_vpinball_config() -> io::Result<()> {
let temp_dir = testdir!();
let config_file = temp_dir.join(CONFIGURATION_FILE_NAME);
let mut file = File::create(&config_file)?;
file.write_all(
b"vpx_executable = \"/tmp/test/vpinball\"\n\
\n\
[[launch_templates]]\n\
name = \"Launch BGFX\"\n\
executable = \"/tmp/test/VPinballX_BGFX\"\n\
vpinball_config = \"/tmp/test/VPinballX_BGFX.ini\"\n\
\n\
[[launch_templates]]\n\
name = \"Launch GL\"\n\
executable = \"/tmp/test/VPinballX_GL\"\n",
)?;
let config = read_config(&config_file)?;
assert_eq!(
config.launch_templates,
vec![
LaunchTemplate {
name: "Launch BGFX".to_string(),
executable: PathBuf::from("/tmp/test/VPinballX_BGFX"),
arguments: None,
env: None,
vpinball_config: Some(PathBuf::from("/tmp/test/VPinballX_BGFX.ini")),
},
LaunchTemplate {
name: "Launch GL".to_string(),
executable: PathBuf::from("/tmp/test/VPinballX_GL"),
arguments: None,
env: None,
vpinball_config: None,
},
]
);
Ok(())
}
#[test]
fn test_read_config_with_tables_scan_max_depth() -> io::Result<()> {
let temp_dir = testdir!();
let config_file = temp_dir.join(CONFIGURATION_FILE_NAME);
let mut file = File::create(&config_file)?;
file.write_all(
b"vpx_executable = \"/tmp/test/vpinball\"\n\
tables_scan_max_depth = 2\n",
)?;
let config = read_config(&config_file)?;
assert_eq!(config.tables_scan_max_depth, Some(2));
Ok(())
}
#[cfg(target_os = "linux")]
#[test]
fn test_read_incomplete_config_linux() -> io::Result<()> {
let temp_dir = testdir!();
let config_file = temp_dir.join(CONFIGURATION_FILE_NAME);
let mut file = File::create(&config_file)?;
file.write_all(b"vpx_executable = \"/tmp/test/vpinball\"")?;
let config = read_config(&config_file)?;
assert_eq!(
config,
ResolvedConfig {
vpx_executable: PathBuf::from("/tmp/test/vpinball"),
launch_templates: vec!(LaunchTemplate {
name: "Launch".to_string(),
executable: PathBuf::from("/tmp/test/vpinball"),
arguments: None,
env: Some(HashMap::from([
("SDL_VIDEODRIVER".to_string(), "".to_string()),
("SDL_RENDER_DRIVER".to_string(), "".to_string()),
])),
vpinball_config: None,
},),
vpx_config: default_vpinball_ini_file(&PathBuf::from("/tmp/test/vpinball")),
tables_folder: PathBuf::from("/tmp/test/tables"),
tables_index_path: PathBuf::from("/tmp/test/tables/vpxtool_index.json"),
tables_scan_max_depth: None,
diff: None,
editor: None,
vpxz_excludes: default_vpxz_excludes(),
}
);
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn test_read_incomplete_config_macos() -> io::Result<()> {
let temp_dir = testdir!();
let config_file = temp_dir.join(CONFIGURATION_FILE_NAME);
let mut file = File::create(&config_file)?;
file.write_all(b"vpx_executable = \"/tmp/test/vpinball\"")?;
let config = read_config(&config_file)?;
let expected_tables_dir = dirs::home_dir().unwrap().join(".vpinball").join("tables");
assert_eq!(
config,
ResolvedConfig {
vpx_executable: PathBuf::from("/tmp/test/vpinball"),
launch_templates: vec!(LaunchTemplate {
name: "Launch".to_string(),
executable: PathBuf::from("/tmp/test/vpinball"),
arguments: None,
env: Some(HashMap::from([
("SDL_VIDEODRIVER".to_string(), "".to_string()),
("SDL_RENDER_DRIVER".to_string(), "".to_string()),
])),
vpinball_config: None,
}),
vpx_config: default_vpinball_ini_file(&PathBuf::from("/tmp/test/vpinball")),
tables_folder: expected_tables_dir.clone(),
tables_index_path: expected_tables_dir.join("vpxtool_index.json"),
tables_scan_max_depth: None,
diff: None,
editor: None,
vpxz_excludes: default_vpxz_excludes(),
}
);
Ok(())
}
#[cfg(target_os = "windows")]
#[test]
fn test_read_incomplete_config_windows() -> io::Result<()> {
let temp_dir = testdir!();
let config_file = temp_dir.join(CONFIGURATION_FILE_NAME);
let mut file = File::create(&config_file)?;
file.write_all(b"vpx_executable = \"C:\\\\test\\\\vpinball\"")?;
let config = read_config(&config_file)?;
assert_eq!(
config,
ResolvedConfig {
vpx_executable: PathBuf::from("C:\\test\\vpinball"),
vpx_config: default_vpinball_ini_file(&PathBuf::from("C:\\test\\vpinball")),
tables_folder: PathBuf::from("C:\\test\\tables"),
tables_index_path: PathBuf::from("C:\\test\\tables\\vpxtool_index.json"),
tables_scan_max_depth: None,
diff: None,
editor: None,
vpxz_excludes: default_vpxz_excludes(),
launch_templates: vec!(LaunchTemplate {
name: "Launch".to_string(),
executable: PathBuf::from("C:\\test\\vpinball"),
arguments: None,
env: Some(HashMap::from([
("SDL_VIDEODRIVER".to_string(), "".to_string()),
("SDL_RENDER_DRIVER".to_string(), "".to_string()),
])),
vpinball_config: None,
})
}
);
Ok(())
}
#[test]
fn test_parse_major_minor() {
assert_eq!(parse_major_minor("10.8"), Some((10, 8)));
assert_eq!(parse_major_minor("10.10"), Some((10, 10)));
assert_eq!(parse_major_minor("11.0"), Some((11, 0)));
assert_eq!(parse_major_minor("10"), None);
assert_eq!(parse_major_minor("10.8.0"), None);
assert_eq!(parse_major_minor(""), None);
assert_eq!(parse_major_minor("foo"), None);
}
#[test]
fn test_newest_versioned_vpinball_ini_picks_highest() -> io::Result<()> {
let base = testdir!().join("VPinballX");
for (version, has_ini) in [("10.8", true), ("10.10", true), ("11.0", false)] {
let dir = base.join(version);
fs::create_dir_all(&dir)?;
if has_ini {
File::create(dir.join("VPinballX.ini"))?;
}
}
fs::create_dir_all(base.join("not-a-version"))?;
let picked = newest_versioned_vpinball_ini(&base).expect("expected an ini path");
assert_eq!(picked, base.join("10.10").join("VPinballX.ini"));
Ok(())
}
#[test]
fn test_newest_versioned_vpinball_ini_returns_none_when_empty() {
let base = testdir!().join("does-not-exist");
assert_eq!(newest_versioned_vpinball_ini(&base), None);
}
#[test]
fn test_rewrite_vpx_config_preserves_comments_and_layout() -> io::Result<()> {
let temp_dir = testdir!();
let config_file = temp_dir.join(CONFIGURATION_FILE_NAME);
let original = "\
# vpxtool config for the tournament rig
vpx_executable = \"/home/me/vpinball\"
# old default - vpxtool will offer to fix this
vpx_config = \"/home/me/.vpinball/VPinballX.ini\"
# don't recurse beyond two levels
tables_scan_max_depth = 2
";
std::fs::write(&config_file, original)?;
let new_path = PathBuf::from("/home/me/.local/share/VPinballX/10.8/VPinballX.ini");
rewrite_vpx_config(&config_file, &new_path)?;
let after = std::fs::read_to_string(&config_file)?;
let expected = original.replace(
"/home/me/.vpinball/VPinballX.ini",
"/home/me/.local/share/VPinballX/10.8/VPinballX.ini",
);
assert_eq!(after, expected);
Ok(())
}
}