use std::{
cell::{Cell, RefCell},
fmt::{self, Display},
fs::{create_dir_all, metadata, read_to_string},
path::{Path, PathBuf},
};
use crate::{ConfigError, GameSetting};
use error::bail_config;
use std::collections::HashSet;
pub mod directorysetting;
use directorysetting::DirectorySetting;
pub mod filesetting;
use filesetting::FileSetting;
pub mod gamesetting;
use gamesetting::GameSettingType;
pub mod genericsetting;
use genericsetting::GenericSetting;
pub mod encodingsetting;
use encodingsetting::EncodingSetting;
pub mod error;
mod hash;
use hash::{FxHashMap, FxHashSet};
#[macro_use]
mod singletonsetting;
mod strings;
mod util;
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum SettingValue {
DataDirectory(DirectorySetting),
GameSetting(GameSettingType),
UserData(DirectorySetting),
DataLocal(DirectorySetting),
Resources(DirectorySetting),
Encoding(EncodingSetting),
SubConfiguration(DirectorySetting),
Generic(GenericSetting),
Replace(GenericSetting),
ContentFile(FileSetting),
BethArchive(FileSetting),
Groundcover(FileSetting),
TrailingComment(TrailingComment),
}
#[derive(Debug, Clone)]
pub struct TrailingComment {
meta: crate::GameSettingMeta,
}
impl TrailingComment {
#[must_use]
pub fn meta(&self) -> &crate::GameSettingMeta {
&self.meta
}
}
impl Display for TrailingComment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.meta.comment)
}
}
impl crate::GameSetting for TrailingComment {
fn meta(&self) -> &crate::GameSettingMeta {
&self.meta
}
fn meta_mut(&mut self) -> &mut crate::GameSettingMeta {
&mut self.meta
}
}
const SERIALIZER_STAMP_PREFIX: &str = "# OpenMW-Config Serializer Version:";
fn write_serializer_stamp(out: &mut impl std::fmt::Write) -> fmt::Result {
writeln!(
out,
"{SERIALIZER_STAMP_PREFIX} {}",
env!("CARGO_PKG_VERSION")
)
}
impl Display for SettingValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let str = match self {
SettingValue::TrailingComment(trailing) => {
return write!(f, "{}", trailing.meta().comment);
}
SettingValue::Encoding(encoding_setting) => encoding_setting.to_string(),
SettingValue::UserData(userdata_setting) => format!(
"{}user-data={}",
userdata_setting.meta().comment,
userdata_setting.original()
),
SettingValue::DataLocal(data_local_setting) => format!(
"{}data-local={}",
data_local_setting.meta().comment,
data_local_setting.original(),
),
SettingValue::Resources(resources_setting) => format!(
"{}resources={}",
resources_setting.meta().comment,
resources_setting.original()
),
SettingValue::GameSetting(game_setting) => game_setting.to_string(),
SettingValue::DataDirectory(data_directory) => format!(
"{}data={}",
data_directory.meta().comment,
data_directory.original()
),
SettingValue::SubConfiguration(sub_config) => format!(
"{}config={}",
sub_config.meta().comment,
sub_config.original()
),
SettingValue::Generic(generic) | SettingValue::Replace(generic) => generic.to_string(),
SettingValue::ContentFile(plugin) => {
format!("{}content={}", plugin.meta().comment, plugin.value())
}
SettingValue::BethArchive(archive) => {
format!(
"{}fallback-archive={}",
archive.meta().comment,
archive.value(),
)
}
SettingValue::Groundcover(grass) => {
format!("{}groundcover={}", grass.meta().comment, grass.value())
}
};
writeln!(f, "{str}")
}
}
impl From<GameSettingType> for SettingValue {
fn from(g: GameSettingType) -> Self {
SettingValue::GameSetting(g)
}
}
impl From<DirectorySetting> for SettingValue {
fn from(d: DirectorySetting) -> Self {
SettingValue::DataDirectory(d)
}
}
impl SettingValue {
#[must_use]
pub fn meta(&self) -> &crate::GameSettingMeta {
match self {
SettingValue::BethArchive(setting)
| SettingValue::Groundcover(setting)
| SettingValue::ContentFile(setting) => setting.meta(),
SettingValue::UserData(setting)
| SettingValue::DataLocal(setting)
| SettingValue::DataDirectory(setting)
| SettingValue::Resources(setting)
| SettingValue::SubConfiguration(setting) => setting.meta(),
SettingValue::GameSetting(setting) => setting.meta(),
SettingValue::Encoding(setting) => setting.meta(),
SettingValue::Generic(setting) | SettingValue::Replace(setting) => setting.meta(),
SettingValue::TrailingComment(setting) => setting.meta(),
}
}
fn meta_mut(&mut self) -> &mut crate::GameSettingMeta {
match self {
SettingValue::BethArchive(setting)
| SettingValue::Groundcover(setting)
| SettingValue::ContentFile(setting) => setting.meta_mut(),
SettingValue::UserData(setting)
| SettingValue::DataLocal(setting)
| SettingValue::DataDirectory(setting)
| SettingValue::Resources(setting)
| SettingValue::SubConfiguration(setting) => setting.meta_mut(),
SettingValue::GameSetting(setting) => setting.meta_mut(),
SettingValue::Encoding(setting) => setting.meta_mut(),
SettingValue::Generic(setting) | SettingValue::Replace(setting) => setting.meta_mut(),
SettingValue::TrailingComment(setting) => setting.meta_mut(),
}
}
fn to_resolved_string(&self) -> Option<String> {
match self {
SettingValue::UserData(userdata_setting) => Some(format!(
"{}user-data={}\n",
userdata_setting.meta().comment,
userdata_setting.parsed().display()
)),
SettingValue::DataLocal(data_local_setting) => Some(format!(
"{}data-local={}\n",
data_local_setting.meta().comment,
data_local_setting.parsed().display()
)),
SettingValue::Resources(resources_setting) => Some(format!(
"{}resources={}\n",
resources_setting.meta().comment,
resources_setting.parsed().display()
)),
SettingValue::DataDirectory(data_directory) => Some(format!(
"{}data={}\n",
data_directory.meta().comment,
data_directory.parsed().display()
)),
SettingValue::SubConfiguration(_) | SettingValue::Replace(_) => None,
SettingValue::Generic(generic) if generic.key().eq_ignore_ascii_case("replace") => None,
_ => Some(self.to_string()),
}
}
}
fn normalize_comment(comment: &str) -> String {
if comment.is_empty() {
return String::new();
}
let mut normalized = String::with_capacity(comment.len() + 2);
for line in comment.strip_suffix('\n').unwrap_or(comment).split('\n') {
if !line.trim().is_empty() && !line.trim_start().starts_with('#') {
normalized.push_str("# ");
}
normalized.push_str(line);
normalized.push('\n');
}
normalized
}
#[derive(Debug, Default, Clone)]
pub struct OpenMWConfiguration {
root_config: PathBuf,
settings: Vec<SettingValue>,
chain: Vec<ConfigChainEntry>,
indexed_content: FxHashSet<String>,
indexed_groundcover: FxHashSet<String>,
indexed_archives: FxHashSet<String>,
indexed_data_dirs: FxHashSet<PathBuf>,
lists: ListIndex,
indexed_game_setting_last: RefCell<FxHashMap<String, usize>>,
indexed_game_setting_order: RefCell<Vec<usize>>,
game_setting_indexes_dirty: Cell<bool>,
user_replaces: Vec<ListOption>,
user_config_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ListOption {
Content,
Groundcover,
FallbackArchive,
Data,
Fallback,
Generic(String),
}
impl ListOption {
fn name(&self) -> &str {
match self {
ListOption::Content => "content",
ListOption::Groundcover => "groundcover",
ListOption::FallbackArchive => "fallback-archive",
ListOption::Data => "data",
ListOption::Fallback => "fallback",
ListOption::Generic(key) => key,
}
}
fn holds(&self, setting: &SettingValue) -> bool {
match (self, setting) {
(ListOption::Content, SettingValue::ContentFile(_))
| (ListOption::Groundcover, SettingValue::Groundcover(_))
| (ListOption::FallbackArchive, SettingValue::BethArchive(_))
| (ListOption::Data, SettingValue::DataDirectory(_))
| (ListOption::Fallback, SettingValue::GameSetting(_)) => true,
(ListOption::Generic(key), SettingValue::Generic(generic)) => generic.key() == key,
_ => false,
}
}
fn is_replaced_by(&self, replace: &GenericSetting) -> bool {
replace.value() == self.name()
}
}
fn list_name(setting: &SettingValue) -> Option<&str> {
match setting {
SettingValue::Replace(_) => Some("replace"),
SettingValue::ContentFile(_) => Some("content"),
SettingValue::Groundcover(_) => Some("groundcover"),
SettingValue::BethArchive(_) => Some("fallback-archive"),
SettingValue::DataDirectory(_) => Some("data"),
SettingValue::GameSetting(_) => Some("fallback"),
SettingValue::Generic(generic) => Some(generic.key()),
_ => None,
}
}
struct ConfigFile {
settings: Vec<(SettingValue, usize)>,
replaces: Vec<String>,
}
impl ConfigFile {
fn sub_configs(&self) -> impl DoubleEndedIterator<Item = &DirectorySetting> {
self.settings
.iter()
.filter_map(|(setting, _)| match setting {
SettingValue::SubConfiguration(dir) => Some(dir),
_ => None,
})
}
#[allow(clippy::too_many_lines)]
fn read(cfg_file_path: &Path) -> Result<Self, ConfigError> {
let lines = read_to_string(cfg_file_path)?;
let source = cfg_file_path.to_path_buf();
let mut settings: Vec<(SettingValue, usize)> = Vec::new();
let mut replaces = Vec::new();
let mut queued_comment = String::new();
let mut sub_configs: Vec<(String, String)> = Vec::new();
for (index, line) in lines.lines().enumerate() {
let line_no = index + 1;
let trimmed = line.trim();
if trimmed.is_empty() {
queued_comment.push('\n');
continue;
} else if trimmed.starts_with('#') {
queued_comment.push_str(line);
queued_comment.push('\n');
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
bail_config!(invalid_line, trimmed.into(), source, line_no);
};
let key = key.trim();
let value = value.trim();
let comment = &mut queued_comment;
let setting = match key {
"content" => SettingValue::ContentFile(FileSetting::new(value, &source, comment)),
"groundcover" => {
SettingValue::Groundcover(FileSetting::new(value, &source, comment))
}
"fallback-archive" => {
SettingValue::BethArchive(FileSetting::new(value, &source, comment))
}
"fallback" => SettingValue::GameSetting(
GameSettingType::try_from((value.to_owned(), source.clone(), comment))
.map_err(|error| match error {
ConfigError::InvalidGameSetting {
value, config_path, ..
} => ConfigError::InvalidGameSetting {
value,
config_path,
line: Some(line_no),
},
_ => error,
})?,
),
"encoding" => {
let encoding = EncodingSetting::try_from((value.to_owned(), &source, comment))
.map_err(|error| match error {
ConfigError::BadEncoding {
value, config_path, ..
} => ConfigError::BadEncoding {
value,
config_path,
line: Some(line_no),
},
_ => error,
})?;
if let Some(first) = settings
.iter_mut()
.find(|(setting, _)| matches!(setting, SettingValue::Encoding(_)))
{
*first = (SettingValue::Encoding(encoding), line_no);
continue;
}
SettingValue::Encoding(encoding)
}
"config" => {
sub_configs.push((value.to_owned(), std::mem::take(comment)));
continue;
}
"data" => SettingValue::DataDirectory(DirectorySetting::new(
value,
source.clone(),
comment,
)),
"resources" => {
SettingValue::Resources(DirectorySetting::new(value, source.clone(), comment))
}
"user-data" => {
SettingValue::UserData(DirectorySetting::new(value, source.clone(), comment))
}
"data-local" => {
SettingValue::DataLocal(DirectorySetting::new(value, source.clone(), comment))
}
"replace" => {
replaces.push(value.to_owned());
SettingValue::Replace(GenericSetting::new(key, value, &source, comment))
}
_ => SettingValue::Generic(GenericSetting::new(key, value, &source, comment)),
};
settings.push((setting, line_no));
}
for (subconfig_path, mut comment) in sub_configs {
let setting = DirectorySetting::new(subconfig_path, source.clone(), &mut comment);
settings.push((SettingValue::SubConfiguration(setting), 0));
}
let trailing: String = queued_comment
.split_inclusive('\n')
.filter(|line| !line.starts_with(SERIALIZER_STAMP_PREFIX))
.collect();
if !trailing.is_empty() {
settings.push((
SettingValue::TrailingComment(TrailingComment {
meta: crate::GameSettingMeta {
source_config: source,
comment: trailing,
},
}),
0,
));
}
Ok(Self { settings, replaces })
}
}
#[derive(Debug, Default, Clone)]
struct ListIndex {
content: Vec<usize>,
groundcover: Vec<usize>,
archives: Vec<usize>,
data_dirs: Vec<usize>,
sub_configs: Vec<usize>,
generic: Vec<usize>,
}
#[cfg(feature = "luau")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ListKind {
Content,
Groundcover,
Archives,
DataDirectories,
SubConfigs,
Generic,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum ConfigChainStatus {
Loaded,
SkippedMissing,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ConfigChainEntry {
path: PathBuf,
depth: usize,
status: ConfigChainStatus,
}
impl ConfigChainEntry {
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn depth(&self) -> usize {
self.depth
}
#[must_use]
pub fn status(&self) -> &ConfigChainStatus {
&self.status
}
}
impl OpenMWConfiguration {
fn inject_data_directories(&mut self) {
self.settings
.retain(|setting| !Self::is_synthetic_data_directory(setting));
let engine_vfs = self.resources().map(|resources| {
let mut engine_vfs = DirectorySetting::new(
resources.parsed().join("vfs").to_string_lossy(),
resources.meta.source_config.clone(),
&mut String::new(),
);
engine_vfs.meta.source_config = PathBuf::new();
engine_vfs
});
let data_local = self.data_local().map(|data_local| {
let mut data_local = data_local.clone();
data_local.meta.source_config = PathBuf::new();
data_local.meta.comment.clear();
data_local
});
if let Some(engine_vfs) = engine_vfs {
self.settings
.insert(0, SettingValue::DataDirectory(engine_vfs));
}
if let Some(data_local) = data_local {
self.settings.push(SettingValue::DataDirectory(data_local));
}
}
fn rebuild_indexes(&mut self) {
self.inject_data_directories();
self.indexed_content.clear();
self.indexed_groundcover.clear();
self.indexed_archives.clear();
self.indexed_data_dirs.clear();
let lists = &mut self.lists;
lists.content.clear();
lists.groundcover.clear();
lists.archives.clear();
lists.data_dirs.clear();
lists.sub_configs.clear();
lists.generic.clear();
for (index, setting) in self.settings.iter().enumerate() {
match setting {
SettingValue::ContentFile(file) => {
lists.content.push(index);
self.indexed_content.insert(file.value().clone());
}
SettingValue::Groundcover(file) => {
lists.groundcover.push(index);
self.indexed_groundcover.insert(file.value().clone());
}
SettingValue::BethArchive(file) => {
lists.archives.push(index);
self.indexed_archives.insert(file.value().clone());
}
SettingValue::DataDirectory(dir) => {
lists.data_dirs.push(index);
self.indexed_data_dirs.insert(dir.parsed().to_path_buf());
}
SettingValue::SubConfiguration(_) => lists.sub_configs.push(index),
SettingValue::Generic(_) => lists.generic.push(index),
_ => {}
}
}
self.mark_game_setting_indexes_dirty();
}
fn files_at<'a>(&'a self, positions: &'a [usize]) -> impl Iterator<Item = &'a FileSetting> {
positions
.iter()
.filter_map(move |&index| match &self.settings[index] {
SettingValue::ContentFile(file)
| SettingValue::Groundcover(file)
| SettingValue::BethArchive(file) => Some(file),
_ => None,
})
}
fn directories_at<'a>(
&'a self,
positions: &'a [usize],
) -> impl Iterator<Item = &'a DirectorySetting> {
positions
.iter()
.filter_map(move |&index| match &self.settings[index] {
SettingValue::DataDirectory(dir) | SettingValue::SubConfiguration(dir) => Some(dir),
_ => None,
})
}
#[cfg(feature = "luau")]
pub(crate) fn list_positions(&self, kind: ListKind) -> &[usize] {
match kind {
ListKind::Content => &self.lists.content,
ListKind::Groundcover => &self.lists.groundcover,
ListKind::Archives => &self.lists.archives,
ListKind::DataDirectories => &self.lists.data_dirs,
ListKind::SubConfigs => &self.lists.sub_configs,
ListKind::Generic => &self.lists.generic,
}
}
#[cfg(feature = "luau")]
pub(crate) fn setting_at(&self, index: usize) -> Option<&SettingValue> {
self.settings.get(index)
}
#[cfg(feature = "luau")]
pub(crate) fn game_setting_position(&self, key: &str) -> Option<usize> {
self.ensure_game_setting_indexes();
self.indexed_game_setting_last.borrow().get(key).copied()
}
#[cfg(feature = "luau")]
pub(crate) fn game_setting_position_at(&self, index: usize) -> Option<usize> {
self.ensure_game_setting_indexes();
self.indexed_game_setting_order.borrow().get(index).copied()
}
#[cfg(feature = "luau")]
pub(crate) fn chain_entry(&self, index: usize) -> Option<&ConfigChainEntry> {
self.chain.get(index)
}
#[must_use]
pub fn content_file_count(&self) -> usize {
self.lists.content.len()
}
#[must_use]
pub fn groundcover_file_count(&self) -> usize {
self.lists.groundcover.len()
}
#[must_use]
pub fn archive_file_count(&self) -> usize {
self.lists.archives.len()
}
#[must_use]
pub fn data_directory_count(&self) -> usize {
self.lists.data_dirs.len()
}
#[must_use]
pub fn sub_config_count(&self) -> usize {
self.lists.sub_configs.len()
}
#[must_use]
pub fn generic_setting_count(&self) -> usize {
self.lists.generic.len()
}
#[must_use]
pub fn game_setting_count(&self) -> usize {
self.ensure_game_setting_indexes();
self.indexed_game_setting_order.borrow().len()
}
fn mark_game_setting_indexes_dirty(&self) {
self.game_setting_indexes_dirty.set(true);
self.indexed_game_setting_last.borrow_mut().clear();
self.indexed_game_setting_order.borrow_mut().clear();
}
fn ensure_game_setting_indexes(&self) {
if !self.game_setting_indexes_dirty.get() {
return;
}
let mut last = FxHashMap::default();
for (index, setting) in self.settings.iter().enumerate() {
if let SettingValue::GameSetting(game_setting) = setting {
last.insert(game_setting.key().clone(), index);
}
}
let mut seen = HashSet::new();
let mut order = Vec::new();
for (index, setting) in self.settings.iter().enumerate().rev() {
if let SettingValue::GameSetting(game_setting) = setting
&& seen.insert(game_setting.key())
{
order.push(index);
}
}
*self.indexed_game_setting_last.borrow_mut() = last;
*self.indexed_game_setting_order.borrow_mut() = order;
self.game_setting_indexes_dirty.set(false);
}
pub fn from_env() -> Result<Self, ConfigError> {
if let Ok(explicit_path) = std::env::var("OPENMW_CONFIG") {
let explicit_path = util::expand_leading_tilde(&explicit_path);
if explicit_path.as_os_str().is_empty() {
return Err(ConfigError::NotFileOrDirectory(explicit_path));
} else if explicit_path.is_absolute() {
return Self::new(Some(explicit_path));
} else if explicit_path.is_relative() {
return Self::new(Some(util::display_preserving_absolute(&explicit_path)?));
}
return Err(ConfigError::NotFileOrDirectory(explicit_path));
} else if let Ok(path_list) = std::env::var("OPENMW_CONFIG_DIR") {
let path_list = if cfg!(windows) {
path_list.split(';')
} else {
path_list.split(':')
};
for dir in path_list {
let dir = util::expand_leading_tilde(dir);
let config_file = dir.join("openmw.cfg");
if config_file.is_file() {
return Self::new(Some(dir));
}
}
}
let root_config = crate::try_default_root_config_path()?;
Self::new(Some(root_config))
}
pub fn from_env_or_user_config() -> Result<Self, ConfigError> {
match Self::from_env() {
Ok(config) => Ok(config),
Err(ConfigError::CannotFindRootConfig { local, global }) => {
let user = crate::try_default_user_config_file()?;
if user.is_file() {
Self::new(Some(user))
} else {
Err(ConfigError::CannotFindAnyConfig {
local,
global,
user,
})
}
}
Err(error) => Err(error),
}
}
pub fn new(path: Option<PathBuf>) -> Result<Self, ConfigError> {
let mut config = OpenMWConfiguration::default();
let root_config = match path {
Some(path) => util::input_config_path(path)?,
None => crate::try_default_config_path()?.join("openmw.cfg"),
};
config.root_config = root_config;
if let Err(error) = config.load(&config.root_config.clone()) {
Err(error)
} else {
if let Some(dir) = config.data_local() {
let path = dir.parsed();
let path_meta = metadata(path);
if path_meta.is_err()
&& let Err(error) = create_dir_all(path)
{
util::debug_log(&format!(
"WARNING: Attempted to create a data-local directory at {}, but failed: {error}",
path.display()
));
}
}
util::debug_log_lazy(|| format!("{:#?}", config.settings));
Ok(config)
}
}
pub fn new_empty(user_config_dir: impl Into<PathBuf>) -> Result<Self, ConfigError> {
let mut user_config_dir = user_config_dir.into();
if user_config_dir.as_os_str().is_empty()
|| user_config_dir
.file_name()
.is_some_and(|file_name| file_name == "openmw.cfg")
{
return Err(ConfigError::NotFileOrDirectory(user_config_dir));
}
if user_config_dir.is_relative() {
user_config_dir = util::display_preserving_absolute(&user_config_dir)?;
}
Ok(Self {
root_config: user_config_dir.join("openmw.cfg"),
..Self::default()
})
}
pub fn load_optional(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
let path = path.into();
if path.as_os_str().is_empty() {
return Err(ConfigError::NotFileOrDirectory(path));
}
if path.is_dir() && !path.join("openmw.cfg").exists() {
return Self::new_empty(path);
}
if path.exists() {
return Self::new(Some(path));
}
if path
.file_name()
.is_some_and(|file_name| file_name == "openmw.cfg")
{
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
return Self::new_empty(parent);
}
Self::new_empty(path)
}
#[must_use]
pub fn root_config_file(&self) -> &std::path::Path {
&self.root_config
}
#[must_use]
pub fn root_config_dir(&self) -> PathBuf {
self.root_config
.parent()
.expect("root_config has no parent directory")
.to_path_buf()
}
#[must_use]
pub fn is_user_config(&self) -> bool {
util::paths_equivalent(&self.root_config_dir(), &self.user_config_path())
}
pub fn user_config(self) -> Result<Self, ConfigError> {
let user_path = self.user_config_path();
if util::paths_equivalent(&self.root_config_dir(), &user_path) {
Ok(self)
} else {
Self::load_optional(user_path)
}
}
pub fn user_config_ref(&self) -> Result<Self, ConfigError> {
let user_path = self.user_config_path();
if util::paths_equivalent(&self.root_config_dir(), &user_path) {
Ok(self.clone())
} else {
Self::load_optional(user_path)
}
}
#[must_use]
pub fn user_config_path(&self) -> PathBuf {
self.user_config_dir
.clone()
.unwrap_or_else(|| self.root_config_dir())
}
impl_singleton_setting! {
UserData => {
get: userdata,
set: set_userdata,
in_type: DirectorySetting
},
Resources => {
get: resources,
set: set_resources,
in_type: DirectorySetting
},
DataLocal => {
get: data_local,
set: set_data_local,
in_type: DirectorySetting
},
Encoding => {
get: encoding,
set: set_encoding,
in_type: EncodingSetting
}
}
fn directory_setting_for_user_config(&self, path: impl AsRef<Path>) -> DirectorySetting {
DirectorySetting::new(
path.as_ref().to_string_lossy(),
self.user_config_path().join("openmw.cfg"),
&mut String::default(),
)
}
pub fn set_data_local_path(&mut self, path: impl AsRef<Path>) {
let setting = self.directory_setting_for_user_config(path);
self.set_data_local(Some(setting));
}
pub fn set_resources_path(&mut self, path: impl AsRef<Path>) {
let setting = self.directory_setting_for_user_config(path);
self.set_resources(Some(setting));
}
pub fn set_user_data_path(&mut self, path: impl AsRef<Path>) {
let setting = self.directory_setting_for_user_config(path);
self.set_userdata(Some(setting));
}
pub fn clear_data_local(&mut self) {
self.set_data_local(None);
}
pub fn clear_resources(&mut self) {
self.set_resources(None);
}
pub fn clear_user_data(&mut self) {
self.set_userdata(None);
}
fn user_cfg_file(&self) -> PathBuf {
self.user_config_path().join("openmw.cfg")
}
fn is_inherited(setting: &SettingValue, cfg: &Path) -> bool {
let source = setting.meta().source_config();
!source.as_os_str().is_empty() && !util::paths_equivalent(source, cfg)
}
fn replace_in_user_config(&mut self, list: ListOption) {
if !self.user_replaces.contains(&list) {
self.user_replaces.push(list);
}
}
fn parent_holds(&self, list: &ListOption, select: impl Fn(&SettingValue) -> bool) -> bool {
let user_cfg = self.user_cfg_file();
self.settings.iter().any(|setting| {
list.holds(setting) && select(setting) && Self::is_inherited(setting, &user_cfg)
})
}
fn replace_list(&mut self, list: ListOption, entries: impl IntoIterator<Item = SettingValue>) {
let inherited = self.parent_holds(&list, |_| true);
self.clear_matching_internal(|setting| list.holds(setting));
if inherited {
self.replace_in_user_config(list);
}
self.settings.extend(entries);
self.rebuild_indexes();
}
fn remove_from_list(&mut self, list: ListOption, remove: impl Fn(&SettingValue) -> bool) {
let inherited = self.parent_holds(&list, &remove);
self.clear_matching_internal(|setting| list.holds(setting) && remove(setting));
if inherited {
self.replace_in_user_config(list);
}
self.rebuild_indexes();
}
pub fn content_files_iter(&self) -> impl Iterator<Item = &FileSetting> {
self.files_at(&self.lists.content)
}
#[must_use]
pub fn has_content_file(&self, file_name: &str) -> bool {
self.indexed_content.contains(file_name)
}
#[must_use]
pub fn has_groundcover_file(&self, file_name: &str) -> bool {
self.indexed_groundcover.contains(file_name)
}
#[must_use]
pub fn has_archive_file(&self, file_name: &str) -> bool {
self.indexed_archives.contains(file_name)
}
#[must_use]
pub fn has_data_dir(&self, file_name: &str) -> bool {
let foreign = if std::path::MAIN_SEPARATOR == '/' {
'\\'
} else {
'/'
};
if file_name.contains(foreign) {
let query = file_name.replace(foreign, std::path::MAIN_SEPARATOR_STR);
return self.indexed_data_dirs.contains(Path::new(&query));
}
self.indexed_data_dirs.contains(Path::new(file_name))
}
pub fn add_content_file(&mut self, content_file: &str) -> Result<(), ConfigError> {
let duplicate = self.settings.iter().find_map(|setting| match setting {
SettingValue::ContentFile(plugin) => {
if plugin.value() == content_file {
Some(plugin)
} else {
None
}
}
_ => None,
});
if let Some(duplicate) = duplicate {
bail_config!(
content_already_defined,
duplicate.value().to_owned(),
duplicate.meta().source_config
)
}
self.settings
.push(SettingValue::ContentFile(FileSetting::new(
content_file,
&self.user_config_path().join("openmw.cfg"),
&mut String::default(),
)));
self.rebuild_indexes();
Ok(())
}
pub fn groundcover_iter(&self) -> impl Iterator<Item = &FileSetting> {
self.files_at(&self.lists.groundcover)
}
pub fn add_groundcover_file(&mut self, content_file: &str) -> Result<(), ConfigError> {
let duplicate = self.settings.iter().find_map(|setting| match setting {
SettingValue::Groundcover(plugin) => {
if plugin.value() == content_file {
Some(plugin)
} else {
None
}
}
_ => None,
});
if let Some(duplicate) = duplicate {
bail_config!(
groundcover_already_defined,
duplicate.value().to_owned(),
duplicate.meta().source_config
)
}
self.settings
.push(SettingValue::Groundcover(FileSetting::new(
content_file,
&self.user_config_path().join("openmw.cfg"),
&mut String::default(),
)));
self.rebuild_indexes();
Ok(())
}
pub fn remove_content_file(&mut self, file_name: &str) {
self.remove_from_list(ListOption::Content, |setting| match setting {
SettingValue::ContentFile(existing_file) => existing_file == file_name,
_ => false,
});
}
pub fn remove_groundcover_file(&mut self, file_name: &str) {
self.remove_from_list(ListOption::Groundcover, |setting| match setting {
SettingValue::Groundcover(existing_file) => existing_file == file_name,
_ => false,
});
}
pub fn remove_archive_file(&mut self, file_name: &str) {
self.remove_from_list(ListOption::FallbackArchive, |setting| match setting {
SettingValue::BethArchive(existing_file) => existing_file == file_name,
_ => false,
});
}
pub fn remove_data_directory(&mut self, data_dir: &PathBuf) {
self.remove_from_list(ListOption::Data, |setting| match setting {
SettingValue::DataDirectory(existing_data_dir) => {
existing_data_dir.parsed() == data_dir
|| existing_data_dir.original() == data_dir.to_string_lossy().as_ref()
}
_ => false,
});
}
pub fn add_data_directory(&mut self, dir: &Path) {
self.settings
.push(SettingValue::DataDirectory(DirectorySetting::new(
dir.to_string_lossy(),
self.user_config_path().join("openmw.cfg"),
&mut String::default(),
)));
self.rebuild_indexes();
}
pub fn add_archive_file(&mut self, archive_file: &str) -> Result<(), ConfigError> {
let duplicate = self.settings.iter().find_map(|setting| match setting {
SettingValue::BethArchive(archive) => {
if archive.value() == archive_file {
Some(archive)
} else {
None
}
}
_ => None,
});
if let Some(duplicate) = duplicate {
bail_config!(
archive_already_defined,
duplicate.value().to_owned(),
duplicate.meta().source_config
)
}
self.settings
.push(SettingValue::BethArchive(FileSetting::new(
archive_file,
&self.user_config_path().join("openmw.cfg"),
&mut String::default(),
)));
self.rebuild_indexes();
Ok(())
}
pub fn fallback_archives_iter(&self) -> impl Iterator<Item = &FileSetting> {
self.files_at(&self.lists.archives)
}
pub fn set_content_files(&mut self, plugins: Option<Vec<String>>) {
let cfg_path = self.user_cfg_file();
let mut empty = String::default();
let entries: Vec<SettingValue> = plugins
.unwrap_or_default()
.iter()
.map(|plugin| {
SettingValue::ContentFile(FileSetting::new(plugin, &cfg_path, &mut empty))
})
.collect();
self.replace_list(ListOption::Content, entries);
}
pub fn set_fallback_archives(&mut self, archives: Option<Vec<String>>) {
let cfg_path = self.user_cfg_file();
let mut empty = String::default();
let entries: Vec<SettingValue> = archives
.unwrap_or_default()
.iter()
.map(|archive| {
SettingValue::BethArchive(FileSetting::new(archive, &cfg_path, &mut empty))
})
.collect();
self.replace_list(ListOption::FallbackArchive, entries);
}
pub fn generic_settings_iter(&self) -> impl Iterator<Item = &GenericSetting> {
self.lists
.generic
.iter()
.filter_map(move |&index| match &self.settings[index] {
SettingValue::Generic(generic) => Some(generic),
_ => None,
})
}
pub fn set_generic_settings(&mut self, key: &str, values: Option<Vec<String>>) {
let cfg_path = self.user_cfg_file();
let mut empty = String::default();
let entries: Vec<SettingValue> = values
.unwrap_or_default()
.iter()
.map(|value| {
SettingValue::Generic(GenericSetting::new(key, value, &cfg_path, &mut empty))
})
.collect();
self.replace_list(ListOption::Generic(key.to_owned()), entries);
}
pub fn add_generic_setting(&mut self, key: &str, value: &str) {
self.settings
.push(SettingValue::Generic(GenericSetting::new(
key,
value,
&self.user_config_path().join("openmw.cfg"),
&mut String::default(),
)));
self.rebuild_indexes();
}
pub fn settings_matching<'a, P>(
&'a self,
predicate: P,
) -> impl Iterator<Item = &'a SettingValue>
where
P: Fn(&SettingValue) -> bool + 'a,
{
self.settings.iter().filter(move |s| predicate(s))
}
fn clear_matching_internal<P>(&mut self, predicate: P)
where
P: Fn(&SettingValue) -> bool,
{
self.settings.retain(|s| !predicate(s));
}
fn is_synthetic_data_directory(setting: &SettingValue) -> bool {
matches!(
setting,
SettingValue::DataDirectory(data_dir) if data_dir.meta().source_config.as_os_str().is_empty()
)
}
pub fn clear_matching<P>(&mut self, predicate: P)
where
P: Fn(&SettingValue) -> bool,
{
self.clear_matching_internal(predicate);
self.rebuild_indexes();
}
pub fn set_data_directories(&mut self, dirs: Option<Vec<PathBuf>>) {
let cfg_path = self.user_cfg_file();
let mut empty = String::default();
let entries: Vec<SettingValue> = dirs
.unwrap_or_default()
.iter()
.map(|dir| {
SettingValue::DataDirectory(DirectorySetting::new(
dir.to_string_lossy(),
cfg_path.clone(),
&mut empty,
))
})
.collect();
self.replace_list(ListOption::Data, entries);
}
pub fn set_game_setting(
&mut self,
base_value: &str,
config_path: Option<PathBuf>,
comment: &mut String,
) -> Result<(), ConfigError> {
*comment = normalize_comment(comment);
let new_setting = GameSettingType::try_from((
base_value.to_owned(),
config_path.unwrap_or_else(|| self.user_config_path().join("openmw.cfg")),
comment,
))?;
self.settings.push(SettingValue::GameSetting(new_setting));
self.rebuild_indexes();
Ok(())
}
pub fn set_game_settings(&mut self, settings: Option<Vec<String>>) -> Result<(), ConfigError> {
let cfg_path = self.user_cfg_file();
let mut empty = String::default();
let mut entries = Vec::new();
let mut failure = None;
for setting in settings.unwrap_or_default() {
match GameSettingType::try_from((setting, cfg_path.clone(), &mut empty)) {
Ok(parsed) => entries.push(SettingValue::GameSetting(parsed)),
Err(error) => {
failure = Some(error);
entries.clear();
break;
}
}
}
self.replace_list(ListOption::Fallback, entries);
failure.map_or(Ok(()), Err)
}
pub fn sub_configs(&self) -> impl Iterator<Item = &DirectorySetting> {
self.directories_at(&self.lists.sub_configs)
}
pub fn config_chain(&self) -> impl Iterator<Item = &ConfigChainEntry> {
self.chain.iter()
}
pub fn game_settings(&self) -> impl Iterator<Item = &GameSettingType> {
self.ensure_game_setting_indexes();
let order = self.indexed_game_setting_order.borrow();
(0..order.len()).filter_map(move |position| match &self.settings[order[position]] {
SettingValue::GameSetting(setting) => Some(setting),
_ => None,
})
}
#[must_use]
pub fn get_game_setting(&self, key: &str) -> Option<&GameSettingType> {
self.ensure_game_setting_indexes();
self.indexed_game_setting_last
.borrow()
.get(key)
.and_then(|index| match &self.settings[*index] {
SettingValue::GameSetting(setting) => Some(setting),
_ => None,
})
}
pub fn data_directories_iter(&self) -> impl Iterator<Item = &DirectorySetting> {
self.directories_at(&self.lists.data_dirs)
}
const MAX_CONFIG_DEPTH: usize = 16;
fn load(&mut self, root_config: &Path) -> Result<(), ConfigError> {
let mut pending_configs = vec![(root_config.to_path_buf(), 0usize)];
let mut tried_dirs: FxHashSet<PathBuf> = FxHashSet::default();
let mut loaded: Vec<ConfigFile> = Vec::new();
let mut active_dirs: Vec<PathBuf> = Vec::new();
while let Some((config_path, depth)) = pending_configs.pop() {
let cfg_file_path = if config_path.is_dir() {
config_path.join("openmw.cfg")
} else {
config_path
};
let config_dir = cfg_file_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
if !tried_dirs.insert(config_dir.clone()) {
util::debug_log_lazy(|| {
format!(
"Skipping {} as the chain already tried it!",
cfg_file_path.display()
)
});
continue;
}
if !cfg_file_path.exists() {
if depth == 0 {
bail_config!(cannot_find, cfg_file_path);
}
util::debug_log_lazy(|| {
format!("Skipping {} as it does not exist!", cfg_file_path.display())
});
self.chain.push(ConfigChainEntry {
path: cfg_file_path,
depth,
status: ConfigChainStatus::SkippedMissing,
});
active_dirs.push(config_dir);
continue;
}
if depth > Self::MAX_CONFIG_DEPTH {
bail_config!(max_depth_exceeded, cfg_file_path);
}
util::debug_log_lazy(|| format!("BEGIN CONFIG PARSING: {}", cfg_file_path.display()));
self.chain.push(ConfigChainEntry {
path: cfg_file_path.clone(),
depth,
status: ConfigChainStatus::Loaded,
});
let file = ConfigFile::read(&cfg_file_path)?;
if loaded.len() > 1 && file.replaces.iter().any(|name| name == "config") {
loaded.truncate(1);
active_dirs.truncate(1);
}
pending_configs.extend(
file.sub_configs()
.rev()
.map(|dir| (dir.parsed().join("openmw.cfg"), depth + 1)),
);
active_dirs.push(config_dir);
loaded.push(file);
}
if let Some(user_dir) = active_dirs.last()
&& std::fs::create_dir_all(user_dir).is_err()
{
bail_config!(not_writable, user_dir);
}
self.merge(loaded, &active_dirs)?;
if active_dirs.len() > 1 {
self.user_config_dir = active_dirs.pop();
}
self.rebuild_indexes();
Ok(())
}
fn merge(
&mut self,
loaded: Vec<ConfigFile>,
active_dirs: &[PathBuf],
) -> Result<(), ConfigError> {
let mut replaced: FxHashSet<String> = FxHashSet::default();
let mut kept_per_config = Vec::with_capacity(loaded.len());
for file in loaded.into_iter().rev() {
let kept: Vec<(SettingValue, usize)> = file
.settings
.into_iter()
.filter(|(setting, _)| match setting {
SettingValue::SubConfiguration(dir) => {
active_dirs.iter().any(|active| active == dir.parsed())
}
_ => list_name(setting).is_none_or(|list| !replaced.contains(list)),
})
.collect();
replaced.extend(kept.iter().filter_map(|(setting, _)| match setting {
SettingValue::Replace(replace) => Some(replace.value().to_owned()),
_ => None,
}));
kept_per_config.push(kept);
}
let mut seen_content = FxHashSet::default();
let mut seen_groundcover = FxHashSet::default();
let mut seen_archives = FxHashSet::default();
for (setting, line) in kept_per_config.into_iter().rev().flatten() {
match &setting {
SettingValue::ContentFile(file) if !seen_content.insert(file.value().clone()) => {
bail_config!(
duplicate_content_file,
file.value().clone(),
file.meta().source_config(),
line
);
}
SettingValue::Groundcover(file)
if !seen_groundcover.insert(file.value().clone()) =>
{
bail_config!(
duplicate_groundcover_file,
file.value().clone(),
file.meta().source_config(),
line
);
}
SettingValue::BethArchive(file) if !seen_archives.insert(file.value().clone()) => {
bail_config!(
duplicate_archive_file,
file.value().clone(),
file.meta().source_config(),
line
);
}
_ => {}
}
self.settings.push(setting);
}
Ok(())
}
fn write_config(config_string: &str, path: &Path) -> Result<(), ConfigError> {
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
let parent = path
.parent()
.ok_or_else(|| ConfigError::NotWritable(path.to_path_buf()))?;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let tmp_path = parent.join(format!(
".openmw-config-tmp-{}-{}",
std::process::id(),
nonce
));
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)?;
file.write_all(config_string.as_bytes())?;
file.sync_all()?;
#[cfg(windows)]
#[allow(clippy::permissions_set_readonly_false)]
{
if path.exists() {
if let Ok(metadata) = std::fs::metadata(path) {
let mut permissions = metadata.permissions();
if permissions.readonly() {
permissions.set_readonly(false);
std::fs::set_permissions(path, permissions)?;
}
}
std::fs::remove_file(path)?;
}
}
std::fs::rename(&tmp_path, path)?;
Ok(())
}
fn save_string_to_path(config_string: &str, path: &Path) -> Result<(), ConfigError> {
let parent = path
.parent()
.ok_or_else(|| ConfigError::NotWritable(path.to_path_buf()))?;
let writable_probe = parent.join(".openmw-config-write-test");
if !util::is_writable(&writable_probe) {
bail_config!(not_writable, parent);
}
Self::write_config(config_string, path)
}
#[must_use]
pub fn to_resolved_string(&self) -> String {
let mut config_string = String::new();
for setting in &self.settings {
if Self::is_synthetic_data_directory(setting) {
continue;
}
if let Some(setting_string) = setting.to_resolved_string() {
config_string.push_str(&setting_string);
}
}
let _ = write_serializer_stamp(&mut config_string);
config_string
}
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
Self::save_string_to_path(&self.to_string(), path.as_ref())
}
pub fn save_resolved_to_path(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
Self::save_string_to_path(&self.to_resolved_string(), path.as_ref())
}
fn file_text(&self, cfg_path: &Path) -> String {
let mut pending: Vec<&ListOption> =
if util::paths_equivalent(cfg_path, &self.user_cfg_file()) {
self.user_replaces.iter().collect()
} else {
Vec::new()
};
let mut text = String::new();
let mut trailing = String::new();
for setting in self.settings.iter().filter(|setting| {
!Self::is_synthetic_data_directory(setting)
&& util::paths_equivalent(setting.meta().source_config(), cfg_path)
}) {
if let SettingValue::TrailingComment(end) = setting {
trailing.push_str(&end.to_string());
continue;
}
if let SettingValue::Replace(replace) = setting
&& let Some(index) = pending.iter().position(|list| list.is_replaced_by(replace))
{
text.push_str(&setting.to_string());
self.write_inherited_entries(&mut text, pending.remove(index), cfg_path);
continue;
}
if let Some(index) = pending.iter().position(|list| list.holds(setting)) {
Self::write_replace_line(&mut text, pending[index]);
self.write_inherited_entries(&mut text, pending.remove(index), cfg_path);
}
text.push_str(&setting.to_string());
}
for list in pending {
Self::write_replace_line(&mut text, list);
self.write_inherited_entries(&mut text, list, cfg_path);
}
text.push_str(&trailing);
text
}
fn write_replace_line(text: &mut String, list: &ListOption) {
text.push_str("replace=");
text.push_str(list.name());
text.push('\n');
}
fn write_inherited_entries(&self, text: &mut String, list: &ListOption, cfg_path: &Path) {
for setting in self
.settings
.iter()
.filter(|setting| list.holds(setting) && Self::is_inherited(setting, cfg_path))
{
let mut entry = setting.clone();
entry.meta_mut().comment.clear();
if let SettingValue::DataDirectory(dir) = &entry {
let original_here = DirectorySetting::new(
dir.original(),
cfg_path.to_path_buf(),
&mut String::new(),
);
if original_here.parsed() != dir.parsed() {
entry = SettingValue::DataDirectory(DirectorySetting::new(
dir.parsed().to_string_lossy(),
cfg_path.to_path_buf(),
&mut String::new(),
));
}
}
text.push_str(&entry.to_string());
}
}
pub fn save_user(&self) -> Result<(), ConfigError> {
let target_dir = self.user_config_path();
let cfg_path = target_dir.join("openmw.cfg");
std::fs::create_dir_all(&target_dir)?;
if !util::is_writable(&cfg_path) {
bail_config!(not_writable, &cfg_path);
}
Self::write_config(&self.file_text(&cfg_path), &cfg_path)
}
pub fn save_subconfig(&self, target_dir: &Path) -> Result<(), ConfigError> {
let subconfig_is_loaded = self.settings.iter().any(|setting| match setting {
SettingValue::SubConfiguration(subconfig) => {
util::paths_equivalent(subconfig.parsed(), target_dir)
|| subconfig.original() == target_dir.to_string_lossy().as_ref()
}
_ => false,
});
if !subconfig_is_loaded {
bail_config!(subconfig_not_loaded, target_dir);
}
let cfg_path = target_dir.join("openmw.cfg");
if !util::is_writable(&cfg_path) {
bail_config!(not_writable, &cfg_path);
}
Self::write_config(&self.file_text(&cfg_path), &cfg_path)
}
}
impl fmt::Display for OpenMWConfiguration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut trailing: Vec<&SettingValue> = Vec::new();
for setting in self
.settings
.iter()
.filter(|setting| !Self::is_synthetic_data_directory(setting))
{
if let SettingValue::TrailingComment(_) = setting {
trailing.push(setting);
continue;
}
if trailing
.first()
.is_some_and(|end| end.meta().source_config() != setting.meta().source_config())
{
trailing.drain(..).try_for_each(|end| write!(f, "{end}"))?;
}
write!(f, "{setting}")?;
}
trailing
.into_iter()
.try_for_each(|end| write!(f, "{end}"))?;
write_serializer_stamp(f)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
fn write_cfg(dir: &std::path::Path, contents: &str) -> PathBuf {
let cfg = dir.join("openmw.cfg");
let mut f = std::fs::File::create(&cfg).unwrap();
f.write_all(contents.as_bytes()).unwrap();
cfg
}
fn temp_dir() -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let base =
std::env::temp_dir().join(format!("openmw_cfg_test_{}_{id}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
base
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
crate::test_env_lock()
}
unsafe fn clear_from_env_overrides() {
unsafe {
std::env::remove_var("OPENMW_CONFIG");
std::env::remove_var("OPENMW_CONFIG_DIR");
std::env::remove_var("OPENMW_GLOBAL_CONFIG_PATH");
std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
std::env::remove_var("OPENMW_FLATPAK_ID");
std::env::remove_var("FLATPAK_ID");
}
}
#[cfg(not(windows))]
fn restore_env_var(key: &str, value: Option<std::ffi::OsString>) {
unsafe {
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
}
fn load(cfg_contents: &str) -> OpenMWConfiguration {
let dir = temp_dir();
write_cfg(&dir, cfg_contents);
OpenMWConfiguration::new(Some(dir)).unwrap()
}
#[cfg(unix)]
fn symlink_dir(target: &std::path::Path, link: &std::path::Path) {
std::os::unix::fs::symlink(target, link).unwrap();
}
#[test]
fn test_content_files_empty_on_bare_config() {
let config = load("");
assert!(config.content_files_iter().next().is_none());
}
#[test]
fn test_content_files_parsed_in_order() {
let config = load("content=Morrowind.esm\ncontent=Tribunal.esm\ncontent=Bloodmoon.esm\n");
let files: Vec<&String> = config
.content_files_iter()
.map(FileSetting::value)
.collect();
assert_eq!(
files,
vec!["Morrowind.esm", "Tribunal.esm", "Bloodmoon.esm"]
);
}
#[test]
fn test_has_content_file_found() {
let config = load("content=Morrowind.esm\n");
assert!(config.has_content_file("Morrowind.esm"));
}
#[test]
fn test_has_content_file_not_found() {
let config = load("content=Morrowind.esm\n");
assert!(!config.has_content_file("Tribunal.esm"));
}
#[test]
fn test_duplicate_content_file_errors_on_load() {
let dir = temp_dir();
write_cfg(&dir, "content=Morrowind.esm\ncontent=Morrowind.esm\n");
assert!(OpenMWConfiguration::new(Some(dir)).is_err());
}
#[test]
fn test_duplicate_content_file_error_reports_line_number() {
let dir = temp_dir();
write_cfg(&dir, "content=Morrowind.esm\ncontent=Morrowind.esm\n");
let result = OpenMWConfiguration::new(Some(dir));
assert!(matches!(
result,
Err(ConfigError::DuplicateContentFile { line: Some(2), .. })
));
}
#[test]
fn test_add_content_file_appends() {
let mut config = load("content=Morrowind.esm\n");
config.add_content_file("MyMod.esp").unwrap();
assert!(config.has_content_file("MyMod.esp"));
}
#[test]
fn test_add_duplicate_content_file_errors() {
let mut config = load("content=Morrowind.esm\n");
assert!(config.add_content_file("Morrowind.esm").is_err());
}
#[test]
fn test_add_content_file_source_config_is_cfg_file() {
let dir = temp_dir();
let cfg_path = write_cfg(&dir, "");
let mut config = OpenMWConfiguration::new(Some(dir)).unwrap();
config.add_content_file("Mod.esp").unwrap();
let setting = config.content_files_iter().next().unwrap();
assert_eq!(
setting.meta().source_config,
cfg_path,
"source_config should be the openmw.cfg file, not a directory"
);
}
#[test]
fn test_remove_content_file() {
let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
config.remove_content_file("Morrowind.esm");
assert!(!config.has_content_file("Morrowind.esm"));
assert!(config.has_content_file("Tribunal.esm"));
}
#[test]
fn test_set_content_files_replaces_all() {
let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
config.set_content_files(Some(vec!["NewMod.esp".to_string()]));
assert!(!config.has_content_file("Morrowind.esm"));
assert!(!config.has_content_file("Tribunal.esm"));
assert!(config.has_content_file("NewMod.esp"));
}
#[test]
fn test_set_content_files_none_clears_all() {
let mut config = load("content=Morrowind.esm\n");
config.set_content_files(None);
assert!(config.content_files_iter().next().is_none());
}
#[test]
fn test_fallback_archives_parsed() {
let config = load("fallback-archive=Morrowind.bsa\nfallback-archive=Tribunal.bsa\n");
let archives: Vec<&String> = config
.fallback_archives_iter()
.map(FileSetting::value)
.collect();
assert_eq!(archives, vec!["Morrowind.bsa", "Tribunal.bsa"]);
}
#[test]
fn test_has_archive_file() {
let config = load("fallback-archive=Morrowind.bsa\n");
assert!(config.has_archive_file("Morrowind.bsa"));
assert!(!config.has_archive_file("Tribunal.bsa"));
}
#[test]
fn test_set_game_setting_comment_is_written_as_a_comment_line() {
let mut config = load("content=Morrowind.esm\n");
let mut comment = String::from("my note");
config
.set_game_setting("fJumpHeight,1.0", None, &mut comment)
.unwrap();
assert!(comment.is_empty(), "the comment is consumed");
assert!(
config
.to_string()
.contains("# my note\nfallback=fJumpHeight,1.0\n"),
"{config}"
);
let mut multi = String::from("# already a comment\n\nsecond line");
config
.set_game_setting("iLevel,2", None, &mut multi)
.unwrap();
assert!(
config
.to_string()
.contains("# already a comment\n\n# second line\nfallback=iLevel,2\n"),
"{config}"
);
let mut empty = String::new();
config
.set_game_setting("iOther,3", None, &mut empty)
.unwrap();
assert!(config.to_string().contains("\nfallback=iOther,3\n"));
assert_eq!(normalize_comment("# kept\n"), "# kept\n");
assert_eq!(normalize_comment("\n"), "\n");
}
#[test]
fn test_trailing_comments_survive_serialization() {
let config = load("# head\ncontent=A.esp\n\n# tail\n");
let serialized = config.to_string();
assert!(
serialized.starts_with(
"# head\ncontent=A.esp\n\n# tail\n# OpenMW-Config Serializer Version:"
),
"{serialized}"
);
let reloaded = load(&serialized).to_string();
assert_eq!(reloaded, serialized);
assert_eq!(serialized.matches(SERIALIZER_STAMP_PREFIX).count(), 1);
let plain = load("content=A.esp\n").to_string();
assert!(
plain.starts_with("content=A.esp\n# OpenMW-Config"),
"{plain}"
);
let only = load("# nothing here\n").to_string();
assert!(
only.starts_with("# nothing here\n# OpenMW-Config"),
"{only}"
);
}
#[test]
fn test_singleton_changes_keep_the_game_setting_index_valid() {
let mut config =
load("encoding=win1252\nfallback=iFirst,1\nfallback=iSecond,2\nno-sound=1\n");
assert_eq!(config.get_game_setting("iSecond").unwrap().value(), "2");
config.set_encoding(None);
assert_eq!(config.get_game_setting("iFirst").unwrap().value(), "1");
assert_eq!(config.get_game_setting("iSecond").unwrap().value(), "2");
let keys: Vec<_> = config
.game_settings()
.map(GameSettingType::key_str)
.collect();
assert_eq!(keys, ["iSecond", "iFirst"]);
config.set_user_data_path("/tmp/user");
config.clear_user_data();
assert_eq!(config.get_game_setting("iFirst").unwrap().value(), "1");
}
#[test]
fn test_injected_data_directories_are_indexed() {
let dir = temp_dir();
let resources = dir.join("res");
write_cfg(
&dir,
&format!("resources={}\ndata=Data Files\n", resources.display()),
);
let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
assert!(config.has_data_dir(&resources.join("vfs").to_string_lossy()));
assert!(config.has_data_dir(&dir.join("Data Files").to_string_lossy()));
}
#[test]
fn test_list_counts_follow_the_lists() {
let mut config = load(
"content=A.esm\ncontent=B.esm\ngroundcover=G.esp\nfallback-archive=X.bsa\ndata=Data Files\nfallback=iA,1\nfallback=iA,2\nfallback=iB,3\nfoo=1\nfoo=2\n",
);
assert_eq!(config.content_file_count(), 2);
assert_eq!(config.groundcover_file_count(), 1);
assert_eq!(config.archive_file_count(), 1);
assert_eq!(config.data_directory_count(), 1);
assert_eq!(config.sub_config_count(), 0);
assert_eq!(config.game_setting_count(), 2, "distinct keys");
assert_eq!(config.generic_setting_count(), 2);
config.remove_content_file("A.esm");
config.set_generic_settings("foo", None);
assert_eq!(config.content_file_count(), 1);
assert_eq!(config.generic_setting_count(), 0);
assert_eq!(config.content_files_iter().count(), 1);
}
#[test]
fn test_add_duplicate_archive_errors() {
let mut config = load("fallback-archive=Morrowind.bsa\n");
let result = config.add_archive_file("Morrowind.bsa");
assert!(
matches!(result, Err(ConfigError::CannotAddArchiveFile { ref file, .. }) if file == "Morrowind.bsa"),
"{result:?}"
);
}
#[test]
fn test_duplicate_archive_error_reports_line_number() {
let dir = temp_dir();
write_cfg(
&dir,
"fallback-archive=Morrowind.bsa\nfallback-archive=Morrowind.bsa\n",
);
let result = OpenMWConfiguration::new(Some(dir));
assert!(matches!(
result,
Err(ConfigError::DuplicateArchiveFile { line: Some(2), .. })
));
}
#[test]
fn test_remove_archive_file() {
let mut config = load("fallback-archive=Morrowind.bsa\nfallback-archive=Tribunal.bsa\n");
config.remove_archive_file("Morrowind.bsa");
assert!(!config.has_archive_file("Morrowind.bsa"));
assert!(config.has_archive_file("Tribunal.bsa"));
}
#[test]
fn test_groundcover_parsed() {
let config = load("groundcover=GrassPlugin.esp\n");
let grass: Vec<&String> = config.groundcover_iter().map(FileSetting::value).collect();
assert_eq!(grass, vec!["GrassPlugin.esp"]);
}
#[test]
fn test_has_groundcover_file() {
let config = load("groundcover=Grass.esp\n");
assert!(config.has_groundcover_file("Grass.esp"));
assert!(!config.has_groundcover_file("Other.esp"));
}
#[test]
fn test_duplicate_groundcover_errors_on_load() {
let dir = temp_dir();
write_cfg(&dir, "groundcover=Grass.esp\ngroundcover=Grass.esp\n");
assert!(OpenMWConfiguration::new(Some(dir)).is_err());
}
#[test]
fn test_duplicate_groundcover_error_reports_line_number() {
let dir = temp_dir();
write_cfg(&dir, "groundcover=Grass.esp\ngroundcover=Grass.esp\n");
let result = OpenMWConfiguration::new(Some(dir));
assert!(matches!(
result,
Err(ConfigError::DuplicateGroundcoverFile { line: Some(2), .. })
));
}
#[test]
fn test_data_directories_absolute_paths_parsed() {
let config = load("data=/absolute/path/to/data\n");
assert!(
config
.data_directories_iter()
.any(|d| d.parsed().ends_with("absolute/path/to/data"))
);
}
#[test]
fn test_add_data_directory() {
let mut config = load("");
config.add_data_directory(Path::new("/some/data/dir"));
assert!(config.has_data_dir("/some/data/dir"));
}
#[test]
fn test_set_data_directories_replaces_all() {
let mut config = load("data=/old/dir\n");
config.set_data_directories(Some(vec![PathBuf::from("/new/dir")]));
assert!(!config.has_data_dir("/old/dir"));
assert!(config.has_data_dir("/new/dir"));
}
#[test]
fn test_remove_data_directory() {
let mut config = load("data=/keep/me\n");
config.add_data_directory(Path::new("/remove/me"));
config.remove_data_directory(&PathBuf::from("/remove/me"));
assert!(!config.has_data_dir("/remove/me"));
assert!(config.has_data_dir("/keep/me"));
}
#[test]
fn test_game_settings_parsed() {
let config = load("fallback=iMaxLevel,100\n");
let setting = config.get_game_setting("iMaxLevel").unwrap();
assert_eq!(setting.value(), "100");
}
#[test]
fn test_game_settings_last_wins() {
let config = load("fallback=iKey,1\nfallback=iKey,2\n");
let setting = config.get_game_setting("iKey").unwrap();
assert_eq!(setting.value(), "2");
}
#[test]
fn test_game_settings_deduplicates_by_key() {
let config = load("fallback=iKey,1\nfallback=iKey,2\n");
let results: Vec<_> = config
.game_settings()
.filter(|s| s.key() == "iKey")
.collect();
assert_eq!(
results.len(),
1,
"game_settings() should deduplicate by key"
);
assert_eq!(results[0].value(), "2", "last-defined value should win");
}
#[test]
fn test_get_game_setting_missing_returns_none() {
let config = load("fallback=iKey,1\n");
assert!(config.get_game_setting("iMissing").is_none());
}
#[test]
fn test_game_setting_color_roundtrip() {
let config = load("fallback=iSkyColor,100,149,237\n");
let setting = config.get_game_setting("iSkyColor").unwrap();
assert_eq!(setting.value(), "100,149,237");
}
#[test]
fn test_game_setting_float_roundtrip() {
let config = load("fallback=fGravity,9.81\n");
let setting = config.get_game_setting("fGravity").unwrap();
assert_eq!(setting.value(), "9.81");
}
#[test]
fn test_invalid_game_setting_error_reports_line_number() {
let dir = temp_dir();
write_cfg(&dir, "fallback=iGood,1\nfallback=InvalidEntry\n");
let result = OpenMWConfiguration::new(Some(dir));
assert!(matches!(
result,
Err(ConfigError::InvalidGameSetting { line: Some(2), .. })
));
}
#[test]
fn test_encoding_parsed() {
use crate::config::encodingsetting::EncodingType;
let config = load("encoding=win1252\n");
assert_eq!(config.encoding().unwrap().value(), EncodingType::WIN1252);
}
#[test]
fn test_invalid_encoding_errors_on_load() {
let dir = temp_dir();
write_cfg(&dir, "encoding=utf8\n");
assert!(OpenMWConfiguration::new(Some(dir)).is_err());
}
#[test]
fn test_invalid_encoding_error_reports_line_number() {
let dir = temp_dir();
write_cfg(&dir, "content=Morrowind.esm\nencoding=utf8\n");
let result = OpenMWConfiguration::new(Some(dir));
assert!(matches!(
result,
Err(ConfigError::BadEncoding { line: Some(2), .. })
));
}
fn load_chain(root: &str, user: &str) -> OpenMWConfiguration {
let root_dir = temp_dir();
let user_dir = temp_dir();
write_cfg(&user_dir, user);
write_cfg(&root_dir, &format!("{root}config={}\n", user_dir.display()));
OpenMWConfiguration::new(Some(root_dir)).unwrap()
}
#[test]
fn test_replace_content_clears_prior_plugins() {
let config = load_chain("content=Old.esm\n", "replace=content\ncontent=New.esm\n");
assert!(!config.has_content_file("Old.esm"));
assert!(config.has_content_file("New.esm"));
}
#[test]
fn test_replace_content_keeps_the_files_own_plugins() {
let config = load("content=Old.esm\nreplace=content\ncontent=New.esm\n");
assert!(config.has_content_file("Old.esm"));
assert!(config.has_content_file("New.esm"));
}
#[test]
fn test_replace_data_clears_prior_dirs() {
let config = load_chain("data=/old\n", "data=/own\nreplace=data\ndata=/new\n");
assert!(!config.has_data_dir("/old"));
assert!(config.has_data_dir("/own"));
assert!(config.has_data_dir("/new"));
}
#[test]
fn test_replace_keeps_comment_adjacency() {
let config = load_chain(
"content=Old.esm\n",
"replace=content\n\n# keep me\ncontent=New.esm\n",
);
let output = config.to_string();
assert!(!output.contains("Old.esm"));
assert!(output.contains("# keep me\ncontent=New.esm"));
}
#[test]
fn test_display_contains_version_comment() {
let config = load("content=Morrowind.esm\n");
let output = config.to_string();
assert!(
output.contains("# OpenMW-Config Serializer Version:"),
"Display should include version comment"
);
}
#[test]
fn test_display_preserves_content_entries() {
let config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
let output = config.to_string();
assert!(output.contains("content=Morrowind.esm"));
assert!(output.contains("content=Tribunal.esm"));
}
#[test]
fn test_display_preserves_comments() {
let config = load("# This is a comment\ncontent=Morrowind.esm\n");
let output = config.to_string();
assert!(output.contains("# This is a comment"));
}
#[test]
fn test_generic_setting_preserved() {
let config = load("some-unknown-key=some-value\n");
let output = config.to_string();
assert!(output.contains("some-unknown-key=some-value"));
}
#[test]
fn test_generic_settings_can_be_replaced_and_iterated() {
let mut config = load("no-sound=1\nno-sound=0\nother=keep\n");
config.set_generic_settings("no-sound", Some(vec!["2".to_string()]));
let values: Vec<(&str, &str)> = config
.generic_settings_iter()
.map(|setting| (setting.key(), setting.value()))
.collect();
assert_eq!(values, vec![("other", "keep"), ("no-sound", "2")]);
}
#[test]
fn test_save_to_path_writes_exact_output_path() {
let dir = temp_dir();
write_cfg(&dir, "no-sound=1\ncontent=Morrowind.esm\n");
let config = OpenMWConfiguration::new(Some(dir)).unwrap();
let out = temp_dir().join("imported-openmw.cfg");
config.save_to_path(&out).unwrap();
let saved = std::fs::read_to_string(&out).unwrap();
assert!(saved.contains("no-sound=1"));
assert!(saved.contains("content=Morrowind.esm"));
}
#[test]
fn test_save_to_path_overwrites_read_only_existing_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir();
write_cfg(&dir, "no-sound=1\n");
let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
let out = dir.join("export.cfg");
std::fs::write(&out, "old=content\n").unwrap();
std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o444)).unwrap();
config.save_to_path(&out).unwrap();
std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o644)).unwrap();
let saved = std::fs::read_to_string(&out).unwrap();
assert!(saved.contains("no-sound=1"));
assert!(!saved.contains("old=content"));
}
}
#[test]
fn test_save_to_path_rejects_unwritable_parent_directory() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir();
write_cfg(&dir, "no-sound=1\n");
let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
let parent = temp_dir();
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).unwrap();
let out = parent.join("export.cfg");
let result = config.save_to_path(&out);
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(matches!(result, Err(ConfigError::NotWritable(_))));
}
}
#[test]
fn test_save_user_round_trips_content_files() {
let dir = temp_dir();
write_cfg(&dir, "content=Morrowind.esm\ncontent=Tribunal.esm\n");
let mut config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
config.add_content_file("Bloodmoon.esm").unwrap();
config.save_user().unwrap();
let reloaded = OpenMWConfiguration::new(Some(dir)).unwrap();
let files: Vec<&String> = reloaded
.content_files_iter()
.map(FileSetting::value)
.collect();
assert!(files.contains(&&"Morrowind.esm".to_string()));
assert!(files.contains(&&"Bloodmoon.esm".to_string()));
}
#[test]
fn test_save_user_does_not_persist_injected_data_local_as_data() {
let dir = temp_dir();
let data_local = dir.join("data-local");
write_cfg(
&dir,
&format!(
"data-local={}\ndata={}\n",
data_local.display(),
data_local.display()
),
);
let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
assert!(config.has_data_dir(&data_local.to_string_lossy()));
config.save_user().unwrap();
let saved = std::fs::read_to_string(dir.join("openmw.cfg")).unwrap();
assert_eq!(saved.matches("data-local=").count(), 1);
assert_eq!(saved.matches("data=").count(), 1);
}
#[test]
fn test_composite_display_does_not_serialize_injected_data_local_as_data() {
let dir = temp_dir();
let data_local = dir.join("data-local");
write_cfg(&dir, &format!("data-local={}\n", data_local.display()));
let config = OpenMWConfiguration::new(Some(dir)).unwrap();
let serialized = config.to_string();
assert!(serialized.contains("data-local="));
assert!(!serialized.contains("data="));
}
#[test]
fn test_display_preserves_directory_originals_for_round_trip_boundary() {
let dir = temp_dir();
write_cfg(
&dir,
"data=Data Files\ndata-local=\"Local Data\"\nresources=?local?/resources\nuser-data=?userdata?\n",
);
let config = OpenMWConfiguration::new(Some(dir)).unwrap();
let serialized = config.to_string();
assert!(serialized.contains("data=Data Files"));
assert!(serialized.contains("data-local=\"Local Data\""));
assert!(serialized.contains("resources=?local?/resources"));
assert!(serialized.contains("user-data=?userdata?"));
}
#[test]
fn test_to_resolved_string_emits_resolved_directory_paths() {
let dir = temp_dir();
write_cfg(
&dir,
"data=Data Files\ndata-local=\"Local Data\"\nresources=?local?/resources\nuser-data=?userdata?\ncontent=Base.esm\nfallback-archive=Morrowind.bsa\n",
);
let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
let serialized = config.to_resolved_string();
assert!(!serialized.contains("data=Data Files"));
assert!(!serialized.contains("data-local=\"Local Data\""));
assert!(!serialized.contains("resources=?local?/resources"));
assert!(!serialized.contains("userdata="));
assert!(serialized.contains(&format!("data={}", dir.join("Data Files").display())));
assert!(serialized.contains(&format!("data-local={}", dir.join("Local Data").display())));
assert!(serialized.contains("user-data="));
assert!(serialized.contains("content=Base.esm"));
assert!(serialized.contains("fallback-archive=Morrowind.bsa"));
}
#[test]
fn test_to_resolved_string_excludes_config_replace_and_synthetic_data_local() {
let root = temp_dir();
let sub = temp_dir();
let data_local = root.join("local-data");
write_cfg(
&root,
&format!(
"data-local={}\nconfig={}\ncontent=Root.esm\n",
data_local.display(),
sub.display()
),
);
write_cfg(&sub, "content=Sub.esm\n");
let mut config = OpenMWConfiguration::new(Some(root)).unwrap();
config.add_generic_setting("replace", "data");
let serialized = config.to_resolved_string();
assert!(!serialized.contains("config="));
assert!(!serialized.contains("replace="));
assert_eq!(serialized.matches("data-local=").count(), 1);
assert_eq!(serialized.matches("data=").count(), 0);
assert!(serialized.contains("content=Root.esm"));
assert!(serialized.contains("content=Sub.esm"));
}
#[test]
fn test_save_resolved_to_path_writes_relocated_safe_output() {
let dir = temp_dir();
let out = temp_dir().join("openmw.cfg");
write_cfg(&dir, "data=Data Files\ncontent=Base.esm\n");
let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
config.save_resolved_to_path(&out).unwrap();
let saved = std::fs::read_to_string(out).unwrap();
assert!(saved.contains(&format!("data={}", dir.join("Data Files").display())));
assert!(!saved.contains("data=Data Files"));
assert!(saved.contains("content=Base.esm"));
}
#[test]
fn test_save_user_not_writable_returns_error() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir();
write_cfg(&dir, "content=Morrowind.esm\n");
let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
let cfg_path = dir.join("openmw.cfg");
std::fs::set_permissions(&cfg_path, std::fs::Permissions::from_mode(0o444)).unwrap();
let result = config.save_user();
std::fs::set_permissions(&cfg_path, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(
matches!(result, Err(ConfigError::NotWritable(_))),
"expected NotWritable, got {result:?}"
);
}
}
#[test]
fn test_save_subconfig_rejects_unloaded_path() {
let dir = temp_dir();
write_cfg(&dir, "content=Morrowind.esm\n");
let config = OpenMWConfiguration::new(Some(dir)).unwrap();
let fake_dir = temp_dir();
let result = config.save_subconfig(&fake_dir);
assert!(
matches!(result, Err(ConfigError::SubconfigNotLoaded(_))),
"expected SubconfigNotLoaded, got {result:?}"
);
}
#[test]
fn test_save_subconfig_round_trips_settings() {
let root_dir = temp_dir();
let sub_dir = temp_dir();
write_cfg(&sub_dir, "content=Plugin.esp\n");
write_cfg(
&root_dir,
&format!("content=Morrowind.esm\nconfig={}\n", sub_dir.display()),
);
let mut config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
config.add_content_file("NewPlugin.esp").unwrap();
config.save_subconfig(&sub_dir).unwrap();
let sub_cfg = sub_dir.join("openmw.cfg");
let saved = std::fs::read_to_string(sub_cfg).unwrap();
assert!(
saved.contains("content=Plugin.esp"),
"sub-config content preserved"
);
}
#[test]
#[cfg(unix)]
fn test_save_subconfig_accepts_equivalent_symlink_path() {
let root_dir = temp_dir();
let real_sub_dir = temp_dir();
let linked_sub_dir = temp_dir().join("linked_subconfig");
std::os::unix::fs::symlink(&real_sub_dir, &linked_sub_dir).unwrap();
write_cfg(&real_sub_dir, "content=Plugin.esp\n");
write_cfg(
&root_dir,
&format!(
"content=Morrowind.esm\nconfig={}\n",
linked_sub_dir.display()
),
);
let config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
config.save_subconfig(&real_sub_dir).unwrap();
let saved = std::fs::read_to_string(real_sub_dir.join("openmw.cfg")).unwrap();
assert!(
saved.contains("content=Plugin.esp"),
"sub-config settings should match by filesystem identity, not symlink spelling"
);
}
#[test]
fn test_new_empty_uses_directory_as_user_config_context() {
let dir = temp_dir().join("not-created-yet");
let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
config.add_content_file("EmptyStart.esp").unwrap();
config.add_data_directory(Path::new("Data Files"));
config
.set_game_setting("fScale,1.0", None, &mut String::new())
.unwrap();
assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
assert_eq!(config.root_config_dir(), dir);
assert_eq!(config.user_config_path(), config.root_config_dir());
let content = config.content_files_iter().next().unwrap();
assert_eq!(content.meta().source_config, config.root_config_file());
let data = config.data_directories_iter().next().unwrap();
assert_eq!(data.meta().source_config, config.root_config_file());
assert_eq!(data.parsed(), &config.root_config_dir().join("Data Files"));
let game_setting = config.get_game_setting("fScale").unwrap();
assert_eq!(game_setting.meta().source_config, config.root_config_file());
}
#[test]
fn test_new_empty_rejects_openmw_cfg_file_shaped_path() {
let result = OpenMWConfiguration::new_empty(temp_dir().join("openmw.cfg"));
assert!(matches!(result, Err(ConfigError::NotFileOrDirectory(_))));
}
#[test]
fn test_save_to_path_works_from_new_empty_config() {
let dir = temp_dir().join("missing-config-dir");
let out_dir = temp_dir();
let out = out_dir.join("openmw.cfg");
let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
config.add_content_file("Generated.esp").unwrap();
config.save_to_path(&out).unwrap();
let saved = std::fs::read_to_string(out).unwrap();
assert!(saved.contains("content=Generated.esp"));
}
#[test]
fn test_save_user_creates_missing_new_empty_config_directory() {
let dir = temp_dir().join("missing-user-config-dir");
let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
config.add_content_file("Generated.esp").unwrap();
config.save_user().unwrap();
let saved = std::fs::read_to_string(dir.join("openmw.cfg")).unwrap();
assert!(saved.contains("content=Generated.esp"));
}
#[test]
fn test_load_optional_existing_cfg_matches_new() {
let dir = temp_dir();
let cfg = write_cfg(&dir, "content=Existing.esm\ndata=Data Files\n");
let from_new = OpenMWConfiguration::new(Some(cfg.clone())).unwrap();
let from_optional = OpenMWConfiguration::load_optional(&cfg).unwrap();
assert_eq!(
from_optional.root_config_file(),
from_new.root_config_file()
);
assert_eq!(from_optional.to_string(), from_new.to_string());
}
#[test]
fn test_load_optional_missing_cfg_uses_parent_context() {
let dir = temp_dir().join("cfg-parent");
let cfg = dir.join("openmw.cfg");
let mut config = OpenMWConfiguration::load_optional(&cfg).unwrap();
config.add_data_directory(Path::new("Data Files"));
assert_eq!(config.root_config_file(), cfg);
assert_eq!(config.root_config_dir(), dir);
assert_eq!(
config.data_directories_iter().next().unwrap().parsed(),
&config.root_config_dir().join("Data Files")
);
}
#[test]
fn test_load_optional_existing_empty_dir_uses_dir_context() {
let dir = temp_dir();
let mut config = OpenMWConfiguration::load_optional(&dir).unwrap();
config.add_data_directory(Path::new("Data Files"));
assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
assert_eq!(config.root_config_dir(), dir);
assert_eq!(
config.data_directories_iter().next().unwrap().parsed(),
&config.root_config_dir().join("Data Files")
);
}
#[test]
fn test_load_optional_missing_dir_uses_dir_context() {
let dir = temp_dir().join("missing-dir");
let config = OpenMWConfiguration::load_optional(&dir).unwrap();
assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
assert_eq!(config.root_config_dir(), dir);
}
#[test]
fn test_new_empty_relative_dir_anchors_to_current_dir_for_resolved_export() {
let relative_dir = PathBuf::from(format!(
"openmw-config-relative-empty-{}",
std::process::id()
));
let expected_dir = util::display_preserving_absolute(&relative_dir).unwrap();
let mut config = OpenMWConfiguration::new_empty(&relative_dir).unwrap();
config.add_data_directory(Path::new("Data Files"));
assert_eq!(config.root_config_dir(), expected_dir);
assert!(config.to_resolved_string().contains(&format!(
"data={}",
expected_dir.join("Data Files").display()
)));
}
#[test]
fn test_load_optional_relative_missing_cfg_anchors_to_current_dir() {
let relative_cfg = PathBuf::from(format!(
"openmw-config-relative-missing-{}/openmw.cfg",
std::process::id()
));
let expected_dir =
util::display_preserving_absolute(relative_cfg.parent().unwrap()).unwrap();
let mut config = OpenMWConfiguration::load_optional(&relative_cfg).unwrap();
config.add_data_directory(Path::new("Data Files"));
assert_eq!(config.root_config_dir(), expected_dir);
assert!(config.to_resolved_string().contains(&format!(
"data={}",
expected_dir.join("Data Files").display()
)));
}
#[test]
fn test_path_level_directory_singleton_setters_use_user_config_context() {
let dir = temp_dir();
let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
config.set_data_local_path("local-data");
config.set_resources_path("resources-dir");
config.set_user_data_path("user-data-dir");
assert_eq!(
config.data_local().unwrap().meta().source_config,
config.root_config_file()
);
assert_eq!(
config.resources().unwrap().meta().source_config,
config.root_config_file()
);
assert_eq!(
config.userdata().unwrap().meta().source_config,
config.root_config_file()
);
assert_eq!(
config.data_local().unwrap().parsed(),
&dir.join("local-data")
);
assert_eq!(
config.resources().unwrap().parsed(),
&dir.join("resources-dir")
);
assert_eq!(
config.userdata().unwrap().parsed(),
&dir.join("user-data-dir")
);
let serialized = config.to_string();
assert!(serialized.contains("data-local=local-data"));
assert!(serialized.contains("resources=resources-dir"));
assert!(serialized.contains("user-data=user-data-dir"));
assert!(!serialized.contains("userdata="));
}
#[test]
fn test_repeated_path_level_singleton_setters_replace() {
let dir = temp_dir();
let mut config = OpenMWConfiguration::new_empty(dir).unwrap();
config.set_data_local_path("first-local");
config.set_data_local_path("second-local");
config.set_resources_path("first-resources");
config.set_resources_path("second-resources");
config.set_user_data_path("first-user-data");
config.set_user_data_path("second-user-data");
let serialized = config.to_string();
assert_eq!(serialized.matches("data-local=").count(), 1);
assert_eq!(serialized.matches("resources=").count(), 1);
assert_eq!(serialized.matches("user-data=").count(), 1);
assert!(serialized.contains("data-local=second-local"));
assert!(serialized.contains("resources=second-resources"));
assert!(serialized.contains("user-data=second-user-data"));
}
#[test]
fn test_path_level_directory_singleton_clearers_remove_values() {
let dir = temp_dir();
let mut config = OpenMWConfiguration::new_empty(dir).unwrap();
config.set_data_local_path("local-data");
config.set_resources_path("resources-dir");
config.set_user_data_path("user-data-dir");
config.clear_data_local();
config.clear_resources();
config.clear_user_data();
assert!(config.data_local().is_none());
assert!(config.resources().is_none());
assert!(config.userdata().is_none());
}
#[test]
fn test_from_env_openmw_config_dir() {
let _guard = env_lock();
let dir = temp_dir();
write_cfg(&dir, "content=Morrowind.esm\n");
unsafe {
clear_from_env_overrides();
std::env::set_var("OPENMW_CONFIG_DIR", &dir);
}
let config = OpenMWConfiguration::from_env().unwrap();
unsafe { clear_from_env_overrides() };
assert!(config.has_content_file("Morrowind.esm"));
assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
}
#[test]
fn test_from_env_openmw_config_file() {
let _guard = env_lock();
let dir = temp_dir();
let cfg = write_cfg(&dir, "content=Tribunal.esm\n");
unsafe {
clear_from_env_overrides();
std::env::set_var("OPENMW_CONFIG", &cfg);
}
let config = OpenMWConfiguration::from_env().unwrap();
unsafe { clear_from_env_overrides() };
assert!(config.has_content_file("Tribunal.esm"));
assert_eq!(config.root_config_file(), cfg);
}
#[test]
fn test_from_env_prefers_openmw_config_file_over_dir() {
let _guard = env_lock();
let file_dir = temp_dir();
let dir_dir = temp_dir();
let cfg = write_cfg(&file_dir, "content=FileWins.esm\n");
write_cfg(&dir_dir, "content=DirLoses.esm\n");
unsafe {
clear_from_env_overrides();
std::env::set_var("OPENMW_CONFIG", &cfg);
std::env::set_var("OPENMW_CONFIG_DIR", &dir_dir);
}
let config = OpenMWConfiguration::from_env().unwrap();
unsafe { clear_from_env_overrides() };
assert_eq!(config.root_config_file(), cfg);
assert!(config.has_content_file("FileWins.esm"));
assert!(!config.has_content_file("DirLoses.esm"));
}
#[test]
fn test_from_env_prefers_first_openmw_config_dir_entry() {
let _guard = env_lock();
let missing_dir = temp_dir().join("missing");
let first_valid = temp_dir();
let second_valid = temp_dir();
write_cfg(&first_valid, "content=FirstValid.esm\n");
write_cfg(&second_valid, "content=SecondValid.esm\n");
let separator = if cfg!(windows) { ";" } else { ":" };
let path_list = format!(
"{}{}{}{}{}",
missing_dir.display(),
separator,
first_valid.display(),
separator,
second_valid.display()
);
unsafe {
clear_from_env_overrides();
std::env::set_var("OPENMW_CONFIG_DIR", path_list);
}
let config = OpenMWConfiguration::from_env().unwrap();
unsafe { clear_from_env_overrides() };
assert_eq!(config.root_config_file(), first_valid.join("openmw.cfg"));
assert!(config.has_content_file("FirstValid.esm"));
assert!(!config.has_content_file("SecondValid.esm"));
}
#[test]
fn test_from_env_config_dir_skips_non_file_openmw_cfg_entry() {
let _guard = env_lock();
let invalid_dir = temp_dir();
let valid_dir = temp_dir();
std::fs::create_dir_all(invalid_dir.join("openmw.cfg")).unwrap();
write_cfg(&valid_dir, "content=ActualFile.esm\n");
let separator = if cfg!(windows) { ";" } else { ":" };
let path_list = format!(
"{}{}{}",
invalid_dir.display(),
separator,
valid_dir.display()
);
unsafe {
clear_from_env_overrides();
std::env::set_var("OPENMW_CONFIG_DIR", path_list);
}
let config = OpenMWConfiguration::from_env().unwrap();
unsafe { clear_from_env_overrides() };
assert_eq!(config.root_config_file(), valid_dir.join("openmw.cfg"));
assert!(config.has_content_file("ActualFile.esm"));
}
#[test]
fn test_from_env_uses_global_config_when_no_explicit_env() {
let _guard = env_lock();
let global_dir = temp_dir();
let cfg = write_cfg(&global_dir, "content=GlobalRoot.esm\n");
unsafe {
clear_from_env_overrides();
std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &global_dir);
}
let config = OpenMWConfiguration::from_env().unwrap();
unsafe { clear_from_env_overrides() };
assert_eq!(config.root_config_file(), cfg);
assert!(config.has_content_file("GlobalRoot.esm"));
}
#[test]
fn test_from_env_errors_without_local_or_global_root_config() {
let _guard = env_lock();
let empty_global_dir = temp_dir();
unsafe {
clear_from_env_overrides();
std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
}
let result = OpenMWConfiguration::from_env();
unsafe { clear_from_env_overrides() };
assert!(
matches!(result, Err(ConfigError::CannotFindRootConfig { .. })),
"expected CannotFindRootConfig, got {result:?}"
);
}
#[test]
#[cfg(not(windows))]
fn test_from_env_or_user_config_falls_back_to_user_config() {
let _guard = env_lock();
let empty_global_dir = temp_dir();
let test_home = temp_dir();
let test_xdg_config_home = temp_dir();
let old_home = std::env::var_os("HOME");
let old_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
unsafe {
clear_from_env_overrides();
std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
std::env::set_var("HOME", &test_home);
std::env::set_var("XDG_CONFIG_HOME", &test_xdg_config_home);
}
let user_config_dir = crate::try_default_config_path().unwrap();
std::fs::create_dir_all(&user_config_dir).unwrap();
let user_cfg = write_cfg(&user_config_dir, "content=UserOnly.esm\n");
let result = OpenMWConfiguration::from_env_or_user_config();
unsafe {
clear_from_env_overrides();
}
restore_env_var("HOME", old_home);
restore_env_var("XDG_CONFIG_HOME", old_xdg_config_home);
let config = result.unwrap();
assert_eq!(config.root_config_file(), user_cfg);
assert!(config.has_content_file("UserOnly.esm"));
}
#[test]
#[cfg(not(windows))]
fn test_from_env_or_user_config_errors_when_no_candidates_exist() {
let _guard = env_lock();
let empty_global_dir = temp_dir();
let test_home = temp_dir();
let test_xdg_config_home = temp_dir();
let old_home = std::env::var_os("HOME");
let old_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
unsafe {
clear_from_env_overrides();
std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
std::env::set_var("HOME", &test_home);
std::env::set_var("XDG_CONFIG_HOME", &test_xdg_config_home);
}
let result = OpenMWConfiguration::from_env_or_user_config();
unsafe {
clear_from_env_overrides();
}
restore_env_var("HOME", old_home);
restore_env_var("XDG_CONFIG_HOME", old_xdg_config_home);
assert!(
matches!(result, Err(ConfigError::CannotFindAnyConfig { .. })),
"expected CannotFindAnyConfig, got {result:?}"
);
}
#[test]
fn test_global_root_config_loads_config_chain() {
let _guard = env_lock();
let global_dir = temp_dir();
let user_dir = temp_dir();
write_cfg(&user_dir, "content=UserConfig.esm\n");
let root = write_cfg(
&global_dir,
&format!("content=GlobalRoot.esm\nconfig={}\n", user_dir.display()),
);
unsafe {
clear_from_env_overrides();
std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &global_dir);
}
let config = OpenMWConfiguration::from_env().unwrap();
unsafe { clear_from_env_overrides() };
assert_eq!(config.root_config_file(), root);
assert!(config.has_content_file("GlobalRoot.esm"));
assert!(config.has_content_file("UserConfig.esm"));
}
#[test]
fn test_error_duplicate_archive_file() {
let dir = temp_dir();
write_cfg(
&dir,
"fallback-archive=Morrowind.bsa\nfallback-archive=Morrowind.bsa\n",
);
let result = OpenMWConfiguration::new(Some(dir));
assert!(matches!(
result,
Err(ConfigError::DuplicateArchiveFile { .. })
));
}
#[test]
fn test_error_cannot_add_groundcover_file() {
let mut config = load("groundcover=GrassPlugin.esp\n");
let result = config.add_groundcover_file("GrassPlugin.esp");
assert!(matches!(
result,
Err(ConfigError::CannotAddGroundcoverFile { .. })
));
}
#[test]
fn test_error_cannot_find() {
let result =
OpenMWConfiguration::new(Some(PathBuf::from("/nonexistent/totally/fake/path")));
assert!(matches!(
result,
Err(ConfigError::CannotFind(_) | ConfigError::NotFileOrDirectory(_))
));
}
#[test]
fn test_error_io_from_conversion() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let config_err: ConfigError = io_err.into();
assert!(matches!(config_err, ConfigError::Io(_)));
}
#[test]
fn test_error_invalid_line() {
let result = OpenMWConfiguration::new(Some({
let dir = temp_dir();
write_cfg(&dir, "this_has_no_equals_sign\n");
dir
}));
assert!(matches!(
result,
Err(ConfigError::InvalidLine { line: Some(1), .. })
));
}
#[test]
fn test_error_max_depth_exceeded() {
let dirs: Vec<PathBuf> = (0..18).map(|_| temp_dir()).collect();
for pair in dirs.windows(2) {
write_cfg(&pair[0], &format!("config={}\n", pair[1].display()));
}
write_cfg(&dirs[17], "");
let result = OpenMWConfiguration::new(Some(dirs[0].clone()));
assert!(matches!(result, Err(ConfigError::MaxDepthExceeded(_))));
}
#[test]
fn test_a_config_that_names_itself_loads_once() {
let dir = temp_dir();
write_cfg(
&dir,
&format!("content=Self.esm\nconfig={}\n", dir.display()),
);
let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
assert_eq!(config.config_chain().count(), 1);
assert_eq!(config.content_file_count(), 1);
assert_eq!(config.user_config_path(), dir);
}
#[test]
fn test_a_circular_chain_loads_each_config_once() {
let a = temp_dir();
let b = temp_dir();
write_cfg(&a, &format!("content=A.esm\nconfig={}\n", b.display()));
write_cfg(&b, &format!("content=B.esm\nconfig={}\n", a.display()));
let config = OpenMWConfiguration::new(Some(a.clone())).unwrap();
let chain: Vec<PathBuf> = config
.config_chain()
.map(|entry| entry.path().to_path_buf())
.collect();
assert_eq!(chain, [a.join("openmw.cfg"), b.join("openmw.cfg")]);
assert_eq!(config.content_file_count(), 2);
assert_eq!(config.user_config_path(), b);
}
#[cfg(unix)]
#[test]
fn test_symlinked_config_dir_loads_like_real_path() {
let real_dir = temp_dir();
write_cfg(&real_dir, "content=Morrowind.esm\n");
let link_parent = temp_dir();
let link_path = link_parent.join("symlinked-config");
if link_path.exists() {
let _ = std::fs::remove_file(&link_path);
let _ = std::fs::remove_dir_all(&link_path);
}
symlink_dir(&real_dir, &link_path);
let config = OpenMWConfiguration::new(Some(link_path.clone())).unwrap();
assert!(config.has_content_file("Morrowind.esm"));
assert_eq!(config.root_config_file(), link_path.join("openmw.cfg"));
assert_eq!(config.root_config_dir(), link_path);
}
#[test]
fn test_settings_matching_filters_correctly() {
let config = load("content=Morrowind.esm\nfallback-archive=Morrowind.bsa\n");
let content_count = config
.settings_matching(|s| matches!(s, SettingValue::ContentFile(_)))
.count();
assert_eq!(content_count, 1);
}
#[test]
fn test_clear_matching_removes_entries() {
let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
config.clear_matching(|s| matches!(s, SettingValue::ContentFile(_)));
assert_eq!(config.content_files_iter().count(), 0);
}
#[test]
fn test_sub_configs_iteration() {
let root_dir = temp_dir();
let sub_dir = temp_dir();
write_cfg(&sub_dir, "content=Plugin.esp\n");
write_cfg(
&root_dir,
&format!("content=Morrowind.esm\nconfig={}\n", sub_dir.display()),
);
let config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
assert_eq!(config.sub_configs().count(), 1);
assert!(
config.has_content_file("Plugin.esp"),
"sub-config content visible in root"
);
}
#[test]
fn test_config_chain_priority_order_for_data_lists_is_depth_first() {
let dir1 = temp_dir();
let dir2 = temp_dir();
let dir3 = temp_dir();
let dir4 = temp_dir();
write_cfg(
&dir1,
&format!(
"data=root-a\nconfig={}\nconfig={}\n",
dir2.display(),
dir3.display()
),
);
write_cfg(
&dir2,
&format!("data=branch-a\nconfig={}\n", dir4.display()),
);
write_cfg(&dir3, "data=sibling-a\n");
write_cfg(&dir4, "data=leaf-a\n");
let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
let actual: Vec<String> = config
.data_directories_iter()
.map(|setting| setting.original().clone())
.collect();
assert_eq!(actual, vec!["root-a", "branch-a", "leaf-a", "sibling-a"]);
}
#[test]
fn test_replace_data_keeps_the_depth_first_order_in_a_branching_chain() {
let dir1 = temp_dir();
let dir2 = temp_dir();
let dir3 = temp_dir();
let dir4 = temp_dir();
write_cfg(
&dir1,
&format!(
"data=root-a\nconfig={}\nconfig={}\n",
dir2.display(),
dir3.display()
),
);
write_cfg(
&dir2,
&format!("replace=data\ndata=branch-a\nconfig={}\n", dir4.display()),
);
write_cfg(&dir3, "data=sibling-a\n");
write_cfg(&dir4, "data=leaf-a\n");
let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
let actual: Vec<String> = config
.data_directories_iter()
.map(|setting| setting.original().clone())
.collect();
assert_eq!(actual, vec!["branch-a", "leaf-a", "sibling-a"]);
}
#[test]
fn test_config_chain_priority_order_for_content_lists_is_depth_first() {
let dir1 = temp_dir();
let dir2 = temp_dir();
let dir3 = temp_dir();
let dir4 = temp_dir();
write_cfg(
&dir1,
&format!(
"content=Root.esm\nconfig={}\nconfig={}\n",
dir2.display(),
dir3.display()
),
);
write_cfg(
&dir2,
&format!("content=Branch.esm\nconfig={}\n", dir4.display()),
);
write_cfg(&dir3, "content=Sibling.esm\n");
write_cfg(&dir4, "content=Leaf.esm\n");
let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
let actual: Vec<String> = config
.content_files_iter()
.map(|setting| setting.value().clone())
.collect();
assert_eq!(
actual,
vec!["Root.esm", "Branch.esm", "Leaf.esm", "Sibling.esm"],
"content= follows the order the chain loads in"
);
}
#[test]
fn test_config_chain_priority_order_for_groundcover_lists_is_depth_first() {
let dir1 = temp_dir();
let dir2 = temp_dir();
let dir3 = temp_dir();
let dir4 = temp_dir();
write_cfg(
&dir1,
&format!(
"groundcover=Root.esp\nconfig={}\nconfig={}\n",
dir2.display(),
dir3.display()
),
);
write_cfg(
&dir2,
&format!("groundcover=Branch.esp\nconfig={}\n", dir4.display()),
);
write_cfg(&dir3, "groundcover=Sibling.esp\n");
write_cfg(&dir4, "groundcover=Leaf.esp\n");
let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
let actual: Vec<String> = config
.groundcover_iter()
.map(|setting| setting.value().clone())
.collect();
assert_eq!(
actual,
vec!["Root.esp", "Branch.esp", "Leaf.esp", "Sibling.esp"],
"groundcover= follows the order the chain loads in"
);
}
#[test]
fn test_config_chain_priority_order_is_depth_first() {
let dir1 = temp_dir();
let dir2 = temp_dir();
let dir3 = temp_dir();
let dir4 = temp_dir();
write_cfg(
&dir1,
&format!("config={}\nconfig={}\n", dir2.display(), dir3.display()),
);
write_cfg(
&dir2,
&format!("encoding=win1250\nconfig={}\n", dir4.display()),
);
write_cfg(&dir3, "encoding=win1251\n");
write_cfg(&dir4, "encoding=win1252\n");
let config = OpenMWConfiguration::new(Some(dir1.clone())).unwrap();
assert_eq!(
config.encoding().unwrap().to_string().trim(),
"encoding=win1251"
);
assert_eq!(config.user_config_path(), dir3);
let chain: Vec<(PathBuf, usize)> = config
.config_chain()
.map(|entry| (entry.path().to_path_buf(), entry.depth()))
.collect();
assert_eq!(
chain,
[
(dir1.join("openmw.cfg"), 0),
(dir2.join("openmw.cfg"), 1),
(dir4.join("openmw.cfg"), 2),
(dir3.join("openmw.cfg"), 1),
]
);
}
#[test]
fn test_config_chain_priority_order_with_user_data_crosscheck() {
let dir1 = temp_dir();
let dir2 = temp_dir();
let dir3 = temp_dir();
let dir4 = temp_dir();
write_cfg(
&dir1,
&format!("config={}\nconfig={}\n", dir2.display(), dir3.display()),
);
write_cfg(
&dir2,
&format!("user-data={}\nconfig={}\n", dir2.display(), dir4.display()),
);
write_cfg(&dir3, &format!("user-data={}\n", dir3.display()));
write_cfg(&dir4, &format!("user-data={}\n", dir4.display()));
let config = OpenMWConfiguration::new(Some(dir1.clone())).unwrap();
assert_eq!(config.user_config_path(), dir3);
assert_eq!(config.userdata().unwrap().parsed(), dir3.as_path());
}
#[test]
fn test_root_config_file_points_to_cfg() {
let dir = temp_dir();
write_cfg(&dir, "");
let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
}
#[test]
fn test_root_config_dir_is_parent() {
let dir = temp_dir();
write_cfg(&dir, "");
let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
assert_eq!(config.root_config_dir(), dir);
}
#[test]
fn test_clone_is_independent() {
let mut original = load("content=Morrowind.esm\n");
let mut cloned = original.clone();
cloned.add_content_file("Tribunal.esm").unwrap();
original.add_content_file("Bloodmoon.esm").unwrap();
assert!(cloned.has_content_file("Tribunal.esm"));
assert!(!cloned.has_content_file("Bloodmoon.esm"));
assert!(original.has_content_file("Bloodmoon.esm"));
assert!(!original.has_content_file("Tribunal.esm"));
}
fn assert_indexes_consistent(config: &OpenMWConfiguration) {
use std::collections::HashSet;
config.ensure_game_setting_indexes();
let scanned_content: FxHashSet<String> = config
.settings
.iter()
.filter_map(|setting| match setting {
SettingValue::ContentFile(file) => Some(file.value().clone()),
_ => None,
})
.collect();
let scanned_groundcover: FxHashSet<String> = config
.settings
.iter()
.filter_map(|setting| match setting {
SettingValue::Groundcover(file) => Some(file.value().clone()),
_ => None,
})
.collect();
let scanned_archives: FxHashSet<String> = config
.settings
.iter()
.filter_map(|setting| match setting {
SettingValue::BethArchive(file) => Some(file.value().clone()),
_ => None,
})
.collect();
let scanned_data_dirs: FxHashSet<PathBuf> = config
.settings
.iter()
.filter_map(|setting| match setting {
SettingValue::DataDirectory(dir) => Some(dir.parsed().to_path_buf()),
_ => None,
})
.collect();
let mut scanned_game_setting_last = FxHashMap::default();
for (index, setting) in config.settings.iter().enumerate() {
if let SettingValue::GameSetting(game_setting) = setting {
scanned_game_setting_last.insert(game_setting.key().clone(), index);
}
}
let mut scanned_game_setting_order = Vec::new();
let mut seen = HashSet::new();
for (index, setting) in config.settings.iter().enumerate().rev() {
if let SettingValue::GameSetting(game_setting) = setting
&& seen.insert(game_setting.key())
{
scanned_game_setting_order.push(index);
}
}
assert_eq!(config.indexed_content, scanned_content);
assert_eq!(config.indexed_groundcover, scanned_groundcover);
assert_eq!(config.indexed_archives, scanned_archives);
assert_eq!(config.indexed_data_dirs, scanned_data_dirs);
assert_eq!(
*config.indexed_game_setting_last.borrow(),
scanned_game_setting_last
);
assert_eq!(
*config.indexed_game_setting_order.borrow(),
scanned_game_setting_order
);
for file in &config.indexed_content {
assert!(config.has_content_file(file));
}
for file in &config.indexed_groundcover {
assert!(config.has_groundcover_file(file));
}
for file in &config.indexed_archives {
assert!(config.has_archive_file(file));
}
for dir in &config.indexed_data_dirs {
assert!(config.has_data_dir(dir.to_string_lossy().as_ref()));
}
let iter_keys: Vec<String> = config
.game_settings()
.map(|setting| setting.key().clone())
.collect();
let expected_keys: Vec<String> = config
.indexed_game_setting_order
.borrow()
.iter()
.filter_map(|index| match &config.settings[*index] {
SettingValue::GameSetting(game_setting) => Some(game_setting.key().clone()),
_ => None,
})
.collect();
assert_eq!(iter_keys, expected_keys);
for (key, index) in config.indexed_game_setting_last.borrow().iter() {
let expected_value = match &config.settings[*index] {
SettingValue::GameSetting(game_setting) => game_setting.value(),
_ => unreachable!("game setting index points to non-game setting"),
};
assert_eq!(
config.get_game_setting(key).map(GameSettingType::value),
Some(expected_value)
);
}
}
#[test]
fn test_indexes_remain_coherent_through_mutations() {
let mut config = load(
"content=Morrowind.esm\n\
content=Tribunal.esm\n\
groundcover=Grass.esp\n\
data=/tmp/data\n\
fallback-archive=Morrowind.bsa\n\
fallback=iGamma,1.00\n",
);
assert_indexes_consistent(&config);
config.add_content_file("Bloodmoon.esm").unwrap();
assert_indexes_consistent(&config);
config.remove_content_file("Tribunal.esm");
assert_indexes_consistent(&config);
config.add_groundcover_file("Flora.esp").unwrap();
assert_indexes_consistent(&config);
config.remove_groundcover_file("Grass.esp");
assert_indexes_consistent(&config);
config.add_archive_file("Tribunal.bsa").unwrap();
assert_indexes_consistent(&config);
config.remove_archive_file("Morrowind.bsa");
assert_indexes_consistent(&config);
config.add_data_directory(Path::new("/tmp/extra-data"));
assert_indexes_consistent(&config);
config.remove_data_directory(&PathBuf::from("/tmp/data"));
assert_indexes_consistent(&config);
config.set_content_files(Some(vec!["One.esp".to_string(), "Two.esp".to_string()]));
assert_indexes_consistent(&config);
config.set_fallback_archives(Some(vec!["Only.bsa".to_string()]));
assert_indexes_consistent(&config);
config
.set_game_settings(Some(vec![
"iFoo,10".to_string(),
"iFoo,11".to_string(),
"fBar,1.5".to_string(),
]))
.unwrap();
assert_indexes_consistent(&config);
let err = config.set_game_settings(Some(vec!["invalid-no-comma".to_string()]));
assert!(err.is_err());
assert_indexes_consistent(&config);
config.clear_matching(|setting| matches!(setting, SettingValue::ContentFile(_)));
assert_indexes_consistent(&config);
}
#[test]
fn test_indexes_coherent_after_replace_during_load() {
let config = load_chain(
"content=Root.esm\n\
groundcover=GrassRoot.esp\n\
fallback-archive=Root.bsa\n\
fallback=iFoo,1\n",
"replace=content\n\
content=AfterReplace.esm\n\
replace=groundcover\n\
groundcover=GrassAfter.esp\n\
replace=fallback-archive\n\
fallback-archive=After.bsa\n\
replace=fallback\n\
fallback=iFoo,2\n",
);
assert_indexes_consistent(&config);
assert!(config.has_content_file("AfterReplace.esm"));
assert!(!config.has_content_file("Root.esm"));
assert!(config.has_groundcover_file("GrassAfter.esp"));
assert!(!config.has_groundcover_file("GrassRoot.esp"));
assert!(config.has_archive_file("After.bsa"));
assert!(!config.has_archive_file("Root.bsa"));
assert_eq!(
config.get_game_setting("iFoo").map(GameSettingType::value),
Some("2".into())
);
}
}