1use std::{
5 cell::{Cell, RefCell},
6 fmt::{self, Display},
7 fs::{create_dir_all, metadata, read_to_string},
8 path::{Path, PathBuf},
9};
10
11use crate::{ConfigError, GameSetting};
12use error::bail_config;
13use std::collections::HashSet;
14
15pub mod directorysetting;
16use directorysetting::DirectorySetting;
17
18pub mod filesetting;
19use filesetting::FileSetting;
20
21pub mod gamesetting;
22use gamesetting::GameSettingType;
23
24pub mod genericsetting;
25use genericsetting::GenericSetting;
26
27pub mod encodingsetting;
28use encodingsetting::EncodingSetting;
29
30pub mod error;
31mod hash;
32use hash::{FxHashMap, FxHashSet};
33#[macro_use]
34mod singletonsetting;
35mod strings;
36mod util;
37
38#[derive(Clone, Debug)]
45#[non_exhaustive]
46pub enum SettingValue {
47 DataDirectory(DirectorySetting),
49 GameSetting(GameSettingType),
51 UserData(DirectorySetting),
53 DataLocal(DirectorySetting),
55 Resources(DirectorySetting),
57 Encoding(EncodingSetting),
59 SubConfiguration(DirectorySetting),
61 Generic(GenericSetting),
63 Replace(GenericSetting),
66 ContentFile(FileSetting),
68 BethArchive(FileSetting),
70 Groundcover(FileSetting),
72 TrailingComment(TrailingComment),
75}
76
77#[derive(Debug, Clone)]
79pub struct TrailingComment {
80 meta: crate::GameSettingMeta,
81}
82
83impl TrailingComment {
84 #[must_use]
86 pub fn meta(&self) -> &crate::GameSettingMeta {
87 &self.meta
88 }
89}
90
91impl Display for TrailingComment {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 write!(f, "{}", self.meta.comment)
94 }
95}
96
97impl crate::GameSetting for TrailingComment {
98 fn meta(&self) -> &crate::GameSettingMeta {
99 &self.meta
100 }
101
102 fn meta_mut(&mut self) -> &mut crate::GameSettingMeta {
103 &mut self.meta
104 }
105}
106
107const SERIALIZER_STAMP_PREFIX: &str = "# OpenMW-Config Serializer Version:";
110
111fn write_serializer_stamp(out: &mut impl std::fmt::Write) -> fmt::Result {
112 writeln!(
113 out,
114 "{SERIALIZER_STAMP_PREFIX} {}",
115 env!("CARGO_PKG_VERSION")
116 )
117}
118
119impl Display for SettingValue {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 let str = match self {
122 SettingValue::TrailingComment(trailing) => {
124 return write!(f, "{}", trailing.meta().comment);
125 }
126 SettingValue::Encoding(encoding_setting) => encoding_setting.to_string(),
127 SettingValue::UserData(userdata_setting) => format!(
128 "{}user-data={}",
129 userdata_setting.meta().comment,
130 userdata_setting.original()
131 ),
132 SettingValue::DataLocal(data_local_setting) => format!(
133 "{}data-local={}",
134 data_local_setting.meta().comment,
135 data_local_setting.original(),
136 ),
137 SettingValue::Resources(resources_setting) => format!(
138 "{}resources={}",
139 resources_setting.meta().comment,
140 resources_setting.original()
141 ),
142 SettingValue::GameSetting(game_setting) => game_setting.to_string(),
143 SettingValue::DataDirectory(data_directory) => format!(
144 "{}data={}",
145 data_directory.meta().comment,
146 data_directory.original()
147 ),
148 SettingValue::SubConfiguration(sub_config) => format!(
149 "{}config={}",
150 sub_config.meta().comment,
151 sub_config.original()
152 ),
153 SettingValue::Generic(generic) | SettingValue::Replace(generic) => generic.to_string(),
154 SettingValue::ContentFile(plugin) => {
155 format!("{}content={}", plugin.meta().comment, plugin.value())
156 }
157 SettingValue::BethArchive(archive) => {
158 format!(
159 "{}fallback-archive={}",
160 archive.meta().comment,
161 archive.value(),
162 )
163 }
164 SettingValue::Groundcover(grass) => {
165 format!("{}groundcover={}", grass.meta().comment, grass.value())
166 }
167 };
168
169 writeln!(f, "{str}")
170 }
171}
172
173impl From<GameSettingType> for SettingValue {
174 fn from(g: GameSettingType) -> Self {
175 SettingValue::GameSetting(g)
176 }
177}
178
179impl From<DirectorySetting> for SettingValue {
180 fn from(d: DirectorySetting) -> Self {
181 SettingValue::DataDirectory(d)
182 }
183}
184
185impl SettingValue {
186 #[must_use]
188 pub fn meta(&self) -> &crate::GameSettingMeta {
189 match self {
190 SettingValue::BethArchive(setting)
191 | SettingValue::Groundcover(setting)
192 | SettingValue::ContentFile(setting) => setting.meta(),
193 SettingValue::UserData(setting)
194 | SettingValue::DataLocal(setting)
195 | SettingValue::DataDirectory(setting)
196 | SettingValue::Resources(setting)
197 | SettingValue::SubConfiguration(setting) => setting.meta(),
198 SettingValue::GameSetting(setting) => setting.meta(),
199 SettingValue::Encoding(setting) => setting.meta(),
200 SettingValue::Generic(setting) | SettingValue::Replace(setting) => setting.meta(),
201 SettingValue::TrailingComment(setting) => setting.meta(),
202 }
203 }
204
205 fn meta_mut(&mut self) -> &mut crate::GameSettingMeta {
206 match self {
207 SettingValue::BethArchive(setting)
208 | SettingValue::Groundcover(setting)
209 | SettingValue::ContentFile(setting) => setting.meta_mut(),
210 SettingValue::UserData(setting)
211 | SettingValue::DataLocal(setting)
212 | SettingValue::DataDirectory(setting)
213 | SettingValue::Resources(setting)
214 | SettingValue::SubConfiguration(setting) => setting.meta_mut(),
215 SettingValue::GameSetting(setting) => setting.meta_mut(),
216 SettingValue::Encoding(setting) => setting.meta_mut(),
217 SettingValue::Generic(setting) | SettingValue::Replace(setting) => setting.meta_mut(),
218 SettingValue::TrailingComment(setting) => setting.meta_mut(),
219 }
220 }
221
222 fn to_resolved_string(&self) -> Option<String> {
223 match self {
224 SettingValue::UserData(userdata_setting) => Some(format!(
225 "{}user-data={}\n",
226 userdata_setting.meta().comment,
227 userdata_setting.parsed().display()
228 )),
229 SettingValue::DataLocal(data_local_setting) => Some(format!(
230 "{}data-local={}\n",
231 data_local_setting.meta().comment,
232 data_local_setting.parsed().display()
233 )),
234 SettingValue::Resources(resources_setting) => Some(format!(
235 "{}resources={}\n",
236 resources_setting.meta().comment,
237 resources_setting.parsed().display()
238 )),
239 SettingValue::DataDirectory(data_directory) => Some(format!(
240 "{}data={}\n",
241 data_directory.meta().comment,
242 data_directory.parsed().display()
243 )),
244 SettingValue::SubConfiguration(_) | SettingValue::Replace(_) => None,
245 SettingValue::Generic(generic) if generic.key().eq_ignore_ascii_case("replace") => None,
246 _ => Some(self.to_string()),
247 }
248 }
249}
250
251fn normalize_comment(comment: &str) -> String {
255 if comment.is_empty() {
256 return String::new();
257 }
258
259 let mut normalized = String::with_capacity(comment.len() + 2);
260 for line in comment.strip_suffix('\n').unwrap_or(comment).split('\n') {
261 if !line.trim().is_empty() && !line.trim_start().starts_with('#') {
262 normalized.push_str("# ");
263 }
264 normalized.push_str(line);
265 normalized.push('\n');
266 }
267 normalized
268}
269
270#[derive(Debug, Default, Clone)]
277pub struct OpenMWConfiguration {
278 root_config: PathBuf,
279 settings: Vec<SettingValue>,
280 chain: Vec<ConfigChainEntry>,
281 indexed_content: FxHashSet<String>,
282 indexed_groundcover: FxHashSet<String>,
283 indexed_archives: FxHashSet<String>,
284 indexed_data_dirs: FxHashSet<PathBuf>,
285 lists: ListIndex,
286 indexed_game_setting_last: RefCell<FxHashMap<String, usize>>,
287 indexed_game_setting_order: RefCell<Vec<usize>>,
288 game_setting_indexes_dirty: Cell<bool>,
289 user_replaces: Vec<ListOption>,
295 user_config_dir: Option<PathBuf>,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
302enum ListOption {
303 Content,
304 Groundcover,
305 FallbackArchive,
306 Data,
307 Fallback,
308 Generic(String),
310}
311
312impl ListOption {
313 fn name(&self) -> &str {
315 match self {
316 ListOption::Content => "content",
317 ListOption::Groundcover => "groundcover",
318 ListOption::FallbackArchive => "fallback-archive",
319 ListOption::Data => "data",
320 ListOption::Fallback => "fallback",
321 ListOption::Generic(key) => key,
322 }
323 }
324
325 fn holds(&self, setting: &SettingValue) -> bool {
328 match (self, setting) {
329 (ListOption::Content, SettingValue::ContentFile(_))
330 | (ListOption::Groundcover, SettingValue::Groundcover(_))
331 | (ListOption::FallbackArchive, SettingValue::BethArchive(_))
332 | (ListOption::Data, SettingValue::DataDirectory(_))
333 | (ListOption::Fallback, SettingValue::GameSetting(_)) => true,
334 (ListOption::Generic(key), SettingValue::Generic(generic)) => generic.key() == key,
335 _ => false,
336 }
337 }
338
339 fn is_replaced_by(&self, replace: &GenericSetting) -> bool {
342 replace.value() == self.name()
343 }
344}
345
346fn list_name(setting: &SettingValue) -> Option<&str> {
349 match setting {
350 SettingValue::Replace(_) => Some("replace"),
351 SettingValue::ContentFile(_) => Some("content"),
352 SettingValue::Groundcover(_) => Some("groundcover"),
353 SettingValue::BethArchive(_) => Some("fallback-archive"),
354 SettingValue::DataDirectory(_) => Some("data"),
355 SettingValue::GameSetting(_) => Some("fallback"),
356 SettingValue::Generic(generic) => Some(generic.key()),
357 _ => None,
358 }
359}
360
361struct ConfigFile {
363 settings: Vec<(SettingValue, usize)>,
366 replaces: Vec<String>,
368}
369
370impl ConfigFile {
371 fn sub_configs(&self) -> impl DoubleEndedIterator<Item = &DirectorySetting> {
373 self.settings
374 .iter()
375 .filter_map(|(setting, _)| match setting {
376 SettingValue::SubConfiguration(dir) => Some(dir),
377 _ => None,
378 })
379 }
380
381 #[allow(clippy::too_many_lines)]
383 fn read(cfg_file_path: &Path) -> Result<Self, ConfigError> {
384 let lines = read_to_string(cfg_file_path)?;
385 let source = cfg_file_path.to_path_buf();
386
387 let mut settings: Vec<(SettingValue, usize)> = Vec::new();
388 let mut replaces = Vec::new();
389 let mut queued_comment = String::new();
390 let mut sub_configs: Vec<(String, String)> = Vec::new();
391
392 for (index, line) in lines.lines().enumerate() {
393 let line_no = index + 1;
394 let trimmed = line.trim();
395
396 if trimmed.is_empty() {
397 queued_comment.push('\n');
398 continue;
399 } else if trimmed.starts_with('#') {
400 queued_comment.push_str(line);
401 queued_comment.push('\n');
402 continue;
403 }
404
405 let Some((key, value)) = trimmed.split_once('=') else {
406 bail_config!(invalid_line, trimmed.into(), source, line_no);
407 };
408
409 let key = key.trim();
410 let value = value.trim();
411 let comment = &mut queued_comment;
412
413 let setting = match key {
414 "content" => SettingValue::ContentFile(FileSetting::new(value, &source, comment)),
415 "groundcover" => {
416 SettingValue::Groundcover(FileSetting::new(value, &source, comment))
417 }
418 "fallback-archive" => {
419 SettingValue::BethArchive(FileSetting::new(value, &source, comment))
420 }
421 "fallback" => SettingValue::GameSetting(
422 GameSettingType::try_from((value.to_owned(), source.clone(), comment))
423 .map_err(|error| match error {
424 ConfigError::InvalidGameSetting {
425 value, config_path, ..
426 } => ConfigError::InvalidGameSetting {
427 value,
428 config_path,
429 line: Some(line_no),
430 },
431 _ => error,
432 })?,
433 ),
434 "encoding" => {
435 let encoding = EncodingSetting::try_from((value.to_owned(), &source, comment))
436 .map_err(|error| match error {
437 ConfigError::BadEncoding {
438 value, config_path, ..
439 } => ConfigError::BadEncoding {
440 value,
441 config_path,
442 line: Some(line_no),
443 },
444 _ => error,
445 })?;
446 if let Some(first) = settings
448 .iter_mut()
449 .find(|(setting, _)| matches!(setting, SettingValue::Encoding(_)))
450 {
451 *first = (SettingValue::Encoding(encoding), line_no);
452 continue;
453 }
454 SettingValue::Encoding(encoding)
455 }
456 "config" => {
457 sub_configs.push((value.to_owned(), std::mem::take(comment)));
458 continue;
459 }
460 "data" => SettingValue::DataDirectory(DirectorySetting::new(
461 value,
462 source.clone(),
463 comment,
464 )),
465 "resources" => {
466 SettingValue::Resources(DirectorySetting::new(value, source.clone(), comment))
467 }
468 "user-data" => {
469 SettingValue::UserData(DirectorySetting::new(value, source.clone(), comment))
470 }
471 "data-local" => {
472 SettingValue::DataLocal(DirectorySetting::new(value, source.clone(), comment))
473 }
474 "replace" => {
475 replaces.push(value.to_owned());
476 SettingValue::Replace(GenericSetting::new(key, value, &source, comment))
477 }
478 _ => SettingValue::Generic(GenericSetting::new(key, value, &source, comment)),
479 };
480 settings.push((setting, line_no));
481 }
482
483 for (subconfig_path, mut comment) in sub_configs {
484 let setting = DirectorySetting::new(subconfig_path, source.clone(), &mut comment);
485 settings.push((SettingValue::SubConfiguration(setting), 0));
486 }
487
488 let trailing: String = queued_comment
491 .split_inclusive('\n')
492 .filter(|line| !line.starts_with(SERIALIZER_STAMP_PREFIX))
493 .collect();
494 if !trailing.is_empty() {
495 settings.push((
496 SettingValue::TrailingComment(TrailingComment {
497 meta: crate::GameSettingMeta {
498 source_config: source,
499 comment: trailing,
500 },
501 }),
502 0,
503 ));
504 }
505
506 Ok(Self { settings, replaces })
507 }
508}
509
510#[derive(Debug, Default, Clone)]
513struct ListIndex {
514 content: Vec<usize>,
515 groundcover: Vec<usize>,
516 archives: Vec<usize>,
517 data_dirs: Vec<usize>,
518 sub_configs: Vec<usize>,
519 generic: Vec<usize>,
520}
521
522#[cfg(feature = "luau")]
524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
525pub(crate) enum ListKind {
526 Content,
527 Groundcover,
528 Archives,
529 DataDirectories,
530 SubConfigs,
531 Generic,
532}
533
534#[derive(Debug, Clone, Eq, PartialEq)]
535pub enum ConfigChainStatus {
536 Loaded,
537 SkippedMissing,
538}
539
540#[derive(Debug, Clone, Eq, PartialEq)]
541pub struct ConfigChainEntry {
542 path: PathBuf,
543 depth: usize,
544 status: ConfigChainStatus,
545}
546
547impl ConfigChainEntry {
548 #[must_use]
549 pub fn path(&self) -> &Path {
550 &self.path
551 }
552
553 #[must_use]
554 pub fn depth(&self) -> usize {
555 self.depth
556 }
557
558 #[must_use]
559 pub fn status(&self) -> &ConfigChainStatus {
560 &self.status
561 }
562}
563
564impl OpenMWConfiguration {
565 fn inject_data_directories(&mut self) {
570 self.settings
571 .retain(|setting| !Self::is_synthetic_data_directory(setting));
572
573 let engine_vfs = self.resources().map(|resources| {
574 let mut engine_vfs = DirectorySetting::new(
575 resources.parsed().join("vfs").to_string_lossy(),
576 resources.meta.source_config.clone(),
577 &mut String::new(),
578 );
579 engine_vfs.meta.source_config = PathBuf::new();
580 engine_vfs
581 });
582 let data_local = self.data_local().map(|data_local| {
583 let mut data_local = data_local.clone();
584 data_local.meta.source_config = PathBuf::new();
585 data_local.meta.comment.clear();
586 data_local
587 });
588
589 if let Some(engine_vfs) = engine_vfs {
590 self.settings
591 .insert(0, SettingValue::DataDirectory(engine_vfs));
592 }
593 if let Some(data_local) = data_local {
594 self.settings.push(SettingValue::DataDirectory(data_local));
595 }
596 }
597
598 fn rebuild_indexes(&mut self) {
601 self.inject_data_directories();
602
603 self.indexed_content.clear();
604 self.indexed_groundcover.clear();
605 self.indexed_archives.clear();
606 self.indexed_data_dirs.clear();
607 let lists = &mut self.lists;
608 lists.content.clear();
609 lists.groundcover.clear();
610 lists.archives.clear();
611 lists.data_dirs.clear();
612 lists.sub_configs.clear();
613 lists.generic.clear();
614
615 for (index, setting) in self.settings.iter().enumerate() {
616 match setting {
617 SettingValue::ContentFile(file) => {
618 lists.content.push(index);
619 self.indexed_content.insert(file.value().clone());
620 }
621 SettingValue::Groundcover(file) => {
622 lists.groundcover.push(index);
623 self.indexed_groundcover.insert(file.value().clone());
624 }
625 SettingValue::BethArchive(file) => {
626 lists.archives.push(index);
627 self.indexed_archives.insert(file.value().clone());
628 }
629 SettingValue::DataDirectory(dir) => {
630 lists.data_dirs.push(index);
631 self.indexed_data_dirs.insert(dir.parsed().to_path_buf());
632 }
633 SettingValue::SubConfiguration(_) => lists.sub_configs.push(index),
634 SettingValue::Generic(_) => lists.generic.push(index),
635 _ => {}
636 }
637 }
638
639 self.mark_game_setting_indexes_dirty();
640 }
641
642 fn files_at<'a>(&'a self, positions: &'a [usize]) -> impl Iterator<Item = &'a FileSetting> {
644 positions
645 .iter()
646 .filter_map(move |&index| match &self.settings[index] {
647 SettingValue::ContentFile(file)
648 | SettingValue::Groundcover(file)
649 | SettingValue::BethArchive(file) => Some(file),
650 _ => None,
651 })
652 }
653
654 fn directories_at<'a>(
656 &'a self,
657 positions: &'a [usize],
658 ) -> impl Iterator<Item = &'a DirectorySetting> {
659 positions
660 .iter()
661 .filter_map(move |&index| match &self.settings[index] {
662 SettingValue::DataDirectory(dir) | SettingValue::SubConfiguration(dir) => Some(dir),
663 _ => None,
664 })
665 }
666
667 #[cfg(feature = "luau")]
669 pub(crate) fn list_positions(&self, kind: ListKind) -> &[usize] {
670 match kind {
671 ListKind::Content => &self.lists.content,
672 ListKind::Groundcover => &self.lists.groundcover,
673 ListKind::Archives => &self.lists.archives,
674 ListKind::DataDirectories => &self.lists.data_dirs,
675 ListKind::SubConfigs => &self.lists.sub_configs,
676 ListKind::Generic => &self.lists.generic,
677 }
678 }
679
680 #[cfg(feature = "luau")]
682 pub(crate) fn setting_at(&self, index: usize) -> Option<&SettingValue> {
683 self.settings.get(index)
684 }
685
686 #[cfg(feature = "luau")]
688 pub(crate) fn game_setting_position(&self, key: &str) -> Option<usize> {
689 self.ensure_game_setting_indexes();
690 self.indexed_game_setting_last.borrow().get(key).copied()
691 }
692
693 #[cfg(feature = "luau")]
695 pub(crate) fn game_setting_position_at(&self, index: usize) -> Option<usize> {
696 self.ensure_game_setting_indexes();
697 self.indexed_game_setting_order.borrow().get(index).copied()
698 }
699
700 #[cfg(feature = "luau")]
702 pub(crate) fn chain_entry(&self, index: usize) -> Option<&ConfigChainEntry> {
703 self.chain.get(index)
704 }
705
706 #[must_use]
708 pub fn content_file_count(&self) -> usize {
709 self.lists.content.len()
710 }
711
712 #[must_use]
714 pub fn groundcover_file_count(&self) -> usize {
715 self.lists.groundcover.len()
716 }
717
718 #[must_use]
720 pub fn archive_file_count(&self) -> usize {
721 self.lists.archives.len()
722 }
723
724 #[must_use]
726 pub fn data_directory_count(&self) -> usize {
727 self.lists.data_dirs.len()
728 }
729
730 #[must_use]
732 pub fn sub_config_count(&self) -> usize {
733 self.lists.sub_configs.len()
734 }
735
736 #[must_use]
738 pub fn generic_setting_count(&self) -> usize {
739 self.lists.generic.len()
740 }
741
742 #[must_use]
744 pub fn game_setting_count(&self) -> usize {
745 self.ensure_game_setting_indexes();
746 self.indexed_game_setting_order.borrow().len()
747 }
748
749 fn mark_game_setting_indexes_dirty(&self) {
750 self.game_setting_indexes_dirty.set(true);
751 self.indexed_game_setting_last.borrow_mut().clear();
752 self.indexed_game_setting_order.borrow_mut().clear();
753 }
754
755 fn ensure_game_setting_indexes(&self) {
756 if !self.game_setting_indexes_dirty.get() {
757 return;
758 }
759
760 let mut last = FxHashMap::default();
761 for (index, setting) in self.settings.iter().enumerate() {
762 if let SettingValue::GameSetting(game_setting) = setting {
763 last.insert(game_setting.key().clone(), index);
764 }
765 }
766
767 let mut seen = HashSet::new();
768 let mut order = Vec::new();
769 for (index, setting) in self.settings.iter().enumerate().rev() {
770 if let SettingValue::GameSetting(game_setting) = setting
771 && seen.insert(game_setting.key())
772 {
773 order.push(index);
774 }
775 }
776
777 *self.indexed_game_setting_last.borrow_mut() = last;
778 *self.indexed_game_setting_order.borrow_mut() = order;
779 self.game_setting_indexes_dirty.set(false);
780 }
781
782 pub fn from_env() -> Result<Self, ConfigError> {
802 if let Ok(explicit_path) = std::env::var("OPENMW_CONFIG") {
803 let explicit_path = util::expand_leading_tilde(&explicit_path);
804
805 if explicit_path.as_os_str().is_empty() {
806 return Err(ConfigError::NotFileOrDirectory(explicit_path));
807 } else if explicit_path.is_absolute() {
808 return Self::new(Some(explicit_path));
809 } else if explicit_path.is_relative() {
810 return Self::new(Some(util::display_preserving_absolute(&explicit_path)?));
811 }
812 return Err(ConfigError::NotFileOrDirectory(explicit_path));
813 } else if let Ok(path_list) = std::env::var("OPENMW_CONFIG_DIR") {
814 let path_list = if cfg!(windows) {
815 path_list.split(';')
816 } else {
817 path_list.split(':')
818 };
819
820 for dir in path_list {
821 let dir = util::expand_leading_tilde(dir);
822 let config_file = dir.join("openmw.cfg");
823
824 if config_file.is_file() {
825 return Self::new(Some(dir));
826 }
827 }
828 }
829
830 let root_config = crate::try_default_root_config_path()?;
831 Self::new(Some(root_config))
832 }
833
834 pub fn from_env_or_user_config() -> Result<Self, ConfigError> {
849 match Self::from_env() {
850 Ok(config) => Ok(config),
851 Err(ConfigError::CannotFindRootConfig { local, global }) => {
852 let user = crate::try_default_user_config_file()?;
853
854 if user.is_file() {
855 Self::new(Some(user))
856 } else {
857 Err(ConfigError::CannotFindAnyConfig {
858 local,
859 global,
860 user,
861 })
862 }
863 }
864 Err(error) => Err(error),
865 }
866 }
867
868 pub fn new(path: Option<PathBuf>) -> Result<Self, ConfigError> {
889 let mut config = OpenMWConfiguration::default();
890 let root_config = match path {
891 Some(path) => util::input_config_path(path)?,
892 None => crate::try_default_config_path()?.join("openmw.cfg"),
893 };
894
895 config.root_config = root_config;
896
897 if let Err(error) = config.load(&config.root_config.clone()) {
898 Err(error)
899 } else {
900 if let Some(dir) = config.data_local() {
901 let path = dir.parsed();
902
903 let path_meta = metadata(path);
904 if path_meta.is_err()
905 && let Err(error) = create_dir_all(path)
906 {
907 util::debug_log(&format!(
908 "WARNING: Attempted to create a data-local directory at {}, but failed: {error}",
909 path.display()
910 ));
911 }
912 }
913
914 util::debug_log_lazy(|| format!("{:#?}", config.settings));
915
916 Ok(config)
917 }
918 }
919
920 pub fn new_empty(user_config_dir: impl Into<PathBuf>) -> Result<Self, ConfigError> {
931 let mut user_config_dir = user_config_dir.into();
932
933 if user_config_dir.as_os_str().is_empty()
934 || user_config_dir
935 .file_name()
936 .is_some_and(|file_name| file_name == "openmw.cfg")
937 {
938 return Err(ConfigError::NotFileOrDirectory(user_config_dir));
939 }
940
941 if user_config_dir.is_relative() {
942 user_config_dir = util::display_preserving_absolute(&user_config_dir)?;
943 }
944
945 Ok(Self {
946 root_config: user_config_dir.join("openmw.cfg"),
947 ..Self::default()
948 })
949 }
950
951 pub fn load_optional(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
962 let path = path.into();
963
964 if path.as_os_str().is_empty() {
965 return Err(ConfigError::NotFileOrDirectory(path));
966 }
967
968 if path.is_dir() && !path.join("openmw.cfg").exists() {
969 return Self::new_empty(path);
970 }
971
972 if path.exists() {
973 return Self::new(Some(path));
974 }
975
976 if path
977 .file_name()
978 .is_some_and(|file_name| file_name == "openmw.cfg")
979 {
980 let parent = path
981 .parent()
982 .filter(|parent| !parent.as_os_str().is_empty())
983 .unwrap_or_else(|| Path::new("."));
984 return Self::new_empty(parent);
985 }
986
987 Self::new_empty(path)
988 }
989
990 #[must_use]
999 pub fn root_config_file(&self) -> &std::path::Path {
1000 &self.root_config
1001 }
1002
1003 #[must_use]
1010 pub fn root_config_dir(&self) -> PathBuf {
1011 self.root_config
1012 .parent()
1013 .expect("root_config has no parent directory")
1014 .to_path_buf()
1015 }
1016
1017 #[must_use]
1018 pub fn is_user_config(&self) -> bool {
1019 util::paths_equivalent(&self.root_config_dir(), &self.user_config_path())
1020 }
1021
1022 pub fn user_config(self) -> Result<Self, ConfigError> {
1029 let user_path = self.user_config_path();
1030 if util::paths_equivalent(&self.root_config_dir(), &user_path) {
1031 Ok(self)
1032 } else {
1033 Self::load_optional(user_path)
1034 }
1035 }
1036
1037 pub fn user_config_ref(&self) -> Result<Self, ConfigError> {
1043 let user_path = self.user_config_path();
1044 if util::paths_equivalent(&self.root_config_dir(), &user_path) {
1045 Ok(self.clone())
1046 } else {
1047 Self::load_optional(user_path)
1048 }
1049 }
1050
1051 #[must_use]
1072 pub fn user_config_path(&self) -> PathBuf {
1073 self.user_config_dir
1074 .clone()
1075 .unwrap_or_else(|| self.root_config_dir())
1076 }
1077
1078 impl_singleton_setting! {
1079 UserData => {
1080 get: userdata,
1081 set: set_userdata,
1082 in_type: DirectorySetting
1083 },
1084 Resources => {
1085 get: resources,
1086 set: set_resources,
1087 in_type: DirectorySetting
1088 },
1089 DataLocal => {
1090 get: data_local,
1091 set: set_data_local,
1092 in_type: DirectorySetting
1093 },
1094 Encoding => {
1095 get: encoding,
1096 set: set_encoding,
1097 in_type: EncodingSetting
1098 }
1099 }
1100
1101 fn directory_setting_for_user_config(&self, path: impl AsRef<Path>) -> DirectorySetting {
1102 DirectorySetting::new(
1103 path.as_ref().to_string_lossy(),
1104 self.user_config_path().join("openmw.cfg"),
1105 &mut String::default(),
1106 )
1107 }
1108
1109 pub fn set_data_local_path(&mut self, path: impl AsRef<Path>) {
1116 let setting = self.directory_setting_for_user_config(path);
1117 self.set_data_local(Some(setting));
1118 }
1119
1120 pub fn set_resources_path(&mut self, path: impl AsRef<Path>) {
1127 let setting = self.directory_setting_for_user_config(path);
1128 self.set_resources(Some(setting));
1129 }
1130
1131 pub fn set_user_data_path(&mut self, path: impl AsRef<Path>) {
1137 let setting = self.directory_setting_for_user_config(path);
1138 self.set_userdata(Some(setting));
1139 }
1140
1141 pub fn clear_data_local(&mut self) {
1145 self.set_data_local(None);
1146 }
1147
1148 pub fn clear_resources(&mut self) {
1152 self.set_resources(None);
1153 }
1154
1155 pub fn clear_user_data(&mut self) {
1159 self.set_userdata(None);
1160 }
1161
1162 fn user_cfg_file(&self) -> PathBuf {
1164 self.user_config_path().join("openmw.cfg")
1165 }
1166
1167 fn is_inherited(setting: &SettingValue, cfg: &Path) -> bool {
1170 let source = setting.meta().source_config();
1171 !source.as_os_str().is_empty() && !util::paths_equivalent(source, cfg)
1172 }
1173
1174 fn replace_in_user_config(&mut self, list: ListOption) {
1176 if !self.user_replaces.contains(&list) {
1177 self.user_replaces.push(list);
1178 }
1179 }
1180
1181 fn parent_holds(&self, list: &ListOption, select: impl Fn(&SettingValue) -> bool) -> bool {
1183 let user_cfg = self.user_cfg_file();
1184 self.settings.iter().any(|setting| {
1185 list.holds(setting) && select(setting) && Self::is_inherited(setting, &user_cfg)
1186 })
1187 }
1188
1189 fn replace_list(&mut self, list: ListOption, entries: impl IntoIterator<Item = SettingValue>) {
1192 let inherited = self.parent_holds(&list, |_| true);
1193 self.clear_matching_internal(|setting| list.holds(setting));
1194 if inherited {
1195 self.replace_in_user_config(list);
1196 }
1197 self.settings.extend(entries);
1198 self.rebuild_indexes();
1199 }
1200
1201 fn remove_from_list(&mut self, list: ListOption, remove: impl Fn(&SettingValue) -> bool) {
1205 let inherited = self.parent_holds(&list, &remove);
1206 self.clear_matching_internal(|setting| list.holds(setting) && remove(setting));
1207 if inherited {
1208 self.replace_in_user_config(list);
1209 }
1210 self.rebuild_indexes();
1211 }
1212
1213 pub fn content_files_iter(&self) -> impl Iterator<Item = &FileSetting> {
1217 self.files_at(&self.lists.content)
1218 }
1219
1220 #[must_use]
1222 pub fn has_content_file(&self, file_name: &str) -> bool {
1223 self.indexed_content.contains(file_name)
1224 }
1225
1226 #[must_use]
1228 pub fn has_groundcover_file(&self, file_name: &str) -> bool {
1229 self.indexed_groundcover.contains(file_name)
1230 }
1231
1232 #[must_use]
1234 pub fn has_archive_file(&self, file_name: &str) -> bool {
1235 self.indexed_archives.contains(file_name)
1236 }
1237
1238 #[must_use]
1243 pub fn has_data_dir(&self, file_name: &str) -> bool {
1244 let foreign = if std::path::MAIN_SEPARATOR == '/' {
1245 '\\'
1246 } else {
1247 '/'
1248 };
1249 if file_name.contains(foreign) {
1250 let query = file_name.replace(foreign, std::path::MAIN_SEPARATOR_STR);
1251 return self.indexed_data_dirs.contains(Path::new(&query));
1252 }
1253 self.indexed_data_dirs.contains(Path::new(file_name))
1254 }
1255
1256 pub fn add_content_file(&mut self, content_file: &str) -> Result<(), ConfigError> {
1259 let duplicate = self.settings.iter().find_map(|setting| match setting {
1260 SettingValue::ContentFile(plugin) => {
1261 if plugin.value() == content_file {
1262 Some(plugin)
1263 } else {
1264 None
1265 }
1266 }
1267 _ => None,
1268 });
1269
1270 if let Some(duplicate) = duplicate {
1271 bail_config!(
1272 content_already_defined,
1273 duplicate.value().to_owned(),
1274 duplicate.meta().source_config
1275 )
1276 }
1277
1278 self.settings
1279 .push(SettingValue::ContentFile(FileSetting::new(
1280 content_file,
1281 &self.user_config_path().join("openmw.cfg"),
1282 &mut String::default(),
1283 )));
1284 self.rebuild_indexes();
1285
1286 Ok(())
1287 }
1288
1289 pub fn groundcover_iter(&self) -> impl Iterator<Item = &FileSetting> {
1291 self.files_at(&self.lists.groundcover)
1292 }
1293
1294 pub fn add_groundcover_file(&mut self, content_file: &str) -> Result<(), ConfigError> {
1297 let duplicate = self.settings.iter().find_map(|setting| match setting {
1298 SettingValue::Groundcover(plugin) => {
1299 if plugin.value() == content_file {
1300 Some(plugin)
1301 } else {
1302 None
1303 }
1304 }
1305 _ => None,
1306 });
1307
1308 if let Some(duplicate) = duplicate {
1309 bail_config!(
1310 groundcover_already_defined,
1311 duplicate.value().to_owned(),
1312 duplicate.meta().source_config
1313 )
1314 }
1315
1316 self.settings
1317 .push(SettingValue::Groundcover(FileSetting::new(
1318 content_file,
1319 &self.user_config_path().join("openmw.cfg"),
1320 &mut String::default(),
1321 )));
1322 self.rebuild_indexes();
1323
1324 Ok(())
1325 }
1326
1327 pub fn remove_content_file(&mut self, file_name: &str) {
1335 self.remove_from_list(ListOption::Content, |setting| match setting {
1336 SettingValue::ContentFile(existing_file) => existing_file == file_name,
1337 _ => false,
1338 });
1339 }
1340
1341 pub fn remove_groundcover_file(&mut self, file_name: &str) {
1344 self.remove_from_list(ListOption::Groundcover, |setting| match setting {
1345 SettingValue::Groundcover(existing_file) => existing_file == file_name,
1346 _ => false,
1347 });
1348 }
1349
1350 pub fn remove_archive_file(&mut self, file_name: &str) {
1354 self.remove_from_list(ListOption::FallbackArchive, |setting| match setting {
1355 SettingValue::BethArchive(existing_file) => existing_file == file_name,
1356 _ => false,
1357 });
1358 }
1359
1360 pub fn remove_data_directory(&mut self, data_dir: &PathBuf) {
1365 self.remove_from_list(ListOption::Data, |setting| match setting {
1366 SettingValue::DataDirectory(existing_data_dir) => {
1367 existing_data_dir.parsed() == data_dir
1368 || existing_data_dir.original() == data_dir.to_string_lossy().as_ref()
1369 }
1370 _ => false,
1371 });
1372 }
1373
1374 pub fn add_data_directory(&mut self, dir: &Path) {
1376 self.settings
1377 .push(SettingValue::DataDirectory(DirectorySetting::new(
1378 dir.to_string_lossy(),
1379 self.user_config_path().join("openmw.cfg"),
1380 &mut String::default(),
1381 )));
1382 self.rebuild_indexes();
1383 }
1384
1385 pub fn add_archive_file(&mut self, archive_file: &str) -> Result<(), ConfigError> {
1388 let duplicate = self.settings.iter().find_map(|setting| match setting {
1389 SettingValue::BethArchive(archive) => {
1390 if archive.value() == archive_file {
1391 Some(archive)
1392 } else {
1393 None
1394 }
1395 }
1396 _ => None,
1397 });
1398
1399 if let Some(duplicate) = duplicate {
1400 bail_config!(
1401 archive_already_defined,
1402 duplicate.value().to_owned(),
1403 duplicate.meta().source_config
1404 )
1405 }
1406
1407 self.settings
1408 .push(SettingValue::BethArchive(FileSetting::new(
1409 archive_file,
1410 &self.user_config_path().join("openmw.cfg"),
1411 &mut String::default(),
1412 )));
1413 self.rebuild_indexes();
1414
1415 Ok(())
1416 }
1417
1418 pub fn fallback_archives_iter(&self) -> impl Iterator<Item = &FileSetting> {
1420 self.files_at(&self.lists.archives)
1421 }
1422
1423 pub fn set_content_files(&mut self, plugins: Option<Vec<String>>) {
1429 let cfg_path = self.user_cfg_file();
1430 let mut empty = String::default();
1431 let entries: Vec<SettingValue> = plugins
1432 .unwrap_or_default()
1433 .iter()
1434 .map(|plugin| {
1435 SettingValue::ContentFile(FileSetting::new(plugin, &cfg_path, &mut empty))
1436 })
1437 .collect();
1438 self.replace_list(ListOption::Content, entries);
1439 }
1440
1441 pub fn set_fallback_archives(&mut self, archives: Option<Vec<String>>) {
1447 let cfg_path = self.user_cfg_file();
1448 let mut empty = String::default();
1449 let entries: Vec<SettingValue> = archives
1450 .unwrap_or_default()
1451 .iter()
1452 .map(|archive| {
1453 SettingValue::BethArchive(FileSetting::new(archive, &cfg_path, &mut empty))
1454 })
1455 .collect();
1456 self.replace_list(ListOption::FallbackArchive, entries);
1457 }
1458
1459 pub fn generic_settings_iter(&self) -> impl Iterator<Item = &GenericSetting> {
1461 self.lists
1462 .generic
1463 .iter()
1464 .filter_map(move |&index| match &self.settings[index] {
1465 SettingValue::Generic(generic) => Some(generic),
1466 _ => None,
1467 })
1468 }
1469
1470 pub fn set_generic_settings(&mut self, key: &str, values: Option<Vec<String>>) {
1476 let cfg_path = self.user_cfg_file();
1477 let mut empty = String::default();
1478 let entries: Vec<SettingValue> = values
1479 .unwrap_or_default()
1480 .iter()
1481 .map(|value| {
1482 SettingValue::Generic(GenericSetting::new(key, value, &cfg_path, &mut empty))
1483 })
1484 .collect();
1485 self.replace_list(ListOption::Generic(key.to_owned()), entries);
1486 }
1487
1488 pub fn add_generic_setting(&mut self, key: &str, value: &str) {
1490 self.settings
1491 .push(SettingValue::Generic(GenericSetting::new(
1492 key,
1493 value,
1494 &self.user_config_path().join("openmw.cfg"),
1495 &mut String::default(),
1496 )));
1497 self.rebuild_indexes();
1498 }
1499
1500 pub fn settings_matching<'a, P>(
1502 &'a self,
1503 predicate: P,
1504 ) -> impl Iterator<Item = &'a SettingValue>
1505 where
1506 P: Fn(&SettingValue) -> bool + 'a,
1507 {
1508 self.settings.iter().filter(move |s| predicate(s))
1509 }
1510
1511 fn clear_matching_internal<P>(&mut self, predicate: P)
1513 where
1514 P: Fn(&SettingValue) -> bool,
1515 {
1516 self.settings.retain(|s| !predicate(s));
1517 }
1518
1519 fn is_synthetic_data_directory(setting: &SettingValue) -> bool {
1523 matches!(
1524 setting,
1525 SettingValue::DataDirectory(data_dir) if data_dir.meta().source_config.as_os_str().is_empty()
1526 )
1527 }
1528
1529 pub fn clear_matching<P>(&mut self, predicate: P)
1531 where
1532 P: Fn(&SettingValue) -> bool,
1533 {
1534 self.clear_matching_internal(predicate);
1535 self.rebuild_indexes();
1536 }
1537
1538 pub fn set_data_directories(&mut self, dirs: Option<Vec<PathBuf>>) {
1545 let cfg_path = self.user_cfg_file();
1546 let mut empty = String::default();
1547 let entries: Vec<SettingValue> = dirs
1548 .unwrap_or_default()
1549 .iter()
1550 .map(|dir| {
1551 SettingValue::DataDirectory(DirectorySetting::new(
1552 dir.to_string_lossy(),
1553 cfg_path.clone(),
1554 &mut empty,
1555 ))
1556 })
1557 .collect();
1558 self.replace_list(ListOption::Data, entries);
1559 }
1560
1561 pub fn set_game_setting(
1573 &mut self,
1574 base_value: &str,
1575 config_path: Option<PathBuf>,
1576 comment: &mut String,
1577 ) -> Result<(), ConfigError> {
1578 *comment = normalize_comment(comment);
1579 let new_setting = GameSettingType::try_from((
1580 base_value.to_owned(),
1581 config_path.unwrap_or_else(|| self.user_config_path().join("openmw.cfg")),
1582 comment,
1583 ))?;
1584
1585 self.settings.push(SettingValue::GameSetting(new_setting));
1586 self.rebuild_indexes();
1587
1588 Ok(())
1589 }
1590
1591 pub fn set_game_settings(&mut self, settings: Option<Vec<String>>) -> Result<(), ConfigError> {
1601 let cfg_path = self.user_cfg_file();
1602 let mut empty = String::default();
1603 let mut entries = Vec::new();
1604 let mut failure = None;
1605 for setting in settings.unwrap_or_default() {
1606 match GameSettingType::try_from((setting, cfg_path.clone(), &mut empty)) {
1607 Ok(parsed) => entries.push(SettingValue::GameSetting(parsed)),
1608 Err(error) => {
1609 failure = Some(error);
1610 entries.clear();
1611 break;
1612 }
1613 }
1614 }
1615 self.replace_list(ListOption::Fallback, entries);
1616 failure.map_or(Ok(()), Err)
1617 }
1618
1619 pub fn sub_configs(&self) -> impl Iterator<Item = &DirectorySetting> {
1627 self.directories_at(&self.lists.sub_configs)
1628 }
1629
1630 pub fn config_chain(&self) -> impl Iterator<Item = &ConfigChainEntry> {
1636 self.chain.iter()
1637 }
1638
1639 pub fn game_settings(&self) -> impl Iterator<Item = &GameSettingType> {
1658 self.ensure_game_setting_indexes();
1659 let order = self.indexed_game_setting_order.borrow();
1662 (0..order.len()).filter_map(move |position| match &self.settings[order[position]] {
1663 SettingValue::GameSetting(setting) => Some(setting),
1664 _ => None,
1665 })
1666 }
1667
1668 #[must_use]
1672 pub fn get_game_setting(&self, key: &str) -> Option<&GameSettingType> {
1673 self.ensure_game_setting_indexes();
1674 self.indexed_game_setting_last
1675 .borrow()
1676 .get(key)
1677 .and_then(|index| match &self.settings[*index] {
1678 SettingValue::GameSetting(setting) => Some(setting),
1679 _ => None,
1680 })
1681 }
1682
1683 pub fn data_directories_iter(&self) -> impl Iterator<Item = &DirectorySetting> {
1692 self.directories_at(&self.lists.data_dirs)
1693 }
1694
1695 const MAX_CONFIG_DEPTH: usize = 16;
1696
1697 fn load(&mut self, root_config: &Path) -> Result<(), ConfigError> {
1701 let mut pending_configs = vec![(root_config.to_path_buf(), 0usize)];
1704 let mut tried_dirs: FxHashSet<PathBuf> = FxHashSet::default();
1709 let mut loaded: Vec<ConfigFile> = Vec::new();
1711 let mut active_dirs: Vec<PathBuf> = Vec::new();
1714
1715 while let Some((config_path, depth)) = pending_configs.pop() {
1716 let cfg_file_path = if config_path.is_dir() {
1717 config_path.join("openmw.cfg")
1718 } else {
1719 config_path
1720 };
1721 let config_dir = cfg_file_path
1722 .parent()
1723 .map(Path::to_path_buf)
1724 .unwrap_or_default();
1725 if !tried_dirs.insert(config_dir.clone()) {
1726 util::debug_log_lazy(|| {
1727 format!(
1728 "Skipping {} as the chain already tried it!",
1729 cfg_file_path.display()
1730 )
1731 });
1732 continue;
1733 }
1734
1735 if !cfg_file_path.exists() {
1736 if depth == 0 {
1737 bail_config!(cannot_find, cfg_file_path);
1738 }
1739 util::debug_log_lazy(|| {
1740 format!("Skipping {} as it does not exist!", cfg_file_path.display())
1741 });
1742 self.chain.push(ConfigChainEntry {
1743 path: cfg_file_path,
1744 depth,
1745 status: ConfigChainStatus::SkippedMissing,
1746 });
1747 active_dirs.push(config_dir);
1748 continue;
1749 }
1750
1751 if depth > Self::MAX_CONFIG_DEPTH {
1752 bail_config!(max_depth_exceeded, cfg_file_path);
1753 }
1754
1755 util::debug_log_lazy(|| format!("BEGIN CONFIG PARSING: {}", cfg_file_path.display()));
1756
1757 self.chain.push(ConfigChainEntry {
1758 path: cfg_file_path.clone(),
1759 depth,
1760 status: ConfigChainStatus::Loaded,
1761 });
1762 let file = ConfigFile::read(&cfg_file_path)?;
1763
1764 if loaded.len() > 1 && file.replaces.iter().any(|name| name == "config") {
1769 loaded.truncate(1);
1770 active_dirs.truncate(1);
1771 }
1772 pending_configs.extend(
1774 file.sub_configs()
1775 .rev()
1776 .map(|dir| (dir.parsed().join("openmw.cfg"), depth + 1)),
1777 );
1778 active_dirs.push(config_dir);
1779 loaded.push(file);
1780 }
1781
1782 if let Some(user_dir) = active_dirs.last()
1786 && std::fs::create_dir_all(user_dir).is_err()
1787 {
1788 bail_config!(not_writable, user_dir);
1789 }
1790
1791 self.merge(loaded, &active_dirs)?;
1792 if active_dirs.len() > 1 {
1793 self.user_config_dir = active_dirs.pop();
1794 }
1795 self.rebuild_indexes();
1796
1797 Ok(())
1798 }
1799
1800 fn merge(
1814 &mut self,
1815 loaded: Vec<ConfigFile>,
1816 active_dirs: &[PathBuf],
1817 ) -> Result<(), ConfigError> {
1818 let mut replaced: FxHashSet<String> = FxHashSet::default();
1819 let mut kept_per_config = Vec::with_capacity(loaded.len());
1820 for file in loaded.into_iter().rev() {
1821 let kept: Vec<(SettingValue, usize)> = file
1822 .settings
1823 .into_iter()
1824 .filter(|(setting, _)| match setting {
1825 SettingValue::SubConfiguration(dir) => {
1826 active_dirs.iter().any(|active| active == dir.parsed())
1827 }
1828 _ => list_name(setting).is_none_or(|list| !replaced.contains(list)),
1829 })
1830 .collect();
1831 replaced.extend(kept.iter().filter_map(|(setting, _)| match setting {
1834 SettingValue::Replace(replace) => Some(replace.value().to_owned()),
1835 _ => None,
1836 }));
1837 kept_per_config.push(kept);
1838 }
1839
1840 let mut seen_content = FxHashSet::default();
1841 let mut seen_groundcover = FxHashSet::default();
1842 let mut seen_archives = FxHashSet::default();
1843 for (setting, line) in kept_per_config.into_iter().rev().flatten() {
1844 match &setting {
1845 SettingValue::ContentFile(file) if !seen_content.insert(file.value().clone()) => {
1846 bail_config!(
1847 duplicate_content_file,
1848 file.value().clone(),
1849 file.meta().source_config(),
1850 line
1851 );
1852 }
1853 SettingValue::Groundcover(file)
1854 if !seen_groundcover.insert(file.value().clone()) =>
1855 {
1856 bail_config!(
1857 duplicate_groundcover_file,
1858 file.value().clone(),
1859 file.meta().source_config(),
1860 line
1861 );
1862 }
1863 SettingValue::BethArchive(file) if !seen_archives.insert(file.value().clone()) => {
1864 bail_config!(
1865 duplicate_archive_file,
1866 file.value().clone(),
1867 file.meta().source_config(),
1868 line
1869 );
1870 }
1871 _ => {}
1872 }
1873 self.settings.push(setting);
1874 }
1875
1876 Ok(())
1877 }
1878
1879 fn write_config(config_string: &str, path: &Path) -> Result<(), ConfigError> {
1880 use std::io::Write;
1881 use std::time::{SystemTime, UNIX_EPOCH};
1882
1883 let parent = path
1884 .parent()
1885 .ok_or_else(|| ConfigError::NotWritable(path.to_path_buf()))?;
1886
1887 let nonce = SystemTime::now()
1888 .duration_since(UNIX_EPOCH)
1889 .map_or(0, |d| d.as_nanos());
1890 let tmp_path = parent.join(format!(
1891 ".openmw-config-tmp-{}-{}",
1892 std::process::id(),
1893 nonce
1894 ));
1895
1896 let mut file = std::fs::OpenOptions::new()
1897 .write(true)
1898 .create_new(true)
1899 .open(&tmp_path)?;
1900
1901 file.write_all(config_string.as_bytes())?;
1902 file.sync_all()?;
1903
1904 #[cfg(windows)]
1905 #[allow(clippy::permissions_set_readonly_false)]
1908 {
1909 if path.exists() {
1910 if let Ok(metadata) = std::fs::metadata(path) {
1911 let mut permissions = metadata.permissions();
1912 if permissions.readonly() {
1913 permissions.set_readonly(false);
1916 std::fs::set_permissions(path, permissions)?;
1917 }
1918 }
1919
1920 std::fs::remove_file(path)?;
1921 }
1922 }
1923
1924 std::fs::rename(&tmp_path, path)?;
1925
1926 Ok(())
1927 }
1928
1929 fn save_string_to_path(config_string: &str, path: &Path) -> Result<(), ConfigError> {
1930 let parent = path
1931 .parent()
1932 .ok_or_else(|| ConfigError::NotWritable(path.to_path_buf()))?;
1933 let writable_probe = parent.join(".openmw-config-write-test");
1934
1935 if !util::is_writable(&writable_probe) {
1936 bail_config!(not_writable, parent);
1937 }
1938
1939 Self::write_config(config_string, path)
1940 }
1941
1942 #[must_use]
1953 pub fn to_resolved_string(&self) -> String {
1954 let mut config_string = String::new();
1955
1956 for setting in &self.settings {
1957 if Self::is_synthetic_data_directory(setting) {
1958 continue;
1959 }
1960
1961 if let Some(setting_string) = setting.to_resolved_string() {
1962 config_string.push_str(&setting_string);
1963 }
1964 }
1965
1966 let _ = write_serializer_stamp(&mut config_string);
1968
1969 config_string
1970 }
1971
1972 pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
1981 Self::save_string_to_path(&self.to_string(), path.as_ref())
1982 }
1983
1984 pub fn save_resolved_to_path(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
1994 Self::save_string_to_path(&self.to_resolved_string(), path.as_ref())
1995 }
1996
1997 fn file_text(&self, cfg_path: &Path) -> String {
2003 let mut pending: Vec<&ListOption> =
2004 if util::paths_equivalent(cfg_path, &self.user_cfg_file()) {
2005 self.user_replaces.iter().collect()
2006 } else {
2007 Vec::new()
2008 };
2009 let mut text = String::new();
2010 let mut trailing = String::new();
2011
2012 for setting in self.settings.iter().filter(|setting| {
2013 !Self::is_synthetic_data_directory(setting)
2014 && util::paths_equivalent(setting.meta().source_config(), cfg_path)
2015 }) {
2016 if let SettingValue::TrailingComment(end) = setting {
2017 trailing.push_str(&end.to_string());
2018 continue;
2019 }
2020 if let SettingValue::Replace(replace) = setting
2022 && let Some(index) = pending.iter().position(|list| list.is_replaced_by(replace))
2023 {
2024 text.push_str(&setting.to_string());
2025 self.write_inherited_entries(&mut text, pending.remove(index), cfg_path);
2026 continue;
2027 }
2028 if let Some(index) = pending.iter().position(|list| list.holds(setting)) {
2029 Self::write_replace_line(&mut text, pending[index]);
2030 self.write_inherited_entries(&mut text, pending.remove(index), cfg_path);
2031 }
2032 text.push_str(&setting.to_string());
2033 }
2034
2035 for list in pending {
2036 Self::write_replace_line(&mut text, list);
2037 self.write_inherited_entries(&mut text, list, cfg_path);
2038 }
2039
2040 text.push_str(&trailing);
2041 text
2042 }
2043
2044 fn write_replace_line(text: &mut String, list: &ListOption) {
2045 text.push_str("replace=");
2046 text.push_str(list.name());
2047 text.push('\n');
2048 }
2049
2050 fn write_inherited_entries(&self, text: &mut String, list: &ListOption, cfg_path: &Path) {
2054 for setting in self
2055 .settings
2056 .iter()
2057 .filter(|setting| list.holds(setting) && Self::is_inherited(setting, cfg_path))
2058 {
2059 let mut entry = setting.clone();
2060 entry.meta_mut().comment.clear();
2061 if let SettingValue::DataDirectory(dir) = &entry {
2062 let original_here = DirectorySetting::new(
2063 dir.original(),
2064 cfg_path.to_path_buf(),
2065 &mut String::new(),
2066 );
2067 if original_here.parsed() != dir.parsed() {
2068 entry = SettingValue::DataDirectory(DirectorySetting::new(
2069 dir.parsed().to_string_lossy(),
2070 cfg_path.to_path_buf(),
2071 &mut String::new(),
2072 ));
2073 }
2074 }
2075 text.push_str(&entry.to_string());
2076 }
2077 }
2078
2079 pub fn save_user(&self) -> Result<(), ConfigError> {
2095 let target_dir = self.user_config_path();
2096 let cfg_path = target_dir.join("openmw.cfg");
2097
2098 std::fs::create_dir_all(&target_dir)?;
2099
2100 if !util::is_writable(&cfg_path) {
2101 bail_config!(not_writable, &cfg_path);
2102 }
2103
2104 Self::write_config(&self.file_text(&cfg_path), &cfg_path)
2105 }
2106
2107 pub fn save_subconfig(&self, target_dir: &Path) -> Result<(), ConfigError> {
2118 let subconfig_is_loaded = self.settings.iter().any(|setting| match setting {
2119 SettingValue::SubConfiguration(subconfig) => {
2120 util::paths_equivalent(subconfig.parsed(), target_dir)
2121 || subconfig.original() == target_dir.to_string_lossy().as_ref()
2122 }
2123 _ => false,
2124 });
2125
2126 if !subconfig_is_loaded {
2127 bail_config!(subconfig_not_loaded, target_dir);
2128 }
2129
2130 let cfg_path = target_dir.join("openmw.cfg");
2131
2132 if !util::is_writable(&cfg_path) {
2133 bail_config!(not_writable, &cfg_path);
2134 }
2135
2136 Self::write_config(&self.file_text(&cfg_path), &cfg_path)
2137 }
2138}
2139
2140impl fmt::Display for OpenMWConfiguration {
2153 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2154 let mut trailing: Vec<&SettingValue> = Vec::new();
2155
2156 for setting in self
2157 .settings
2158 .iter()
2159 .filter(|setting| !Self::is_synthetic_data_directory(setting))
2160 {
2161 if let SettingValue::TrailingComment(_) = setting {
2162 trailing.push(setting);
2163 continue;
2164 }
2165 if trailing
2166 .first()
2167 .is_some_and(|end| end.meta().source_config() != setting.meta().source_config())
2168 {
2169 trailing.drain(..).try_for_each(|end| write!(f, "{end}"))?;
2170 }
2171 write!(f, "{setting}")?;
2172 }
2173 trailing
2174 .into_iter()
2175 .try_for_each(|end| write!(f, "{end}"))?;
2176
2177 write_serializer_stamp(f)?;
2178
2179 Ok(())
2180 }
2181}
2182
2183#[cfg(test)]
2184mod tests {
2185 use super::*;
2186 use std::io::Write;
2187 use std::sync::atomic::{AtomicU64, Ordering};
2188
2189 fn write_cfg(dir: &std::path::Path, contents: &str) -> PathBuf {
2194 let cfg = dir.join("openmw.cfg");
2195 let mut f = std::fs::File::create(&cfg).unwrap();
2196 f.write_all(contents.as_bytes()).unwrap();
2197 cfg
2198 }
2199
2200 fn temp_dir() -> PathBuf {
2201 static COUNTER: AtomicU64 = AtomicU64::new(0);
2206 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
2207 let base =
2208 std::env::temp_dir().join(format!("openmw_cfg_test_{}_{id}", std::process::id()));
2209 std::fs::create_dir_all(&base).unwrap();
2210 base
2211 }
2212
2213 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2214 crate::test_env_lock()
2215 }
2216
2217 unsafe fn clear_from_env_overrides() {
2218 unsafe {
2221 std::env::remove_var("OPENMW_CONFIG");
2222 std::env::remove_var("OPENMW_CONFIG_DIR");
2223 std::env::remove_var("OPENMW_GLOBAL_CONFIG_PATH");
2224 std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
2225 std::env::remove_var("OPENMW_FLATPAK_ID");
2226 std::env::remove_var("FLATPAK_ID");
2227 }
2228 }
2229
2230 #[cfg(not(windows))]
2231 fn restore_env_var(key: &str, value: Option<std::ffi::OsString>) {
2232 unsafe {
2234 if let Some(value) = value {
2235 std::env::set_var(key, value);
2236 } else {
2237 std::env::remove_var(key);
2238 }
2239 }
2240 }
2241
2242 fn load(cfg_contents: &str) -> OpenMWConfiguration {
2243 let dir = temp_dir();
2244 write_cfg(&dir, cfg_contents);
2245 OpenMWConfiguration::new(Some(dir)).unwrap()
2246 }
2247
2248 #[cfg(unix)]
2249 fn symlink_dir(target: &std::path::Path, link: &std::path::Path) {
2250 std::os::unix::fs::symlink(target, link).unwrap();
2251 }
2252
2253 #[test]
2258 fn test_content_files_empty_on_bare_config() {
2259 let config = load("");
2260 assert!(config.content_files_iter().next().is_none());
2261 }
2262
2263 #[test]
2264 fn test_content_files_parsed_in_order() {
2265 let config = load("content=Morrowind.esm\ncontent=Tribunal.esm\ncontent=Bloodmoon.esm\n");
2266 let files: Vec<&String> = config
2267 .content_files_iter()
2268 .map(FileSetting::value)
2269 .collect();
2270 assert_eq!(
2271 files,
2272 vec!["Morrowind.esm", "Tribunal.esm", "Bloodmoon.esm"]
2273 );
2274 }
2275
2276 #[test]
2277 fn test_has_content_file_found() {
2278 let config = load("content=Morrowind.esm\n");
2279 assert!(config.has_content_file("Morrowind.esm"));
2280 }
2281
2282 #[test]
2283 fn test_has_content_file_not_found() {
2284 let config = load("content=Morrowind.esm\n");
2285 assert!(!config.has_content_file("Tribunal.esm"));
2286 }
2287
2288 #[test]
2289 fn test_duplicate_content_file_errors_on_load() {
2290 let dir = temp_dir();
2291 write_cfg(&dir, "content=Morrowind.esm\ncontent=Morrowind.esm\n");
2292 assert!(OpenMWConfiguration::new(Some(dir)).is_err());
2293 }
2294
2295 #[test]
2296 fn test_duplicate_content_file_error_reports_line_number() {
2297 let dir = temp_dir();
2298 write_cfg(&dir, "content=Morrowind.esm\ncontent=Morrowind.esm\n");
2299
2300 let result = OpenMWConfiguration::new(Some(dir));
2301 assert!(matches!(
2302 result,
2303 Err(ConfigError::DuplicateContentFile { line: Some(2), .. })
2304 ));
2305 }
2306
2307 #[test]
2308 fn test_add_content_file_appends() {
2309 let mut config = load("content=Morrowind.esm\n");
2310 config.add_content_file("MyMod.esp").unwrap();
2311 assert!(config.has_content_file("MyMod.esp"));
2312 }
2313
2314 #[test]
2315 fn test_add_duplicate_content_file_errors() {
2316 let mut config = load("content=Morrowind.esm\n");
2317 assert!(config.add_content_file("Morrowind.esm").is_err());
2318 }
2319
2320 #[test]
2321 fn test_add_content_file_source_config_is_cfg_file() {
2322 let dir = temp_dir();
2323 let cfg_path = write_cfg(&dir, "");
2324 let mut config = OpenMWConfiguration::new(Some(dir)).unwrap();
2325 config.add_content_file("Mod.esp").unwrap();
2326 let setting = config.content_files_iter().next().unwrap();
2327 assert_eq!(
2328 setting.meta().source_config,
2329 cfg_path,
2330 "source_config should be the openmw.cfg file, not a directory"
2331 );
2332 }
2333
2334 #[test]
2335 fn test_remove_content_file() {
2336 let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
2337 config.remove_content_file("Morrowind.esm");
2338 assert!(!config.has_content_file("Morrowind.esm"));
2339 assert!(config.has_content_file("Tribunal.esm"));
2340 }
2341
2342 #[test]
2343 fn test_set_content_files_replaces_all() {
2344 let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
2345 config.set_content_files(Some(vec!["NewMod.esp".to_string()]));
2346 assert!(!config.has_content_file("Morrowind.esm"));
2347 assert!(!config.has_content_file("Tribunal.esm"));
2348 assert!(config.has_content_file("NewMod.esp"));
2349 }
2350
2351 #[test]
2352 fn test_set_content_files_none_clears_all() {
2353 let mut config = load("content=Morrowind.esm\n");
2354 config.set_content_files(None);
2355 assert!(config.content_files_iter().next().is_none());
2356 }
2357
2358 #[test]
2363 fn test_fallback_archives_parsed() {
2364 let config = load("fallback-archive=Morrowind.bsa\nfallback-archive=Tribunal.bsa\n");
2365 let archives: Vec<&String> = config
2366 .fallback_archives_iter()
2367 .map(FileSetting::value)
2368 .collect();
2369 assert_eq!(archives, vec!["Morrowind.bsa", "Tribunal.bsa"]);
2370 }
2371
2372 #[test]
2373 fn test_has_archive_file() {
2374 let config = load("fallback-archive=Morrowind.bsa\n");
2375 assert!(config.has_archive_file("Morrowind.bsa"));
2376 assert!(!config.has_archive_file("Tribunal.bsa"));
2377 }
2378
2379 #[test]
2380 fn test_set_game_setting_comment_is_written_as_a_comment_line() {
2381 let mut config = load("content=Morrowind.esm\n");
2382 let mut comment = String::from("my note");
2383 config
2384 .set_game_setting("fJumpHeight,1.0", None, &mut comment)
2385 .unwrap();
2386 assert!(comment.is_empty(), "the comment is consumed");
2387 assert!(
2388 config
2389 .to_string()
2390 .contains("# my note\nfallback=fJumpHeight,1.0\n"),
2391 "{config}"
2392 );
2393
2394 let mut multi = String::from("# already a comment\n\nsecond line");
2395 config
2396 .set_game_setting("iLevel,2", None, &mut multi)
2397 .unwrap();
2398 assert!(
2399 config
2400 .to_string()
2401 .contains("# already a comment\n\n# second line\nfallback=iLevel,2\n"),
2402 "{config}"
2403 );
2404
2405 let mut empty = String::new();
2406 config
2407 .set_game_setting("iOther,3", None, &mut empty)
2408 .unwrap();
2409 assert!(config.to_string().contains("\nfallback=iOther,3\n"));
2410 assert_eq!(normalize_comment("# kept\n"), "# kept\n");
2411 assert_eq!(normalize_comment("\n"), "\n");
2412 }
2413
2414 #[test]
2415 fn test_trailing_comments_survive_serialization() {
2416 let config = load("# head\ncontent=A.esp\n\n# tail\n");
2417 let serialized = config.to_string();
2418 assert!(
2419 serialized.starts_with(
2420 "# head\ncontent=A.esp\n\n# tail\n# OpenMW-Config Serializer Version:"
2421 ),
2422 "{serialized}"
2423 );
2424
2425 let reloaded = load(&serialized).to_string();
2427 assert_eq!(reloaded, serialized);
2428 assert_eq!(serialized.matches(SERIALIZER_STAMP_PREFIX).count(), 1);
2429
2430 let plain = load("content=A.esp\n").to_string();
2432 assert!(
2433 plain.starts_with("content=A.esp\n# OpenMW-Config"),
2434 "{plain}"
2435 );
2436
2437 let only = load("# nothing here\n").to_string();
2439 assert!(
2440 only.starts_with("# nothing here\n# OpenMW-Config"),
2441 "{only}"
2442 );
2443 }
2444
2445 #[test]
2446 fn test_singleton_changes_keep_the_game_setting_index_valid() {
2447 let mut config =
2450 load("encoding=win1252\nfallback=iFirst,1\nfallback=iSecond,2\nno-sound=1\n");
2451 assert_eq!(config.get_game_setting("iSecond").unwrap().value(), "2");
2452 config.set_encoding(None);
2453 assert_eq!(config.get_game_setting("iFirst").unwrap().value(), "1");
2454 assert_eq!(config.get_game_setting("iSecond").unwrap().value(), "2");
2455 let keys: Vec<_> = config
2456 .game_settings()
2457 .map(GameSettingType::key_str)
2458 .collect();
2459 assert_eq!(keys, ["iSecond", "iFirst"]);
2460
2461 config.set_user_data_path("/tmp/user");
2462 config.clear_user_data();
2463 assert_eq!(config.get_game_setting("iFirst").unwrap().value(), "1");
2464 }
2465
2466 #[test]
2467 fn test_injected_data_directories_are_indexed() {
2468 let dir = temp_dir();
2469 let resources = dir.join("res");
2470 write_cfg(
2471 &dir,
2472 &format!("resources={}\ndata=Data Files\n", resources.display()),
2473 );
2474 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2475 assert!(config.has_data_dir(&resources.join("vfs").to_string_lossy()));
2476 assert!(config.has_data_dir(&dir.join("Data Files").to_string_lossy()));
2477 }
2478
2479 #[test]
2480 fn test_list_counts_follow_the_lists() {
2481 let mut config = load(
2482 "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",
2483 );
2484 assert_eq!(config.content_file_count(), 2);
2485 assert_eq!(config.groundcover_file_count(), 1);
2486 assert_eq!(config.archive_file_count(), 1);
2487 assert_eq!(config.data_directory_count(), 1);
2488 assert_eq!(config.sub_config_count(), 0);
2489 assert_eq!(config.game_setting_count(), 2, "distinct keys");
2490 assert_eq!(config.generic_setting_count(), 2);
2491 config.remove_content_file("A.esm");
2492 config.set_generic_settings("foo", None);
2493 assert_eq!(config.content_file_count(), 1);
2494 assert_eq!(config.generic_setting_count(), 0);
2495 assert_eq!(config.content_files_iter().count(), 1);
2496 }
2497
2498 #[test]
2499 fn test_add_duplicate_archive_errors() {
2500 let mut config = load("fallback-archive=Morrowind.bsa\n");
2501 let result = config.add_archive_file("Morrowind.bsa");
2502 assert!(
2503 matches!(result, Err(ConfigError::CannotAddArchiveFile { ref file, .. }) if file == "Morrowind.bsa"),
2504 "{result:?}"
2505 );
2506 }
2507
2508 #[test]
2509 fn test_duplicate_archive_error_reports_line_number() {
2510 let dir = temp_dir();
2511 write_cfg(
2512 &dir,
2513 "fallback-archive=Morrowind.bsa\nfallback-archive=Morrowind.bsa\n",
2514 );
2515
2516 let result = OpenMWConfiguration::new(Some(dir));
2517 assert!(matches!(
2518 result,
2519 Err(ConfigError::DuplicateArchiveFile { line: Some(2), .. })
2520 ));
2521 }
2522
2523 #[test]
2524 fn test_remove_archive_file() {
2525 let mut config = load("fallback-archive=Morrowind.bsa\nfallback-archive=Tribunal.bsa\n");
2526 config.remove_archive_file("Morrowind.bsa");
2527 assert!(!config.has_archive_file("Morrowind.bsa"));
2528 assert!(config.has_archive_file("Tribunal.bsa"));
2529 }
2530
2531 #[test]
2536 fn test_groundcover_parsed() {
2537 let config = load("groundcover=GrassPlugin.esp\n");
2538 let grass: Vec<&String> = config.groundcover_iter().map(FileSetting::value).collect();
2539 assert_eq!(grass, vec!["GrassPlugin.esp"]);
2540 }
2541
2542 #[test]
2543 fn test_has_groundcover_file() {
2544 let config = load("groundcover=Grass.esp\n");
2545 assert!(config.has_groundcover_file("Grass.esp"));
2546 assert!(!config.has_groundcover_file("Other.esp"));
2547 }
2548
2549 #[test]
2550 fn test_duplicate_groundcover_errors_on_load() {
2551 let dir = temp_dir();
2552 write_cfg(&dir, "groundcover=Grass.esp\ngroundcover=Grass.esp\n");
2553 assert!(OpenMWConfiguration::new(Some(dir)).is_err());
2554 }
2555
2556 #[test]
2557 fn test_duplicate_groundcover_error_reports_line_number() {
2558 let dir = temp_dir();
2559 write_cfg(&dir, "groundcover=Grass.esp\ngroundcover=Grass.esp\n");
2560
2561 let result = OpenMWConfiguration::new(Some(dir));
2562 assert!(matches!(
2563 result,
2564 Err(ConfigError::DuplicateGroundcoverFile { line: Some(2), .. })
2565 ));
2566 }
2567
2568 #[test]
2573 fn test_data_directories_absolute_paths_parsed() {
2574 let config = load("data=/absolute/path/to/data\n");
2575 assert!(
2576 config
2577 .data_directories_iter()
2578 .any(|d| d.parsed().ends_with("absolute/path/to/data"))
2579 );
2580 }
2581
2582 #[test]
2583 fn test_add_data_directory() {
2584 let mut config = load("");
2585 config.add_data_directory(Path::new("/some/data/dir"));
2586 assert!(config.has_data_dir("/some/data/dir"));
2587 }
2588
2589 #[test]
2590 fn test_set_data_directories_replaces_all() {
2591 let mut config = load("data=/old/dir\n");
2592 config.set_data_directories(Some(vec![PathBuf::from("/new/dir")]));
2593 assert!(!config.has_data_dir("/old/dir"));
2594 assert!(config.has_data_dir("/new/dir"));
2595 }
2596
2597 #[test]
2598 fn test_remove_data_directory() {
2599 let mut config = load("data=/keep/me\n");
2600 config.add_data_directory(Path::new("/remove/me"));
2601 config.remove_data_directory(&PathBuf::from("/remove/me"));
2602 assert!(!config.has_data_dir("/remove/me"));
2603 assert!(config.has_data_dir("/keep/me"));
2604 }
2605
2606 #[test]
2611 fn test_game_settings_parsed() {
2612 let config = load("fallback=iMaxLevel,100\n");
2613 let setting = config.get_game_setting("iMaxLevel").unwrap();
2614 assert_eq!(setting.value(), "100");
2615 }
2616
2617 #[test]
2618 fn test_game_settings_last_wins() {
2619 let config = load("fallback=iKey,1\nfallback=iKey,2\n");
2620 let setting = config.get_game_setting("iKey").unwrap();
2621 assert_eq!(setting.value(), "2");
2622 }
2623
2624 #[test]
2625 fn test_game_settings_deduplicates_by_key() {
2626 let config = load("fallback=iKey,1\nfallback=iKey,2\n");
2629 let results: Vec<_> = config
2630 .game_settings()
2631 .filter(|s| s.key() == "iKey")
2632 .collect();
2633 assert_eq!(
2634 results.len(),
2635 1,
2636 "game_settings() should deduplicate by key"
2637 );
2638 assert_eq!(results[0].value(), "2", "last-defined value should win");
2639 }
2640
2641 #[test]
2642 fn test_get_game_setting_missing_returns_none() {
2643 let config = load("fallback=iKey,1\n");
2644 assert!(config.get_game_setting("iMissing").is_none());
2645 }
2646
2647 #[test]
2648 fn test_game_setting_color_roundtrip() {
2649 let config = load("fallback=iSkyColor,100,149,237\n");
2650 let setting = config.get_game_setting("iSkyColor").unwrap();
2651 assert_eq!(setting.value(), "100,149,237");
2652 }
2653
2654 #[test]
2655 fn test_game_setting_float_roundtrip() {
2656 let config = load("fallback=fGravity,9.81\n");
2657 let setting = config.get_game_setting("fGravity").unwrap();
2658 assert_eq!(setting.value(), "9.81");
2659 }
2660
2661 #[test]
2662 fn test_invalid_game_setting_error_reports_line_number() {
2663 let dir = temp_dir();
2664 write_cfg(&dir, "fallback=iGood,1\nfallback=InvalidEntry\n");
2665
2666 let result = OpenMWConfiguration::new(Some(dir));
2667 assert!(matches!(
2668 result,
2669 Err(ConfigError::InvalidGameSetting { line: Some(2), .. })
2670 ));
2671 }
2672
2673 #[test]
2678 fn test_encoding_parsed() {
2679 use crate::config::encodingsetting::EncodingType;
2680 let config = load("encoding=win1252\n");
2681 assert_eq!(config.encoding().unwrap().value(), EncodingType::WIN1252);
2682 }
2683
2684 #[test]
2685 fn test_invalid_encoding_errors_on_load() {
2686 let dir = temp_dir();
2687 write_cfg(&dir, "encoding=utf8\n");
2688 assert!(OpenMWConfiguration::new(Some(dir)).is_err());
2689 }
2690
2691 #[test]
2692 fn test_invalid_encoding_error_reports_line_number() {
2693 let dir = temp_dir();
2694 write_cfg(&dir, "content=Morrowind.esm\nencoding=utf8\n");
2695
2696 let result = OpenMWConfiguration::new(Some(dir));
2697 assert!(matches!(
2698 result,
2699 Err(ConfigError::BadEncoding { line: Some(2), .. })
2700 ));
2701 }
2702
2703 fn load_chain(root: &str, user: &str) -> OpenMWConfiguration {
2709 let root_dir = temp_dir();
2710 let user_dir = temp_dir();
2711 write_cfg(&user_dir, user);
2712 write_cfg(&root_dir, &format!("{root}config={}\n", user_dir.display()));
2713 OpenMWConfiguration::new(Some(root_dir)).unwrap()
2714 }
2715
2716 #[test]
2717 fn test_replace_content_clears_prior_plugins() {
2718 let config = load_chain("content=Old.esm\n", "replace=content\ncontent=New.esm\n");
2719 assert!(!config.has_content_file("Old.esm"));
2720 assert!(config.has_content_file("New.esm"));
2721 }
2722
2723 #[test]
2724 fn test_replace_content_keeps_the_files_own_plugins() {
2725 let config = load("content=Old.esm\nreplace=content\ncontent=New.esm\n");
2727 assert!(config.has_content_file("Old.esm"));
2728 assert!(config.has_content_file("New.esm"));
2729 }
2730
2731 #[test]
2732 fn test_replace_data_clears_prior_dirs() {
2733 let config = load_chain("data=/old\n", "data=/own\nreplace=data\ndata=/new\n");
2734 assert!(!config.has_data_dir("/old"));
2735 assert!(config.has_data_dir("/own"));
2736 assert!(config.has_data_dir("/new"));
2737 }
2738
2739 #[test]
2740 fn test_replace_keeps_comment_adjacency() {
2741 let config = load_chain(
2742 "content=Old.esm\n",
2743 "replace=content\n\n# keep me\ncontent=New.esm\n",
2744 );
2745 let output = config.to_string();
2746
2747 assert!(!output.contains("Old.esm"));
2748 assert!(output.contains("# keep me\ncontent=New.esm"));
2749 }
2750
2751 #[test]
2756 fn test_display_contains_version_comment() {
2757 let config = load("content=Morrowind.esm\n");
2758 let output = config.to_string();
2759 assert!(
2760 output.contains("# OpenMW-Config Serializer Version:"),
2761 "Display should include version comment"
2762 );
2763 }
2764
2765 #[test]
2766 fn test_display_preserves_content_entries() {
2767 let config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
2768 let output = config.to_string();
2769 assert!(output.contains("content=Morrowind.esm"));
2770 assert!(output.contains("content=Tribunal.esm"));
2771 }
2772
2773 #[test]
2774 fn test_display_preserves_comments() {
2775 let config = load("# This is a comment\ncontent=Morrowind.esm\n");
2776 let output = config.to_string();
2777 assert!(output.contains("# This is a comment"));
2778 }
2779
2780 #[test]
2785 fn test_generic_setting_preserved() {
2786 let config = load("some-unknown-key=some-value\n");
2787 let output = config.to_string();
2788 assert!(output.contains("some-unknown-key=some-value"));
2789 }
2790
2791 #[test]
2792 fn test_generic_settings_can_be_replaced_and_iterated() {
2793 let mut config = load("no-sound=1\nno-sound=0\nother=keep\n");
2794
2795 config.set_generic_settings("no-sound", Some(vec!["2".to_string()]));
2796
2797 let values: Vec<(&str, &str)> = config
2798 .generic_settings_iter()
2799 .map(|setting| (setting.key(), setting.value()))
2800 .collect();
2801
2802 assert_eq!(values, vec![("other", "keep"), ("no-sound", "2")]);
2803 }
2804
2805 #[test]
2806 fn test_save_to_path_writes_exact_output_path() {
2807 let dir = temp_dir();
2808 write_cfg(&dir, "no-sound=1\ncontent=Morrowind.esm\n");
2809 let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2810
2811 let out = temp_dir().join("imported-openmw.cfg");
2812 config.save_to_path(&out).unwrap();
2813
2814 let saved = std::fs::read_to_string(&out).unwrap();
2815 assert!(saved.contains("no-sound=1"));
2816 assert!(saved.contains("content=Morrowind.esm"));
2817 }
2818
2819 #[test]
2820 fn test_save_to_path_overwrites_read_only_existing_file() {
2821 #[cfg(unix)]
2822 {
2823 use std::os::unix::fs::PermissionsExt;
2824
2825 let dir = temp_dir();
2826 write_cfg(&dir, "no-sound=1\n");
2827 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2828
2829 let out = dir.join("export.cfg");
2830 std::fs::write(&out, "old=content\n").unwrap();
2831 std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o444)).unwrap();
2832
2833 config.save_to_path(&out).unwrap();
2834
2835 std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o644)).unwrap();
2836 let saved = std::fs::read_to_string(&out).unwrap();
2837 assert!(saved.contains("no-sound=1"));
2838 assert!(!saved.contains("old=content"));
2839 }
2840 }
2841
2842 #[test]
2843 fn test_save_to_path_rejects_unwritable_parent_directory() {
2844 #[cfg(unix)]
2845 {
2846 use std::os::unix::fs::PermissionsExt;
2847
2848 let dir = temp_dir();
2849 write_cfg(&dir, "no-sound=1\n");
2850 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2851
2852 let parent = temp_dir();
2853 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).unwrap();
2854
2855 let out = parent.join("export.cfg");
2856 let result = config.save_to_path(&out);
2857
2858 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
2859
2860 assert!(matches!(result, Err(ConfigError::NotWritable(_))));
2861 }
2862 }
2863
2864 #[test]
2869 fn test_save_user_round_trips_content_files() {
2870 let dir = temp_dir();
2871 write_cfg(&dir, "content=Morrowind.esm\ncontent=Tribunal.esm\n");
2872 let mut config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2873 config.add_content_file("Bloodmoon.esm").unwrap();
2874 config.save_user().unwrap();
2875
2876 let reloaded = OpenMWConfiguration::new(Some(dir)).unwrap();
2877 let files: Vec<&String> = reloaded
2878 .content_files_iter()
2879 .map(FileSetting::value)
2880 .collect();
2881 assert!(files.contains(&&"Morrowind.esm".to_string()));
2882 assert!(files.contains(&&"Bloodmoon.esm".to_string()));
2883 }
2884
2885 #[test]
2886 fn test_save_user_does_not_persist_injected_data_local_as_data() {
2887 let dir = temp_dir();
2888 let data_local = dir.join("data-local");
2889 write_cfg(
2890 &dir,
2891 &format!(
2892 "data-local={}\ndata={}\n",
2893 data_local.display(),
2894 data_local.display()
2895 ),
2896 );
2897
2898 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2899 assert!(config.has_data_dir(&data_local.to_string_lossy()));
2900
2901 config.save_user().unwrap();
2902
2903 let saved = std::fs::read_to_string(dir.join("openmw.cfg")).unwrap();
2904 assert_eq!(saved.matches("data-local=").count(), 1);
2905 assert_eq!(saved.matches("data=").count(), 1);
2906 }
2907
2908 #[test]
2909 fn test_composite_display_does_not_serialize_injected_data_local_as_data() {
2910 let dir = temp_dir();
2911 let data_local = dir.join("data-local");
2912 write_cfg(&dir, &format!("data-local={}\n", data_local.display()));
2913
2914 let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2915 let serialized = config.to_string();
2916
2917 assert!(serialized.contains("data-local="));
2918 assert!(!serialized.contains("data="));
2919 }
2920
2921 #[test]
2922 fn test_display_preserves_directory_originals_for_round_trip_boundary() {
2923 let dir = temp_dir();
2924 write_cfg(
2925 &dir,
2926 "data=Data Files\ndata-local=\"Local Data\"\nresources=?local?/resources\nuser-data=?userdata?\n",
2927 );
2928
2929 let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2930 let serialized = config.to_string();
2931
2932 assert!(serialized.contains("data=Data Files"));
2933 assert!(serialized.contains("data-local=\"Local Data\""));
2934 assert!(serialized.contains("resources=?local?/resources"));
2935 assert!(serialized.contains("user-data=?userdata?"));
2936 }
2937
2938 #[test]
2939 fn test_to_resolved_string_emits_resolved_directory_paths() {
2940 let dir = temp_dir();
2941 write_cfg(
2942 &dir,
2943 "data=Data Files\ndata-local=\"Local Data\"\nresources=?local?/resources\nuser-data=?userdata?\ncontent=Base.esm\nfallback-archive=Morrowind.bsa\n",
2944 );
2945
2946 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2947 let serialized = config.to_resolved_string();
2948
2949 assert!(!serialized.contains("data=Data Files"));
2950 assert!(!serialized.contains("data-local=\"Local Data\""));
2951 assert!(!serialized.contains("resources=?local?/resources"));
2952 assert!(!serialized.contains("userdata="));
2953 assert!(serialized.contains(&format!("data={}", dir.join("Data Files").display())));
2954 assert!(serialized.contains(&format!("data-local={}", dir.join("Local Data").display())));
2955 assert!(serialized.contains("user-data="));
2956 assert!(serialized.contains("content=Base.esm"));
2957 assert!(serialized.contains("fallback-archive=Morrowind.bsa"));
2958 }
2959
2960 #[test]
2961 fn test_to_resolved_string_excludes_config_replace_and_synthetic_data_local() {
2962 let root = temp_dir();
2963 let sub = temp_dir();
2964 let data_local = root.join("local-data");
2965 write_cfg(
2966 &root,
2967 &format!(
2968 "data-local={}\nconfig={}\ncontent=Root.esm\n",
2969 data_local.display(),
2970 sub.display()
2971 ),
2972 );
2973 write_cfg(&sub, "content=Sub.esm\n");
2974
2975 let mut config = OpenMWConfiguration::new(Some(root)).unwrap();
2976 config.add_generic_setting("replace", "data");
2977
2978 let serialized = config.to_resolved_string();
2979
2980 assert!(!serialized.contains("config="));
2981 assert!(!serialized.contains("replace="));
2982 assert_eq!(serialized.matches("data-local=").count(), 1);
2983 assert_eq!(serialized.matches("data=").count(), 0);
2984 assert!(serialized.contains("content=Root.esm"));
2985 assert!(serialized.contains("content=Sub.esm"));
2986 }
2987
2988 #[test]
2989 fn test_save_resolved_to_path_writes_relocated_safe_output() {
2990 let dir = temp_dir();
2991 let out = temp_dir().join("openmw.cfg");
2992 write_cfg(&dir, "data=Data Files\ncontent=Base.esm\n");
2993
2994 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2995 config.save_resolved_to_path(&out).unwrap();
2996
2997 let saved = std::fs::read_to_string(out).unwrap();
2998 assert!(saved.contains(&format!("data={}", dir.join("Data Files").display())));
2999 assert!(!saved.contains("data=Data Files"));
3000 assert!(saved.contains("content=Base.esm"));
3001 }
3002
3003 #[test]
3004 fn test_save_user_not_writable_returns_error() {
3005 #[cfg(unix)]
3007 {
3008 use std::os::unix::fs::PermissionsExt;
3009 let dir = temp_dir();
3010 write_cfg(&dir, "content=Morrowind.esm\n");
3011 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
3012
3013 let cfg_path = dir.join("openmw.cfg");
3015 std::fs::set_permissions(&cfg_path, std::fs::Permissions::from_mode(0o444)).unwrap();
3016
3017 let result = config.save_user();
3018 std::fs::set_permissions(&cfg_path, std::fs::Permissions::from_mode(0o644)).unwrap();
3020
3021 assert!(
3022 matches!(result, Err(ConfigError::NotWritable(_))),
3023 "expected NotWritable, got {result:?}"
3024 );
3025 }
3026 }
3027
3028 #[test]
3033 fn test_save_subconfig_rejects_unloaded_path() {
3034 let dir = temp_dir();
3035 write_cfg(&dir, "content=Morrowind.esm\n");
3036 let config = OpenMWConfiguration::new(Some(dir)).unwrap();
3037
3038 let fake_dir = temp_dir();
3039 let result = config.save_subconfig(&fake_dir);
3040 assert!(
3041 matches!(result, Err(ConfigError::SubconfigNotLoaded(_))),
3042 "expected SubconfigNotLoaded, got {result:?}"
3043 );
3044 }
3045
3046 #[test]
3047 fn test_save_subconfig_round_trips_settings() {
3048 let root_dir = temp_dir();
3049 let sub_dir = temp_dir();
3050 write_cfg(&sub_dir, "content=Plugin.esp\n");
3051 write_cfg(
3052 &root_dir,
3053 &format!("content=Morrowind.esm\nconfig={}\n", sub_dir.display()),
3054 );
3055
3056 let mut config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
3057 config.add_content_file("NewPlugin.esp").unwrap();
3058 config.save_subconfig(&sub_dir).unwrap();
3059
3060 let sub_cfg = sub_dir.join("openmw.cfg");
3061 let saved = std::fs::read_to_string(sub_cfg).unwrap();
3062 assert!(
3063 saved.contains("content=Plugin.esp"),
3064 "sub-config content preserved"
3065 );
3066 }
3067
3068 #[test]
3069 #[cfg(unix)]
3070 fn test_save_subconfig_accepts_equivalent_symlink_path() {
3071 let root_dir = temp_dir();
3072 let real_sub_dir = temp_dir();
3073 let linked_sub_dir = temp_dir().join("linked_subconfig");
3074
3075 std::os::unix::fs::symlink(&real_sub_dir, &linked_sub_dir).unwrap();
3076 write_cfg(&real_sub_dir, "content=Plugin.esp\n");
3077 write_cfg(
3078 &root_dir,
3079 &format!(
3080 "content=Morrowind.esm\nconfig={}\n",
3081 linked_sub_dir.display()
3082 ),
3083 );
3084
3085 let config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
3086 config.save_subconfig(&real_sub_dir).unwrap();
3087
3088 let saved = std::fs::read_to_string(real_sub_dir.join("openmw.cfg")).unwrap();
3089 assert!(
3090 saved.contains("content=Plugin.esp"),
3091 "sub-config settings should match by filesystem identity, not symlink spelling"
3092 );
3093 }
3094
3095 #[test]
3100 fn test_new_empty_uses_directory_as_user_config_context() {
3101 let dir = temp_dir().join("not-created-yet");
3102
3103 let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
3104 config.add_content_file("EmptyStart.esp").unwrap();
3105 config.add_data_directory(Path::new("Data Files"));
3106 config
3107 .set_game_setting("fScale,1.0", None, &mut String::new())
3108 .unwrap();
3109
3110 assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
3111 assert_eq!(config.root_config_dir(), dir);
3112 assert_eq!(config.user_config_path(), config.root_config_dir());
3113
3114 let content = config.content_files_iter().next().unwrap();
3115 assert_eq!(content.meta().source_config, config.root_config_file());
3116
3117 let data = config.data_directories_iter().next().unwrap();
3118 assert_eq!(data.meta().source_config, config.root_config_file());
3119 assert_eq!(data.parsed(), &config.root_config_dir().join("Data Files"));
3120
3121 let game_setting = config.get_game_setting("fScale").unwrap();
3122 assert_eq!(game_setting.meta().source_config, config.root_config_file());
3123 }
3124
3125 #[test]
3126 fn test_new_empty_rejects_openmw_cfg_file_shaped_path() {
3127 let result = OpenMWConfiguration::new_empty(temp_dir().join("openmw.cfg"));
3128 assert!(matches!(result, Err(ConfigError::NotFileOrDirectory(_))));
3129 }
3130
3131 #[test]
3132 fn test_save_to_path_works_from_new_empty_config() {
3133 let dir = temp_dir().join("missing-config-dir");
3134 let out_dir = temp_dir();
3135 let out = out_dir.join("openmw.cfg");
3136
3137 let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
3138 config.add_content_file("Generated.esp").unwrap();
3139 config.save_to_path(&out).unwrap();
3140
3141 let saved = std::fs::read_to_string(out).unwrap();
3142 assert!(saved.contains("content=Generated.esp"));
3143 }
3144
3145 #[test]
3146 fn test_save_user_creates_missing_new_empty_config_directory() {
3147 let dir = temp_dir().join("missing-user-config-dir");
3148
3149 let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
3150 config.add_content_file("Generated.esp").unwrap();
3151 config.save_user().unwrap();
3152
3153 let saved = std::fs::read_to_string(dir.join("openmw.cfg")).unwrap();
3154 assert!(saved.contains("content=Generated.esp"));
3155 }
3156
3157 #[test]
3158 fn test_load_optional_existing_cfg_matches_new() {
3159 let dir = temp_dir();
3160 let cfg = write_cfg(&dir, "content=Existing.esm\ndata=Data Files\n");
3161
3162 let from_new = OpenMWConfiguration::new(Some(cfg.clone())).unwrap();
3163 let from_optional = OpenMWConfiguration::load_optional(&cfg).unwrap();
3164
3165 assert_eq!(
3166 from_optional.root_config_file(),
3167 from_new.root_config_file()
3168 );
3169 assert_eq!(from_optional.to_string(), from_new.to_string());
3170 }
3171
3172 #[test]
3173 fn test_load_optional_missing_cfg_uses_parent_context() {
3174 let dir = temp_dir().join("cfg-parent");
3175 let cfg = dir.join("openmw.cfg");
3176
3177 let mut config = OpenMWConfiguration::load_optional(&cfg).unwrap();
3178 config.add_data_directory(Path::new("Data Files"));
3179
3180 assert_eq!(config.root_config_file(), cfg);
3181 assert_eq!(config.root_config_dir(), dir);
3182 assert_eq!(
3183 config.data_directories_iter().next().unwrap().parsed(),
3184 &config.root_config_dir().join("Data Files")
3185 );
3186 }
3187
3188 #[test]
3189 fn test_load_optional_existing_empty_dir_uses_dir_context() {
3190 let dir = temp_dir();
3191
3192 let mut config = OpenMWConfiguration::load_optional(&dir).unwrap();
3193 config.add_data_directory(Path::new("Data Files"));
3194
3195 assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
3196 assert_eq!(config.root_config_dir(), dir);
3197 assert_eq!(
3198 config.data_directories_iter().next().unwrap().parsed(),
3199 &config.root_config_dir().join("Data Files")
3200 );
3201 }
3202
3203 #[test]
3204 fn test_load_optional_missing_dir_uses_dir_context() {
3205 let dir = temp_dir().join("missing-dir");
3206
3207 let config = OpenMWConfiguration::load_optional(&dir).unwrap();
3208
3209 assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
3210 assert_eq!(config.root_config_dir(), dir);
3211 }
3212
3213 #[test]
3214 fn test_new_empty_relative_dir_anchors_to_current_dir_for_resolved_export() {
3215 let relative_dir = PathBuf::from(format!(
3216 "openmw-config-relative-empty-{}",
3217 std::process::id()
3218 ));
3219 let expected_dir = util::display_preserving_absolute(&relative_dir).unwrap();
3220
3221 let mut config = OpenMWConfiguration::new_empty(&relative_dir).unwrap();
3222 config.add_data_directory(Path::new("Data Files"));
3223
3224 assert_eq!(config.root_config_dir(), expected_dir);
3225 assert!(config.to_resolved_string().contains(&format!(
3226 "data={}",
3227 expected_dir.join("Data Files").display()
3228 )));
3229 }
3230
3231 #[test]
3232 fn test_load_optional_relative_missing_cfg_anchors_to_current_dir() {
3233 let relative_cfg = PathBuf::from(format!(
3234 "openmw-config-relative-missing-{}/openmw.cfg",
3235 std::process::id()
3236 ));
3237 let expected_dir =
3238 util::display_preserving_absolute(relative_cfg.parent().unwrap()).unwrap();
3239
3240 let mut config = OpenMWConfiguration::load_optional(&relative_cfg).unwrap();
3241 config.add_data_directory(Path::new("Data Files"));
3242
3243 assert_eq!(config.root_config_dir(), expected_dir);
3244 assert!(config.to_resolved_string().contains(&format!(
3245 "data={}",
3246 expected_dir.join("Data Files").display()
3247 )));
3248 }
3249
3250 #[test]
3251 fn test_path_level_directory_singleton_setters_use_user_config_context() {
3252 let dir = temp_dir();
3253 let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
3254
3255 config.set_data_local_path("local-data");
3256 config.set_resources_path("resources-dir");
3257 config.set_user_data_path("user-data-dir");
3258
3259 assert_eq!(
3260 config.data_local().unwrap().meta().source_config,
3261 config.root_config_file()
3262 );
3263 assert_eq!(
3264 config.resources().unwrap().meta().source_config,
3265 config.root_config_file()
3266 );
3267 assert_eq!(
3268 config.userdata().unwrap().meta().source_config,
3269 config.root_config_file()
3270 );
3271 assert_eq!(
3272 config.data_local().unwrap().parsed(),
3273 &dir.join("local-data")
3274 );
3275 assert_eq!(
3276 config.resources().unwrap().parsed(),
3277 &dir.join("resources-dir")
3278 );
3279 assert_eq!(
3280 config.userdata().unwrap().parsed(),
3281 &dir.join("user-data-dir")
3282 );
3283
3284 let serialized = config.to_string();
3285 assert!(serialized.contains("data-local=local-data"));
3286 assert!(serialized.contains("resources=resources-dir"));
3287 assert!(serialized.contains("user-data=user-data-dir"));
3288 assert!(!serialized.contains("userdata="));
3289 }
3290
3291 #[test]
3292 fn test_repeated_path_level_singleton_setters_replace() {
3293 let dir = temp_dir();
3294 let mut config = OpenMWConfiguration::new_empty(dir).unwrap();
3295
3296 config.set_data_local_path("first-local");
3297 config.set_data_local_path("second-local");
3298 config.set_resources_path("first-resources");
3299 config.set_resources_path("second-resources");
3300 config.set_user_data_path("first-user-data");
3301 config.set_user_data_path("second-user-data");
3302
3303 let serialized = config.to_string();
3304 assert_eq!(serialized.matches("data-local=").count(), 1);
3305 assert_eq!(serialized.matches("resources=").count(), 1);
3306 assert_eq!(serialized.matches("user-data=").count(), 1);
3307 assert!(serialized.contains("data-local=second-local"));
3308 assert!(serialized.contains("resources=second-resources"));
3309 assert!(serialized.contains("user-data=second-user-data"));
3310 }
3311
3312 #[test]
3313 fn test_path_level_directory_singleton_clearers_remove_values() {
3314 let dir = temp_dir();
3315 let mut config = OpenMWConfiguration::new_empty(dir).unwrap();
3316
3317 config.set_data_local_path("local-data");
3318 config.set_resources_path("resources-dir");
3319 config.set_user_data_path("user-data-dir");
3320
3321 config.clear_data_local();
3322 config.clear_resources();
3323 config.clear_user_data();
3324
3325 assert!(config.data_local().is_none());
3326 assert!(config.resources().is_none());
3327 assert!(config.userdata().is_none());
3328 }
3329
3330 #[test]
3335 fn test_from_env_openmw_config_dir() {
3336 let _guard = env_lock();
3337 let dir = temp_dir();
3338 write_cfg(&dir, "content=Morrowind.esm\n");
3339
3340 unsafe {
3343 clear_from_env_overrides();
3344 std::env::set_var("OPENMW_CONFIG_DIR", &dir);
3345 }
3346 let config = OpenMWConfiguration::from_env().unwrap();
3347 unsafe { clear_from_env_overrides() };
3348
3349 assert!(config.has_content_file("Morrowind.esm"));
3350 assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
3351 }
3352
3353 #[test]
3354 fn test_from_env_openmw_config_file() {
3355 let _guard = env_lock();
3356 let dir = temp_dir();
3357 let cfg = write_cfg(&dir, "content=Tribunal.esm\n");
3358
3359 unsafe {
3360 clear_from_env_overrides();
3361 std::env::set_var("OPENMW_CONFIG", &cfg);
3362 }
3363 let config = OpenMWConfiguration::from_env().unwrap();
3364 unsafe { clear_from_env_overrides() };
3365
3366 assert!(config.has_content_file("Tribunal.esm"));
3367 assert_eq!(config.root_config_file(), cfg);
3368 }
3369
3370 #[test]
3371 fn test_from_env_prefers_openmw_config_file_over_dir() {
3372 let _guard = env_lock();
3373 let file_dir = temp_dir();
3374 let dir_dir = temp_dir();
3375 let cfg = write_cfg(&file_dir, "content=FileWins.esm\n");
3376 write_cfg(&dir_dir, "content=DirLoses.esm\n");
3377
3378 unsafe {
3379 clear_from_env_overrides();
3380 std::env::set_var("OPENMW_CONFIG", &cfg);
3381 std::env::set_var("OPENMW_CONFIG_DIR", &dir_dir);
3382 }
3383
3384 let config = OpenMWConfiguration::from_env().unwrap();
3385 unsafe { clear_from_env_overrides() };
3386
3387 assert_eq!(config.root_config_file(), cfg);
3388 assert!(config.has_content_file("FileWins.esm"));
3389 assert!(!config.has_content_file("DirLoses.esm"));
3390 }
3391
3392 #[test]
3393 fn test_from_env_prefers_first_openmw_config_dir_entry() {
3394 let _guard = env_lock();
3395 let missing_dir = temp_dir().join("missing");
3396 let first_valid = temp_dir();
3397 let second_valid = temp_dir();
3398 write_cfg(&first_valid, "content=FirstValid.esm\n");
3399 write_cfg(&second_valid, "content=SecondValid.esm\n");
3400
3401 let separator = if cfg!(windows) { ";" } else { ":" };
3402 let path_list = format!(
3403 "{}{}{}{}{}",
3404 missing_dir.display(),
3405 separator,
3406 first_valid.display(),
3407 separator,
3408 second_valid.display()
3409 );
3410
3411 unsafe {
3412 clear_from_env_overrides();
3413 std::env::set_var("OPENMW_CONFIG_DIR", path_list);
3414 }
3415
3416 let config = OpenMWConfiguration::from_env().unwrap();
3417 unsafe { clear_from_env_overrides() };
3418
3419 assert_eq!(config.root_config_file(), first_valid.join("openmw.cfg"));
3420 assert!(config.has_content_file("FirstValid.esm"));
3421 assert!(!config.has_content_file("SecondValid.esm"));
3422 }
3423
3424 #[test]
3425 fn test_from_env_config_dir_skips_non_file_openmw_cfg_entry() {
3426 let _guard = env_lock();
3427 let invalid_dir = temp_dir();
3428 let valid_dir = temp_dir();
3429 std::fs::create_dir_all(invalid_dir.join("openmw.cfg")).unwrap();
3430 write_cfg(&valid_dir, "content=ActualFile.esm\n");
3431
3432 let separator = if cfg!(windows) { ";" } else { ":" };
3433 let path_list = format!(
3434 "{}{}{}",
3435 invalid_dir.display(),
3436 separator,
3437 valid_dir.display()
3438 );
3439
3440 unsafe {
3441 clear_from_env_overrides();
3442 std::env::set_var("OPENMW_CONFIG_DIR", path_list);
3443 }
3444
3445 let config = OpenMWConfiguration::from_env().unwrap();
3446 unsafe { clear_from_env_overrides() };
3447
3448 assert_eq!(config.root_config_file(), valid_dir.join("openmw.cfg"));
3449 assert!(config.has_content_file("ActualFile.esm"));
3450 }
3451
3452 #[test]
3453 fn test_from_env_uses_global_config_when_no_explicit_env() {
3454 let _guard = env_lock();
3455 let global_dir = temp_dir();
3456 let cfg = write_cfg(&global_dir, "content=GlobalRoot.esm\n");
3457
3458 unsafe {
3459 clear_from_env_overrides();
3460 std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &global_dir);
3461 }
3462
3463 let config = OpenMWConfiguration::from_env().unwrap();
3464 unsafe { clear_from_env_overrides() };
3465
3466 assert_eq!(config.root_config_file(), cfg);
3467 assert!(config.has_content_file("GlobalRoot.esm"));
3468 }
3469
3470 #[test]
3471 fn test_from_env_errors_without_local_or_global_root_config() {
3472 let _guard = env_lock();
3473 let empty_global_dir = temp_dir();
3474
3475 unsafe {
3476 clear_from_env_overrides();
3477 std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
3478 }
3479
3480 let result = OpenMWConfiguration::from_env();
3481 unsafe { clear_from_env_overrides() };
3482
3483 assert!(
3484 matches!(result, Err(ConfigError::CannotFindRootConfig { .. })),
3485 "expected CannotFindRootConfig, got {result:?}"
3486 );
3487 }
3488
3489 #[test]
3490 #[cfg(not(windows))]
3491 fn test_from_env_or_user_config_falls_back_to_user_config() {
3492 let _guard = env_lock();
3493 let empty_global_dir = temp_dir();
3494 let test_home = temp_dir();
3495 let test_xdg_config_home = temp_dir();
3496 let old_home = std::env::var_os("HOME");
3497 let old_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
3498
3499 unsafe {
3500 clear_from_env_overrides();
3501 std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
3502 std::env::set_var("HOME", &test_home);
3503 std::env::set_var("XDG_CONFIG_HOME", &test_xdg_config_home);
3504 }
3505
3506 let user_config_dir = crate::try_default_config_path().unwrap();
3507 std::fs::create_dir_all(&user_config_dir).unwrap();
3508 let user_cfg = write_cfg(&user_config_dir, "content=UserOnly.esm\n");
3509
3510 let result = OpenMWConfiguration::from_env_or_user_config();
3511 unsafe {
3512 clear_from_env_overrides();
3513 }
3514 restore_env_var("HOME", old_home);
3515 restore_env_var("XDG_CONFIG_HOME", old_xdg_config_home);
3516
3517 let config = result.unwrap();
3518 assert_eq!(config.root_config_file(), user_cfg);
3519 assert!(config.has_content_file("UserOnly.esm"));
3520 }
3521
3522 #[test]
3523 #[cfg(not(windows))]
3524 fn test_from_env_or_user_config_errors_when_no_candidates_exist() {
3525 let _guard = env_lock();
3526 let empty_global_dir = temp_dir();
3527 let test_home = temp_dir();
3528 let test_xdg_config_home = temp_dir();
3529 let old_home = std::env::var_os("HOME");
3530 let old_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
3531
3532 unsafe {
3533 clear_from_env_overrides();
3534 std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
3535 std::env::set_var("HOME", &test_home);
3536 std::env::set_var("XDG_CONFIG_HOME", &test_xdg_config_home);
3537 }
3538
3539 let result = OpenMWConfiguration::from_env_or_user_config();
3540 unsafe {
3541 clear_from_env_overrides();
3542 }
3543 restore_env_var("HOME", old_home);
3544 restore_env_var("XDG_CONFIG_HOME", old_xdg_config_home);
3545
3546 assert!(
3547 matches!(result, Err(ConfigError::CannotFindAnyConfig { .. })),
3548 "expected CannotFindAnyConfig, got {result:?}"
3549 );
3550 }
3551
3552 #[test]
3553 fn test_global_root_config_loads_config_chain() {
3554 let _guard = env_lock();
3555 let global_dir = temp_dir();
3556 let user_dir = temp_dir();
3557 write_cfg(&user_dir, "content=UserConfig.esm\n");
3558 let root = write_cfg(
3559 &global_dir,
3560 &format!("content=GlobalRoot.esm\nconfig={}\n", user_dir.display()),
3561 );
3562
3563 unsafe {
3564 clear_from_env_overrides();
3565 std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &global_dir);
3566 }
3567
3568 let config = OpenMWConfiguration::from_env().unwrap();
3569 unsafe { clear_from_env_overrides() };
3570
3571 assert_eq!(config.root_config_file(), root);
3572 assert!(config.has_content_file("GlobalRoot.esm"));
3573 assert!(config.has_content_file("UserConfig.esm"));
3574 }
3575
3576 #[test]
3581 fn test_error_duplicate_archive_file() {
3582 let dir = temp_dir();
3584 write_cfg(
3585 &dir,
3586 "fallback-archive=Morrowind.bsa\nfallback-archive=Morrowind.bsa\n",
3587 );
3588 let result = OpenMWConfiguration::new(Some(dir));
3589 assert!(matches!(
3590 result,
3591 Err(ConfigError::DuplicateArchiveFile { .. })
3592 ));
3593 }
3594
3595 #[test]
3596 fn test_error_cannot_add_groundcover_file() {
3597 let mut config = load("groundcover=GrassPlugin.esp\n");
3598 let result = config.add_groundcover_file("GrassPlugin.esp");
3599 assert!(matches!(
3600 result,
3601 Err(ConfigError::CannotAddGroundcoverFile { .. })
3602 ));
3603 }
3604
3605 #[test]
3606 fn test_error_cannot_find() {
3607 let result =
3608 OpenMWConfiguration::new(Some(PathBuf::from("/nonexistent/totally/fake/path")));
3609 assert!(matches!(
3610 result,
3611 Err(ConfigError::CannotFind(_) | ConfigError::NotFileOrDirectory(_))
3612 ));
3613 }
3614
3615 #[test]
3616 fn test_error_io_from_conversion() {
3617 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
3618 let config_err: ConfigError = io_err.into();
3619 assert!(matches!(config_err, ConfigError::Io(_)));
3620 }
3621
3622 #[test]
3623 fn test_error_invalid_line() {
3624 let result = OpenMWConfiguration::new(Some({
3626 let dir = temp_dir();
3627 write_cfg(&dir, "this_has_no_equals_sign\n");
3628 dir
3629 }));
3630 assert!(matches!(
3631 result,
3632 Err(ConfigError::InvalidLine { line: Some(1), .. })
3633 ));
3634 }
3635
3636 #[test]
3637 fn test_error_max_depth_exceeded() {
3638 let dirs: Vec<PathBuf> = (0..18).map(|_| temp_dir()).collect();
3640 for pair in dirs.windows(2) {
3641 write_cfg(&pair[0], &format!("config={}\n", pair[1].display()));
3642 }
3643 write_cfg(&dirs[17], "");
3644 let result = OpenMWConfiguration::new(Some(dirs[0].clone()));
3645 assert!(matches!(result, Err(ConfigError::MaxDepthExceeded(_))));
3646 }
3647
3648 #[test]
3649 fn test_a_config_that_names_itself_loads_once() {
3650 let dir = temp_dir();
3653 write_cfg(
3654 &dir,
3655 &format!("content=Self.esm\nconfig={}\n", dir.display()),
3656 );
3657 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
3658
3659 assert_eq!(config.config_chain().count(), 1);
3660 assert_eq!(config.content_file_count(), 1);
3661 assert_eq!(config.user_config_path(), dir);
3662 }
3663
3664 #[test]
3665 fn test_a_circular_chain_loads_each_config_once() {
3666 let a = temp_dir();
3667 let b = temp_dir();
3668
3669 write_cfg(&a, &format!("content=A.esm\nconfig={}\n", b.display()));
3670 write_cfg(&b, &format!("content=B.esm\nconfig={}\n", a.display()));
3671
3672 let config = OpenMWConfiguration::new(Some(a.clone())).unwrap();
3673 let chain: Vec<PathBuf> = config
3674 .config_chain()
3675 .map(|entry| entry.path().to_path_buf())
3676 .collect();
3677 assert_eq!(chain, [a.join("openmw.cfg"), b.join("openmw.cfg")]);
3678 assert_eq!(config.content_file_count(), 2);
3679 assert_eq!(config.user_config_path(), b);
3680 }
3681
3682 #[cfg(unix)]
3683 #[test]
3684 fn test_symlinked_config_dir_loads_like_real_path() {
3685 let real_dir = temp_dir();
3686 write_cfg(&real_dir, "content=Morrowind.esm\n");
3687
3688 let link_parent = temp_dir();
3689 let link_path = link_parent.join("symlinked-config");
3690 if link_path.exists() {
3691 let _ = std::fs::remove_file(&link_path);
3692 let _ = std::fs::remove_dir_all(&link_path);
3693 }
3694 symlink_dir(&real_dir, &link_path);
3695
3696 let config = OpenMWConfiguration::new(Some(link_path.clone())).unwrap();
3697
3698 assert!(config.has_content_file("Morrowind.esm"));
3699 assert_eq!(config.root_config_file(), link_path.join("openmw.cfg"));
3700 assert_eq!(config.root_config_dir(), link_path);
3701 }
3702
3703 #[test]
3708 fn test_settings_matching_filters_correctly() {
3709 let config = load("content=Morrowind.esm\nfallback-archive=Morrowind.bsa\n");
3710 let content_count = config
3711 .settings_matching(|s| matches!(s, SettingValue::ContentFile(_)))
3712 .count();
3713 assert_eq!(content_count, 1);
3714 }
3715
3716 #[test]
3717 fn test_clear_matching_removes_entries() {
3718 let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
3719 config.clear_matching(|s| matches!(s, SettingValue::ContentFile(_)));
3720 assert_eq!(config.content_files_iter().count(), 0);
3721 }
3722
3723 #[test]
3728 fn test_sub_configs_iteration() {
3729 let root_dir = temp_dir();
3730 let sub_dir = temp_dir();
3731 write_cfg(&sub_dir, "content=Plugin.esp\n");
3732 write_cfg(
3733 &root_dir,
3734 &format!("content=Morrowind.esm\nconfig={}\n", sub_dir.display()),
3735 );
3736
3737 let config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
3738 assert_eq!(config.sub_configs().count(), 1);
3739 assert!(
3740 config.has_content_file("Plugin.esp"),
3741 "sub-config content visible in root"
3742 );
3743 }
3744
3745 #[test]
3750 fn test_config_chain_priority_order_for_data_lists_is_depth_first() {
3751 let dir1 = temp_dir();
3752 let dir2 = temp_dir();
3753 let dir3 = temp_dir();
3754 let dir4 = temp_dir();
3755
3756 write_cfg(
3757 &dir1,
3758 &format!(
3759 "data=root-a\nconfig={}\nconfig={}\n",
3760 dir2.display(),
3761 dir3.display()
3762 ),
3763 );
3764 write_cfg(
3765 &dir2,
3766 &format!("data=branch-a\nconfig={}\n", dir4.display()),
3767 );
3768 write_cfg(&dir3, "data=sibling-a\n");
3769 write_cfg(&dir4, "data=leaf-a\n");
3770
3771 let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3772 let actual: Vec<String> = config
3773 .data_directories_iter()
3774 .map(|setting| setting.original().clone())
3775 .collect();
3776
3777 assert_eq!(actual, vec!["root-a", "branch-a", "leaf-a", "sibling-a"]);
3778 }
3779
3780 #[test]
3781 fn test_replace_data_keeps_the_depth_first_order_in_a_branching_chain() {
3782 let dir1 = temp_dir();
3783 let dir2 = temp_dir();
3784 let dir3 = temp_dir();
3785 let dir4 = temp_dir();
3786
3787 write_cfg(
3788 &dir1,
3789 &format!(
3790 "data=root-a\nconfig={}\nconfig={}\n",
3791 dir2.display(),
3792 dir3.display()
3793 ),
3794 );
3795 write_cfg(
3796 &dir2,
3797 &format!("replace=data\ndata=branch-a\nconfig={}\n", dir4.display()),
3798 );
3799 write_cfg(&dir3, "data=sibling-a\n");
3800 write_cfg(&dir4, "data=leaf-a\n");
3801
3802 let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3803 let actual: Vec<String> = config
3804 .data_directories_iter()
3805 .map(|setting| setting.original().clone())
3806 .collect();
3807
3808 assert_eq!(actual, vec!["branch-a", "leaf-a", "sibling-a"]);
3809 }
3810
3811 #[test]
3812 fn test_config_chain_priority_order_for_content_lists_is_depth_first() {
3813 let dir1 = temp_dir();
3814 let dir2 = temp_dir();
3815 let dir3 = temp_dir();
3816 let dir4 = temp_dir();
3817
3818 write_cfg(
3819 &dir1,
3820 &format!(
3821 "content=Root.esm\nconfig={}\nconfig={}\n",
3822 dir2.display(),
3823 dir3.display()
3824 ),
3825 );
3826 write_cfg(
3827 &dir2,
3828 &format!("content=Branch.esm\nconfig={}\n", dir4.display()),
3829 );
3830 write_cfg(&dir3, "content=Sibling.esm\n");
3831 write_cfg(&dir4, "content=Leaf.esm\n");
3832
3833 let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3834 let actual: Vec<String> = config
3835 .content_files_iter()
3836 .map(|setting| setting.value().clone())
3837 .collect();
3838
3839 assert_eq!(
3840 actual,
3841 vec!["Root.esm", "Branch.esm", "Leaf.esm", "Sibling.esm"],
3842 "content= follows the order the chain loads in"
3843 );
3844 }
3845
3846 #[test]
3847 fn test_config_chain_priority_order_for_groundcover_lists_is_depth_first() {
3848 let dir1 = temp_dir();
3849 let dir2 = temp_dir();
3850 let dir3 = temp_dir();
3851 let dir4 = temp_dir();
3852
3853 write_cfg(
3854 &dir1,
3855 &format!(
3856 "groundcover=Root.esp\nconfig={}\nconfig={}\n",
3857 dir2.display(),
3858 dir3.display()
3859 ),
3860 );
3861 write_cfg(
3862 &dir2,
3863 &format!("groundcover=Branch.esp\nconfig={}\n", dir4.display()),
3864 );
3865 write_cfg(&dir3, "groundcover=Sibling.esp\n");
3866 write_cfg(&dir4, "groundcover=Leaf.esp\n");
3867
3868 let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3869 let actual: Vec<String> = config
3870 .groundcover_iter()
3871 .map(|setting| setting.value().clone())
3872 .collect();
3873
3874 assert_eq!(
3875 actual,
3876 vec!["Root.esp", "Branch.esp", "Leaf.esp", "Sibling.esp"],
3877 "groundcover= follows the order the chain loads in"
3878 );
3879 }
3880
3881 #[test]
3882 fn test_config_chain_priority_order_is_depth_first() {
3883 let dir1 = temp_dir();
3884 let dir2 = temp_dir();
3885 let dir3 = temp_dir();
3886 let dir4 = temp_dir();
3887
3888 write_cfg(
3889 &dir1,
3890 &format!("config={}\nconfig={}\n", dir2.display(), dir3.display()),
3891 );
3892 write_cfg(
3893 &dir2,
3894 &format!("encoding=win1250\nconfig={}\n", dir4.display()),
3895 );
3896 write_cfg(&dir3, "encoding=win1251\n");
3897 write_cfg(&dir4, "encoding=win1252\n");
3898
3899 let config = OpenMWConfiguration::new(Some(dir1.clone())).unwrap();
3900
3901 assert_eq!(
3902 config.encoding().unwrap().to_string().trim(),
3903 "encoding=win1251"
3904 );
3905 assert_eq!(config.user_config_path(), dir3);
3906
3907 let chain: Vec<(PathBuf, usize)> = config
3908 .config_chain()
3909 .map(|entry| (entry.path().to_path_buf(), entry.depth()))
3910 .collect();
3911 assert_eq!(
3912 chain,
3913 [
3914 (dir1.join("openmw.cfg"), 0),
3915 (dir2.join("openmw.cfg"), 1),
3916 (dir4.join("openmw.cfg"), 2),
3917 (dir3.join("openmw.cfg"), 1),
3918 ]
3919 );
3920 }
3921
3922 #[test]
3923 fn test_config_chain_priority_order_with_user_data_crosscheck() {
3924 let dir1 = temp_dir();
3925 let dir2 = temp_dir();
3926 let dir3 = temp_dir();
3927 let dir4 = temp_dir();
3928
3929 write_cfg(
3930 &dir1,
3931 &format!("config={}\nconfig={}\n", dir2.display(), dir3.display()),
3932 );
3933 write_cfg(
3934 &dir2,
3935 &format!("user-data={}\nconfig={}\n", dir2.display(), dir4.display()),
3936 );
3937 write_cfg(&dir3, &format!("user-data={}\n", dir3.display()));
3938 write_cfg(&dir4, &format!("user-data={}\n", dir4.display()));
3939
3940 let config = OpenMWConfiguration::new(Some(dir1.clone())).unwrap();
3941
3942 assert_eq!(config.user_config_path(), dir3);
3943 assert_eq!(config.userdata().unwrap().parsed(), dir3.as_path());
3944 }
3945
3946 #[test]
3951 fn test_root_config_file_points_to_cfg() {
3952 let dir = temp_dir();
3953 write_cfg(&dir, "");
3954 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
3955 assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
3956 }
3957
3958 #[test]
3959 fn test_root_config_dir_is_parent() {
3960 let dir = temp_dir();
3961 write_cfg(&dir, "");
3962 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
3963 assert_eq!(config.root_config_dir(), dir);
3964 }
3965
3966 #[test]
3971 fn test_clone_is_independent() {
3972 let mut original = load("content=Morrowind.esm\n");
3973 let mut cloned = original.clone();
3974 cloned.add_content_file("Tribunal.esm").unwrap();
3975 original.add_content_file("Bloodmoon.esm").unwrap();
3976 assert!(cloned.has_content_file("Tribunal.esm"));
3977 assert!(!cloned.has_content_file("Bloodmoon.esm"));
3978 assert!(original.has_content_file("Bloodmoon.esm"));
3979 assert!(!original.has_content_file("Tribunal.esm"));
3980 }
3981
3982 fn assert_indexes_consistent(config: &OpenMWConfiguration) {
3983 use std::collections::HashSet;
3984
3985 config.ensure_game_setting_indexes();
3986
3987 let scanned_content: FxHashSet<String> = config
3988 .settings
3989 .iter()
3990 .filter_map(|setting| match setting {
3991 SettingValue::ContentFile(file) => Some(file.value().clone()),
3992 _ => None,
3993 })
3994 .collect();
3995 let scanned_groundcover: FxHashSet<String> = config
3996 .settings
3997 .iter()
3998 .filter_map(|setting| match setting {
3999 SettingValue::Groundcover(file) => Some(file.value().clone()),
4000 _ => None,
4001 })
4002 .collect();
4003 let scanned_archives: FxHashSet<String> = config
4004 .settings
4005 .iter()
4006 .filter_map(|setting| match setting {
4007 SettingValue::BethArchive(file) => Some(file.value().clone()),
4008 _ => None,
4009 })
4010 .collect();
4011 let scanned_data_dirs: FxHashSet<PathBuf> = config
4012 .settings
4013 .iter()
4014 .filter_map(|setting| match setting {
4015 SettingValue::DataDirectory(dir) => Some(dir.parsed().to_path_buf()),
4016 _ => None,
4017 })
4018 .collect();
4019
4020 let mut scanned_game_setting_last = FxHashMap::default();
4021 for (index, setting) in config.settings.iter().enumerate() {
4022 if let SettingValue::GameSetting(game_setting) = setting {
4023 scanned_game_setting_last.insert(game_setting.key().clone(), index);
4024 }
4025 }
4026
4027 let mut scanned_game_setting_order = Vec::new();
4028 let mut seen = HashSet::new();
4029 for (index, setting) in config.settings.iter().enumerate().rev() {
4030 if let SettingValue::GameSetting(game_setting) = setting
4031 && seen.insert(game_setting.key())
4032 {
4033 scanned_game_setting_order.push(index);
4034 }
4035 }
4036
4037 assert_eq!(config.indexed_content, scanned_content);
4038 assert_eq!(config.indexed_groundcover, scanned_groundcover);
4039 assert_eq!(config.indexed_archives, scanned_archives);
4040 assert_eq!(config.indexed_data_dirs, scanned_data_dirs);
4041 assert_eq!(
4042 *config.indexed_game_setting_last.borrow(),
4043 scanned_game_setting_last
4044 );
4045 assert_eq!(
4046 *config.indexed_game_setting_order.borrow(),
4047 scanned_game_setting_order
4048 );
4049
4050 for file in &config.indexed_content {
4051 assert!(config.has_content_file(file));
4052 }
4053 for file in &config.indexed_groundcover {
4054 assert!(config.has_groundcover_file(file));
4055 }
4056 for file in &config.indexed_archives {
4057 assert!(config.has_archive_file(file));
4058 }
4059 for dir in &config.indexed_data_dirs {
4060 assert!(config.has_data_dir(dir.to_string_lossy().as_ref()));
4061 }
4062
4063 let iter_keys: Vec<String> = config
4064 .game_settings()
4065 .map(|setting| setting.key().clone())
4066 .collect();
4067 let expected_keys: Vec<String> = config
4068 .indexed_game_setting_order
4069 .borrow()
4070 .iter()
4071 .filter_map(|index| match &config.settings[*index] {
4072 SettingValue::GameSetting(game_setting) => Some(game_setting.key().clone()),
4073 _ => None,
4074 })
4075 .collect();
4076 assert_eq!(iter_keys, expected_keys);
4077
4078 for (key, index) in config.indexed_game_setting_last.borrow().iter() {
4079 let expected_value = match &config.settings[*index] {
4080 SettingValue::GameSetting(game_setting) => game_setting.value(),
4081 _ => unreachable!("game setting index points to non-game setting"),
4082 };
4083 assert_eq!(
4084 config.get_game_setting(key).map(GameSettingType::value),
4085 Some(expected_value)
4086 );
4087 }
4088 }
4089
4090 #[test]
4091 fn test_indexes_remain_coherent_through_mutations() {
4092 let mut config = load(
4093 "content=Morrowind.esm\n\
4094content=Tribunal.esm\n\
4095groundcover=Grass.esp\n\
4096data=/tmp/data\n\
4097fallback-archive=Morrowind.bsa\n\
4098fallback=iGamma,1.00\n",
4099 );
4100 assert_indexes_consistent(&config);
4101
4102 config.add_content_file("Bloodmoon.esm").unwrap();
4103 assert_indexes_consistent(&config);
4104
4105 config.remove_content_file("Tribunal.esm");
4106 assert_indexes_consistent(&config);
4107
4108 config.add_groundcover_file("Flora.esp").unwrap();
4109 assert_indexes_consistent(&config);
4110
4111 config.remove_groundcover_file("Grass.esp");
4112 assert_indexes_consistent(&config);
4113
4114 config.add_archive_file("Tribunal.bsa").unwrap();
4115 assert_indexes_consistent(&config);
4116
4117 config.remove_archive_file("Morrowind.bsa");
4118 assert_indexes_consistent(&config);
4119
4120 config.add_data_directory(Path::new("/tmp/extra-data"));
4121 assert_indexes_consistent(&config);
4122
4123 config.remove_data_directory(&PathBuf::from("/tmp/data"));
4124 assert_indexes_consistent(&config);
4125
4126 config.set_content_files(Some(vec!["One.esp".to_string(), "Two.esp".to_string()]));
4127 assert_indexes_consistent(&config);
4128
4129 config.set_fallback_archives(Some(vec!["Only.bsa".to_string()]));
4130 assert_indexes_consistent(&config);
4131
4132 config
4133 .set_game_settings(Some(vec![
4134 "iFoo,10".to_string(),
4135 "iFoo,11".to_string(),
4136 "fBar,1.5".to_string(),
4137 ]))
4138 .unwrap();
4139 assert_indexes_consistent(&config);
4140
4141 let err = config.set_game_settings(Some(vec!["invalid-no-comma".to_string()]));
4142 assert!(err.is_err());
4143 assert_indexes_consistent(&config);
4144
4145 config.clear_matching(|setting| matches!(setting, SettingValue::ContentFile(_)));
4146 assert_indexes_consistent(&config);
4147 }
4148
4149 #[test]
4150 fn test_indexes_coherent_after_replace_during_load() {
4151 let config = load_chain(
4152 "content=Root.esm\n\
4153groundcover=GrassRoot.esp\n\
4154fallback-archive=Root.bsa\n\
4155fallback=iFoo,1\n",
4156 "replace=content\n\
4157content=AfterReplace.esm\n\
4158replace=groundcover\n\
4159groundcover=GrassAfter.esp\n\
4160replace=fallback-archive\n\
4161fallback-archive=After.bsa\n\
4162replace=fallback\n\
4163fallback=iFoo,2\n",
4164 );
4165
4166 assert_indexes_consistent(&config);
4167 assert!(config.has_content_file("AfterReplace.esm"));
4168 assert!(!config.has_content_file("Root.esm"));
4169 assert!(config.has_groundcover_file("GrassAfter.esp"));
4170 assert!(!config.has_groundcover_file("GrassRoot.esp"));
4171 assert!(config.has_archive_file("After.bsa"));
4172 assert!(!config.has_archive_file("Root.bsa"));
4173 assert_eq!(
4174 config.get_game_setting("iFoo").map(GameSettingType::value),
4175 Some("2".into())
4176 );
4177 }
4178}